dsh-open-dream-rsi 0.3.8 → 0.3.9

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.
@@ -46,7 +46,7 @@ from open_dream_rsi.bench_policy import (
46
46
  run_policy_benchmark,
47
47
  )
48
48
 
49
- __version__ = "0.3.8"
49
+ __version__ = "0.3.9"
50
50
 
51
51
  __all__ = [
52
52
  "DiscoveryTree",
@@ -57,7 +57,7 @@ def _make_client(provider: str, model: Optional[str]) -> Any:
57
57
  def _default_tasks() -> List[Task]:
58
58
  from open_dream_rsi.dashboard import DEMOS
59
59
 
60
- return [Task(max_attempts=3, **t) for t in DEMOS]
60
+ return [Task(**{**t, "max_attempts": 3}) for t in DEMOS]
61
61
 
62
62
 
63
63
  def run_arm(
@@ -72,7 +72,6 @@ class TrapSuite:
72
72
  promising: str
73
73
  fix: str
74
74
  low: str
75
- low: str
76
75
 
77
76
 
78
77
  TRAP_SUITES: List[TrapSuite] = [
@@ -109,6 +109,7 @@ def cmd_sessions(args: argparse.Namespace) -> int:
109
109
  from open_dream_rsi.sessions import (
110
110
  episode_failures,
111
111
  load_episodes,
112
+ read_claude_code_sessions,
112
113
  read_cursor_sessions,
113
114
  read_hermes_sessions,
114
115
  write_episodes,
@@ -120,6 +121,9 @@ def cmd_sessions(args: argparse.Namespace) -> int:
120
121
  source=args.filter,
121
122
  limit=args.limit,
122
123
  redact=not args.no_redact)
124
+ elif args.source == "claude":
125
+ eps = read_claude_code_sessions(args.db, limit=args.limit,
126
+ redact=not args.no_redact)
123
127
  else:
124
128
  # --db accepts either the Cursor User dir or a state.vscdb copy
125
129
  db_arg = args.db
@@ -243,7 +247,8 @@ def cmd_dream(args: argparse.Namespace) -> int:
243
247
  cost), so any trigger — a Hermes hook, the MCP tool, or this CLI —
244
248
  can fire as often as it likes without overrunning the budget."""
245
249
  from open_dream_rsi.dream import dream_once
246
- from open_dream_rsi.sessions import default_cursor_user_dir
250
+ from open_dream_rsi.sessions import (default_claude_projects_dir,
251
+ default_cursor_user_dir)
247
252
 
248
253
  client = None
249
254
  if not args.offline:
@@ -252,6 +257,9 @@ def cmd_dream(args: argparse.Namespace) -> int:
252
257
  # the Cursor store is a User DIR (state.vscdb inside), not a db file
253
258
  sessions_db = args.sessions or args.cursor_user_dir \
254
259
  or str(default_cursor_user_dir())
260
+ elif args.source == "claude":
261
+ # Claude Code transcripts live in a projects DIR of JSONL files
262
+ sessions_db = args.sessions or str(default_claude_projects_dir())
255
263
  else:
256
264
  sessions_db = args.sessions
257
265
  report = dream_once(
@@ -264,10 +272,12 @@ def cmd_dream(args: argparse.Namespace) -> int:
264
272
 
265
273
 
266
274
  def cmd_lessons(args: argparse.Namespace) -> int:
267
- """Git-backed lesson stores: publish active lessons, import others'
275
+ """Git-backed stores: publish lessons/worlds/policies, import others'
268
276
  knowledge as staging (see README 'Sharing lessons')."""
269
277
  from open_dream_rsi.share import (LessonShareError, export_lessons,
270
- import_lessons, store_status)
278
+ export_policies, export_worlds,
279
+ import_lessons, import_policies,
280
+ import_worlds, store_status)
271
281
 
272
282
  memory = DreamMemory(args.memory)
273
283
  try:
@@ -284,6 +294,31 @@ def cmd_lessons(args: argparse.Namespace) -> int:
284
294
  print(f"[odr] {cat}: +{n} staging lesson(s) — activate via "
285
295
  f"the paired-replay gate (odr loop)")
286
296
  return 0
297
+ elif args.lessons_cmd == "worlds-export":
298
+ st = export_worlds(memory, args.store, task_ids=args.task,
299
+ message=args.message, push=not args.no_push)
300
+ elif args.lessons_cmd == "worlds-import":
301
+ counts = import_worlds(args.store, memory, task_ids=args.task)
302
+ if not counts:
303
+ print("[odr] nothing imported (empty/filtered store)")
304
+ return 0
305
+ for tid, n in sorted(counts.items()):
306
+ print(f"[odr] {tid}: imported world ({n} nodes) -> "
307
+ f"trees/imported/{tid}.json (evidence, never a live task)")
308
+ return 0
309
+ elif args.lessons_cmd == "policies-export":
310
+ st = export_policies(memory, args.store, categories=args.category,
311
+ message=args.message, push=not args.no_push)
312
+ elif args.lessons_cmd == "policies-import":
313
+ counts = import_policies(args.store, memory,
314
+ categories=args.category)
315
+ if not counts:
316
+ print("[odr] nothing imported (empty/filtered store)")
317
+ return 0
318
+ for cat, n in sorted(counts.items()):
319
+ print(f"[odr] {cat}: +{n} policy program(s) registered as "
320
+ f"CANDIDATE->VALIDATED in the ALM (adoption stays local)")
321
+ return 0
287
322
  else: # status
288
323
  st = store_status(args.store)
289
324
  except LessonShareError as exc:
@@ -294,13 +329,59 @@ def cmd_lessons(args: argparse.Namespace) -> int:
294
329
  else:
295
330
  print(f"[odr] store: {st.location} "
296
331
  f"({'git' if st.git else 'plain dir'}, ok={st.ok})")
332
+ label = {"export": "lesson(s)", "worlds-export": "node(s)",
333
+ "policies-export": "program(s)"}.get(args.lessons_cmd, "entry")
297
334
  for cat, n in sorted(st.categories.items()):
298
- print(f" {cat}: {n} lesson(s)")
335
+ if ":" in cat: # status mixes sections; derive the label per key
336
+ label = {"world": "node(s)", "policy": "program(s)"}[
337
+ cat.split(":", 1)[0]]
338
+ print(f" {cat}: {n} {label}")
339
+ else:
340
+ print(f" {cat}: {n} {label}")
299
341
  for e in st.errors:
300
342
  print(f" ! {e}")
301
343
  return 0 if st.ok else 1
302
344
 
303
345
 
346
+ def cmd_holdout(args: argparse.Namespace) -> int:
347
+ """Single-use holdout reservations: a preregistered eval task is consumed
348
+ once; a later phase cannot silently reuse it (see README 'Holdout hygiene')."""
349
+ from open_dream_rsi.holdout import HoldoutRegistry, split_reserved
350
+
351
+ reg = HoldoutRegistry(args.memory)
352
+ if args.holdout_cmd == "claim":
353
+ refused = [t for t in args.ids if not reg.claim(t, args.purpose,
354
+ phase=args.phase)]
355
+ ok = [t for t in args.ids if t not in refused]
356
+ print(f"[odr] reserved {len(ok)} task id(s) for '{args.purpose}'"
357
+ + (f" (phase {args.phase})" if args.phase else ""))
358
+ if refused:
359
+ print(f"[odr] REFUSED (already held for another purpose): "
360
+ f"{', '.join(refused)}", file=sys.stderr)
361
+ return 1
362
+ return 0
363
+ if args.holdout_cmd == "release":
364
+ released = [t for t in args.ids if reg.release(t)]
365
+ print(f"[odr] released {len(released)} reservation(s): "
366
+ f"{', '.join(released) or '(none)'}")
367
+ return 0
368
+ if args.holdout_cmd == "show":
369
+ entries = reg.entries()
370
+ if not entries:
371
+ print("[odr] no reservations")
372
+ for tid, meta in sorted(entries.items()):
373
+ print(f" {tid} purpose={meta.get('purpose')}"
374
+ + (f" phase={meta['phase']}" if meta.get("phase") else ""))
375
+ return 0
376
+ # split: report which tasks of a queue file are holdout-reserved
377
+ tasks = json.loads(Path(args.tasks).read_text(encoding="utf-8"))
378
+ allowed, held = split_reserved(tasks, reg)
379
+ print(f"[odr] {len(allowed)} allowed, {len(held)} holdout-reserved")
380
+ for t in held:
381
+ print(f" held: {t.get('task_id') if isinstance(t, dict) else t}")
382
+ return 0
383
+
384
+
304
385
  def cmd_skills(args: argparse.Namespace) -> int:
305
386
  """Render the curated lesson KB into Hermes-style skills (issue #3
306
387
  boundary: only lessons that EARNED activation become a prompt)."""
@@ -399,9 +480,11 @@ def main(argv: list[str] | None = None) -> int:
399
480
  dr.add_argument("--sessions", default=None,
400
481
  help="host session store to import evidence from "
401
482
  "(hermes: state.db path; cursor: the User dir)")
402
- dr.add_argument("--source", default="hermes", choices=["hermes", "cursor"],
483
+ dr.add_argument("--source", default="hermes",
484
+ choices=["hermes", "cursor", "claude"],
403
485
  help="kind of the --sessions store (default hermes; "
404
- "cursor reads state.vscdb directly, no MCP needed)")
486
+ "cursor reads state.vscdb directly, no MCP needed; "
487
+ "claude reads ~/.claude/projects JSONL)")
405
488
  dr.add_argument("--cursor-user-dir", default=None,
406
489
  help="Cursor User dir for --source cursor (default: the "
407
490
  "platform location, e.g. %%APPDATA%%\\Cursor\\User)")
@@ -459,16 +542,19 @@ def main(argv: list[str] | None = None) -> int:
459
542
 
460
543
  sess = sub.add_parser(
461
544
  "sessions",
462
- help="import host transcripts (Hermes state.db / Cursor state.vscdb) "
545
+ help="import host transcripts (Hermes state.db / Cursor "
546
+ "state.vscdb / Claude Code projects JSONL) "
463
547
  "as curator evidence — export to JSONL, then distill into "
464
548
  "staging lessons (promotion gate still decides activation)")
465
549
  sess_sub = sess.add_subparsers(dest="sessions_cmd", required=True)
466
550
 
467
551
  exp = sess_sub.add_parser("export", help="read a host store, write episodes.jsonl")
468
- exp.add_argument("--source", required=True, choices=["hermes", "cursor"])
552
+ exp.add_argument("--source", required=True,
553
+ choices=["hermes", "cursor", "claude"])
469
554
  exp.add_argument("--db", default=None,
470
- help="path to state.db (hermes) or Cursor User dir "
471
- "(default: platform Cursor location)")
555
+ help="path to state.db (hermes), the Cursor User dir, "
556
+ "or the Claude Code projects dir (default: the "
557
+ "platform location)")
472
558
  exp.add_argument("--filter", default=None,
473
559
  help="hermes only: session source filter (e.g. cli, desktop)")
