@hicaru/pi-rlm 0.1.5 → 0.1.6

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.6",
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,",
@@ -267,13 +267,13 @@ function nativeReplGlossary(): string {
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 })`. 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,126 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { resolve } from "node:path";
3
+ import { createEditToolDefinition, type AgentToolResult, type ToolDefinition } from "@earendil-works/pi-coding-agent";
4
+ import { Text } from "@earendil-works/pi-tui";
5
+ import { Type } from "typebox";
6
+ import type { EditToolDetails } from "@earendil-works/pi-coding-agent";
7
+ import type { EditRegistry } from "../registry/edit-registry.ts";
8
+ import { countOccurrences } from "../text/edits.ts";
9
+ import { errorMessage, formatError } from "../util/errors.ts";
10
+
11
+ export const ApplyEditsToolParams = Object.freeze(Type.Object({
12
+ ids: Type.Array(Type.String({ description: "A staged edit ID returned by stage_edit()." }), {
13
+ description: "Staged edit IDs to apply.",
14
+ }),
15
+ }));
16
+
17
+ export interface ApplyEditsFailure {
18
+ readonly id: string;
19
+ readonly error: string;
20
+ }
21
+
22
+ export interface ApplyEditsDetails {
23
+ readonly status: "done" | "partial" | "error";
24
+ readonly appliedIds: readonly string[];
25
+ readonly errors: readonly ApplyEditsFailure[];
26
+ readonly editDetails: readonly EditToolDetails[];
27
+ }
28
+
29
+ function statusFor(appliedCount: number, errorCount: number): ApplyEditsDetails["status"] {
30
+ if (errorCount === 0) return "done";
31
+ return appliedCount > 0 ? "partial" : "error";
32
+ }
33
+
34
+ function summarize(details: ApplyEditsDetails): string {
35
+ const head = details.errors.length > 0
36
+ ? `apply_edits: ${details.appliedIds.length} applied, ${details.errors.length} failed`
37
+ : `apply_edits: ${details.appliedIds.length} applied`;
38
+ if (details.errors.length === 0) return `${head}.`;
39
+ const rows = new Array<string>(details.errors.length);
40
+ for (let i = 0; i < details.errors.length; i++) {
41
+ const error = details.errors[i];
42
+ rows[i] = `${error.id}: ${error.error}`;
43
+ }
44
+ return `${head}.\n${rows.join("\n")}`;
45
+ }
46
+
47
+ export function createApplyEditsTool(editRegistry: EditRegistry): ToolDefinition<typeof ApplyEditsToolParams, ApplyEditsDetails> {
48
+ return {
49
+ name: "apply_edits",
50
+ label: "Apply Edits",
51
+ description: "Apply staged REPL edits by ID without re-typing file paths or edit bodies.",
52
+ parameters: ApplyEditsToolParams,
53
+
54
+ async execute(toolCallId, params, signal, _onUpdate, ctx): Promise<AgentToolResult<ApplyEditsDetails>> {
55
+ const appliedIds = new Array<string>(params.ids.length);
56
+ const errors = new Array<ApplyEditsFailure>(params.ids.length);
57
+ const editDetails = new Array<EditToolDetails>(params.ids.length);
58
+ let appliedCount = 0;
59
+ let errorCount = 0;
60
+ let detailCount = 0;
61
+
62
+ const editTool = createEditToolDefinition(ctx.cwd);
63
+ for (let i = 0; i < params.ids.length; i++) {
64
+ const id = params.ids[i];
65
+ const edit = editRegistry.get(id);
66
+ if (edit === undefined) {
67
+ errors[errorCount] = { id, error: formatError("unknown edit id") };
68
+ errorCount++;
69
+ continue;
70
+ }
71
+
72
+ try {
73
+ const fullPath = resolve(ctx.cwd, edit.path);
74
+ const content = await readFile(fullPath, "utf8");
75
+ const occurrences = countOccurrences(content, edit.oldText);
76
+ if (occurrences !== 1) {
77
+ errors[errorCount] = { id, error: formatError(`anchor occurs ${occurrences} times in ${edit.path}`) };
78
+ errorCount++;
79
+ continue;
80
+ }
81
+
82
+ const result = await editTool.execute(
83
+ toolCallId,
84
+ { path: edit.path, edits: [{ oldText: edit.oldText, newText: edit.newText }] },
85
+ signal,
86
+ undefined,
87
+ ctx,
88
+ );
89
+ if (result.details !== undefined) {
90
+ editDetails[detailCount] = result.details;
91
+ detailCount++;
92
+ }
93
+ editRegistry.delete(id);
94
+ appliedIds[appliedCount] = id;
95
+ appliedCount++;
96
+ } catch (error) {
97
+ errors[errorCount] = { id, error: formatError(errorMessage(error)) };
98
+ errorCount++;
99
+ }
100
+ }
101
+
102
+ const details: ApplyEditsDetails = {
103
+ status: statusFor(appliedCount, errorCount),
104
+ appliedIds: appliedIds.slice(0, appliedCount),
105
+ errors: errors.slice(0, errorCount),
106
+ editDetails: editDetails.slice(0, detailCount),
107
+ };
108
+ return { content: [{ type: "text", text: summarize(details) }], details };
109
+ },
110
+
111
+ renderCall(args, theme) {
112
+ return new Text(
113
+ theme.fg("toolTitle", theme.bold("apply_edits ")) + theme.fg("dim", args.ids.join(", ")),
114
+ 0,
115
+ 0,
116
+ );
117
+ },
118
+
119
+ renderResult(result, _options, theme) {
120
+ const details = result.details;
121
+ if (details === undefined) return new Text("(no apply_edits details)", 0, 0);
122
+ const summary = summarize(details);
123
+ return new Text(theme.fg(details.status === "error" ? "error" : "success", summary), 0, 0);
124
+ },
125
+ };
126
+ }
@@ -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";