@khanhicetea/pi-better-tool 0.2.1 → 0.2.2
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/README.md +69 -47
- package/package.json +1 -1
- package/src/apply.ts +22 -12
- package/src/diagnostics.ts +122 -19
- package/src/index.ts +6 -1
- package/src/read-evidence.ts +56 -32
- package/src/similarity.ts +140 -63
- package/src/text.ts +36 -7
- package/src/tool.ts +176 -78
package/src/tool.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The "better edit" tool: a drop-in override of pi's built-in edit tool.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* Normal matching closely tracks the built-in tool and keeps its success
|
|
5
|
+
* result shape so the built-in diff renderer is inherited. Conservative
|
|
6
|
+
* schema, encoding, read-evidence, and commit-boundary checks are intentional
|
|
7
|
+
* safety differences. Instead of a bare "Could
|
|
7
8
|
* not find edits[1]" error that forces the model to re-read the file and
|
|
8
9
|
* guess at larger context, failures include recovery context:
|
|
9
10
|
*
|
|
@@ -22,8 +23,14 @@ import {
|
|
|
22
23
|
type ExtensionAPI,
|
|
23
24
|
type ExtensionContext,
|
|
24
25
|
} from "@earendil-works/pi-coding-agent";
|
|
25
|
-
import { constants } from "node:fs";
|
|
26
|
-
import {
|
|
26
|
+
import { constants, type Stats } from "node:fs";
|
|
27
|
+
import {
|
|
28
|
+
access as fsAccess,
|
|
29
|
+
readFile as fsReadFile,
|
|
30
|
+
realpath as fsRealpath,
|
|
31
|
+
stat as fsStat,
|
|
32
|
+
writeFile as fsWriteFile,
|
|
33
|
+
} from "node:fs/promises";
|
|
27
34
|
import { homedir } from "node:os";
|
|
28
35
|
import { isAbsolute, join, resolve } from "node:path";
|
|
29
36
|
import { fileURLToPath } from "node:url";
|
|
@@ -54,8 +61,10 @@ const replaceEditSchema = Type.Object({
|
|
|
54
61
|
});
|
|
55
62
|
|
|
56
63
|
export const betterEditSchema = Type.Object({
|
|
57
|
-
path: Type.String({ description: "Path to the file to edit (relative or absolute)" }),
|
|
64
|
+
path: Type.String({ minLength: 1, description: "Path to the file to edit (relative or absolute)" }),
|
|
58
65
|
edits: Type.Array(replaceEditSchema, {
|
|
66
|
+
minItems: 1,
|
|
67
|
+
maxItems: 100,
|
|
59
68
|
description:
|
|
60
69
|
"One or more targeted replacements. Each edit is matched against the original file, not incrementally. Do not include overlapping or nested edits. If two changes touch the same block or nearby lines, merge them into one edit instead.",
|
|
61
70
|
}),
|
|
@@ -112,28 +121,26 @@ export function prepareEditArguments(input: unknown): unknown {
|
|
|
112
121
|
[path: string]: unknown;
|
|
113
122
|
};
|
|
114
123
|
|
|
115
|
-
|
|
124
|
+
let preparedEdits = args.edits;
|
|
125
|
+
if (typeof preparedEdits === "string") {
|
|
116
126
|
try {
|
|
117
|
-
const parsed: unknown = JSON.parse(
|
|
118
|
-
if (Array.isArray(parsed))
|
|
119
|
-
|
|
120
|
-
} else if (isSingleEditInput(parsed)) {
|
|
121
|
-
args.edits = [parsed];
|
|
122
|
-
}
|
|
127
|
+
const parsed: unknown = JSON.parse(preparedEdits);
|
|
128
|
+
if (Array.isArray(parsed)) preparedEdits = parsed;
|
|
129
|
+
else if (isSingleEditInput(parsed)) preparedEdits = [parsed];
|
|
123
130
|
} catch {
|
|
124
131
|
// leave as-is; schema validation will report it
|
|
125
132
|
}
|
|
126
|
-
} else if (isSingleEditInput(
|
|
127
|
-
|
|
133
|
+
} else if (isSingleEditInput(preparedEdits)) {
|
|
134
|
+
preparedEdits = [preparedEdits];
|
|
128
135
|
}
|
|
129
136
|
|
|
130
137
|
if (typeof args.oldText === "string" && typeof args.newText === "string") {
|
|
131
|
-
const edits = Array.isArray(
|
|
138
|
+
const edits = Array.isArray(preparedEdits) ? [...(preparedEdits as EditOp[])] : [];
|
|
132
139
|
edits.push({ oldText: args.oldText, newText: args.newText });
|
|
133
140
|
const { oldText: _oldText, newText: _newText, ...rest } = args;
|
|
134
141
|
return { ...rest, edits };
|
|
135
142
|
}
|
|
136
|
-
return args;
|
|
143
|
+
return preparedEdits === args.edits ? args : { ...args, edits: preparedEdits };
|
|
137
144
|
}
|
|
138
145
|
|
|
139
146
|
export interface BetterEditSuccess {
|
|
@@ -141,39 +148,108 @@ export interface BetterEditSuccess {
|
|
|
141
148
|
details: EditToolDetails;
|
|
142
149
|
}
|
|
143
150
|
|
|
151
|
+
export interface BetterEditOperations {
|
|
152
|
+
access(path: string): Promise<void>;
|
|
153
|
+
readFile(path: string): Promise<Buffer>;
|
|
154
|
+
realpath(path: string): Promise<string>;
|
|
155
|
+
stat(path: string): Promise<Stats>;
|
|
156
|
+
writeFile(path: string, content: string): Promise<void>;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const defaultOperations: BetterEditOperations = {
|
|
160
|
+
access: (path) => fsAccess(path, constants.R_OK | constants.W_OK),
|
|
161
|
+
readFile: (path) => fsReadFile(path),
|
|
162
|
+
realpath: (path) => fsRealpath(path),
|
|
163
|
+
stat: (path) => fsStat(path),
|
|
164
|
+
writeFile: (path, content) => fsWriteFile(path, content, "utf-8"),
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
export interface BetterEditExecutionOptions {
|
|
168
|
+
operations?: BetterEditOperations;
|
|
169
|
+
/** Test/integration seam; runs before the write commit boundary. */
|
|
170
|
+
formatSuccess?: typeof formatAutoDisambiguationSuccess;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function describeFsError(error: unknown): string {
|
|
174
|
+
return error instanceof Error && "code" in error
|
|
175
|
+
? `Error code: ${(error as NodeJS.ErrnoException).code}`
|
|
176
|
+
: error instanceof Error
|
|
177
|
+
? error.message
|
|
178
|
+
: String(error);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function sameFileIdentity(a: Stats, b: Stats): boolean {
|
|
182
|
+
return a.dev === b.dev && a.ino === b.ino;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function decodeEditableUtf8(buffer: Buffer, path: string): string {
|
|
186
|
+
try {
|
|
187
|
+
new TextDecoder("utf-8", { fatal: true }).decode(buffer);
|
|
188
|
+
} catch {
|
|
189
|
+
throw new Error(`Could not edit file: ${path}. The file is not valid UTF-8; unsupported encodings are never transcoded.`);
|
|
190
|
+
}
|
|
191
|
+
if (buffer.includes(0)) {
|
|
192
|
+
throw new Error(`Could not edit file: ${path}. The file contains NUL bytes and is treated as binary or an unsupported text encoding.`);
|
|
193
|
+
}
|
|
194
|
+
return buffer.toString("utf-8");
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function countLiteralOccurrences(haystack: string, needle: string): number {
|
|
198
|
+
if (!needle) return 0;
|
|
199
|
+
let count = 0;
|
|
200
|
+
let index = haystack.indexOf(needle);
|
|
201
|
+
while (index !== -1) {
|
|
202
|
+
count++;
|
|
203
|
+
index = haystack.indexOf(needle, index + needle.length);
|
|
204
|
+
}
|
|
205
|
+
return count;
|
|
206
|
+
}
|
|
207
|
+
|
|
144
208
|
export async function executeBetterEdit(
|
|
145
209
|
input: BetterEditInput,
|
|
146
210
|
signal: AbortSignal | undefined,
|
|
147
211
|
ctx: Pick<ExtensionContext, "cwd"> & Partial<Pick<ExtensionContext, "sessionManager">>,
|
|
212
|
+
options: BetterEditExecutionOptions = {},
|
|
148
213
|
): Promise<BetterEditSuccess> {
|
|
149
214
|
const edits: EditOp[] = input.edits ?? [];
|
|
150
|
-
if (!Array.isArray(edits) || edits.length === 0) {
|
|
151
|
-
throw new Error("Edit tool input is invalid. edits must contain
|
|
215
|
+
if (!input.path || !Array.isArray(edits) || edits.length === 0 || edits.length > 100) {
|
|
216
|
+
throw new Error("Edit tool input is invalid. path must be non-empty and edits must contain 1-100 replacements.");
|
|
152
217
|
}
|
|
153
218
|
const absolutePath = resolveToCwd(input.path, ctx.cwd);
|
|
219
|
+
const ops = options.operations ?? defaultOperations;
|
|
220
|
+
const formatSuccess = options.formatSuccess ?? formatAutoDisambiguationSuccess;
|
|
154
221
|
|
|
155
222
|
return withFileMutationQueue(absolutePath, async () => {
|
|
156
|
-
//
|
|
157
|
-
//
|
|
223
|
+
// Never reject from an abort listener: the queue must remain held until
|
|
224
|
+
// in-flight filesystem work settles.
|
|
158
225
|
const throwIfAborted = () => {
|
|
159
|
-
if (signal?.aborted) throw new Error("Operation aborted");
|
|
226
|
+
if (signal?.aborted) throw new Error("Operation aborted before the edit was committed; no write was started.");
|
|
160
227
|
};
|
|
161
228
|
throwIfAborted();
|
|
162
229
|
|
|
163
230
|
try {
|
|
164
|
-
await
|
|
231
|
+
await ops.access(absolutePath);
|
|
165
232
|
} catch (error) {
|
|
166
233
|
throwIfAborted();
|
|
167
|
-
|
|
168
|
-
error instanceof Error && "code" in error
|
|
169
|
-
? `Error code: ${(error as NodeJS.ErrnoException).code}`
|
|
170
|
-
: String(error);
|
|
171
|
-
throw new Error(`Could not edit file: ${input.path}. ${errorMessage}.`);
|
|
234
|
+
throw new Error(`Could not access file for editing: ${input.path}. ${describeFsError(error)}. No write was started.`);
|
|
172
235
|
}
|
|
173
236
|
throwIfAborted();
|
|
174
237
|
|
|
175
|
-
|
|
176
|
-
|
|
238
|
+
let buffer: Buffer;
|
|
239
|
+
let initialStat: Stats;
|
|
240
|
+
try {
|
|
241
|
+
const beforeReadStat = await ops.stat(absolutePath);
|
|
242
|
+
buffer = await ops.readFile(absolutePath);
|
|
243
|
+
initialStat = await ops.stat(absolutePath);
|
|
244
|
+
if (!sameFileIdentity(beforeReadStat, initialStat)) throw new Error("target-changed-during-read");
|
|
245
|
+
} catch (error) {
|
|
246
|
+
throwIfAborted();
|
|
247
|
+
if (error instanceof Error && error.message === "target-changed-during-read") {
|
|
248
|
+
throw new Error(`Could not edit file: ${input.path}. The target identity changed while it was being read; no write was started. Re-read and retry.`);
|
|
249
|
+
}
|
|
250
|
+
throw new Error(`Could not read file for editing: ${input.path}. ${describeFsError(error)}. No write was started.`);
|
|
251
|
+
}
|
|
252
|
+
const rawContent = decodeEditableUtf8(buffer, input.path);
|
|
177
253
|
throwIfAborted();
|
|
178
254
|
|
|
179
255
|
const { bom, text: content } = splitBom(rawContent);
|
|
@@ -193,17 +269,23 @@ export async function executeBetterEdit(
|
|
|
193
269
|
const editIndex = result.failure.editIndex;
|
|
194
270
|
if (selections.has(editIndex)) break;
|
|
195
271
|
const edit = normalizedEdits[editIndex];
|
|
196
|
-
const
|
|
272
|
+
const exactCount = countLiteralOccurrences(normalizedContent, edit.oldText);
|
|
273
|
+
const exactOffsets = findAllOccurrences(normalizedContent, edit.oldText, 256);
|
|
197
274
|
// Fuzzy-equivalent aliases cannot be mapped safely to original offsets.
|
|
198
|
-
if (
|
|
275
|
+
if (exactCount !== result.failure.occurrenceCount) break;
|
|
199
276
|
|
|
200
277
|
if (readEvidence === undefined) {
|
|
201
|
-
|
|
278
|
+
let canonicalTarget: string;
|
|
279
|
+
try {
|
|
280
|
+
canonicalTarget = await ops.realpath(absolutePath);
|
|
281
|
+
} catch (error) {
|
|
282
|
+
throw new Error(`Could not resolve edit target: ${input.path}. ${describeFsError(error)}. No write was started.`);
|
|
283
|
+
}
|
|
202
284
|
readEvidence = await findLatestReadEvidence(
|
|
203
285
|
ctx.sessionManager,
|
|
204
286
|
canonicalTarget,
|
|
205
287
|
normalizedContent,
|
|
206
|
-
async (readPath) =>
|
|
288
|
+
async (readPath) => ops.realpath(resolveToCwd(readPath, ctx.cwd)),
|
|
207
289
|
);
|
|
208
290
|
}
|
|
209
291
|
if (!readEvidence) break;
|
|
@@ -231,75 +313,91 @@ export async function executeBetterEdit(
|
|
|
231
313
|
}
|
|
232
314
|
|
|
233
315
|
if (!result.ok) {
|
|
234
|
-
throw new Error(
|
|
235
|
-
formatEditFailure({
|
|
236
|
-
path: input.path,
|
|
237
|
-
normalizedContent,
|
|
238
|
-
edits,
|
|
239
|
-
failure: result.failure,
|
|
240
|
-
}),
|
|
241
|
-
);
|
|
316
|
+
throw new Error(formatEditFailure({ path: input.path, normalizedContent, edits, failure: result.failure }));
|
|
242
317
|
}
|
|
243
318
|
|
|
244
319
|
const { baseContent, newContent } = applyAnalysis(normalizedContent, result.analysis);
|
|
245
320
|
if (baseContent === newContent) {
|
|
246
|
-
throw new Error(
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
}),
|
|
253
|
-
);
|
|
321
|
+
throw new Error(formatEditFailure({
|
|
322
|
+
path: input.path,
|
|
323
|
+
normalizedContent,
|
|
324
|
+
edits,
|
|
325
|
+
failure: { kind: "no-change" },
|
|
326
|
+
}));
|
|
254
327
|
}
|
|
255
|
-
throwIfAborted();
|
|
256
328
|
|
|
329
|
+
// Prepare every required success artifact before crossing the write
|
|
330
|
+
// boundary. Optional diagnostics are bounded and cannot fail after commit.
|
|
257
331
|
const finalContent = bom + restoreLineEndings(newContent, originalEnding);
|
|
258
|
-
await fsWriteFile(absolutePath, finalContent, "utf-8");
|
|
259
|
-
throwIfAborted();
|
|
260
|
-
|
|
261
332
|
const diffResult = generateDiffString(baseContent, newContent);
|
|
262
|
-
const
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
),
|
|
272
|
-
},
|
|
273
|
-
],
|
|
333
|
+
const success: BetterEditSuccess = {
|
|
334
|
+
content: [{
|
|
335
|
+
type: "text",
|
|
336
|
+
text: formatSuccess(
|
|
337
|
+
`Successfully replaced ${edits.length} block(s) in ${input.path}.`,
|
|
338
|
+
newContent,
|
|
339
|
+
resolutions,
|
|
340
|
+
),
|
|
341
|
+
}],
|
|
274
342
|
details: {
|
|
275
343
|
diff: diffResult.diff,
|
|
276
|
-
patch,
|
|
344
|
+
patch: generateUnifiedPatch(input.path, baseContent, newContent),
|
|
277
345
|
firstChangedLine: diffResult.firstChangedLine,
|
|
278
|
-
}
|
|
346
|
+
},
|
|
279
347
|
};
|
|
348
|
+
throwIfAborted();
|
|
349
|
+
|
|
350
|
+
// Best-effort external modification detection. This is not a lock or a
|
|
351
|
+
// race-free compare-and-swap; another process can still change the target
|
|
352
|
+
// after this check and before/during the write.
|
|
353
|
+
try {
|
|
354
|
+
const [currentBuffer, currentStat] = await Promise.all([
|
|
355
|
+
ops.readFile(absolutePath),
|
|
356
|
+
ops.stat(absolutePath),
|
|
357
|
+
]);
|
|
358
|
+
if (!sameFileIdentity(initialStat, currentStat) || !buffer.equals(currentBuffer)) {
|
|
359
|
+
throw new Error("target-changed");
|
|
360
|
+
}
|
|
361
|
+
} catch (error) {
|
|
362
|
+
throwIfAborted();
|
|
363
|
+
if (error instanceof Error && error.message === "target-changed") {
|
|
364
|
+
throw new Error(`Could not edit file: ${input.path}. The target changed after it was read; no write was started. Re-read and retry.`);
|
|
365
|
+
}
|
|
366
|
+
throw new Error(`Could not revalidate file before writing: ${input.path}. ${describeFsError(error)}. No write was started.`);
|
|
367
|
+
}
|
|
368
|
+
throwIfAborted();
|
|
369
|
+
|
|
370
|
+
try {
|
|
371
|
+
await ops.writeFile(absolutePath, finalContent);
|
|
372
|
+
} catch (error) {
|
|
373
|
+
throw new Error(`Could not complete write to ${input.path}. ${describeFsError(error)}. The file may be unchanged, partially written, or fully written; inspect it before retrying.`);
|
|
374
|
+
}
|
|
375
|
+
// A resolved write is the commit boundary. Ignore cancellation that arrived
|
|
376
|
+
// during it and report the committed result; no fallible formatting remains.
|
|
377
|
+
return success;
|
|
280
378
|
});
|
|
281
379
|
}
|
|
282
380
|
|
|
283
|
-
export function registerBetterEditTool(pi: ExtensionAPI): void {
|
|
381
|
+
export function registerBetterEditTool(pi: ExtensionAPI, options: BetterEditExecutionOptions = {}): void {
|
|
284
382
|
pi.registerTool({
|
|
285
383
|
name: "edit",
|
|
286
384
|
label: "edit",
|
|
287
385
|
description:
|
|
288
|
-
"Edit a single file using exact text replacement. Every edits[].oldText should match a unique, non-overlapping region of the original file.
|
|
386
|
+
"Edit a single local file using exact text replacement. Every edits[].oldText should match a unique, non-overlapping region of the original file. Repeated literal text is selected only when exactly one tracked occurrence is contained in the latest verified stored-context read. Failures include bounded recovery context; low-confidence, non-unique, omitted, or stale candidates require a read before retrying.",
|
|
289
387
|
promptSnippet:
|
|
290
|
-
"Make precise file edits with exact text replacement; failures return recovery context
|
|
388
|
+
"Make precise local file edits with exact text replacement; failures return bounded recovery context",
|
|
291
389
|
promptGuidelines: [
|
|
292
|
-
"Use edit for precise changes (edits[].oldText must match exactly)",
|
|
293
|
-
"When changing multiple separate locations in one file, use one edit call with multiple entries in edits[] instead of multiple edit calls",
|
|
294
|
-
"
|
|
295
|
-
"Keep edits[].oldText as small as possible while still being unique in the file
|
|
296
|
-
"When edit safely auto-disambiguates repeated text from the latest verified read, its success message lists
|
|
297
|
-
"When
|
|
390
|
+
"Use edit for precise changes (edits[].oldText must match exactly).",
|
|
391
|
+
"When changing multiple separate locations in one file, use one edit call with multiple entries in edits[] instead of multiple edit calls.",
|
|
392
|
+
"In edit, each edits[].oldText is matched against the original file, not after earlier edits are applied. Do not emit overlapping or nested edits; merge nearby changes into one edit.",
|
|
393
|
+
"Keep edit edits[].oldText as small as possible while still being unique in the file; do not pad with large unchanged regions.",
|
|
394
|
+
"When edit safely auto-disambiguates repeated text from the latest verified stored-context read, its success message lists bounded remaining candidates for an optional follow-up edit.",
|
|
395
|
+
"When edit fails, reuse only a fenced snippet explicitly marked retryable. If edit reports low confidence, competing candidates, omitted output, stale evidence, or a write that may have modified the file, read the referenced file/range before retrying.",
|
|
298
396
|
],
|
|
299
397
|
parameters: betterEditSchema,
|
|
300
398
|
prepareArguments: (args: unknown): BetterEditInput => prepareEditArguments(args) as BetterEditInput,
|
|
301
399
|
async execute(_toolCallId, input, signal, _onUpdate, ctx) {
|
|
302
|
-
return executeBetterEdit(input, signal, ctx);
|
|
400
|
+
return executeBetterEdit(input, signal, ctx, options);
|
|
303
401
|
},
|
|
304
402
|
});
|
|
305
403
|
}
|