@hicaru/pi-rlm 0.1.7 → 0.1.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.
Files changed (43) hide show
  1. package/README.md +41 -4
  2. package/package.json +2 -1
  3. package/src/bridge/library.ts +155 -0
  4. package/src/bridge/llm-query.ts +1 -0
  5. package/src/bridge/rlm-query.ts +56 -12
  6. package/src/config/defaults.ts +2 -0
  7. package/src/config/settings.ts +4 -0
  8. package/src/context/library-context.ts +266 -0
  9. package/src/context/repomix-context.ts +2 -48
  10. package/src/core/answer.ts +1 -10
  11. package/src/core/artifacts.ts +88 -0
  12. package/src/core/critique.ts +92 -0
  13. package/src/core/engine.ts +446 -53
  14. package/src/core/gates.ts +301 -0
  15. package/src/core/iteration.ts +7 -2
  16. package/src/core/pipeline.ts +196 -28
  17. package/src/core/types.ts +5 -3
  18. package/src/index.ts +3 -6
  19. package/src/mode/native-guards.ts +2 -2
  20. package/src/prompts/phases.ts +104 -0
  21. package/src/prompts/system.ts +59 -16
  22. package/src/prompts/user.ts +12 -4
  23. package/src/sandbox/protocol.ts +29 -11
  24. package/src/sandbox/sandbox.ts +77 -2
  25. package/src/sandbox/worker.py +215 -46
  26. package/src/state/index.ts +2 -1
  27. package/src/state/paths.ts +4 -2
  28. package/src/state/reads.ts +31 -2
  29. package/src/state/resume.ts +31 -6
  30. package/src/state/rows.ts +8 -2
  31. package/src/state/writes.ts +5 -3
  32. package/src/text/tokens.ts +7 -1
  33. package/src/tool/repl-details.ts +2 -3
  34. package/src/tool/repl-tool.ts +52 -57
  35. package/src/tool/rlm-aggregator.ts +7 -7
  36. package/src/tool/rlm-details.ts +6 -3
  37. package/src/tool/rlm-events.ts +14 -11
  38. package/src/tool/rlm-tool.ts +2 -8
  39. package/src/tool/subcall-store.ts +2 -0
  40. package/src/ui/config-panel.ts +8 -1
  41. package/src/registry/edit-registry.ts +0 -22
  42. package/src/text/edits.ts +0 -16
  43. package/src/tool/apply-edits-tool.ts +0 -288
@@ -7,9 +7,9 @@ This is NOT a security sandbox: __import__ and open are available, so code can i
7
7
  Protocol (parent -> worker): {"id","type":"exec"|"load_context"|"shutdown", ...}
8
8
  Protocol (worker -> parent): {"id","ok",...result} # response to a request
9
9
  {"type":"llm_query"|"llm_query_batched"|"rlm_query"|...
10
- "advance_phase"|"ask_user_question"|"todo","rid",...}
10
+ "advance_phase"|"save_artifact"|"ask_user_question"|"todo","rid",...}
11
11
  # mid-exec helper request
12
- When sandbox code calls llm_query/rlm_query/advance_phase/ask_user_question/todo, the worker writes a request line
12
+ When sandbox code calls llm_query/rlm_query/advance_phase/save_artifact/ask_user_question/todo, the worker writes a request line
13
13
  and BLOCKS reading stdin until the matching {"type":"llm_reply","rid",...} arrives. The parent
14
14
  services the request in-process (it holds API keys).
