@hicaru/pi-rlm 0.1.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 (72) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +237 -0
  3. package/README.ru.md +200 -0
  4. package/README.zh-CN.md +224 -0
  5. package/package.json +54 -0
  6. package/src/bridge/fallback-todo.ts +137 -0
  7. package/src/bridge/interactive.ts +65 -0
  8. package/src/bridge/llm-query.ts +124 -0
  9. package/src/bridge/model.ts +97 -0
  10. package/src/bridge/pi-interactive.ts +86 -0
  11. package/src/bridge/rlm-query.ts +78 -0
  12. package/src/commands/rlm-config.ts +42 -0
  13. package/src/commands/rlm.ts +165 -0
  14. package/src/config/defaults.ts +38 -0
  15. package/src/config/settings.ts +185 -0
  16. package/src/context/repomix-context.ts +253 -0
  17. package/src/core/answer.ts +97 -0
  18. package/src/core/compaction.ts +64 -0
  19. package/src/core/engine.ts +408 -0
  20. package/src/core/history.ts +13 -0
  21. package/src/core/iteration.ts +45 -0
  22. package/src/core/limits.ts +90 -0
  23. package/src/core/pipeline.ts +100 -0
  24. package/src/core/resource-limits.ts +14 -0
  25. package/src/core/types.ts +131 -0
  26. package/src/index.ts +165 -0
  27. package/src/mode/input-router.ts +23 -0
  28. package/src/mode/rlm-mode.ts +149 -0
  29. package/src/patch/apply.ts +148 -0
  30. package/src/patch/index.ts +37 -0
  31. package/src/prompts/system.ts +278 -0
  32. package/src/prompts/user.ts +21 -0
  33. package/src/sandbox/protocol.ts +191 -0
  34. package/src/sandbox/sandbox-manager.ts +143 -0
  35. package/src/sandbox/sandbox.ts +362 -0
  36. package/src/sandbox/worker.py +457 -0
  37. package/src/state/events.ts +22 -0
  38. package/src/state/index.ts +23 -0
  39. package/src/state/internal.ts +46 -0
  40. package/src/state/paths.ts +42 -0
  41. package/src/state/reads.ts +96 -0
  42. package/src/state/resume.ts +154 -0
  43. package/src/state/rows.ts +117 -0
  44. package/src/state/writes.ts +56 -0
  45. package/src/telemetry/dispatcher.ts +116 -0
  46. package/src/telemetry/index.ts +14 -0
  47. package/src/telemetry/mlflow-config.ts +15 -0
  48. package/src/telemetry/mlflow-sink.ts +136 -0
  49. package/src/telemetry/mlflow.ts +99 -0
  50. package/src/telemetry/sink.ts +8 -0
  51. package/src/text/edits.ts +16 -0
  52. package/src/text/parsing.ts +35 -0
  53. package/src/text/preview.ts +18 -0
  54. package/src/text/tokens.ts +64 -0
  55. package/src/tool/apply-diff-tool.ts +125 -0
  56. package/src/tool/emitter-listener.ts +24 -0
  57. package/src/tool/repl-details.ts +23 -0
  58. package/src/tool/repl-tool.ts +528 -0
  59. package/src/tool/rlm-aggregator.ts +115 -0
  60. package/src/tool/rlm-details.ts +53 -0
  61. package/src/tool/rlm-events.ts +215 -0
  62. package/src/tool/rlm-tool.ts +199 -0
  63. package/src/tool/subcall-render.ts +129 -0
  64. package/src/tool/subcall-store.ts +90 -0
  65. package/src/tool/tool-utils.ts +73 -0
  66. package/src/ui/config-panel.ts +92 -0
  67. package/src/ui/intro.ts +23 -0
  68. package/src/ui/model-picker.ts +139 -0
  69. package/src/ui/status.ts +26 -0
  70. package/src/ui/theme.ts +47 -0
  71. package/src/util/concurrency.ts +15 -0
  72. package/src/util/errors.ts +27 -0
