@khanhicetea/pi-better-tool 0.1.0 → 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/src/tool.ts CHANGED
@@ -1,14 +1,16 @@
1
1
  /**
2
2
  * The "better edit" tool: a drop-in override of pi's built-in edit tool.
3
3
  *
4
- * Happy-path behavior is identical to the built-in tool (same schema, same
5
- * matching semantics, same result shapes so the built-in diff renderer is
6
- * inherited). The difference is failure behavior: instead of a bare "Could
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
  *
10
- * - ambiguous oldText → occurrence line numbers + the minimal prefix/suffix
11
- * context that disambiguates each occurrence, as copy-paste-ready snippets
11
+ * - ambiguous literal oldText → if the latest verified read of the file shows
12
+ * exactly one occurrence, select it and report up to four retryable remaining
13
+ * candidates; otherwise return occurrence line numbers + minimal context
12
14
  * - not-found oldText → closest matching region with a per-line comparison
13
15
  * and the exact file bytes to retry with
14
16
  */
@@ -21,14 +23,34 @@ import {
21
23
  type ExtensionAPI,
22
24
  type ExtensionContext,
23
25
  } from "@earendil-works/pi-coding-agent";
24
- import { constants } from "node:fs";
25
- import { access as fsAccess, readFile as fsReadFile, writeFile as fsWriteFile } from "node:fs/promises";
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";
26
34
  import { homedir } from "node:os";
27
- import { resolve } from "node:path";
35
+ import { isAbsolute, join, resolve } from "node:path";
36
+ import { fileURLToPath } from "node:url";
28
37
  import { type Static, Type } from "typebox";
29
- import { analyzeEdits, applyAnalysis, type EditOp } from "./apply.ts";
30
- import { formatEditFailure } from "./diagnostics.ts";
31
- import { detectLineEnding, normalizeToLF, restoreLineEndings, splitBom } from "./text.ts";
38
+ import { analyzeEdits, applyAnalysis, fuzzyFindText, normalizeEdits, type EditOp } from "./apply.ts";
39
+ import {
40
+ formatAutoDisambiguationSuccess,
41
+ formatEditFailure,
42
+ type AutoDisambiguation,
43
+ } from "./diagnostics.ts";
44
+ import { findLatestReadEvidence } from "./read-evidence.ts";
45
+ import {
46
+ detectLineEnding,
47
+ findAllOccurrences,
48
+ getLineSpans,
49
+ lineAt,
50
+ normalizeToLF,
51
+ restoreLineEndings,
52
+ splitBom,
53
+ } from "./text.ts";
32
54
 
