@hicaru/pi-rlm 0.1.5 → 0.1.7

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hicaru/pi-rlm",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "type": "module",
5
5
  "description": "Save 99% tokens, Recursive Language Model (RLM) for the Pi",
6
6
  "license": "MIT",
package/src/index.ts CHANGED
@@ -7,6 +7,8 @@ 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";
10
12
  import { loadSettings, mergeConfig, resolveModelId } from "./config/settings.ts";
11
13
  import { RlmController, cheapestModel } from "./mode/rlm-mode.ts";
12
14
  import { postRlmGuide } from "./ui/intro.ts";
@@ -24,12 +26,14 @@ export default function rlmExtension(pi: ExtensionAPI): void {
24
26
  // Init synchronously with defaults — ensures commands/tools/handlers register before session_start
25
27
  const config = mergeConfig({});
26
28
  const controller = new RlmController(config);
29
+ const editRegistry = new EditRegistry();
27
30
  const sandboxManager = new SandboxManager({
28
31
  execTimeoutS: config.execTimeoutS,
29
32
  requestTimeoutMs: config.requestTimeoutMs,
30
33
  python: config.python,
31
34
  sandboxInitTimeoutMs: config.sandboxInitTimeoutMs,
32
35
  maxPromptChars: config.maxPromptChars,
36
+ onSandboxDiscarded: () => { editRegistry.clear(); },
33
37
  });
34
38
  let packedContextText: string | undefined;
35
39
  let contextPackPromise: Promise<string | undefined> | undefined;
@@ -77,6 +81,7 @@ export default function rlmExtension(pi: ExtensionAPI): void {
77
81
 
78
82
  // ── Tool registration ──
79
83
  pi.registerTool(createRlmTool(controller));
84
+ pi.registerTool(createApplyEditsTool(editRegistry));
80
85
  let guidePosted = false;
81
86
 
82
87
  pi.on("session_start", async (_event, ctx) => {
@@ -100,6 +105,7 @@ export default function rlmExtension(pi: ExtensionAPI): void {
100
105
  getModel: () => controller.resolveModels(ctx)?.model,
101
106
  getWorkerModel: () => controller.resolveModels(ctx)?.worker,
102
107
  registry: ctx.modelRegistry,
108
+ editRegistry,
103
109
  config: controller.config,
104
110
  ensureContext: async () => {
105
111
  const contextText = await ensureRepositoryContext(ctx.cwd ?? process.cwd());
@@ -233,8 +233,8 @@ function nativeReplGlossary(): string {
233
233
  "",
234
234
  "- `todo(action, **kwargs) -> str` — manage a task list. Actions: create, update, list, get, delete, clear. Statuses: pending → in_progress → completed.",
235
235
  "- `SHOW_VARS() -> str` — list all variables currently in the REPL.",
236
- "- `stage_edit(path, old_text, new_text)`: stage exact edits from `context`; apply returned STAGED_EDITS verbatim with edit().",
237
- "- `answer`: dict `{\"content\": \"\", \"ready\": False}`. To submit: `answer[\"content\"] = \"...\"; answer[\"ready\"] = True`.",
236
+ "- `stage_edit(path, old_text, new_text) -> str`: stages an edit and returns an edit ID; apply IDs with `apply_edits`.",
237
+ "- `answer`: dict `{\"content\": \"\", \"ready\": False}`. Setting `answer[\"ready\"] = True` delivers it to the user; do not restate it.",
238
238
  "",
239
239
  "### Orchestrator Pattern",
240
240
  "You are an **orchestrator, not a solver**. After probing `context`, decompose the task into sub-LLM / REPL steps,",
@@ -262,18 +262,18 @@ function nativeReplGlossary(): string {
262
262
  "|------|------|",
263
263
  "| `repl({code})` | Need to chunk/delegate `context` to sub-LLMs; need Python scripting; need REPL state across calls |",
264
264
  "| `zebra-mcp` | Semantic search over the codebase |",
265
- "| `edit` | Modify an existing file with exact text replacement (native Pi flow, visible to all plugins) |",
265
+ "| `edit` | Native Pi edit tool; prefer `stage_edit` + `apply_edits` from REPL for file changes in native RLM mode |",
266
266
  "| `write` | Create a new file (native Pi flow, visible to all plugins) |",
267
267
  "| `llm_query` (inside repl) | Extract, summarize, or classify a chunk of text |",
268
268
  "| `rlm_query` (inside repl) | Complex sub-task needing iterative reasoning with its own REPL |",
269
269
  "| `todo` (inside repl) | Track multi-step progress visibly to the user |",
270
- "| `stage_edit(path, old, new)` (inside repl) | Sub-agent stages exact edit params; relay STAGED_EDITS to `edit` |",
270
+ "| `stage_edit(path, old, new)` (inside repl) | Stage exact edit params; apply returned IDs with `apply_edits` |",
271
271
  "",
272
272
  "### Workflow",
273
273
  "1. **Plan**: Create todos for the multi-step analysis. Probe `context` — print length, inspect a few entries.",
274
274
  "2. **Chunk & Delegate**: Slice `context` into batches, delegate each batch to sub-LLMs via `llm_query_batched`.",
275
275
  "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 relay the STAGED_EDITS from the result to `edit`. For analysis tasks, write a normal message.",
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.",
277
277
  "",
278
278
  "### Task-Specific Patterns",
279
279
  LARGE_FILE_RULE_NATIVE,
@@ -305,12 +305,9 @@ export function buildNativeSystemPrompt(): string {
305
305
  "All file content is pre-loaded in the REPL `context` variable. Use ONLY `repl({code})`.",
306
306
  "If sub-LLM credits are exhausted → report the error to the user and stop.",
307
307
  "",
308
- "For file changes, use `edit` (modify existing) or `write` (create new) these route through",
309
- "Pi's native tool flow, visible to all plugins with a `+/-` diff preview.",
310
- "",
311
- "When repl() returns a STAGED_EDITS block, apply each entry by calling `edit` verbatim:",
312
- " edit({ path: entry.path, edits: [{ oldText: entry.oldText, newText: entry.newText }] })",
313
- "Do not analyze or modify the parameters — relay them exactly as provided by the sub-agent.",
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.",
314
311
  "",
315
312
  nativeReplGlossary(),
316
313
  ].join("\n");
@@ -0,0 +1,22 @@
1
+ import type { ProposedEdit } from "../sandbox/protocol.ts";
2
+
3
+ export class EditRegistry {
4
+ private readonly edits = new Map<string, ProposedEdit>();
5
+
6
+ registerAll(edits: readonly ProposedEdit[] | undefined): void {
7
+ if (edits === undefined) return;
8
+ for (const edit of edits) this.edits.set(edit.id, edit);
9
+ }
10
+
11
+ get(id: string): ProposedEdit | undefined {
12
+ return this.edits.get(id);
13
+ }
14
+
15
+ delete(id: string): boolean {
16
+ return this.edits.delete(id);
17
+ }
18
+
19
+ clear(): void {
20
+ this.edits.clear();
21
+ }
22
+ }
@@ -27,6 +27,7 @@ export interface LlmReply {
27
27
  export type ParentMessage = WorkerRequest | LlmReply;
28
28
 
29
29
  export interface ProposedEdit {
30
+ readonly id: string;
30
31
  readonly path: string;
31
32
  readonly oldText: string;
32
33
  readonly newText: string;
@@ -15,6 +15,7 @@ export interface SandboxManagerConfig {
15
15
  readonly sandboxInitTimeoutMs: number;
16
16
  readonly maxPromptChars: number;
17
17
  readonly signal?: AbortSignal;
18
+ readonly onSandboxDiscarded?: () => void;
18
19
  }
19
20
 
20
21
  export class SandboxManager {
@@ -118,6 +119,7 @@ export class SandboxManager {
118
119
  try { await this.sandbox.dispose(); } catch { /* already dead */ }
119
120
  this.sandbox = null;
120
121
  this.contextLoaded = false;
122
+ this.config.onSandboxDiscarded?.();
121
123
  }
122
124
  throw err;
123
125
  } finally {
@@ -141,7 +143,10 @@ export class SandboxManager {
141
143
  if (this.disposed) return;
142
144
  this.disposed = true;
143
145
  await this.sandbox?.dispose();
144
- this.sandbox = null;
145
- this.contextLoaded = false;
146
+ if (this.sandbox !== null) {
147
+ this.sandbox = null;
148
+ this.contextLoaded = false;
149
+ this.config.onSandboxDiscarded?.();
150
+ }
146
151
  }
147
152
  }
@@ -132,6 +132,7 @@ class Worker:
132
132
  self.ns = {"__builtins__": _SAFE_BUILTINS.copy(), "__name__": "__main__"}
133
133
  self._ctx_payloads: dict[int, Any] = {}
134
134
  self._staged_edits: list[dict[str, str]] = []
135
+ self._edit_counter = 0
135
136
  self._nudged: set[str] = set()
136
137
  self._restore_scaffold()
137
138
 
@@ -325,8 +326,10 @@ class Worker:
325
326
  def _stage_edit(self, path: str, old_text: str, new_text: str) -> str:
326
327
  if not isinstance(path, str) or not isinstance(old_text, str) or not isinstance(new_text, str):
327
328
  return "Error: path, old_text, new_text must be strings"
328
- self._staged_edits.append({"path": path, "oldText": old_text, "newText": new_text})
329
- return f"Staged edit for {path} ({len(old_text)} → {len(new_text)} chars)"
329
+ self._edit_counter += 1
330
+ edit_id = f"e{self._edit_counter}"
331
+ self._staged_edits.append({"id": edit_id, "path": path, "oldText": old_text, "newText": new_text})
332
+ return edit_id
330
333
 
331
334
  def _advance_phase(self, phase: str, summary: str | None = None) -> str:
332
335
  """Transition the root RLM pipeline to a new phase.
@@ -0,0 +1,288 @@
1
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import { dirname, resolve } from "node:path";
3
+ import { createEditToolDefinition, type AgentToolResult, type Theme, type ToolDefinition } from "@earendil-works/pi-coding-agent";
4
+ import { Container, Text, type Component } from "@earendil-works/pi-tui";
5
+ import { Type } from "typebox";
6
+ import type { EditRegistry } from "../registry/edit-registry.ts";
7
+ import { countOccurrences } from "../text/edits.ts";
8
+ import { errorMessage, formatError } from "../util/errors.ts";
9
+
10
+ export const ApplyEditsToolParams = Object.freeze(Type.Object({
11
+ ids: Type.Array(Type.String({ description: "A staged edit ID returned by stage_edit()." }), {
12
+ description: "Staged edit IDs to apply.",
13
+ }),
14
+ }));
15
+
16
+ export interface ApplyEditsFailure {
17
+ readonly id: string;
18
+ readonly path: string;
19
+ readonly error: string;
20
+ }
21
+
22
+ export interface ApplyEditsPatch {
23
+ readonly oldText: string;
24
+ readonly newText: string;
25
+ }
26
+
27
+ export interface ApplyEditsFileStat {
28
+ readonly path: string;
29
+ readonly status: "applied" | "failed";
30
+ readonly added: number;
31
+ readonly removed: number;
32
+ readonly edits: readonly ApplyEditsPatch[];
33
+ }
34
+
35
+ export interface ApplyEditsDetails {
36
+ readonly status: "done" | "partial" | "error";
37
+ readonly appliedCount: number;
38
+ readonly failedCount: number;
39
+ readonly errors: readonly ApplyEditsFailure[];
40
+ readonly fileStats: readonly ApplyEditsFileStat[];
41
+ }
42
+
43
+ export interface LineStats {
44
+ readonly added: number;
45
+ readonly removed: number;
46
+ }
47
+
48
+ export function countLines(text: string): number {
49
+ return text.length === 0 ? 0 : text.split("\n").length;
50
+ }
51
+
52
+ export function diffStats(before: string, after: string): LineStats {
53
+ const beforeLineCount = countLines(before);
54
+ const afterLineCount = countLines(after);
55
+ return Object.freeze({
56
+ added: Math.max(0, afterLineCount - beforeLineCount),
57
+ removed: Math.max(0, beforeLineCount - afterLineCount),
58
+ });
59
+ }
60
+
61
+ function statusFor(appliedCount: number, failedCount: number): ApplyEditsDetails["status"] {
62
+ if (failedCount === 0 && appliedCount > 0) return "done";
63
+ return appliedCount > 0 ? "partial" : "error";
64
+ }
65
+
66
+ function aggregateLineStats(fileStats: readonly ApplyEditsFileStat[]): LineStats {
67
+ let added = 0;
68
+ let removed = 0;
69
+ for (let i = 0; i < fileStats.length; i++) {
70
+ const stat = fileStats[i];
71
+ added += stat.added;
72
+ removed += stat.removed;
73
+ }
74
+ return Object.freeze({ added, removed });
75
+ }
76
+
77
+ function appendPatch(existing: readonly ApplyEditsPatch[], patch: ApplyEditsPatch | undefined): readonly ApplyEditsPatch[] {
78
+ if (patch === undefined) return existing;
79
+ const patches = new Array<ApplyEditsPatch>(existing.length + 1);
80
+ for (let i = 0; i < existing.length; i++) {
81
+ patches[i] = existing[i];
82
+ }
83
+ patches[existing.length] = patch;
84
+ return Object.freeze(patches);
85
+ }
86
+
87
+ function mergeFileStat(
88
+ fileStatsByPath: Map<string, ApplyEditsFileStat>,
89
+ path: string,
90
+ status: ApplyEditsFileStat["status"],
91
+ stats: LineStats,
92
+ patch?: ApplyEditsPatch,
93
+ ): void {
94
+ const existing = fileStatsByPath.get(path);
95
+ const nextStatus: ApplyEditsFileStat["status"] = existing?.status === "failed" || status === "failed" ? "failed" : "applied";
96
+ fileStatsByPath.set(path, {
97
+ path,
98
+ status: nextStatus,
99
+ added: (existing?.added ?? 0) + stats.added,
100
+ removed: (existing?.removed ?? 0) + stats.removed,
101
+ edits: appendPatch(existing?.edits ?? Object.freeze([]), patch),
102
+ });
103
+ }
104
+
105
+ function renderLineStats(stats: LineStats, theme: Theme): string {
106
+ return `${theme.fg("success", `+${stats.added}`)} ${theme.fg("error", `-${stats.removed}`)} lines`;
107
+ }
108
+
109
+ function formatEditCounts(details: ApplyEditsDetails): string {
110
+ return details.failedCount > 0
111
+ ? `${details.appliedCount} applied, ${details.failedCount} failed`
112
+ : `${details.appliedCount} applied`;
113
+ }
114
+
115
+ function formatFileCount(fileCount: number): string {
116
+ return `${fileCount} file${fileCount === 1 ? "" : "s"}`;
117
+ }
118
+
119
+ function summarizeFiles(details: ApplyEditsDetails): string {
120
+ return `apply_edits: ${formatFileCount(details.fileStats.length)}, ${formatEditCounts(details)}`;
121
+ }
122
+
123
+ function summarize(details: ApplyEditsDetails): string {
124
+ const stats = aggregateLineStats(details.fileStats);
125
+ const head = `${summarizeFiles(details)} (+${stats.added} -${stats.removed} lines)`;
126
+ if (details.errors.length === 0) return `${head}.`;
127
+ const rows = new Array<string>(details.errors.length);
128
+ for (let i = 0; i < details.errors.length; i++) {
129
+ const error = details.errors[i];
130
+ rows[i] = `${error.id}: ${error.error}`;
131
+ }
132
+ return `${head}.\n${rows.join("\n")}`;
133
+ }
134
+
135
+ function renderCollapsed(details: ApplyEditsDetails, theme: Theme): Text {
136
+ const stats = aggregateLineStats(details.fileStats);
137
+ const statusColor = details.status === "error" ? "error" : "success";
138
+ return new Text(`${theme.fg(statusColor, summarizeFiles(details))} ${renderLineStats(stats, theme)}`, 0, 0);
139
+ }
140
+
141
+ function renderFileLine(fileStat: ApplyEditsFileStat, theme: Theme): Text {
142
+ const glyph = fileStat.status === "applied" ? theme.fg("success", "✓") : theme.fg("error", "✗");
143
+ const stats = renderLineStats(fileStat, theme);
144
+ return new Text(`${glyph} ${theme.fg("dim", fileStat.path)} ${stats}`, 0, 0);
145
+ }
146
+
147
+ function limitPatchText(text: string): string {
148
+ const maxChars = 2_000;
149
+ return text.length > maxChars ? `${text.slice(0, maxChars)}…` : text;
150
+ }
151
+
152
+ function renderPatchLines(text: string, prefix: string, color: "error" | "success", theme: Theme): string {
153
+ const limitedText = limitPatchText(text);
154
+ const lines = limitedText.split("\n");
155
+ const rendered = new Array<string>(lines.length);
156
+ for (let i = 0; i < lines.length; i++) {
157
+ rendered[i] = theme.fg(color, `${prefix}${lines[i]}`);
158
+ }
159
+ return rendered.join("\n");
160
+ }
161
+
162
+ function renderPatch(patch: ApplyEditsPatch, theme: Theme): Text {
163
+ const oldText = patch.oldText.length === 0 ? "(new file)" : patch.oldText;
164
+ const text = [
165
+ theme.fg("error", "--- old"),
166
+ renderPatchLines(oldText, "- ", "error", theme),
167
+ theme.fg("success", "+++ new"),
168
+ renderPatchLines(patch.newText, "+ ", "success", theme),
169
+ ].join("\n");
170
+ return new Text(text, 2, 0);
171
+ }
172
+
173
+ function renderExpanded(details: ApplyEditsDetails, theme: Theme): Container {
174
+ const container = new Container();
175
+ const header = summarizeFiles(details);
176
+ const headerColor = details.status === "error" ? "error" : "success";
177
+ container.addChild(new Text(theme.fg(headerColor, header), 0, 0));
178
+ for (let i = 0; i < details.fileStats.length; i++) {
179
+ const fileStat = details.fileStats[i];
180
+ container.addChild(renderFileLine(fileStat, theme));
181
+ for (let editIndex = 0; editIndex < fileStat.edits.length; editIndex++) {
182
+ container.addChild(renderPatch(fileStat.edits[editIndex], theme));
183
+ }
184
+ }
185
+ if (details.errors.length > 0) {
186
+ const rows = new Array<string>(details.errors.length);
187
+ for (let i = 0; i < details.errors.length; i++) {
188
+ const error = details.errors[i];
189
+ rows[i] = `${error.id}: ${error.error}`;
190
+ }
191
+ container.addChild(new Text(theme.fg("error", rows.join("\n")), 0, 0));
192
+ }
193
+ return container;
194
+ }
195
+
196
+ export function createApplyEditsTool(editRegistry: EditRegistry): ToolDefinition<typeof ApplyEditsToolParams, ApplyEditsDetails> {
197
+ return {
198
+ name: "apply_edits",
199
+ label: "Apply Edits",
200
+ description: "Apply staged REPL edits by ID without re-typing file paths or edit bodies.",
201
+ parameters: ApplyEditsToolParams,
202
+
203
+ async execute(toolCallId, params, signal, _onUpdate, ctx): Promise<AgentToolResult<ApplyEditsDetails>> {
204
+ const errors = new Array<ApplyEditsFailure>(params.ids.length);
205
+ const fileStatsByPath = new Map<string, ApplyEditsFileStat>();
206
+ let appliedCount = 0;
207
+ let failedCount = 0;
208
+
209
+ const editTool = createEditToolDefinition(ctx.cwd);
210
+ for (let i = 0; i < params.ids.length; i++) {
211
+ const id = params.ids[i];
212
+ const edit = editRegistry.get(id);
213
+ if (edit === undefined) {
214
+ errors[failedCount] = { id, path: id, error: formatError("unknown edit id") };
215
+ mergeFileStat(fileStatsByPath, id, "failed", { added: 0, removed: 0 });
216
+ failedCount++;
217
+ continue;
218
+ }
219
+
220
+ try {
221
+ const fullPath = resolve(ctx.cwd, edit.path);
222
+ if (edit.oldText.length === 0) {
223
+ await mkdir(dirname(fullPath), { recursive: true });
224
+ await writeFile(fullPath, edit.newText, "utf8");
225
+ mergeFileStat(fileStatsByPath, edit.path, "applied", diffStats("", edit.newText), { oldText: edit.oldText, newText: edit.newText });
226
+ editRegistry.delete(id);
227
+ appliedCount++;
228
+ continue;
229
+ }
230
+
231
+ const content = await readFile(fullPath, "utf8");
232
+ const occurrences = countOccurrences(content, edit.oldText);
233
+ if (occurrences !== 1) {
234
+ errors[failedCount] = { id, path: edit.path, error: formatError(`anchor occurs ${occurrences} times in ${edit.path}`) };
235
+ mergeFileStat(fileStatsByPath, edit.path, "failed", { added: 0, removed: 0 }, { oldText: edit.oldText, newText: edit.newText });
236
+ failedCount++;
237
+ continue;
238
+ }
239
+
240
+ const after = content.replace(edit.oldText, edit.newText);
241
+ await editTool.execute(
242
+ toolCallId,
243
+ { path: edit.path, edits: [{ oldText: edit.oldText, newText: edit.newText }] },
244
+ signal,
245
+ undefined,
246
+ ctx,
247
+ );
248
+ mergeFileStat(fileStatsByPath, edit.path, "applied", diffStats(content, after), { oldText: edit.oldText, newText: edit.newText });
249
+ editRegistry.delete(id);
250
+ appliedCount++;
251
+ } catch (error: unknown) {
252
+ const path = edit.path;
253
+ errors[failedCount] = { id, path, error: formatError(errorMessage(error)) };
254
+ mergeFileStat(fileStatsByPath, path, "failed", { added: 0, removed: 0 }, { oldText: edit.oldText, newText: edit.newText });
255
+ failedCount++;
256
+ }
257
+ }
258
+
259
+ const fileStats = new Array<ApplyEditsFileStat>(fileStatsByPath.size);
260
+ let fileStatIndex = 0;
261
+ for (const stat of fileStatsByPath.values()) {
262
+ fileStats[fileStatIndex] = stat;
263
+ fileStatIndex++;
264
+ }
265
+
266
+ const details = Object.freeze({
267
+ status: statusFor(appliedCount, failedCount),
268
+ appliedCount,
269
+ failedCount,
270
+ errors: Object.freeze(errors.slice(0, failedCount)),
271
+ fileStats: Object.freeze(fileStats),
272
+ });
273
+ return { content: [{ type: "text", text: summarize(details) }], details };
274
+ },
275
+
276
+ renderCall(args, theme) {
277
+ const editCount = args.ids.length;
278
+ const summary = `apply_edits: ${editCount} edit${editCount === 1 ? "" : "s"}`;
279
+ return new Text(theme.fg("toolTitle", theme.bold(summary)), 0, 0);
280
+ },
281
+
282
+ renderResult(result, options, theme): Component {
283
+ const details = result.details;
284
+ if (details === undefined) return new Text("(no apply_edits details)", 0, 0);
285
+ return options.expanded ? renderExpanded(details, theme) : renderCollapsed(details, theme);
286
+ },
287
+ };
288
+ }
@@ -21,6 +21,8 @@ export interface ReplDetails {
21
21
  readonly subcalls: readonly RlmSubcall[];
22
22
  /** Running totals for this repl() call (cost + tokens from sub-LLM calls). */
23
23
  readonly totals: { readonly costUsd: number; readonly tokens: number };
24
- /** File edits staged inside the REPL for native relay through edit(). */
24
+ /** Final answer submitted through answer["ready"] without echoing it to the model. */
25
+ readonly finalAnswer?: string;
26
+ /** File edits staged inside the REPL for native relay through apply_edits(). */
25
27
  readonly edits?: readonly ProposedEdit[];
26
28
  }
@@ -34,6 +34,7 @@ import type { ReplDetails } from "./repl-details.ts";
34
34
  import type { RlmSubcall } from "./rlm-details.ts";
35
35
  import { createEngine } from "../core/engine.ts";
36
36
  import { formatCost, formatTokens, spinnerFrame } from "../ui/theme.ts";
37
+ import type { EditRegistry } from "../registry/edit-registry.ts";
37
38
  import { errorMessage, formatError, isErrorText } from "../util/errors.ts";
38
39
  import {
39
40
  headlineStatusGlyph,
@@ -59,29 +60,49 @@ export interface ReplResultText {
59
60
  readonly surfacedEdits: readonly ProposedEdit[] | undefined;
60
61
  }
61
62
 
63
+ function countLines(text: string): number {
64
+ if (text.length === 0) return 0;
65
+ let count = 1;
66
+ for (const ch of text) if (ch === "\n") count++;
67
+ return count;
68
+ }
69
+
70
+ function stagedEditSummary(edits: readonly ProposedEdit[]): string {
71
+ const rows = new Array<string>(edits.length);
72
+ for (let i = 0; i < edits.length; i++) {
73
+ const edit = edits[i];
74
+ rows[i] = ` ${edit.id} ${edit.path} (-${countLines(edit.oldText)}/+${countLines(edit.newText)} lines)`;
75
+ }
76
+ return [
77
+ "STAGED_EDITS (apply by id with apply_edits; do NOT re-type content):",
78
+ ...rows,
79
+ ].join("\n");
80
+ }
81
+
62
82
  /**
63
83
  * Assemble the model-visible text for a repl() result: cap stdout, append a zero-subcall
64
- * delegation nudge (suppressed when edits were staged), and append the STAGED_EDITS block
65
- * AFTER capping so edit JSON is never truncated. Extracted as a pure function so the
66
- * capping/ordering invariants are testable independently of the sandbox.
84
+ * delegation nudge (suppressed when edits were staged), and summarize staged edits by ID
85
+ * without exposing oldText/newText bodies to the root model.
67
86
  */
68
87
  export function buildReplResultText(
69
88
  stdout: string,
70
- answerContent: string | undefined,
89
+ finalAnswer: string | undefined,
71
90
  edits: readonly ProposedEdit[],
72
91
  raised: boolean,
73
92
  subcalls: readonly RlmSubcall[],
74
93
  ): ReplResultText {
75
- const rawText = stdout || answerContent || "(no output)";
94
+ const answerSubmitted = finalAnswer !== undefined;
95
+ const rawText = answerSubmitted
96
+ ? `ANSWER_SUBMITTED (${finalAnswer.length} chars) — delivered to user. Do not restate it.`
97
+ : stdout || "(no output)";
76
98
  const surfacedEdits = surfaceReplEdits(edits, raised);
77
- const editsBlock = surfacedEdits
78
- ? `\n\nSTAGED_EDITS:\n${JSON.stringify(surfacedEdits)}`
79
- : "";
80
- // Model-visible text is capped; the caller keeps full stdout in `details.output` for the TUI.
81
- const cappedText = capReplResultText(rawText) ?? rawText;
99
+ const editsBlock = surfacedEdits ? `\n\n${stagedEditSummary(surfacedEdits)}` : "";
100
+ const modelText = rawText + editsBlock;
101
+ // Model-visible text is capped; the caller keeps full stdout/final answer in `details` for the TUI.
102
+ const cappedText = capReplResultText(modelText) ?? modelText;
82
103
  const delegated = subcalls.some((s) => s.kind === "llm" || s.kind === "batch" || s.kind === "rlm");
83
- const nudge = surfacedEdits ? undefined : replDelegationNudge(rawText.length, delegated);
84
- return { text: cappedText + (nudge ?? "") + editsBlock, surfacedEdits };
104
+ const nudge = surfacedEdits || answerSubmitted ? undefined : replDelegationNudge(rawText.length, delegated);
105
+ return { text: cappedText + (nudge ?? ""), surfacedEdits };
85
106
  }
86
107
 
87
108
  // ── Mutable bridge state (handler indirection) ──
@@ -315,6 +336,7 @@ export interface ReplToolDeps {
315
336
  readonly getModel?: () => Model<Api> | undefined;
316
337
  readonly getWorkerModel?: () => Model<Api> | undefined;
317
338
  readonly registry: ModelRegistry;
339
+ readonly editRegistry?: EditRegistry;
318
340
  readonly config: RlmConfig;
319
341
  readonly signal?: AbortSignal;
320
342
  readonly onUsage?: (usage: Usage, role: "sub") => void;
@@ -322,7 +344,7 @@ export interface ReplToolDeps {
322
344
  }
323
345
 
324
346
  export function createReplTool(deps: ReplToolDeps): ToolDefinition<typeof ReplToolParams, ReplDetails> {
325
- const { sandboxManager, workerModel, registry, config, signal, onUsage } = deps;
347
+ const { sandboxManager, workerModel, registry, editRegistry, config, signal, onUsage } = deps;
326
348
  const bridgeState = new NativeBridgeState();
327
349
 
328
350
  // Build handlers once — llm/rlm use mutable refs, interactive is session-stable
@@ -464,13 +486,15 @@ export function createReplTool(deps: ReplToolDeps): ToolDefinition<typeof ReplTo
464
486
 
465
487
  if (queuedId) emitter.emitSubcallUpdated({ id: queuedId, status: "done" });
466
488
 
489
+ const finalAnswer = result.finalAnswer ?? undefined;
467
490
  const { text: resultText, surfacedEdits } = buildReplResultText(
468
491
  result.stdout,
469
- result.answerContent,
492
+ finalAnswer,
470
493
  result.edits,
471
494
  result.raised,
472
495
  store.getSubcalls(),
473
496
  );
497
+ editRegistry?.registerAll(surfacedEdits);
474
498
 
475
499
  const details: ReplDetails = {
476
500
  status: "done",
@@ -479,10 +503,14 @@ export function createReplTool(deps: ReplToolDeps): ToolDefinition<typeof ReplTo
479
503
  executionTimeMs: elapsed,
480
504
  subcalls: store.getSubcalls(),
481
505
  totals: store.getTotals(),
506
+ finalAnswer,
482
507
  edits: surfacedEdits,
483
508
  };
509
+ const progressText = finalAnswer !== undefined
510
+ ? `ANSWER_SUBMITTED (${finalAnswer.length} chars)`
511
+ : result.stdout.slice(0, 500) || "(no output)";
484
512
  // Final progressive update
485
- onUpdate?.({ content: [{ type: "text", text: result.stdout.slice(0, 500) || "(no output)" }], details });
513
+ onUpdate?.({ content: [{ type: "text", text: progressText }], details });
486
514
  return { content: [{ type: "text", text: resultText }], details };
487
515
  } catch (e) {
488
516
  progressStatus = "error";