474
560
  exp.add_argument("--limit", type=int, default=None)
@@ -532,11 +618,74 @@ def main(argv: list[str] | None = None) -> int:
532
618
  limp.add_argument("--category", action="append", default=None)
533
619
  limp.set_defaults(func=cmd_lessons)
534
620
 
621
+ wexp = ls_sub.add_parser("worlds-export",
622
+ help="add archived discovery trees to a store")
623
+ wexp.add_argument("--store", required=True,
624
+ help="local dir or git URL")
625
+ wexp.add_argument("--task", action="append", default=None,
626
+ help="limit to these task ids (repeatable)")
627
+ wexp.add_argument("--message", default=None, help="git commit message")
628
+ wexp.add_argument("--no-push", action="store_true",
629
+ help="commit locally but do not push")
630
+ wexp.set_defaults(func=cmd_lessons)
631
+
632
+ wimp = ls_sub.add_parser("worlds-import",
633
+ help="import a store's worlds as evidence "
634
+ "(trees/imported/, never live tasks)")
635
+ wimp.add_argument("--store", required=True, help="local dir or git URL")
636
+ wimp.add_argument("--task", action="append", default=None,
637
+ help="limit to these task ids (repeatable)")
638
+ wimp.set_defaults(func=cmd_lessons)
639
+
640
+ pexp = ls_sub.add_parser("policies-export",
641
+ help="add promoted exploration-policy programs "
642
+ "(structural gate applies on export)")
643
+ pexp.add_argument("--store", required=True, help="local dir or git URL")
644
+ pexp.add_argument("--category", action="append", default=None,
645
+ help="limit to these categories (repeatable)")
646
+ pexp.add_argument("--message", default=None, help="git commit message")
647
+ pexp.add_argument("--no-push", action="store_true",
648
+ help="commit locally but do not push")
649
+ pexp.set_defaults(func=cmd_lessons)
650
+
651
+ pimp = ls_sub.add_parser("policies-import",
652
+ help="register a store's policy programs as "
653
+ "ALM CANDIDATE->VALIDATED (never incumbents)")
654
+ pimp.add_argument("--store", required=True, help="local dir or git URL")
655
+ pimp.add_argument("--category", action="append", default=None)
656
+ pimp.set_defaults(func=cmd_lessons)
657
+
535
658
  lst = ls_sub.add_parser("status", help="verify manifest + checksums")
