@hicaru/pi-rlm 0.1.8 → 0.2.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 (52) hide show
  1. package/README.md +22 -19
  2. package/package.json +2 -1
  3. package/src/bridge/library.ts +93 -15
  4. package/src/bridge/llm-query.ts +60 -36
  5. package/src/bridge/rlm-query.ts +63 -79
  6. package/src/commands/rlm-config.ts +8 -8
  7. package/src/commands/rlm.ts +48 -12
  8. package/src/config/settings.ts +33 -3
  9. package/src/context/library-context.ts +209 -22
  10. package/src/context/repomix-context.ts +7 -58
  11. package/src/core/answer.ts +5 -13
  12. package/src/core/artifacts.ts +4 -3
  13. package/src/core/critique.ts +92 -0
  14. package/src/core/engine.ts +94 -299
  15. package/src/core/gates.ts +33 -4
  16. package/src/core/limits.ts +19 -1
  17. package/src/core/pipeline-handlers.ts +319 -0
  18. package/src/core/pipeline.ts +40 -15
  19. package/src/core/types.ts +26 -30
  20. package/src/index.ts +36 -26
  21. package/src/mode/native-guards.ts +2 -2
  22. package/src/mode/rlm-mode.ts +8 -11
  23. package/src/prompts/phases.ts +18 -39
  24. package/src/prompts/system.ts +167 -64
  25. package/src/prompts/user.ts +1 -5
  26. package/src/sandbox/protocol.ts +5 -17
  27. package/src/sandbox/sandbox-manager.ts +5 -5
  28. package/src/sandbox/sandbox.ts +67 -27
  29. package/src/sandbox/worker.py +534 -48
  30. package/src/state/paths.ts +1 -1
  31. package/src/state/reads.ts +12 -4
  32. package/src/state/resume.ts +26 -25
  33. package/src/state/rows.ts +2 -2
  34. package/src/text/parsing.ts +0 -6
  35. package/src/text/tokens.ts +7 -1
  36. package/src/tool/repl-details.ts +2 -3
  37. package/src/tool/repl-tool.ts +132 -337
  38. package/src/tool/rlm-aggregator.ts +7 -7
  39. package/src/tool/rlm-details.ts +6 -13
  40. package/src/tool/rlm-events.ts +14 -11
  41. package/src/tool/rlm-tool.ts +20 -38
  42. package/src/tool/subcall-render.ts +61 -9
  43. package/src/tool/subcall-store.ts +4 -2
  44. package/src/ui/config-panel.ts +43 -23
  45. package/src/ui/intro.ts +2 -1
  46. package/src/ui/status.ts +8 -5
  47. package/src/ui/theme-adapter.ts +36 -0
  48. package/src/ui/theme.ts +0 -25
  49. package/src/mode/input-router.ts +0 -23
  50. package/src/registry/edit-registry.ts +0 -22
  51. package/src/text/edits.ts +0 -164
  52. package/src/tool/apply-edits-tool.ts +0 -295
@@ -17,8 +17,11 @@ services the request in-process (it holds API keys).
17
17
  from __future__ import annotations
18
18
 
19
19
  import argparse
20
+ import fnmatch
21
+ import heapq
20
22
  import io
21
23
  import json
24
+ import math
22
25
  import os
23
26
  import pickle
24
27
  import re
@@ -57,8 +60,44 @@ _SAFE_BUILTINS = {
57
60
  "ArithmeticError", "ZeroDivisionError", "LookupError", "Warning", "True", "False", "None",
58
61
  )
59
62
  }
60
- # `open` is allowed (data work needs files); eval/exec/compile/input/globals/locals are not.
61
- _SAFE_BUILTINS["open"] = open
63
+ # `open` is allowed for data work; eval/exec/compile/input/globals/locals are not.
64
+ # When read_only=True (pipeline runs), write modes raise PermissionError via
65
+ # builtins.open, io.open (pathlib), and os.open. Steering, not a security sandbox.
66
+ _WRITE_MODE_CHARS = frozenset("wax+")
67
+ _OS_WRITE_FLAGS = os.O_WRONLY | os.O_RDWR | os.O_CREAT | os.O_APPEND | os.O_TRUNC
68
+
69
+ _REAL_IO_OPEN = io.open
70
+ _REAL_OS_OPEN = os.open
71
+
72
+
73
+ def _install_read_only_guards():
74
+ """Route every common file-open path through the read-only check.
75
+
76
+ Steering, not a sandbox: closes builtins.open, io.open (hence pathlib), and
77
+ os.open. A determined model can still reach the filesystem via ctypes or a
78
+ subprocess — the goal is that ACCIDENTAL writes cannot pass silently.
79
+ Worker-internal I/O keeps using _REAL_IO_OPEN / _REAL_OS_OPEN.
80
+ """
81
+ def guarded_io_open(file, mode="r", *args, **kwargs):
82
+ if _WRITE_MODE_CHARS & set(str(mode)):
83
+ raise PermissionError(
84
+ f"read-only RLM run: refusing to open {file!r} with mode {mode!r}. "
85
+ "This pipeline produces a plan; file changes go through the host edit tool."
86
+ )
87
+ return _REAL_IO_OPEN(file, mode, *args, **kwargs)
88
+
89
+ def guarded_os_open(path, flags, *args, **kwargs):
90
+ if flags & _OS_WRITE_FLAGS:
91
+ raise PermissionError(
92
+ f"read-only RLM run: refusing os.open({path!r}) with write flags."
93
+ )
94
+ return _REAL_OS_OPEN(path, flags, *args, **kwargs)
95
+
96
+ io.open = guarded_io_open
97
+ os.open = guarded_os_open
98
+ return guarded_io_open
99
+
100
+
62
101
  for _blocked in ("eval", "exec", "compile", "input", "globals", "locals"):
