@hicaru/pi-rlm 0.1.6 → 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.6",
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",
@@ -262,7 +262,7 @@ 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 |",
@@ -273,7 +273,7 @@ function nativeReplGlossary(): string {
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 apply the returned IDs with `apply_edits({ ids })`. 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,
@@ -1,9 +1,8 @@
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";
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
5
  import { Type } from "typebox";
6
- import type { EditToolDetails } from "@earendil-works/pi-coding-agent";
7
6
  import type { EditRegistry } from "../registry/edit-registry.ts";
8
7
  import { countOccurrences } from "../text/edits.ts";
9
8
  import { errorMessage, formatError } from "../util/errors.ts";
@@ -16,25 +15,114 @@ export const ApplyEditsToolParams = Object.freeze(Type.Object({
16
15
 
17
16
  export interface ApplyEditsFailure {
18
17
  readonly id: string;
18
+ readonly path: string;
19
19
  readonly error: string;
20
20
  }
21
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
+
22
35
  export interface ApplyEditsDetails {
23
36
  readonly status: "done" | "partial" | "error";
24
- readonly appliedIds: readonly string[];
37
+ readonly appliedCount: number;
38
+ readonly failedCount: number;
25
39
  readonly errors: readonly ApplyEditsFailure[];
26
- readonly editDetails: readonly EditToolDetails[];
40
+ readonly fileStats: readonly ApplyEditsFileStat[];
41
+ }
42
+
43
+ export interface LineStats {
44
+ readonly added: number;
45
+ readonly removed: number;
27
46
  }
28
47
 
29
- function statusFor(appliedCount: number, errorCount: number): ApplyEditsDetails["status"] {
30
- if (errorCount === 0) return "done";
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";
31
63
  return appliedCount > 0 ? "partial" : "error";
32
64
  }
33
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
+
34
123
  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`;
124
+ const stats = aggregateLineStats(details.fileStats);
125
+ const head = `${summarizeFiles(details)} (+${stats.added} -${stats.removed} lines)`;
38
126
  if (details.errors.length === 0) return `${head}.`;
39
127
  const rows = new Array<string>(details.errors.length);
40
128
  for (let i = 0; i < details.errors.length; i++) {
@@ -44,6 +132,67 @@ function summarize(details: ApplyEditsDetails): string {
44
132
  return `${head}.\n${rows.join("\n")}`;
45
133
  }
46
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
+
47
196
  export function createApplyEditsTool(editRegistry: EditRegistry): ToolDefinition<typeof ApplyEditsToolParams, ApplyEditsDetails> {
48
197
  return {
49
198
  name: "apply_edits",
@@ -52,75 +201,88 @@ export function createApplyEditsTool(editRegistry: EditRegistry): ToolDefinition
52
201
  parameters: ApplyEditsToolParams,
53
202
 
54
203
  async execute(toolCallId, params, signal, _onUpdate, ctx): Promise<AgentToolResult<ApplyEditsDetails>> {
55
- const appliedIds = new Array<string>(params.ids.length);
56
204
  const errors = new Array<ApplyEditsFailure>(params.ids.length);
57
- const editDetails = new Array<EditToolDetails>(params.ids.length);
205
+ const fileStatsByPath = new Map<string, ApplyEditsFileStat>();
58
206
  let appliedCount = 0;
59
- let errorCount = 0;
60
- let detailCount = 0;
207
+ let failedCount = 0;
61
208
 
62
209
  const editTool = createEditToolDefinition(ctx.cwd);
63
210
  for (let i = 0; i < params.ids.length; i++) {
64
211
  const id = params.ids[i];
65
212
  const edit = editRegistry.get(id);
66
213
  if (edit === undefined) {
67
- errors[errorCount] = { id, error: formatError("unknown edit id") };
68
- errorCount++;
214
+ errors[failedCount] = { id, path: id, error: formatError("unknown edit id") };
215
+ mergeFileStat(fileStatsByPath, id, "failed", { added: 0, removed: 0 });
216
+ failedCount++;
69
217
  continue;
70
218
  }
71
219
 
72
220
  try {
73
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
+
74
231
  const content = await readFile(fullPath, "utf8");
75
232
  const occurrences = countOccurrences(content, edit.oldText);
76
233
  if (occurrences !== 1) {
77
- errors[errorCount] = { id, error: formatError(`anchor occurs ${occurrences} times in ${edit.path}`) };
78
- errorCount++;
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++;
79
237
  continue;
80
238
  }
81
239
 
82
- const result = await editTool.execute(
240
+ const after = content.replace(edit.oldText, edit.newText);
241
+ await editTool.execute(
83
242
  toolCallId,
84
243
  { path: edit.path, edits: [{ oldText: edit.oldText, newText: edit.newText }] },
85
244
  signal,
86
245
  undefined,
87
246
  ctx,
88
247
  );
89
- if (result.details !== undefined) {
90
- editDetails[detailCount] = result.details;
91
- detailCount++;
92
- }
248
+ mergeFileStat(fileStatsByPath, edit.path, "applied", diffStats(content, after), { oldText: edit.oldText, newText: edit.newText });
93
249
  editRegistry.delete(id);
94
- appliedIds[appliedCount] = id;
95
250
  appliedCount++;
96
- } catch (error) {
97
- errors[errorCount] = { id, error: formatError(errorMessage(error)) };
98
- errorCount++;
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++;
99
256
  }
100
257
  }
101
258
 
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
- };
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
+ });
108
273
  return { content: [{ type: "text", text: summarize(details) }], details };
109
274
  },
110
275
 
111
276
  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
- );
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);
117
280
  },
118
281
 
119
- renderResult(result, _options, theme) {
282
+ renderResult(result, options, theme): Component {
120
283
  const details = result.details;
121
284
  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);
285
+ return options.expanded ? renderExpanded(details, theme) : renderCollapsed(details, theme);
124
286
  },
125
287
  };
126
288
  }