@hicaru/pi-rlm 0.1.7 → 0.1.8

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/text/edits.ts CHANGED
@@ -1,3 +1,9 @@
1
+ import { access, mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import { constants } from "node:fs";
3
+ import { dirname, resolve } from "node:path";
4
+ import type { ProposedEdit } from "../sandbox/protocol.ts";
5
+ import { errorMessage, formatError } from "../util/errors.ts";
6
+
1
7
  export interface AnchorEdit {
2
8
  readonly oldText: string;
3
9
  readonly newText: string;
@@ -14,3 +20,145 @@ export function countOccurrences(haystack: string, needle: string): number {
14
20
  offset = match + needle.length;
15
21
  }
16
22
  }
23
+
24
+ /**
25
+ * Literal string replace of the first occurrence of `oldText` with `newText`.
26
+ * Splice-based so `$&` / `$$` / `$'` in newText are NOT treated as
27
+ * special replacement patterns (String.prototype.replace string-form hazard).
28
+ */
29
+ export function replaceOnceLiteral(content: string, oldText: string, newText: string): string {
30
+ const idx = content.indexOf(oldText);
31
+ if (idx < 0) return content;
32
+ return content.slice(0, idx) + newText + content.slice(idx + oldText.length);
33
+ }
34
+
35
+ export type PlanEditResult =
36
+ | {
37
+ readonly ok: true;
38
+ /** create = new file; replace = one-shot anchor swap; already-applied = idempotent skip */
39
+ readonly kind: "create" | "replace" | "already-applied";
40
+ readonly before: string;
41
+ readonly after: string;
42
+ }
43
+ | { readonly ok: false; readonly error: string };
44
+
45
+ async function pathExists(abs: string): Promise<boolean> {
46
+ try {
47
+ await access(abs, constants.F_OK);
48
+ return true;
49
+ } catch {
50
+ return false;
51
+ }
52
+ }
53
+
54
+ /**
55
+ * Validate and compute an edit without writing.
56
+ * Shared by headless apply and the native apply_edits tool (DRY).
57
+ *
58
+ * Idempotent retry: if a prior unit already landed the change
59
+ * (oldText absent + newText already present for replace; create with identical content),
60
+ * returns kind "already-applied" so a failed fanout can be retried without wedging.
61
+ *
62
+ * Create-file: refuses to clobber when the target exists with different content.
63
+ */
64
+ export async function planEdit(
65
+ cwd: string,
66
+ path: string,
67
+ oldText: string,
68
+ newText: string,
69
+ ): Promise<PlanEditResult> {
70
+ try {
71
+ const fullPath = resolve(cwd, path);
72
+ if (oldText.length === 0) {
73
+ if (await pathExists(fullPath)) {
74
+ const existing = await readFile(fullPath, "utf8");
75
+ if (existing === newText) {
76
+ return { ok: true, kind: "already-applied", before: existing, after: existing };
77
+ }
78
+ return {
79
+ ok: false,
80
+ error: formatError(
81
+ `${path}: file already exists with different content — refuse to clobber (create requires empty target or identical content)`,
82
+ ),
83
+ };
84
+ }
85
+ return { ok: true, kind: "create", before: "", after: newText };
86
+ }
87
+
88
+ const content = await readFile(fullPath, "utf8");
89
+ const occurrences = countOccurrences(content, oldText);
90
+ if (occurrences === 0) {
91
+ // Idempotent skip: prior apply already removed the anchor and left newText.
92
+ // Deletions (newText === "") always "include" empty string — treat them as
93
+ // retry-unsafe so a typo'd anchor fails instead of silently skipping.
94
+ if (newText.length > 0 && content.includes(newText)) {
95
+ return { ok: true, kind: "already-applied", before: content, after: content };
96
+ }
97
+ return { ok: false, error: formatError(`anchor occurs 0 times in ${path}`) };
98
+ }
99
+ if (occurrences !== 1) {
100
+ return { ok: false, error: formatError(`anchor occurs ${occurrences} times in ${path}`) };
101
+ }
102
+ const after = replaceOnceLiteral(content, oldText, newText);
103
+ return { ok: true, kind: "replace", before: content, after };
104
+ } catch (err: unknown) {
105
+ return { ok: false, error: formatError(`${path}: ${errorMessage(err)}`) };
106
+ }
107
+ }
108
+
109
+ export type ApplyOneEditResult =
110
+ | { readonly ok: true; readonly before: string; readonly after: string; readonly kind: "create" | "replace" | "already-applied" }
111
+ | { readonly ok: false; readonly error: string };
112
+
113
+ /**
114
+ * Apply a single anchor edit to the working tree (direct disk write).
115
+ * Used by the implement fanout and any headless apply path.
116
+ */
117
+ export async function applyOneEdit(
118
+ cwd: string,
119
+ path: string,
120
+ oldText: string,
121
+ newText: string,
122
+ ): Promise<ApplyOneEditResult> {
123
+ const planned = await planEdit(cwd, path, oldText, newText);
124
+ if (!planned.ok) return planned;
125
+ if (planned.kind === "already-applied") {
126
+ return { ok: true, before: planned.before, after: planned.after, kind: "already-applied" };
127
+ }
128
+ try {
129
+ const fullPath = resolve(cwd, path);
130
+ if (planned.kind === "create") {
131
+ await mkdir(dirname(fullPath), { recursive: true });
132
+ }
133
+ await writeFile(fullPath, planned.after, "utf8");
134
+ return { ok: true, before: planned.before, after: planned.after, kind: planned.kind };
135
+ } catch (err: unknown) {
136
+ return { ok: false, error: formatError(`${path}: ${errorMessage(err)}`) };
137
+ }
138
+ }
139
+
140
+ export type ApplyProposedEditsResult =
141
+ | { readonly ok: true; readonly applied: number }
142
+ | { readonly ok: false; readonly error: string; readonly applied: number };
143
+
144
+ /**
145
+ * Apply a series of proposed edits to the working tree (patch series, not a race).
146
+ * Shared by the implement fanout and any headless apply path.
147
+ * Safe to re-run: already-applied units succeed without re-writing.
148
+ */
149
+ export async function applyProposedEdits(
150
+ edits: readonly ProposedEdit[],
151
+ cwd: string,
152
+ ): Promise<ApplyProposedEditsResult> {
153
+ let applied = 0;
154
+ for (let i = 0; i < edits.length; i++) {
155
+ const edit = edits[i];
156
+ if (edit === undefined) continue;
157
+ const one = await applyOneEdit(cwd, edit.path, edit.oldText, edit.newText);
158
+ if (!one.ok) {
159
+ return { ok: false, error: one.error, applied };
160
+ }
161
+ applied++;
162
+ }
163
+ return { ok: true, applied };
164
+ }
@@ -1,10 +1,10 @@
1
- import { mkdir, readFile, writeFile } from "node:fs/promises";
2
- import { dirname, resolve } from "node:path";
3
1
  import { createEditToolDefinition, type AgentToolResult, type Theme, type ToolDefinition } from "@earendil-works/pi-coding-agent";
4
2
  import { Container, Text, type Component } from "@earendil-works/pi-tui";
5
3
  import { Type } from "typebox";
4
+ import { mkdir, writeFile } from "node:fs/promises";
5
+ import { dirname, resolve } from "node:path";
6
6
  import type { EditRegistry } from "../registry/edit-registry.ts";
7
- import { countOccurrences } from "../text/edits.ts";
7
+ import { planEdit } from "../text/edits.ts";
8
8
  import { errorMessage, formatError } from "../util/errors.ts";
9
9
 
10
10
  export const ApplyEditsToolParams = Object.freeze(Type.Object({
@@ -205,8 +205,10 @@ export function createApplyEditsTool(editRegistry: EditRegistry): ToolDefinition
205
205
  const fileStatsByPath = new Map<string, ApplyEditsFileStat>();
206
206
  let appliedCount = 0;
207
207
  let failedCount = 0;
208
-
208
+ // Native mode: plan/validate with shared planEdit, then write through the host
209
+ // edit tool so file-watcher / undo hooks still fire (direct write for create only).
209
210
  const editTool = createEditToolDefinition(ctx.cwd);
211
+
210
212
  for (let i = 0; i < params.ids.length; i++) {
211
213
  const id = params.ids[i];
212
214
  const edit = editRegistry.get(id);
@@ -217,41 +219,46 @@ export function createApplyEditsTool(editRegistry: EditRegistry): ToolDefinition
217
219
  continue;
218
220
  }
219
221
 
222
+ const planned = await planEdit(ctx.cwd, edit.path, edit.oldText, edit.newText);
223
+ if (!planned.ok) {
224
+ errors[failedCount] = { id, path: edit.path, error: planned.error };
225
+ mergeFileStat(fileStatsByPath, edit.path, "failed", { added: 0, removed: 0 }, { oldText: edit.oldText, newText: edit.newText });
226
+ failedCount++;
227
+ continue;
228
+ }
229
+
220
230
  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 });
231
+ if (planned.kind === "already-applied") {
232
+ mergeFileStat(fileStatsByPath, edit.path, "applied", { added: 0, removed: 0 }, { oldText: edit.oldText, newText: edit.newText });
226
233
  editRegistry.delete(id);
227
234
  appliedCount++;
228
235
  continue;
229
236
  }
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;
237
+ if (planned.kind === "create") {
238
+ const fullPath = resolve(ctx.cwd, edit.path);
239
+ await mkdir(dirname(fullPath), { recursive: true });
240
+ await writeFile(fullPath, planned.after, "utf8");
241
+ } else {
242
+ await editTool.execute(
243
+ toolCallId,
244
+ { path: edit.path, edits: [{ oldText: edit.oldText, newText: edit.newText }] },
245
+ signal,
246
+ undefined,
247
+ ctx,
248
+ );
238
249
  }
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,
250
+ mergeFileStat(
251
+ fileStatsByPath,
252
+ edit.path,
253
+ "applied",
254
+ diffStats(planned.before, planned.after),
255
+ { oldText: edit.oldText, newText: edit.newText },
247
256
  );
248
- mergeFileStat(fileStatsByPath, edit.path, "applied", diffStats(content, after), { oldText: edit.oldText, newText: edit.newText });
249
257
  editRegistry.delete(id);
250
258
  appliedCount++;
251
259
  } 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 });