33
55
  const replaceEditSchema = Type.Object({
34
56
  oldText: Type.String({
@@ -39,8 +61,10 @@ const replaceEditSchema = Type.Object({
39
61
  });
40
62
 
41
63
  export const betterEditSchema = Type.Object({
42
- 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)" }),
43
65
  edits: Type.Array(replaceEditSchema, {
66
+ minItems: 1,
67
+ maxItems: 100,
44
68
  description:
45
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.",
46
70
  }),
@@ -48,14 +72,29 @@ export const betterEditSchema = Type.Object({
48
72
 
49
73
  export type BetterEditInput = Static<typeof betterEditSchema>;
50
74
 
51
- function resolveToCwd(filePath: string, cwd: string): string {
52
- let path = filePath;
53
- if (path === "~") {
54
- path = homedir();
55
- } else if (path.startsWith("~/")) {
56
- path = resolve(homedir(), path.slice(2));
75
+ const UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g;
76
+
77
+ /** Match pi's built-in path normalization for tool arguments. */
78
+ function normalizeToolPath(input: string): string {
79
+ let path = input.replace(UNICODE_SPACES, " ");
80
+ if (path.startsWith("@")) path = path.slice(1);
81
+
82
+ if (process.platform === "win32" && path.startsWith("/") && !path.startsWith("//") && !path.includes("\\")) {
83
+ const match = path.match(/^\/(?:mnt\/|cygdrive\/)?([a-z])(?:\/(.*))?$/i);
84
+ if (match) path = `${match[1].toUpperCase()}:\\${match[2]?.replaceAll("/", "\\") ?? ""}`;
85
+ }
86
+
87
+ if (path === "~") return homedir();
88
+ if (path.startsWith("~/") || (process.platform === "win32" && path.startsWith("~\\"))) {
89
+ return join(homedir(), path.slice(2));
57
90
  }
58
- return resolve(cwd, path);
91
+ if (/^file:\/\//.test(path)) return fileURLToPath(path);
92
+ return path;
93
+ }
94
+
95
+ function resolveToCwd(filePath: string, cwd: string): string {
96
+ const path = normalizeToolPath(filePath);
97
+ return isAbsolute(path) ? resolve(path) : resolve(cwd, path);
59
98
  }
60
99
 
61
100
  function isSingleEditInput(value: unknown): value is { oldText: string; newText: string } {
@@ -82,28 +121,26 @@ export function prepareEditArguments(input: unknown): unknown {
82
121
  [path: string]: unknown;
83
122
  };
84
123
 
85
- if (typeof args.edits === "string") {
124
+ let preparedEdits = args.edits;
125
+ if (typeof preparedEdits === "string") {
86
126
  try {
87
- const parsed: unknown = JSON.parse(args.edits);
88
- if (Array.isArray(parsed)) {
89
- args.edits = parsed;
90
- } else if (isSingleEditInput(parsed)) {
91
- args.edits = [parsed];
92
- }
127
+ const parsed: unknown = JSON.parse(preparedEdits);
128
+ if (Array.isArray(parsed)) preparedEdits = parsed;
129
+ else if (isSingleEditInput(parsed)) preparedEdits = [parsed];
93
130
  } catch {
94
131
  // leave as-is; schema validation will report it
95
132
  }
96
- } else if (isSingleEditInput(args.edits)) {
97
- args.edits = [args.edits];
133
+ } else if (isSingleEditInput(preparedEdits)) {
134
+ preparedEdits = [preparedEdits];
98
135
  }
99
136
 
100
137
  if (typeof args.oldText === "string" && typeof args.newText === "string") {
101
- const edits = Array.isArray(args.edits) ? [...(args.edits as EditOp[])] : [];
138
+ const edits = Array.isArray(preparedEdits) ? [...(preparedEdits as EditOp[])] : [];
102
139
  edits.push({ oldText: args.oldText, newText: args.newText });
103
140
  const { oldText: _oldText, newText: _newText, ...rest } = args;
104
141
  return { ...rest, edits };
105
142
  }
106
- return args;
143
+ return preparedEdits === args.edits ? args : { ...args, edits: preparedEdits };
107
144
  }
108
145
 
109
146
  export interface BetterEditSuccess {
@@ -111,111 +148,256 @@ export interface BetterEditSuccess {
111
148
  details: EditToolDetails;
112
149
  }
113
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
+
114
208
  export async function executeBetterEdit(
115
209
  input: BetterEditInput,
116
210
  signal: AbortSignal | undefined,
117
- ctx: Pick<ExtensionContext, "cwd">,
211
+ ctx: Pick<ExtensionContext, "cwd"> & Partial<Pick<ExtensionContext, "sessionManager">>,
212
+ options: BetterEditExecutionOptions = {},
118
213
  ): Promise<BetterEditSuccess> {
119
214
  const edits: EditOp[] = input.edits ?? [];
120
- if (!Array.isArray(edits) || edits.length === 0) {
121
- throw new Error("Edit tool input is invalid. edits must contain at least one replacement.");
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.");
122
217
  }
123
218
  const absolutePath = resolveToCwd(input.path, ctx.cwd);
219
+ const ops = options.operations ?? defaultOperations;
220
+ const formatSuccess = options.formatSuccess ?? formatAutoDisambiguationSuccess;
124
221
 
125
222
  return withFileMutationQueue(absolutePath, async () => {
126
- // Do not reject from an abort event listener here: that would release the
127
- // mutation queue while an in-flight filesystem operation may still finish.
223
+ // Never reject from an abort listener: the queue must remain held until
224
+ // in-flight filesystem work settles.
128
225
  const throwIfAborted = () => {
129
- 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.");
130
227
  };
131
228
  throwIfAborted();
132
229
 
133
230
  try {
134
- await fsAccess(absolutePath, constants.R_OK | constants.W_OK);
231
+ await ops.access(absolutePath);
135
232
  } catch (error) {
136
233
  throwIfAborted();
137
- const errorMessage =
138
- error instanceof Error && "code" in error
139
- ? `Error code: ${(error as NodeJS.ErrnoException).code}`
140
- : String(error);
141
- 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.`);
142
235
  }
143
236
  throwIfAborted();
144
237
 
145
- const buffer = await fsReadFile(absolutePath);
146
- const rawContent = buffer.toString("utf-8");
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);
147
253
  throwIfAborted();
148
254
 
149
255
  const { bom, text: content } = splitBom(rawContent);
150
256
  const originalEnding = detectLineEnding(content);
151
257
  const normalizedContent = normalizeToLF(content);
152
258
 
153
- const result = analyzeEdits(normalizedContent, edits);
154
- if (!result.ok) {
155
- throw new Error(
156
- formatEditFailure({
157
- path: input.path,
259
+ const selections = new Map<number, number>();
260
+ const resolutions: AutoDisambiguation[] = [];
261
+ let result = analyzeEdits(normalizedContent, edits, { ambiguousSelections: selections });
262
+ let readEvidence: Awaited<ReturnType<typeof findLatestReadEvidence>> | undefined;
263
+ const normalizedEdits = normalizeEdits(edits);
264
+ const exactBase = normalizedEdits.every(
265
+ (edit) => !fuzzyFindText(normalizedContent, edit.oldText).usedFuzzyMatch,
266
+ );
267
+
268
+ while (!result.ok && result.failure.kind === "ambiguous" && exactBase) {
269
+ const editIndex = result.failure.editIndex;
270
+ if (selections.has(editIndex)) break;
271
+ const edit = normalizedEdits[editIndex];
272
+ const exactCount = countLiteralOccurrences(normalizedContent, edit.oldText);
273
+ const exactOffsets = findAllOccurrences(normalizedContent, edit.oldText, 256);
274
+ // Fuzzy-equivalent aliases cannot be mapped safely to original offsets.
275
+ if (exactCount !== result.failure.occurrenceCount) break;
276
+
277
+ if (readEvidence === undefined) {
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
+ }
284
+ readEvidence = await findLatestReadEvidence(
285
+ ctx.sessionManager,
286
+ canonicalTarget,
158
287
  normalizedContent,
159
- edits,
160
- failure: result.failure,
161
- }),
288
+ async (readPath) => ops.realpath(resolveToCwd(readPath, ctx.cwd)),
289
+ );
290
+ }
291
+ if (!readEvidence) break;
292
+
293
+ const candidates = exactOffsets.filter(
294
+ (offset) =>
295
+ offset >= readEvidence!.startOffset &&
296
+ offset + edit.oldText.length <= readEvidence!.endOffset,
162
297
  );
298
+ if (candidates.length !== 1) break;
299
+
300
+ const selectedOffset = candidates[0];
301
+ const spans = getLineSpans(normalizedContent);
302
+ selections.set(editIndex, selectedOffset);
303
+ resolutions.push({
304
+ editIndex,
305
+ oldText: edit.oldText,
306
+ chosenRange: {
307
+ start: lineAt(spans, selectedOffset) + 1,
308
+ end: lineAt(spans, selectedOffset + Math.max(1, edit.oldText.length) - 1) + 1,
309
+ },
310
+ readRange: { start: readEvidence.startLine, end: readEvidence.endLine },
311
+ });
312
+ result = analyzeEdits(normalizedContent, edits, { ambiguousSelections: selections });
313
+ }
314
+
315
+ if (!result.ok) {
316
+ throw new Error(formatEditFailure({ path: input.path, normalizedContent, edits, failure: result.failure }));
163
317
  }
164
318
 
165
319
  const { baseContent, newContent } = applyAnalysis(normalizedContent, result.analysis);
166
320
  if (baseContent === newContent) {
167
- throw new Error(
168
- formatEditFailure({
169
- path: input.path,
170
- normalizedContent,
171
- edits,
172
- failure: { kind: "no-change" },
173
- }),
174
- );
321
+ throw new Error(formatEditFailure({
322
+ path: input.path,
323
+ normalizedContent,
324
+ edits,
325
+ failure: { kind: "no-change" },
326
+ }));
175
327
  }
176
- throwIfAborted();
177
328
 
329
+ // Prepare every required success artifact before crossing the write
330
+ // boundary. Optional diagnostics are bounded and cannot fail after commit.
178
331
  const finalContent = bom + restoreLineEndings(newContent, originalEnding);
179
- await fsWriteFile(absolutePath, finalContent, "utf-8");
180
- throwIfAborted();
181
-
182
332
  const diffResult = generateDiffString(baseContent, newContent);
183
- const patch = generateUnifiedPatch(input.path, baseContent, newContent);
184
- return {
185
- content: [
186
- {
187
- type: "text",
188
- text: `Successfully replaced ${edits.length} block(s) in ${input.path}.`,
189
- },
190
- ],
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
+ }],
191
342
  details: {
192
343
  diff: diffResult.diff,
193
- patch,
344
+ patch: generateUnifiedPatch(input.path, baseContent, newContent),
194
345
  firstChangedLine: diffResult.firstChangedLine,
195
- } satisfies EditToolDetails,
346
+ },
196
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;
197
378
  });
198
379
  }
199
380
 
200
- export function registerBetterEditTool(pi: ExtensionAPI): void {
381
+ export function registerBetterEditTool(pi: ExtensionAPI, options: BetterEditExecutionOptions = {}): void {
201
382
  pi.registerTool({
202
383
  name: "edit",
203
384
  label: "edit",
204
385
  description:
205
- "Edit a single file using exact text replacement. Every edits[].oldText must match a unique, non-overlapping region of the original file. If two changes affect the same block or nearby lines, merge them into one edit instead of emitting overlapping edits. Do not include large unchanged regions just to connect distant changes. On failure the error includes recovery context (closest matching region or per-occurrence disambiguation snippets) so you can retry immediately without re-reading the 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.",
206
387
  promptSnippet:
207
- "Make precise file edits with exact text replacement; failures return recovery context (closest match or disambiguation snippets)",
388
+ "Make precise local file edits with exact text replacement; failures return bounded recovery context",
208
389
  promptGuidelines: [
209
- "Use edit for precise changes (edits[].oldText must match exactly)",
210
- "When changing multiple separate locations in one file, use one edit call with multiple entries in edits[] instead of multiple edit calls",
211
- "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.",
212
- "Keep edits[].oldText as small as possible while still being unique in the file. Do not pad with large unchanged regions.",
213
- "When an edit call fails, the error message already contains recovery context: the closest matching region with exact file bytes (not-found) or each occurrence with a ready-to-use disambiguated oldText (ambiguous match). Retry the edit using that text directly instead of re-reading the file.",
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.",
214
396
  ],
215
397
  parameters: betterEditSchema,
216
398
  prepareArguments: (args: unknown): BetterEditInput => prepareEditArguments(args) as BetterEditInput,
217
399
  async execute(_toolCallId, input, signal, _onUpdate, ctx) {
218
- return executeBetterEdit(input, signal, ctx);
400
+ return executeBetterEdit(input, signal, ctx, options);
219
401
  },
220
402
  });
221
403
  }