From 25eaab7e2e3741eef2b8ba3f04394155afadfbbb Mon Sep 17 00:00:00 2001 From: ignorejjj Date: Mon, 29 Jun 2026 14:22:48 +0800 Subject: [PATCH 01/16] feat(skills): distill run insights into a cross-run skill library Adds ~/.arbor/skills// as a library tier (built-in < library < project) and a deterministic distiller: at finalize, merged/done node insights + the root global insight become a learned-.md, auto-loaded by future runs via the existing LoadSkill flow. Off by default (coordinator.distill_skills); LLM-level abstraction + dedup are later refinements. --- src/coordinator/config.py | 2 + src/coordinator/orchestrator.py | 9 +++++ src/core/skill_registry.py | 13 +++++- src/distill.py | 71 +++++++++++++++++++++++++++++++++ 4 files changed, 94 insertions(+), 1 deletion(-) create mode 100644 src/distill.py diff --git a/src/coordinator/config.py b/src/coordinator/config.py index c1fc22d..9c94f35 100644 --- a/src/coordinator/config.py +++ b/src/coordinator/config.py @@ -476,6 +476,8 @@ class CoordinatorConfig(ProxyModel): export_trajectory: bool = True # Capture per-call token-level traces (tokens.jsonl) for SFT/RL. Heavy; off. token_trace: bool = False + # Distill run insights into a reusable skill in ~/.arbor/skills (line 2). Off. + distill_skills: bool = False # ── Plugin (runtime object; not serialized) ────────────────────── plugin: Any = PydField(default=None, exclude=True, repr=False) diff --git a/src/coordinator/orchestrator.py b/src/coordinator/orchestrator.py index 0e47377..9d2a3fe 100644 --- a/src/coordinator/orchestrator.py +++ b/src/coordinator/orchestrator.py @@ -1154,6 +1154,15 @@ def _write_run_stats( except Exception as e: # pylint: disable=broad-exception-caught _print_status(f"Warning: failed to write trajectory: {e}") + # Self-evolution line 2: distill insights into a reusable cross-run skill. + if getattr(self.config, "distill_skills", False): + try: + from ..distill import distill_to_library + p = distill_to_library(self.config.workspace_dir) + _print_status(f"Distilled skill: {p}" if p else "Distill: nothing to learn") + except Exception as e: # pylint: disable=broad-exception-caught + _print_status(f"Warning: failed to distill skill: {e}") + # ------------------------------------------------------------------ # Final report # ------------------------------------------------------------------ diff --git a/src/core/skill_registry.py b/src/core/skill_registry.py index 22f743a..56a0b0e 100644 --- a/src/core/skill_registry.py +++ b/src/core/skill_registry.py @@ -130,14 +130,25 @@ def build_default_registry(cwd: str, *, disabled: list[str] | set[str] | tuple[s # Built-in: /skills/ pkg_skills = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "skills") n_builtin = registry.load_dir(pkg_skills, source="built-in") + # Cross-run library: skills distilled from past runs (self-evolution). Loaded + # from ~/.arbor/skills/**; lower priority than project, higher than built-in. + n_lib = 0 + home_lib = os.path.join(os.path.expanduser("~"), ".arbor", "skills") + n_lib += registry.load_dir(home_lib, source="library") + if os.path.isdir(home_lib): + for sub in sorted(os.listdir(home_lib)): + d = os.path.join(home_lib, sub) + if os.path.isdir(d): + n_lib += registry.load_dir(d, source="library") # Project override project_skills = os.path.join(cwd, ".arbor", "skills") n_project = registry.load_dir(project_skills, source="project") for name in disabled: registry._skills.pop(str(name), None) log.info( - "skill_registry: loaded %d built-in + %d project skills (total %d)", + "skill_registry: loaded %d built-in + %d library + %d project skills (total %d)", n_builtin, + n_lib, n_project, len(registry), ) diff --git a/src/distill.py b/src/distill.py new file mode 100644 index 0000000..e18454a --- /dev/null +++ b/src/distill.py @@ -0,0 +1,71 @@ +"""Skill distillation (self-evolution line 2a). + +Turns a finished run's distilled insights into a reusable skill markdown in the +cross-run library (~/.arbor/skills//), so future runs auto-load it via +the existing SkillRegistry. v1 is deterministic — it lifts the merged/done node +insights the tree already abstracted; an LLM "raise the abstraction level" pass +is a later refinement. Best-effort: never fail a finished run. +""" + +from __future__ import annotations + +import re +from pathlib import Path +from typing import Any + +from .trajectory import _load_tree + + +def _slug(s: str) -> str: + return re.sub(r"[^a-z0-9]+", "-", (s or "general").lower()).strip("-") or "general" + + +def _domain(session_dir: Path, tree_meta: dict[str, Any]) -> str: + d = tree_meta.get("domain") or tree_meta.get("benchmark") + if not d: + cwd = tree_meta.get("cwd") or "" + d = Path(cwd).name if cwd else "general" + return _slug(str(d)) + + +def build_skill(session_dir: Path) -> tuple[str, str] | None: + """Return (domain, markdown) distilled from the run, or None if nothing useful.""" + session_dir = Path(session_dir) + tree = _load_tree(session_dir) + if not tree: + return None + meta = (tree.get("ROOT") or {}).get("meta", {}) if isinstance(tree.get("ROOT"), dict) else {} + domain = _domain(session_dir, meta if isinstance(meta, dict) else {}) + + wins = [n for n in tree.values() if n.get("status") in ("merged", "done") and n.get("insight")] + if not wins: + return None + root_insight = (tree.get("ROOT") or {}).get("insight", "") + + lines = [ + "---", + f"name: learned-{domain}-{session_dir.name}", + f"description: Lessons distilled from a past {domain} run (auto-learned).", + f"when_to_apply: At IDEATE on a {domain}-like task — treat as candidate priors, not rules.", + "---", + f"\n# Learned: {domain}\n", + ] + if root_insight: + lines.append(root_insight.strip() + "\n") + lines.append("## What worked / didn't (verified by held-out gate)") + for n in sorted(wins, key=lambda x: x.get("score") or 0, reverse=True): + lines.append(f"- [{n.get('status')}, score={n.get('score')}] {n.get('insight','').strip()}") + return domain, "\n".join(lines) + + +def distill_to_library(session_dir: Path, lib_root: Path | None = None) -> Path | None: + """Write the distilled skill into ~/.arbor/skills//; return path or None.""" + built = build_skill(session_dir) + if built is None: + return None + domain, md = built + lib = (lib_root or Path.home() / ".arbor" / "skills") / domain + lib.mkdir(parents=True, exist_ok=True) + out = lib / f"learned-{Path(session_dir).name}.md" + out.write_text(md, encoding="utf-8") + return out From 26e91ada1e722e59aa4d2dab9acf1f9b1893e3d2 Mon Sep 17 00:00:00 2001 From: ignorejjj Date: Mon, 29 Jun 2026 14:44:35 +0800 Subject: [PATCH 02/16] feat(skills): layer distilled experience by altitude (meta vs domain) Distiller now emits altitude-tagged fragments: meta (cross-domain strategy from tree shape/pruned dead-ends) and domain (verified idea-class wins/losses). Task-specific findings stay in REPORT/trajectory, out of the library. Stored under skills///; registry walks recursively. Recall can weight by altitude so high-level transfers widely, domain only on match. --- src/coordinator/orchestrator.py | 4 +- src/core/skill_registry.py | 7 +- src/distill.py | 109 ++++++++++++++++++-------------- 3 files changed, 67 insertions(+), 53 deletions(-) diff --git a/src/coordinator/orchestrator.py b/src/coordinator/orchestrator.py index 9d2a3fe..68a5211 100644 --- a/src/coordinator/orchestrator.py +++ b/src/coordinator/orchestrator.py @@ -1158,8 +1158,8 @@ def _write_run_stats( if getattr(self.config, "distill_skills", False): try: from ..distill import distill_to_library - p = distill_to_library(self.config.workspace_dir) - _print_status(f"Distilled skill: {p}" if p else "Distill: nothing to learn") + paths = distill_to_library(self.config.workspace_dir) + _print_status(f"Distilled {len(paths)} skill(s)" if paths else "Distill: nothing to learn") except Exception as e: # pylint: disable=broad-exception-caught _print_status(f"Warning: failed to distill skill: {e}") diff --git a/src/core/skill_registry.py b/src/core/skill_registry.py index 56a0b0e..602c2a5 100644 --- a/src/core/skill_registry.py +++ b/src/core/skill_registry.py @@ -134,12 +134,9 @@ def build_default_registry(cwd: str, *, disabled: list[str] | set[str] | tuple[s # from ~/.arbor/skills/**; lower priority than project, higher than built-in. n_lib = 0 home_lib = os.path.join(os.path.expanduser("~"), ".arbor", "skills") - n_lib += registry.load_dir(home_lib, source="library") if os.path.isdir(home_lib): - for sub in sorted(os.listdir(home_lib)): - d = os.path.join(home_lib, sub) - if os.path.isdir(d): - n_lib += registry.load_dir(d, source="library") + for root, _dirs, _files in os.walk(home_lib): + n_lib += registry.load_dir(root, source="library") # Project override project_skills = os.path.join(cwd, ".arbor", "skills") n_project = registry.load_dir(project_skills, source="project") diff --git a/src/distill.py b/src/distill.py index e18454a..4855055 100644 --- a/src/distill.py +++ b/src/distill.py @@ -1,10 +1,18 @@ """Skill distillation (self-evolution line 2a). -Turns a finished run's distilled insights into a reusable skill markdown in the -cross-run library (~/.arbor/skills//), so future runs auto-load it via -the existing SkillRegistry. v1 is deterministic — it lifts the merged/done node -insights the tree already abstracted; an LLM "raise the abstraction level" pass -is a later refinement. Best-effort: never fail a finished run. +Turns a finished run into reusable skills in the cross-run library +(~/.arbor/skills/), tagged by **altitude** so recall can reuse them safely: + + * ``meta`` — research strategy from the tree shape/process (pruned dead-ends, + merge timing). Transfers across domains. + * ``domain`` — what classes of idea won/lost (from verified node insights). + Transfers within a domain. + * task-specific findings stay in the run's REPORT/trajectory, not the library — + they don't transfer and would only pollute recall. + +Deterministic v1: lifts what the tree already abstracted. An LLM "raise the +abstraction level" pass and dedup/confidence are later refinements. Best-effort: +never fail a finished run. """ from __future__ import annotations @@ -20,52 +28,61 @@ def _slug(s: str) -> str: return re.sub(r"[^a-z0-9]+", "-", (s or "general").lower()).strip("-") or "general" -def _domain(session_dir: Path, tree_meta: dict[str, Any]) -> str: - d = tree_meta.get("domain") or tree_meta.get("benchmark") - if not d: - cwd = tree_meta.get("cwd") or "" - d = Path(cwd).name if cwd else "general" - return _slug(str(d)) +def _domain(tree_meta: dict[str, Any]) -> str: + d = tree_meta.get("domain") or tree_meta.get("benchmark") or tree_meta.get("cwd") or "general" + return _slug(Path(str(d)).name) + + +def _frag(name: str, desc: str, when: str, title: str, body: list[str]) -> str: + return "\n".join(["---", f"name: {name}", f"description: {desc}", + f"when_to_apply: {when}", "---", f"\n# {title}\n", *body]) -def build_skill(session_dir: Path) -> tuple[str, str] | None: - """Return (domain, markdown) distilled from the run, or None if nothing useful.""" +def build_skills(session_dir: Path) -> list[tuple[str, str, str]]: + """Return [(level, domain, markdown), ...] — empty if nothing worth keeping.""" session_dir = Path(session_dir) tree = _load_tree(session_dir) if not tree: - return None - meta = (tree.get("ROOT") or {}).get("meta", {}) if isinstance(tree.get("ROOT"), dict) else {} - domain = _domain(session_dir, meta if isinstance(meta, dict) else {}) + return [] + root = tree.get("ROOT") or {} + meta = root.get("meta", {}) if isinstance(root.get("meta"), dict) else {} + domain = _domain(meta) + run = session_dir.name + out: list[tuple[str, str, str]] = [] + # domain layer — verified wins/losses, transferable within domain wins = [n for n in tree.values() if n.get("status") in ("merged", "done") and n.get("insight")] - if not wins: - return None - root_insight = (tree.get("ROOT") or {}).get("insight", "") - - lines = [ - "---", - f"name: learned-{domain}-{session_dir.name}", - f"description: Lessons distilled from a past {domain} run (auto-learned).", - f"when_to_apply: At IDEATE on a {domain}-like task — treat as candidate priors, not rules.", - "---", - f"\n# Learned: {domain}\n", - ] - if root_insight: - lines.append(root_insight.strip() + "\n") - lines.append("## What worked / didn't (verified by held-out gate)") - for n in sorted(wins, key=lambda x: x.get("score") or 0, reverse=True): - lines.append(f"- [{n.get('status')}, score={n.get('score')}] {n.get('insight','').strip()}") - return domain, "\n".join(lines) - - -def distill_to_library(session_dir: Path, lib_root: Path | None = None) -> Path | None: - """Write the distilled skill into ~/.arbor/skills//; return path or None.""" - built = build_skill(session_dir) - if built is None: - return None - domain, md = built - lib = (lib_root or Path.home() / ".arbor" / "skills") / domain - lib.mkdir(parents=True, exist_ok=True) - out = lib / f"learned-{Path(session_dir).name}.md" - out.write_text(md, encoding="utf-8") + if wins or root.get("insight"): + body = ([root["insight"].strip() + "\n"] if root.get("insight") else []) + body.append("## Idea classes that won / lost (held-out verified)") + for n in sorted(wins, key=lambda x: x.get("score") or 0, reverse=True): + body.append(f"- [{n.get('status')}, score={n.get('score')}] {n.get('insight','').strip()}") + out.append(("domain", domain, _frag( + f"learned-{domain}-{run}", f"Domain lessons from a {domain} run.", + f"IDEATE on a {domain}-like task — candidate priors, not rules.", + f"Learned: {domain}", body))) + + # meta layer — strategy from the tree's shape, transfers across domains + pruned = [n for n in tree.values() if n.get("status") == "pruned"] + merged = [n for n in tree.values() if n.get("status") == "merged"] + process = [f"- {len(merged)} merged, {len(pruned)} pruned of {max(0,len(tree)-1)} ideas — " + f"{'broad search paid off' if merged else 'most directions died; prune faster'}."] + process += [f"- dead-end: {n.get('insight','').strip()[:160]}" for n in pruned if n.get("insight")] + if pruned or merged: + out.append(("meta", "general", _frag( + f"strategy-{run}", "Cross-domain research strategy from a past run.", + "IDEATE on any task — search-strategy priors.", "Learned: strategy", process))) return out + + +def distill_to_library(session_dir: Path, lib_root: Path | None = None) -> list[Path]: + """Write layered skills into ///; return paths written.""" + root = lib_root or Path.home() / ".arbor" / "skills" + paths: list[Path] = [] + for level, domain, md in build_skills(session_dir): + d = root / level / domain + d.mkdir(parents=True, exist_ok=True) + out = d / f"learned-{Path(session_dir).name}.md" + out.write_text(md, encoding="utf-8") + paths.append(out) + return paths From 828ec6e9eade5cace80bf0cfac937c4353235b2a Mon Sep 17 00:00:00 2001 From: ignorejjj Date: Mon, 29 Jun 2026 14:51:38 +0800 Subject: [PATCH 03/16] fix(skills): tag domain from project dir instead of 'general' Recover the project name from the session path when tree meta lacks a domain, so domain-layer skills file under skills/domain// and recall can match by domain. Meta-layer stays general (cross-domain). --- src/distill.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/distill.py b/src/distill.py index 4855055..4524e94 100644 --- a/src/distill.py +++ b/src/distill.py @@ -28,9 +28,15 @@ def _slug(s: str) -> str: return re.sub(r"[^a-z0-9]+", "-", (s or "general").lower()).strip("-") or "general" -def _domain(tree_meta: dict[str, Any]) -> str: - d = tree_meta.get("domain") or tree_meta.get("benchmark") or tree_meta.get("cwd") or "general" - return _slug(Path(str(d)).name) +def _domain(tree_meta: dict[str, Any], session_dir: Path) -> str: + d = tree_meta.get("domain") or tree_meta.get("benchmark") or tree_meta.get("cwd") + if not d: + # session lives at /.arbor/sessions/; recover the project name. + parts = session_dir.resolve().parts + d = parts[-4] if len(parts) >= 4 and parts[-3:-1] == ("sessions",) else "" + if not d and ".arbor" in parts: + d = parts[parts.index(".arbor") - 1] + return _slug(Path(str(d)).name) if d else "general" def _frag(name: str, desc: str, when: str, title: str, body: list[str]) -> str: @@ -46,7 +52,7 @@ def build_skills(session_dir: Path) -> list[tuple[str, str, str]]: return [] root = tree.get("ROOT") or {} meta = root.get("meta", {}) if isinstance(root.get("meta"), dict) else {} - domain = _domain(meta) + domain = _domain(meta, session_dir) run = session_dir.name out: list[tuple[str, str, str]] = [] From d9fd8f0b1c2e39934d4c3d2130861b886a8fc1b9 Mon Sep 17 00:00:00 2001 From: ignorejjj Date: Mon, 29 Jun 2026 14:52:25 +0800 Subject: [PATCH 04/16] feat(skills): domain-scope library recall to prevent negative transfer meta/ skills load everywhere; domain// skills load only when matches the current project name. A run reuses its own domain's lessons + cross-domain strategy, never an unrelated domain's task tricks. Deterministic, no LLM, no prompt. --- src/core/skill_registry.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/core/skill_registry.py b/src/core/skill_registry.py index 602c2a5..8617fe1 100644 --- a/src/core/skill_registry.py +++ b/src/core/skill_registry.py @@ -22,6 +22,7 @@ import logging import os +import re from dataclasses import dataclass from pathlib import Path @@ -132,10 +133,16 @@ def build_default_registry(cwd: str, *, disabled: list[str] | set[str] | tuple[s n_builtin = registry.load_dir(pkg_skills, source="built-in") # Cross-run library: skills distilled from past runs (self-evolution). Loaded # from ~/.arbor/skills/**; lower priority than project, higher than built-in. + # Recall is domain-scoped to avoid negative transfer: meta/ (cross-domain) + # always loads; domain// loads only when matches this project. n_lib = 0 home_lib = os.path.join(os.path.expanduser("~"), ".arbor", "skills") + here = re.sub(r"[^a-z0-9]+", "-", os.path.basename(os.path.abspath(cwd)).lower()).strip("-") if os.path.isdir(home_lib): for root, _dirs, _files in os.walk(home_lib): + rel = os.path.relpath(root, home_lib) + if rel.startswith("domain") and here and here not in rel.split(os.sep): + continue # skip other domains' skills n_lib += registry.load_dir(root, source="library") # Project override project_skills = os.path.join(cwd, ".arbor", "skills") From 016668cae08836220037d8a09b2e775bd11c82d8 Mon Sep 17 00:00:00 2001 From: ignorejjj Date: Mon, 29 Jun 2026 15:13:25 +0800 Subject: [PATCH 05/16] feat(experience): capture lessons live during the run, fold in at finalize Two halves: append_experience logs a lesson each time the coordinator updates a node (status/insight) so the research *process* is kept, not just the surviving tree; the distiller then folds experience.jsonl into the cross-domain meta skill alongside tree shape. Sources = interaction-driven node updates + hypothesis tree. --- src/coordinator/tools/tree_ops.py | 10 ++++++ src/distill.py | 7 ++++ src/experience.py | 56 +++++++++++++++++++++++++++++++ 3 files changed, 73 insertions(+) create mode 100644 src/experience.py diff --git a/src/coordinator/tools/tree_ops.py b/src/coordinator/tools/tree_ops.py index 27e2c4d..8b8633b 100644 --- a/src/coordinator/tools/tree_ops.py +++ b/src/coordinator/tools/tree_ops.py @@ -331,6 +331,16 @@ async def execute(self, **kwargs: Any) -> str: self._tree.update_node(node_id, **updates) + # Live experience capture (self-evolution): log lessons as the research + # advances, not only at finalize. Best-effort — never break a tool call. + if updates.get("insight") or updates.get("status"): + try: + from ...experience import append_experience + append_experience(getattr(self._tree, "json_path", None), + node_id=node_id, updates=updates) + except Exception: # pylint: disable=broad-exception-caught + pass + parts = [f"Updated {node_id}:"] for k, v in updates.items(): parts.append(f" {k} = {v}") diff --git a/src/distill.py b/src/distill.py index 4524e94..e207003 100644 --- a/src/distill.py +++ b/src/distill.py @@ -74,6 +74,13 @@ def build_skills(session_dir: Path) -> list[tuple[str, str, str]]: process = [f"- {len(merged)} merged, {len(pruned)} pruned of {max(0,len(tree)-1)} ideas — " f"{'broad search paid off' if merged else 'most directions died; prune faster'}."] process += [f"- dead-end: {n.get('insight','').strip()[:160]}" for n in pruned if n.get("insight")] + # fold in the live process trail (lessons logged mid-run, beyond the final tree) + try: + from .experience import load_experience + trail = [e for e in load_experience(session_dir) if e.get("status") in ("pruned", "done")] + process += [f"- step {e['node_id']}: {e['insight'][:140]}" for e in trail if e.get("insight")] + except Exception: # pylint: disable=broad-exception-caught + pass if pruned or merged: out.append(("meta", "general", _frag( f"strategy-{run}", "Cross-domain research strategy from a past run.", diff --git a/src/experience.py b/src/experience.py new file mode 100644 index 0000000..43ab75b --- /dev/null +++ b/src/experience.py @@ -0,0 +1,56 @@ +"""Experience capture (self-evolution): two halves. + +1. **Live** — ``append_experience`` logs a lesson each time the coordinator + updates a node mid-run (status/insight), so the research *process* is kept, + not just the surviving tree. +2. **Consolidate** — ``load_experience`` reads the notes back at finalize; the + distiller folds them together with the hypothesis tree into reusable skills. + +The session dir is recovered from the tree's json_path (/.coordinator/ +idea_tree.json). Best-effort throughout — never break a run. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +EXPERIENCE_FILENAME = "experience.jsonl" + + +def _session_dir(tree_json_path: str | None) -> Path | None: + if not tree_json_path: + return None + p = Path(tree_json_path) + # /.coordinator/idea_tree.json -> + return p.parent.parent if p.parent.name == ".coordinator" else p.parent + + +def append_experience(tree_json_path: str | None, *, node_id: str, updates: dict[str, Any]) -> None: + sd = _session_dir(tree_json_path) + if sd is None: + return + rec = { + "node_id": node_id, + "status": updates.get("status"), + "insight": (updates.get("insight") or "").strip(), + "result": (updates.get("result") or "").strip() if updates.get("result") else "", + "score": updates.get("score"), + } + with open(sd / EXPERIENCE_FILENAME, "a", encoding="utf-8") as f: + f.write(json.dumps(rec, ensure_ascii=False) + "\n") + + +def load_experience(session_dir: Path) -> list[dict[str, Any]]: + p = Path(session_dir) / EXPERIENCE_FILENAME + if not p.exists(): + return [] + out = [] + for line in p.read_text(encoding="utf-8").splitlines(): + if line.strip(): + try: + out.append(json.loads(line)) + except json.JSONDecodeError: + continue + return out From 4dca950a79f8038b89200f969a3139544a14c1c1 Mon Sep 17 00:00:00 2001 From: ignorejjj Date: Mon, 29 Jun 2026 15:20:03 +0800 Subject: [PATCH 06/16] feat(skills): consolidate experience across runs with occurrence counts One learned.md per (level,domain) that reinforces instead of one file per run. Dedup by normalized text; [xN] count = confidence, recurring lessons rank first. Repeated runs bump counts rather than pile up. Cross-run accumulation, no LLM. --- src/distill.py | 66 ++++++++++++++++++++++++++++++++------------------ 1 file changed, 42 insertions(+), 24 deletions(-) diff --git a/src/distill.py b/src/distill.py index e207003..32fa91b 100644 --- a/src/distill.py +++ b/src/distill.py @@ -53,49 +53,67 @@ def build_skills(session_dir: Path) -> list[tuple[str, str, str]]: root = tree.get("ROOT") or {} meta = root.get("meta", {}) if isinstance(root.get("meta"), dict) else {} domain = _domain(meta, session_dir) - run = session_dir.name - out: list[tuple[str, str, str]] = [] + out: list[tuple[str, str, list[str]]] = [] # (level, domain, bullets) # domain layer — verified wins/losses, transferable within domain wins = [n for n in tree.values() if n.get("status") in ("merged", "done") and n.get("insight")] - if wins or root.get("insight"): - body = ([root["insight"].strip() + "\n"] if root.get("insight") else []) - body.append("## Idea classes that won / lost (held-out verified)") - for n in sorted(wins, key=lambda x: x.get("score") or 0, reverse=True): - body.append(f"- [{n.get('status')}, score={n.get('score')}] {n.get('insight','').strip()}") - out.append(("domain", domain, _frag( - f"learned-{domain}-{run}", f"Domain lessons from a {domain} run.", - f"IDEATE on a {domain}-like task — candidate priors, not rules.", - f"Learned: {domain}", body))) + dom_bul = [] + if root.get("insight"): + dom_bul.append(root["insight"].strip().split("\n")[0][:200]) + for n in sorted(wins, key=lambda x: x.get("score") or 0, reverse=True): + dom_bul.append(f"[{n.get('status')}, score={n.get('score')}] {n.get('insight','').strip()[:200]}") + if dom_bul: + out.append(("domain", domain, dom_bul)) # meta layer — strategy from the tree's shape, transfers across domains pruned = [n for n in tree.values() if n.get("status") == "pruned"] merged = [n for n in tree.values() if n.get("status") == "merged"] - process = [f"- {len(merged)} merged, {len(pruned)} pruned of {max(0,len(tree)-1)} ideas — " - f"{'broad search paid off' if merged else 'most directions died; prune faster'}."] - process += [f"- dead-end: {n.get('insight','').strip()[:160]}" for n in pruned if n.get("insight")] - # fold in the live process trail (lessons logged mid-run, beyond the final tree) + process = [f"dead-end: {n.get('insight','').strip()[:160]}" for n in pruned if n.get("insight")] try: from .experience import load_experience trail = [e for e in load_experience(session_dir) if e.get("status") in ("pruned", "done")] - process += [f"- step {e['node_id']}: {e['insight'][:140]}" for e in trail if e.get("insight")] + process += [f"{e['node_id']}: {e['insight'][:140]}" for e in trail if e.get("insight")] except Exception: # pylint: disable=broad-exception-caught pass - if pruned or merged: - out.append(("meta", "general", _frag( - f"strategy-{run}", "Cross-domain research strategy from a past run.", - "IDEATE on any task — search-strategy priors.", "Learned: strategy", process))) + if process: + out.append(("meta", "general", process)) return out +def _norm(b: str) -> str: + return re.sub(r"\s+", " ", re.sub(r"\[.*?\]|score=\S+|\d+", "", b.lower())).strip() + + def distill_to_library(session_dir: Path, lib_root: Path | None = None) -> list[Path]: - """Write layered skills into ///; return paths written.""" + """Merge layered bullets into one consolidated skill per (level, domain). + + Recurring lessons reinforce (occurrence count = confidence) instead of piling + up one file per run. Deterministic dedup by normalized text. Returns paths. + """ root = lib_root or Path.home() / ".arbor" / "skills" paths: list[Path] = [] - for level, domain, md in build_skills(session_dir): + for level, domain, bullets in build_skills(session_dir): d = root / level / domain d.mkdir(parents=True, exist_ok=True) - out = d / f"learned-{Path(session_dir).name}.md" - out.write_text(md, encoding="utf-8") + out = d / "learned.md" + seen: dict[str, list[str]] = {} # norm -> [count, text] + if out.exists(): # parse prior counts from "- [xN] text" + for ln in out.read_text(encoding="utf-8").splitlines(): + m = re.match(r"- \[x(\d+)\] (.+)", ln) + if m: + seen[_norm(m.group(2))] = [int(m.group(1)), m.group(2)] + for b in bullets: + k = _norm(b) + if k in seen: + seen[k][0] += 1 + else: + seen[k] = [1, b] + ranked = sorted(seen.values(), key=lambda x: -x[0]) + when = ("any task — search-strategy priors" if level == "meta" + else f"a {domain}-like task — candidate priors, not rules") + body = [f"- [x{c}] {t}" for c, t in ranked] + out.write_text(_frag(f"learned-{level}-{domain}", f"Consolidated {level} lessons.", + f"IDEATE on {when}.", f"Learned: {level}/{domain}", body), + encoding="utf-8") paths.append(out) return paths From d5d219e46890ae1f1ef4b48b71d97c63fdf933ca Mon Sep 17 00:00:00 2001 From: ignorejjj Date: Mon, 29 Jun 2026 16:45:01 +0800 Subject: [PATCH 07/16] refactor(experience): per-session EXPERIENCE.md + topic recall, not a skill library Drop the global ~/.arbor/skills tier (was bloating the curated skill menu). Each run writes EXPERIENCE.md in its session; recall.find_similar scans past sessions and ranks by topic-term coverage. Intake surfaces strong matches and asks the user; accepted ones get composed for the agent (compose step next). --- src/coordinator/orchestrator.py | 6 +-- src/core/skill_registry.py | 17 +------- src/distill.py | 73 +++++++++++---------------------- src/recall.py | 45 ++++++++++++++++++++ 4 files changed, 72 insertions(+), 69 deletions(-) create mode 100644 src/recall.py diff --git a/src/coordinator/orchestrator.py b/src/coordinator/orchestrator.py index 68a5211..a8e46b7 100644 --- a/src/coordinator/orchestrator.py +++ b/src/coordinator/orchestrator.py @@ -1157,9 +1157,9 @@ def _write_run_stats( # Self-evolution line 2: distill insights into a reusable cross-run skill. if getattr(self.config, "distill_skills", False): try: - from ..distill import distill_to_library - paths = distill_to_library(self.config.workspace_dir) - _print_status(f"Distilled {len(paths)} skill(s)" if paths else "Distill: nothing to learn") + from ..distill import distill_to_session + p = distill_to_session(self.config.workspace_dir) + _print_status(f"Distilled experience: {p}" if p else "Distill: nothing to learn") except Exception as e: # pylint: disable=broad-exception-caught _print_status(f"Warning: failed to distill skill: {e}") diff --git a/src/core/skill_registry.py b/src/core/skill_registry.py index 8617fe1..22f743a 100644 --- a/src/core/skill_registry.py +++ b/src/core/skill_registry.py @@ -22,7 +22,6 @@ import logging import os -import re from dataclasses import dataclass from pathlib import Path @@ -131,28 +130,14 @@ def build_default_registry(cwd: str, *, disabled: list[str] | set[str] | tuple[s # Built-in: /skills/ pkg_skills = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "skills") n_builtin = registry.load_dir(pkg_skills, source="built-in") - # Cross-run library: skills distilled from past runs (self-evolution). Loaded - # from ~/.arbor/skills/**; lower priority than project, higher than built-in. - # Recall is domain-scoped to avoid negative transfer: meta/ (cross-domain) - # always loads; domain// loads only when matches this project. - n_lib = 0 - home_lib = os.path.join(os.path.expanduser("~"), ".arbor", "skills") - here = re.sub(r"[^a-z0-9]+", "-", os.path.basename(os.path.abspath(cwd)).lower()).strip("-") - if os.path.isdir(home_lib): - for root, _dirs, _files in os.walk(home_lib): - rel = os.path.relpath(root, home_lib) - if rel.startswith("domain") and here and here not in rel.split(os.sep): - continue # skip other domains' skills - n_lib += registry.load_dir(root, source="library") # Project override project_skills = os.path.join(cwd, ".arbor", "skills") n_project = registry.load_dir(project_skills, source="project") for name in disabled: registry._skills.pop(str(name), None) log.info( - "skill_registry: loaded %d built-in + %d library + %d project skills (total %d)", + "skill_registry: loaded %d built-in + %d project skills (total %d)", n_builtin, - n_lib, n_project, len(registry), ) diff --git a/src/distill.py b/src/distill.py index 32fa91b..bb793aa 100644 --- a/src/distill.py +++ b/src/distill.py @@ -1,18 +1,9 @@ -"""Skill distillation (self-evolution line 2a). +"""Experience distillation (self-evolution line 2). -Turns a finished run into reusable skills in the cross-run library -(~/.arbor/skills/), tagged by **altitude** so recall can reuse them safely: - - * ``meta`` — research strategy from the tree shape/process (pruned dead-ends, - merge timing). Transfers across domains. - * ``domain`` — what classes of idea won/lost (from verified node insights). - Transfers within a domain. - * task-specific findings stay in the run's REPORT/trajectory, not the library — - they don't transfer and would only pollute recall. - -Deterministic v1: lifts what the tree already abstracted. An LLM "raise the -abstraction level" pass and dedup/confidence are later refinements. Best-effort: -never fail a finished run. +Turns a finished run into a consolidated EXPERIENCE.md in its own session folder, +layered by altitude (meta = cross-domain strategy; domain = idea-class wins/losses). +Task-specific findings stay in REPORT/trajectory. Experience is recalled per-session +by recall.find_similar, not registered as global skills. Best-effort: never fail a run. """ from __future__ import annotations @@ -67,7 +58,6 @@ def build_skills(session_dir: Path) -> list[tuple[str, str, str]]: # meta layer — strategy from the tree's shape, transfers across domains pruned = [n for n in tree.values() if n.get("status") == "pruned"] - merged = [n for n in tree.values() if n.get("status") == "merged"] process = [f"dead-end: {n.get('insight','').strip()[:160]}" for n in pruned if n.get("insight")] try: from .experience import load_experience @@ -80,40 +70,23 @@ def build_skills(session_dir: Path) -> list[tuple[str, str, str]]: return out -def _norm(b: str) -> str: - return re.sub(r"\s+", " ", re.sub(r"\[.*?\]|score=\S+|\d+", "", b.lower())).strip() - +def distill_to_session(session_dir: Path) -> Path | None: + """Write a consolidated EXPERIENCE.md inside the run's own session folder. -def distill_to_library(session_dir: Path, lib_root: Path | None = None) -> list[Path]: - """Merge layered bullets into one consolidated skill per (level, domain). - - Recurring lessons reinforce (occurrence count = confidence) instead of piling - up one file per run. Deterministic dedup by normalized text. Returns paths. + Experience stays per-session (not a global skill library): future runs search + sessions, ask the user, and compose a tailored block. Returns the path or None. """ - root = lib_root or Path.home() / ".arbor" / "skills" - paths: list[Path] = [] - for level, domain, bullets in build_skills(session_dir): - d = root / level / domain - d.mkdir(parents=True, exist_ok=True) - out = d / "learned.md" - seen: dict[str, list[str]] = {} # norm -> [count, text] - if out.exists(): # parse prior counts from "- [xN] text" - for ln in out.read_text(encoding="utf-8").splitlines(): - m = re.match(r"- \[x(\d+)\] (.+)", ln) - if m: - seen[_norm(m.group(2))] = [int(m.group(1)), m.group(2)] - for b in bullets: - k = _norm(b) - if k in seen: - seen[k][0] += 1 - else: - seen[k] = [1, b] - ranked = sorted(seen.values(), key=lambda x: -x[0]) - when = ("any task — search-strategy priors" if level == "meta" - else f"a {domain}-like task — candidate priors, not rules") - body = [f"- [x{c}] {t}" for c, t in ranked] - out.write_text(_frag(f"learned-{level}-{domain}", f"Consolidated {level} lessons.", - f"IDEATE on {when}.", f"Learned: {level}/{domain}", body), - encoding="utf-8") - paths.append(out) - return paths + session_dir = Path(session_dir) + frags = build_skills(session_dir) + if not frags: + return None + domain = next((d for lvl, d, _ in frags if lvl == "domain"), "general") + lines: list[str] = [] + for level, _d, bullets in frags: + lines.append(f"## {level}") + lines += [f"- {b}" for b in bullets] + md = _frag(f"experience-{domain}", f"Experience from a {domain} run.", + "reuse on a similar topic", f"Experience: {domain}", lines) + out = session_dir / "EXPERIENCE.md" + out.write_text(md, encoding="utf-8") + return out diff --git a/src/recall.py b/src/recall.py new file mode 100644 index 0000000..5aa2d5d --- /dev/null +++ b/src/recall.py @@ -0,0 +1,45 @@ +"""Experience recall: find prior runs whose lessons may help the current topic. + +Each finished run leaves an ``EXPERIENCE.md`` in its session folder. When a new +run starts, intake scans past sessions, scores each against the current research +topic, and (if a strong match exists) asks the user whether to reuse it. Accepted +matches are composed into a tailored experience block for the agent. + +Matching is deterministic keyword overlap here — cheap and good enough to gate +the ask; an embedding/LLM judge can replace ``_score`` later. +""" + +from __future__ import annotations + +import re +from pathlib import Path +from typing import Any + +_STOP = {"the", "a", "an", "to", "of", "and", "for", "on", "in", "with", "without", + "maximize", "minimize", "improve", "optimize", "score", "test", "dev", "task"} + + +def _tokens(text: str) -> set[str]: + return {w for w in re.findall(r"[a-z0-9]+", (text or "").lower()) if len(w) > 2 and w not in _STOP} + + +def _score(topic: set[str], experience: set[str]) -> float: + if not topic or not experience: + return 0.0 + return len(topic & experience) / len(topic) # fraction of topic terms covered + + +def find_similar(cwd: str, topic: str, *, limit: int = 3, threshold: float = 0.25) -> list[dict[str, Any]]: + """Return prior sessions ranked by topic overlap: [{name, path, score, text}].""" + sessions = Path(cwd) / ".arbor" / "sessions" + if not sessions.is_dir(): + return [] + tt = _tokens(topic) + hits: list[dict[str, Any]] = [] + for exp in sessions.glob("*/EXPERIENCE.md"): + text = exp.read_text(encoding="utf-8") + s = _score(tt, _tokens(text)) + if s >= threshold: + hits.append({"name": exp.parent.name, "path": str(exp), "score": round(s, 3), "text": text}) + hits.sort(key=lambda h: -h["score"]) + return hits[:limit] From a47d36a7ccd19bca384948d1e8f9d1f136a5e8b5 Mon Sep 17 00:00:00 2001 From: ignorejjj Date: Mon, 29 Jun 2026 17:48:17 +0800 Subject: [PATCH 08/16] feat(experience): surface prior experience at intake + compose for topic Intake system prompt lists past runs that left EXPERIENCE.md and tells the agent to offer the relevant one and ask the user (default yes) when the goal matches. compose_for_topic assembles a tailored block from topic-matched sessions for the agent. Closes the loop: store -> recall -> ask -> compose. No skill-menu bloat. --- src/cli/intake/system_prompt.py | 21 +++++++++++++++++++++ src/recall.py | 27 +++++++++++++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/src/cli/intake/system_prompt.py b/src/cli/intake/system_prompt.py index 30b2ce5..c97fc62 100644 --- a/src/cli/intake/system_prompt.py +++ b/src/cli/intake/system_prompt.py @@ -3,6 +3,26 @@ from __future__ import annotations +def _prior_experience_block(starting_cwd: str) -> str: + """If past runs here left experience, tell intake to offer it and ask the user.""" + try: + from ...recall import list_experiences + exps = list_experiences(starting_cwd) + except Exception: # pylint: disable=broad-exception-caught + exps = [] + if not exps: + return "" + lines = [f" - {name}: {desc}" for name, desc in exps] + return ( + "## Prior experience available\n" + "This project has experience from past runs:\n" + + "\n".join(lines) + "\n" + "If the user's goal is similar, briefly offer to reuse the relevant one " + "and ask whether to apply it (default yes). Don't push it if unrelated.\n" + "\n" + ) + + def build_system_prompt(*, starting_cwd: str) -> str: """Build the planning-agent system prompt. @@ -101,6 +121,7 @@ def build_system_prompt(*, starting_cwd: str) -> str: "## Target directory\n" f"The user launched you from: {starting_cwd}\n" "\n" + + _prior_experience_block(starting_cwd) + "Treat that path as the default target. In the common case the user " "is already inside their project — just go with it and start talking " "about the goal. Quietly check it looks like a project (one Glob is " diff --git a/src/recall.py b/src/recall.py index 5aa2d5d..acbb698 100644 --- a/src/recall.py +++ b/src/recall.py @@ -43,3 +43,30 @@ def find_similar(cwd: str, topic: str, *, limit: int = 3, threshold: float = 0.2 hits.append({"name": exp.parent.name, "path": str(exp), "score": round(s, 3), "text": text}) hits.sort(key=lambda h: -h["score"]) return hits[:limit] + + +def list_experiences(cwd: str, limit: int = 8) -> list[tuple[str, str]]: + """[(session_name, first-line summary)] of prior runs that left experience.""" + sessions = Path(cwd) / ".arbor" / "sessions" + out: list[tuple[str, str]] = [] + if not sessions.is_dir(): + return out + for exp in sorted(sessions.glob("*/EXPERIENCE.md"), reverse=True)[:limit]: + desc = "" + for ln in exp.read_text(encoding="utf-8").splitlines(): + if ln.startswith("description:"): + desc = ln.split(":", 1)[1].strip() + break + out.append((exp.parent.name, desc)) + return out + + +def compose_for_topic(cwd: str, topic: str) -> str: + """Compose one tailored experience block from sessions matching the topic.""" + hits = find_similar(cwd, topic) + if not hits: + return "" + parts = ["# Prior experience (candidate priors — verify, don't blindly apply)"] + for h in hits: + parts.append(f"\n## from {h['name']} (match {h['score']})\n{h['text']}") + return "\n".join(parts) From 818b8f99d074f4276a9415797b1c7350e3fe715f Mon Sep 17 00:00:00 2001 From: ignorejjj Date: Mon, 29 Jun 2026 18:03:44 +0800 Subject: [PATCH 09/16] feat(experience): optional LLM abstraction of lessons into transferable principles distill_to_session takes a provider; when coordinator.distill_abstract is on, it lifts task-specific bullets ('argpartition at d=16') to principles ('prefer partial selection over full sort'). Any failure falls back to deterministic bullets, so distillation never breaks. Off by default. --- src/coordinator/config.py | 2 ++ src/coordinator/orchestrator.py | 3 ++- src/distill.py | 25 +++++++++++++++++++++++-- 3 files changed, 27 insertions(+), 3 deletions(-) diff --git a/src/coordinator/config.py b/src/coordinator/config.py index 9c94f35..436a67b 100644 --- a/src/coordinator/config.py +++ b/src/coordinator/config.py @@ -478,6 +478,8 @@ class CoordinatorConfig(ProxyModel): token_trace: bool = False # Distill run insights into a reusable skill in ~/.arbor/skills (line 2). Off. distill_skills: bool = False + # Use the LLM to abstract distilled lessons into transferable principles. Off. + distill_abstract: bool = False # ── Plugin (runtime object; not serialized) ────────────────────── plugin: Any = PydField(default=None, exclude=True, repr=False) diff --git a/src/coordinator/orchestrator.py b/src/coordinator/orchestrator.py index a8e46b7..6d43290 100644 --- a/src/coordinator/orchestrator.py +++ b/src/coordinator/orchestrator.py @@ -1158,7 +1158,8 @@ def _write_run_stats( if getattr(self.config, "distill_skills", False): try: from ..distill import distill_to_session - p = distill_to_session(self.config.workspace_dir) + prov = self.provider if getattr(self.config, "distill_abstract", False) else None + p = distill_to_session(self.config.workspace_dir, provider=prov) _print_status(f"Distilled experience: {p}" if p else "Distill: nothing to learn") except Exception as e: # pylint: disable=broad-exception-caught _print_status(f"Warning: failed to distill skill: {e}") diff --git a/src/distill.py b/src/distill.py index bb793aa..711f46f 100644 --- a/src/distill.py +++ b/src/distill.py @@ -70,7 +70,28 @@ def build_skills(session_dir: Path) -> list[tuple[str, str, str]]: return out -def distill_to_session(session_dir: Path) -> Path | None: +def _abstract(provider: Any, bullets: list[str]) -> list[str]: + """Lift task-specific bullets to transferable principles via the LLM. + + Best-effort: any failure (no provider, async issues, bad output) returns the + bullets unchanged, so distillation always succeeds deterministically. + """ + if not provider or not bullets: + return bullets + try: + import asyncio + prompt = ("Rewrite each lesson as ONE transferable principle: drop task-specific " + "names/numbers, keep what generalizes. Same count, one per line, no preamble.\n\n" + + "\n".join(f"- {b}" for b in bullets)) + resp = asyncio.run(provider.create(system="You distill reusable research principles.", + messages=[{"role": "user", "content": prompt}], max_tokens=600)) + out = [ln.lstrip("-* ").strip() for ln in resp.get_text().splitlines() if ln.strip()] + return out or bullets + except Exception: # pylint: disable=broad-exception-caught + return bullets + + +def distill_to_session(session_dir: Path, provider: Any = None) -> Path | None: """Write a consolidated EXPERIENCE.md inside the run's own session folder. Experience stays per-session (not a global skill library): future runs search @@ -84,7 +105,7 @@ def distill_to_session(session_dir: Path) -> Path | None: lines: list[str] = [] for level, _d, bullets in frags: lines.append(f"## {level}") - lines += [f"- {b}" for b in bullets] + lines += [f"- {b}" for b in _abstract(provider, bullets)] md = _frag(f"experience-{domain}", f"Experience from a {domain} run.", "reuse on a similar topic", f"Experience: {domain}", lines) out = session_dir / "EXPERIENCE.md" From 2a37d1911f350fba9376de2c54943951d69afaab Mon Sep 17 00:00:00 2001 From: ignorejjj Date: Mon, 29 Jun 2026 18:18:47 +0800 Subject: [PATCH 10/16] feat(experience): inject composed prior experience into the launched instruction LaunchExperiment gains apply_experience: when the user agreed at intake, matched prior lessons are composed and prepended to the instruction the coordinator runs on. Off -> unchanged. store->recall->ask->compose->inject is now closed. --- src/cli/intake/launch_tool.py | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/src/cli/intake/launch_tool.py b/src/cli/intake/launch_tool.py index 7098e4a..2b931a0 100644 --- a/src/cli/intake/launch_tool.py +++ b/src/cli/intake/launch_tool.py @@ -129,6 +129,11 @@ class LaunchExperimentTool(Tool): "items": {"type": "string"}, "description": "Optional bullet notes (constraints, edge cases) to attach to the plan.", }, + "apply_experience": { + "type": "boolean", + "description": "Set true when the user agreed to reuse prior experience; " + "matched lessons are composed into the instruction.", + }, }, "required": ["cwd", "instruction"], } @@ -165,7 +170,8 @@ async def execute(self, **kwargs: Any) -> str: plan = LaunchPlan( cwd=str(resolved.resolve()), - instruction=instruction, + instruction=_with_experience(str(resolved.resolve()), instruction, + kwargs.get("apply_experience")), rationale=(kwargs.get("rationale") or "").strip(), suggested_max_cycles=_safe_int(kwargs.get("suggested_max_cycles")), suggested_max_turns=_safe_int(kwargs.get("suggested_max_turns")), @@ -188,3 +194,15 @@ def _safe_int(v: Any) -> int | None: return int(v) if v is not None else None except (TypeError, ValueError): return None + + +def _with_experience(cwd: str, instruction: str, apply: Any) -> str: + """Prepend composed prior experience to the instruction when the user opted in.""" + if not apply: + return instruction + try: + from ...recall import compose_for_topic + block = compose_for_topic(cwd, instruction) + except Exception: # pylint: disable=broad-exception-caught + block = "" + return f"{block}\n\n---\n{instruction}" if block else instruction From 83ff42d2a53faaef79a755af9644f4ae132b21c9 Mon Sep 17 00:00:00 2001 From: ignorejjj Date: Mon, 29 Jun 2026 21:07:52 +0800 Subject: [PATCH 11/16] feat(experience): structured decision-ready experience + LLM selection + fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Distill into transferable sections (Try first / Avoid / Key lever / Pitfalls / How to approach) via an LLM pass, with a clean deterministic fallback — what a next run actually needs to decide, not a win/loss dump. - Fix abstraction never firing: run the provider call in a thread so it works inside the live finalize loop (asyncio.run no longer raises). - Drop the heading-as-bullet wart; live capture now grabs the node's real insight. - Recall selection is LLM-driven: intake names experience_sessions it judged relevant; keyword match is only a fallback. --- src/cli/intake/launch_tool.py | 25 +++++++-- src/cli/intake/system_prompt.py | 2 + src/coordinator/tools/tree_ops.py | 8 ++- src/distill.py | 86 +++++++++++++++++++++++-------- src/recall.py | 20 +++++++ 5 files changed, 113 insertions(+), 28 deletions(-) diff --git a/src/cli/intake/launch_tool.py b/src/cli/intake/launch_tool.py index 2b931a0..79833cf 100644 --- a/src/cli/intake/launch_tool.py +++ b/src/cli/intake/launch_tool.py @@ -134,6 +134,12 @@ class LaunchExperimentTool(Tool): "description": "Set true when the user agreed to reuse prior experience; " "matched lessons are composed into the instruction.", }, + "experience_sessions": { + "type": "array", + "items": {"type": "string"}, + "description": "Session names (from 'Prior experience available') you judged " + "relevant to this goal. You select; keyword match is only a fallback.", + }, }, "required": ["cwd", "instruction"], } @@ -171,7 +177,8 @@ async def execute(self, **kwargs: Any) -> str: plan = LaunchPlan( cwd=str(resolved.resolve()), instruction=_with_experience(str(resolved.resolve()), instruction, - kwargs.get("apply_experience")), + kwargs.get("apply_experience"), + kwargs.get("experience_sessions")), rationale=(kwargs.get("rationale") or "").strip(), suggested_max_cycles=_safe_int(kwargs.get("suggested_max_cycles")), suggested_max_turns=_safe_int(kwargs.get("suggested_max_turns")), @@ -196,13 +203,21 @@ def _safe_int(v: Any) -> int | None: return None -def _with_experience(cwd: str, instruction: str, apply: Any) -> str: - """Prepend composed prior experience to the instruction when the user opted in.""" +def _with_experience(cwd: str, instruction: str, apply: Any, sessions: Any = None) -> str: + """Prepend composed prior experience to the instruction when the user opted in. + + Prefers the sessions the intake agent (an LLM) selected as relevant; falls back + to keyword topic matching only if it named none. + """ if not apply: return instruction try: - from ...recall import compose_for_topic - block = compose_for_topic(cwd, instruction) + from ...recall import compose_for_topic, compose_from_sessions + block = "" + if isinstance(sessions, list) and sessions: + block = compose_from_sessions(cwd, [str(s) for s in sessions]) + if not block: + block = compose_for_topic(cwd, instruction) except Exception: # pylint: disable=broad-exception-caught block = "" return f"{block}\n\n---\n{instruction}" if block else instruction diff --git a/src/cli/intake/system_prompt.py b/src/cli/intake/system_prompt.py index c97fc62..f0d3325 100644 --- a/src/cli/intake/system_prompt.py +++ b/src/cli/intake/system_prompt.py @@ -19,6 +19,8 @@ def _prior_experience_block(starting_cwd: str) -> str: + "\n".join(lines) + "\n" "If the user's goal is similar, briefly offer to reuse the relevant one " "and ask whether to apply it (default yes). Don't push it if unrelated.\n" + "When launching with the user's agreement, set apply_experience=true and " + "list the relevant session names in experience_sessions (you judge relevance).\n" "\n" ) diff --git a/src/coordinator/tools/tree_ops.py b/src/coordinator/tools/tree_ops.py index 8b8633b..e1bfb43 100644 --- a/src/coordinator/tools/tree_ops.py +++ b/src/coordinator/tools/tree_ops.py @@ -336,8 +336,14 @@ async def execute(self, **kwargs: Any) -> str: if updates.get("insight") or updates.get("status"): try: from ...experience import append_experience + # Use the node's current insight (it may have been set by a prior + # update), not just this call's fields, so notes aren't empty. + node = self._tree.get_node(node_id) + enriched = dict(updates) + if not enriched.get("insight") and node is not None and getattr(node, "insight", ""): + enriched["insight"] = node.insight append_experience(getattr(self._tree, "json_path", None), - node_id=node_id, updates=updates) + node_id=node_id, updates=enriched) except Exception: # pylint: disable=broad-exception-caught pass diff --git a/src/distill.py b/src/distill.py index 711f46f..14ab852 100644 --- a/src/distill.py +++ b/src/distill.py @@ -70,44 +70,86 @@ def build_skills(session_dir: Path) -> list[tuple[str, str, str]]: return out -def _abstract(provider: Any, bullets: list[str]) -> list[str]: - """Lift task-specific bullets to transferable principles via the LLM. +def _run_coro(coro: Any) -> Any: + """Run an async provider call from sync finalize, even inside a live loop. - Best-effort: any failure (no provider, async issues, bad output) returns the - bullets unchanged, so distillation always succeeds deterministically. + ``asyncio.run`` raises if a loop is already running (which is why the earlier + abstraction pass silently fell back). Run it in a dedicated thread instead. """ - if not provider or not bullets: - return bullets + import asyncio + import concurrent.futures try: - import asyncio - prompt = ("Rewrite each lesson as ONE transferable principle: drop task-specific " - "names/numbers, keep what generalizes. Same count, one per line, no preamble.\n\n" - + "\n".join(f"- {b}" for b in bullets)) - resp = asyncio.run(provider.create(system="You distill reusable research principles.", - messages=[{"role": "user", "content": prompt}], max_tokens=600)) - out = [ln.lstrip("-* ").strip() for ln in resp.get_text().splitlines() if ln.strip()] - return out or bullets + asyncio.get_running_loop() + except RuntimeError: + return asyncio.run(coro) + with concurrent.futures.ThreadPoolExecutor(max_workers=1) as ex: + return ex.submit(lambda: asyncio.run(coro)).result() + + +_DISTILL_SYS = ( + "You distill REUSABLE research experience from one optimization run, for a future " + "agent attacking a SIMILAR task. Drop task-specific names/numbers; keep what transfers. " + "Return markdown with exactly these sections, terse bullets, omit a section if empty:\n" + "## Try first (promising directions + why)\n" + "## Avoid (dead-ends that failed + why)\n" + "## Key lever (the dominant factor that decided performance)\n" + "## Pitfalls (looked good but failed on held-out / overfit traps)\n" + "## How to approach (search method that worked for this class of problem)" +) + + +def _llm_experience(provider: Any, raw: list[str]) -> str | None: + """Ask the LLM to turn raw lessons into structured, transferable experience.""" + if not provider or not raw: + return None + try: + msg = "Raw lessons from the run:\n" + "\n".join(f"- {b}" for b in raw) + resp = _run_coro(provider.create(system=_DISTILL_SYS, + messages=[{"role": "user", "content": msg}], max_tokens=800)) + text = resp.get_text().strip() + return text if "##" in text else None except Exception: # pylint: disable=broad-exception-caught - return bullets + return None + + +def _raw_lessons(frags: list[tuple[str, str, list[str]]]) -> list[str]: + """Flatten layered bullets into raw lessons, dropping markdown-heading noise.""" + raw: list[str] = [] + for _level, _d, bullets in frags: + for b in bullets: + b = b.strip() + if b and not b.lstrip("[").startswith("#") and "##" not in b: + raw.append(b) + return raw def distill_to_session(session_dir: Path, provider: Any = None) -> Path | None: """Write a consolidated EXPERIENCE.md inside the run's own session folder. - Experience stays per-session (not a global skill library): future runs search - sessions, ask the user, and compose a tailored block. Returns the path or None. + Prefers an LLM pass that structures lessons into transferable, decision-ready + sections (Try first / Avoid / Key lever / Pitfalls / How to approach); falls + back to clean deterministic bullets when no provider or the call fails. """ session_dir = Path(session_dir) frags = build_skills(session_dir) if not frags: return None domain = next((d for lvl, d, _ in frags if lvl == "domain"), "general") - lines: list[str] = [] - for level, _d, bullets in frags: - lines.append(f"## {level}") - lines += [f"- {b}" for b in _abstract(provider, bullets)] + raw = _raw_lessons(frags) + + body = _llm_experience(provider, raw) + if body is None: # deterministic fallback: clean sections, no heading noise + wins = [b for b in raw if b.startswith("[merged") or b.startswith("[done")] + avoid = [b for b in raw if b.startswith("dead-end")] + lines = [] + if wins: + lines += ["## Try first"] + [f"- {b}" for b in wins] + if avoid: + lines += ["## Avoid"] + [f"- {b}" for b in avoid] + body = "\n".join(lines) or "_(no transferable lessons)_" + md = _frag(f"experience-{domain}", f"Experience from a {domain} run.", - "reuse on a similar topic", f"Experience: {domain}", lines) + "reuse on a similar topic", f"Experience: {domain}", [body]) out = session_dir / "EXPERIENCE.md" out.write_text(md, encoding="utf-8") return out diff --git a/src/recall.py b/src/recall.py index acbb698..eb89760 100644 --- a/src/recall.py +++ b/src/recall.py @@ -64,6 +64,26 @@ def list_experiences(cwd: str, limit: int = 8) -> list[tuple[str, str]]: def compose_for_topic(cwd: str, topic: str) -> str: """Compose one tailored experience block from sessions matching the topic.""" hits = find_similar(cwd, topic) + return _compose(hits) + + +def compose_from_sessions(cwd: str, names: list[str]) -> str: + """Compose from sessions the intake agent (an LLM) judged relevant. + + LLM selection beats keyword matching: the intake agent reads the goal and the + project, so it picks which prior runs actually transfer. Falls back to nothing + if the named sessions lack experience. + """ + sessions = Path(cwd) / ".arbor" / "sessions" + hits = [] + for name in names or []: + exp = sessions / name / "EXPERIENCE.md" + if exp.exists(): + hits.append({"name": name, "score": "selected", "text": exp.read_text(encoding="utf-8")}) + return _compose(hits) + + +def _compose(hits: list[dict[str, Any]]) -> str: if not hits: return "" parts = ["# Prior experience (candidate priors — verify, don't blindly apply)"] From 78aec99c7051eac64b3fb33e65c5a71833e3636a Mon Sep 17 00:00:00 2001 From: ignorejjj Date: Mon, 29 Jun 2026 21:22:48 +0800 Subject: [PATCH 12/16] feat(experience): concrete situational findings (A: RecordFinding tool + B: mine) Replace the rigid 5-section template with what was actually wanted: specific, situational findings worth remembering next time on the same dataset/task/harness -- a dataset quirk that helps the metric, a trap an executor or harness fell into. - A: RecordFinding tool lets the agent log a finding the moment it hits one. - B: finalize mines the run for concrete findings that were never logged. EXPERIENCE.md is now free-form '[leverage|pitfall] about: note', deduped. --- src/coordinator/tools/__init__.py | 2 + src/coordinator/tools/record_finding.py | 55 ++++++++++++ src/distill.py | 109 +++++++++++++----------- src/experience.py | 33 +++++++ 4 files changed, 151 insertions(+), 48 deletions(-) create mode 100644 src/coordinator/tools/record_finding.py diff --git a/src/coordinator/tools/__init__.py b/src/coordinator/tools/__init__.py index d1cf80f..434cf36 100644 --- a/src/coordinator/tools/__init__.py +++ b/src/coordinator/tools/__init__.py @@ -19,6 +19,7 @@ from .search_ctx import SearchIdeaContextTool, SearchIdeaContextParallelTool, SearchStatusTool from .research_ctx import ResearchSearchTool from .ask_user import AskUserTool +from .record_finding import RecordFindingTool from ..convergence import ConvergenceDetector, ConvergenceConfig if TYPE_CHECKING: @@ -85,6 +86,7 @@ def get_coordinator_tools( FileReadTool(cwd=cwd, workspace_dir=wdir), GrepTool(cwd=cwd, workspace_dir=wdir), GlobTool(cwd=cwd, workspace_dir=wdir), + RecordFindingTool(cwd=cwd, workspace_dir=wdir), ] if skill_registry is not None: # Skills (read-only reference docs loaded on demand) diff --git a/src/coordinator/tools/record_finding.py b/src/coordinator/tools/record_finding.py new file mode 100644 index 0000000..f239033 --- /dev/null +++ b/src/coordinator/tools/record_finding.py @@ -0,0 +1,55 @@ +"""RecordFinding — let the agent jot a concrete, situational discovery mid-run. + +This is the live half (A) of experience capture. When the coordinator or executor +notices something specific and worth remembering for next time — a dataset quirk +that helps the metric, a trap an executor fell into, a gotcha in the harness — it +records it here. Kept specific on purpose; finalize also mines the trajectory (B) +for findings that were never explicitly logged. +""" + +from __future__ import annotations + +from typing import Any + +from ...core.tools.base import Tool + + +class RecordFindingTool(Tool): + """Record a concrete discovery or pitfall for future runs to reuse.""" + + name = "RecordFinding" + is_read_only = True # only appends to a notes file; safe to parallelize + description = ( + "Record a CONCRETE, situational discovery worth remembering next time you " + "work on this dataset / task / harness. Two typical kinds:\n" + "- 'leverage': a specific property you found that helps the metric " + "(e.g. 'this dataset's labels are noisy above index 9000 — drop them').\n" + "- 'pitfall': a specific trap to avoid (e.g. 'the executor kept editing " + "eval.py — remind it the harness is protected').\n" + "Keep it specific — the value is the detail, not a general principle. Do " + "NOT log routine progress or generic advice." + ) + input_schema: dict[str, Any] = { + "type": "object", + "properties": { + "kind": {"type": "string", "description": "'leverage' or 'pitfall' (free-form ok)."}, + "about": {"type": "string", "description": "What it concerns: the dataset, an executor, the harness, etc."}, + "note": {"type": "string", "description": "The concrete finding, in one or two sentences."}, + }, + "required": ["note"], + } + + def __init__(self, *, cwd: str, workspace_dir: str | None = None): + super().__init__(cwd=cwd, workspace_dir=workspace_dir) + + async def execute(self, **kwargs: Any) -> str: + note = (kwargs.get("note") or "").strip() + if not note: + return "Error: 'note' is required." + try: + from ...experience import record_finding + record_finding(self.workspace_dir or self.cwd, + kind=kwargs.get("kind", ""), about=kwargs.get("about", ""), note=note) + except Exception as exc: # pylint: disable=broad-exception-caught + return f"Could not record finding: {exc}" + return f"Recorded {kwargs.get('kind') or 'finding'}: {note[:80]}" diff --git a/src/distill.py b/src/distill.py index 14ab852..4100f28 100644 --- a/src/distill.py +++ b/src/distill.py @@ -86,32 +86,6 @@ def _run_coro(coro: Any) -> Any: return ex.submit(lambda: asyncio.run(coro)).result() -_DISTILL_SYS = ( - "You distill REUSABLE research experience from one optimization run, for a future " - "agent attacking a SIMILAR task. Drop task-specific names/numbers; keep what transfers. " - "Return markdown with exactly these sections, terse bullets, omit a section if empty:\n" - "## Try first (promising directions + why)\n" - "## Avoid (dead-ends that failed + why)\n" - "## Key lever (the dominant factor that decided performance)\n" - "## Pitfalls (looked good but failed on held-out / overfit traps)\n" - "## How to approach (search method that worked for this class of problem)" -) - - -def _llm_experience(provider: Any, raw: list[str]) -> str | None: - """Ask the LLM to turn raw lessons into structured, transferable experience.""" - if not provider or not raw: - return None - try: - msg = "Raw lessons from the run:\n" + "\n".join(f"- {b}" for b in raw) - resp = _run_coro(provider.create(system=_DISTILL_SYS, - messages=[{"role": "user", "content": msg}], max_tokens=800)) - text = resp.get_text().strip() - return text if "##" in text else None - except Exception: # pylint: disable=broad-exception-caught - return None - - def _raw_lessons(frags: list[tuple[str, str, list[str]]]) -> list[str]: """Flatten layered bullets into raw lessons, dropping markdown-heading noise.""" raw: list[str] = [] @@ -123,33 +97,72 @@ def _raw_lessons(frags: list[tuple[str, str, list[str]]]) -> list[str]: return raw +_MINE_SYS = ( + "You review one optimization run and surface CONCRETE, situational findings worth " + "remembering for the next run on this same dataset / task / harness. Keep them " + "SPECIFIC — a dataset quirk that helped the metric, a trap an executor or the harness " + "fell into. NOT generic advice or principles. One finding per line, format:\n" + "[leverage|pitfall] about: the concrete finding\n" + "Return only such lines, or nothing if there are no concrete findings." +) + + +def _mine_findings(provider: Any, raw: list[str]) -> list[dict[str, str]]: + """B: mine the run's lessons for concrete findings not explicitly logged.""" + if not provider or not raw: + return [] + try: + msg = "Run material:\n" + "\n".join(f"- {b}" for b in raw) + resp = _run_coro(provider.create(system=_MINE_SYS, + messages=[{"role": "user", "content": msg}], max_tokens=700)) + found = [] + for ln in resp.get_text().splitlines(): + m = re.match(r"\s*\[?(leverage|pitfall)\]?\s*([^:]*):\s*(.+)", ln, re.I) + if m: + found.append({"kind": m.group(1).lower(), "about": m.group(2).strip(), "note": m.group(3).strip()}) + return found + except Exception: # pylint: disable=broad-exception-caught + return [] + + def distill_to_session(session_dir: Path, provider: Any = None) -> Path | None: - """Write a consolidated EXPERIENCE.md inside the run's own session folder. + """Write EXPERIENCE.md: the run's concrete findings (logged live + mined). - Prefers an LLM pass that structures lessons into transferable, decision-ready - sections (Try first / Avoid / Key lever / Pitfalls / How to approach); falls - back to clean deterministic bullets when no provider or the call fails. + Experience here is specific and situational by design — dataset quirks that + help, traps to avoid — for the next run on the same/similar target. Combines + findings the agent logged via RecordFinding (A) with an LLM mining pass (B). """ session_dir = Path(session_dir) - frags = build_skills(session_dir) - if not frags: + domain = _domain({}, session_dir) + + findings: list[dict[str, str]] = [] + seen: set[str] = set() + + def _add(f: dict[str, str]) -> None: + note = (f.get("note") or "").strip() + key = re.sub(r"\W+", "", note.lower())[:60] + if note and key not in seen: + seen.add(key) + findings.append({"kind": f.get("kind", ""), "about": f.get("about", ""), "note": note}) + + try: # A: explicitly logged findings + from .experience import load_findings + for f in load_findings(session_dir): + _add(f) + except Exception: # pylint: disable=broad-exception-caught + pass + + raw = _raw_lessons(build_skills(session_dir)) # B: mine the rest from the run + for f in _mine_findings(provider, raw): + _add(f) + + if not findings: return None - domain = next((d for lvl, d, _ in frags if lvl == "domain"), "general") - raw = _raw_lessons(frags) - - body = _llm_experience(provider, raw) - if body is None: # deterministic fallback: clean sections, no heading noise - wins = [b for b in raw if b.startswith("[merged") or b.startswith("[done")] - avoid = [b for b in raw if b.startswith("dead-end")] - lines = [] - if wins: - lines += ["## Try first"] + [f"- {b}" for b in wins] - if avoid: - lines += ["## Avoid"] + [f"- {b}" for b in avoid] - body = "\n".join(lines) or "_(no transferable lessons)_" - - md = _frag(f"experience-{domain}", f"Experience from a {domain} run.", - "reuse on a similar topic", f"Experience: {domain}", [body]) + lines = [f"- **[{(f['kind'] or 'finding')}] {f['about']}** — {f['note']}" if f["about"] + else f"- **[{f['kind'] or 'finding'}]** {f['note']}" for f in findings] + md = _frag(f"experience-{domain}", f"Concrete findings from a {domain} run.", + "reuse when working on this dataset / task / harness again", + f"Findings: {domain}", lines) out = session_dir / "EXPERIENCE.md" out.write_text(md, encoding="utf-8") return out diff --git a/src/experience.py b/src/experience.py index 43ab75b..737aa74 100644 --- a/src/experience.py +++ b/src/experience.py @@ -54,3 +54,36 @@ def load_experience(session_dir: Path) -> list[dict[str, Any]]: except json.JSONDecodeError: continue return out + + +FINDINGS_FILENAME = "findings.jsonl" + + +def record_finding(session_dir: str | Path | None, *, kind: str, about: str, note: str, + source: str = "agent") -> None: + """Append one concrete, situational finding (a discovery or a pitfall). + + These are kept specific on purpose — a dataset quirk you can exploit, a trap an + executor fell into — because their value is the specificity, for the next run on + the same/similar target. ``kind`` is free-form ('leverage' | 'pitfall' | ...). + """ + if not session_dir or not (note or "").strip(): + return + rec = {"kind": (kind or "").strip(), "about": (about or "").strip(), + "note": note.strip(), "source": source} + with open(Path(session_dir) / FINDINGS_FILENAME, "a", encoding="utf-8") as f: + f.write(json.dumps(rec, ensure_ascii=False) + "\n") + + +def load_findings(session_dir: Path) -> list[dict[str, Any]]: + p = Path(session_dir) / FINDINGS_FILENAME + if not p.exists(): + return [] + out = [] + for line in p.read_text(encoding="utf-8").splitlines(): + if line.strip(): + try: + out.append(json.loads(line)) + except json.JSONDecodeError: + continue + return out From 3cb28f62004a28c249e2b48ccb068e40a3960fd7 Mon Sep 17 00:00:00 2001 From: ignorejjj Date: Mon, 29 Jun 2026 21:59:41 +0800 Subject: [PATCH 13/16] fix(experience): stop the mining-prompt placeholder leaking as the subject The literal 'about:' in the format example made the model echo 'about' as the subject. Use SUBJECT in the template and blank any leaked about/subject placeholder. --- src/distill.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/distill.py b/src/distill.py index 4100f28..6dba38d 100644 --- a/src/distill.py +++ b/src/distill.py @@ -102,8 +102,9 @@ def _raw_lessons(frags: list[tuple[str, str, list[str]]]) -> list[str]: "remembering for the next run on this same dataset / task / harness. Keep them " "SPECIFIC — a dataset quirk that helped the metric, a trap an executor or the harness " "fell into. NOT generic advice or principles. One finding per line, format:\n" - "[leverage|pitfall] about: the concrete finding\n" - "Return only such lines, or nothing if there are no concrete findings." + "[leverage|pitfall] SUBJECT: the concrete finding\n" + "where SUBJECT is the specific thing it concerns (the dataset, an executor, the " + "harness, a numpy call...). Return only such lines, or nothing if none." ) @@ -119,7 +120,10 @@ def _mine_findings(provider: Any, raw: list[str]) -> list[dict[str, str]]: for ln in resp.get_text().splitlines(): m = re.match(r"\s*\[?(leverage|pitfall)\]?\s*([^:]*):\s*(.+)", ln, re.I) if m: - found.append({"kind": m.group(1).lower(), "about": m.group(2).strip(), "note": m.group(3).strip()}) + about = m.group(2).strip() + if about.lower() in ("about", "subject"): # guard leaked placeholder + about = "" + found.append({"kind": m.group(1).lower(), "about": about, "note": m.group(3).strip()}) return found except Exception: # pylint: disable=broad-exception-caught return [] From b6393e3ada2cdb28b2dfbe3a0a5874f4d0110058 Mon Sep 17 00:00:00 2001 From: ignorejjj Date: Mon, 29 Jun 2026 22:14:59 +0800 Subject: [PATCH 14/16] feat(experience): merge findings across recalled sessions with recurrence count MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Compose now dedups findings across the selected sessions and tags each [xN] = seen in N runs, ranking recurrent (more trustworthy) findings first. Cross-session confidence without a global library — fits the per-session storage model. --- src/recall.py | 29 ++++++++++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/src/recall.py b/src/recall.py index eb89760..f8c37ca 100644 --- a/src/recall.py +++ b/src/recall.py @@ -84,9 +84,32 @@ def compose_from_sessions(cwd: str, names: list[str]) -> str: def _compose(hits: list[dict[str, Any]]) -> str: + """Merge findings across the matched sessions, deduped with a recurrence count. + + A finding seen in several past runs is more trustworthy, so it's tagged [xN] + and ranked first — cross-session confidence without a global library. + """ if not hits: return "" - parts = ["# Prior experience (candidate priors — verify, don't blindly apply)"] + merged: dict[str, list[Any]] = {} # norm -> [count, bullet, first-session] for h in hits: - parts.append(f"\n## from {h['name']} (match {h['score']})\n{h['text']}") - return "\n".join(parts) + for ln in h["text"].splitlines(): + ln = ln.strip() + if not ln.startswith("- "): + continue + note = re.sub(r"\*\*\[.*?\]\s*[^—]*\*\*\s*—?\s*", "", ln[2:]).strip() + key = re.sub(r"\W+", "", note.lower())[:80] + if not key: + continue + if key in merged: + merged[key][0] += 1 + else: + merged[key] = [1, ln[2:].strip(), h["name"]] + if not merged: # nothing parseable — fall back to raw concatenation + return "\n".join(["# Prior experience (candidate priors — verify, don't blindly apply)"] + + [f"\n## from {h['name']}\n{h['text']}" for h in hits]) + ranked = sorted(merged.values(), key=lambda x: -x[0]) + lines = ["# Prior experience (candidate priors — verify, don't blindly apply)", + f"_merged from {len(hits)} past run(s); [xN] = seen in N runs_\n"] + lines += [f"- [x{c}] {bullet}" for c, bullet, _src in ranked] + return "\n".join(lines) From 0762f62b2dcb4b8d16c165b17f8cbd3343e36f36 Mon Sep 17 00:00:00 2001 From: ignorejjj Date: Mon, 29 Jun 2026 22:35:40 +0800 Subject: [PATCH 15/16] =?UTF-8?q?docs(readme):=20News=20=E2=80=94=20Arbor?= =?UTF-8?q?=20learns=20from=20its=20own=20runs=20(experience=20capture=20&?= =?UTF-8?q?=20reuse)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 69e0ba7..4fdd6fc 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,7 @@ https://github.com/user-attachments/assets/49c1a306-d2e9-49d6-9c83-65e38a62df30 ## 📣 News +- **2026-06** — **Arbor learns from its own runs.** Every run now leaves concrete, reusable findings — a dataset quirk that lifted the metric, a trap an executor or the harness fell into — captured live (the agent logs them mid-run) and mined again at the end. When you start a similar task, intake surfaces the relevant past findings and asks whether to reuse them, so the agent begins from hard-won experience instead of scratch. 🧠 - **2026-06** — **Built-in literature search & idea novelty checks.** Arbor can now ground its research in prior work via the public [alphaXiv](https://www.alphaxiv.org) API — zero config, no search endpoint or key. Novelty-check any idea before you build it with `arbor idea-check ""`, or let the Coordinator vet every new branch automatically. See [Literature Search & Novelty Checks](#-literature-search--novelty-checks). 🔎 - **2026-06** — Arbor was featured by [VentureBeat](https://venturebeat.com/), one of the leading tech media outlets in the US: ["New AI optimization framework beats Claude Code and Codex by 2.5x on the same compute budget"](https://venturebeat.com/orchestration/new-ai-optimization-framework-beats-claude-code-and-codex-by-2-5x-on-the-same-compute-budget). 📰 - **2026-06** — Arbor's native CLI runtime and Agent Skill Suite (Codex / Claude Code) are released. 🚀 From cdcdede3a4954b60a3e9677fa4a8fea3b3d62fef Mon Sep 17 00:00:00 2001 From: ignorejjj Date: Tue, 30 Jun 2026 09:51:01 +0800 Subject: [PATCH 16/16] docs(readme): tighten the self-evolution News line; sync zh-CN --- README.md | 2 +- README.zh-CN.md | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 4fdd6fc..40175de 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ https://github.com/user-attachments/assets/49c1a306-d2e9-49d6-9c83-65e38a62df30 ## 📣 News -- **2026-06** — **Arbor learns from its own runs.** Every run now leaves concrete, reusable findings — a dataset quirk that lifted the metric, a trap an executor or the harness fell into — captured live (the agent logs them mid-run) and mined again at the end. When you start a similar task, intake surfaces the relevant past findings and asks whether to reuse them, so the agent begins from hard-won experience instead of scratch. 🧠 +- **2026-06** — **Arbor learns from its own runs.** Each run leaves concrete, reusable findings — a dataset quirk that helped, a trap to avoid; the next similar task recalls them at intake, so the agent starts from experience instead of scratch. 🧠 - **2026-06** — **Built-in literature search & idea novelty checks.** Arbor can now ground its research in prior work via the public [alphaXiv](https://www.alphaxiv.org) API — zero config, no search endpoint or key. Novelty-check any idea before you build it with `arbor idea-check ""`, or let the Coordinator vet every new branch automatically. See [Literature Search & Novelty Checks](#-literature-search--novelty-checks). 🔎 - **2026-06** — Arbor was featured by [VentureBeat](https://venturebeat.com/), one of the leading tech media outlets in the US: ["New AI optimization framework beats Claude Code and Codex by 2.5x on the same compute budget"](https://venturebeat.com/orchestration/new-ai-optimization-framework-beats-claude-code-and-codex-by-2-5x-on-the-same-compute-budget). 📰 - **2026-06** — Arbor's native CLI runtime and Agent Skill Suite (Codex / Claude Code) are released. 🚀 diff --git a/README.zh-CN.md b/README.zh-CN.md index 0796f96..dcaf609 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -29,6 +29,7 @@ ## 📣 最新动态 +- **2026-06** — **Arbor 会从自己的运行中学习。** 每次运行都会留下具体、可复用的发现——数据集上能提分的特性、需要避开的坑;下次类似任务在 intake 时把它们召回,于是 agent 从经验出发,而不是从零开始。🧠 - **2026-06** — **内置文献检索与想法新颖性审查。** Arbor 现在可以通过 [alphaXiv](https://www.alphaxiv.org) 公共 API 把研究建立在已有工作之上——零配置,无需搜索端点或密钥。动手前用 `arbor idea-check "<你的想法>"` 审查任意想法的新颖性,或让 Coordinator 自动为每个新分支把关。详见[文献检索与新颖性审查](#-文献检索与新颖性审查)。🔎 - **2026-06** — Arbor 被美国知名科技媒体 [VentureBeat](https://venturebeat.com/) 报道:[《New AI optimization framework beats Claude Code and Codex by 2.5x on the same compute budget》](https://venturebeat.com/orchestration/new-ai-optimization-framework-beats-claude-code-and-codex-by-2-5x-on-the-same-compute-budget)。📰 - **2026-06** — Arbor 原生 CLI 运行时与智能体技能套件(Codex / Claude Code)正式发布。🚀