15
15
  """
@@ -57,8 +57,44 @@ _SAFE_BUILTINS = {
57
57
  "ArithmeticError", "ZeroDivisionError", "LookupError", "Warning", "True", "False", "None",
58
58
  )
59
59
  }
60
- # `open` is allowed (data work needs files); eval/exec/compile/input/globals/locals are not.
61
- _SAFE_BUILTINS["open"] = open
60
+ # `open` is allowed for data work; eval/exec/compile/input/globals/locals are not.
61
+ # When read_only=True (pipeline runs), write modes raise PermissionError via
62
+ # builtins.open, io.open (pathlib), and os.open. Steering, not a security sandbox.
63
+ _WRITE_MODE_CHARS = frozenset("wax+")
64
+ _OS_WRITE_FLAGS = os.O_WRONLY | os.O_RDWR | os.O_CREAT | os.O_APPEND | os.O_TRUNC
65
+
66
+ _REAL_IO_OPEN = io.open
67
+ _REAL_OS_OPEN = os.open
68
+
69
+
70
+ def _install_read_only_guards():
71
+ """Route every common file-open path through the read-only check.
72
+
73
+ Steering, not a sandbox: closes builtins.open, io.open (hence pathlib), and
74
+ os.open. A determined model can still reach the filesystem via ctypes or a
75
+ subprocess — the goal is that ACCIDENTAL writes cannot pass silently.
76
+ Worker-internal I/O keeps using _REAL_IO_OPEN / _REAL_OS_OPEN.
77
+ """
78
+ def guarded_io_open(file, mode="r", *args, **kwargs):
79
+ if _WRITE_MODE_CHARS & set(str(mode)):
80
+ raise PermissionError(
81
+ f"read-only RLM run: refusing to open {file!r} with mode {mode!r}. "
82
+ "This pipeline produces a plan; file changes go through the host edit tool."
83
+ )
84
+ return _REAL_IO_OPEN(file, mode, *args, **kwargs)
85
+
86
+ def guarded_os_open(path, flags, *args, **kwargs):
87
+ if flags & _OS_WRITE_FLAGS:
88
+ raise PermissionError(
89
+ f"read-only RLM run: refusing os.open({path!r}) with write flags."
90
+ )
91
+ return _REAL_OS_OPEN(path, flags, *args, **kwargs)
92
+
93
+ io.open = guarded_io_open
94
+ os.open = guarded_os_open
95
+ return guarded_io_open
96
+
97
+
62
98
  for _blocked in ("eval", "exec", "compile", "input", "globals", "locals"):
63
99
  _SAFE_BUILTINS[_blocked] = None
64
100
 
@@ -66,13 +102,14 @@ RESERVED = frozenset(
66
102
  {
67
103
  "llm_query", "llm_query_batched", "llm_query_chunked",
68
104
  "rlm_query", "rlm_query_batched",
69
- "advance_phase",
105
+ "advance_phase", "save_artifact",
70
106
  "ask_user_question", "todo",
71
- "stage_edit",
107
+ "load_library",
72
108
  "SHOW_VARS", "answer", "context",
73
109
  }
74
110
  )
75
- _CONTEXT_SLOT = re.compile(r"context(_\d+)?\Z")
111
+ # Only the single name `context` is the packed world. Legacy context_N names are filtered out.
112
+ _CONTEXT_NAME = re.compile(r"context(_\d+)?\Z")
76
113
 
77
114
  # Sizing for llm_query_chunked: leave room for the instruction and the chunk header.
78
115
  _CHUNK_HEADER_OVERHEAD = 64
@@ -118,21 +155,24 @@ def _send(obj: dict[str, Any]) -> None:
118
155
 
119
156
 
120
157
  class Worker:
121
- def __init__(self, depth: int, exec_timeout_s: float, max_prompt_chars: int):
158
+ def __init__(self, depth: int, exec_timeout_s: float, max_prompt_chars: int, read_only: bool = False):
122
159
  self.depth = depth
123
160
  self.exec_timeout_s = exec_timeout_s
124
161
  self.max_prompt_chars = max_prompt_chars
162
+ self.read_only = read_only
125
163
  self._rid = 0
126
164
  self._final_answer: str | None = None
127
- self._context_count = 0
128
165
  self.ns: dict[str, Any] = {}
129
166
  self._setup()
130
167
 
131
168
  def _setup(self) -> None:
132
- self.ns = {"__builtins__": _SAFE_BUILTINS.copy(), "__name__": "__main__"}
133
- self._ctx_payloads: dict[int, Any] = {}
134
- self._staged_edits: list[dict[str, str]] = []
135
- self._edit_counter = 0
169
+ builtins = _SAFE_BUILTINS.copy()
170
+ if self.read_only:
171
+ builtins["open"] = _install_read_only_guards()
172
+ else:
173
+ builtins["open"] = open
174
+ self.ns = {"__builtins__": builtins, "__name__": "__main__"}
175
+ self._context_payload: Any | None = None # pristine restore for the single `context` var
136
176
  self._nudged: set[str] = set()
137
177
  self._restore_scaffold()
138
178
 
@@ -148,9 +188,10 @@ class Worker:
148
188
  ns["rlm_query"] = self._rlm_query
149
189
  ns["rlm_query_batched"] = self._rlm_query_batched
150
190
  ns["advance_phase"] = self._advance_phase
191
+ ns["save_artifact"] = self._save_artifact
151
192
  ns["ask_user_question"] = self._ask_user_question
152
193
  ns["todo"] = self._todo
153
- ns["stage_edit"] = self._stage_edit
194
+ ns["load_library"] = self._load_library
154
195
  ns["SHOW_VARS"] = self._show_vars
155
196
  if not isinstance(ns.get("answer"), _AnswerDict):
156
197
  cur = ns.get("answer")
@@ -161,17 +202,18 @@ class Worker:
161
202
  if cur.get("ready") and self._final_answer is None:
162
203
  self._final_answer = str(cur.get("content", ""))
163
204
  ns["answer"] = ans
164
- # Context slots are ordinary variables (RLM paper: the context lives in the
165
- # environment and the model may transform it in place). Re-inject only if the
166
- # model deleted the name entirely; mutations and re-binds persist within the run.
167
- # Resume reloads pristine context; keep derived resume-critical values in user vars.
168
- for idx, payload in self._ctx_payloads.items():
169
- ns.setdefault(f"context_{idx}", payload)
170
- if 0 in self._ctx_payloads:
171
- ns.setdefault("context", self._ctx_payloads[0])
205
+ # Single context variable (RLM paper: the context lives in the environment and
206
+ # the model may transform it in place). Re-inject only if the model deleted the
207
+ # name entirely; mutations and re-binds persist within the run.
208
+ if self._context_payload is not None:
209
+ ns.setdefault("context", self._context_payload)
210
+ # Scrub any legacy context_N names so the model never sees multi-slot APIs.
211
+ for k in list(ns.keys()):
212
+ if k != "context" and _CONTEXT_NAME.match(k):
213
+ del ns[k]
172
214
 
173
215
  def _user_var_names(self) -> list[str]:
174
- """User-created variable names — filters builtins, scaffold, and context slots.
216
+ """User-created variable names — filters builtins, scaffold, and `context`.
175
217
 
176
218
  Shared by SHOW_VARS() and the exec result so both expose the same namespace view.
177
219
  This is the cheap orientation hint that goes into history instead of full stdout.
@@ -179,7 +221,7 @@ class Worker:
179
221
  return [
180
222
  k for k in self.ns
181
223
  if not k.startswith("_")
182
- and not _CONTEXT_SLOT.match(k)
224
+ and not _CONTEXT_NAME.match(k)
183
225
  and k not in RESERVED
184
226
  ]
185
227
 
@@ -323,19 +365,125 @@ class Worker:
323
365
  return f"Error: {r['error']}"
324
366
  return str(r.get("response", "ok"))
325
367
 
326
- def _stage_edit(self, path: str, old_text: str, new_text: str) -> str:
327
- if not isinstance(path, str) or not isinstance(old_text, str) or not isinstance(new_text, str):
328
- return "Error: path, old_text, new_text must be strings"
329
- self._edit_counter += 1
330
- edit_id = f"e{self._edit_counter}"
331
- self._staged_edits.append({"id": edit_id, "path": path, "oldText": old_text, "newText": new_text})
332
- return edit_id
368
+ def _load_library(self, source: str) -> dict[str, Any] | str:
369
+ """Pack an external dir/file/git-URL on the host and append it into `context`.
370
+
371
+ Paths are namespaced under lib/<source_id>/ (host). Content is always in the
372
+ single `context` list — never a new context_N variable.
373
+ Host-side idempotency may return already_loaded without a payload path.
374
+ """
375
+ r = self._rpc("load_library", {"source": str(source)})
376
+ if r.get("error"):
377
+ return f"Error: {r['error']}"
378
+ if r.get("already_loaded"):
379
+ source_id = r.get("source_id") if isinstance(r.get("source_id"), str) else "lib"
380
+ path_prefix = r.get("path_prefix") if isinstance(r.get("path_prefix"), str) else f"lib/{source_id}/"
381
+ ctx = self.ns.get("context")
382
+ ctx_len = len(ctx) if isinstance(ctx, list) else 0
383
+ print(
384
+ f"[rlm] load_library: already loaded {source_id} "
385
+ f"(paths under {path_prefix}, context len={ctx_len})"
386
+ )
387
+ return {
388
+ "source": str(source),
389
+ "source_id": source_id,
390
+ "path_prefix": path_prefix,
391
+ "files": 0,
392
+ "chars": r.get("chars"),
393
+ "context_len": ctx_len,
394
+ "already_loaded": True,
395
+ }
396
+ path = r.get("path")
397
+ if not isinstance(path, str):
398
+ return "Error: malformed load_library reply (no path)"
399
+ try:
400
+ # Worker-internal read — use real io.open so read-only guards never block us.
401
+ with _REAL_IO_OPEN(path, "r") as f:
402
+ payload = json.load(f) if r.get("json") else f.read()
403
+ finally:
404
+ try:
405
+ os.remove(path) # worker owns temp-file cleanup (host does NOT unlink)
406
+ except OSError:
407
+ pass
408
+ return self._append_library(str(source), payload, r)
409
+
410
+ def _append_library(self, source: str, payload: Any, meta: dict[str, Any]) -> dict[str, Any] | str:
411
+ """Append host-packed library files into `context` (idempotent by path prefix)."""
412
+ ctx = self.ns.get("context")
413
+ if not isinstance(ctx, list):
414
+ kind = type(ctx).__name__ if ctx is not None else "None"
415
+ return f"Error: load_library requires list context (file bundle); got {kind}"
416
+
417
+ source_id = meta.get("source_id")
418
+ if not isinstance(source_id, str) or not source_id:
419
+ source_id = "lib"
420
+ path_prefix = meta.get("path_prefix")
421
+ if not isinstance(path_prefix, str) or not path_prefix:
422
+ path_prefix = f"lib/{source_id}/"
423
+
424
+ # Idempotent: already present if any path uses this library prefix.
425
+ for item in ctx:
426
+ if isinstance(item, dict) and str(item.get("path", "")).startswith(path_prefix):
427
+ print(
428
+ f"[rlm] load_library: already loaded {source_id} "
429
+ f"(paths under {path_prefix}, context len={len(ctx)})"
430
+ )
431
+ return {
432
+ "source": source,
433
+ "source_id": source_id,
434
+ "path_prefix": path_prefix,
435
+ "files": 0,
436
+ "chars": meta.get("chars"),
437
+ "context_len": len(ctx),
438
+ "already_loaded": True,
439
+ }
440
+
441
+ files = self._library_file_entries(payload, path_prefix)
442
+ if not files:
443
+ return "Error: load_library produced no files"
444
+
445
+ ctx.extend(files)
446
+ # Keep restore payload in sync with the live list.
447
+ self._context_payload = ctx
448
+ self.ns["context"] = ctx
449
+
450
+ print(
451
+ f"[rlm] load_library: +{len(files)} files into context "
452
+ f"(len={len(ctx)}); paths under {path_prefix}"
453
+ )
454
+ return {
455
+ "source": source,
456
+ "source_id": source_id,
457
+ "path_prefix": path_prefix,
458
+ "files": len(files),
459
+ "chars": meta.get("chars"),
460
+ "context_len": len(ctx),
461
+ "already_loaded": False,
462
+ }
463
+
464
+ @staticmethod
465
+ def _library_file_entries(payload: Any, path_prefix: str) -> list[dict[str, Any]]:
466
+ """Normalize host payload to list[dict]. Host already namespaces; string is fallback."""
467
+ if isinstance(payload, str):
468
+ return [{
469
+ "path": f"{path_prefix}content",
470
+ "content": payload,
471
+ "tokens": max(1, (len(payload) + 3) // 4),
472
+ }]
473
+ if not isinstance(payload, list):
474
+ return []
475
+ out: list[dict[str, Any]] = []
476
+ for item in payload:
477
+ if isinstance(item, dict) and "path" in item and "content" in item:
478
+ out.append(item)
479
+ return out
333
480
 
334
481
  def _advance_phase(self, phase: str, summary: str | None = None) -> str:
335
482
  """Transition the root RLM pipeline to a new phase.