@@ -0,0 +1,457 @@
1
+ """RLM sandbox worker: a persistent Python REPL driven over a JSONL stdio protocol.
2
+
3
+ Executes model-authored Python with secrets stripped from the environment.
4
+ This is NOT a security sandbox: __import__ and open are available, so code can import networking modules
5
+ (socket, urllib, subprocess) and read/write local files. Trust the root model's code.
6
+
7
+ Protocol (parent -> worker): {"id","type":"exec"|"load_context"|"shutdown", ...}
8
+ Protocol (worker -> parent): {"id","ok",...result} # response to a request
9
+ {"type":"llm_query"|"llm_query_batched"|"rlm_query"|...
10
+ "advance_phase"|"ask_user_question"|"todo","rid",...}
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
13
+ and BLOCKS reading stdin until the matching {"type":"llm_reply","rid",...} arrives. The parent
14
+ services the request in-process (it holds API keys).
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import argparse
20
+ import io
21
+ import json
22
+ import os
23
+ import pickle
24
+ import signal
25
+ import sys
26
+ import time
27
+ import traceback
28
+ from contextlib import contextmanager
29
+ from typing import Any
30
+
31
+ # Capture the REAL stdout/stdin before exec() redirects sys.stdout into a buffer.
32
+ # All protocol writes must go to the real stdout even while user code's prints are captured.
33
+ _REAL_STDOUT = sys.stdout
34
+ _REAL_STDIN = sys.stdin
35
+
36
+
37
+ def _builtin(name: str):
38
+ return __builtins__[name] if isinstance(__builtins__, dict) else getattr(__builtins__, name, None)
39
+
40
+
41
+ # Restricted builtins: enough for real data work, minus the dangerous reflection escapes.
42
+ _SAFE_BUILTINS = {
43
+ name: _builtin(name)
44
+ for name in (
45
+ "abs", "all", "any", "ascii", "bin", "bool", "bytearray", "bytes", "callable",
46
+ "chr", "classmethod", "complex", "dict", "dir", "divmod", "enumerate", "filter",
47
+ "float", "format", "frozenset", "getattr", "hasattr", "hash", "hex", "id", "int",
48
+ "isinstance", "issubclass", "iter", "len", "list", "map", "max", "min", "next",
49
+ "object", "oct", "ord", "pow", "print", "property", "range", "repr", "reversed",
50
+ "round", "set", "setattr", "slice", "sorted", "staticmethod", "str", "sum", "super",
51
+ "tuple", "type", "vars", "zip", "delattr", "memoryview", "__import__", "__build_class__",
52
+ "Exception", "BaseException", "ValueError", "TypeError", "KeyError", "IndexError",
53
+ "AttributeError", "FileNotFoundError", "OSError", "IOError", "RuntimeError",
54
+ "NameError", "ImportError", "StopIteration", "AssertionError", "NotImplementedError",
55
+ "ArithmeticError", "ZeroDivisionError", "LookupError", "Warning", "True", "False", "None",
56
+ )
57
+ }
58
+ # `open` is allowed (data work needs files); eval/exec/compile/input/globals/locals are not.
59
+ _SAFE_BUILTINS["open"] = open
60
+ for _blocked in ("eval", "exec", "compile", "input", "globals", "locals"):
61
+ _SAFE_BUILTINS[_blocked] = None
62
+
63
+ RESERVED = frozenset(
64
+ {
65
+ "llm_query", "llm_query_batched", "rlm_query", "rlm_query_batched",
66
+ "advance_phase",
67
+ "ask_user_question", "todo",
68
+ "SHOW_EDITS", "SHOW_DIFFS", "SHOW_VARS", "answer", "context",
69
+ }
70
+ )
71
+
72
+
73
+ class _AnswerDict(dict):
74
+ """`answer` dict; flipping `ready` True captures the final answer for the parent."""
75
+
76
+ def __init__(self, on_ready):
77
+ super().__init__()
78
+ super().__setitem__("content", "")
79
+ super().__setitem__("ready", False)
80
+ self._on_ready = on_ready
81
+
82
+ def __setitem__(self, key, value):
83
+ super().__setitem__(key, value)
84
+ if key == "ready" and value:
85
+ self._on_ready(self.get("content", ""))
86
+
87
+
88
+ def _send(obj: dict[str, Any]) -> None:
89
+ _REAL_STDOUT.write(json.dumps(obj, ensure_ascii=False) + "\n")
90
+ _REAL_STDOUT.flush()
91
+
92
+
93
+ class Worker:
94
+ def __init__(self, depth: int, exec_timeout_s: float):
95
+ self.depth = depth
96
+ self.exec_timeout_s = exec_timeout_s
97
+ self._rid = 0
98
+ self._final_answer: str | None = None
99
+ self._context_count = 0
100
+ self.ns: dict[str, Any] = {}
101
+ self._setup()
102
+
103
+ def _setup(self) -> None:
104
+ self.ns = {"__builtins__": _SAFE_BUILTINS.copy(), "__name__": "__main__"}
105
+ self._ctx_payloads: dict[int, Any] = {}
106
+ self._restore_scaffold()
107
+
108
+ def _capture_answer(self, content: Any) -> None:
109
+ self._final_answer = str(content)
110
+
111
+ def _restore_scaffold(self) -> None:
112
+ # Re-inject any scaffolding the user code clobbered.
113
+ ns = self.ns
114
+ ns["llm_query"] = self._llm_query
115
+ ns["llm_query_batched"] = self._llm_query_batched
116
+ ns["rlm_query"] = self._rlm_query
117
+ ns["rlm_query_batched"] = self._rlm_query_batched
118
+ ns["advance_phase"] = self._advance_phase
119
+ ns["ask_user_question"] = self._ask_user_question
120
+ ns["todo"] = self._todo
121
+ ns["SHOW_EDITS"] = self._show_edits
122
+ ns["SHOW_DIFFS"] = self._show_diffs
123
+ ns["SHOW_VARS"] = self._show_vars
124
+ if not isinstance(ns.get("answer"), _AnswerDict):
125
+ cur = ns.get("answer")
126
+ ans = _AnswerDict(self._capture_answer)
127
+ if isinstance(cur, dict):
128
+ for k, v in cur.items():
129
+ dict.__setitem__(ans, k, v)
130
+ if cur.get("ready") and self._final_answer is None:
131
+ self._final_answer = str(cur.get("content", ""))
132
+ ns["answer"] = ans
133
+ # Restore context slots from immutable originals so REPL mutations don't persist.
134
+ for idx, payload in self._ctx_payloads.items():
135
+ ns[f"context_{idx}"] = payload
136
+ if 0 in self._ctx_payloads:
137
+ ns["context"] = self._ctx_payloads[0]
138
+
139
+ def _user_var_names(self) -> list[str]:
140
+ """User-created variable names — filters builtins, scaffold, and context slots.
141
+
142
+ Shared by SHOW_VARS() and the exec result so both expose the same namespace view.
143
+ This is the cheap orientation hint that goes into history instead of full stdout.
144
+ """
145
+ return [
146
+ k for k in self.ns
147
+ if not k.startswith("_")
148
+ and not k.startswith("context_")
149
+ and k not in RESERVED
150
+ ]
151
+
152
+ def _show_vars(self) -> str:
153
+ avail = {k: type(self.ns[k]).__name__ for k in self._user_var_names()}
154
+ return f"Available variables: {avail}" if avail else "No variables created yet."
155
+
156
+ def _show_edits(self) -> str:
157
+ return "No edits — edit tools are not available in this run."
158
+
159
+ def _show_diffs(self) -> str:
160
+ return "No diffs — edit tools are not available in this run."
161
+
162
+ # ---- sub-LLM bridge over stdio --------------------------------------------------------
163
+
164
+ def _rpc(self, kind: str, payload: dict[str, Any]) -> dict[str, Any]:
165
+ self._rid += 1
166
+ rid = f"q{self._rid}"
167
+ _send({"type": kind, "rid": rid, "depth": self.depth, **payload})
168
+ # The per-cell SIGALRM is wall-clock; it must not count time blocked here waiting for
169
+ # a sub-LLM reply (network/LLM latency, not local CPU). Pause it across the readline.
170
+ pause = self.exec_timeout_s > 0 and hasattr(signal, "SIGALRM")
171
+ if pause:
172
+ remaining = signal.getitimer(signal.ITIMER_REAL)[0]
173
+ signal.setitimer(signal.ITIMER_REAL, 0)
174
+ try:
175
+ while True:
176
+ line = _REAL_STDIN.readline()
177
+ if not line:
178
+ raise RuntimeError("parent closed the pipe during a sub-LLM request")
179
+ msg = json.loads(line)
180
+ if msg.get("type") == "llm_reply" and msg.get("rid") == rid:
181
+ return msg
182
+ # The parent only ever sends our reply mid-exec; anything else is a protocol error.
183
+ raise RuntimeError(f"unexpected parent message during sub-LLM request: {msg!r}")
184
+ finally:
185
+ if pause and remaining > 0:
186
+ signal.setitimer(signal.ITIMER_REAL, remaining)
187
+
188
+ def _llm_query(self, prompt: str, model: str | None = None) -> str:
189
+ r = self._rpc("llm_query", {"prompt": str(prompt), "model": model})
190
+ return f"Error: {r['error']}" if r.get("error") else r.get("response", "")
191
+
192
+ def _llm_query_batched(self, prompts, model: str | None = None) -> list[str]:
193
+ prompts = [str(p) for p in prompts]
194
+ if not prompts:
195
+ return []
196
+ r = self._rpc("llm_query_batched", {"prompts": prompts, "model": model})
197
+ if r.get("error"):
198
+ return [f"Error: {r['error']}"] * len(prompts)
199
+ out = r.get("responses")
200
+ if not isinstance(out, list) or len(out) != len(prompts):
201
+ return ["Error: malformed batched response"] * len(prompts)
202
+ return [s if isinstance(s, str) else f"Error: {s}" for s in out]
203
+
204
+ def _rlm_query(self, prompt: str, model: str | None = None) -> str:
205
+ r = self._rpc("rlm_query", {"prompt": str(prompt), "model": model})
206
+ return f"Error: {r['error']}" if r.get("error") else r.get("response", "")
207
+
208
+ def _ask_user_question(self, questions: list[dict]) -> list[dict]:
209
+ """Present structured questions to the user; blocks until answered.
210
+
211
+ Returns a list of {question, selected, custom?} dicts.
212
+ Each dict has: question (str), selected (list[str]), custom (str|None).
213
+ Only valid at root depth; sub-RLM calls return an error answer.
214
+ """
215
+ if self.depth > 0:
216
+ qlist = questions if isinstance(questions, list) else []
217
+ return [
218
+ {"question": str(q.get("question", "")) if isinstance(q, dict) else "",
219
+ "selected": [],
220
+ "custom": "Error: ask_user_question not available inside rlm_query sub-calls"}
221
+ for q in qlist
222
+ ] or [{"question": "", "selected": [],
223
+ "custom": "Error: ask_user_question not available inside rlm_query sub-calls"}]
224
+ if not isinstance(questions, list) or not questions:
225
+ return [{"question": "", "selected": [], "custom": "Error: questions must be a non-empty list"}]
226
+ cleaned = []
227
+ for q in questions:
228
+ if not isinstance(q, dict) or "question" not in q or "options" not in q:
229
+ return [{"question": "", "selected": [], "custom": "Error: each question needs 'question', 'header', 'options'"}]
230
+ opts = q.get("options")
231
+ if not isinstance(opts, list):
232
+ return [{"question": str(q.get("question", "")), "selected": [], "custom": "Error: options must be a list"}]
233
+ cleaned_opts = []
234
+ for o in opts:
235
+ if not isinstance(o, dict) or "label" not in o:
236
+ return [{"question": str(q.get("question", "")), "selected": [], "custom": "Error: each option needs 'label'"}]
237
+ item = {"label": str(o["label"]), "description": str(o.get("description", ""))}
238
+ if "preview" in o:
239
+ item["preview"] = str(o["preview"])
240
+ cleaned_opts.append(item)
241
+ cleaned.append({
242
+ "question": str(q["question"]),
243
+ "header": str(q.get("header", "Q")),
244
+ "multiSelect": bool(q.get("multiSelect", False)),
245
+ "options": cleaned_opts,
246
+ })
247
+ r = self._rpc("ask_user_question", {"questions": cleaned})
248
+ if r.get("error"):
249
+ return [{"question": q["question"], "selected": [], "custom": f"Error: {r['error']}"} for q in cleaned]
250
+ answers = r.get("answers", [])
251
+ return answers if isinstance(answers, list) else []
252
+
253
+ def _todo(self, action: str, **kwargs) -> str:
254
+ """Manage the run's task list.
255
+
256
+ action: "create" | "update" | "list" | "get" | "delete" | "clear"
257
+ kwargs: id, subject, description, status, activeForm, blockedBy, owner, filterStatus
258
+ Returns a human-readable string result.
259
+ """
260
+ params = {k: v for k, v in kwargs.items() if v is not None}
261
+ r = self._rpc("todo", {"action": str(action), **params})
262
+ if r.get("error"):
263
+ return f"Error: {r['error']}"
264
+ return str(r.get("response", "ok"))
265
+
266
+ def _advance_phase(self, phase: str, summary: str | None = None) -> str:
267
+ """Transition the root RLM pipeline to a new phase.
268
+
269
+ Only callable at depth 0. The parent handler validates the transition
270
+ against the phase state machine (research → blueprint → implement → validate).
271
+ Returns a short confirmation, or an `Error: …` string the model can act on.
272
+ """
273
+ if self.depth > 0:
274
+ return "Error: advance_phase is only available at the root RLM depth"
275
+ r = self._rpc("advance_phase", {"phase": str(phase), "summary": summary})
276
+ if r.get("error"):
277
+ return f"Error: {r['error']}"
278
+ response = r.get("response", "ok")
279
+ if isinstance(response, str) and response.startswith("Error:"):
280
+ return response
281
+ return response if isinstance(response, str) else "ok"
282
+
283
+ def _rlm_query_batched(self, prompts, model: str | None = None) -> list[str]:
284
+ prompts = [str(p) for p in prompts]
285
+ if not prompts:
286
+ return []
287
+ r = self._rpc("rlm_query_batched", {"prompts": prompts, "model": model})
288
+ if r.get("error"):
289
+ return [f"Error: {r['error']}"] * len(prompts)
290
+ out = r.get("responses")
291
+ if not isinstance(out, list) or len(out) != len(prompts):
292
+ return ["Error: malformed batched response"] * len(prompts)
293
+ return [s if isinstance(s, str) else f"Error: {s}" for s in out]
294
+
295
+ # ---- context + execution --------------------------------------------------------------
296
+
297
+ def load_context(self, path: str, index: int | None = None, is_json: bool = False) -> int:
298
+ if index is None:
299
+ index = self._context_count
300
+ with open(path, "r") as f:
301
+ payload = json.load(f) if is_json else f.read()
302
+ self._ctx_payloads[index] = payload
303
+ self.ns[f"context_{index}"] = payload
304
+ if index == 0:
305
+ self.ns["context"] = payload
306
+ self._context_count = max(self._context_count, index + 1)
307
+ return index
308
+
309
+ @contextmanager
310
+ def _capture(self):
311
+ out, err = io.StringIO(), io.StringIO()
312
+ old_out, old_err = sys.stdout, sys.stderr
313
+ sys.stdout, sys.stderr = out, err
314
+ try:
315
+ yield out, err
316
+ finally:
317
+ sys.stdout, sys.stderr = old_out, old_err
318
+
319
+ def _exec(self, code: str, ns: dict[str, Any]) -> None:
320
+ t = self.exec_timeout_s
321
+ if t <= 0 or not hasattr(signal, "SIGALRM"):
322
+ exec(compile(code, "<repl>", "exec"), ns, ns) # noqa: S102
323
+ return
324
+
325
+ def _alarm(signum, frame): # noqa: ARG001
326
+ raise TimeoutError(f"```repl``` block exceeded {t:g}s timeout")
327
+
328
+ old = signal.signal(signal.SIGALRM, _alarm)
329
+ signal.setitimer(signal.ITIMER_REAL, t)
330
+ try:
331
+ exec(compile(code, "<repl>", "exec"), ns, ns) # noqa: S102
332
+ finally:
333
+ signal.setitimer(signal.ITIMER_REAL, 0)
334
+ signal.signal(signal.SIGALRM, old)
335
+
336
+ def execute(self, code: str) -> dict[str, Any]:
337
+ start = time.perf_counter()
338
+ raised = False
339
+ with self._capture() as (out, err):
340
+ try:
341
+ self._restore_scaffold()
342
+ self._exec(code, self.ns)
343
+ self._restore_scaffold()
344
+ stdout, stderr = out.getvalue(), err.getvalue()
345
+ except BaseException as e: # noqa: BLE001
346
+ raised = True
347
+ self._restore_scaffold()
348
+ stdout = out.getvalue()
349
+ stderr = err.getvalue() + f"\n{type(e).__name__}: {e}\n" + traceback.format_exc()
350
+ final, self._final_answer = self._final_answer, None
351
+ answer = self.ns.get("answer")
352
+ answer_content = answer.get("content", "") if isinstance(answer, dict) else ""
353
+ return {
354
+ "stdout": stdout,
355
+ "stderr": stderr,
356
+ "final_answer": final,
357
+ "answer_content": str(answer_content),
358
+ "edits": [],
359
+ "diffs": [],
360
+ "raised": raised,
361
+ "execution_time": time.perf_counter() - start,
362
+ "var_names": self._user_var_names(),
363
+ }
364
+
365
+ def _serializer(self):
366
+ try:
367
+ import dill as s
368
+ return s
369
+ except ImportError:
370
+ return pickle
371
+
372
+ def snapshot(self, path: str, nonce: str) -> dict:
373
+ """Pickle user variables atomically to path. Stores session nonce for restore verification.
374
+
375
+ Writes to path.tmp then os.rename — atomic on POSIX, so no .tmp leak and no
376
+ TypeScript-side finalize step needed. On resume (fresh session = different nonce),
377
+ restore fails — caller falls back to history-only replay.
378
+ """
379
+ s = self._serializer()
380
+ out, skipped = {}, []
381
+ MAX_VAR_BYTES = 50 * 1024 * 1024
382
+ for k, v in self.ns.items():
383
+ if k.startswith("_") or k.startswith("context") or k in RESERVED or k == "__builtins__":
384
+ continue
385
+ try:
386
+ blob = s.dumps(v)
387
+ if len(blob) > MAX_VAR_BYTES:
388
+ skipped.append(k)
389
+ continue
390
+ out[k] = v
391
+ except Exception:
392
+ skipped.append(k)
393
+ if skipped:
394
+ print(f"[rlm-sandbox] snapshot skipped {len(skipped)} unpicklable/oversized vars: {skipped}", file=sys.stderr)
395
+ tmp = path + ".tmp"
396
+ with open(tmp, "wb") as f:
397
+ s.dump({"nonce": nonce, "vars": out}, f)
398
+ os.rename(tmp, path) # atomic rename
399
+ return {"skipped": skipped}
400
+
401
+ def restore(self, path: str, nonce: str) -> dict:
402
+ """Restore user variables from a pickle file. Verifies session nonce before deserializing.
403
+
404
+ SECURITY: pickle.load executes arbitrary code. The session nonce check ensures the
405
+ .pkl was written by THIS engine session. Cross-session resume falls back to
406
+ history-only replay (caller skips restore when sessionNonce is undefined).
407
+ """
408
+ s = self._serializer()
409
+ with open(path, "rb") as f:
410
+ data = s.load(f)
411
+ if not isinstance(data, dict) or data.get("nonce") != nonce:
412
+ raise ValueError("snapshot nonce mismatch — not from this session")
413
+ self.ns.update(data.get("vars", {}))
414
+ self._restore_scaffold()
415
+ return {"restored": list(data.get("vars", {}).keys())}
416
+
417
+
418
+ def main() -> None:
419
+ ap = argparse.ArgumentParser()
420
+ ap.add_argument("--depth", type=int, default=int(os.environ.get("RLM_DEPTH", "1")))
421
+ ap.add_argument("--timeout", type=float, default=float(os.environ.get("RLM_EXEC_TIMEOUT_S", "600")))
422
+ args = ap.parse_args()
423
+
424
+ worker = Worker(depth=args.depth, exec_timeout_s=args.timeout)
425
+ _send({"id": "_init", "ok": True})
426
+
427
+ for raw in _REAL_STDIN:
428
+ raw = raw.strip()
429
+ if not raw:
430
+ continue
431
+ try:
432
+ req = json.loads(raw)
433
+ except json.JSONDecodeError as e:
434
+ _send({"id": "?", "ok": False, "error": f"bad json: {e}"})
435
+ continue
436
+ rid, kind = req.get("id", "?"), req.get("type")
437
+ try:
438
+ if kind == "exec":
439
+ _send({"id": rid, "ok": True, **worker.execute(req.get("code", ""))})
440
+ elif kind == "load_context":
441
+ idx = worker.load_context(req.get("path"), req.get("index"), req.get("json"))
442
+ _send({"id": rid, "ok": True, "index": idx})
443
+ elif kind == "shutdown":
444
+ _send({"id": rid, "ok": True})
445
+ return
446
+ elif kind == "snapshot":
447
+ _send({"id": rid, "ok": True, **worker.snapshot(req.get("path", ""), req.get("nonce", ""))})
448
+ elif kind == "restore":
449
+ _send({"id": rid, "ok": True, **worker.restore(req.get("path", ""), req.get("nonce", ""))})
450
+ else:
451
+ _send({"id": rid, "ok": False, "error": f"unknown type: {kind!r}"})
452
+ except BaseException as e: # noqa: BLE001
453
+ _send({"id": rid, "ok": False, "error": f"{type(e).__name__}: {e}\n{traceback.format_exc()}"})
454
+
455
+
456
+ if __name__ == "__main__":
457
+ main()
@@ -0,0 +1,22 @@
1
+ /**
2
+ * SubcallStart — parameter object carried forward for telemetry compatibility.
3
+ *
4
+ * The SubcallObserver interface, treeObserver(), observerWith(), and NOOP_OBSERVER
5
+ * have been removed. The engine and bridges now call RlmToolBridge directly.
6
+ */
7
+
8
+ import type { SubcallKind } from "../tool/rlm-details.ts";
9
+
10
+ export interface SubcallStart {
11
+ readonly kind: SubcallKind;
12
+ readonly depth: number;
13
+ readonly parentId?: string;
14
+ readonly model?: string;
15
+ readonly label: string;
16
+ readonly detail?: string;
17
+ readonly args?: string;
18
+ /** Run ID for the root node — lets MLflow correlate a resumed trace with the original. */
19
+ readonly runId?: string;
20
+ /** True when this is a resumed root node (not a fresh start). */
21
+ readonly resume?: boolean;
22
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Barrel for the RLM run-state module.
3
+ *
4
+ * Re-exports every public symbol so `core/engine.ts` and `mode/rlm-mode.ts`
5
+ * import from one door. Type-only re-exports use `export type`.
6
+ */
7
+
8
+ export { generateRunId, runsDir, runDir, trailPath, contextPath, snapshotPath } from "./paths.ts";
9
+ export type {
10
+ UsageRow,
11
+ RunHeader,
12
+ TurnRow,
13
+ CompactionRow,
14
+ TerminalRow,
15
+ TodoRow,
16
+ PhaseRow,
17
+ Row,
18
+ } from "./rows.ts";
19
+ export { STATE_SCHEMA_VERSION, isHeader, isTurn, isCompaction, isPhase, isTodo, isTerminal, isRow } from "./rows.ts";
20
+ export { appendRow, appendTodoRow, pruneRuns, writeContextSidecar } from "./writes.ts";
21
+ export { readRows, readHeader, readContextSidecar, listRunIds, resolveRunId } from "./reads.ts";
22
+ export { reconstructRlmState } from "./resume.ts";
23
+ export type { PhaseRecon, ReconstructResult } from "./resume.ts";
@@ -0,0 +1,46 @@
1
+ /** Internal helpers shared across the RLM run-state module. */
2
+
3
+ import { access, readdir } from "node:fs/promises";
4
+ import { errorMessage } from "../util/errors.ts";
5
+
6
+ export { errorMessage } from "../util/errors.ts";
7
+
8
+ export interface FailSoftOptions {
9
+ readonly label?: string;
10
+ readonly warn?: boolean;
11
+ }
12
+
13
+ const DEFAULT_FAIL_SOFT_OPTIONS = Object.freeze({});
14
+
15
+ export const warn = (e: unknown): void => console.warn(`[rlm-state] ${errorMessage(e)}`);
16
+
17
+ export async function failSoft<T>(
18
+ fn: () => Promise<T>,
19
+ fallback: T,
20
+ options: FailSoftOptions = DEFAULT_FAIL_SOFT_OPTIONS,
21
+ ): Promise<T> {
22
+ try {
23
+ return await fn();
24
+ } catch (e) {
25
+ if (options.warn !== false) warn(options.label ? `${options.label}: ${errorMessage(e)}` : e);
26
+ return fallback;
27
+ }
28
+ }
29
+
30
+ export async function listDirectoriesSorted(root: string): Promise<string[]> {
31
+ const entries = await readdir(root, { withFileTypes: true });
32
+ return entries
33
+ .filter((entry) => entry.isDirectory())
34
+ .map((entry) => entry.name)
35
+ .sort()
36
+ .reverse();
37
+ }
38
+
39
+ export async function pathExists(path: string): Promise<boolean> {
40
+ try {
41
+ await access(path);
42
+ return true;
43
+ } catch {
44
+ return false;
45
+ }
46
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Pure path/id helpers for the RLM run-state module.
3
+ *
4
+ * Run-IDs are filename-sortable ISO-like slugs with a random hex suffix
5
+ * for sub-second collision safety. All helpers are pure — no I/O.
6
+ */
7
+
8
+ import { randomBytes } from "node:crypto";
9
+ import { isAbsolute, join } from "node:path";
10
+
11
+ const RUN_ID_SUFFIX_BYTES = 2;
12
+ const ISO_DATETIME_LENGTH = 19;
13
+
14
+ /** `YYYY-MM-DD_HH-MM-SS-<4hex>` — filename-sortable, sub-second collision-safe.
15
+ *
16
+ * Prune ordering in writes.ts:pruneRuns depends on the ISO-slug format producing
17
+ * chronologically sortable strings. If the format changes, update pruning logic
18
+ * to maintain oldest-first deletion. */
19
+ export function generateRunId(
20
+ now: Date = new Date(),
21
+ suffix: string = randomBytes(RUN_ID_SUFFIX_BYTES).toString("hex"),
22
+ ): string {
23
+ const pad = (n: number) => String(n).padStart(2, "0");
24
+ const iso = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}T${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`;
25
+ return `${iso.slice(0, ISO_DATETIME_LENGTH).replaceAll(":", "-").replace("T", "_")}-${suffix}`;
26
+ }
27
+
28
+ export const runsDir = (cwd: string, dir: string): string =>
29
+ isAbsolute(dir) ? dir : join(cwd, dir);
30
+
31
+ export const runDir = (cwd: string, dir: string, runId: string): string => join(runsDir(cwd, dir), runId);
32
+
33
+ export const trailPath = (cwd: string, dir: string, runId: string): string => join(runDir(cwd, dir, runId), "trail.jsonl");
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");
37
+
38
+ /** R-C1: per-turn snapshot files — `sandbox-<turn>.pkl` so resume can fall back to a prior turn if the latest rename failed. */
39
+ export function snapshotPath(cwd: string, dir: string, runId: string, turn?: number): string {
40
+ const name = turn !== undefined ? `sandbox-${turn}.pkl` : "sandbox.pkl";
41
+ return join(runDir(cwd, dir, runId), name);
42
+ }