63
102
  _SAFE_BUILTINS[_blocked] = None
64
103
 
@@ -66,13 +105,19 @@ RESERVED = frozenset(
66
105
  {
67
106
  "llm_query", "llm_query_batched", "llm_query_chunked",
68
107
  "rlm_query", "rlm_query_batched",
108
+ "map_files", "llm_map_reduce",
109
+ "search", "grep_context", "outline",
69
110
  "advance_phase", "save_artifact",
70
111
  "ask_user_question", "todo",
71
- "stage_edit", "load_library",
112
+ "load_library",
72
113
  "SHOW_VARS", "answer", "context",
73
114
  }
74
115
  )
75
- _CONTEXT_SLOT = re.compile(r"context(_\d+)?\Z")
116
+ # NOTE: `answers` and `plan` are deliberately NOT reserved. They are seeded by the scaffold but
117
+ # owned by the model, so they must appear in SHOW_VARS and be captured by snapshots — losing a
118
+ # memoized answer across a resume is exactly the failure the memo exists to prevent.
119
+ # Only the single name `context` is the packed world. Legacy context_N names are filtered out.
120
+ _CONTEXT_NAME = re.compile(r"context(_\d+)?\Z")
76
121
 
77
122
  # Sizing for llm_query_chunked: leave room for the instruction and the chunk header.
78
123
  _CHUNK_HEADER_OVERHEAD = 64
@@ -97,6 +142,145 @@ def _chunk_text(text: str, chunk_chars: int) -> list[str]:
97
142
  return chunks
98
143
 
99
144
 