536
659
  lst.add_argument("--store", required=True)
537
660
  lst.add_argument("--json", action="store_true")
538
661
  lst.set_defaults(func=cmd_lessons)
539
662
 
663
+ hld = sub.add_parser(
664
+ "holdout",
665
+ help="single-use holdout reservations — a preregistered eval task "
666
+ "is consumed once; later phases cannot silently reuse it")
667
+ hld_sub = hld.add_subparsers(dest="holdout_cmd", required=True)
668
+
669
+ hc = hld_sub.add_parser("claim", help="reserve task ids for a purpose")
670
+ hc.add_argument("--ids", nargs="+", required=True, help="task id(s)")
671
+ hc.add_argument("--purpose", required=True,
672
+ help="e.g. travelplanner_eval, spolki_holdout")
673
+ hc.add_argument("--phase", default=None,
674
+ help="optional phase label recorded with the reservation")
675
+ hc.set_defaults(func=cmd_holdout)
676
+
677
+ hr = hld_sub.add_parser("release", help="forget a reservation (correction path)")
678
+ hr.add_argument("--ids", nargs="+", required=True)
679
+ hr.set_defaults(func=cmd_holdout)
680
+
681
+ hs = hld_sub.add_parser("show", help="list all reservations")
682
+ hs.set_defaults(func=cmd_holdout)
683
+
684
+ hsplit = hld_sub.add_parser(
685
+ "split", help="partition a task queue into allowed vs held-back")
686
+ hsplit.add_argument("--tasks", required=True, help="tasks JSON file")
687
+ hsplit.set_defaults(func=cmd_holdout)
688
+
540
689
  dash = sub.add_parser("dashboard", help="serve the live web dashboard")
