@hicaru/pi-rlm 0.2.0 → 0.2.2

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 (68) hide show
  1. package/README.md +12 -35
  2. package/README.ru.md +18 -23
  3. package/README.zh-CN.md +17 -28
  4. package/package.json +1 -1
  5. package/src/bridge/library.ts +61 -26
  6. package/src/bridge/subcall-handlers.ts +382 -0
  7. package/src/commands/rlm-config.ts +47 -18
  8. package/src/commands/rlm.ts +3 -152
  9. package/src/config/defaults.ts +7 -15
  10. package/src/config/settings.ts +8 -32
  11. package/src/context/library-context.ts +90 -17
  12. package/src/core/engine.ts +115 -360
  13. package/src/core/history.ts +1 -1
  14. package/src/core/limits.ts +5 -12
  15. package/src/core/resource-limits.ts +0 -2
  16. package/src/core/types.ts +3 -36
  17. package/src/index.ts +49 -10
  18. package/src/mode/llm-model.ts +54 -0
  19. package/src/mode/rlm-mode.ts +26 -57
  20. package/src/prompts/glossary.ts +287 -0
  21. package/src/prompts/native.ts +127 -0
  22. package/src/prompts/system.ts +14 -386
  23. package/src/sandbox/context-file.ts +154 -0
  24. package/src/sandbox/interrupts.ts +145 -0
  25. package/src/sandbox/protocol.ts +14 -69
  26. package/src/sandbox/py/guards.py +150 -0
  27. package/src/sandbox/py/retrieval.py +265 -0
  28. package/src/sandbox/py/tasks.py +116 -0
  29. package/src/sandbox/py/worker.py +836 -0
  30. package/src/sandbox/sandbox-manager.ts +33 -6
  31. package/src/sandbox/sandbox.ts +153 -182
  32. package/src/text/tokens.ts +29 -3
  33. package/src/tool/background-tasks.ts +95 -0
  34. package/src/tool/repl-details.ts +4 -2
  35. package/src/tool/repl-render.ts +58 -0
  36. package/src/tool/repl-result.ts +70 -0
  37. package/src/tool/repl-tool.ts +178 -216
  38. package/src/tool/rlm-aggregator.ts +2 -10
  39. package/src/tool/rlm-details.ts +0 -2
  40. package/src/tool/rlm-events.ts +10 -16
  41. package/src/tool/rlm-tool.ts +1 -12
  42. package/src/tool/subcall-render.ts +15 -3
  43. package/src/tool/subcall-store.ts +57 -1
  44. package/src/ui/config-panel.ts +4 -16
  45. package/src/ui/intro.ts +1 -2
  46. package/src/ui/model-picker.ts +34 -10
  47. package/src/ui/status.ts +3 -7
  48. package/src/util/concurrency.ts +91 -13
  49. package/src/util/trace.ts +42 -0
  50. package/src/bridge/fallback-todo.ts +0 -137
  51. package/src/bridge/interactive.ts +0 -65
  52. package/src/bridge/llm-query.ts +0 -156
  53. package/src/bridge/pi-interactive.ts +0 -41
  54. package/src/bridge/rlm-query.ts +0 -108
  55. package/src/core/artifacts.ts +0 -89
  56. package/src/core/critique.ts +0 -92
  57. package/src/core/gates.ts +0 -301
  58. package/src/core/pipeline-handlers.ts +0 -319
  59. package/src/core/pipeline.ts +0 -268
  60. package/src/prompts/phases.ts +0 -104
  61. package/src/sandbox/worker.py +0 -1078
  62. package/src/state/index.ts +0 -24
  63. package/src/state/internal.ts +0 -46
  64. package/src/state/paths.ts +0 -44
  65. package/src/state/reads.ts +0 -133
  66. package/src/state/resume.ts +0 -173
  67. package/src/state/rows.ts +0 -123
  68. package/src/state/writes.ts +0 -58
@@ -5,6 +5,17 @@
5
5
  * exposes `context`, the sub-LLM functions, and the `answer` dict the model flips to submit.
6
6
  */
7
7
  import type { ContextSizeStats } from "../text/tokens.ts";
8
+ import {
9
+ contextKindOf,
10
+ DEFAULT_PROMPT_CAP,
11
+ ENV_TIPS,
12
+ howToRunCode,
13
+ LARGE_FILE_RULE_LINES,
14
+ promptCapTokensK,
15
+ replGlossary,
16
+ } from "./glossary.ts";
17
+
18
+ export { contextKindOf, type ContextKind } from "./glossary.ts";
8
19
 