145
+ # ---- deterministic retrieval over `context` -----------------------------------------------
146
+ #
147
+ # The RLM paper's trajectories retrieve by having the root model hand-write regex over the
148
+ # context (App. E.1). Frontier models do that well; small/fast models guess keywords badly and
149
+ # the first decomposition attempt disproportionately decides the outcome (paper §5, Fig. 4a).
150
+ # These primitives make retrieval deterministic and token-free: no sub-LLM call, no root tokens
151
+ # spent on printed file bodies — the model gets ranked pointers and decides what to delegate.
152
+
153
+ _INDEX_WINDOW_LINES = 40 # a window is the retrieval unit: big enough to carry meaning
154
+ _INDEX_MAX_WINDOWS = 20_000 # ceiling so a huge load_library() cannot exhaust worker memory
155
+ _SNIPPET_CHARS = 400
156
+ _GREP_HARD_CAP = 200 # absolute ceiling on returned grep hits, whatever k asks for
157
+ _BM25_K1 = 1.2
158
+ _BM25_B = 0.75
159
+
160
+ _TOKEN_SPLIT = re.compile(r"[^0-9A-Za-z]+") # also splits snake_case and paths
161
+ _CAMEL_SPLIT = re.compile(r"(?<=[a-z0-9])(?=[A-Z])")
162
+
163
+ # Definition-ish lines across the languages this plugin is likely to meet. Deliberately
164
+ # lexical: an outline is an orientation aid, not a parse tree.
165
+ _OUTLINE_LINE = re.compile(
166
+ r"^\s*(?:"
167
+ r"(?:export\s+)?(?:default\s+)?(?:async\s+)?(?:function|class|interface|type|enum|struct|impl|trait|namespace)\s+\w+"
168
+ r"|(?:export\s+)?(?:const|let|var)\s+\w+\s*[:=]\s*(?:async\s*)?(?:function|\(|<)"
169
+ r"|(?:pub\s+)?(?:async\s+)?fn\s+\w+"
170
+ r"|def\s+\w+|class\s+\w+"
171
+ r"|func\s+\w+"
172
+ r"|#{1,4}\s+\S"
173
+ r")"
174
+ )
175
+
176
+
177
+ def _tokenize(text: str) -> list[str]:
178
+ """Lowercased alphanumeric runs, plus camelCase parts so `resolveModelId` matches `model id`."""
179
+ out: list[str] = []
180
+ for raw in _TOKEN_SPLIT.split(text):
181
+ if not raw:
182
+ continue
183
+ lowered = raw.lower()
184
+ out.append(lowered)
185
+ if len(raw) > 3:
186
+ parts = _CAMEL_SPLIT.split(raw)
187
+ if len(parts) > 1:
188
+ for part in parts:
189
+ piece = part.lower()
190
+ if piece and piece != lowered:
191
+ out.append(piece)
192
+ return out
193
+
194
+
195
+ def _context_entries(context: Any) -> list[tuple[str, str]]:
196
+ """(path, content) pairs for either context shape: list[dict] bundles or a raw string."""
197
+ if isinstance(context, str):
198
+ return [("<context>", context)]
199
+ if not isinstance(context, list):
200
+ return []
201
+ out: list[tuple[str, str]] = []
202
+ for i, item in enumerate(context):
203
+ if isinstance(item, dict):
204
+ content = item.get("content", "")
205
+ out.append((
206
+ str(item.get("path", f"<context[{i}]>")),
207
+ content if isinstance(content, str) else str(content),
208
+ ))
209
+ elif isinstance(item, str):
210
+ out.append((f"<context[{i}]>", item))
211
+ return out
212
+
213
+
214
+ class _Bm25Index:
215
+ """Okapi BM25 over fixed-line windows of `context`. Built lazily, discarded on change."""
216
+
217
+ __slots__ = ("paths", "starts", "texts", "postings", "doc_len", "avg_len", "truncated")
218
+
219
+ def __init__(self, entries: list[tuple[str, str]]) -> None:
220
+ self.paths: list[str] = []
221
+ self.starts: list[int] = []
222
+ self.texts: list[str] = []
223
+ self.postings: dict[str, list[tuple[int, int]]] = {}
224
+ self.doc_len: list[int] = []
225
+ self.truncated = False
226
+
227
+ for path, content in entries:
228
+ if not content:
229
+ continue
230
+ lines = content.split("\n")
231
+ for start in range(0, len(lines), _INDEX_WINDOW_LINES):
232
+ if len(self.texts) >= _INDEX_MAX_WINDOWS:
233
+ self.truncated = True
234
+ break
235
+ window = "\n".join(lines[start:start + _INDEX_WINDOW_LINES])
236
+ idx = len(self.texts)
237
+ self.paths.append(path)
238
+ self.starts.append(start + 1)
239
+ self.texts.append(window)
240
+ terms = _tokenize(window)
241
+ self.doc_len.append(len(terms))
242
+ freq: dict[str, int] = {}
243
+ for term in terms:
244
+ freq[term] = freq.get(term, 0) + 1
245
+ for term, tf in freq.items():
246
+ self.postings.setdefault(term, []).append((idx, tf))
247
+ if self.truncated:
248
+ break
249
+
250
+ total = len(self.doc_len)
251
+ self.avg_len = (sum(self.doc_len) / total) if total else 1.0
252
+
253
+ def query(self, terms: list[str], k: int, path_glob: str | None) -> list[dict[str, Any]]:
254
+ total = len(self.texts)
255
+ if total == 0:
256
+ return []
257
+ scores: dict[int, float] = {}
258
+ for term in set(terms):
259
+ posting = self.postings.get(term)
260
+ if not posting:
261
+ continue
262
+ df = len(posting)
263
+ idf = math.log(1.0 + (total - df + 0.5) / (df + 0.5))
264
+ for idx, tf in posting:
265
+ norm = _BM25_K1 * (1.0 - _BM25_B + _BM25_B * self.doc_len[idx] / self.avg_len)
266
+ scores[idx] = scores.get(idx, 0.0) + idf * (tf * (_BM25_K1 + 1.0)) / (tf + norm)
267
+ if path_glob:
268
+ scores = {i: s for i, s in scores.items() if fnmatch.fnmatch(self.paths[i], path_glob)}
269
+ if not scores:
270
+ return []
271
+ top = heapq.nlargest(k, scores.items(), key=lambda kv: kv[1])
272
+ out: list[dict[str, Any]] = [None] * len(top) # type: ignore[list-item]
273
+ for i, (idx, score) in enumerate(top):
274
+ text = self.texts[idx]
275
+ out[i] = {
276
+ "path": self.paths[idx],
277
+ "line": self.starts[idx],
278
+ "score": round(score, 3),
279
+ "snippet": text[:_SNIPPET_CHARS],
280
+ }
281
+ return out
282
+
283
+
100
284
  class _AnswerDict(dict):
101
285
  """`answer` dict; flipping `ready` True captures the final answer for the parent."""
102
286
 
@@ -118,22 +302,27 @@ def _send(obj: dict[str, Any]) -> None:
118
302
 
119
303
 
120
304
  class Worker:
121
- def __init__(self, depth: int, exec_timeout_s: float, max_prompt_chars: int):
305
+ def __init__(self, depth: int, exec_timeout_s: float, max_prompt_chars: int, read_only: bool = False):
122
306
  self.depth = depth
123
307
  self.exec_timeout_s = exec_timeout_s
124
308
  self.max_prompt_chars = max_prompt_chars
309
+ self.read_only = read_only
125
310
  self._rid = 0
126
311
  self._final_answer: str | None = None
