@hicaru/pi-rlm 0.1.7 → 0.1.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.md +41 -4
  2. package/package.json +2 -1
  3. package/src/bridge/library.ts +155 -0
  4. package/src/bridge/llm-query.ts +1 -0
  5. package/src/bridge/rlm-query.ts +56 -12
  6. package/src/config/defaults.ts +2 -0
  7. package/src/config/settings.ts +4 -0
  8. package/src/context/library-context.ts +266 -0
  9. package/src/context/repomix-context.ts +2 -48
  10. package/src/core/answer.ts +1 -10
  11. package/src/core/artifacts.ts +88 -0
  12. package/src/core/critique.ts +92 -0
  13. package/src/core/engine.ts +446 -53
  14. package/src/core/gates.ts +301 -0
  15. package/src/core/iteration.ts +7 -2
  16. package/src/core/pipeline.ts +196 -28
  17. package/src/core/types.ts +5 -3
  18. package/src/index.ts +3 -6
  19. package/src/mode/native-guards.ts +2 -2
  20. package/src/prompts/phases.ts +104 -0
  21. package/src/prompts/system.ts +59 -16
  22. package/src/prompts/user.ts +12 -4
  23. package/src/sandbox/protocol.ts +29 -11
  24. package/src/sandbox/sandbox.ts +77 -2
  25. package/src/sandbox/worker.py +215 -46
  26. package/src/state/index.ts +2 -1
  27. package/src/state/paths.ts +4 -2
  28. package/src/state/reads.ts +31 -2
  29. package/src/state/resume.ts +31 -6
  30. package/src/state/rows.ts +8 -2
  31. package/src/state/writes.ts +5 -3
  32. package/src/text/tokens.ts +7 -1
  33. package/src/tool/repl-details.ts +2 -3
  34. package/src/tool/repl-tool.ts +52 -57
  35. package/src/tool/rlm-aggregator.ts +7 -7
  36. package/src/tool/rlm-details.ts +6 -3
  37. package/src/tool/rlm-events.ts +14 -11
  38. package/src/tool/rlm-tool.ts +2 -8
  39. package/src/tool/subcall-store.ts +2 -0
  40. package/src/ui/config-panel.ts +8 -1
  41. package/src/registry/edit-registry.ts +0 -22
  42. package/src/text/edits.ts +0 -16
  43. package/src/tool/apply-edits-tool.ts +0 -288
package/src/index.ts CHANGED
@@ -7,8 +7,6 @@ import { registerRlmCommand } from "./commands/rlm.ts";
7
7
  import { registerRlmConfigCommand } from "./commands/rlm-config.ts";
8
8
  import { createRlmTool } from "./tool/rlm-tool.ts";
9
9
  import { createReplTool } from "./tool/repl-tool.ts";
10
- import { createApplyEditsTool } from "./tool/apply-edits-tool.ts";
11
- import { EditRegistry } from "./registry/edit-registry.ts";
12
10
  import { loadSettings, mergeConfig, resolveModelId } from "./config/settings.ts";
13
11
  import { RlmController, cheapestModel } from "./mode/rlm-mode.ts";
14
12
  import { postRlmGuide } from "./ui/intro.ts";