541
690
  dash.add_argument("--host", default="0.0.0.0",
542
691
  help="bind address (default 0.0.0.0 — reachable on the LAN)")
@@ -88,7 +88,8 @@ LESSON_CONTRACT = (
88
88
  "Reply with ONLY one fenced ```json``` block containing an array of "
89
89
  "objects, each exactly: {{\"trigger\": \"...\", \"text\": \"...\"}}. "
90
90
  "'trigger' is 1-4 lowercase keywords tying the lesson to this failure "
91
- "family; 'text' is one actionable insight in at most 3000 characters, "
91
+ "family — separate the keywords with SINGLE SPACES, never commas or "
92
+ "semicolons; 'text' is one actionable insight in at most 3000 characters, "
92
93
  "grounded ONLY in the evidence shown. Be SPECIFIC, not terse: name the "
93
94
  "observable signal (the exact error shape, tool, or state that "
94
95
  "recurs), the mechanism that made the attempt fail, and the bounded "
@@ -144,18 +145,28 @@ def validate_lesson_items(items: Any, max_lessons: int) -> List[Dict[str, str]]:
144
145
  raise LessonValidationError("payload is not a JSON array")
145
146
  out: List[Dict[str, str]] = []
146
147
  seen = set()
148
+ reasons: List[str] = []
147
149
  for it in items:
148
150
  if not isinstance(it, dict):
149
- raise LessonValidationError("lesson entry is not an object")
151
+ reasons.append("entry is not an object")
152
+ continue
150
153
  trig = str(it.get("trigger", "")).strip()
151
154
  text = str(it.get("text", "")).strip()
152
155
  words = normalize(trig).split()
153
156
  if not (MIN_TRIGGER_WORDS <= len(words) <= MAX_TRIGGER_WORDS):
154
- raise LessonValidationError(
155
- f"trigger must be {MIN_TRIGGER_WORDS}-{MAX_TRIGGER_WORDS} words, got {trig!r}")
157
+ # Tolerate comma/semicolon-separated keyword lists (LLMs produce
158
+ # "traceback, timeout" despite the contract): fold to the
159
+ # fragments when the joined form fits the word budget.
160
+ folded = " ".join(w for w in re.split(r"[,;/]+", trig) if w.strip())
161
+ if MIN_TRIGGER_WORDS <= len(folded.split()) <= MAX_TRIGGER_WORDS:
162
+ trig = folded
163
+ words = folded.split()
164
+ else:
165
+ reasons.append(f"trigger width {trig!r}")
166
+ continue
156
167
  if not (20 <= len(text) <= MAX_LESSON_CHARS):
157
- raise LessonValidationError(
158
- f"lesson text must be 20-{MAX_LESSON_CHARS} chars, got {len(text)}")
168
+ reasons.append(f"text len {len(text)}")
169
+ continue
159
170
  key = lesson_key({"trigger": trig, "text": text})
160
171
  if key in seen:
161
172
  continue
@@ -163,6 +174,11 @@ def validate_lesson_items(items: Any, max_lessons: int) -> List[Dict[str, str]]:
163
174
  out.append({"trigger": trig, "text": text})
164
175
  if len(out) >= max_lessons:
165
176
  break
177
+ if not out:
178
+ # All-or-nothing (the old behaviour) destroyed mixed payloads: one
179
+ # malformed trigger discarded every good lesson with it. Raise only
180
+ # when nothing is salvageable; the message feeds the repair loop.
181
+ raise LessonValidationError("; ".join(reasons[:3]) or "empty payload")
166
182
  return out
167
183
 
168
184
 
@@ -359,7 +375,9 @@ def lesson_gate_verdict(results: Dict[str, List[Tuple[bool, bool]]]) -> GateVerd
359
375
  unbounded exploration instruction must never activate on a greedy
360
376
  solver no matter what a small sample says.
361
377
  """
362
- promoted, rejected, detail = [], [], {}
378
+ promoted: List[str] = []
379
+ rejected: List[str] = []
380
+ detail: Dict[str, Any] = {}
363
381
  for key, pairs in results.items():
364
382
  gains = sum(1 for w, wo in pairs if w and not wo)
365
383
  regs = sum(1 for w, wo in pairs if wo and not w)
@@ -61,7 +61,8 @@ from pathlib import Path
61
61
  from typing import Any, Dict, List, Optional, Tuple
62
62
 
63
63
  from open_dream_rsi.core.tree import DiscoveryTree
64
- from open_dream_rsi.sandbox import run_isolated
64
+ from open_dream_rsi.sandbox import (SandboxCache, cached_run_isolated,
65
+ content_key, run_isolated)
65
66
 
66
67
  #: Weight of branch diversity in the rollout score (the paper's beta_2:
67
68
  #: replay objectives reward visiting *different* branches, not just high-
@@ -289,8 +290,11 @@ class PolicySandbox:
289
290
  gated by :func:`validate_policy_source` before we ever spawn a process.
290
291
  """
291
292
 
292
- def __init__(self, timeout: float = 5.0):
293
+ def __init__(self, timeout: float = 5.0,
294
+ cache: Optional[SandboxCache] = None):
293
295
  self.timeout = timeout
296
+ #: Optional content-addressed result cache (opt-in; None = uncached).
297
+ self.cache = cache
294
298
 
295
299
  def choose(self, source: str, frontier: List[Dict[str, Any]], step: int) -> PolicyRun:
296
300
  try:
@@ -305,10 +309,14 @@ class PolicySandbox:
305
309
  fr_path.write_text(json.dumps(frontier), encoding="utf-8")
306
310
  harness_path.write_text(_POLICY_HARNESS, encoding="utf-8")
307
311
  try:
308
- proc = run_isolated(
312
+ proc = cached_run_isolated(
313
+ run_isolated,
309
314
  [sys.executable, "-I", str(harness_path), str(src_path), str(fr_path), str(step)],
315
+ key=content_key({"harness": _POLICY_HARNESS, "policy": source,
316
+ "frontier": frontier}, timeout=self.timeout),
310
317
  timeout=self.timeout,
311
318
  cwd=d,
319
+ cache=self.cache,
312
320
  )
313
321
  except subprocess.TimeoutExpired:
314
322
  return PolicyRun(None, "policy execution timed out")
@@ -346,10 +354,14 @@ class PolicySandbox:
346
354
  world_path.write_text(json.dumps(world), encoding="utf-8")
347
355
  harness_path.write_text(_ROLLOUT_HARNESS, encoding="utf-8")
348
356
  try:
349
- proc = run_isolated(
357
+ proc = cached_run_isolated(
358
+ run_isolated,
350
359
  [sys.executable, "-I", str(harness_path), str(src_path), str(world_path)],
360
+ key=content_key({"harness": _ROLLOUT_HARNESS, "policy": source,
361
+ "world": world}, timeout=self.timeout),
351
362
  timeout=self.timeout,
352
363
  cwd=d,
364
+ cache=self.cache,
353
365
  )
354
366
  except subprocess.TimeoutExpired:
355
367
  return None, [], 0, "policy rollout timed out"
@@ -410,7 +422,7 @@ def frontier_entry(node: Any, outcomes: Dict[str, float]) -> Dict[str, Any]:
410
422
  ``outcome`` per step with the as-of-now value over revealed descendants
411
423
  only (prefix rule).
412
424
  """
413
- errors = []
425
+ errors: List[Any] = []
414
426
  result = node.result
415
427
  if isinstance(result, dict):
416
428
  errors = result.get("errors") or []
@@ -635,6 +647,25 @@ Previous candidate rejection reason: {feedback}
635
647
  Return an improved complete policy as one python code block."""
636
648
 
637
649
 
650
+ REVISE_USER_TEMPLATE = """Category: {category}
651
+
652
+ Your current policy program (the incumbent — keep its successful strategy,
653
+ fix only what the replay feedback indicts):
654
+ ```python
655
+ {incumbent}
656
+ ```
657
+
658
+ Replay metrics of the current policy: {metrics}
659
+
660
+ Discovery history summary ({nodes} nodes, best score {best_score}):
661
+ {summary}
662
+
663
+ Why the last challenger was rejected: {feedback}
664
+
665
+ Return a REVISED complete policy as one python code block (full program,
666
+ not a patch)."""
667
+
668
+
638
669
  @dataclass
639
670
  class GenerationResult:
640
671
  source: Optional[str]
@@ -664,6 +695,7 @@ class PolicyGenerator:
664
695
  incumbent_score: float,
665
696
  tree: DiscoveryTree,
666
697
  api_calls: Optional[List[int]] = None,
698
+ revise: bool = False,
667
699
  ) -> GenerationResult:
668
700
  steps = sum(1 for n in tree.nodes.values() if n.parent_id)
669
701
  if steps < 2:
@@ -671,8 +703,13 @@ class PolicyGenerator:
671
703
  summary = self._tree_summary(tree)
672
704
  feedback = ""
673
705
  source: Optional[str] = None
706
+ user_template = REVISE_USER_TEMPLATE if revise else GEN_USER_TEMPLATE
707
+ system_prompt = (
708
+ POLICY_CONTRACT + " You are REVISING the incumbent policy: keep "
709
+ "its successful strategy, change only what the feedback indicts."
710
+ if revise else POLICY_CONTRACT)
674
711
  for attempt in range(1 + self.max_repair):
675
- user = GEN_USER_TEMPLATE.format(
712
+ user = user_template.format(
676
713
  category=category,
677
714
  incumbent=incumbent_source or "(none — first policy)",
678
715
  metrics=f"{incumbent_score:.4f}",
@@ -686,7 +723,7 @@ class PolicyGenerator:
686
723
  if self.max_tokens:
687
724
  kwargs["max_tokens"] = self.max_tokens
688
725
  reply = self.client.chat(
689
- [{"role": "system", "content": POLICY_CONTRACT},
726
+ [{"role": "system", "content": system_prompt},
690
727
  {"role": "user", "content": user}],
691
728
  **kwargs,
692
729
  )
@@ -720,6 +757,50 @@ class PolicyGenerator:
720
757
  return GenerationResult(source, score, "")
721
758
  return GenerationResult(None, float("-inf"), feedback or "generation failed")
722
759
 
760
+ @staticmethod
761
+ def _replay_brief(tree: DiscoveryTree, horizon: Optional[int] = None,
762
+ max_steps: int = 24) -> str:
763
+ """Greedy replay trace: the counterfactual the incumbent must beat.
764
+
765
+ Mirrors :func:`_simulate_step_rewards` (greedy pick = best visible
766
+ score) and reports, per step, what the greedy policy would harvest —
767
+ the model sees WHERE the greedy replay stalls (repeated exhausted
768
+ picks, zero-reward steps) and revises against that evidence instead
769
+ of rewriting blind.
770
+ """
771
+ if not tree.nodes:
772
+ return "(empty)"
773
+ world = rollout_world_payload(tree, horizon)
774
+
775
+ def pick(visible, step):
776
+ return max(visible, key=lambda e: e["score"])["node_id"] if visible else None
777
+
778
+ rewards, picks, _invalid, _err = _simulate_step_rewards(world, pick)
779
+ lines = []
780
+ for i, (pick_id, reward) in enumerate(list(zip(picks, rewards))[:max_steps]):
781
+ repeat = " REPEAT" if picks[:i].count(pick_id) else ""
782
+ lines.append(f"step {i}: expand {pick_id} -> reward {reward:.3f}{repeat}")
783
+ exhausted = [p for p in picks if picks.count(p) > 1]
784
+ if exhausted:
785
+ lines.append(f"greedy re-picks: {sorted(set(exhausted))[:4]} "
786
+ "(exhausted branches — the incumbent camps here)")
787
+ return "\n".join(lines) or "(no steps)"
788
+
789
+ def generate_with_brief(self, category, incumbent_source, incumbent_score,
790
+ tree, api_calls=None):
791
+ """Revise mode with the greedy replay brief as structured feedback.
792
+
793
+ A candidate promoted here faced strictly stronger evidence than a
794
+ blind rewrite: same promotion gate, same strict win requirement —
795
+ only the prompt carries the incumbent's measured replay trace.
796
+ """
797
+ brief = self._replay_brief(tree)
798
+ result = self.generate(category, incumbent_source, incumbent_score,
799
+ tree, api_calls=api_calls, revise=True)
800
+ if result.source is None and brief:
801
+ result.error = f"{result.error} | greedy replay: {brief[:200]}".strip(" |")
802
+ return result
803
+
723
804
  @staticmethod
724
805
  def _tree_summary(tree: DiscoveryTree, max_nodes: int = 12) -> str:
725
806
  lines = []
@@ -51,7 +51,7 @@ SOLUTIONS = {
51
51
  ),
52
52
  }
53
53
 
54
- DEMOS = [
54
+ DEMOS: List[Dict[str, Any]] = [
55
55
  dict(task_id="add1", category="add",
56
56
  prompt="Implement add(a, b) returning the sum of two numbers.",
57
57
  tests=[{"call": "add(2, 3)", "expected": 5}, {"call": "add(-1, 1)", "expected": 0}]),
@@ -224,7 +224,7 @@ def run_dashboard(host: str = "0.0.0.0", port: int = 8765, provider: str = "mock
224
224
  raw = json.loads(open(tasks_file, encoding="utf-8").read())
225
225
  demo_tasks = [Task(**t) for t in raw]
226
226
  else:
227
- demo_tasks = [Task(max_attempts=3, **t) for t in DEMOS]
227
+ demo_tasks = [Task(**{**t, "max_attempts": 3}) for t in DEMOS]
228
228
 
229
229
  if provider == "mock":
230
230
  client: Any = MockLLM()
@@ -194,6 +194,8 @@ def ingest_sessions(memory, sessions_db: "str | Path", *,
194
194
  ``source="hermes"`` reads a Hermes ``state.db``; ``source="cursor"``
195
195
  reads the Cursor ``User`` dir (``state.vscdb``) directly — no MCP
196
196
  server involved, so this also works where MCP is blocked by policy.
197
+ ``source="claude"`` reads Claude Code JSONL transcripts under
198
+ ``~/.claude/projects`` (or the given projects dir).
197
199
 
198
200
  Returns {'new': n_sessions, 'added': n_lessons, 'probes': n_tasks,
199
201
  'deferred': n_deferred (endpoint failed; session left unseen)}.
@@ -214,13 +216,17 @@ def ingest_sessions(memory, sessions_db: "str | Path", *,
214
216
  state = _load_state(root)
215
217
  seen = set(state.get("seen_sessions", []))
216
218
 
217
- if source not in ("hermes", "cursor"):
219
+ if source not in ("hermes", "cursor", "claude"):
218
220
  raise ValueError(f"unknown session source {source!r} "
219
- "(expected 'hermes' or 'cursor')")
221
+ "(expected 'hermes', 'cursor' or 'claude')")
220
222
  if source == "cursor":
221
223
  from open_dream_rsi.sessions import read_cursor_sessions
222
224
  eps = read_cursor_sessions(user_dir=sessions_db,
223
225
  limit=limit + len(seen))
226
+ elif source == "claude":
227
+ from open_dream_rsi.sessions import read_claude_code_sessions
228
+ eps = read_claude_code_sessions(projects_dir=sessions_db,
229
+ limit=limit + len(seen))
224
230
  else:
225
231
  eps = read_hermes_sessions(sessions_db, limit=limit + len(seen))
226
232
  new = [e for e in eps if e.session_id not in seen][:limit]