@khanhicetea/pi-better-tool 0.2.1 → 0.2.3

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,11 +23,15 @@ 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";
27
- import { homedir } from "node:os";
28
- import { isAbsolute, join, resolve } from "node:path";
29
- import { fileURLToPath } from "node:url";
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";
34
+ import { resolveToolPath as resolveToCwd } from "./paths.ts";
30
35
  import { type Static, Type } from "typebox";
31
36
  import { analyzeEdits, applyAnalysis, fuzzyFindText, normalizeEdits, type EditOp } from "./apply.ts";
32
37
  import {
@@ -54,8 +59,10 @@ const replaceEditSchema = Type.Object({
54
59
  });
55
60
 
56
61
  export const betterEditSchema = Type.Object({
57
- path: Type.String({ description: "Path to the file to edit (relative or absolute)" }),
62
+ path: Type.String({ minLength: 1, description: "Path to the file to edit (relative or absolute)" }),
58
63
  edits: Type.Array(replaceEditSchema, {
64
+ minItems: 1,
65
+ maxItems: 100,
59
66
  description:
60
67
  "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
68
  }),
@@ -63,31 +70,6 @@ export const betterEditSchema = Type.Object({
63
70
 
64
71
  export type BetterEditInput = Static<typeof betterEditSchema>;
65
72
 
66
- const UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g;
67
-
68
- /** Match pi's built-in path normalization for tool arguments. */
69
- function normalizeToolPath(input: string): string {
70
- let path = input.replace(UNICODE_SPACES, " ");
71
- if (path.startsWith("@")) path = path.slice(1);
72
-
73
- if (process.platform === "win32" && path.startsWith("/") && !path.startsWith("//") && !path.includes("\\")) {
74
- const match = path.match(/^\/(?:mnt\/|cygdrive\/)?([a-z])(?:\/(.*))?$/i);
75
- if (match) path = `${match[1].toUpperCase()}:\\${match[2]?.replaceAll("/", "\\") ?? ""}`;
76
- }
77
-
78
- if (path === "~") return homedir();
79
- if (path.startsWith("~/") || (process.platform === "win32" && path.startsWith("~\\"))) {
80
- return join(homedir(), path.slice(2));
81
- }
82
- if (/^file:\/\//.test(path)) return fileURLToPath(path);
83
- return path;
84
- }
85
-
86
- function resolveToCwd(filePath: string, cwd: string): string {
87
- const path = normalizeToolPath(filePath);
88
- return isAbsolute(path) ? resolve(path) : resolve(cwd, path);
89
- }
90
-
91
73
  function isSingleEditInput(value: unknown): value is { oldText: string; newText: string } {
92
74
  if (!value || typeof value !== "object" || Array.isArray(value)) {
93
75
  return false;
@@ -112,28 +94,26 @@ export function prepareEditArguments(input: unknown): unknown {
112
94
  [path: string]: unknown;
113
95
  };
114
96
 
115
- if (typeof args.edits === "string") {
97
+ let preparedEdits = args.edits;
98
+ if (typeof preparedEdits === "string") {
116
99
  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
- }
100
+ const parsed: unknown = JSON.parse(preparedEdits);
101
+ if (Array.isArray(parsed)) preparedEdits = parsed;
102
+ else if (isSingleEditInput(parsed)) preparedEdits = [parsed];
123
103
  } catch {
124
104
  // leave as-is; schema validation will report it
125
105
  }
126
- } else if (isSingleEditInput(args.edits)) {
127
- args.edits = [args.edits];
106
+ } else if (isSingleEditInput(preparedEdits)) {
107
+ preparedEdits = [preparedEdits];
128
108
  }
129
109
 
130
110
  if (typeof args.oldText === "string" && typeof args.newText === "string") {
131
- const edits = Array.isArray(args.edits) ? [...(args.edits as EditOp[])] : [];
111
+ const edits = Array.isArray(preparedEdits) ? [...(preparedEdits as EditOp[])] : [];
132
112
  edits.push({ oldText: args.oldText, newText: args.newText });
133
113
  const { oldText: _oldText, newText: _newText, ...rest } = args;
134
114
  return { ...rest, edits };
135
115
  }
136
- return args;
116
+ return preparedEdits === args.edits ? args : { ...args, edits: preparedEdits };
137
117
  }
138
118
 
139
119
  export interface BetterEditSuccess {
@@ -141,39 +121,108 @@ export interface BetterEditSuccess {
141
121
  details: EditToolDetails;
142
122
  }
143
123
 
124
+ export interface BetterEditOperations {
125
+ access(path: string): Promise<void>;
126
+ readFile(path: string): Promise<Buffer>;
127
+ realpath(path: string): Promise<string>;
128
+ stat(path: string): Promise<Stats>;
129
+ writeFile(path: string, content: string): Promise<void>;
130
+ }
131
+
132
+ const defaultOperations: BetterEditOperations = {
133
+ access: (path) => fsAccess(path, constants.R_OK | constants.W_OK),
134
+ readFile: (path) => fsReadFile(path),
135
+ realpath: (path) => fsRealpath(path),
136
+ stat: (path) => fsStat(path),
137
+ writeFile: (path, content) => fsWriteFile(path, content, "utf-8"),
138
+ };
139
+
140
+ export interface BetterEditExecutionOptions {
141
+ operations?: BetterEditOperations;
142
+ /** Test/integration seam; runs before the write commit boundary. */
143
+ formatSuccess?: typeof formatAutoDisambiguationSuccess;
144
+ }
145
+
146
+ function describeFsError(error: unknown): string {
147
+ return error instanceof Error && "code" in error
148
+ ? `Error code: ${(error as NodeJS.ErrnoException).code}`
149
+ : error instanceof Error
150
+ ? error.message
151
+ : String(error);
152
+ }
153
+
154
+ function sameFileIdentity(a: Stats, b: Stats): boolean {
155
+ return a.dev === b.dev && a.ino === b.ino;
156
+ }
157
+
158
+ function decodeEditableUtf8(buffer: Buffer, path: string): string {
159
+ try {
160
+ new TextDecoder("utf-8", { fatal: true }).decode(buffer);
161
+ } catch {
162
+ throw new Error(`Could not edit file: ${path}. The file is not valid UTF-8; unsupported encodings are never transcoded.`);
163
+ }
164
+ if (buffer.includes(0)) {
165
+ throw new Error(`Could not edit file: ${path}. The file contains NUL bytes and is treated as binary or an unsupported text encoding.`);
166
+ }
167
+ return buffer.toString("utf-8");
168
+ }
169
+
170
+ function countLiteralOccurrences(haystack: string, needle: string): number {
171
+ if (!needle) return 0;
172
+ let count = 0;
173
+ let index = haystack.indexOf(needle);
174
+ while (index !== -1) {
175
+ count++;
176
+ index = haystack.indexOf(needle, index + needle.length);
177
+ }
178
+ return count;
179
+ }
180
+
144
181
  export async function executeBetterEdit(
145
182
  input: BetterEditInput,
146
183
  signal: AbortSignal | undefined,
147
184
  ctx: Pick<ExtensionContext, "cwd"> & Partial<Pick<ExtensionContext, "sessionManager">>,
185
+ options: BetterEditExecutionOptions = {},
148
186
  ): Promise<BetterEditSuccess> {
149
187
  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.");
188
+ if (!input.path || !Array.isArray(edits) || edits.length === 0 || edits.length > 100) {
189
+ throw new Error("Edit tool input is invalid. path must be non-empty and edits must contain 1-100 replacements.");
152
190
  }
153
191
  const absolutePath = resolveToCwd(input.path, ctx.cwd);
192
+ const ops = options.operations ?? defaultOperations;
193
+ const formatSuccess = options.formatSuccess ?? formatAutoDisambiguationSuccess;
154
194
 
155
195
  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.
196
+ // Never reject from an abort listener: the queue must remain held until
197
+ // in-flight filesystem work settles.
158
198
  const throwIfAborted = () => {
159
- if (signal?.aborted) throw new Error("Operation aborted");
199
+ if (signal?.aborted) throw new Error("Operation aborted before the edit was committed; no write was started.");
160
200
  };
161
201
  throwIfAborted();
162
202
 
163
203
  try {
164
- await fsAccess(absolutePath, constants.R_OK | constants.W_OK);
204
+ await ops.access(absolutePath);
165
205
  } catch (error) {
166
206
  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}.`);
207
+ throw new Error(`Could not access file for editing: ${input.path}. ${describeFsError(error)}. No write was started.`);
172
208
  }
173
209
  throwIfAborted();
174
210
 
175
- const buffer = await fsReadFile(absolutePath);
176
- const rawContent = buffer.toString("utf-8");
211
+ let buffer: Buffer;
212
+ let initialStat: Stats;
213
+ try {
214
+ const beforeReadStat = await ops.stat(absolutePath);
215
+ buffer = await ops.readFile(absolutePath);
216
+ initialStat = await ops.stat(absolutePath);
217
+ if (!sameFileIdentity(beforeReadStat, initialStat)) throw new Error("target-changed-during-read");
218
+ } catch (error) {
219
+ throwIfAborted();
220
+ if (error instanceof Error && error.message === "target-changed-during-read") {
221
+ 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.`);
222
+ }
223
+ throw new Error(`Could not read file for editing: ${input.path}. ${describeFsError(error)}. No write was started.`);
224
+ }
225
+ const rawContent = decodeEditableUtf8(buffer, input.path);
177
226
  throwIfAborted();
178
227
 
179
228
  const { bom, text: content } = splitBom(rawContent);
@@ -193,17 +242,23 @@ export async function executeBetterEdit(
193
242
  const editIndex = result.failure.editIndex;
194
243
  if (selections.has(editIndex)) break;
195
244
  const edit = normalizedEdits[editIndex];
196
- const exactOffsets = findAllOccurrences(normalizedContent, edit.oldText);
245
+ const exactCount = countLiteralOccurrences(normalizedContent, edit.oldText);
246
+ const exactOffsets = findAllOccurrences(normalizedContent, edit.oldText, 256);
197
247
  // Fuzzy-equivalent aliases cannot be mapped safely to original offsets.
198
- if (exactOffsets.length !== result.failure.occurrenceOffsets.length) break;
248
+ if (exactCount !== result.failure.occurrenceCount) break;
199
249
 
200
250
  if (readEvidence === undefined) {
201
- const canonicalTarget = await fsRealpath(absolutePath);
251
+ let canonicalTarget: string;
252
+ try {
253
+ canonicalTarget = await ops.realpath(absolutePath);
254
+ } catch (error) {
255
+ throw new Error(`Could not resolve edit target: ${input.path}. ${describeFsError(error)}. No write was started.`);
256
+ }
202
257
  readEvidence = await findLatestReadEvidence(
203
258
  ctx.sessionManager,
204
259
  canonicalTarget,
205
260
  normalizedContent,
206
- async (readPath) => fsRealpath(resolveToCwd(readPath, ctx.cwd)),
261
+ async (readPath) => ops.realpath(resolveToCwd(readPath, ctx.cwd)),
207
262
  );
208
263
  }
209
264
  if (!readEvidence) break;
@@ -231,75 +286,92 @@ export async function executeBetterEdit(
231
286
  }
232
287
 
233
288
  if (!result.ok) {
234
- throw new Error(
235
- formatEditFailure({
236
- path: input.path,
237
- normalizedContent,
238
- edits,
239
- failure: result.failure,
240
- }),
241
- );
289
+ throw new Error(formatEditFailure({ path: input.path, normalizedContent, edits, failure: result.failure }));
242
290
  }
243
291
 
244
292
  const { baseContent, newContent } = applyAnalysis(normalizedContent, result.analysis);
245
293
  if (baseContent === newContent) {
246
- throw new Error(
247
- formatEditFailure({
248
- path: input.path,
249
- normalizedContent,
250
- edits,
251
- failure: { kind: "no-change" },
252
- }),
253
- );
294
+ throw new Error(formatEditFailure({
295
+ path: input.path,
296
+ normalizedContent,
297
+ edits,
298
+ failure: { kind: "no-change" },
299
+ }));
254
300
  }
255
- throwIfAborted();
256
301
 
302
+ // Prepare every required success artifact before crossing the write
303
+ // boundary. Optional diagnostics are bounded and cannot fail after commit.
257
304
  const finalContent = bom + restoreLineEndings(newContent, originalEnding);
258
- await fsWriteFile(absolutePath, finalContent, "utf-8");
259
- throwIfAborted();
260
-
261
305
  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
- ],
306
+ const success: BetterEditSuccess = {
307
+ content: [{
308
+ type: "text",
309
+ text: formatSuccess(
310
+ `Successfully replaced ${edits.length} block(s) in ${input.path}.`,
311
+ newContent,
312
+ resolutions,
313
+ ),
314
+ }],
274
315
  details: {
275
316
  diff: diffResult.diff,
276
- patch,
317
+ patch: generateUnifiedPatch(input.path, baseContent, newContent),
277
318
  firstChangedLine: diffResult.firstChangedLine,
278
- } satisfies EditToolDetails,
319
+ },
279
320
  };
321
+ throwIfAborted();
322
+
323
+ // Best-effort external modification detection. This is not a lock or a
324
+ // race-free compare-and-swap; another process can still change the target
325
+ // after this check and before/during the write.
326
+ try {
327
+ const [currentBuffer, currentStat] = await Promise.all([
328
+ ops.readFile(absolutePath),
329
+ ops.stat(absolutePath),
330
+ ]);
331
+ if (!sameFileIdentity(initialStat, currentStat) || !buffer.equals(currentBuffer)) {
332
+ throw new Error("target-changed");
333
+ }
334
+ } catch (error) {
335
+ throwIfAborted();
336
+ if (error instanceof Error && error.message === "target-changed") {
337
+ throw new Error(`Could not edit file: ${input.path}. The target changed after it was read; no write was started. Re-read and retry.`);
338
+ }
339
+ throw new Error(`Could not revalidate file before writing: ${input.path}. ${describeFsError(error)}. No write was started.`);
340
+ }
341
+ throwIfAborted();
342
+
343
+ try {
344
+ await ops.writeFile(absolutePath, finalContent);
345
+ } catch (error) {
346
+ 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.`);
347
+ }
348
+ // A resolved write is the commit boundary. Ignore cancellation that arrived
349
+ // during it and report the committed result; no fallible formatting remains.
350
+ return success;
280
351
  });
281
352
  }
282
353
 
283
- export function registerBetterEditTool(pi: ExtensionAPI): void {
354
+ export function registerBetterEditTool(pi: ExtensionAPI, options: BetterEditExecutionOptions = {}): void {
284
355
  pi.registerTool({
285
356
  name: "edit",
286
357
  label: "edit",
287
358
  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.",
359
+ "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
360
  promptSnippet:
290
- "Make precise file edits with exact text replacement; failures return recovery context (closest match or disambiguation snippets)",
361
+ "Make precise local file edits with exact text replacement; failures return bounded recovery context",
291
362
  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.",
363
+ "Use edit for precise changes (edits[].oldText must match exactly).",
364
+ "When changing multiple separate locations in one file, use one edit call with multiple entries in edits[] instead of multiple edit calls.",
365
+ "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.",
366
+ "Keep edit edits[].oldText as small as possible while still being unique in the file; do not pad with large unchanged regions.",
367
+ "When edit safely auto-disambiguates repeated text from the latest verified stored-context read or read_symbol result, its success message lists bounded remaining candidates for an optional follow-up edit.",
368
+ "An edit matching failure applies none of the batch. Fix the reported entries and resubmit the complete batch, not only the failed replacement. Use suggested read_symbol calls when you need the whole enclosing function.",
369
+ "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
370
  ],
299
371
  parameters: betterEditSchema,
300
372
  prepareArguments: (args: unknown): BetterEditInput => prepareEditArguments(args) as BetterEditInput,
301
373
  async execute(_toolCallId, input, signal, _onUpdate, ctx) {
302
- return executeBetterEdit(input, signal, ctx);
374
+ return executeBetterEdit(input, signal, ctx, options);
303
375
  },
304
376
  });
305
377
  }