@@ -26,14 +24,14 @@ export default function rlmExtension(pi: ExtensionAPI): void {
26
24
  // Init synchronously with defaults — ensures commands/tools/handlers register before session_start
27
25
  const config = mergeConfig({});
28
26
  const controller = new RlmController(config);
29
- const editRegistry = new EditRegistry();
27
+ let onSandboxDiscardExtra: (() => void) | undefined;
30
28
  const sandboxManager = new SandboxManager({
31
29
  execTimeoutS: config.execTimeoutS,
32
30
  requestTimeoutMs: config.requestTimeoutMs,
33
31
  python: config.python,
34
32
  sandboxInitTimeoutMs: config.sandboxInitTimeoutMs,
35
33
  maxPromptChars: config.maxPromptChars,
36
- onSandboxDiscarded: () => { editRegistry.clear(); },
34
+ onSandboxDiscarded: () => { onSandboxDiscardExtra?.(); },
37
35
  });
38
36
  let packedContextText: string | undefined;
39
37
  let contextPackPromise: Promise<string | undefined> | undefined;
@@ -81,7 +79,6 @@ export default function rlmExtension(pi: ExtensionAPI): void {
81
79
 
82
80
  // ── Tool registration ──
83
81
  pi.registerTool(createRlmTool(controller));
84
- pi.registerTool(createApplyEditsTool(editRegistry));
85
82
  let guidePosted = false;
86
83
 
87
84
  pi.on("session_start", async (_event, ctx) => {
@@ -105,8 +102,8 @@ export default function rlmExtension(pi: ExtensionAPI): void {
105
102
  getModel: () => controller.resolveModels(ctx)?.model,
106
103
  getWorkerModel: () => controller.resolveModels(ctx)?.worker,
107
104
  registry: ctx.modelRegistry,
108
- editRegistry,
109
105
  config: controller.config,
106
+ registerDiscardHook: (reset) => { onSandboxDiscardExtra = reset; },
110
107
  ensureContext: async () => {
111
108
  const contextText = await ensureRepositoryContext(ctx.cwd ?? process.cwd());
112
109
  if (contextText === undefined) throw new Error("repository context could not be loaded into RLM sandbox");
@@ -86,7 +86,7 @@ export function replDelegationNudge(stdoutChars: number, delegated: boolean): st
86
86
  if (delegated || stdoutChars <= NUDGE_STDOUT_CHARS) return undefined;
87
87
  return (
88
88
  `\n[RLM: this repl() printed ${stdoutChars.toLocaleString()} chars with 0 sub-LLM calls — ` +
89
- "delegate semantic reading via llm_query / llm_query_batched / llm_query_chunked instead of " +
90
- "reading output yourself.]"
89
+ "if you were READING, delegate via llm_query / llm_query_batched / llm_query_chunked. " +
90
+ "Authoring an edit body yourself is correct and needs no delegation.]"
91
91
  );
92
92
  }
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Per-phase guidance for the gated RLM pipeline.
3
+ * Injected at each phase boundary; the only channel between phases is durable
4
+ * artifacts under .rlm/artifacts/.
5
+ */
6
+ import type { Phase } from "../core/pipeline.ts";
7
+
8
+ const CLARIFY_GUIDANCE = Object.freeze([
9
+ "## Clarify phase (intake interview)",
10
+ "Interview the user until the task is understood. The engine COUNTS ask_user_question",
11
+ "rounds it services — you cannot advance without having actually asked at least once.",
12
+ "",
13
+ "1. Intent first: ask ONE open-ended intent question (what problem, who hits it, what",
14
+ " success looks like). Options are answer *shapes* (e.g. end user / maintainer / operator);",
15
+ " do NOT mark a Recommended option. The automatic Other / free-text path carries the real framing.",
16
+ " Up to 2 more intent rounds if the answer still cannot scope a narrow probe; then proceed",
17
+ " with what you have.",
18
+ "2. Probe before asking more: ground follow-ups in the REPL (deterministic search over",
19
+ " context, a few llm_query reads) so questions cite real code as file:line.",
20
+ "3. Confirm inferences, don't record them: batch inferred decisions into one call —",
21
+ " \"From the code I inferred <behavior> (file:line). Keep or change?\" The user's answer",
22
+ " is the Decision, not your inference.",
23
+ "4. Scope / shape / detail rounds, one at a time: recommended option first; architecture",
24
+ " (shape) options must state the tradeoff (\"optimizes X, costs Y\") — never one option",
25
+ " masquerading as a choice; 2–4 independent detail questions may batch in one call.",
26
+ "5. Classify answers: Decision → record; Correction → re-probe that seam, re-ask dependents;",
27
+ " Defer → ## Open Questions.",
28
+ "6. Terminate on depth, not politeness: stop when every raised branch has a Decision or a",
29
+ " Deferral and ## Problem & Intent holds the user's own words verbatim. Do not pad the",
30
+ " interview; do not ask a final \"looks good?\" rubber-stamp.",
31
+ "7. Then save_artifact(\"clarification\", content) with frontmatter:",
32
+ " status: ready",
33
+ " decisions_count: N",
34
+ " open_questions_count: M",
35
+ " and body sections:",
36
+ " ## Problem & Intent (user's words VERBATIM)",
37
+ " ## Decisions (one '- ' bullet per decision)",
38
+ " ## Open Questions (one '- ' bullet per deferral; may be empty with count 0)",
39
+ " ## Non-Goals",
40
+ ' Then advance_phase("research", summary).',
41
+ ].join("\n"));
42
+
43
+ const RESEARCH_GUIDANCE = Object.freeze([
44
+ "## Research phase",
45
+ "Read the clarifications artifact (if present) and honor recorded Decisions; do not silently",
46
+ "resolve Open Questions.",
47
+ "Probe the repository, delegate long reads via `llm_query_batched` / `llm_query_chunked`.",
48
+ "Every factual claim about the code MUST be cited as `path/file.ext:LINE` (or `LINE-LINE`).",
49
+ "The engine VERIFIES citations against the working tree — unbacked citations reject advance.",
50
+ "When research is complete, write ONE research document with a `## Findings` section and call:",
51
+ ' `save_artifact("research", content)` # frontmatter must include `status: ready`',
52
+ ' `advance_phase("blueprint", summary)`',
53
+ "Do not implement code in this phase.",
54
+ ].join("\n"));
55
+
56
+ const BLUEPRINT_GUIDANCE = Object.freeze([
57
+ "## Blueprint phase",
58
+ "Read the clarifications artifact (if present) and plan only within recorded Decisions;",
59
+ "Open Questions that would block the design must be re-asked via ask_user_question, not assumed.",
60
+ 'Produce ONE plan document and save it with `save_artifact("plan", content)`.',
61
+ "This pipeline is read-only: it produces a plan a human or a native-mode agent executes;",
62
+ "do not write source files here (sandbox write modes are blocked).",
63
+ "Write changes as exact, copy-pasteable before/after code — never as prose instructions.",
64
+ "The ENGINE derive-checks the document — these are hard gates, not suggestions:",
65
+ "- frontmatter: `status: ready`, `phase_count: N`, `phases: [{n: 1, title: ...}, ...]`",
66
+ "- `phases:` / `phase_count` MUST match the `## Phase N:` body headings exactly (fenced examples ignored)",
67
+ "- every `file:line` citation MUST resolve against the working tree at this revision",
68
+ "Each `## Phase N: <title>` section contains:",
69
+ "- `### Changes Required` — per file: path + the exact intended change",
70
+ "- `### Success Criteria` — `#### Automated Verification:` (runnable commands) and",
71
+ " `#### Manual Verification:` checklists",
72
+ "Phases must be independently implementable in order, each leaving the tree working.",
73
+ 'When ready: `advance_phase("validate", summary)`.',
74
+ ].join("\n"));
75
+
76
+ const VALIDATE_GUIDANCE = Object.freeze([
77
+ "## Validate phase (adversarial plan review)",
78
+ "Nothing has been implemented — you are reviewing the PLAN, not a diff.",
79
+ "Read the goal artifact (verbatim brief), the clarifications artifact (recorded Decisions),",
80
+ "and the plan. For each plan phase check against the working tree:",
81
+ "- every cited `file:line` still resolves and says what the plan claims",
82
+ "- every file the phase edits exists (or is created by an earlier phase)",
83
+ "- no phase contradicts a recorded Decision or silently resolves an Open Question",
84
+ "- the Success Criteria are actually runnable and actually prove the change",
85
+ "- phases are independently implementable in the stated order",
86
+ "Write ONE review via `save_artifact(\"validation\", content)` with frontmatter:",
87
+ "- `status: ready`",
88
+ "- `blockers_count: <int >= 0>` # MEASURED — routing key; not prose",
89
+ "- `verdict: pass | fail` # pass requires blockers_count === 0",
90
+ "Each blocker needs a resolvable `file:line` citation.",
91
+ "Then finalize: `answer[\"content\"] = <the reviewed plan + review summary>; answer[\"ready\"] = True`.",
92
+ "If `blockers_count > 0`, the engine re-enters blueprint (bounded by maxBackwardJumps).",
93
+ ].join("\n"));
94
+
95
+ const GUIDANCE: Readonly<Record<Phase, string>> = Object.freeze({
96
+ clarify: CLARIFY_GUIDANCE,
97
+ research: RESEARCH_GUIDANCE,
98
+ blueprint: BLUEPRINT_GUIDANCE,
99
+ validate: VALIDATE_GUIDANCE,
100
+ });
101
+
102
+ export function phaseGuidance(phase: Phase): string {
103
+ return GUIDANCE[phase];
104
+ }
@@ -20,6 +20,7 @@ export interface SystemPromptOptions {
20
20
  readonly todo?: boolean;
21
21
  readonly pipeline?: boolean;
22
22
  readonly maxPromptChars?: number;
23
+ readonly libraryLoader?: boolean;
23
24
  }
24
25
 
25
26
  export type ContextKind = "files" | "text";
@@ -76,7 +77,14 @@ function howToRunCode(): string {
76
77
  ].join(" ");
77
78
  }
78
79
 
79
- function replGlossary(kind: ContextKind, recursion: boolean, askUserQuestion: boolean, todo: boolean, pipeline: boolean): string {
80
+ function replGlossary(
81
+ kind: ContextKind,
82
+ recursion: boolean,
83
+ askUserQuestion: boolean,
84
+ todo: boolean,
85
+ pipeline: boolean,
86
+ libraryLoader: boolean,
87
+ ): string {
80
88
  const lines = ["Available in the REPL:"];
81
89
  if (kind === "text") {
82
90
  lines.push(
@@ -116,8 +124,9 @@ function replGlossary(kind: ContextKind, recursion: boolean, askUserQuestion: bo
116
124
  "- `ask_user_question(questions: list[dict]) -> list[dict]`: pause and present the user",
117
125
  " with 1-4 structured questions. Each question: {question, header, options: [{label, description}],",
118
126
  " multiSelect?}. Returns list of {question, selected: [label], custom?}.",
119
- " Use when you have 2-4 concrete options from your analysis and need a decision before proceeding.",
120
- " DO NOT ask open-ended chat questions use concrete options grounded in code/data.",
127
+ " Default: use concrete options grounded in code/data (2–4 choices, Recommended first when ranking).",
128
+ " Exception clarify-phase intent rounds: lead with an open-ended intent question whose options",
129
+ " are answer *shapes* (not a Recommended pick); free-text / Other carries the real framing.",
121
130
  " Only valid at root depth; returns an error inside rlm_query sub-calls.",
122
131
  );
123
132
  }
@@ -130,6 +139,25 @@ function replGlossary(kind: ContextKind, recursion: boolean, askUserQuestion: bo
130
139
  " Use to plan multi-step work before starting, then mark tasks as you complete them.",
131
140
  );
132
141
  }
142
+ if (libraryLoader) {
143
+ lines.push(
144
+ "- `load_library(source: str) -> dict`: load an EXTERNAL library, source tree, or document and",
145
+ " **APPEND its files into the existing `context` list** (same shape: path/content/tokens).",
146
+ " `source` may be a local directory (repomix-packed), a single file path, or an https/git@ URL",
147
+ " (shallow-cloned, then packed). Paths are namespaced under `lib/<source_id>/…` so you can filter",
148
+ " by prefix. Returns metadata only:",
149
+ " {\"source\", \"source_id\", \"path_prefix\", \"files\", \"chars\", \"context_len\", \"already_loaded\"}",
150
+ " or an \"Error: ...\" string. **Never treat the return value as the file list** — always search",
151
+ " and chunk the single variable `context`. Do not invent `context_1` / aliases; do not call",
152
+ " globals()/locals(). Idempotent: re-loading the same source is a no-op.",
153
+ "",
154
+ " ```python",
155
+ " info = load_library(\"/path/to/other-project\")",
156
+ " # info is metadata; files are already in context under info[\"path_prefix\"]",
157
+ " lib_files = [f for f in context if f[\"path\"].startswith(info[\"path_prefix\"])]",
158
+ " ```",
159
+ );
160
+ }
133
161
  if (recursion) {
134
162
  lines.push(
135
163
  "- `rlm_query(prompt, model=None)` / `rlm_query_batched(prompts, model=None)`: recursive RLM",
@@ -148,9 +176,16 @@ function replGlossary(kind: ContextKind, recursion: boolean, askUserQuestion: bo
148
176
  }
149
177
  if (pipeline) {
150
178
  lines.push(
151
- "- `advance_phase(phase: str, summary=None) -> str`: transition the root RLM pipeline to the next phase.",
152
- " Valid phases in order: 'research' 'blueprint' 'implement' 'validate'. You must advance forward",
153
- " one phase at a time. Only callable at the root depth; returns an error in sub-RLM contexts.",
179
+ "- `save_artifact(kind: str, content: str) -> str`: persist a stage artifact under `.rlm/artifacts/`.",
180
+ " Kinds: `'clarification'` | `'research'` | `'plan'` | `'validation'`. Must match the current phase.",
181
+ " Frontmatter must eventually include `status: ready` before `advance_phase` will accept the transition.",
182
+ "- `advance_phase(phase: str, summary=None) -> str`: transition to the next pipeline phase.",
183
+ " Order: 'clarify' → 'research' → 'blueprint' → 'validate' (one step at a time;",
184
+ " clarify is skipped when ask_user_question is disabled). The pipeline is READ-ONLY.",
185
+ " **advance_phase is validated by the engine** — it measures the latest saved artifact",
186
+ " (status, structure, citations, blockers_count; clarify also requires ≥1 ask_user_question round).",
187
+ " A rejected transition returns the gate error for you to fix; the phase does NOT advance.",
188
+ " Only callable at root depth.",
154
189
  );
155
190
  }
156
191
  lines.push(
@@ -197,7 +232,10 @@ export function buildRlmSystemPrompt(meta: PromptMeta, opts: SystemPromptOptions
197
232
  "",
198
233
  howToRunCode(),
199
234
  "",
200
- replGlossary(kind, recursion, opts.askUserQuestion ?? false, opts.todo ?? false, opts.pipeline ?? false),
235
+ replGlossary(
236
+ kind, recursion, opts.askUserQuestion ?? false, opts.todo ?? false,
237
+ opts.pipeline ?? false, opts.libraryLoader ?? false,
238
+ ),
201
239
  "",
202
240
  "REPL stdout over ~800 characters is truncated to a short excerpt — large results stay in your",
203
241
  "REPL variables as buffers. Re-print only the slice you need (e.g. `print(result[:500])`); never",
@@ -232,8 +270,8 @@ function nativeReplGlossary(): string {
232
270
  "- `rlm_query_batched(prompts, model=None) -> list[str]` — concurrent recursive RLM calls.",
233
271
  "",
234
272
  "- `todo(action, **kwargs) -> str` — manage a task list. Actions: create, update, list, get, delete, clear. Statuses: pending → in_progress → completed.",
273
+ "- `load_library(source) -> dict`: append external dir/file/git tree into `context` under `lib/<id>/…`. Return is metadata only — always use `context`.",
235
274
  "- `SHOW_VARS() -> str` — list all variables currently in the REPL.",
236
- "- `stage_edit(path, old_text, new_text) -> str`: stages an edit and returns an edit ID; apply IDs with `apply_edits`.",
237
275
  "- `answer`: dict `{\"content\": \"\", \"ready\": False}`. Setting `answer[\"ready\"] = True` delivers it to the user; do not restate it.",
238
276
  "",
239
277
  "### Orchestrator Pattern",
@@ -262,18 +300,19 @@ function nativeReplGlossary(): string {
262
300
  "|------|------|",
263
301
  "| `repl({code})` | Need to chunk/delegate `context` to sub-LLMs; need Python scripting; need REPL state across calls |",
264
302
  "| `zebra-mcp` | Semantic search over the codebase |",
265
- "| `edit` | Native Pi edit tool; prefer `stage_edit` + `apply_edits` from REPL for file changes in native RLM mode |",
266
- "| `write` | Create a new file (native Pi flow, visible to all plugins) |",
303
+ "| `edit` | Change an existing file. Compose oldText/newText yourself; exact match required |",
304
+ "| `write` | Create a new file |",
267
305
  "| `llm_query` (inside repl) | Extract, summarize, or classify a chunk of text |",
268
306
  "| `rlm_query` (inside repl) | Complex sub-task needing iterative reasoning with its own REPL |",
269
307
  "| `todo` (inside repl) | Track multi-step progress visibly to the user |",
270
- "| `stage_edit(path, old, new)` (inside repl) | Stage exact edit params; apply returned IDs with `apply_edits` |",
271
308
  "",
272
309
  "### Workflow",
273
310
  "1. **Plan**: Create todos for the multi-step analysis. Probe `context` — print length, inspect a few entries.",
274
311
  "2. **Chunk & Delegate**: Slice `context` into batches, delegate each batch to sub-LLMs via `llm_query_batched`.",
275
312
  "3. **Aggregate**: Collect results in Python, pass aggregated results to a final `llm_query` or produce the answer directly.",
276
- "4. **Finalize**: For file changes, stage them inside repl() via `stage_edit(path, old, new)`, then apply the returned IDs with `apply_edits({ ids })`. Do not use the native `edit` tool directly for native RLM file changes unless explicitly asked. For analysis tasks, write a normal message.",
313
+ "4. **Finalize**: For file changes, call the native `edit` / `write` tools directly you",
314
+ " author the change, and Pi validates the anchor and renders the diff. For analysis tasks,",
315
+ " write a normal message.",
277
316
  "",
278
317
  "### Task-Specific Patterns",
279
318
  LARGE_FILE_RULE_NATIVE,
@@ -305,9 +344,11 @@ export function buildNativeSystemPrompt(): string {
305
344
  "All file content is pre-loaded in the REPL `context` variable. Use ONLY `repl({code})`.",
306
345
  "If sub-LLM credits are exhausted → report the error to the user and stop.",
307
346
  "",
308
- "For file changes, prefer `stage_edit()` inside repl(); it returns edit IDs and keeps edit bodies out of your output.",
309
- "When repl() returns STAGED_EDITS, apply them with `apply_edits({ ids: [\"e1\", ...] })`.",
310
- "Never re-type file paths, oldText, newText, file bodies, or `answer[\"content\"]` in your own output.",
347
+ "AUTHORING RULE: sub-LLMs (`llm_query` family) READ they extract, locate, and summarize.",
348
+ "They never author code you will ship. Once you know WHAT to change, compose the exact",
349
+ "oldText / newText yourself and apply it with `edit`. Delegated code is written by a small",
350
+ "model with no view of the codebase; it is a research aid, never a patch.",
351
+ "Never re-type file bodies or `answer[\"content\"]` in your own output.",
311
352
  "",
312
353
  nativeReplGlossary(),
313
354
  ].join("\n");
@@ -326,7 +367,9 @@ export const NATIVE_TURN_REMINDER = [
326
367
  "repl() stdout to you is hard-capped at 4K chars; read/grep and bash readers are blocked.",
327
368
  "Any SEMANTIC reading of file/text content MUST go through llm_query / llm_query_batched /",
328
369
  "llm_query_chunked (rlm_query for iterative sub-tasks). Deterministic Python (search, count,",
329
- "slice, json) is free. Keep your own output to decisions and aggregation.]",
370
+ "slice, json) is free. AUTHORING IS NOT READING: you write every edit body yourself and apply",
371
+ "it with the native `edit` / `write` tools — never delegate code you will ship to a sub-LLM.",
372
+ "Keep your own output to decisions, authored edits, and aggregation.]",
330
373
  ].join("\n");
331
374
 
332
375
  /** The one-line context metadata, also reused by the per-turn prompt in headless mode. */
@@ -3,16 +3,24 @@
3
3
  * Native mode does not use these — pi's own loop supplies the turns.
4
4
  */
5
5
 
6
- export function buildTurnPrompt(iteration: number, maxIterations: number, gateMessage?: string): string {
6
+ export function buildTurnPrompt(
7
+ iteration: number,
8
+ maxIterations: number,
9
+ gateMessage?: string,
10
+ phaseGuidanceText?: string,
11
+ ): string {
12
+ const parts: string[] = [];
13
+ if (phaseGuidanceText) parts.push(phaseGuidanceText);
14
+ if (gateMessage) parts.push(gateMessage);
15
+ const prefix = parts.length > 0 ? `${parts.join("\n\n")}\n\n` : "";
7
16
  const body = `Turn ${iteration + 1}/${maxIterations}:`;
8
- const gate = gateMessage ? `\n${gateMessage}\n\n` : "";
9
17
  if (iteration === 0) {
10
18
  return (
11
19
  "You have not interacted with the REPL or seen your context yet. Look at the context first; " +
12
- `do not provide a final answer yet.\n\n${gate}${body}`
20
+ `do not provide a final answer yet.\n\n${prefix}${body}`
13
21
  );
14
22
  }
15
- return `${gate}${body}`;
23
+ return `${prefix}${body}`;
16
24
  }
17
25
 
18
26
  /** Asked once when the engine runs out of turns without a submitted answer. */
@@ -21,18 +21,21 @@ export interface LlmReply {
21
21
  readonly response?: string;
22
22
  readonly responses?: readonly string[];
23
23
  readonly answers?: readonly AskAnswer[];
24
+ /** load_library reply: temp file with the packed payload (+ resume index / namespace). */
25
+ readonly path?: string;
26
+ readonly json?: boolean;
27
+ readonly index?: number;
28
+ readonly files?: number;
29
+ readonly chars?: number;
30
+ readonly source_id?: string;
31
+ readonly path_prefix?: string;
32
+ /** Host-side idempotency: library already loaded — no path payload. */
33
+ readonly already_loaded?: boolean;
24
34
  readonly error?: string;
25
35
  }
26
36
 
27
37
  export type ParentMessage = WorkerRequest | LlmReply;
28
38
 
29
- export interface ProposedEdit {
30
- readonly id: string;
31
- readonly path: string;
32
- readonly oldText: string;
33
- readonly newText: string;
34
- }
35
-
36
39
  /** A normal response to a request (keyed by the request `id`). */
37
40
  export interface WorkerResponse {
38
41
  readonly id: string;
@@ -43,7 +46,6 @@ export interface WorkerResponse {
43
46
  readonly stderr?: string;
44
47
  readonly final_answer?: string | null;
45
48
  readonly answer_content?: string;
46
- readonly edits?: readonly ProposedEdit[];
47
49
  readonly raised?: boolean;
48
50
  readonly execution_time?: number;
49
51
  // user-created variable names after this exec (filters builtins/context) — Metadata(stdout) for history orientation
@@ -62,8 +64,10 @@ export type InterruptKind =
62
64
  | "rlm_query"
63
65
  | "rlm_query_batched"
64
66
  | "advance_phase"
67
+ | "save_artifact"
65
68
  | "ask_user_question"
66
- | "todo";
69
+ | "todo"
70
+ | "load_library";
67
71
 
68
72
  export interface AskOption {
69
73
  readonly label: string;
@@ -111,6 +115,12 @@ interface AdvancePhaseInterrupt extends InterruptBase {
111
115
  readonly summary?: string;
112
116
  }
113
117
 
118
+ interface SaveArtifactInterrupt extends InterruptBase {
119
+ readonly type: "save_artifact";
120
+ readonly artifactKind?: string;
121
+ readonly content?: string;
122
+ }
123
+
114
124
  export interface AskUserQuestionInterrupt extends InterruptBase {
115
125
  readonly type: "ask_user_question";
116
126
  readonly questions: readonly AskQuestion[];
@@ -132,13 +142,20 @@ export interface TodoInterrupt extends InterruptBase {
132
142
  readonly includeDeleted?: boolean;
133
143
  }
134
144
 
145
+ export interface LoadLibraryInterrupt extends InterruptBase {
146
+ readonly type: "load_library";
147
+ readonly source?: string;
148
+ }
149
+
135
150
  /** A mid-exec sub-LLM/tool request from the worker. */
136
151
  export type WorkerInterrupt =
137
152
  | PromptInterrupt
138
153
  | BatchedPromptInterrupt
139
154
  | AdvancePhaseInterrupt
155
+ | SaveArtifactInterrupt
140
156
  | AskUserQuestionInterrupt
141
- | TodoInterrupt;
157
+ | TodoInterrupt
158
+ | LoadLibraryInterrupt;
142
159
 
143
160
  export type WorkerMessage = WorkerResponse | WorkerInterrupt;
144
161
 
@@ -148,8 +165,10 @@ export const INTERRUPT_KINDS = Object.freeze(new Set<InterruptKind>([
148
165
  "rlm_query",
149
166
  "rlm_query_batched",
150
167
  "advance_phase",
168
+ "save_artifact",
151
169
  "ask_user_question",
152
170
  "todo",
171
+ "load_library",
153
172
  ]));
154
173
 
155
174
  function isRecord(value: unknown): value is Record<string, unknown> {
@@ -178,7 +197,6 @@ export interface ReplResult {
178
197
  readonly stderr: string;
179
198
  readonly finalAnswer: string | null;
180
199
  readonly answerContent: string;
181
- readonly edits: readonly ProposedEdit[];
182
200
  readonly raised: boolean;
183
201
  readonly executionTimeMs: number;
184
202
  /** User-created variable names after this exec (builtins/context filtered out). */
@@ -26,6 +26,18 @@ import {
26
26
  } from "./protocol.ts";
27
27
  import { formatError } from "../util/errors.ts";
28
28
 
29
+ /** Result of a host-side library pack requested by `load_library`. */
30
+ export interface LibraryLoadResult {
31
+ readonly payload: unknown; // always ContextFile[] under lib/<id>/
32
+ readonly index: number; // resume-sidecar index (not a REPL var name); -1 if alreadyLoaded
33
+ readonly files?: number;
34
+ readonly chars: number;
35
+ readonly sourceId: string;
36
+ readonly pathPrefix: string;
37
+ /** Host already has this library — no pack, no sidecar, empty payload. */
38
+ readonly alreadyLoaded?: boolean;
39
+ }
40
+
29
41
  /** Handlers the bridge installs to service sub-LLM interrupts. Return the reply payload. */
30
42
  export interface SubLlmHandlers {
31
43
  llmQuery(prompt: string, model: string | null, depth: number): Promise<string>;
@@ -33,8 +45,10 @@ export interface SubLlmHandlers {
33
45
  rlmQuery(prompt: string, model: string | null, depth: number): Promise<string>;
34
46
  rlmQueryBatched(prompts: readonly string[], model: string | null, depth: number): Promise<string[]>;
35
47
  advancePhase(phase: string, summary: string | undefined, depth: number): Promise<string>;
48
+ saveArtifact(kind: string, content: string, depth: number): Promise<string>;
36
49
  askUserQuestion(questions: readonly AskQuestion[], depth: number): Promise<AskAnswer[]>;
37
50
  todo(action: string, params: Record<string, unknown>, depth: number): Promise<string>;
51
+ loadLibrary(source: string, depth: number): Promise<LibraryLoadResult>;
38
52
  }
39
53
 
40
54
  export interface SandboxOptions {
@@ -54,6 +68,11 @@ export interface SandboxOptions {
54
68
  readonly initTimeoutMs?: number;
55
69
  /** Sub-LLM prompt cap (chars) — sizes llm_query_chunked chunks inside the worker. */
56
70
  readonly maxPromptChars?: number;
71
+ /**
72
+ * When true, the worker rejects open() write modes (pipeline read-only runs).
73
+ * Native repl() data work leaves this false so scratch-file writes still work.
74
+ */
75
+ readonly readOnly?: boolean;
57
76
  }
58
77
 
59
78
  const WORKER_PATH = join(dirname(fileURLToPath(import.meta.url)), "worker.py");
@@ -76,12 +95,14 @@ const REJECT: SubLlmHandlers = {
76
95
  rlmQuery: async () => formatError("sub-LLM bridge not configured"),
77
96
  rlmQueryBatched: async (p) => p.map(() => formatError("sub-LLM bridge not configured")),
78
97
  advancePhase: async () => formatError("phase advancement not available"),
98
+ saveArtifact: async () => formatError("save_artifact not available"),
79
99
  askUserQuestion: async (questions) => questions.map((q) => ({
80
100
  question: q.question,
81
101
  selected: [],
82
102
  custom: formatError("ask_user_question not configured"),
83
103
  })),
84
104
  todo: async () => formatError("todo not configured"),
105
+ loadLibrary: async () => { throw new Error("load_library not configured"); },
85
106
  };
86
107
 
87
108
  /** Distributive omit so each union member keeps its own fields (plain Omit collapses to shared keys). */
@@ -120,6 +141,9 @@ export class PythonSandbox {
120
141
  if (opts.maxPromptChars !== undefined) {
121
142
  workerArgs.push("--max-prompt-chars", String(opts.maxPromptChars));
122
143
  }
144
+ if (opts.readOnly) {
145
+ workerArgs.push("--read-only");
146
+ }
123
147
  this.proc = spawn(
124
148
  python,
125
149
  workerArgs,
@@ -200,7 +224,6 @@ export class PythonSandbox {
200
224
  stderr: res.stderr ?? "",
201
225
  finalAnswer: res.final_answer ?? null,
202
226
  answerContent: res.answer_content ?? "",
203
- edits: res.edits ?? [],
204
227
  raised: res.raised ?? false,
205
228
  executionTimeMs: Math.round((res.execution_time ?? 0) * 1000),
206
229
  varNames: res.var_names ?? [],
@@ -285,6 +308,15 @@ export class PythonSandbox {
285
308
  }
286
309
  }
287
310
 
311
+ /**
312
+ * Refresh the parent-side request watchdog for every pending request.
313
+ * Used during long mid-exec work that does not
314
+ * produce additional worker interrupts on this sandbox.
315
+ */
316
+ refreshWatchdog(): void {
317
+ this.touchPending();
318
+ }
319
+
288
320
  private send(msg: ParentMessage): void {
289
321
  this.proc.stdin.write(`${JSON.stringify(msg)}\n`);
290
322
  }
@@ -346,6 +378,9 @@ export class PythonSandbox {
346
378
  } else if (msg.type === "advance_phase") {
347
379
  const response = await h.advancePhase(msg.phase ?? "", msg.summary, d);
348
380
  this.reply(msg.rid, { response });
381
+ } else if (msg.type === "save_artifact") {
382
+ const response = await h.saveArtifact(msg.artifactKind ?? "", msg.content ?? "", d);
383
+ this.reply(msg.rid, { response });
349
384
  } else if (msg.type === "ask_user_question") {
350
385
  const answers = await h.askUserQuestion(msg.questions ?? [], d);
351
386
  this.reply(msg.rid, { answers });
@@ -355,13 +390,53 @@ export class PythonSandbox {
355
390
  );
356
391
  const response = await h.todo(msg.action ?? "list", params, d);
357
392
  this.reply(msg.rid, { response });
393
+ } else if (msg.type === "load_library") {
394
+ const lib = await h.loadLibrary(msg.source ?? "", d);
395
+ if (lib.alreadyLoaded) {
396
+ // No temp file — worker short-circuits on already_loaded.
397
+ this.reply(msg.rid, {
398
+ already_loaded: true,
399
+ index: lib.index,
400
+ files: 0,
401
+ chars: lib.chars,
402
+ source_id: lib.sourceId,
403
+ path_prefix: lib.pathPrefix,
404
+ });
405
+ } else {
406
+ const isJson = typeof lib.payload !== "string";
407
+ const path = await this.writeContextFile(lib.payload, isJson);
408
+ // Worker reads then unlinks (worker._load_library). Host must not unlink here —
409
+ // if the worker is SIGKILLed before os.remove, the temp file leaks in tmpdir (acceptable).
410
+ this.reply(msg.rid, {
411
+ path,
412
+ json: isJson,
413
+ index: lib.index,
414
+ files: lib.files,
415
+ chars: lib.chars,
416
+ source_id: lib.sourceId,
417
+ path_prefix: lib.pathPrefix,
418
+ });
419
+ }
358
420
  }
359
421
  } catch (err) {
360
422
  this.reply(msg.rid, { error: err instanceof Error ? err.message : String(err) });
361
423
  }
362
424
  }
363
425
 
364
- private reply(rid: string, body: { response?: string; responses?: string[]; answers?: AskAnswer[]; error?: string }): void {
426
+ private reply(rid: string, body: {
427
+ response?: string;
428
+ responses?: string[];
429
+ answers?: AskAnswer[];
430
+ path?: string;
431
+ json?: boolean;
432
+ index?: number;
433
+ files?: number;
434
+ chars?: number;
435
+ source_id?: string;
436
+ path_prefix?: string;
437
+ already_loaded?: boolean;
438
+ error?: string;
439
+ }): void {
365
440
  if (!this.disposed) this.send({ type: "llm_reply", rid, ...body });
366
441
  }
367
442