336
483
 
337
484
  Only callable at depth 0. The parent handler validates the transition
338
- against the phase state machine (research → blueprint → implement → validate).
485
+ against the phase state machine (research → blueprint → validate)
486
+ and runs deterministic artifact gates before accepting the transition.
339
487
  Returns a short confirmation, or an `Error: …` string the model can act on.
340
488
  """
341
489
  if self.depth > 0:
@@ -348,6 +496,22 @@ class Worker:
348
496
  return response
349
497
  return response if isinstance(response, str) else "ok"
350
498
 
499
+ def _save_artifact(self, kind: str, content: str) -> str:
500
+ """Persist a stage artifact (research/plan/validation) under .rlm/artifacts/.
501
+
502
+ Only callable at depth 0. The engine gates advance_phase against the latest
503
+ saved artifact for the current stage.
504
+ """
505
+ if self.depth > 0:
506
+ return "Error: save_artifact is only available at the root RLM depth"
507
+ r = self._rpc("save_artifact", {"artifactKind": str(kind), "content": str(content)})
508
+ if r.get("error"):
509
+ return f"Error: {r['error']}"
510
+ response = r.get("response", "ok")
511
+ if isinstance(response, str) and response.startswith("Error:"):
512
+ return response
513
+ return response if isinstance(response, str) else "ok"
514
+
351
515
  def _rlm_query_batched(self, prompts, model: str | None = None) -> list[str]:
352
516
  prompts = [str(p) for p in prompts]
353
517
  if not prompts:
@@ -363,16 +527,20 @@ class Worker:
363
527
  # ---- context + execution --------------------------------------------------------------
364
528
 
365
529
  def load_context(self, path: str, index: int | None = None, is_json: bool = False) -> int:
366
- if index is None:
367
- index = self._context_count
530
+ """Load the packed world into the single REPL variable `context`.
531
+
532
+ `index` is accepted for protocol compatibility but ignored — there is only
533
+ one context slot. Libraries are merged on the host (or via load_library).
534
+ """
368
535
  with open(path, "r") as f:
369
536
  payload = json.load(f) if is_json else f.read()
370
- self._ctx_payloads[index] = payload
371
- self.ns[f"context_{index}"] = payload
372
- if index == 0:
373
- self.ns["context"] = payload
374
- self._context_count = max(self._context_count, index + 1)
375
- return index
537
+ self._context_payload = payload
538
+ self.ns["context"] = payload
539
+ # Drop legacy multi-slot names if present.
540
+ for k in list(self.ns.keys()):
541
+ if k != "context" and _CONTEXT_NAME.match(k):
542
+ del self.ns[k]
543
+ return 0
376
544
 
377
545
  @contextmanager
378
546
  def _capture(self):
@@ -434,7 +602,6 @@ class Worker:
434
602
  stdout = out.getvalue()
435
603
  stderr = err.getvalue() + f"\n{type(e).__name__}: {e}\n" + traceback.format_exc()
436
604
  final, self._final_answer = self._final_answer, None
437
- edits, self._staged_edits = self._staged_edits, []
438
605
  answer = self.ns.get("answer")
439
606
  answer_content = answer.get("content", "") if isinstance(answer, dict) else ""
440
607
  # ready may have been flipped with empty content before content was assigned later
@@ -451,7 +618,6 @@ class Worker:
451
618
  "stderr": stderr,
452
619
  "final_answer": final,
453
620
  "answer_content": str(answer_content),
454
- "edits": edits,
455
621
  "raised": raised,
456
622
  "execution_time": time.perf_counter() - start,
457
623
  "var_names": self._user_var_names(),
@@ -475,7 +641,7 @@ class Worker:
475
641
  out, skipped = {}, []
476
642
  MAX_VAR_BYTES = 50 * 1024 * 1024
477
643
  for k, v in self.ns.items():
478
- if k.startswith("_") or _CONTEXT_SLOT.match(k) or k in RESERVED or k == "__builtins__":
644
+ if k.startswith("_") or _CONTEXT_NAME.match(k) or k in RESERVED or k == "__builtins__":
479
645
  continue
480
646
  try:
481
647
  blob = s.dumps(v)
@@ -488,7 +654,7 @@ class Worker:
488
654
  if skipped:
489
655
  print(f"[rlm-sandbox] snapshot skipped {len(skipped)} unpicklable/oversized vars: {skipped}", file=_REAL_STDERR)
490
656
  tmp = path + ".tmp"
491
- with open(tmp, "wb") as f:
657
+ with _REAL_IO_OPEN(tmp, "wb") as f:
492
658
  s.dump({"nonce": nonce, "vars": out}, f)
493
659
  os.rename(tmp, path) # atomic rename
494
660
  return {"skipped": skipped}
@@ -501,7 +667,7 @@ class Worker:
501
667
  history-only replay (caller skips restore when sessionNonce is undefined).
502
668
  """
