self-evolve-framework 1.3.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/package.json +1 -1
  2. package/template/rules/ponytail.mdc +98 -23
  3. package/template/skills/skillopt-sleep/SKILL.md +48 -34
  4. package/template/skills/skillopt-sleep/scripts/python/__init__.py +20 -0
  5. package/template/skills/skillopt-sleep/scripts/python/__main__.py +343 -0
  6. package/template/skills/skillopt-sleep/scripts/python/backend.py +1371 -0
  7. package/template/skills/skillopt-sleep/scripts/python/budget.py +75 -0
  8. package/template/skills/skillopt-sleep/scripts/python/config.py +162 -0
  9. package/template/skills/skillopt-sleep/scripts/python/consolidate.py +238 -0
  10. package/template/skills/skillopt-sleep/scripts/python/cycle.py +291 -0
  11. package/template/skills/skillopt-sleep/scripts/python/dream.py +138 -0
  12. package/template/skills/skillopt-sleep/scripts/python/experiments/__init__.py +1 -0
  13. package/template/skills/skillopt-sleep/scripts/python/experiments/gbrain_bench.py +119 -0
  14. package/template/skills/skillopt-sleep/scripts/python/experiments/personas.py +86 -0
  15. package/template/skills/skillopt-sleep/scripts/python/experiments/report.py +132 -0
  16. package/template/skills/skillopt-sleep/scripts/python/experiments/run_experiment.py +178 -0
  17. package/template/skills/skillopt-sleep/scripts/python/experiments/run_gbrain.py +209 -0
  18. package/template/skills/skillopt-sleep/scripts/python/experiments/run_transfer.py +155 -0
  19. package/template/skills/skillopt-sleep/scripts/python/experiments/sweep.py +164 -0
  20. package/template/skills/skillopt-sleep/scripts/python/gate.py +50 -0
  21. package/template/skills/skillopt-sleep/scripts/python/harvest.py +304 -0
  22. package/template/skills/skillopt-sleep/scripts/python/harvest_codex.py +253 -0
  23. package/template/skills/skillopt-sleep/scripts/python/harvest_sources.py +41 -0
  24. package/template/skills/skillopt-sleep/scripts/python/judges.py +84 -0
  25. package/template/skills/skillopt-sleep/scripts/python/llm_miner.py +134 -0
  26. package/template/skills/skillopt-sleep/scripts/python/memory.py +129 -0
  27. package/template/skills/skillopt-sleep/scripts/python/mine.py +312 -0
  28. package/template/skills/skillopt-sleep/scripts/python/replay.py +146 -0
  29. package/template/skills/skillopt-sleep/scripts/python/rollout.py +153 -0
  30. package/template/skills/skillopt-sleep/scripts/python/scheduler.py +138 -0
  31. package/template/skills/skillopt-sleep/scripts/python/slow_update.py +142 -0
  32. package/template/skills/skillopt-sleep/scripts/python/staging.py +103 -0
  33. package/template/skills/skillopt-sleep/scripts/python/state.py +96 -0
  34. package/template/skills/skillopt-sleep/scripts/python/tasks_file.py +81 -0
  35. package/template/skills/skillopt-sleep/scripts/python/types.py +146 -0
  36. package/template/skills/skillopt-sleep/scripts/shell/__init__.py +0 -0
  37. package/template/skills/skillopt-sleep/scripts/shell/eval_only.py +466 -0
  38. package/template/skills/skillopt-sleep/scripts/shell/materialize_searchqa.py +148 -0
  39. package/template/skills/skillopt-sleep/scripts/shell/run_alfworld.sh +60 -0
  40. package/template/skills/skillopt-sleep/scripts/shell/run_searchqa.sh +40 -0
  41. package/template/skills/skillopt-sleep/scripts/shell/run_spreadsheetbench.sh +39 -0
  42. package/template/skills/skillopt-sleep/scripts/shell/train.py +556 -0
  43. package/template/skills/ponytail/SKILL.md +0 -117
  44. package/template/skills/ponytail-audit/SKILL.md +0 -41
  45. package/template/skills/ponytail-debt/SKILL.md +0 -44
  46. package/template/skills/ponytail-gain/SKILL.md +0 -50
  47. package/template/skills/ponytail-help/SKILL.md +0 -69
  48. package/template/skills/ponytail-review/SKILL.md +0 -57