127
- self._context_count = 0
128
312
  self.ns: dict[str, Any] = {}
129
313
  self._setup()
130
314
 
131
315
  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
316
+ builtins = _SAFE_BUILTINS.copy()
317
+ if self.read_only:
318
+ builtins["open"] = _install_read_only_guards()
319
+ else:
320
+ builtins["open"] = open
321
+ self.ns = {"__builtins__": builtins, "__name__": "__main__"}
322
+ self._context_payload: Any | None = None # pristine restore for the single `context` var
136
323
  self._nudged: set[str] = set()
324
+ self._index: _Bm25Index | None = None
325
+ self._index_stamp: tuple[int, int] | None = None # (id(context), len(context))
137
326
  self._restore_scaffold()
138
327
 
139
328
  def _capture_answer(self, content: Any) -> None:
@@ -147,11 +336,21 @@ class Worker:
147
336
  ns["llm_query_chunked"] = self._llm_query_chunked
148
337
  ns["rlm_query"] = self._rlm_query
149
338
  ns["rlm_query_batched"] = self._rlm_query_batched
339
+ ns["map_files"] = self._map_files
340
+ ns["llm_map_reduce"] = self._llm_map_reduce
341
+ ns["search"] = self._search
342
+ ns["grep_context"] = self._grep_context
343
+ ns["outline"] = self._outline
344
+ # env_tips memo (paper App. C.3): "If a value isn't in `answers`, it doesn't exist."
345
+ # Re-created only when deleted — contents must survive every turn.
346
+ if not isinstance(ns.get("answers"), dict):
347
+ ns["answers"] = {}
348
+ if not isinstance(ns.get("plan"), dict):
349
+ ns["plan"] = {}
150
350
  ns["advance_phase"] = self._advance_phase
151
351
  ns["save_artifact"] = self._save_artifact
152
352
  ns["ask_user_question"] = self._ask_user_question
153
353
  ns["todo"] = self._todo
154
- ns["stage_edit"] = self._stage_edit
155
354
  ns["load_library"] = self._load_library
156
355
  ns["SHOW_VARS"] = self._show_vars
157
356
  if not isinstance(ns.get("answer"), _AnswerDict):
@@ -163,17 +362,18 @@ class Worker:
163
362
  if cur.get("ready") and self._final_answer is None:
164
363
  self._final_answer = str(cur.get("content", ""))
165
364
  ns["answer"] = ans
166
- # Context slots are ordinary variables (RLM paper: the context lives in the
167
- # environment and the model may transform it in place). Re-inject only if the
168
- # model deleted the name entirely; mutations and re-binds persist within the run.
169
- # Resume reloads pristine context; keep derived resume-critical values in user vars.
170
- for idx, payload in self._ctx_payloads.items():
171
- ns.setdefault(f"context_{idx}", payload)
172
- if 0 in self._ctx_payloads:
173
- ns.setdefault("context", self._ctx_payloads[0])
365
+ # Single context variable (RLM paper: the context lives in the environment and
366
+ # the model may transform it in place). Re-inject only if the model deleted the
367
+ # name entirely; mutations and re-binds persist within the run.
368
+ if self._context_payload is not None:
369
+ ns.setdefault("context", self._context_payload)
370
+ # Scrub any legacy context_N names so the model never sees multi-slot APIs.
371
+ for k in list(ns.keys()):
372
+ if k != "context" and _CONTEXT_NAME.match(k):
373
+ del ns[k]
174
374
 
175
375
  def _user_var_names(self) -> list[str]:
176
- """User-created variable names — filters builtins, scaffold, and context slots.
376
+ """User-created variable names — filters builtins, scaffold, and `context`.
177
377
 
178
378
  Shared by SHOW_VARS() and the exec result so both expose the same namespace view.
179
379
  This is the cheap orientation hint that goes into history instead of full stdout.
@@ -181,7 +381,7 @@ class Worker:
181
381
  return [
182
382
  k for k in self.ns
183
383
  if not k.startswith("_")
184
- and not _CONTEXT_SLOT.match(k)
384
+ and not _CONTEXT_NAME.match(k)
185
385
  and k not in RESERVED
186
386
  ]
187
387
 
@@ -263,6 +463,200 @@ class Worker:
263
463
  results.extend(self._llm_query_batched(batch, model))
264
464
  return results
265
465
 