503
669
  s = self._serializer()
504
- with open(path, "rb") as f:
670
+ with _REAL_IO_OPEN(path, "rb") as f:
505
671
  data = s.load(f)
506
672
  if not isinstance(data, dict) or data.get("nonce") != nonce:
507
673
  raise ValueError("snapshot nonce mismatch — not from this session")
@@ -516,10 +682,13 @@ def main() -> None:
516
682
  ap.add_argument("--timeout", type=float, default=float(os.environ.get("RLM_EXEC_TIMEOUT_S", "600")))
517
683
  ap.add_argument("--max-prompt-chars", type=int,
518
684
  default=int(os.environ.get("RLM_MAX_PROMPT_CHARS", "400000")))
685
+ ap.add_argument("--read-only", action="store_true",
686
+ default=os.environ.get("RLM_READ_ONLY", "").lower() in ("1", "true", "yes"),
687
+ help="Reject open() write modes (pipeline runs)")
519
688
  args = ap.parse_args()
520
689
 
521
690
  worker = Worker(depth=args.depth, exec_timeout_s=args.timeout,
522
- max_prompt_chars=args.max_prompt_chars)
691
+ max_prompt_chars=args.max_prompt_chars, read_only=args.read_only)
523
692
  _send({"id": "_init", "ok": True})