@@ -0,0 +1,164 @@
1
+ """SkillOpt-Sleep — benchmark sweep driver.
2
+
3
+ Runs many (backend, model, seed, transfer-pair) configurations SEQUENTIALLY in
4
+ one process, appending each result to a JSONL file as it finishes. Designed to
5
+ run unattended in the background; safe to interrupt (already-written rows
6
+ survive) and resume (skip configs whose row already exists).
7
+
8
+ Then `report.py` turns the JSONL into a presented Markdown scorecard.
9
+
10
+ Usage:
11
+ python -m skillopt_sleep.experiments.sweep --plan quick --out docs/sleep/sweep.jsonl
12
+ python -m skillopt_sleep.experiments.sweep --plan full --out docs/sleep/sweep.jsonl
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import argparse
17
+ import json
18
+ import os
19
+ import sys
20
+ import time
21
+ from typing import Any, Dict, List
22
+
23
+ from skillopt_sleep.backend import build_backend, get_backend
24
+ from skillopt_sleep.experiments.gbrain_bench import find_data_root, load_seed
25
+ from skillopt_sleep.experiments.run_gbrain import run_seed as bench_seed
26
+ from skillopt_sleep.experiments.run_transfer import run_seed as transfer_seed
27
+
28
+
29
+ # Plans: lists of config dicts. Kept small per-run to bound cost/latency.
30
+ def _direct_cfg(backend, model, seed, nights=2):
31
+ return {"kind": "direct", "backend": backend, "model": model, "seed": seed, "nights": nights}
32
+
33
+
34
+ def _dual_cfg(opt_backend, opt_model, tgt_backend, tgt_model, seed, nights=2):
35
+ # a 'direct' run on a DualBackend: strong optimizer proposes, weak target runs
36
+ return {"kind": "dual", "optimizer_backend": opt_backend, "optimizer_model": opt_model,
37
+ "target_backend": tgt_backend, "target_model": tgt_model, "seed": seed, "nights": nights}
38
+
39
+
40
+ def _transfer_cfg(sb, sm, tb, tm, seed, nights=2):
41
+ return {"kind": "transfer", "source_backend": sb, "source_model": sm,
42
+ "target_backend": tb, "target_model": tm, "seed": seed, "nights": nights}
43
+
44
+
45
+ PLANS: Dict[str, List[Dict[str, Any]]] = {
46
+ # one cheap seed each, both backends — fast sanity
47
+ "quick": [
48
+ _direct_cfg("claude", "haiku", "brief-writer", 1),
49
+ _direct_cfg("codex", "", "brief-writer", 2),
50
+ ],
51
+ # SkillOpt-faithful: STRONG optimizer (sonnet) proposes, WEAK target (haiku)
52
+ # runs — the reliable config. Plus Codex self-optimized. All 4 gbrain seeds,
53
+ # including quick-answerer (real tool loop).
54
+ "direct": [
55
+ _dual_cfg("claude", "sonnet", "claude", "haiku", "brief-writer"),
56
+ _dual_cfg("claude", "sonnet", "claude", "haiku", "advisor"),
57
+ _dual_cfg("claude", "sonnet", "claude", "haiku", "thorough-analyst"),
58
+ _dual_cfg("claude", "sonnet", "claude", "haiku", "quick-answerer"),
59
+ _direct_cfg("codex", "", "brief-writer"),
60
+ _direct_cfg("codex", "", "advisor"),
61
+ _direct_cfg("codex", "", "quick-answerer"),
62
+ ],
63
+ # the price-difference story: optimize cheap, deploy expensive (and reverse)
64
+ "transfer": [
65
+ _transfer_cfg("claude", "haiku", "claude", "sonnet", "brief-writer"),
66
+ _transfer_cfg("claude", "sonnet", "claude", "haiku", "brief-writer"),
67
+ _transfer_cfg("codex", "", "claude", "haiku", "brief-writer"),
68
+ _transfer_cfg("claude", "haiku", "codex", "", "brief-writer"),
69
+ ],
70
+ }
71
+ PLANS["full"] = PLANS["direct"] + PLANS["transfer"]
72
+
73
+
74
+ def _cfg_key(c: Dict[str, Any]) -> str:
75
+ return json.dumps({k: c[k] for k in sorted(c)}, ensure_ascii=False)
76
+
77
+
78
+ def _load_done(out_path: str) -> set:
79
+ done = set()
80
+ if os.path.exists(out_path):
81
+ with open(out_path) as f:
82
+ for line in f:
83
+ try:
84
+ row = json.loads(line)
85
+ if "cfg_key" in row:
86
+ done.add(row["cfg_key"])
87
+ except Exception:
88
+ pass
89
+ return done
90
+
91
+
92
+ def _append(out_path: str, row: Dict[str, Any]) -> None:
93
+ os.makedirs(os.path.dirname(out_path) or ".", exist_ok=True)
94
+ with open(out_path, "a") as f:
95
+ f.write(json.dumps(row, ensure_ascii=False) + "\n")
96
+
97
+
98
+ def run_one(cfg: Dict[str, Any], data_root: str, codex_path: str,
99
+ limit_replay: int, limit_holdout: int) -> Dict[str, Any]:
100
+ seed = cfg["seed"]
101
+ skill, tasks = load_seed(data_root, seed)
102
+ t0 = time.time()
103
+ if cfg["kind"] in ("direct", "dual"):
104
+ if cfg["kind"] == "dual":
105
+ be = build_backend(
106
+ optimizer_backend=cfg["optimizer_backend"], optimizer_model=cfg.get("optimizer_model", ""),
107
+ target_backend=cfg["target_backend"], target_model=cfg.get("target_model", ""),
108
+ codex_path=codex_path,
109
+ )
110
+ else:
111
+ be = get_backend(cfg["backend"], model=cfg.get("model", ""), codex_path=codex_path)
112
+ r = bench_seed(be, seed, skill, tasks, nights=cfg["nights"],
113
+ limit_replay=limit_replay, limit_holdout=limit_holdout)
114
+ out = {"baseline": r["held_out_before"], "after": r["held_out_after"],
115
+ "improved": r["improved"], "tokens": be.tokens_used()}
116
+ else:
117
+ src = get_backend(cfg["source_backend"], model=cfg.get("source_model", ""), codex_path=codex_path)
118
+ tgt = get_backend(cfg["target_backend"], model=cfg.get("target_model", ""), codex_path=codex_path)
119
+ r = transfer_seed(seed, skill, tasks, source=src, target=tgt, nights=cfg["nights"],
120
+ edit_budget=4, limit_replay=limit_replay, limit_holdout=limit_holdout,
121
+ do_direct=False)
122
+ out = {"baseline_target": r["baseline_target"], "transferred": r["transferred"],
123
+ "transfer_gain": r["transfer_gain"],
124
+ "tokens": src.tokens_used() + tgt.tokens_used()}
125
+ out.update({"cfg": cfg, "cfg_key": _cfg_key(cfg), "elapsed_s": round(time.time() - t0, 1)})
126
+ return out
127
+
128
+
129
+ def main(argv=None) -> int:
130
+ ap = argparse.ArgumentParser(description="SkillOpt-Sleep benchmark sweep")
131
+ ap.add_argument("--plan", default="quick", choices=list(PLANS.keys()))
132
+ ap.add_argument("--out", default="docs/sleep/sweep.jsonl")
133
+ ap.add_argument("--data-root", default="")
134
+ ap.add_argument("--codex-path", default="")
135
+ ap.add_argument("--limit-replay", type=int, default=3)
136
+ ap.add_argument("--limit-holdout", type=int, default=3)
137
+ args = ap.parse_args(argv)
138
+
139
+ data_root = find_data_root(args.data_root)
140
+ if not data_root:
141
+ print("ERROR: gbrain-evals data not found; pass --data-root", file=sys.stderr)
142
+ return 2
143
+
144
+ plan = PLANS[args.plan]
145
+ done = _load_done(args.out)
146
+ print(f"[sweep] plan={args.plan} configs={len(plan)} already_done={len(done)} -> {args.out}")
147
+ for i, cfg in enumerate(plan, 1):
148
+ key = _cfg_key(cfg)
149
+ if key in done:
150
+ print(f"[sweep] ({i}/{len(plan)}) skip (done): {cfg}")
151
+ continue
152
+ print(f"[sweep] ({i}/{len(plan)}) running: {cfg}", flush=True)
153
+ try:
154
+ row = run_one(cfg, data_root, args.codex_path, args.limit_replay, args.limit_holdout)
155
+ except Exception as e: # never let one config kill the sweep
156
+ row = {"cfg": cfg, "cfg_key": key, "error": f"{type(e).__name__}: {e}"}
157
+ _append(args.out, row)
158
+ print(f"[sweep] -> {json.dumps({k: v for k, v in row.items() if k not in ('cfg','cfg_key')})}", flush=True)
159
+ print(f"[sweep] done. rows in {args.out}: {len(_load_done(args.out))}")
160
+ return 0
161
+
162
+
163
+ if __name__ == "__main__":
164
+ sys.exit(main())
@@ -0,0 +1,50 @@
1
+ """SkillOpt-Sleep — vendored validation gate.
2
+
3
+ This is a self-contained copy of the SkillOpt validation gate so the sleep
4
+ engine has ZERO dependency on the research package (skillopt/*). The research
5
+ repo's ``skillopt.evaluation.gate`` is the reference implementation and the two
6
+ are kept behaviourally identical; vendoring keeps this open-source tool
7
+ decoupled from the paper's experiment code.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class GateResult:
16
+ action: str # "accept_new_best" | "accept" | "reject"
17
+ current_skill: str
18
+ current_score: float
19
+ best_skill: str
20
+ best_score: float
21
+ best_step: int
22
+
23
+
24
+ def select_gate_score(hard: float, soft: float, metric: str = "hard",
25
+ mixed_weight: float = 0.5) -> float:
26
+ """Project (hard, soft) onto a single comparison metric."""
27
+ if metric == "hard":
28
+ return float(hard)
29
+ if metric == "soft":
30
+ return float(soft)
31
+ if metric == "mixed":
32
+ w = max(0.0, min(1.0, float(mixed_weight)))
33
+ return (1.0 - w) * float(hard) + w * float(soft)
34
+ raise ValueError(f"unknown gate metric {metric!r}; expected hard/soft/mixed")
35
+
36
+
37
+ def evaluate_gate(candidate_skill: str, cand_hard: float, current_skill: str,
38
+ current_score: float, best_skill: str, best_score: float,
39
+ best_step: int, global_step: int, *, cand_soft: float = 0.0,
40
+ metric: str = "hard", mixed_weight: float = 0.5) -> GateResult:
41
+ """Pure gate decision: compare candidate score to current/best."""
42
+ cand_score = select_gate_score(cand_hard, cand_soft, metric, mixed_weight)
43
+ if cand_score > current_score:
44
+ if cand_score > best_score:
45
+ return GateResult("accept_new_best", candidate_skill, cand_score,
46
+ candidate_skill, cand_score, global_step)
47
+ return GateResult("accept", candidate_skill, cand_score,
48
+ best_skill, best_score, best_step)
49
+ return GateResult("reject", current_skill, current_score,
50
+ best_skill, best_score, best_step)
@@ -0,0 +1,304 @@
1
+ """SkillOpt-Sleep — Stage 1: harvest.
2
+
3
+ Read the user's local Claude Code records (read-only) and normalize them
4
+ into :class:`SessionDigest` objects.
5
+
6
+ Sources (verified schema):
7
+ * ~/.claude/history.jsonl — one JSON/line:
8
+ {"display": <prompt text>, "pastedContents": {...},
9
+ "timestamp": <epoch ms>, "project": <abs path>}
10
+ * ~/.claude/projects/<slug>/<sessionId>.jsonl — one record/line; the
11
+ records we care about have type "user"/"assistant" and carry:
12
+ message{role, content}, cwd, gitBranch, timestamp, sessionId, version
13
+
14
+ This module performs NO writes and NO network calls.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ import os
20
+ from datetime import datetime
21
+ from typing import Any, Dict, Iterable, List, Optional
22
+
23
+ from skillopt_sleep.types import SessionDigest
24
+
25
+
26
+ # Heuristic phrases that signal the user (dis)approving of prior output.
27
+ # English-only by default. Users whose sessions are in another language can add
28
+ # their own phrases via the SKILLOPT_SLEEP_NEG_FEEDBACK / _POS_FEEDBACK env vars
29
+ # (comma-separated), so the capability is extensible without hardcoding locales.
30
+ _NEGATIVE_FEEDBACK = (
31
+ "still broken", "still not", "still wrong", "doesn't work", "does not work",
32
+ "not working", "that's wrong", "thats wrong", "incorrect", "wrong",
33
+ "no,", "nope", "fix it", "didn't", "did not", "broken", "error again",
34
+ "still failing", "still fails", "not fixed", "revert", "undo",
35
+ )
36
+ _POSITIVE_FEEDBACK = (
37
+ "thanks", "thank you", "perfect", "great", "works now", "fixed",
38
+ "that works", "lgtm", "looks good", "nice", "awesome", "correct",
39
+ )
40
+
41
+
42
+ def _extra_phrases(env_var: str) -> tuple:
43
+ raw = os.environ.get(env_var, "")
44
+ return tuple(p.strip().lower() for p in raw.split(",") if p.strip())
45
+
46
+
47
+ _NEGATIVE_FEEDBACK = _NEGATIVE_FEEDBACK + _extra_phrases("SKILLOPT_SLEEP_NEG_FEEDBACK")
48
+ _POSITIVE_FEEDBACK = _POSITIVE_FEEDBACK + _extra_phrases("SKILLOPT_SLEEP_POS_FEEDBACK")
49
+
50
+
51
+ def _iter_jsonl(path: str) -> Iterable[Dict[str, Any]]:
52
+ try:
53
+ with open(path, encoding="utf-8") as f:
54
+ for line in f:
55
+ line = line.strip()
56
+ if not line:
57
+ continue
58
+ try:
59
+ yield json.loads(line)
60
+ except Exception:
61
+ continue
62
+ except (FileNotFoundError, IsADirectoryError, PermissionError):
63
+ return
64
+
65
+
66
+ def _text_from_content(content: Any) -> str:
67
+ """Flatten a message.content (str or list of blocks) into text."""
68
+ if isinstance(content, str):
69
+ return content
70
+ if isinstance(content, list):
71
+ parts: List[str] = []
72
+ for b in content:
73
+ if isinstance(b, dict):
74
+ if b.get("type") == "text" and b.get("text"):
75
+ parts.append(str(b["text"]))
76
+ return "\n".join(parts)
77
+ return ""
78
+
79
+
80
+ def _tool_names_from_content(content: Any) -> List[str]:
81
+ names: List[str] = []
82
+ if isinstance(content, list):
83
+ for b in content:
84
+ if isinstance(b, dict) and b.get("type") == "tool_use" and b.get("name"):
85
+ names.append(str(b["name"]))
86
+ return names
87
+
88
+
89
+ def _detect_feedback(text: str) -> List[str]:
90
+ low = text.lower()
91
+ sig: List[str] = []
92
+ for ph in _NEGATIVE_FEEDBACK:
93
+ if ph in low:
94
+ sig.append("neg:" + ph)
95
+ for ph in _POSITIVE_FEEDBACK:
96
+ if ph in low:
97
+ sig.append("pos:" + ph)
98
+ return sig
99
+
100
+
101
+ def _is_meta_prompt(text: str) -> bool:
102
+ """Skip slash-commands / system noise that aren't real user intents."""
103
+ t = text.strip()
104
+ if not t:
105
+ return True
106
+ if t.startswith("<") and t.endswith(">"):
107
+ return True
108
+ if t.startswith("/") and len(t.split()) <= 3:
109
+ return True
110
+ if t.startswith("[Pasted text") or t.startswith("Caveat:"):
111
+ return True
112
+ return False
113
+
114
+
115
+ # ── Issue #62: filter headless replay sessions ─────────────────────────
116
+
117
+ # Prompt markers generated by the engine's own headless `claude -p` calls
118
+ # (judge, reflect, attempt). If the sole user prompt in a single-turn
119
+ # session matches any of these, the session is engine-generated, not a
120
+ # real user task.
121
+ _REPLAY_PROMPT_MARKERS = (
122
+ "## CURRENT SKILL",
123
+ "## FAILED TASKS",
124
+ "## SUCCESSFUL TASKS",
125
+ "## OUTPUT FORMAT",
126
+ "You are a strict grader",
127
+ "Score the response 0.0-1.0",
128
+ "You are SkillOpt-Sleep",
129
+ "## TASK\n",
130
+ "## SKILL\n",
131
+ )
132
+
133
+
134
+ def _is_headless_replay(digest: "SessionDigest") -> bool:
135
+ """Detect sessions created by the engine's own headless replay calls.
136
+
137
+ Heuristics (conservatively applied):
138
+ 1. Session has exactly 1 user turn AND
139
+ 2. The sole prompt matches engine-generated patterns (grader/reflect),
140
+ OR the session lasted < 3 seconds (programmatic, not interactive).
141
+ Multi-turn sessions are always kept (interactive by definition).
142
+ """
143
+ if digest.n_user_turns > 1:
144
+ return False
145
+ if digest.n_user_turns == 0:
146
+ return True
147
+ prompt = digest.user_prompts[0] if digest.user_prompts else ""
148
+ for marker in _REPLAY_PROMPT_MARKERS:
149
+ if marker in prompt:
150
+ return True
151
+ # Sub-3-second single-turn sessions with short prompts are almost
152
+ # certainly programmatic (engine grader/judge calls). We require the
153
+ # prompt to also be short (<200 chars) to avoid false-positives on
154
+ # real one-shot questions that Claude happens to answer quickly.
155
+ if digest.started_at and digest.ended_at and len(prompt) < 200:
156
+ try:
157
+ fmt = "%Y-%m-%dT%H:%M:%S"
158
+ start = datetime.strptime(digest.started_at[:19], fmt)
159
+ end = datetime.strptime(digest.ended_at[:19], fmt)
160
+ if (end - start).total_seconds() < 3:
161
+ return True
162
+ except (ValueError, TypeError):
163
+ pass
164
+ return False
165
+
166
+
167
+ def digest_transcript(path: str) -> Optional[SessionDigest]:
168
+ """Build a SessionDigest from one ``<sessionId>.jsonl`` transcript."""
169
+ session_id = os.path.splitext(os.path.basename(path))[0]
170
+ project = ""
171
+ git_branch = ""
172
+ started = ""
173
+ ended = ""
174
+ user_prompts: List[str] = []
175
+ assistant_finals: List[str] = []
176
+ tools: List[str] = []
177
+ files: List[str] = []
178
+ feedback: List[str] = []
179
+ n_user = 0
180
+ n_asst = 0
181
+
182
+ for rec in _iter_jsonl(path):
183
+ rtype = rec.get("type")
184
+ ts = rec.get("timestamp")
185
+ if isinstance(ts, str) and ts:
186
+ if not started:
187
+ started = ts
188
+ ended = ts
189
+ if rec.get("cwd") and not project:
190
+ project = str(rec.get("cwd"))
191
+ if rec.get("gitBranch") and not git_branch:
192
+ git_branch = str(rec.get("gitBranch"))
193
+ if rtype == "file-history-snapshot":
194
+ snap = rec.get("snapshot") or rec.get("files") or {}
195
+ if isinstance(snap, dict):
196
+ files.extend([str(k) for k in list(snap.keys())[:20]])
197
+ msg = rec.get("message")
198
+ if not isinstance(msg, dict):
199
+ continue
200
+ role = msg.get("role")
201
+ content = msg.get("content")
202
+ if role == "user":
203
+ text = _text_from_content(content)
204
+ if text and not _is_meta_prompt(text):
205
+ n_user += 1
206
+ user_prompts.append(text.strip())
207
+ feedback.extend(_detect_feedback(text))
208
+ elif role == "assistant":
209
+ n_asst += 1
210
+ tools.extend(_tool_names_from_content(content))
211
+ text = _text_from_content(content)
212
+ if text.strip():
213
+ assistant_finals.append(text.strip())
214
+
215
+ if n_user == 0 and n_asst == 0:
216
+ return None
217
+
218
+ # de-dup tools/files preserving order
219
+ def _dedup(xs: List[str]) -> List[str]:
220
+ seen = set()
221
+ out = []
222
+ for x in xs:
223
+ if x not in seen:
224
+ seen.add(x)
225
+ out.append(x)
226
+ return out
227
+
228
+ return SessionDigest(
229
+ session_id=session_id,
230
+ project=project,
231
+ git_branch=git_branch,
232
+ started_at=started,
233
+ ended_at=ended,
234
+ user_prompts=user_prompts,
235
+ assistant_finals=assistant_finals[-5:], # last few finals are the useful ones
236
+ tools_used=_dedup(tools),
237
+ files_touched=_dedup(files),
238
+ feedback_signals=feedback,
239
+ n_user_turns=n_user,
240
+ n_assistant_turns=n_asst,
241
+ raw_path=path,
242
+ )
243
+
244
+
245
+ def _project_matches(project: str, scope: Any, invoked: str) -> bool:
246
+ if scope == "all":
247
+ return True
248
+ if isinstance(scope, (list, tuple)):
249
+ return any(os.path.abspath(project) == os.path.abspath(p) for p in scope)
250
+ # "invoked": match the invoked project (or a subdir of it)
251
+ if not invoked:
252
+ return True
253
+ a = os.path.abspath(project)
254
+ b = os.path.abspath(invoked)
255
+ return a == b or a.startswith(b + os.sep) or b.startswith(a + os.sep)
256
+
257
+
258
+ def harvest(
259
+ transcripts_dir: str,
260
+ *,
261
+ scope: Any = "all",
262
+ invoked_project: str = "",
263
+ since_iso: Optional[str] = None,
264
+ limit: int = 0,
265
+ ) -> List[SessionDigest]:
266
+ """Walk ~/.claude/projects and return digests matching scope/time.
267
+
268
+ Parameters
269
+ ----------
270
+ transcripts_dir : str ~/.claude/projects
271
+ scope : "all" | "invoked" | list[path]
272
+ invoked_project : str used when scope == "invoked"
273
+ since_iso : str|None ISO8601; only sessions ending after this are kept
274
+ limit : int cap number of digests (0 = no cap)
275
+ """
276
+ digests: List[SessionDigest] = []
277
+ if not os.path.isdir(transcripts_dir):
278
+ return digests
279
+
280
+ paths: List[str] = []
281
+ for root, _dirs, files in os.walk(transcripts_dir):
282
+ for fn in files:
283
+ if fn.endswith(".jsonl"):
284
+ paths.append(os.path.join(root, fn))
285
+ # newest first by mtime
286
+ paths.sort(key=lambda p: os.path.getmtime(p), reverse=True)
287
+
288
+ for p in paths:
289
+ d = digest_transcript(p)
290
+ if d is None:
291
+ continue
292
+ if _is_headless_replay(d):
293
+ continue # Issue #62: skip engine's own headless replay sessions
294
+ if not _project_matches(d.project or "", scope, invoked_project):
295
+ continue
296
+ if since_iso and d.ended_at and d.ended_at < since_iso:
297
+ # Note: files are sorted by mtime but we compare the embedded
298
+ # ended_at timestamp — mtime can diverge (copy/touch), so we
299
+ # cannot break here; we must continue to check all files.
300
+ continue
301
+ digests.append(d)
302
+ if limit and len(digests) >= limit:
303
+ break
304
+ return digests