@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 +1 -1
- package/src/prompts/system.ts +2 -2
- package/src/tool/apply-edits-tool.ts +205 -43
package/package.json
CHANGED
package/src/prompts/system.ts
CHANGED
|
@@ -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` |
|
|
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
|
|
37
|
+
readonly appliedCount: number;
|
|
38
|
+
readonly failedCount: number;
|
|
25
39
|
readonly errors: readonly ApplyEditsFailure[];
|
|
26
|
-
readonly
|
|
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
|
|
30
|
-
|
|
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
|
|
36
|
-
|
|
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
|
|
205
|
+
const fileStatsByPath = new Map<string, ApplyEditsFileStat>();
|
|
58
206
|
let appliedCount = 0;
|
|
59
|
-
let
|
|
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[
|
|
68
|
-
|
|
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[
|
|
78
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
98
|
-
|
|
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
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
}
|