260
+ errors[failedCount] = { id, path: edit.path, error: formatError(errorMessage(error)) };
261
+ mergeFileStat(fileStatsByPath, edit.path, "failed", { added: 0, removed: 0 }, { oldText: edit.oldText, newText: edit.newText });
255
262
  failedCount++;
256
263
  }
257
264
  }
@@ -18,6 +18,7 @@ import type { Model, Usage, Api } from "@earendil-works/pi-ai";
18
18
  import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
19
19
  import { modelRef, resolveModelId } from "../config/settings.ts";
20
20
  import { buildInteractiveHandlers } from "../bridge/interactive.ts";
21
+ import { buildLibraryHandler } from "../bridge/library.ts";
21
22
  import { createPiInteractiveDeps } from "../bridge/pi-interactive.ts";
22
23
  import { type ChatMsg, modelComplete } from "../bridge/model.ts";
23
24
  import { previewText } from "../text/preview.ts";
@@ -341,13 +342,19 @@ export interface ReplToolDeps {
341
342
  readonly signal?: AbortSignal;
342
343
  readonly onUsage?: (usage: Usage, role: "sub") => void;
343
344
  readonly ensureContext?: () => Promise<void>;
345
+ /** Register a reset hook for sandbox death/dispose (e.g. load_library slot counter). */
346
+ readonly registerDiscardHook?: (reset: () => void) => void;
344
347
  }