9
20
  export interface PromptMeta {
10
21
  readonly contextType: string;
@@ -16,275 +27,10 @@ export interface PromptMeta {
16
27
  export interface SystemPromptOptions {
17
28
  readonly orchestrator?: boolean;
18
29
  readonly recursion?: boolean;
19
- readonly askUserQuestion?: boolean;
20
- readonly todo?: boolean;
21
- readonly pipeline?: boolean;
22
30
  readonly maxPromptChars?: number;
23
31
  readonly libraryLoader?: boolean;
24
- }
25
-
26
- export type ContextKind = "files" | "text";
27
-
28
- /** "str" (raw string context, e.g. rlm_query children) → text; everything else → files. */
29
- export function contextKindOf(contextType: string): ContextKind {
30
- return contextType === "str" ? "text" : "files";
31
- }
32
-
33
- const DEFAULT_PROMPT_CAP = 400_000;
34
-
35
- function promptCapTokensK(maxPromptChars: number): number {
36
- return Math.round(maxPromptChars / 4_000);
37
- }
38
-
39
- /**
40
- * Deterministic retrieval over `context` (headless + native).
41
- *
42
- * The paper's trajectories retrieve with hand-written regex (App. E.1); frontier models do that
43
- * well, small ones guess keywords badly, and the first decomposition disproportionately decides
44
- * the outcome (§5, Fig. 4a). These cost no tokens and no sub-calls.
45
- */
46
- const RETRIEVAL_GLOSSARY_LINES: readonly string[] = Object.freeze([
47
- "- `search(query: str, k=10, path_glob=None)`: BM25 ranking over `context`. Returns",
48
- " [{path, line, score, snippet}] — POINTERS, not bodies. **Start here.** It is free:",
49
- " no sub-LLM call, no tokens. Use it before you guess at filenames or write regex.",
50
- "- `grep_context(pattern, k=50, path_glob=None, before=0, after=0) -> dict`: regex over",
51
- " `context`. Returns {hits: [{path, line, text}], counts: {path: n}, total, truncated} —",
52
- " `counts` is complete even when `hits` is capped, so a wide pattern reports its shape",
53
- " instead of flooding you. Use for exact lexical needles; use `search` for meaning.",
54
- "- `outline(path) -> str`: definition/heading skeleton of one file with line numbers.",
55
- " Orient in ~200 chars instead of printing 20K. Matches exact path, then suffix, then glob.",
56
- ]);
57
-
58
- /** One-line delegation helpers — orchestrating must be cheaper than solving. */
59
- const DELEGATION_GLOSSARY_LINES: readonly string[] = Object.freeze([
60
- "- `map_files(files, prompt, model=None) -> dict[path, str]`: ask `prompt` of every file and",
61
- " get back {path: answer}. Accepts context entries or paths, packs them into cap-sized",
62
- " batched sub-calls, and splits oversized files automatically. **This is the default way to",
63
- " read many files** — prefer it over hand-rolling a chunk loop.",
64
- "- `llm_map_reduce(items, map_prompt, reduce_prompt, model=None) -> str`: map over items in",
65
- " one batch, then reduce the partial answers with a single call. The paper's canonical",
66
- " strategy (query per chunk → aggregate the buffers) as one call.",
67
- ]);
68
-
69
- /** Shared glossary entry for the chunked-query helper (headless + native). */
70
- const CHUNKED_GLOSSARY_LINES: readonly string[] = Object.freeze([
71
- "- `llm_query_chunked(text: str, prompt: str, model=None) -> list[str]`: auto-splits `text` into",
72
- " chunks that fit the sub-LLM prompt cap, fans them out concurrently (order preserved), and",
73
- " returns one answer per chunk. Use it for ANY text too large for a single `llm_query` — a file",
74
- " you open()ed, an oversized sub-result, or several concatenated context files.",
75
- ]);
76
-
77
- /** Why a file the user mentioned may be missing from `context`. */
78
- const CONTEXT_EXCLUSION_NOTE =
79
- " NOTE: files larger than 1MB and gitignored files are NOT in `context` — they exist only on disk.";
80
-
81
- /** The large-on-disk-file protocol (headless + native). */
82
- const LARGE_FILE_RULE_LINES: readonly string[] = Object.freeze([
83
- "**Large on-disk files (profiles, logs, dumps, generated JSON):** files >1MB or gitignored are",
84
- "absent from `context`. Protocol:",
85
- '1. Load in Python: `raw = open("dhat-heap.json").read()` — loading into a variable is fine.',
86
- "2. Deterministic processing in Python (`json.load`, `re`, counting, aggregation) is fine and preferred.",
87
- "3. The moment you need MEANING from raw text (summarize, explain, find anomalies), do NOT read it",
88
- " yourself — call `llm_query_chunked(raw, question)`, or slice + `llm_query_batched`.",
89
- "4. Never print more than a small probe (~2K chars) of raw content.",
90
- 'Example: `parts = llm_query_chunked(raw, "Extract top allocation sites with byte totals")`, then',
91
- "aggregate `parts` in Python or with one final `llm_query`.",
92
- ]);
93
-
94
- /** Concise native-mode glossary line for the chunked helper (native prompt has a 6K budget). */
95
- const CHUNKED_GLOSSARY_LINE_NATIVE =
96
- "- `llm_query_chunked(text, prompt, model=None) -> list[str]` — auto-splits oversized text into cap-sized chunks, fans out concurrently; one answer per chunk.";
97
-
98
- /** Concise native-mode large-file rule (folds in the context-exclusion note; native 6K budget). */
99
- const LARGE_FILE_RULE_NATIVE =
100
- "- Files >1MB or gitignored are NOT in `context`: open() + parse deterministically in Python is fine; ANY semantic reading of the raw text goes through llm_query_chunked. Never print >2K chars raw.";
101
-
102
- /**
103
- * The decomposition doctrine, ported from the RLM paper's Appendix C.3 `<env_tips>` and
104
- * retargeted from competition math to repository analysis.
105
- *
106
- * This block is the single highest-leverage prompt intervention the paper reports: +69.5% on
107
- * LongCoT-mini over the same RLM without it (Table 2). Plain RLM prompting alone actually
108
- * *regressed* two of the five categories; the doctrine is what fixed them. Its purpose is to
109
- * counter under-delegation — the model doing the work itself in the REPL instead of fanning out.
110
- *
111
- * Note the counterweight: `orchestratorAddendum` carries the anti-OVER-recursion batching rule.
112
- * The paper is explicit (App. B) that one prompt does not port across models and that both
113
- * guardrails are needed; keep them both.
114
- */
115
- const ENV_TIPS = [
116
- "## Decomposition doctrine",
117
- "",
118
- "**Orchestrate; don't solve.** A single chain of thought over a large repository drifts —",
119
- "you lose partials and compound mistakes. Your sub-LLMs are competent readers: given a",
120
- "self-contained prompt and the text, they will extract, locate, classify, and summarize",
121
- "reliably. Trust them; don't do their reading yourself.",
122
- "",
123
- "Your job: (1) find the relevant slice with `search` / `grep_context` / `outline`,",
124
- "(2) delegate all semantic reading to `map_files` / `llm_query_batched` / `llm_map_reduce`,",
125
- "(3) memoize every result you will reuse in `answers`, (4) sanity-check an answer before",
126
- "another step depends on it, (5) assemble the final answer from `answers` by lookup.",
127
- "Your own compute is: pointers, dict lookups, string formatting, and decisions.",
128
- "",
129
- "### The only state that matters",
130
- "`answers` and `plan` are dicts that persist across every turn and survive snapshots.",
131
- "**If a value isn't in `answers`, it doesn't exist.** Do not trust a number from your own",
132
- "earlier reasoning or from truncated stdout — context drifts. Memoize everything you reuse.",
133
- "",
134
- "### Shape of a run",
135
- "1. Probe: `print(len(context))`, `search(<the user's question>)`. Do not print file bodies.",
136
- "2. Plan: write the sub-questions into `plan`; each must be answerable from a named slice.",
137
- "3. Fan out: one `map_files` / `llm_query_batched` per independent group, not one call per",
138
- " file. Store results into `answers` keyed by path or sub-question.",
139
- "4. Assemble: build the answer from `answers`. Delegate the aggregation too if it is large.",
140
- "",
141
- "### Red flags — you are off track",
142
- "- Printing file bodies to read them yourself → stop, delegate to `map_files`.",
143
- "- Writing regex to *infer meaning* (naming conventions, intent, correctness) → that is a",
144
- " sub-LLM job. Regex is for exact lexical needles only.",
145
- "- Two turns in with zero sub-LLM calls on an analysis task → you are solving it yourself.",
146
- "- About to reuse a value that is not in `answers` → re-derive it and store it.",
147
- "- One sub-call per file over dozens of files → batch them; fat prompts in small batches win.",
148
- ].join("\n");
149
-
150
- /** Native-mode variant of the doctrine — same rules, sized for the native prompt budget. */
151
- const ENV_TIPS_CONDENSED = [
152
- "### Decomposition doctrine (paper App. C.3 — worth +69.5% there)",
153
- "Orchestrate; don't solve. Loop: `search`/`grep_context`/`outline` to find the slice →",
154
- "`map_files` / `llm_query_batched` to read it → memoize into `answers` → assemble by lookup.",
155
- "`answers` and `plan` persist across turns and snapshots: **if a value isn't in `answers`, it",
156
- "doesn't exist** — never reuse a number from your own earlier reasoning or truncated stdout.",
157
- "Red flags: printing file bodies to read them; regex used to infer meaning rather than match",
158
- "a literal; two turns into an analysis with zero sub-LLM calls; one sub-call per file instead",
159
- "of one batch. Exception — AUTHORING is not reading: you write every edit body yourself.",
160
- ].join("\n");
161
-
162
- function howToRunCode(): string {
163
- return [
164
- "To run Python, write a fenced ```repl``` block. The REPL **persists** across turns. Only",
165
- "`print(...)` output (stdout) is returned; a bare expression on the last line is discarded, so",
166
- "always wrap inspections in `print(...)`.",
167
- ].join(" ");
168
- }
169
-
170
- function replGlossary(
171
- kind: ContextKind,
172
- recursion: boolean,
173
- askUserQuestion: boolean,
174
- todo: boolean,
175
- pipeline: boolean,
176
- libraryLoader: boolean,
177
- ): string {
178
- const lines = ["Available in the REPL:"];
179
- if (kind === "text") {
180
- lines.push(
181
- "- `context`: str — the raw text you must analyze. Probe it with slices",
182
- " (`print(context[:2000])`), split it programmatically, and delegate large chunks",
183
- " to sub-LLMs — never dump the whole string into your own output.",
184
- );
185
- } else {
186
- lines.push(
187
- "- `context`: list[dict] — a pre-packed JSON array of every file in the repository. Each dict has",
188
- " keys: `path` (relative file path, str), `content` (file text, str), `tokens` (estimated count, int).",
189
- " For large repos, chunk `context` into batches and delegate to sub-LLMs — never dump raw file",
190
- " bodies into your own output.",
191
- CONTEXT_EXCLUSION_NOTE,
192
- "",
193
- " Worked example — find the slice, then delegate it:",
194
- " ```python",
195
- ' hits = search("where is the retry/backoff policy configured?", k=8)',
196
- " paths = sorted({h['path'] for h in hits})",
197
- ' answers.update(map_files(paths, "Describe any retry/backoff policy in this file, with line numbers. Say NONE if absent."))',
198
- " print({p: a[:80] for p, a in answers.items()})",
199
- " ```",
200
- );
201
- }
202
- lines.push(...RETRIEVAL_GLOSSARY_LINES);
203
- lines.push(
204
- "- `llm_query(prompt: str, model=None) -> str`: a single sub-LLM completion. Use for extraction,",
205
- " summarization, or Q&A over a chunk of text.",
206
- "- `llm_query_batched(prompts: list[str], model=None) -> list[str]`: run several sub-LLM calls",
207
- " concurrently; output order matches input order.",
208
- ...CHUNKED_GLOSSARY_LINES,
209
- ...DELEGATION_GLOSSARY_LINES,
210
- );
211
- if (askUserQuestion) {
212
- lines.push(
213
- "- `ask_user_question(questions: list[dict]) -> list[dict]`: pause and present the user",
214
- " with 1-4 structured questions. Each question: {question, header, options: [{label, description}],",
215
- " multiSelect?}. Returns list of {question, selected: [label], custom?}.",
216
- " Default: use concrete options grounded in code/data (2–4 choices, Recommended first when ranking).",
217
- " Exception — clarify-phase intent rounds: lead with an open-ended intent question whose options",
218
- " are answer *shapes* (not a Recommended pick); free-text / Other carries the real framing.",
219
- " Only valid at root depth; returns an error inside rlm_query sub-calls.",
220
- );
221
- }
222
- if (todo) {
223
- lines.push(
224
- "- `todo(action, **kwargs) -> str`: manage a task list visible to the user.",
225
- " Actions: create(subject, description?, status='pending'), update(id, status?, activeForm?),",
226
- " list(filterStatus?), get(id), delete(id), clear().",
227
- " Status flow: pending → in_progress → completed.",
228
- " Use to plan multi-step work before starting, then mark tasks as you complete them.",
229
- );
230
- }
231
- if (libraryLoader) {
232
- lines.push(
233
- "- `load_library(source: str) -> dict`: load an EXTERNAL library, source tree, or document and",
234
- " **APPEND its files into the existing `context` list** (same shape: path/content/tokens).",
235
- " `source` may be a local directory (repomix-packed), a single file path, or an https/git@ URL",
236
- " (shallow-cloned, then packed). Paths are namespaced under `lib/<source_id>/…` so you can filter",
237
- " by prefix. Returns metadata only:",
238
- " {\"source\", \"source_id\", \"path_prefix\", \"files\", \"chars\", \"context_len\", \"already_loaded\"}",
239
- " or an \"Error: ...\" string. **Never treat the return value as the file list** — always search",
240
- " and chunk the single variable `context`. Do not invent `context_1` / aliases; do not call",
241
- " globals()/locals(). Idempotent: re-loading the same source is a no-op.",
242
- "",
243
- " ```python",
244
- " info = load_library(\"/path/to/other-project\")",
245
- " # info is metadata; files are already in context under info[\"path_prefix\"]",
246
- " lib_files = [f for f in context if f[\"path\"].startswith(info[\"path_prefix\"])]",
247
- " ```",
248
- );
249
- }
250
- if (recursion) {
251
- lines.push(
252
- "- `rlm_query(prompt, model=None)` / `rlm_query_batched(prompts, model=None)`: recursive RLM",
253
- " sub-calls. Each child runs a full REPL loop internally — its entire conversation is PRIVATE",
254
- " and never enters your history. Only the final answer (a short string) is returned.",
255
- "",
256
- " **Choosing between `llm_query` and `rlm_query`:**",
257
- " - `llm_query` for simple one-shot tasks — summarize a chunk, extract a fact, answer a direct",
258
- " question. It is a single LLM call: fast and cheap. Prefer it by default, and fan out with",
259
- " `llm_query_batched` for parallel one-shots.",
260
- " - `rlm_query` only when a sub-task genuinely needs iterative reasoning with its own code",
261
- " execution (e.g. a sub-context large enough to need its own chunking, or a multi-step",
262
- " reasoning chain). It is slower and more expensive — reserve it for cases `llm_query` cannot",
263
- " handle. Avoid excessive recursive sub-calls when a batched one-shot would suffice.",
264
- );
265
- }
266
- if (pipeline) {
267
- lines.push(
268
- "- `save_artifact(kind: str, content: str) -> str`: persist a stage artifact under `.rlm/artifacts/`.",
269
- " Kinds: `'clarification'` | `'research'` | `'plan'` | `'validation'`. Must match the current phase.",
270
- " Frontmatter must eventually include `status: ready` before `advance_phase` will accept the transition.",
271
- "- `advance_phase(phase: str, summary=None) -> str`: transition to the next pipeline phase.",
272
- " Order: 'clarify' → 'research' → 'blueprint' → 'validate' (one step at a time;",
273
- " clarify is skipped when ask_user_question is disabled). The pipeline is READ-ONLY.",
274
- " **advance_phase is validated by the engine** — it measures the latest saved artifact",
275
- " (status, structure, citations, blockers_count; clarify also requires ≥1 ask_user_question round).",
276
- " A rejected transition returns the gate error for you to fix; the phase does NOT advance.",
277
- " Only callable at root depth.",
278
- );
279
- }
280
- lines.push(
281
- "- `answers` / `plan`: two dicts that persist across turns and snapshots. Memoize every",
282
- " verified result in `answers` — see the decomposition doctrine below.",
283
- "- `SHOW_VARS() -> str`: list every variable currently in the REPL.",
284
- '- `answer`: a dict initialized to {"content": "", "ready": False}. To submit your final answer,',
285
- ' set `answer["content"]` to the answer text and `answer["ready"] = True`.',
286
- );
287
- return lines.join("\n");
32
+ /** depth > 0 — this run is an rlm_query child and its `context` is the parent's world. */
33
+ readonly child?: boolean;
288
34
  }
289
35
 
290
36
  function orchestratorAddendum(maxPromptChars: number): string {
@@ -324,8 +70,7 @@ export function buildRlmSystemPrompt(meta: PromptMeta, opts: SystemPromptOptions
324
70
  howToRunCode(),
325
71
  "",
326
72
  replGlossary(
327
- kind, recursion, opts.askUserQuestion ?? false, opts.todo ?? false,
328
- opts.pipeline ?? false, opts.libraryLoader ?? false,
73
+ kind, recursion, opts.libraryLoader ?? false, opts.child ?? false,
329
74
  ),
330
75
  "",
331
76
  "REPL stdout over ~800 characters is truncated to a short excerpt — large results stay in your",
@@ -346,123 +91,6 @@ export function buildRlmSystemPrompt(meta: PromptMeta, opts: SystemPromptOptions
346
91
  return parts.join("\n");
347
92
  }
348
93
 
349
- /** Adapts the REPL glossary for native mode — agent calls `repl({code})` instead of writing ```repl``` blocks. */
350
- function nativeReplGlossary(): string {
351
- return [
352
- "## RLM Native Mode — Persistent Python REPL",
353
- "",
354
- "Call `repl({code: \"...\"})` to execute Python in a **persistent** sandbox. Variables, imports,",
355
- "State persists; only `print()` output is returned, so wrap inspections in `print(...)`.",
356
- "",
357
- "### REPL Environment",
358
- "- `context`: list[dict] — every file in the repository. Each dict: `path` (str), `content` (str), `tokens` (int).",
359
- "",
360
- "Retrieval — free (no sub-LLM call, no tokens). **Start here, before guessing filenames:**",
361
- "- `search(query, k=10, path_glob=None) -> [{path, line, score, snippet}]` — BM25 over `context`. Returns pointers, not bodies.",
362
- "- `grep_context(pattern, k=50, path_glob=None, before=0, after=0) -> {hits, counts, total, truncated}` — regex; `counts` stays complete when `hits` is capped. Lexical needles only.",
363
- "- `outline(path) -> str` — definition/heading skeleton with line numbers. Orient in ~200 chars instead of printing 20K.",
364
- "",
365
- "Delegation — everything semantic goes through these:",
366
- "- `map_files(files, prompt, model=None) -> {path: answer}` — ask `prompt` of many files; batches and splits oversized files for you. **The default way to read many files.**",
367
- "- `llm_map_reduce(items, map_prompt, reduce_prompt, model=None) -> str` — map in one batch, then reduce with one call.",
368
- "- `llm_query(prompt, model=None) -> str` — one-shot sub-LLM. Use for extraction, summarization, Q&A over a chunk.",
369
- "- `llm_query_batched(prompts, model=None) -> list[str]` — concurrent sub-LLM calls; output order matches input order.",
370
- CHUNKED_GLOSSARY_LINE_NATIVE,
371
- "- `rlm_query(prompt, model=None) -> str` — recursive RLM with its own REPL for complex sub-tasks needing iterative reasoning. Prefer llm_query — rlm_query is slower and costlier.",
372
- "- `rlm_query_batched(prompts, model=None) -> list[str]` — concurrent recursive RLM calls.",
373
- "",
374
- "",
375
- "- `answers` / `plan` — dicts persisted across every repl() call and snapshot. Your memo.",
376
- "- `todo(action, **kwargs) -> str` — manage a task list. Actions: create, update, list, get, delete, clear. Statuses: pending → in_progress → completed.",
377
- "- `load_library(source) -> dict`: append external dir/file/git tree into `context` under `lib/<id>/…`. Return is metadata only — always use `context`.",
378
- "- `SHOW_VARS() -> str` — list all variables currently in the REPL.",
379
- "- `answer`: dict `{\"content\": \"\", \"ready\": False}`. Setting `answer[\"ready\"] = True` delivers it to the user; do not restate it.",
380
- "",
381
- ENV_TIPS_CONDENSED,
382
- "",
383
- "### Worked pattern",
384
- "```python",
385
- 'hits = search("where is retry/backoff configured?", k=8)',
386
- "paths = sorted({h['path'] for h in hits})",
387
- 'answers.update(map_files(paths, "Describe any retry/backoff policy here, with line numbers. Say NONE if absent."))',
388
- "print({p: a[:80] for p, a in answers.items()})",
389
- "```",
390
- `- Sub-prompts cap at ${DEFAULT_PROMPT_CAP.toLocaleString()} chars (≈${promptCapTokensK(DEFAULT_PROMPT_CAP)}K tokens); ~20 prompts per batch. Fat prompts in small batches > thousands of tiny prompts.`,
391
- "",
392
- "### Choosing Between Tools",
393
- "| Tool | When |",
394
- "|------|------|",
395
- "| `repl({code})` | ALL repository reading, search, and analysis; Python scripting; state across calls |",
396
- "| `edit` / `write` | Change or create a file. Compose oldText/newText yourself; exact match required |",
397
- "| `search` / `grep_context` / `outline` (in repl) | Locate the relevant slice — free, do this first |",
398
- "| `zebra-mcp` | Semantic/embedding search when lexical `search` misses the concept |",
399
- "| `map_files` / `llm_query_batched` (in repl) | Read/extract/classify that slice |",
400
- "| `rlm_query` (in repl) | Sub-task needing its own iterative reasoning and REPL |",
401
- "| `todo` (in repl) | Track multi-step progress visibly to the user |",
402
- "",
403
- "### Task-Specific Patterns",
404
- LARGE_FILE_RULE_NATIVE,
405
- "- Architecture/code review: `search` for the subsystem, then `map_files` the hits.",
406
- "- Bug investigation: `grep_context` for the literal symbol/message, then `map_files` the matching files.",
407
- "- Finalizing: for file changes call `edit`/`write` directly so Pi validates the anchor and renders the diff; for analysis, write a normal message.",
408
- "- If sub-LLM credits are exhausted, report partial results and stop — do not bypass REPL restrictions.",
409
- "",
410
- "Reserve your own tokens for high-level decisions: what to ask next, how to combine sub-LLM outputs, when to finalize.",
411
- "Delegate everything else. Do not submit a final answer before inspecting `context`.",
412
- ].join("\n");
413
- }
414
-
415
- /** Build the native-mode system prompt for the main Pi agent. */
416
- export function buildNativeSystemPrompt(): string {
417
- return [
418
- "╔══════════════════════════════════════════════════════════════════╗",
419
- "║ NATIVE RLM MODE — YOU ARE AN ORCHESTRATOR, NOT A READER ║",
420
- "╚══════════════════════════════════════════════════════════════════╝",
421
- "",
422
- "ENFORCED BY THE RUNTIME (not advisory):",
423
- "- `read`/`grep` are blocked; bash readers (cat/sed/head/tail/awk/rg) are blocked; bash output is hard-capped at 4K chars.",
424
- "- repl() stdout returned to you is hard-capped at 4K chars — printing file bodies is USELESS; the text will not reach you.",
425
- "",
426
- "LOCATE-THEN-DELEGATE: `search(query)` / `grep_context(pattern)` / `outline(path)` cost nothing",
427
- "— run them FIRST to find the relevant slice. Then, if a step needs MEANING from more than ~4K",
428
- "chars, that reading MUST be a map_files / llm_query / llm_query_batched / llm_query_chunked",
429
- "call (rlm_query for iterative sub-tasks). Deterministic Python over `context` is free and",
430
- "preferred for lookups. Semantic reading is always delegated.",
431
- "",
432
- "All file content is pre-loaded in the REPL `context` variable. Use ONLY `repl({code})`.",
433
- "If sub-LLM credits are exhausted → report the error to the user and stop.",
434
- "",
435
- "AUTHORING RULE: sub-LLMs (`llm_query` family) READ — they extract, locate, and summarize.",
436
- "They never author code you will ship. Once you know WHAT to change, compose the exact",
437
- "oldText / newText yourself and apply it with `edit`. Delegated code is written by a small",
438
- "model with no view of the codebase; it is a research aid, never a patch.",
439
- "Never re-type file bodies or `answer[\"content\"]` in your own output.",
440
- "",
441
- nativeReplGlossary(),
442
- ].join("\n");
443
- }
444
-
445
- /** Soft cap on the static native prompt. Leaves headroom for per-turn context injection
446
- * without bloating the root model's system prompt. Exceeded → phase-guards.ts fails.
447
- * Raised from 6K when the retrieval glossary and the condensed decomposition doctrine
448
- * landed; both buy far more than they cost (paper Table 2, Fig. 4a). */
449
- export const NATIVE_PROMPT_BUDGET = 7_500;
450
-
451
- /** Exported for tests — prompt length without context metadata (which is injected separately). */
452
- export const NATIVE_PROMPT_STATIC = buildNativeSystemPrompt();
453
-
454
- /** Per-turn last-position reminder for native mode — appended to every context build. */
455
- export const NATIVE_TURN_REMINDER = [
456
- "[RLM orchestrator contract — enforced by the runtime, not optional:",
457
- "repl() stdout to you is hard-capped at 4K chars; read/grep and bash readers are blocked.",
458
- "LOCATE FIRST with search() / grep_context() / outline() — they cost nothing. Any SEMANTIC",
459
- "reading MUST then go through map_files / llm_query / llm_query_batched / llm_query_chunked",
460
- "(rlm_query for iterative sub-tasks). Memoize what you reuse in `answers`; a value not in",
461
- "`answers` does not exist. AUTHORING IS NOT READING: you write every edit body yourself and",
462
- "apply it with the native `edit` / `write` tools — never delegate code you will ship.",
463
- "Keep your own output to decisions, authored edits, and aggregation.]",
464
- ].join("\n");
465
-
466
94
  /** The one-line context metadata, also reused by the per-turn prompt in headless mode. */
467
95
  export function buildMetadataLine(meta: PromptMeta, maxPromptChars = DEFAULT_PROMPT_CAP): string {
468
96
  const kind = contextKindOf(meta.contextType);
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Temp-file transport for sandbox context payloads, with refcounted sharing.
3
+ *
4
+ * Two callers, two ownership models, one writer:
5
+ * - `writeContextTempFile` — non-owning. `load_library` uses it because the WORKER unlinks
6
+ * that file after reading it (see sandbox.ts serviceInterrupt / worker.py `_load_library`).
7
+ * - `pinContext` — refcounted. Every child RLM of one node inherits the SAME payload, so an
8
+ * 18-way fan-out would otherwise cost 18 serializations and 18 files. Pins are keyed by
9
+ * payload identity, which is a free version key: `mergeLibraryIntoContext` always returns a
10
+ * NEW array, so loading a library mints a new key and old holders keep their own file.
11
+ *
12
+ * Serialization is chunked with an await between chunks so the event loop is never blocked for
13
+ * more than ~SERIALIZE_CHUNK entries. A Worker Thread was considered and rejected: posting the
14
+ * payload structured-clones the whole array, which costs about what the stringify costs and
15
+ * doubles peak RSS.
16
+ */
17
+
18
+ import { open, unlink, type FileHandle } from "node:fs/promises";
19
+ import { tmpdir } from "node:os";
20
+ import { join } from "node:path";
21
+
22
+ /** Entries serialized per await. Bounds the longest synchronous span on the event loop. */
23
+ const SERIALIZE_CHUNK = 64;
24
+
25
+ /** A temp file on disk holding a serialized context payload. */
26
+ export interface ContextTempFile {
27
+ readonly path: string;
28
+ /** True when the file holds JSON; false when the payload was a raw string. */
29
+ readonly json: boolean;
30
+ }
31
+
32
+ /** A shared, refcounted context file. Every holder must `release()` exactly once. */
33
+ export interface PinnedContext extends ContextTempFile {
34
+ /** Drop this holder's reference; unlinks once the last holder releases. Idempotent. */
35
+ release(): Promise<void>;
36
+ }
37
+
38
+ interface PinEntry extends ContextTempFile {
39
+ refs: number;
40
+ }
41
+
42
+ /**
43
+ * Live pins keyed by payload identity, storing the in-flight PROMISE rather than the settled
44
+ * entry. Children of one node race here (each drives its own sandbox, so nothing else
45
+ * serializes them); inserting the promise before the first await makes them join one write
46
+ * instead of each starting their own and orphaning the loser's file.
47
+ */
48
+ const pins = new Map<unknown, Promise<PinEntry>>();
49
+
50
+ function tempPath(isJson: boolean): string {
51
+ const suffix = isJson ? "json" : "txt";
52
+ return join(
53
+ tmpdir(),
54
+ `rlm-ctx-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.${suffix}`,
55
+ );
56
+ }
57
+
58
+ /** Write a JSON array incrementally, yielding to the event loop between chunks. */
59
+ async function writeChunkedArray(handle: FileHandle, items: readonly unknown[]): Promise<void> {
60
+ await handle.write("[");
61
+ const buf = new Array<string>(SERIALIZE_CHUNK);
62
+ let n = 0;
63
+ for (let i = 0; i < items.length; i++) {
64
+ // Comma prefix beats trimming a trailing one; no `+=` accumulation anywhere.
65
+ buf[n++] = i === 0 ? JSON.stringify(items[i]) : `,${JSON.stringify(items[i])}`;
66
+ if (n === SERIALIZE_CHUNK) {
67
+ await handle.write(buf.join("")); // the await is the yield point
68
+ n = 0;
69
+ }
70
+ }
71
+ if (n > 0) await handle.write(buf.slice(0, n).join(""));
72
+ await handle.write("]");
73
+ }
74
+
75
+ /**
76
+ * Serialize a payload to a fresh temp file. The caller owns the file and decides when (or
77
+ * whether) to unlink it.
78
+ */
79
+ export async function writeContextTempFile(payload: unknown): Promise<ContextTempFile> {
80
+ const json = typeof payload !== "string";
81
+ const path = tempPath(json);
82
+ try {
83
+ const handle = await open(path, "w");
84
+ try {
85
+ if (typeof payload === "string") await handle.write(payload);
86
+ else if (Array.isArray(payload)) await writeChunkedArray(handle, payload);
87
+ else await handle.write(JSON.stringify(payload));
88
+ } finally {
89
+ await handle.close();
90
+ }
91
+ } catch (err) {
92
+ await unlink(path).catch(() => {});
93
+ throw err;
94
+ }
95
+ return Object.freeze({ path, json });
96
+ }
97
+
98
+ async function writePinEntry(payload: unknown): Promise<PinEntry> {
99
+ const file = await writeContextTempFile(payload);
100
+ return { path: file.path, json: file.json, refs: 1 };
101
+ }
102
+
103
+ /** One holder's view of a pin. `shared` entries are evicted from the map at refcount zero. */
104
+ function handleFor(key: unknown, entry: PinEntry, shared: boolean): PinnedContext {
105
+ let released = false;
106
+ return Object.freeze({
107
+ path: entry.path,
108
+ json: entry.json,
109
+ release: async (): Promise<void> => {
110
+ if (released) return; // idempotent per handle, so a `finally` cannot double-decrement
111
+ released = true;
112
+ entry.refs -= 1;
113
+ if (entry.refs > 0) return;
114
+ if (shared) pins.delete(key);
115
+ await unlink(entry.path).catch(() => {});
116
+ },
117
+ });
118
+ }
119
+
120
+ /**
121
+ * Acquire a shared context file for `payload`. Holders must `release()` exactly once; the file
122
+ * is unlinked when the last one does.
123
+ */
124
+ export async function pinContext(payload: unknown): Promise<PinnedContext> {
125
+ // Only arrays are shared. Their identity is a meaningful version key; a string's is not
126
+ // (two equal strings may or may not be the same reference), and the string payloads here are
127
+ // one-off child prompts with nothing to share anyway.
128
+ if (!Array.isArray(payload)) {
129
+ return handleFor(payload, await writePinEntry(payload), false);
130
+ }
131
+
132
+ const existing = pins.get(payload);
133
+ if (existing !== undefined) {
134
+ const entry = await existing;
135
+ entry.refs += 1;
136
+ return handleFor(payload, entry, true);
137
+ }
138
+
139
+ // Insert synchronously, BEFORE any await, so a concurrent caller sees this write in flight.
140
+ const pending = writePinEntry(payload);
141
+ pins.set(payload, pending);
142
+ try {
143
+ return handleFor(payload, await pending, true);
144
+ } catch (err) {
145
+ // Evict the rejected promise so a later caller retries instead of awaiting a poisoned pin.
146
+ pins.delete(payload);
147
+ throw err;
148
+ }
149
+ }
150
+
151
+ /** Live pin count. Exported for tests asserting the sharing and the unlink-once behaviour. */
152
+ export function pinnedCount(): number {
153
+ return pins.size;
154
+ }