466
+ # ---- deterministic retrieval (no sub-LLM calls, no root tokens) -----------------------
467
+
468
+ def _entries(self) -> list[tuple[str, str]]:
469
+ return _context_entries(self.ns.get("context"))
470
+
471
+ def _get_index(self) -> _Bm25Index:
472
+ """Build the BM25 index on first use; rebuild when `context` was replaced or resized.
473
+
474
+ Identity+length is a cheap stamp that catches the two ways context actually changes:
475
+ load_library() extending the list, and the model re-binding the name. In-place edits
476
+ that preserve length are not detected — documented, and rare in practice.
477
+ """
478
+ ctx = self.ns.get("context")
479
+ stamp = (id(ctx), len(ctx) if isinstance(ctx, (list, str)) else 0)
480
+ if self._index is None or self._index_stamp != stamp:
481
+ self._index = _Bm25Index(self._entries())
482
+ self._index_stamp = stamp
483
+ return self._index
484
+
485
+ def _search(self, query: str, k: int = 10, path_glob: str | None = None) -> list[dict[str, Any]]:
486
+ """Rank `context` windows against a natural-language query (BM25).
487
+
488
+ Returns [{path, line, score, snippet}] — pointers, not bodies. Follow up by slicing the
489
+ named files out of `context` and delegating them to llm_query / map_files.
490
+ """
491
+ terms = _tokenize(str(query))
492
+ if not terms:
493
+ return []
494
+ try:
495
+ limit = max(1, min(int(k), 100))
496
+ except (TypeError, ValueError):
497
+ limit = 10
498
+ return self._get_index().query(terms, limit, path_glob)
499
+
500
+ def _grep_context(
501
+ self,
502
+ pattern: str,
503
+ k: int = 50,
504
+ path_glob: str | None = None,
505
+ before: int = 0,
506
+ after: int = 0,
507
+ ) -> dict[str, Any]:
508
+ """Regex over `context`, capped and shaped.
509
+
510
+ Returns {"hits": [{path, line, text}], "counts": {path: n}, "total": n, "truncated": bool}.
511
+ `counts` is complete even when `hits` is capped, so a wide pattern reports its shape
512
+ instead of flooding stdout.
513
+ """
514
+ try:
515
+ rx = re.compile(pattern)
516
+ except re.error as e:
517
+ return {"hits": [], "counts": {}, "total": 0, "truncated": False, "error": f"bad regex: {e}"}
518
+ try:
519
+ limit = max(1, min(int(k), _GREP_HARD_CAP))
520
+ except (TypeError, ValueError):
521
+ limit = 50
522
+ pad_before = max(0, min(int(before or 0), 10))
523
+ pad_after = max(0, min(int(after or 0), 10))
524
+
525
+ hits: list[dict[str, Any]] = []
526
+ counts: dict[str, int] = {}
527
+ total = 0
528
+ for path, content in self._entries():
529
+ if path_glob and not fnmatch.fnmatch(path, path_glob):
530
+ continue
531
+ if not rx.search(content):
532
+ continue
533
+ lines = content.split("\n")
534
+ for i, line in enumerate(lines):
535
+ if not rx.search(line):
536
+ continue
537
+ total += 1
538
+ counts[path] = counts.get(path, 0) + 1
539
+ if len(hits) >= limit:
540
+ continue
541
+ lo = max(0, i - pad_before)
542
+ hi = min(len(lines), i + pad_after + 1)
543
+ hits.append({"path": path, "line": i + 1, "text": "\n".join(lines[lo:hi])[:_SNIPPET_CHARS]})
544
+ return {"hits": hits, "counts": counts, "total": total, "truncated": total > len(hits)}
545
+
546
+ def _outline(self, path: str) -> str:
547
+ """Definition/heading skeleton of one context file — orient in ~200 chars, not 20K.
548
+
549
+ `path` matches exactly, then by suffix, then as a glob.
550
+ """
551
+ target = str(path)
552
+ entries = self._entries()
553
+ content: str | None = None
554
+ for p, c in entries:
555
+ if p == target:
556
+ content = c
557
+ break
558
+ if content is None:
559
+ for p, c in entries:
560
+ if p.endswith(target) or fnmatch.fnmatch(p, target):
561
+ content = c
562
+ target = p
563
+ break
564
+ if content is None:
565
+ return f"Error: no context file matching {path!r} — use search() or list paths from context"
566
+ out: list[str] = [f"# {target}"]
567
+ for i, line in enumerate(content.split("\n")):
568
+ if _OUTLINE_LINE.match(line):
569
+ out.append(f"{i + 1}: {line.strip()[:160]}")
570
+ if len(out) == 1:
571
+ return f"# {target}\n(no definition-like lines found)"
572
+ return "\n".join(out)
573
+
574
+ # ---- one-line delegation (structural: orchestrating must be easier than solving) -------
575
+
576
+ def _map_files(self, files: Any, prompt: str, model: str | None = None) -> dict[str, str]:
577
+ """Ask `prompt` of every given file, batched, and return {path: answer}.
578
+
579
+ `files` accepts context entries (dicts), paths (strings), or a mix — the whole
580
+ chunk/batch/collect loop the system prompt used to spell out, as one call.
581
+ Oversized files are split and their per-chunk answers joined.
582
+ """
583
+ prompt = str(prompt)
584
+ by_path: list[tuple[str, str]] = []
585
+ lookup: dict[str, str] | None = None
586
+ for item in files if isinstance(files, (list, tuple)) else [files]:
587
+ if isinstance(item, dict):
588
+ content = item.get("content", "")
589
+ by_path.append((str(item.get("path", "?")), content if isinstance(content, str) else str(content)))
590
+ elif isinstance(item, str):
591
+ if lookup is None:
592
+ lookup = {p: c for p, c in self._entries()}
593
+ if item in lookup:
594
+ by_path.append((item, lookup[item]))
595
+ else:
596
+ by_path.append((item, ""))
597
+ if not by_path:
598
+ return {}
599
+
600
+ # Per-file prompt budget; anything larger is chunked and its answers concatenated.
601
+ budget = self.max_prompt_chars - len(prompt) - _CHUNK_HEADER_OVERHEAD - 256
602
+ if budget < 1_000:
603
+ return {p: "Error: prompt too long to leave room for file content" for p, _ in by_path}
604
+
605
+ requests: list[str] = []
606
+ spans: list[tuple[str, int]] = [] # (path, number of chunks contributed)
607
+ for path, content in by_path:
608
+ chunks = _chunk_text(content, budget) if len(content) > budget else [content]
609
+ spans.append((path, len(chunks)))
610
+ for j, chunk in enumerate(chunks):
611
+ header = f"[file {path}" + (f", part {j + 1}/{len(chunks)}]" if len(chunks) > 1 else "]")
612
+ requests.append(f"{prompt}\n\n{header}\n{chunk}")
613
+
614
+ responses: list[str] = []
615
+ for i in range(0, len(requests), _MAX_CHUNK_BATCH):
616
+ responses.extend(self._llm_query_batched(requests[i:i + _MAX_CHUNK_BATCH], model))
617
+
618
+ out: dict[str, str] = {}
619
+ cursor = 0
620
+ for path, count in spans:
621
+ part = responses[cursor:cursor + count]
622
+ cursor += count
623
+ out[path] = part[0] if count == 1 and part else "\n\n".join(part)
624
+ return out
625
+
626
+ def _llm_map_reduce(
627
+ self,
628
+ items: Any,
629
+ map_prompt: str,
630
+ reduce_prompt: str,
631
+ model: str | None = None,
632
+ ) -> str:
633
+ """Map `map_prompt` over `items` in one batch, then reduce the answers with one call.
634
+
635
+ The paper's canonical strategy ("query an LLM per chunk ... then query an LLM with all
636
+ the buffers") as a single call, so the root never hand-rolls the loop.
637
+ """
638
+ map_prompt, reduce_prompt = str(map_prompt), str(reduce_prompt)
639
+ seq = list(items) if isinstance(items, (list, tuple)) else [items]
640
+ if not seq:
641
+ return "Error: llm_map_reduce got no items"
642
+ texts = [
643
+ (str(it.get("content", "")) if isinstance(it, dict) else str(it))
644
+ for it in seq
645
+ ]
646
+ labels = [
647
+ (str(it.get("path", f"item {i + 1}")) if isinstance(it, dict) else f"item {i + 1}")
648
+ for i, it in enumerate(seq)
649
+ ]
650
+ mapped: list[str] = []
651
+ for i in range(0, len(texts), _MAX_CHUNK_BATCH):
652
+ batch = [
653
+ f"{map_prompt}\n\n[{labels[i + j]}]\n{t}"
654
+ for j, t in enumerate(texts[i:i + _MAX_CHUNK_BATCH])
655
+ ]
656
+ mapped.extend(self._llm_query_batched(batch, model))
657
+ joined = "\n\n".join(f"[{labels[i]}]\n{a}" for i, a in enumerate(mapped))
658
+ return self._llm_query(f"{reduce_prompt}\n\nPartial answers:\n{joined}", model)
659
+
266
660
  def _rlm_query(self, prompt: str, model: str | None = None) -> str:
267
661
  r = self._rpc("rlm_query", {"prompt": str(prompt), "model": model})
268
662
  return f"Error: {r['error']}" if r.get("error") else r.get("response", "")
@@ -325,37 +719,124 @@ class Worker:
325
719
  return f"Error: {r['error']}"
326
720
  return str(r.get("response", "ok"))
327
721
 
328
- def _stage_edit(self, path: str, old_text: str, new_text: str) -> str:
329
- if not isinstance(path, str) or not isinstance(old_text, str) or not isinstance(new_text, str):
330
- return "Error: path, old_text, new_text must be strings"
331
- self._edit_counter += 1
332
- edit_id = f"e{self._edit_counter}"
333
- self._staged_edits.append({"id": edit_id, "path": path, "oldText": old_text, "newText": new_text})
334
- return edit_id
335
-
336
722
  def _load_library(self, source: str) -> dict[str, Any] | str:
337
- """Ask the host to pack an external dir/file/git-URL and load it as a new context slot."""
723
+ """Pack an external dir/file/git-URL on the host and append it into `context`.
724
+
725
+ Paths are namespaced under lib/<source_id>/ (host). Content is always in the
726
+ single `context` list — never a new context_N variable.
727
+ Host-side idempotency may return already_loaded without a payload path.
728
+ """
338
729
  r = self._rpc("load_library", {"source": str(source)})
339
730
  if r.get("error"):