345
348
 
346
349
  export function createReplTool(deps: ReplToolDeps): ToolDefinition<typeof ReplToolParams, ReplDetails> {
347
350
  const { sandboxManager, workerModel, registry, editRegistry, config, signal, onUsage } = deps;
348
351
  const bridgeState = new NativeBridgeState();
349
352
 
350
- // Build handlers once llm/rlm use mutable refs, interactive is session-stable
353
+ // Late-bound cwdgetOrCreate installs handlers only at spawn; never rebuild the closure.
354
+ let sessionCwd = process.cwd();
355
+
356
+ // Build handlers once — llm/rlm/library use late-bound deps so the same closures stay correct
357
+ // across repl() calls; counter resets when the sandbox is discarded and re-spawned.
351
358
  const llmHandlers = bridgeState.buildLlmHandlers({
352
359
  workerModel,
353
360
  getWorkerModel: deps.getWorkerModel,
@@ -373,6 +380,17 @@ export function createReplTool(deps: ReplToolDeps): ToolDefinition<typeof ReplTo
373
380
  llmHandlers,
374
381
  });
375
382
 
383
+ const libraryBundle = config.libraryLoader
384
+ ? buildLibraryHandler({
385
+ getCwd: () => sessionCwd,
386
+ getEmitter: () => bridgeState.currentEmitter,
387
+ parentId: undefined,
388
+ signal,
389
+ startIndex: 1,
390
+ })
391
+ : undefined;
392
+ if (libraryBundle) deps.registerDiscardHook?.(libraryBundle.reset);
393
+
376
394
  return {
377
395
  name: "repl",
378
396
  label: "REPL",
@@ -382,7 +400,7 @@ export function createReplTool(deps: ReplToolDeps): ToolDefinition<typeof ReplTo
382
400
  "chunk `context` and delegate semantic work to llm_query / llm_query_batched / " +
383
401
  "llm_query_chunked / rlm_query — stdout returned to you is hard-capped at 4K chars, so " +
384
402
  "printing file bodies is useless. Variables, imports, and state persist across calls. " +
385
- "Also supports todo and ask_user_question inside the sandbox.",
403
+ "Also supports todo, ask_user_question, and load_library inside the sandbox.",
386
404
  parameters: ReplToolParams,
387
405
 
388
406
  async execute(_toolCallId, rawParams, _execSignal, onUpdate, ctx) {
@@ -448,12 +466,15 @@ export function createReplTool(deps: ReplToolDeps): ToolDefinition<typeof ReplTo
448
466
  parentId: undefined,
449
467
  });
450
468
 
469
+ sessionCwd = ctx.cwd ?? process.cwd();
470
+
451
471
  await deps.ensureContext?.();
452
472
  await sandboxManager.getOrCreate({
453
473
  ...llmHandlers,
454
474
  ...rlmHandlers,
455
475
  askUserQuestion: interactiveHandlers.askUserQuestion,
456
476
  todo: interactiveHandlers.todo,
477
+ ...(libraryBundle?.handlers ?? {}),
457
478
  });
458
479
 
459
480
  // Detect queue contention AFTER sandbox init (initPromise settled, isExecuting now accurate)
@@ -1,7 +1,6 @@
1
1
  /**
2
2
  * RLM tool — registers the RLM engine as a Pi tool with inline rendering.
3
3
  *
4
- * Modeled after rpiv-mono's subagent tool.
5
4
  * The tool's execute() wraps createEngine() with an RlmEmitter + RlmEventAggregator that feeds
6
5
  * onUpdate(partialResult) for progressive TUI re-rendering.
7
6
  */
@@ -16,11 +16,13 @@ const CHOICES = Object.freeze({
16
16
  maxErrors: Object.freeze(["3", "5", "10", "none"]),
17
17
  orchestrator: Object.freeze(["on", "off"]),
18
18
  pipeline: Object.freeze(["on", "off"]),
19
+ maxBackwardJumps: Object.freeze(["0", "1", "2", "3"]),
19
20
  compaction: Object.freeze(["on", "off"]),
20
21
  rootSamplingMaxTokens: Object.freeze(["4096", "8192", "16384", "32768"]),
21
22
  sandboxInitTimeoutMs: Object.freeze(["10000", "30000", "60000", "120000"]),
22
23
  askUserQuestion: Object.freeze(["on", "off"]),
23
24
  todo: Object.freeze(["on", "off"]),
25
+ libraryLoader: Object.freeze(["on", "off"]),
24
26
  });
25
27
 
26
28
  function item(id: string, label: string, currentValue: string, values: readonly string[], description: string): SettingItem {
@@ -39,12 +41,15 @@ export async function showConfigPanel(ctx: ExtensionContext, config: RlmConfig):
39
41
  item("maxTokens", "Token ceiling", config.maxTokens != null ? String(config.maxTokens) : "none", CHOICES.maxTokens, "Total input+output token cap for the whole recursive tree."),
40
42
  item("maxErrors", "Max consecutive errors", config.maxErrors != null ? String(config.maxErrors) : "none", CHOICES.maxErrors, "Stop after this many consecutive failing turns; none disables the guard."),
41
43
  item("orchestrator", "Orchestrator addendum", config.orchestrator ? "on" : "off", CHOICES.orchestrator, "Append extra divide-and-conquer guidance to the root model system prompt."),
42
- item("pipeline", "Phase pipeline", config.pipeline ? "on" : "off", CHOICES.pipeline, "Enable advance_phase plus phase-stall reminders at root depth."),
44
+ item("pipeline", "Phase pipeline", config.pipeline ? "on" : "off", CHOICES.pipeline, "Enable artifact-gated phases: clarify→research→blueprint→implement fanout→validate (clarify needs Ask user on)."),
45
+ item("maxBackwardJumps", "Max validate→blueprint loops", String(config.maxBackwardJumps), CHOICES.maxBackwardJumps, "Bounded corrective re-entries when validation reports blockers_count > 0."),
43
46
  item("compaction", "Trajectory compaction", config.compaction ? "on" : "off", CHOICES.compaction, "Summarize old turns when history approaches the model context window."),
44
47
  item("rootSamplingMaxTokens", "Root model output cap (tok)", String(config.rootSampling?.maxTokens ?? 16384), CHOICES.rootSamplingMaxTokens, "Max output tokens per root-model turn. Lower values keep each turn lean."),
45
48
  item("sandboxInitTimeoutMs", "Sandbox init timeout", String(config.sandboxInitTimeoutMs), CHOICES.sandboxInitTimeoutMs, "How long to wait for the Python worker to start."),
46
49
  item("askUserQuestion", "[Interactive] Ask user", config.askUserQuestion ? "on" : "off", CHOICES.askUserQuestion, "Allow root REPL code to present structured ask_user_question dialogs."),
47
50
  item("todo", "[Interactive] Todo", config.todo ? "on" : "off", CHOICES.todo, "Allow REPL code to manage a visible todo task list."),
51
+ item("libraryLoader", "Library loader", config.libraryLoader ? "on" : "off", CHOICES.libraryLoader,
52
+ "Allow load_library() to pull an external dir, file, or git repo into a new context_N slot."),
48
53
  item("__save__", "Save & close", "↵", ["↵"], "Save these settings and close (Esc also saves)."),
49
54
  ];
50
55
 
@@ -86,10 +91,12 @@ function applySetting(config: RlmConfig, id: string, value: string): void {
86
91
  case "maxErrors": config.maxErrors = value === "none" ? undefined : Number(value); break;
87
92
  case "orchestrator": config.orchestrator = value === "on"; break;
88
93
  case "pipeline": config.pipeline = value === "on"; break;
94
+ case "maxBackwardJumps": config.maxBackwardJumps = Number(value); break;
89
95
  case "compaction": config.compaction = value === "on"; break;
90
96
  case "rootSamplingMaxTokens": config.rootSampling = Object.freeze({ ...config.rootSampling, maxTokens: Number(value) }); break;
91
97
  case "sandboxInitTimeoutMs": config.sandboxInitTimeoutMs = Number(value); break;
92
98
  case "askUserQuestion": config.askUserQuestion = value === "on"; break;
93
99
  case "todo": config.todo = value === "on"; break;
100
+ case "libraryLoader": config.libraryLoader = value === "on"; break;
94
101
  }
95
102
  }