524
693
 
525
694
  for raw in _REAL_STDIN:
@@ -18,6 +18,7 @@ export type {
18
18
  } from "./rows.ts";
19
19
  export { STATE_SCHEMA_VERSION, isHeader, isTurn, isCompaction, isPhase, isTodo, isTerminal, isRow } from "./rows.ts";
20
20
  export { appendRow, appendTodoRow, pruneRuns, writeContextSidecar } from "./writes.ts";
21
- export { readRows, readHeader, readContextSidecar, listRunIds, resolveRunId } from "./reads.ts";
21
+ export { readRows, readHeader, readContextSidecar, readLibrarySidecars, listRunIds, resolveRunId } from "./reads.ts";
22
+ export type { LibrarySlot } from "./reads.ts";
22
23
  export { reconstructRlmState } from "./resume.ts";
23
24
  export type { PhaseRecon, ReconstructResult } from "./resume.ts";
@@ -32,8 +32,10 @@ export const runDir = (cwd: string, dir: string, runId: string): string => join(
32
32
 
33
33
  export const trailPath = (cwd: string, dir: string, runId: string): string => join(runDir(cwd, dir, runId), "trail.jsonl");
34
34
 
35
- export const contextPath = (cwd: string, dir: string, runId: string, json: boolean): string =>
36
- join(runDir(cwd, dir, runId), json ? "context.json" : "context.txt");
35
+ export const contextPath = (cwd: string, dir: string, runId: string, json: boolean, index = 0): string =>
36
+ join(runDir(cwd, dir, runId), index === 0
37
+ ? (json ? "context.json" : "context.txt")
38
+ : `context.${index}.${json ? "json" : "txt"}`);
37
39
 
38
40
  /** R-C1: per-turn snapshot files — `sandbox-<turn>.pkl` so resume can fall back to a prior turn if the latest rename failed. */
39
41
  export function snapshotPath(cwd: string, dir: string, runId: string, turn?: number): string {
@@ -6,8 +6,9 @@
6
6
  * by the slug (ISO-like timestamps are self-sorting).
7
7
  */
8
8
 
9
- import { open, readFile } from "node:fs/promises";
10
- import { runsDir, trailPath, contextPath } from "./paths.ts";
9
+ import { open, readdir, readFile } from "node:fs/promises";
10
+ import { join } from "node:path";
11
+ import { runsDir, runDir, trailPath, contextPath } from "./paths.ts";
11
12
  import { isHeader, isRow, type RunHeader, type Row } from "./rows.ts";
12
13
  import { errorMessage, failSoft, listDirectoriesSorted, pathExists, warn } from "./internal.ts";
13
14
 
@@ -83,6 +84,34 @@ export async function readContextSidecar(cwd: string, dir: string, runId: string
83
84
  }
84
85
  }
85
86
 
87
+ const LIBRARY_SIDECAR = /^context\.(\d+)\.(json|txt)$/;
88
+
89
+ export interface LibrarySlot {
90
+ readonly index: number;
91
+ readonly payload: unknown;
92
+ }
93
+
94
+ /** Fail-soft lister for load_library resume sidecars (`context.<index>.json|txt`). */
95
+ export async function readLibrarySidecars(cwd: string, dir: string, runId: string): Promise<LibrarySlot[]> {
96
+ const entries = await failSoft(() => readdir(runDir(cwd, dir, runId)), [] as string[]);
97
+ const slots: LibrarySlot[] = [];
98
+ for (const name of entries) {
99
+ const m = LIBRARY_SIDECAR.exec(name);
100
+ if (!m) continue;
101
+ const index = Number(m[1]);
102
+ const json = m[2] === "json";
103
+ const content = await failSoft(
104
+ () => readFile(join(runDir(cwd, dir, runId), name), "utf-8"),
105
+ undefined as string | undefined,
106
+ );
107
+ if (content === undefined) continue;
108
+ try {
109
+ slots.push({ index, payload: json ? JSON.parse(content) as unknown : content });
110
+ } catch (e) { warn(e); }
111
+ }
112
+ return slots.sort((a, b) => a.index - b.index);
113
+ }
114
+
86
115
  /** Enumerate run-ids by directory listing; newest first (slug sorts chronologically). */
87
116
  export async function listRunIds(cwd: string, dir: string): Promise<string[]> {
88
117
  return await failSoft(() => listDirectoriesSorted(runsDir(cwd, dir)), [], { warn: false });
@@ -11,7 +11,6 @@ import { readFile } from "node:fs/promises";
11
11
  import { type ChatMsg } from "../bridge/model.ts";
12
12
  import { appendUserMessage } from "../core/history.ts";
13
13
  import { buildTurnPrompt } from "../prompts/user.ts";
14
- import type { ProposedEdit } from "../sandbox/protocol.ts";
15
14
  import { readHeader } from "./reads.ts";
16
15
  import {
17
16
  isCompaction,
@@ -28,10 +27,19 @@ import {
28
27
  import { trailPath, snapshotPath } from "./paths.ts";
29
28
  import { failSoft, pathExists } from "./internal.ts";
30
29
 
30
+ /** Artifact path + supersede flag reconstructed from phase rows. */
31
+ export interface PhaseReconArtifact {
32
+ readonly path: string;
33
+ readonly superseded: boolean;
34
+ }
35
+
31
36
  export interface PhaseRecon {
32
37
  readonly current: string;
33
38
  readonly advancedAt: number;
34
39
  readonly summary?: string;
40
+ /** Repo-relative artifacts keyed by the phase that produced them. */
41
+ readonly artifacts?: Readonly<Partial<Record<string, PhaseReconArtifact>>>;
42
+ readonly backwardJumps?: number;
35
43
  }
36
44
 
37
45
  export type ReconstructResult =
@@ -42,7 +50,6 @@ export type ReconstructResult =
42
50
  readonly pendingReplOutputs?: string;
43
51
  readonly usageSeed: { readonly costUsd: number; readonly inputTokens: number; readonly outputTokens: number; readonly durationMs: number };
44
52
  readonly best: string;
45
- readonly editsAcc: ProposedEdit[];
46
53
  readonly completedTurns: number;
47
54
  readonly compactions: number;
48
55
  /** R-C1: the latest turn whose per-turn snapshot file exists on disk (undefined ⇒ no restore). */
@@ -99,7 +106,6 @@ export async function reconstructRlmState(
99
106
  let history: ChatMsg[] = [{ role: "system", content: systemPrompt }];
100
107
  const usageSeed = { costUsd: 0, inputTokens: 0, outputTokens: 0, durationMs: 0 };
101
108
  let best = "";
102
- let editsAcc: ProposedEdit[] = [];
103
109
  let completedTurns = 0;
104
110
  let compactions = 0;
105
111
  let snapshotTurn: number | undefined; // R-C1: latest turn with an existing snapshot file
@@ -107,6 +113,8 @@ export async function reconstructRlmState(
107
113
  const todoRows: { action: string; params: Record<string, unknown>; result: string }[] = [];
108
114
  let terminated = false;
109
115
  let phase: PhaseRecon | undefined;
116
+ // Append-only journal: paths stay; supersededPath flips the blueprint slot.
117
+ const artifactsAcc = new Map<string, PhaseReconArtifact>();
110
118
 
111
119
  for (const row of rows) {
112
120
  if (isHeader(row)) continue;
@@ -129,7 +137,6 @@ export async function reconstructRlmState(
129
137
  usageSeed.outputTokens += row.usage.outputTokens;
130
138
  if (row.answerContent) best = row.answerContent;
131
139
  else if (!best && row.response.trim()) best = row.response; // C3: mirror engine fallback
132
- if (row.edits && row.edits.length > 0) editsAcc = [...row.edits];
133
140
  completedTurns = row.turn;
134
141
  // R-C1: verify the per-turn snapshot file exists — a crashed finalize leaves the row claiming snapshotOk:true with no pkl.
135
142
  if (row.snapshotOk && await pathExists(snapshotPath(cwd, dir, runId, row.turn)))
@@ -139,7 +146,25 @@ export async function reconstructRlmState(
139
146
  continue;
140
147
  }
141
148
  if (isPhase(row)) {
142
- phase = { current: row.phase, advancedAt: row.turn - 1, summary: row.summary };
149
+ if (row.artifactPath !== undefined && row.artifactPhase !== undefined) {
150
+ artifactsAcc.set(row.artifactPhase, { path: row.artifactPath, superseded: false });
151
+ }
152
+ // Append-only: a superseded artifact keeps its slot, flipped to superseded.
153
+ if (row.supersededPath !== undefined) {
154
+ const prior = artifactsAcc.get("blueprint");
155
+ if (prior !== undefined) {
156
+ artifactsAcc.set("blueprint", { path: prior.path, superseded: true });
157
+ }
158
+ }
159
+ const artifactsObj: Record<string, PhaseReconArtifact> = {};
160
+ for (const [k, v] of artifactsAcc) artifactsObj[k] = v;
161
+ phase = {
162
+ current: row.phase,
163
+ advancedAt: row.turn - 1,
164
+ summary: row.summary,
165
+ artifacts: artifactsAcc.size > 0 ? artifactsObj : undefined,
166
+ backwardJumps: row.backwardJumps,
167
+ };
143
168
  continue;
144
169
  }
145
170
  if (isTodo(row)) {
@@ -150,5 +175,5 @@ export async function reconstructRlmState(
150
175
  }
151
176
 
152
177
  if (completedTurns === 0 && !terminated) return { ok: false, reason: "no-turns", detail: runId };
153
- return { ok: true, header, history, pendingReplOutputs, usageSeed, best, editsAcc, completedTurns, compactions, snapshotTurn, todoRows, terminated, phase };
178
+ return { ok: true, header, history, pendingReplOutputs, usageSeed, best, completedTurns, compactions, snapshotTurn, todoRows, terminated, phase };
154
179
  }
package/src/state/rows.ts CHANGED
@@ -9,7 +9,6 @@
9
9
  */
10
10
 
11
11
  import type { ChatMsg } from "../bridge/model.ts";
12
- import type { ProposedEdit } from "../sandbox/protocol.ts";
13
12
 
14
13
  /** Bump when a row shape changes such that the resume fold cannot replay older files. */
15
14
  export const STATE_SCHEMA_VERSION = 5;
@@ -47,7 +46,6 @@ export interface TurnRow {
47
46
  readonly response: string; // assistant message
48
47
  readonly replOutputs?: string; // formatReplOutputs(results) → next user message
49
48
  readonly answerContent?: string; // restores `best`
50
- readonly edits?: readonly ProposedEdit[]; // restores editsAcc (latest wins)
51
49
  readonly error: boolean; // turnHadError → limits.observe on resume
52
50
  readonly usage: UsageRow;
53
51
  readonly cumulativeDurationMs: number; // limits.usage().durationMs at turn-write time
@@ -88,6 +86,14 @@ export interface PhaseRow {
88
86
  readonly ts: string;
89
87
  readonly phase: string;
90
88
  readonly summary?: string;
89
+ /** Optional fields (no schema-version break — isPhase guard unchanged). */
90
+ readonly artifactPath?: string;
91
+ /** Phase that produced `artifactPath` (not inferred from order — loop-back safe). */
92
+ readonly artifactPhase?: string;
93
+ readonly blockersCount?: number;
94
+ readonly backwardJumps?: number;
95
+ /** Path of the artifact this transition superseded (validate loop-back). */
96
+ readonly supersededPath?: string;
91
97
  }
92
98
 
93
99
  export type Row = RunHeader | TurnRow | CompactionRow | TodoRow | TerminalRow | PhaseRow;
@@ -32,11 +32,13 @@ export async function appendTodoRow(cwd: string, dir: string, runId: string, row
32
32
  return await appendRow(cwd, dir, runId, { kind: "todo", ...row });
33
33
  }
34
34
 
35
- /** Persist the original context ONCE at run start so resume can reload it. */
36
- export async function writeContextSidecar(cwd: string, dir: string, runId: string, context: unknown, json: boolean): Promise<boolean> {
35
+ /** Persist a context payload for resume. Slot 0 = repo context; index ≥ 1 = load_library slots. */
36
+ export async function writeContextSidecar(
37
+ cwd: string, dir: string, runId: string, context: unknown, json: boolean, index = 0,
38
+ ): Promise<boolean> {
37
39
  return await failSoft(async () => {
38
40
  await mkdir(runDir(cwd, dir, runId), { recursive: true });
39
- await writeFile(contextPath(cwd, dir, runId, json), json ? JSON.stringify(context) : String(context), "utf-8");
41
+ await writeFile(contextPath(cwd, dir, runId, json, index), json ? JSON.stringify(context) : String(context), "utf-8");
40
42
  return true;
41
43
  }, false);
42
44
  }
@@ -8,11 +8,17 @@
8
8
 
9
9
  const CHARS_PER_TOKEN = 4;
10
10
 
11
+ /** Rough token count for a character length (≈4 chars/token). Always ≥ 1 for non-empty text. */
12
+ export function estimateTokens(charCount: number): number {
13
+ if (charCount <= 0) return 0;
14
+ return Math.ceil(charCount / CHARS_PER_TOKEN);
15
+ }
16
+
11
17
  /** Rough token count for a list of role/content messages. */
12
18
  export function estimateMessageTokens(messages: { content: string }[]): number {
13
19
  let chars = 0;
14
20
  for (const m of messages) chars += m.content.length + 8; // small per-message overhead
15
- return Math.ceil(chars / CHARS_PER_TOKEN);
21
+ return estimateTokens(chars);
16
22
  }
17
23
 
18
24
  /** Total character length of a context payload (string or list of strings). */
@@ -6,7 +6,6 @@
6
6
  * accumulated into the subcalls array for tree rendering.
7
7
  */
8
8
 
9
- import type { ProposedEdit } from "../sandbox/protocol.ts";
10
9
  import type { RlmSubcall } from "./rlm-details.ts";
11
10
 
12
11
  export interface ReplDetails {
@@ -23,6 +22,6 @@ export interface ReplDetails {
23
22
  readonly totals: { readonly costUsd: number; readonly tokens: number };
24
23
  /** Final answer submitted through answer["ready"] without echoing it to the model. */
25
24
  readonly finalAnswer?: string;
26
- /** File edits staged inside the REPL for native relay through apply_edits(). */
27
- readonly edits?: readonly ProposedEdit[];
25
+ /** Advisory diagnostics surfaced to the user, never a failure. */
26
+ readonly warnings?: readonly string[];
28
27
  }