340
731
  return f"Error: {r['error']}"
732
+ if r.get("already_loaded"):
733
+ source_id = r.get("source_id") if isinstance(r.get("source_id"), str) else "lib"
734
+ path_prefix = r.get("path_prefix") if isinstance(r.get("path_prefix"), str) else f"lib/{source_id}/"
735
+ ctx = self.ns.get("context")
736
+ ctx_len = len(ctx) if isinstance(ctx, list) else 0
737
+ print(
738
+ f"[rlm] load_library: already loaded {source_id} "
739
+ f"(paths under {path_prefix}, context len={ctx_len})"
740
+ )
741
+ return {
742
+ "source": str(source),
743
+ "source_id": source_id,
744
+ "path_prefix": path_prefix,
745
+ "files": 0,
746
+ "chars": r.get("chars"),
747
+ "context_len": ctx_len,
748
+ "already_loaded": True,
749
+ }
341
750
  path = r.get("path")
342
751
  if not isinstance(path, str):
343
752
  return "Error: malformed load_library reply (no path)"
344
753
  try:
345
- idx = self.load_context(path, r.get("index"), bool(r.get("json")))
754
+ # Worker-internal read — use real io.open so read-only guards never block us.
755
+ with _REAL_IO_OPEN(path, "r") as f:
756
+ payload = json.load(f) if r.get("json") else f.read()
346
757
  finally:
347
758
  try:
348
759
  os.remove(path) # worker owns temp-file cleanup (host does NOT unlink)
349
760
  except OSError:
350
761
  pass
