@hicaru/pi-rlm 0.1.8 → 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.
package/src/core/gates.ts CHANGED
@@ -78,6 +78,35 @@ export function countHeadingsOutsideFences(content: string, re: RegExp): number
78
78
  return count;
79
79
  }
80
80
 
81
+ /** Count newlines without materialising a line array. */
82
+ export function lineCountOf(text: string): number {
83
+ let count = 1;
84
+ for (let i = 0; i < text.length; i++) if (text.charCodeAt(i) === 10) count++;
85
+ return count;
86
+ }
87
+
88
+ /** Names of `## Phase N:` sections that contain no `### Success Criteria` heading. */
89
+ export function phasesMissingSuccessCriteria(content: string): readonly string[] {
90
+ const missing: string[] = [];
91
+ let currentPhase: string | undefined;
92
+ let sawCriteria = false;
93
+ const flush = (): void => {
94
+ if (currentPhase !== undefined && !sawCriteria) missing.push(currentPhase);
95
+ };
96
+ forEachLineOutsideFences(content, (line) => {
97
+ const phase = /^## Phase (\d+):/.exec(line);
98
+ if (phase) {
99
+ flush();
100
+ currentPhase = `Phase ${phase[1] ?? "?"}`;
101
+ sawCriteria = false;
102
+ return;
103
+ }
104
+ if (currentPhase !== undefined && /^### Success Criteria/.test(line)) sawCriteria = true;
105
+ });
106
+ flush();
107
+ return Object.freeze(missing);
108
+ }
109
+
81
110
  /**
82
111
  * Fence-aware count of top-level (column-0) `- ` bullets under a `## <heading>` section.
83
112
  * Nested/indented sub-bullets are ignored. The next `## ` heading ends the section.
@@ -207,7 +236,7 @@ export function verifyCitations(body: string, cwd: string): GateResult<undefined
207
236
  }
208
237
  let lineCount: number;
209
238
  try {
210
- lineCount = readFileSync(abs, "utf-8").split("\n").length;
239
+ lineCount = lineCountOf(readFileSync(abs, "utf-8"));
211
240
  } catch {
212
241
  errors.push(`unbacked citation ${key} — file could not be read`);
213
242
  continue;
@@ -1,11 +1,14 @@
1
1
  /**
2
2
  * RLM pipeline stage graph — data-driven transitions with deterministic gates.
3
3
  *
4
- * Stages: clarify → research → blueprint → implement → validate
4
+ * Stages: clarify → research → blueprint → validate
5
5
  * (clarify skipped when askUserQuestion is off).
6
6
  * The root RLM writes artifacts via save_artifact(); advance_phase() is gated by
7
7
  * TypeScript floors (never LLM judgment). Validate routes on measured
8
8
  * blockers_count with a bounded corrective loop back to blueprint.
9
+ * The pipeline is read-only by design: it produces a validated plan and does not
10
+ * write source. Accidental sandbox writes are blocked (open/pathlib/os.open);
11
+ * this is steering, not a hard security boundary.
9
12
  */
10
13
  import {
11
14
  checkStatusReady,
@@ -19,16 +22,31 @@ import {
19
22
  verifyCitations,
20
23
  } from "./gates.ts";
21
24
 
22
- export type Phase = "clarify" | "research" | "blueprint" | "implement" | "validate";
25
+ export type Phase = "clarify" | "research" | "blueprint" | "validate";
23
26
 
24
27
  export const PHASES = Object.freeze([
25
28
  "clarify",
26
29
  "research",
27
30
  "blueprint",
28
- "implement",
29
31
  "validate",
30
32
  ] as const satisfies readonly Phase[]);
31
33
 
34
+ const PHASE_SET: ReadonlySet<string> = Object.freeze(new Set<string>(PHASES));
35
+
36
+ export function isPhase(value: unknown): value is Phase {
37
+ return typeof value === "string" && PHASE_SET.has(value);
38
+ }
39
+
40
+ /**
41
+ * Map a persisted phase name onto the current graph. Trails written before the
42
+ * implement phase was retired resume at `blueprint` — the last phase whose
43
+ * artifact is still meaningful.
44
+ */
45
+ export function reconcilePhase(persisted: unknown): Phase {
46
+ if (isPhase(persisted)) return persisted;
47
+ return "blueprint";
48
+ }
49
+
32
50
  /** Kind string the model passes to save_artifact(kind, content). */
33
51
  export type ArtifactKind = "clarification" | "research" | "plan" | "validation";
34
52
 
@@ -37,8 +55,7 @@ export type StageGateData =
37
55
  | { readonly kind: "clarification"; readonly clarification: ClarificationGateData }
38
56
  | { readonly kind: "research" }
39
57
  | { readonly kind: "plan"; readonly plan: PlanGateData }
40
- | { readonly kind: "validation"; readonly validation: ValidationGateData }
41
- | { readonly kind: "side-effect" };
58
+ | { readonly kind: "validation"; readonly validation: ValidationGateData };
42
59
 
43
60
  export interface StageDef {
44
61
  readonly phase: Phase;
@@ -90,13 +107,6 @@ export const STAGES: Readonly<Record<Phase, StageDef>> = Object.freeze({
90
107
  clarify: { phase: "clarify", artifactDir: "clarifications", artifactKind: "clarification", gate: clarifyGate },
91
108
  research: { phase: "research", artifactDir: "research", artifactKind: "research", gate: researchGate },
92
109
  blueprint: { phase: "blueprint", artifactDir: "plans", artifactKind: "plan", gate: blueprintGate },
93
- // implement is a side-effect stage; exit is engine-driven (serial fanout).
94
- implement: {
95
- phase: "implement",
96
- artifactDir: "",
97
- artifactKind: "",
98
- gate: (): GateResult<StageGateData> => ({ ok: true, value: { kind: "side-effect" } }),
99
- },
100
110
  validate: { phase: "validate", artifactDir: "validations", artifactKind: "validation", gate: validateGate },
101
111
  });
102
112
 
@@ -115,12 +125,27 @@ export function stageForArtifactKind(kind: string): StageDef | undefined {
115
125
  return undefined;
116
126
  }
117
127
 
128
+ /** An artifact path plus its lifecycle status — append-only, never deleted. */
129
+ export interface ArtifactRef {
130
+ readonly path: string;
131
+ readonly status: "active" | "superseded";
132
+ /** Path of the artifact that superseded this one (the rejecting validation). */
133
+ readonly supersededBy?: string;
134
+ }
135
+
136
+ /** The latest artifact saved for a stage, plus its gate result when it passed. */
137
+ export interface SavedArtifact {
138
+ readonly path: string;
139
+ /** Gate payload when the artifact passed; undefined ⇒ advance_phase must re-gate. */
140
+ readonly gateData?: StageGateData;
141
+ }
142
+
118
143
  export interface PhaseState {
119
144
  readonly current: Phase;
120
145
  readonly advancedAt: number;
121
146
  readonly summary?: string;
122
147
  /** Artifact each completed stage produced — the named channels. */
123
- readonly artifacts: Readonly<Partial<Record<Phase, string>>>;
148
+ readonly artifacts: Readonly<Partial<Record<Phase, ArtifactRef>>>;
124
149
  /** Corrective validate→blueprint re-entries taken so far. */
125
150
  readonly backwardJumps: number;
126
151
  }
package/src/core/types.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  /** Shared configuration + runtime types for the RLM engine. */
2
2
 
3
3
  import type { ThinkingLevel } from "@earendil-works/pi-ai";
4
- import type { AskAnswer, AskQuestion, ProposedEdit } from "../sandbox/protocol.ts";
4
+ import type { AskAnswer, AskQuestion } from "../sandbox/protocol.ts";
5
5
  import type { ReconstructResult } from "../state/resume.ts";
6
6
 
7
7
  export interface Sampling {
@@ -105,8 +105,6 @@ export interface RlmInput {
105
105
  /** Result of a completed RLM run. */
106
106
  export interface RlmResult {
107
107
  readonly answer: string;
108
- /** Legacy anchor edits retained for compatibility while older run-state rows exist. */
109
- readonly edits?: readonly ProposedEdit[];
110
108
  readonly iterations: number;
111
109
  readonly costUsd: number;
112
110
  readonly inputTokens: number;
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,7 +24,6 @@ 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();
30
27
  let onSandboxDiscardExtra: (() => void) | undefined;
31
28
  const sandboxManager = new SandboxManager({
32
29
  execTimeoutS: config.execTimeoutS,
@@ -34,10 +31,7 @@ export default function rlmExtension(pi: ExtensionAPI): void {
34
31
  python: config.python,
35
32
  sandboxInitTimeoutMs: config.sandboxInitTimeoutMs,
36
33
  maxPromptChars: config.maxPromptChars,
37
- onSandboxDiscarded: () => {
38
- editRegistry.clear();
39
- onSandboxDiscardExtra?.();
40
- },
34
+ onSandboxDiscarded: () => { onSandboxDiscardExtra?.(); },
41
35
  });
42
36
  let packedContextText: string | undefined;
43
37
  let contextPackPromise: Promise<string | undefined> | undefined;
@@ -85,7 +79,6 @@ export default function rlmExtension(pi: ExtensionAPI): void {
85
79
 
86
80
  // ── Tool registration ──
87
81
  pi.registerTool(createRlmTool(controller));
88
- pi.registerTool(createApplyEditsTool(editRegistry));
89
82
  let guidePosted = false;
90
83
 
91
84
  pi.on("session_start", async (_event, ctx) => {
@@ -109,7 +102,6 @@ export default function rlmExtension(pi: ExtensionAPI): void {
109
102
  getModel: () => controller.resolveModels(ctx)?.model,
110
103
  getWorkerModel: () => controller.resolveModels(ctx)?.worker,
111
104
  registry: ctx.modelRegistry,
112
- editRegistry,
113
105
  config: controller.config,
114
106
  registerDiscardHook: (reset) => { onSandboxDiscardExtra = reset; },
115
107
  ensureContext: async () => {
@@ -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
  }
@@ -3,7 +3,6 @@
3
3
  * Injected at each phase boundary; the only channel between phases is durable
4
4
  * artifacts under .rlm/artifacts/.
5
5
  */
6
- import type { PhaseRecord } from "../core/gates.ts";
7
6
  import type { Phase } from "../core/pipeline.ts";
8
7
 
9
8
  const CLARIFY_GUIDANCE = Object.freeze([
@@ -48,7 +47,7 @@ const RESEARCH_GUIDANCE = Object.freeze([
48
47
  "Probe the repository, delegate long reads via `llm_query_batched` / `llm_query_chunked`.",
49
48
  "Every factual claim about the code MUST be cited as `path/file.ext:LINE` (or `LINE-LINE`).",
50
49
  "The engine VERIFIES citations against the working tree — unbacked citations reject advance.",
51
- "When research is complete, write ONE research document and call:",
50
+ "When research is complete, write ONE research document with a `## Findings` section and call:",
52
51
  ' `save_artifact("research", content)` # frontmatter must include `status: ready`',
53
52
  ' `advance_phase("blueprint", summary)`',
54
53
  "Do not implement code in this phase.",
@@ -59,39 +58,37 @@ const BLUEPRINT_GUIDANCE = Object.freeze([
59
58
  "Read the clarifications artifact (if present) and plan only within recorded Decisions;",
60
59
  "Open Questions that would block the design must be re-asked via ask_user_question, not assumed.",
61
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.",
62
64
  "The ENGINE derive-checks the document — these are hard gates, not suggestions:",
63
65
  "- frontmatter: `status: ready`, `phase_count: N`, `phases: [{n: 1, title: ...}, ...]`",
64
66
  "- `phases:` / `phase_count` MUST match the `## Phase N:` body headings exactly (fenced examples ignored)",
65
67
  "- every `file:line` citation MUST resolve against the working tree at this revision",
66
68
  "Each `## Phase N: <title>` section contains:",
67
- "- `### Changes Required` — per file: path + exact intended change (code from research)",
69
+ "- `### Changes Required` — per file: path + the exact intended change",
68
70
  "- `### Success Criteria` — `#### Automated Verification:` (runnable commands) and",
69
71
  " `#### Manual Verification:` checklists",
70
- "Phases are executed by ISOLATED workers, one at a time, in order: each phase must be",
71
- "independently implementable, leave the tree working, and never share a file with a",
72
- "later phase unless that phase only EDITS what an earlier phase CREATED.",
73
- 'When ready: `advance_phase("implement", summary)` — the engine runs implement fanout itself.',
74
- ].join("\n"));
75
-
76
- const IMPLEMENT_GUIDANCE = Object.freeze([
77
- "## Implement phase",
78
- "The engine drives serial child-RLM workers over each plan phase — you do not implement",
79
- "here. If you were re-entered into implement unexpectedly, call",
80
- '`advance_phase("validate")` only after the fanout has already completed.',
72
+ "Phases must be independently implementable in order, each leaving the tree working.",
73
+ 'When ready: `advance_phase("validate", summary)`.',
81
74
  ].join("\n"));
82
75
 
83
76
  const VALIDATE_GUIDANCE = Object.freeze([
84
- "## Validate phase",
77
+ "## Validate phase (adversarial plan review)",
78
+ "Nothing has been implemented — you are reviewing the PLAN, not a diff.",
85
79
  "Read the goal artifact (verbatim brief), the clarifications artifact (recorded Decisions),",
86
- "and the plan. Check each phase's Success Criteria against the working tree.",
87
- "Exclude paths listed in the baseline JSON (pre-existing dirt).",
88
- "Open Questions are not silently resolved — a blocking one surfaces via ask_user_question.",
89
- "Write ONE validation document via `save_artifact(\"validation\", content)` with frontmatter:",
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:",
90
87
  "- `status: ready`",
91
- "- `blockers_count: <int ≥ 0>` # MEASURED — routing key; not prose",
88
+ "- `blockers_count: <int >= 0>` # MEASURED — routing key; not prose",
92
89
  "- `verdict: pass | fail` # pass requires blockers_count === 0",
93
90
  "Each blocker needs a resolvable `file:line` citation.",
94
- "Then finalize: `answer[\"content\"] = <report>; answer[\"ready\"] = True`.",
91
+ "Then finalize: `answer[\"content\"] = <the reviewed plan + review summary>; answer[\"ready\"] = True`.",
95
92
  "If `blockers_count > 0`, the engine re-enters blueprint (bounded by maxBackwardJumps).",
96
93
  ].join("\n"));
97
94
 
@@ -99,27 +96,9 @@ const GUIDANCE: Readonly<Record<Phase, string>> = Object.freeze({
99
96
  clarify: CLARIFY_GUIDANCE,
100
97
  research: RESEARCH_GUIDANCE,
101
98
  blueprint: BLUEPRINT_GUIDANCE,
102
- implement: IMPLEMENT_GUIDANCE,
103
99
  validate: VALIDATE_GUIDANCE,
104
100
  });
105
101
 
106
102
  export function phaseGuidance(phase: Phase): string {
107
103
  return GUIDANCE[phase];
108
104
  }
109
-
110
- /** Single-phase implement prompt for an isolated child RLM (serial fanout unit). */
111
- export function buildImplementPhasePrompt(planPath: string, r: PhaseRecord): string {
112
- return [
113
- `Implement ONLY Phase ${r.n} (${r.title}) of the plan at ${planPath} (${r.index + 1}/${r.total}).`,
114
- `Read the plan from the REPL (open("${planPath}").read()).`,
115
- "Hard rules (you are one unit of a sequenced run):",
116
- "- CRITICAL: `context` is a STALE snapshot from run start. Always `open(path).read()` any file",
117
- " you will edit BEFORE computing stage_edit anchors — earlier phases may have already changed them.",
118
- "- Touch ONLY the files this phase's ### Changes Required lists.",
119
- "- Earlier phases have already landed; a missing prerequisite file is a HARD ERROR —",
120
- " finalize with 'Error: prerequisite missing: <path>' instead of creating it.",
121
- "- Stage every change via stage_edit(path, old_text, new_text); never defer your own edits.",
122
- "- Run only THIS phase's '#### Automated Verification:' commands; whole-plan checks are validate's job.",
123
- `Finalize with a short summary of what landed for Phase ${r.n}.`,
124
- ].join("\n");
125
- }
@@ -141,13 +141,21 @@ function replGlossary(
141
141
  }
142
142
  if (libraryLoader) {
143
143
  lines.push(
144
- "- `load_library(source: str) -> dict`: load an EXTERNAL library, source tree, or document into a",
145
- " NEW `context_N` REPL variable. `source` may be a local directory (repomix-packed to the same",
146
- " list[dict] shape as `context`), a single file path (loaded as a plain str), or an https/git@ URL",
147
- " (shallow-cloned, then packed). Returns {\"index\": N, \"var\": \"context_N\", \"files\", \"chars\"}",
148
- " on success or an \"Error: ...\" string. Use it when the task requires learning an external lib's",
149
- " API, structure, or docs that are not in `context`; then chunk `context_N` to sub-LLMs exactly like",
150
- " `context`. Do not re-load a source that is already in a slot.",
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
+ " ```",
151
159
  );
152
160
  }
153
161
  if (recursion) {
@@ -172,8 +180,8 @@ function replGlossary(
172
180
  " Kinds: `'clarification'` | `'research'` | `'plan'` | `'validation'`. Must match the current phase.",
173
181
  " Frontmatter must eventually include `status: ready` before `advance_phase` will accept the transition.",
174
182
  "- `advance_phase(phase: str, summary=None) -> str`: transition to the next pipeline phase.",
175
- " Order: 'clarify' → 'research' → 'blueprint' → 'implement' → 'validate' (one step at a time;",
176
- " clarify is skipped when ask_user_question is disabled).",
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.",
177
185
  " **advance_phase is validated by the engine** — it measures the latest saved artifact",
178
186
  " (status, structure, citations, blockers_count; clarify also requires ≥1 ask_user_question round).",
179
187
  " A rejected transition returns the gate error for you to fix; the phase does NOT advance.",
@@ -262,9 +270,8 @@ function nativeReplGlossary(): string {
262
270
  "- `rlm_query_batched(prompts, model=None) -> list[str]` — concurrent recursive RLM calls.",
263
271
  "",
264
272
  "- `todo(action, **kwargs) -> str` — manage a task list. Actions: create, update, list, get, delete, clear. Statuses: pending → in_progress → completed.",
265
- "- `load_library(source)` → `context_N` (external).",
273
+ "- `load_library(source) -> dict`: append external dir/file/git tree into `context` under `lib/<id>/…`. Return is metadata only — always use `context`.",
266
274
  "- `SHOW_VARS() -> str` — list all variables currently in the REPL.",
267
- "- `stage_edit(path, old_text, new_text) -> str`: stages an edit and returns an edit ID; apply IDs with `apply_edits`.",
268
275
  "- `answer`: dict `{\"content\": \"\", \"ready\": False}`. Setting `answer[\"ready\"] = True` delivers it to the user; do not restate it.",
269
276
  "",
270
277
  "### Orchestrator Pattern",
@@ -293,18 +300,19 @@ function nativeReplGlossary(): string {
293
300
  "|------|------|",
294
301
  "| `repl({code})` | Need to chunk/delegate `context` to sub-LLMs; need Python scripting; need REPL state across calls |",
295
302
  "| `zebra-mcp` | Semantic search over the codebase |",
296
- "| `edit` | Native Pi edit tool; prefer `stage_edit` + `apply_edits` from REPL for file changes in native RLM mode |",
297
- "| `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 |",
298
305
  "| `llm_query` (inside repl) | Extract, summarize, or classify a chunk of text |",
299
306
  "| `rlm_query` (inside repl) | Complex sub-task needing iterative reasoning with its own REPL |",
300
307
  "| `todo` (inside repl) | Track multi-step progress visibly to the user |",
301
- "| `stage_edit(path, old, new)` (inside repl) | Stage exact edit params; apply returned IDs with `apply_edits` |",
302
308
  "",
303
309
  "### Workflow",
304
310
  "1. **Plan**: Create todos for the multi-step analysis. Probe `context` — print length, inspect a few entries.",
305
311
  "2. **Chunk & Delegate**: Slice `context` into batches, delegate each batch to sub-LLMs via `llm_query_batched`.",
306
312
  "3. **Aggregate**: Collect results in Python, pass aggregated results to a final `llm_query` or produce the answer directly.",
307
- "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.",
308
316
  "",
309
317
  "### Task-Specific Patterns",
310
318
  LARGE_FILE_RULE_NATIVE,
@@ -336,9 +344,11 @@ export function buildNativeSystemPrompt(): string {
336
344
  "All file content is pre-loaded in the REPL `context` variable. Use ONLY `repl({code})`.",
337
345
  "If sub-LLM credits are exhausted → report the error to the user and stop.",
338
346
  "",
339
- "For file changes, prefer `stage_edit()` inside repl(); it returns edit IDs and keeps edit bodies out of your output.",
340
- "When repl() returns STAGED_EDITS, apply them with `apply_edits({ ids: [\"e1\", ...] })`.",
341
- "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.",
342
352
  "",
343
353
  nativeReplGlossary(),
344
354
  ].join("\n");
@@ -357,7 +367,9 @@ export const NATIVE_TURN_REMINDER = [
357
367
  "repl() stdout to you is hard-capped at 4K chars; read/grep and bash readers are blocked.",
358
368
  "Any SEMANTIC reading of file/text content MUST go through llm_query / llm_query_batched /",
359
369
  "llm_query_chunked (rlm_query for iterative sub-tasks). Deterministic Python (search, count,",
360
- "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.]",
361
373
  ].join("\n");
362
374
 
363
375
  /** The one-line context metadata, also reused by the per-turn prompt in headless mode. */
@@ -21,24 +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 + assigned slot. */
24
+ /** load_library reply: temp file with the packed payload (+ resume index / namespace). */
25
25
  readonly path?: string;
26
26
  readonly json?: boolean;
27
27
  readonly index?: number;
28
28
  readonly files?: number;
29
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;
30
34
  readonly error?: string;
31
35
  }
32
36
 
33
37
  export type ParentMessage = WorkerRequest | LlmReply;
34
38
 
35
- export interface ProposedEdit {
36
- readonly id: string;
37
- readonly path: string;
38
- readonly oldText: string;
39
- readonly newText: string;
40
- }
41
-
42
39
  /** A normal response to a request (keyed by the request `id`). */
43
40
  export interface WorkerResponse {
44
41
  readonly id: string;
@@ -49,7 +46,6 @@ export interface WorkerResponse {
49
46
  readonly stderr?: string;
50
47
  readonly final_answer?: string | null;
51
48
  readonly answer_content?: string;
52
- readonly edits?: readonly ProposedEdit[];
53
49
  readonly raised?: boolean;
54
50
  readonly execution_time?: number;
55
51
  // user-created variable names after this exec (filters builtins/context) — Metadata(stdout) for history orientation
@@ -201,7 +197,6 @@ export interface ReplResult {
201
197
  readonly stderr: string;
202
198
  readonly finalAnswer: string | null;
203
199
  readonly answerContent: string;
204
- readonly edits: readonly ProposedEdit[];
205
200
  readonly raised: boolean;
206
201
  readonly executionTimeMs: number;
207
202
  /** User-created variable names after this exec (builtins/context filtered out). */
@@ -28,10 +28,14 @@ import { formatError } from "../util/errors.ts";
28
28
 
29
29
  /** Result of a host-side library pack requested by `load_library`. */
30
30
  export interface LibraryLoadResult {
31
- readonly payload: unknown; // string (single file) or ContextFile[] (packed dir/repo)
32
- readonly index: number; // slot assigned by the host
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
33
  readonly files?: number;
34
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;
35
39
  }
36
40
 
37
41
  /** Handlers the bridge installs to service sub-LLM interrupts. Return the reply payload. */
@@ -64,6 +68,11 @@ export interface SandboxOptions {
64
68
  readonly initTimeoutMs?: number;
65
69
  /** Sub-LLM prompt cap (chars) — sizes llm_query_chunked chunks inside the worker. */
66
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;
67
76
  }
68
77
 
69
78
  const WORKER_PATH = join(dirname(fileURLToPath(import.meta.url)), "worker.py");
@@ -132,6 +141,9 @@ export class PythonSandbox {
132
141
  if (opts.maxPromptChars !== undefined) {
133
142
  workerArgs.push("--max-prompt-chars", String(opts.maxPromptChars));
134
143
  }
144
+ if (opts.readOnly) {
145
+ workerArgs.push("--read-only");
146
+ }
135
147
  this.proc = spawn(
136
148
  python,
137
149
  workerArgs,
@@ -212,7 +224,6 @@ export class PythonSandbox {
212
224
  stderr: res.stderr ?? "",
213
225
  finalAnswer: res.final_answer ?? null,
214
226
  answerContent: res.answer_content ?? "",
215
- edits: res.edits ?? [],
216
227
  raised: res.raised ?? false,
217
228
  executionTimeMs: Math.round((res.execution_time ?? 0) * 1000),
218
229
  varNames: res.var_names ?? [],
@@ -299,7 +310,7 @@ export class PythonSandbox {
299
310
 
300
311
  /**
301
312
  * Refresh the parent-side request watchdog for every pending request.
302
- * Used during long mid-exec work (e.g. serial implement fanout) that does not
313
+ * Used during long mid-exec work that does not
303
314
  * produce additional worker interrupts on this sandbox.
304
315
  */
305
316
  refreshWatchdog(): void {
@@ -381,11 +392,31 @@ export class PythonSandbox {
381
392
  this.reply(msg.rid, { response });
382
393
  } else if (msg.type === "load_library") {
383
394
  const lib = await h.loadLibrary(msg.source ?? "", d);
384
- const isJson = typeof lib.payload !== "string";
385
- const path = await this.writeContextFile(lib.payload, isJson);
386
- // Worker reads then unlinks (worker._load_library). Host must not unlink here —
387
- // if the worker is SIGKILLed before os.remove, the temp file leaks in tmpdir (acceptable).
388
- this.reply(msg.rid, { path, json: isJson, index: lib.index, files: lib.files, chars: lib.chars });
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
+ }
389
420
  }
390
421
  } catch (err) {
391
422
  this.reply(msg.rid, { error: err instanceof Error ? err.message : String(err) });
@@ -401,6 +432,9 @@ export class PythonSandbox {
401
432
  index?: number;
402
433
  files?: number;
403
434
  chars?: number;
435
+ source_id?: string;
436
+ path_prefix?: string;
437
+ already_loaded?: boolean;
404
438
  error?: string;
405
439
  }): void {
406
440
  if (!this.disposed) this.send({ type: "llm_reply", rid, ...body });