@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/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
- * 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
  *
@@ -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 { access as fsAccess, readFile as fsReadFile, realpath as fsRealpath, 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";
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
- if (typeof args.edits === "string") {
124
+ let preparedEdits = args.edits;
125
+ if (typeof preparedEdits === "string") {
116
126
  try {
117
- const parsed: unknown = JSON.parse(args.edits);
118
- if (Array.isArray(parsed)) {
119
- args.edits = parsed;
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(args.edits)) {
127
- args.edits = [args.edits];
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(args.edits) ? [...(args.edits as EditOp[])] : [];
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 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.");
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
- // Do not reject from an abort event listener here: that would release the
157
- // 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.
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 fsAccess(absolutePath, constants.R_OK | constants.W_OK);
231
+ await ops.access(absolutePath);
165
232
  } catch (error) {
166
233
  throwIfAborted();
167
- const errorMessage =
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
- const buffer = await fsReadFile(absolutePath);
176
- 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);
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 exactOffsets = findAllOccurrences(normalizedContent, edit.oldText);
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 (exactOffsets.length !== result.failure.occurrenceOffsets.length) break;
275
+ if (exactCount !== result.failure.occurrenceCount) break;
199
276
 
200
277
  if (readEvidence === undefined) {
201
- const canonicalTarget = await fsRealpath(absolutePath);
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) => fsRealpath(resolveToCwd(readPath, ctx.cwd)),
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
- formatEditFailure({
248
- path: input.path,
249
- normalizedContent,
250
- edits,
251
- failure: { kind: "no-change" },
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 patch = generateUnifiedPatch(input.path, baseContent, newContent);
263
- return {
264
- content: [
265
- {
266
- type: "text",
267
- text: formatAutoDisambiguationSuccess(
268
- `Successfully replaced ${edits.length} block(s) in ${input.path}.`,
269
- newContent,
270
- resolutions,
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
- } satisfies EditToolDetails,
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. When literal text is repeated, edit may safely select it only if exactly one occurrence was fully shown by the latest verified read of that file; the success result then includes up to four remaining disambiguated candidates. 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.",
289
387
  promptSnippet:
290
- "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",
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
- "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.",
295
- "Keep edits[].oldText as small as possible while still being unique in the file. Do not pad with large unchanged regions.",
296
- "When edit safely auto-disambiguates repeated text from the latest verified read, its success message lists up to four remaining occurrences with effective prefix/suffix context for an optional follow-up edit.",
297
- "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.",
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
  }