351
- return {"index": idx, "var": f"context_{idx}",
352
- "files": r.get("files"), "chars": r.get("chars")}
762
+ return self._append_library(str(source), payload, r)
763
+
764
+ def _append_library(self, source: str, payload: Any, meta: dict[str, Any]) -> dict[str, Any] | str:
765
+ """Append host-packed library files into `context` (idempotent by path prefix)."""
766
+ ctx = self.ns.get("context")
767
+ if not isinstance(ctx, list):
768
+ kind = type(ctx).__name__ if ctx is not None else "None"
769
+ return f"Error: load_library requires list context (file bundle); got {kind}"
770
+
771
+ source_id = meta.get("source_id")
772
+ if not isinstance(source_id, str) or not source_id:
773
+ source_id = "lib"
774
+ path_prefix = meta.get("path_prefix")
775
+ if not isinstance(path_prefix, str) or not path_prefix:
776
+ path_prefix = f"lib/{source_id}/"
777
+
778
+ # Idempotent: already present if any path uses this library prefix.
779
+ for item in ctx:
780
+ if isinstance(item, dict) and str(item.get("path", "")).startswith(path_prefix):
781
+ print(
782
+ f"[rlm] load_library: already loaded {source_id} "
783
+ f"(paths under {path_prefix}, context len={len(ctx)})"
784
+ )
785
+ return {
786
+ "source": source,
787
+ "source_id": source_id,
788
+ "path_prefix": path_prefix,
789
+ "files": 0,
790
+ "chars": meta.get("chars"),
791
+ "context_len": len(ctx),
792
+ "already_loaded": True,
793
+ }
794
+
795
+ files = self._library_file_entries(payload, path_prefix)
796
+ if not files:
797
+ return "Error: load_library produced no files"
798
+
799
+ ctx.extend(files)
800
+ # Keep restore payload in sync with the live list.
801
+ self._context_payload = ctx
802
+ self.ns["context"] = ctx
803
+
804
+ print(
805
+ f"[rlm] load_library: +{len(files)} files into context "
806
+ f"(len={len(ctx)}); paths under {path_prefix}"
807
+ )
808
+ return {
809
+ "source": source,
810
+ "source_id": source_id,
811
+ "path_prefix": path_prefix,
812
+ "files": len(files),
813
+ "chars": meta.get("chars"),
814
+ "context_len": len(ctx),
815
+ "already_loaded": False,
816
+ }
817
+
818
+ @staticmethod
819
+ def _library_file_entries(payload: Any, path_prefix: str) -> list[dict[str, Any]]:
820
+ """Normalize host payload to list[dict]. Host already namespaces; string is fallback."""
821
+ if isinstance(payload, str):
822
+ return [{
823
+ "path": f"{path_prefix}content",
824
+ "content": payload,
825
+ "tokens": max(1, (len(payload) + 3) // 4),
826
+ }]
827
+ if not isinstance(payload, list):
828
+ return []
829
+ out: list[dict[str, Any]] = []
830
+ for item in payload:
831
+ if isinstance(item, dict) and "path" in item and "content" in item:
832
+ out.append(item)
833
+ return out
353
834
 
354
835
  def _advance_phase(self, phase: str, summary: str | None = None) -> str:
355
836
  """Transition the root RLM pipeline to a new phase.
356
837
 
357
838
  Only callable at depth 0. The parent handler validates the transition
358
- against the phase state machine (research → blueprint → implement → validate)
839
+ against the phase state machine (research → blueprint → validate)
359
840
  and runs deterministic artifact gates before accepting the transition.
360
841
  Returns a short confirmation, or an `Error: …` string the model can act on.
361
842
  """
@@ -400,16 +881,20 @@ class Worker:
400
881
  # ---- context + execution --------------------------------------------------------------
401
882
 
402
883
  def load_context(self, path: str, index: int | None = None, is_json: bool = False) -> int:
403
- if index is None:
404
- index = self._context_count
884
+ """Load the packed world into the single REPL variable `context`.
885
+
886
+ `index` is accepted for protocol compatibility but ignored — there is only
887
+ one context slot. Libraries are merged on the host (or via load_library).
888
+ """
405
889
  with open(path, "r") as f:
406
890
  payload = json.load(f) if is_json else f.read()
407
- self._ctx_payloads[index] = payload
408
- self.ns[f"context_{index}"] = payload
409
- if index == 0:
410
- self.ns["context"] = payload
411
- self._context_count = max(self._context_count, index + 1)
412
- return index
891
+ self._context_payload = payload
892
+ self.ns["context"] = payload
893
+ # Drop legacy multi-slot names if present.
894
+ for k in list(self.ns.keys()):
895
+ if k != "context" and _CONTEXT_NAME.match(k):
896
+ del self.ns[k]
897
+ return 0
413
898
 
414
899
  @contextmanager
415
900
  def _capture(self):
@@ -471,7 +956,6 @@ class Worker:
471
956
  stdout = out.getvalue()
472
957
  stderr = err.getvalue() + f"\n{type(e).__name__}: {e}\n" + traceback.format_exc()
473
958
  final, self._final_answer = self._final_answer, None
474
- edits, self._staged_edits = self._staged_edits, []
475
959
  answer = self.ns.get("answer")
476
960
  answer_content = answer.get("content", "") if isinstance(answer, dict) else ""
477
961
  # ready may have been flipped with empty content before content was assigned later
@@ -488,7 +972,6 @@ class Worker:
488
972
  "stderr": stderr,
489
973
  "final_answer": final,
490
974
  "answer_content": str(answer_content),
491
- "edits": edits,
492
975
  "raised": raised,
493
976
  "execution_time": time.perf_counter() - start,
494
977
  "var_names": self._user_var_names(),
@@ -512,7 +995,7 @@ class Worker:
512
995
  out, skipped = {}, []
513
996
  MAX_VAR_BYTES = 50 * 1024 * 1024
514
997
  for k, v in self.ns.items():
515
- if k.startswith("_") or _CONTEXT_SLOT.match(k) or k in RESERVED or k == "__builtins__":
998
+ if k.startswith("_") or _CONTEXT_NAME.match(k) or k in RESERVED or k == "__builtins__":
516
999
  continue
517
1000
  try:
518
1001
  blob = s.dumps(v)
@@ -525,7 +1008,7 @@ class Worker:
525
1008
  if skipped:
526
1009
  print(f"[rlm-sandbox] snapshot skipped {len(skipped)} unpicklable/oversized vars: {skipped}", file=_REAL_STDERR)
527
1010
  tmp = path + ".tmp"
528
- with open(tmp, "wb") as f:
1011
+ with _REAL_IO_OPEN(tmp, "wb") as f:
529
1012
  s.dump({"nonce": nonce, "vars": out}, f)
530
1013
  os.rename(tmp, path) # atomic rename
531
1014
  return {"skipped": skipped}
@@ -538,7 +1021,7 @@ class Worker:
538
1021
  history-only replay (caller skips restore when sessionNonce is undefined).
539
1022
  """
540
1023
  s = self._serializer()
541
- with open(path, "rb") as f:
1024
+ with _REAL_IO_OPEN(path, "rb") as f:
542
1025
  data = s.load(f)
543
1026
  if not isinstance(data, dict) or data.get("nonce") != nonce:
544
1027
  raise ValueError("snapshot nonce mismatch — not from this session")
@@ -553,10 +1036,13 @@ def main() -> None:
553
1036
  ap.add_argument("--timeout", type=float, default=float(os.environ.get("RLM_EXEC_TIMEOUT_S", "600")))
554
1037
  ap.add_argument("--max-prompt-chars", type=int,
555
1038
  default=int(os.environ.get("RLM_MAX_PROMPT_CHARS", "400000")))
1039
+ ap.add_argument("--read-only", action="store_true",
1040
+ default=os.environ.get("RLM_READ_ONLY", "").lower() in ("1", "true", "yes"),
1041
+ help="Reject open() write modes (pipeline runs)")
556
1042
  args = ap.parse_args()
557
1043
 
558
1044
  worker = Worker(depth=args.depth, exec_timeout_s=args.timeout,
559
- max_prompt_chars=args.max_prompt_chars)
1045
+ max_prompt_chars=args.max_prompt_chars, read_only=args.read_only)
560
1046
  _send({"id": "_init", "ok": True})
561
1047
 
562
1048
  for raw in _REAL_STDIN: