@khanhicetea/pi-better-tool 0.1.0 → 0.2.1

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 CHANGED
@@ -10,9 +10,10 @@ The built-in `edit` tool requires `edits[].oldText` to match **exactly and uniqu
10
10
  Could not find edits[1] in /code/app.go. The oldText must match exactly including all whitespace and newlines.
11
11
  ```
12
12
 
13
- That failure wastes a whole loop: the model has to `read` the file again, guess a larger context, and retry — sometimes failing again. `pi-better-tool` keeps the refusal (writing the wrong occurrence would be worse) but returns **recovery context** so the next call succeeds without re-reading:
13
+ That failure wastes a whole loop: the model has to `read` the file again, guess a larger context, and retry — sometimes failing again. `pi-better-tool` resolves only ambiguity supported by verified evidence and otherwise returns **recovery context** so the next call succeeds without re-reading:
14
14
 
15
- - **Ambiguous match (2+ occurrences)** — every occurrence's line number plus the **minimum prefix/suffix context** that makes each occurrence unique, rendered as ready-to-use `oldText` snippets.
15
+ - **Ambiguous literal match after a bounded read** — when exactly one occurrence was fully visible in the latest successful, byte-verified `read` of the same file, edit selects that occurrence. Its success message reports the selected/read ranges and up to four remaining occurrences with effective prefix/suffix context and retryable snippets.
16
+ - **Other ambiguous matches (2+ occurrences)** — bounded occurrence line numbers plus the **minimum prefix/suffix context** that makes the first few occurrences unique, rendered as ready-to-use `oldText` snippets when they fit safely.
16
17
  - **Text not found** — the closest matching region (fuzzy line similarity), a line-by-line comparison against your `oldText`, the exact file bytes to retry with, and likely causes (tabs vs spaces, indentation, case).
17
18
 
18
19
  ## Install
@@ -85,16 +86,17 @@ No changes were written — the file was not modified.
85
86
 
86
87
  ## Behavior
87
88
 
88
- Happy-path semantics are **identical** to the built-in `edit` tool:
89
+ Normal unique-match semantics are **identical** to the built-in `edit` tool. The one intentional extension is conservative read-based selection for repeated literal text:
89
90
 
90
91
  - same schema (`path` + `edits[{oldText,newText}]`), including the compatibility shim for models that send `edits` as a JSON string, a single edit object, or legacy top-level `oldText`/`newText`
91
92
  - same matching engine ported from pi's `edit-diff.ts`: exact match first, fuzzy fallback (trailing whitespace, smart quotes, dashes, unicode spaces), uniqueness checked in fuzzy-normalized space, all edits matched against the original content, overlap/empty/no-change detection
92
93
  - same BOM and CRLF handling
93
94
  - same success result shape (`details.diff` / `details.patch` / `details.firstChangedLine`), and no custom renderers — the built-in diff renderer is inherited
95
+ - read-based selection fails closed unless the newest same-file read is still in active context, its stored output exactly matches the current bytes and built-in read formatting, and exactly one complete literal occurrence lies inside the visible range; fuzzy/Unicode-equivalent ambiguity, same-line ambiguity, stale reads, and broad reads containing multiple occurrences still refuse
94
96
 
95
- Failure behavior is the difference: errors carry the recovery context described above, and nothing is written on failure (edits remain atomic).
97
+ Failure behavior is the other difference: errors carry the recovery context described above, and nothing is written on failure (edits remain atomic).
96
98
 
97
- Diagnostics degrade gracefully: files over ~2 MB skip the analysis and return the plain built-in-style error; repeated blocks that cannot be disambiguated within 12 context lines get a guidance note instead of snippets.
99
+ Diagnostics degrade gracefully: files over ~2 MB skip the analysis and return the plain built-in-style error; repeated blocks that cannot be disambiguated within 12 context lines get a guidance note instead of snippets. Complete diagnostic output is bounded below pi's 50 KB / 2,000-line tool-output limit. Oversized exact snippets are omitted with a line range instead of being presented as copyable text, and low-confidence or non-unique closest matches require verification before retrying.
98
100
 
99
101
  ## Development
100
102
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@khanhicetea/pi-better-tool",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "Better built-in tools for pi: an edit tool override that returns recovery context (closest match + disambiguation snippets) instead of bare failures",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/apply.ts CHANGED
@@ -77,6 +77,14 @@ export interface FuzzyFindResult {
77
77
 
78
78
  export type AnalyzeResult = { ok: true; analysis: EditAnalysis } | { ok: false; failure: EditFailure };
79
79
 
80
+ export interface AnalyzeOptions {
81
+ /**
82
+ * Exact offsets chosen for otherwise-ambiguous edits. Offsets are accepted
83
+ * only when they point at the literal oldText in the matching base.
84
+ */
85
+ ambiguousSelections?: ReadonlyMap<number, number>;
86
+ }
87
+
80
88
  export function normalizeEdits(edits: EditOp[]): EditOp[] {
81
89
  return edits.map((edit) => ({
82
90
  oldText: normalizeToLF(edit.oldText),
@@ -124,10 +132,13 @@ function rangeOf(spans: LineSpan[], matchIndex: number, matchLength: number): Li
124
132
  };
125
133
  }
126
134
 
127
- export function analyzeEdits(normalizedContent: string, rawEdits: EditOp[]): AnalyzeResult {
135
+ export function analyzeEdits(normalizedContent: string, rawEdits: EditOp[], options: AnalyzeOptions = {}): AnalyzeResult {
128
136
  const edits = normalizeEdits(rawEdits);
129
137
  for (let i = 0; i < edits.length; i++) {
130
- if (edits[i].oldText.length === 0) {
138
+ // Fuzzy normalization can erase whitespace-only needles. Allowing an
139
+ // empty normalized needle makes String#indexOf match at offset zero and
140
+ // turns an intended replacement into an insertion.
141
+ if (edits[i].oldText.length === 0 || normalizeForFuzzyMatch(edits[i].oldText).length === 0) {
131
142
  return { ok: false, failure: { kind: "empty-old-text", editIndex: i } };
132
143
  }
133
144
  }
@@ -146,17 +157,31 @@ export function analyzeEdits(normalizedContent: string, rawEdits: EditOp[]): Ana
146
157
  return { ok: false, failure: { kind: "not-found", editIndex: i } };
147
158
  }
148
159
  const occurrences = countFuzzyOccurrences(fuzzyBase, edit.oldText);
160
+ let selectedMatch = matchResult;
149
161
  if (occurrences > 1) {
150
162
  const occurrenceOffsets = findAllOccurrences(fuzzyBase, normalizeForFuzzyMatch(edit.oldText));
151
- return {
152
- ok: false,
153
- failure: { kind: "ambiguous", editIndex: i, occurrenceOffsets },
163
+ const selectedOffset = options.ambiguousSelections?.get(i);
164
+ if (
165
+ selectedOffset === undefined ||
166
+ base.slice(selectedOffset, selectedOffset + edit.oldText.length) !== edit.oldText
167
+ ) {
168
+ return {
169
+ ok: false,
170
+ failure: { kind: "ambiguous", editIndex: i, occurrenceOffsets },
171
+ };
172
+ }
173
+ selectedMatch = {
174
+ found: true,
175
+ index: selectedOffset,
176
+ matchLength: edit.oldText.length,
177
+ usedFuzzyMatch: false,
178
+ contentForReplacement: base,
154
179
  };
155
180
  }
156
181
  replacements.push({
157
182
  editIndex: i,
158
- matchIndex: matchResult.index,
159
- matchLength: matchResult.matchLength,
183
+ matchIndex: selectedMatch.index,
184
+ matchLength: selectedMatch.matchLength,
160
185
  newText: edit.newText,
161
186
  });
162
187
  }
@@ -11,14 +11,17 @@
11
11
  * a per-line comparison, and the exact file bytes to retry with
12
12
  */
13
13
 
14
+ import { formatSize, truncateHead } from "@earendil-works/pi-coding-agent";
14
15
  import type { EditFailure, EditOp, LineRange } from "./apply.ts";
15
16
  import { normalizeEdits } from "./apply.ts";
16
17
  import { findClosestRegion, lineSimilarity, probeMatchCauses } from "./similarity.ts";
17
18
  import {
18
19
  countFuzzyOccurrences,
20
+ findAllOccurrences,
19
21
  getLineSpans,
20
22
  lineAt,
21
23
  normalizeForFuzzyMatch,
24
+ normalizeToLF,
22
25
  type LineSpan,
23
26
  } from "./text.ts";
24
27
 
@@ -30,8 +33,14 @@ const MAX_TOTAL_CONTEXT_LINES = 12;
30
33
  const MAX_LISTED_OCCURRENCES = 8;
31
34
  /** Occurrences that get a ready-to-use disambiguation snippet. */
32
35
  const MAX_SNIPPET_OCCURRENCES = 3;
33
- /** Max lines shown inside a snippet. */
36
+ /** Max lines/bytes shown inside a copyable snippet. */
34
37
  const MAX_SNIPPET_LINES = 60;
38
+ const MAX_SNIPPET_BYTES = 12 * 1024;
39
+ /** Hard model-context bound for the complete tool error. */
40
+ const MAX_OUTPUT_LINES = 1_500;
41
+ const MAX_OUTPUT_BYTES = 48 * 1024;
42
+ /** Minimum score at which a unique closest region may be suggested directly. */
43
+ const MIN_DIRECT_RETRY_SCORE = 0.75;
35
44
  /** Max non-equal alignment ops rendered. */
36
45
  const MAX_DIFF_OPS_SHOWN = 12;
37
46
 
@@ -46,6 +55,13 @@ export interface Expansion {
46
55
  endLine: number;
47
56
  }
48
57
 
58
+ export interface AutoDisambiguation {
59
+ editIndex: number;
60
+ oldText: string;
61
+ chosenRange: LineRange;
62
+ readRange: LineRange;
63
+ }
64
+
49
65
  export interface FormatFailureOptions {
50
66
  path: string;
51
67
  /** LF-normalized, BOM-stripped file content. */
@@ -55,7 +71,65 @@ export interface FormatFailureOptions {
55
71
  failure: EditFailure;
56
72
  }
57
73
 
74
+ const MAX_SUCCESS_RESOLUTIONS = 4;
75
+ const MAX_REMAINING_OCCURRENCES = 4;
76
+ const MAX_SUCCESS_SNIPPET_BYTES = 2_500;
77
+
78
+ /** Add verified read-based selections and retryable remaining candidates to a successful edit result. */
79
+ export function formatAutoDisambiguationSuccess(
80
+ baseMessage: string,
81
+ newContent: string,
82
+ resolutions: AutoDisambiguation[],
83
+ ): string {
84
+ if (resolutions.length === 0) return baseMessage;
85
+ const lines = [baseMessage, ""];
86
+ const shownResolutions = resolutions.slice(0, MAX_SUCCESS_RESOLUTIONS);
87
+
88
+ for (const resolution of shownResolutions) {
89
+ lines.push(
90
+ `Auto-disambiguated edits[${resolution.editIndex}] to ${describeLines(resolution.chosenRange.start, resolution.chosenRange.end)} because it was the only occurrence fully contained in the latest verified read of ${describeLines(resolution.readRange.start, resolution.readRange.end)}.`,
91
+ );
92
+ const oldText = normalizeToLF(resolution.oldText);
93
+ const offsets = findAllOccurrences(newContent, oldText);
94
+ if (offsets.length === 0) {
95
+ lines.push("No exact occurrences of the original oldText remain after this edit.", "");
96
+ continue;
97
+ }
98
+
99
+ lines.push(`Remaining exact occurrences of the original oldText (${Math.min(offsets.length, MAX_REMAINING_OCCURRENCES)} shown${offsets.length > MAX_REMAINING_OCCURRENCES ? `, ${offsets.length - MAX_REMAINING_OCCURRENCES} more omitted` : ""}):`);
100
+ const fuzzyContent = normalizeForFuzzyMatch(newContent);
101
+ const spans = getLineSpans(newContent);
102
+ for (const [index, offset] of offsets.slice(0, MAX_REMAINING_OCCURRENCES).entries()) {
103
+ const range = rangeFromOffset(spans, offset, oldText.length);
104
+ const expansion = findMinimalUniqueExpansion(newContent, fuzzyContent, spans, range);
105
+ if (!expansion) {
106
+ lines.push(` ${index + 1}. ${describeLines(range.start, range.end)} — no unique prefix/suffix found within ${MAX_TOTAL_CONTEXT_LINES} context lines.`);
107
+ continue;
108
+ }
109
+ const where = `effective context: ${plural(expansion.prefixLines, "line")} before, ${plural(expansion.suffixLines, "line")} after`;
110
+ if (!isSnippetRenderable(expansion.text) || Buffer.byteLength(expansion.text, "utf8") > MAX_SUCCESS_SNIPPET_BYTES) {
111
+ lines.push(` ${index + 1}. ${describeLines(range.start, range.end)} — ${where}; snippet omitted, read lines ${expansion.startLine}-${expansion.endLine}.`);
112
+ continue;
113
+ }
114
+ lines.push(` ${index + 1}. ${describeLines(range.start, range.end)} — ${where}:`);
115
+ lines.push(...renderSnippet(expansion.text));
116
+ }
117
+ lines.push("Use one fenced snippet byte-for-byte as oldText if you want to edit another occurrence.", "");
118
+ }
119
+ if (resolutions.length > shownResolutions.length) {
120
+ lines.push(`${resolutions.length - shownResolutions.length} more auto-disambiguated edits were applied; details omitted.`);
121
+ }
122
+ return lines.join("\n").trimEnd();
123
+ }
124
+
58
125
  export function formatEditFailure(opts: FormatFailureOptions): string {
126
+ const message = formatEditFailureUnbounded(opts);
127
+ const bounded = truncateHead(message, { maxBytes: MAX_OUTPUT_BYTES, maxLines: MAX_OUTPUT_LINES });
128
+ if (!bounded.truncated) return message;
129
+ return `${bounded.content}\n\n[Diagnostic output truncated to ${formatSize(bounded.outputBytes)} / ${bounded.outputLines} lines. Any incomplete snippet is not retryable; read the referenced range first.]`;
130
+ }
131
+
132
+ function formatEditFailureUnbounded(opts: FormatFailureOptions): string {
59
133
  const { path, normalizedContent, edits, failure } = opts;
60
134
  const total = edits.length;
61
135
 
@@ -129,11 +203,12 @@ function formatAmbiguous(opts: FormatFailureOptions, failure: Extract<EditFailur
129
203
  lines.push("");
130
204
 
131
205
  lines.push(
132
- "Retry with a disambiguated oldText: pick ONE occurrence below and reuse its snippet exactly. Each snippet already includes the minimum surrounding context that makes it unique:",
206
+ "Disambiguated oldText candidates are shown below when they fit safely. A fenced snippet can be reused exactly; an omitted snippet must be read from its referenced range first:",
133
207
  );
134
208
 
135
209
  let shown = 0;
136
210
  let missingExpansionNote = false;
211
+ let omittedSnippet = false;
137
212
  for (let i = 0; i < listed.length && shown < MAX_SNIPPET_OCCURRENCES; i++) {
138
213
  const offset = listed[i];
139
214
  const range = rangeFromOffset(fuzzySpans, offset, fuzzyOld.length);
@@ -142,11 +217,18 @@ function formatAmbiguous(opts: FormatFailureOptions, failure: Extract<EditFailur
142
217
  missingExpansionNote = true;
143
218
  continue;
144
219
  }
145
- shown++;
146
220
  const where = `minimum context: ${plural(expansion.prefixLines, "line")} before, ${plural(expansion.suffixLines, "line")} after`;
147
221
  lines.push("");
222
+ if (!isSnippetRenderable(expansion.text)) {
223
+ omittedSnippet = true;
224
+ lines.push(
225
+ `Occurrence ${i + 1} (${describeLines(range.start, range.end)}) — ${where}. Exact unique snippet omitted because it exceeds the safe output limit; read lines ${expansion.startLine}-${expansion.endLine}.`,
226
+ );
227
+ continue;
228
+ }
229
+ shown++;
148
230
  lines.push(`Occurrence ${i + 1} (${describeLines(range.start, range.end)}) — ${where}:`);
149
- lines.push(...renderSnippet(expansion.text, `lines ${expansion.startLine}-${expansion.endLine}`));
231
+ lines.push(...renderSnippet(expansion.text));
150
232
  }
151
233
 
152
234
  if (missingExpansionNote) {
@@ -155,12 +237,12 @@ function formatAmbiguous(opts: FormatFailureOptions, failure: Extract<EditFailur
155
237
  `Some occurrences could not be auto-disambiguated within ${MAX_TOTAL_CONTEXT_LINES} context lines (likely near-identical repeated blocks). Extend oldText manually with distinguishing lines from the occurrences listed above.`,
156
238
  );
157
239
  }
158
- if (shown === 0 && !missingExpansionNote) {
240
+ if (shown === 0 && !missingExpansionNote && !omittedSnippet) {
159
241
  lines.push("", "Extend oldText with more surrounding lines until it matches exactly one location.");
160
242
  }
161
243
  lines.push("");
162
244
  lines.push(
163
- "Tip: use the snippet byte-for-byte as the new oldText, and make newText the snippet with your change applied (the snippet may span whole lines).",
245
+ "Tip: only fenced snippets above are byte-for-byte retryable. Make newText from the chosen snippet with your intended change applied.",
164
246
  );
165
247
  return lines.join("\n");
166
248
  }
@@ -290,12 +372,34 @@ function formatNotFound(opts: FormatFailureOptions, oldText: string): string {
290
372
  }
291
373
  }
292
374
  lines.push("");
293
- lines.push(
294
- `Exact file content at ${describeLines(closest.startLine, closest.endLine)} — retry using this text as oldText (then apply your change to newText):`,
295
- );
296
- lines.push(...snippetFromLines(normalizedContent, closest.startLine, closest.endLine));
375
+ const candidate = textFromLines(normalizedContent, closest.startLine, closest.endLine);
376
+ const uniqueCandidate = verifyUniqueSnippet(normalizeForFuzzyMatch(normalizedContent), candidate);
377
+ const safelyRenderable = isSnippetRenderable(uniqueCandidate ?? candidate);
378
+ const directRetry =
379
+ !closest.truncated &&
380
+ closest.score >= MIN_DIRECT_RETRY_SCORE &&
381
+ uniqueCandidate !== null &&
382
+ safelyRenderable;
383
+ if (directRetry) {
384
+ lines.push(
385
+ `Exact file content at ${describeLines(closest.startLine, closest.endLine)} (unique under edit matching) — retry using this text as oldText (then apply your change to newText):`,
386
+ );
387
+ lines.push(...renderSnippet(uniqueCandidate));
388
+ } else {
389
+ const reasons = [
390
+ closest.truncated ? "only part of oldText was compared" : undefined,
391
+ closest.score < MIN_DIRECT_RETRY_SCORE ? "similarity confidence is too low" : undefined,
392
+ uniqueCandidate === null ? "the candidate is not unique under edit matching" : undefined,
393
+ !safelyRenderable ? "the exact candidate exceeds the safe output limit" : undefined,
394
+ ].filter((reason): reason is string => reason !== undefined);
395
+ lines.push(
396
+ `Candidate file content at ${describeLines(closest.startLine, closest.endLine)} is not safe for a direct retry (${reasons.join("; ")}). Read and verify this range before editing.`,
397
+ );
398
+ if (safelyRenderable) lines.push(...renderSnippet(uniqueCandidate ?? candidate));
399
+ }
400
+
297
401
  } else {
298
- lines.push("No similar region was found in the file (best similarity below threshold).");
402
+ lines.push("No reliable similar region was found within the bounded diagnostic search.");
299
403
  lines.push("If you expected this text to exist, read the file around the expected location and retry.");
300
404
  }
301
405
 
@@ -316,29 +420,22 @@ function truncateLine(line: string): string {
316
420
  // Snippet rendering
317
421
  // ---------------------------------------------------------------------------
318
422
 
319
- function snippetFromLines(content: string, startLine: number, endLine: number): string[] {
423
+ function textFromLines(content: string, startLine: number, endLine: number): string {
320
424
  const spans = getLineSpans(content);
321
- const raw = content.slice(spans[startLine - 1].start, spans[endLine - 1].end);
322
- const text = raw.endsWith("\n") ? raw.slice(0, -1) : raw;
323
- return renderSnippet(text, `lines ${startLine}-${endLine}`);
425
+ return content.slice(spans[startLine - 1].start, spans[endLine - 1].end);
324
426
  }
325
427
 
326
- function renderSnippet(text: string, where: string): string[] {
327
- const fence = fenceFor(text);
328
- const allLines = text.split("\n");
329
- if (allLines.length <= MAX_SNIPPET_LINES) {
330
- return [fence, text, fence];
428
+ function isSnippetRenderable(text: string): boolean {
429
+ return text.split("\n").length <= MAX_SNIPPET_LINES && Buffer.byteLength(text, "utf8") <= MAX_SNIPPET_BYTES;
430
+ }
431
+
432
+ /** Render only complete snippets. Callers must check isSnippetRenderable first. */
433
+ function renderSnippet(text: string): string[] {
434
+ if (!isSnippetRenderable(text)) {
435
+ return ["[Exact snippet omitted because it exceeds the safe output limit; read the referenced range first.]" ];
331
436
  }
332
- const head = allLines.slice(0, MAX_SNIPPET_LINES - 10);
333
- const tail = allLines.slice(-10);
334
- const skipped = allLines.length - head.length - tail.length;
335
- return [
336
- fence,
337
- ...head,
338
- `… (${skipped} middle lines snipped — see ${where}; re-read that range if you need the full text)`,
339
- ...tail,
340
- fence,
341
- ];
437
+ const fence = fenceFor(text);
438
+ return [fence, text, fence];
342
439
  }
343
440
 
344
441
  /** Choose a fence longer than any backtick run inside the snippet. */
package/src/index.ts CHANGED
@@ -2,10 +2,9 @@
2
2
  * pi-better-tool — better built-in tools for the pi coding agent.
3
3
  *
4
4
  * Currently ships one override:
5
- * - `edit` — identical matching semantics to the built-in edit tool, but
6
- * failures return recovery context (closest matching region, per-occurrence
7
- * minimal disambiguation snippets) so the agent can retry immediately
8
- * without re-reading the file.
5
+ * - `edit` — built-in-compatible exact replacement with richer recovery
6
+ * context and conservative read-based resolution when a recent verified
7
+ * read contains exactly one of several literal occurrences.
9
8
  */
10
9
 
11
10
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
@@ -13,9 +12,15 @@ import { registerBetterEditTool } from "./tool.ts";
13
12
 
14
13
  export { registerBetterEditTool, executeBetterEdit, prepareEditArguments, betterEditSchema } from "./tool.ts";
15
14
  export type { BetterEditInput } from "./tool.ts";
16
- export { formatEditFailure, findMinimalUniqueExpansion, fenceFor } from "./diagnostics.ts";
15
+ export {
16
+ formatAutoDisambiguationSuccess,
17
+ formatEditFailure,
18
+ findMinimalUniqueExpansion,
19
+ fenceFor,
20
+ } from "./diagnostics.ts";
21
+ export type { AutoDisambiguation } from "./diagnostics.ts";
17
22
  export { analyzeEdits, applyAnalysis } from "./apply.ts";
18
- export type { EditFailure, EditOp, EditAnalysis } from "./apply.ts";
23
+ export type { AnalyzeOptions, EditFailure, EditOp, EditAnalysis } from "./apply.ts";
19
24
 
20
25
  export default function (pi: ExtensionAPI) {
21
26
  registerBetterEditTool(pi);
@@ -0,0 +1,150 @@
1
+ import {
2
+ DEFAULT_MAX_BYTES,
3
+ DEFAULT_MAX_LINES,
4
+ formatSize,
5
+ truncateHead,
6
+ type ExtensionContext,
7
+ } from "@earendil-works/pi-coding-agent";
8
+ import { normalizeToLF } from "./text.ts";
9
+
10
+ export interface ReadEvidence {
11
+ /** 0-based, end-exclusive offsets in LF-normalized, BOM-stripped content. */
12
+ startOffset: number;
13
+ endOffset: number;
14
+ /** 1-based inclusive range actually shown to the model. */
15
+ startLine: number;
16
+ endLine: number;
17
+ }
18
+
19
+ interface ReadCall {
20
+ path: string;
21
+ offset?: number;
22
+ limit?: number;
23
+ }
24
+
25
+ interface StoredToolResult {
26
+ toolCallId: string;
27
+ toolName: string;
28
+ isError: boolean;
29
+ content: Array<{ type: string; text?: string }>;
30
+ }
31
+
32
+ /**
33
+ * Find the newest successful read of targetPath still present in the active
34
+ * model context. The returned range is accepted only when the stored tool
35
+ * output exactly matches what the built-in read tool would produce from the
36
+ * current file bytes. This makes stale or custom read output fail closed.
37
+ */
38
+ export async function findLatestReadEvidence(
39
+ sessionManager: Pick<ExtensionContext["sessionManager"], "buildContextEntries"> | undefined,
40
+ targetPath: string,
41
+ normalizedContent: string,
42
+ resolvePath: (path: string) => Promise<string>,
43
+ ): Promise<ReadEvidence | null> {
44
+ if (!sessionManager) return null;
45
+ const entries = sessionManager.buildContextEntries();
46
+ const calls = new Map<string, ReadCall>();
47
+
48
+ for (const entry of entries) {
49
+ if (entry.type !== "message" || entry.message.role !== "assistant") continue;
50
+ for (const item of entry.message.content) {
51
+ if (item.type !== "toolCall" || item.name !== "read") continue;
52
+ const args = item.arguments as Record<string, unknown>;
53
+ if (typeof args.path !== "string") continue;
54
+ calls.set(item.id, {
55
+ path: args.path,
56
+ offset: typeof args.offset === "number" ? args.offset : undefined,
57
+ limit: typeof args.limit === "number" ? args.limit : undefined,
58
+ });
59
+ }
60
+ }
61
+
62
+ for (let i = entries.length - 1; i >= 0; i--) {
63
+ const entry = entries[i];
64
+ if (entry.type !== "message" || entry.message.role !== "toolResult") continue;
65
+ const result = entry.message as StoredToolResult;
66
+ if (result.toolName !== "read" || result.isError) continue;
67
+ const call = calls.get(result.toolCallId);
68
+ if (!call) continue;
69
+
70
+ let readPath: string;
71
+ try {
72
+ readPath = await resolvePath(call.path);
73
+ } catch {
74
+ continue;
75
+ }
76
+ if (readPath !== targetPath) continue;
77
+
78
+ // This is the latest successful read of this file. If it cannot be
79
+ // verified, do not silently fall back to older, potentially stale intent.
80
+ return evidenceFromBuiltinRead(normalizedContent, call, result.content);
81
+ }
82
+ return null;
83
+ }
84
+
85
+ function evidenceFromBuiltinRead(
86
+ content: string,
87
+ call: ReadCall,
88
+ blocks: Array<{ type: string; text?: string }>,
89
+ ): ReadEvidence | null {
90
+ if (blocks.length !== 1 || blocks[0].type !== "text" || typeof blocks[0].text !== "string") return null;
91
+ if (call.offset !== undefined && (!Number.isInteger(call.offset) || call.offset < 1)) return null;
92
+ if (call.limit !== undefined && (!Number.isInteger(call.limit) || call.limit < 1)) return null;
93
+ const actualOutput = normalizeToLF(blocks[0].text);
94
+ const allLines = content.split("\n");
95
+ const startIndex = call.offset ? Math.max(0, call.offset - 1) : 0;
96
+ if (startIndex >= allLines.length) return null;
97
+
98
+ let selectedContent: string;
99
+ let userLimitedLines: number | undefined;
100
+ if (call.limit !== undefined) {
101
+ const endIndex = Math.min(startIndex + call.limit, allLines.length);
102
+ selectedContent = allLines.slice(startIndex, endIndex).join("\n");
103
+ userLimitedLines = endIndex - startIndex;
104
+ } else {
105
+ selectedContent = allLines.slice(startIndex).join("\n");
106
+ }
107
+
108
+ const truncation = truncateHead(selectedContent);
109
+ if (truncation.firstLineExceedsLimit) return null;
110
+
111
+ let expectedOutput = truncation.content;
112
+ let visibleLines = truncation.outputLines;
113
+ if (truncation.truncated) {
114
+ const startLine = startIndex + 1;
115
+ const endLine = startLine + truncation.outputLines - 1;
116
+ const nextOffset = endLine + 1;
117
+ if (truncation.truncatedBy === "lines") {
118
+ expectedOutput += `\n\n[Showing lines ${startLine}-${endLine} of ${allLines.length}. Use offset=${nextOffset} to continue.]`;
119
+ } else {
120
+ expectedOutput += `\n\n[Showing lines ${startLine}-${endLine} of ${allLines.length} (${formatSize(DEFAULT_MAX_BYTES)} limit). Use offset=${nextOffset} to continue.]`;
121
+ }
122
+ } else if (userLimitedLines !== undefined && startIndex + userLimitedLines < allLines.length) {
123
+ const remaining = allLines.length - (startIndex + userLimitedLines);
124
+ const nextOffset = startIndex + userLimitedLines + 1;
125
+ expectedOutput += `\n\n[${remaining} more lines in file. Use offset=${nextOffset} to continue.]`;
126
+ visibleLines = userLimitedLines;
127
+ }
128
+
129
+ if (actualOutput !== expectedOutput || visibleLines <= 0 || visibleLines > DEFAULT_MAX_LINES) return null;
130
+
131
+ const startLine = startIndex + 1;
132
+ const endLine = startLine + visibleLines - 1;
133
+ const startOffset = offsetAtLine(content, startLine);
134
+ const endOffset = offsetAtLine(content, endLine + 1);
135
+ return { startOffset, endOffset, startLine, endLine };
136
+ }
137
+
138
+ /** Offset of a 1-based line; the line after EOF maps to content.length. */
139
+ function offsetAtLine(content: string, line: number): number {
140
+ if (line <= 1) return 0;
141
+ let currentLine = 1;
142
+ let offset = 0;
143
+ while (currentLine < line) {
144
+ const newline = content.indexOf("\n", offset);
145
+ if (newline === -1) return content.length;
146
+ offset = newline + 1;
147
+ currentLine++;
148
+ }
149
+ return offset;
150
+ }
package/src/similarity.ts CHANGED
@@ -1,7 +1,6 @@
1
1
  /**
2
- * Fuzzy line similarity used to locate the region of the file that a failed
3
- * oldText *almost* matched, so the tool can show the model the actual bytes
4
- * instead of forcing a full re-read.
2
+ * Bounded fuzzy line similarity used to locate the region of a file that a
3
+ * failed oldText almost matched.
5
4
  */
6
5
 
7
6
  import { toFuzzyLines } from "./text.ts";
@@ -19,7 +18,7 @@ export interface AlignOp {
19
18
  export interface ClosestRegion {
20
19
  startLine: number;
21
20
  endLine: number;
22
- /** 0..1 mean line similarity. */
21
+ /** 0..1 order-preserving, one-to-one line similarity. */
23
22
  score: number;
24
23
  ops: AlignOp[];
25
24
  equalCount: number;
@@ -28,146 +27,151 @@ export interface ClosestRegion {
28
27
  truncated: boolean;
29
28
  }
30
29
 
30
+ interface PreparedLine {
31
+ text: string;
32
+ grams: Map<string, number>;
33
+ gramCount: number;
34
+ }
35
+
36
+ function prepareLine(text: string): PreparedLine {
37
+ const grams = new Map<string, number>();
38
+ for (let i = 0; i + 1 < text.length; i++) {
39
+ const gram = text.slice(i, i + 2);
40
+ grams.set(gram, (grams.get(gram) ?? 0) + 1);
41
+ }
42
+ return { text, grams, gramCount: Math.max(0, text.length - 1) };
43
+ }
44
+
31
45
  /** Dice coefficient over character bigrams; 1.0 for identical strings. */
32
46
  export function lineSimilarity(a: string, b: string): number {
33
- if (a === b) return 1;
34
- if (a.length < 2 || b.length < 2) return 0;
35
- const gramsA = bigramCounts(a);
36
- const gramsB = bigramCounts(b);
37
- let overlap = 0;
38
- let total = 0;
39
- for (const count of gramsA.values()) total += count;
40
- for (const count of gramsB.values()) total += count;
41
- for (const [gram, countA] of gramsA) {
42
- const countB = gramsB.get(gram);
43
- if (countB) overlap += Math.min(countA, countB);
44
- }
45
- return (2 * overlap) / total;
47
+ return preparedSimilarity(prepareLine(a), prepareLine(b));
46
48
  }
47
49
 
48
- function bigramCounts(s: string): Map<string, number> {
49
- const counts = new Map<string, number>();
50
- for (let i = 0; i + 1 < s.length; i++) {
51
- const gram = s.slice(i, i + 2);
52
- counts.set(gram, (counts.get(gram) ?? 0) + 1);
50
+ function preparedSimilarity(a: PreparedLine, b: PreparedLine): number {
51
+ if (a.text === b.text) return 1;
52
+ if (a.gramCount === 0 || b.gramCount === 0) return 0;
53
+ const [small, large] = a.grams.size <= b.grams.size ? [a.grams, b.grams] : [b.grams, a.grams];
54
+ let overlap = 0;
55
+ for (const [gram, count] of small) {
56
+ const other = large.get(gram);
57
+ if (other) overlap += Math.min(count, other);
53
58
  }
54
- return counts;
59
+ return (2 * overlap) / (a.gramCount + b.gramCount);
55
60
  }
56
61
 
57
62
  const MAX_COMPARE_LINES = 300;
58
- const PREFILTER_FILE_LINES = 2000;
63
+ const MAX_SEARCH_FILE_LINES = 10_000;
64
+ const MAX_CANDIDATE_WINDOWS = 24;
59
65
  const MIN_SCORE = 0.3;
60
66
  const MATCH_THRESHOLD = 0.75;
61
67
 
68
+ interface CandidateWindow {
69
+ start: number;
70
+ size: number;
71
+ positionalScore: number;
72
+ }
73
+
62
74
  /**
63
75
  * Find the window of file lines most similar to oldText.
64
- * Compares window sizes of len-1, len, len+1 so a single extra/missing line
65
- * still lands the search on the right region.
76
+ *
77
+ * The first pass ranks every window using cheap positional similarity. Only a
78
+ * small bounded set of candidates receives the more expensive sequence score.
79
+ * The sequence score is order-preserving and one-to-one, so repeated query
80
+ * lines cannot all claim the same file line.
66
81
  */
67
82
  export function findClosestRegion(content: string, oldText: string): ClosestRegion | null {
68
83
  const allQLines = toFuzzyLines(oldText);
69
84
  const cLines = toFuzzyLines(content);
70
- if (allQLines.length === 0 || cLines.length === 0) return null;
85
+ if (allQLines.length === 0 || cLines.length === 0 || cLines.length > MAX_SEARCH_FILE_LINES) return null;
71
86
 
72
87
  const truncated = allQLines.length > MAX_COMPARE_LINES;
73
88
  const q = truncated ? allQLines.slice(0, MAX_COMPARE_LINES) : allQLines;
74
89
  const L = q.length;
75
-
76
- const sizes = [...new Set([L - 1, L, L + 1].filter((s) => s >= 1 && s <= cLines.length))];
77
- const bigFile = cLines.length > PREFILTER_FILE_LINES;
78
-
79
- let bestScore = -1;
80
- let bestStart = -1;
81
- let bestSize = L;
82
-
83
- for (const w of sizes) {
84
- for (let s = 0; s + w <= cLines.length; s++) {
85
- if (bigFile && !prefilter(cLines, q, s, w, L)) continue;
86
- const score = scoreWindow(cLines, q, s, w, L);
87
- if (score > bestScore) {
88
- bestScore = score;
89
- bestStart = s;
90
- bestSize = w;
90
+ const sizes = [...new Set([L - 1, L, L + 1].filter((size) => size >= 1 && size <= cLines.length))];
91
+ const preparedQ = q.map(prepareLine);
92
+ const preparedContent = cLines.map(prepareLine);
93
+ const candidates: CandidateWindow[] = [];
94
+
95
+ for (const size of sizes) {
96
+ for (let start = 0; start + size <= cLines.length; start++) {
97
+ const pairs = Math.min(L, size);
98
+ let sum = 0;
99
+ for (let i = 0; i < pairs; i++) {
100
+ sum += preparedSimilarity(preparedQ[i], preparedContent[start + i]);
91
101
  }
102
+ keepCandidate(candidates, { start, size, positionalScore: sum / Math.max(L, size) });
92
103
  }
93
104
  }
94
105
 
95
- if (bestStart < 0 || bestScore < MIN_SCORE) return null;
106
+ let bestScore = -1;
107
+ let best: CandidateWindow | undefined;
108
+ for (const candidate of candidates) {
109
+ const window = preparedContent.slice(candidate.start, candidate.start + candidate.size);
110
+ const score = sequenceSimilarity(preparedQ, window);
111
+ if (score > bestScore || (score === bestScore && candidate.positionalScore > (best?.positionalScore ?? -1))) {
112
+ bestScore = score;
113
+ best = candidate;
114
+ }
115
+ }
116
+ if (!best || bestScore < MIN_SCORE) return null;
96
117
 
97
- const windowLines = cLines.slice(bestStart, bestStart + bestSize);
98
- const ops = alignLines(q, windowLines, bestStart);
99
- const equalCount = ops.filter((op) => op.type === "equal").length;
118
+ const windowLines = cLines.slice(best.start, best.start + best.size);
119
+ const ops = alignLines(q, windowLines, best.start);
100
120
  return {
101
- startLine: bestStart + 1,
102
- endLine: bestStart + bestSize,
121
+ startLine: best.start + 1,
122
+ endLine: best.start + best.size,
103
123
  score: bestScore,
104
124
  ops,
105
- equalCount,
125
+ equalCount: ops.filter((op) => op.type === "equal").length,
106
126
  totalOldLines: L,
107
127
  truncated,
108
128
  };
109
129
  }
110
130
 
111
- function scoreWindow(cLines: string[], q: string[], start: number, w: number, L: number): number {
112
- // Positional score: strict index-by-index pairing (handles indentation /
113
- // trailing-whitespace drift).
114
- const pairs = Math.min(L, w);
115
- let positionalSum = 0;
116
- for (let i = 0; i < pairs; i++) {
117
- positionalSum += lineSimilarity(q[i], cLines[start + i]);
118
- }
119
- const positional = positionalSum / Math.max(L, w);
120
-
121
- // Bag score: best match per oldText line anywhere in the window (handles a
122
- // single inserted/removed line in the middle that breaks positional pairing).
123
- let bagSum = 0;
124
- for (let i = 0; i < L; i++) {
125
- let best = 0;
126
- for (let j = start; j < start + w; j++) {
127
- const sim = lineSimilarity(q[i], cLines[j]);
128
- if (sim > best) best = sim;
129
- if (best === 1) break;
130
- }
131
- bagSum += best;
131
+ function keepCandidate(candidates: CandidateWindow[], candidate: CandidateWindow): void {
132
+ if (candidates.length < MAX_CANDIDATE_WINDOWS) {
133
+ candidates.push(candidate);
134
+ candidates.sort((a, b) => a.positionalScore - b.positionalScore);
135
+ return;
132
136
  }
133
- return Math.max(positional, (bagSum / L) * 0.95);
137
+ if (candidate.positionalScore <= candidates[0].positionalScore) return;
138
+ candidates[0] = candidate;
139
+ candidates.sort((a, b) => a.positionalScore - b.positionalScore);
134
140
  }
135
141
 
136
- function prefilter(cLines: string[], q: string[], s: number, w: number, L: number): boolean {
137
- const mid = Math.floor(L / 2);
138
- const probes: Array<[number, number]> = [
139
- [s, 0],
140
- [s, L - 1],
141
- [s, mid],
142
- [s + w - 1, L - 1],
143
- [s + w - 1, 0],
144
- [s + w - 1, mid],
145
- [s + Math.floor(w / 2), mid],
146
- ];
147
- for (const [ci, qi] of probes) {
148
- if (ci >= 0 && ci < cLines.length && qi >= 0 && qi < L) {
149
- if (lineSimilarity(cLines[ci], q[qi]) >= 0.45) return true;
142
+ /** Weighted LCS: lines are matched at most once and in source order. */
143
+ function sequenceSimilarity(query: PreparedLine[], window: PreparedLine[]): number {
144
+ let previous = new Float64Array(window.length + 1);
145
+ for (let i = 1; i <= query.length; i++) {
146
+ const current = new Float64Array(window.length + 1);
147
+ for (let j = 1; j <= window.length; j++) {
148
+ const matched = previous[j - 1] + preparedSimilarity(query[i - 1], window[j - 1]);
149
+ current[j] = Math.max(previous[j], current[j - 1], matched);
150
150
  }
151
+ previous = current;
151
152
  }
152
- return false;
153
+ return previous[window.length] / Math.max(query.length, window.length);
153
154
  }
154
155
 
155
156
  /**
156
- * LCS-style alignment between oldText lines and the best window.
157
- * Lines with similarity >= MATCH_THRESHOLD count as equal; adjacent
158
- * old-only/file-only runs are merged into "changed" pairs.
157
+ * LCS-style alignment between oldText lines and the best window. Lines with
158
+ * similarity >= MATCH_THRESHOLD count as equal; adjacent insert/delete pairs
159
+ * are merged into changed operations for compact diagnostics.
159
160
  */
160
161
  function alignLines(q: string[], w: string[], wOffset: number): AlignOp[] {
161
162
  const n = q.length;
162
163
  const m = w.length;
164
+ const preparedQ = q.map(prepareLine);
165
+ const preparedW = w.map(prepareLine);
166
+ const similarities = Array.from({ length: n }, (_, i) =>
167
+ Array.from({ length: m }, (_, j) => preparedSimilarity(preparedQ[i], preparedW[j])),
168
+ );
163
169
  const dp: number[][] = Array.from({ length: n + 1 }, () => new Array<number>(m + 1).fill(0));
164
170
  for (let i = n - 1; i >= 0; i--) {
165
171
  for (let j = m - 1; j >= 0; j--) {
166
- if (lineSimilarity(q[i], w[j]) >= MATCH_THRESHOLD) {
167
- dp[i][j] = dp[i + 1][j + 1] + 1;
168
- } else {
169
- dp[i][j] = Math.max(dp[i + 1][j], dp[i][j + 1]);
170
- }
172
+ dp[i][j] = similarities[i][j] >= MATCH_THRESHOLD
173
+ ? dp[i + 1][j + 1] + 1
174
+ : Math.max(dp[i + 1][j], dp[i][j + 1]);
171
175
  }
172
176
  }
173
177
 
@@ -175,54 +179,32 @@ function alignLines(q: string[], w: string[], wOffset: number): AlignOp[] {
175
179
  let i = 0;
176
180
  let j = 0;
177
181
  while (i < n && j < m) {
178
- if (lineSimilarity(q[i], w[j]) >= MATCH_THRESHOLD) {
182
+ if (similarities[i][j] >= MATCH_THRESHOLD) {
179
183
  raw.push({ type: "equal", fileLine: wOffset + j + 1, fileText: w[j], oldLine: i + 1, oldText: q[i] });
180
184
  i++;
181
185
  j++;
182
186
  } else if (dp[i + 1][j] > dp[i][j + 1]) {
183
- // Strictly better to skip the oldText line; on ties prefer skipping
184
- // the file line so inserted file lines surface as file-only ops.
185
- raw.push({ type: "old-only", oldLine: i + 1, oldText: q[i] });
186
- i++;
187
+ raw.push({ type: "old-only", oldLine: i + 1, oldText: q[i++] });
187
188
  } else {
188
- raw.push({ type: "file-only", fileLine: wOffset + j + 1, fileText: w[j] });
189
- j++;
189
+ raw.push({ type: "file-only", fileLine: wOffset + j + 1, fileText: w[j++] });
190
190
  }
191
191
  }
192
- while (i < n) {
193
- raw.push({ type: "old-only", oldLine: i + 1, oldText: q[i] });
194
- i++;
195
- }
196
- while (j < m) {
197
- raw.push({ type: "file-only", fileLine: wOffset + j + 1, fileText: w[j] });
198
- j++;
199
- }
192
+ while (i < n) raw.push({ type: "old-only", oldLine: i + 1, oldText: q[i++] });
193
+ while (j < m) raw.push({ type: "file-only", fileLine: wOffset + j + 1, fileText: w[j++] });
200
194
 
201
- // Merge adjacent old-only/file-only runs into pairwise "changed" ops.
202
195
  const merged: AlignOp[] = [];
203
196
  for (let k = 0; k < raw.length; k++) {
204
197
  const op = raw[k];
205
- if (op.type === "old-only") {
206
- const next = raw[k + 1];
207
- if (next && next.type === "file-only") {
208
- merged.push({ type: "changed", fileLine: next.fileLine, fileText: next.fileText, oldLine: op.oldLine, oldText: op.oldText });
209
- k++;
210
- continue;
211
- }
212
- merged.push(op);
213
- continue;
214
- }
215
- if (op.type === "file-only") {
216
- const next = raw[k + 1];
217
- if (next && next.type === "old-only") {
218
- merged.push({ type: "changed", fileLine: op.fileLine, fileText: op.fileText, oldLine: next.oldLine, oldText: next.oldText });
219
- k++;
220
- continue;
221
- }
198
+ const next = raw[k + 1];
199
+ if (op.type === "old-only" && next?.type === "file-only") {
200
+ merged.push({ type: "changed", fileLine: next.fileLine, fileText: next.fileText, oldLine: op.oldLine, oldText: op.oldText });
201
+ k++;
202
+ } else if (op.type === "file-only" && next?.type === "old-only") {
203
+ merged.push({ type: "changed", fileLine: op.fileLine, fileText: op.fileText, oldLine: next.oldLine, oldText: next.oldText });
204
+ k++;
205
+ } else {
222
206
  merged.push(op);
223
- continue;
224
207
  }
225
- merged.push(op);
226
208
  }
227
209
  return merged;
228
210
  }
@@ -230,20 +212,15 @@ function alignLines(q: string[], w: string[], wOffset: number): AlignOp[] {
230
212
  /** Cheap probes for common causes of a failed match. */
231
213
  export function probeMatchCauses(content: string, oldText: string): string[] {
232
214
  const causes: string[] = [];
233
- const stripWS = (s: string) => s.replace(/\s+/g, "");
215
+ const stripWS = (value: string) => value.replace(/\s+/g, "");
234
216
  if (stripWS(content).includes(stripWS(oldText))) {
235
- causes.push(
236
- "whitespace mismatch: the text matches when ALL whitespace is removed — check tabs vs spaces and indentation width",
237
- );
217
+ causes.push("whitespace mismatch: the text matches when ALL whitespace is removed — check tabs vs spaces and indentation width");
238
218
  } else if (content.toLowerCase().includes(oldText.toLowerCase())) {
239
219
  causes.push("letter-case mismatch: the text matches case-insensitively");
240
220
  }
241
221
  const fileHasTabs = content.includes("\t");
242
222
  const oldHasTabs = oldText.includes("\t");
243
- if (fileHasTabs && !oldHasTabs) {
244
- causes.push("the file contains tab characters but your oldText uses spaces");
245
- } else if (!fileHasTabs && oldHasTabs) {
246
- causes.push("your oldText contains tab characters but the file uses spaces");
247
- }
223
+ if (fileHasTabs && !oldHasTabs) causes.push("the file contains tab characters but your oldText uses spaces");
224
+ else if (!fileHasTabs && oldHasTabs) causes.push("your oldText contains tab characters but the file uses spaces");
248
225
  return causes;
249
226
  }
package/src/tool.ts CHANGED
@@ -7,8 +7,9 @@
7
7
  * not find edits[1]" error that forces the model to re-read the file and
8
8
  * guess at larger context, failures include recovery context:
9
9
  *
10
- * - ambiguous oldText → occurrence line numbers + the minimal prefix/suffix
11
- * context that disambiguates each occurrence, as copy-paste-ready snippets
10
+ * - ambiguous literal oldText → if the latest verified read of the file shows
11
+ * exactly one occurrence, select it and report up to four retryable remaining
12
+ * candidates; otherwise return occurrence line numbers + minimal context
12
13
  * - not-found oldText → closest matching region with a per-line comparison
13
14
  * and the exact file bytes to retry with
14
15
  */
@@ -22,13 +23,27 @@ import {
22
23
  type ExtensionContext,
23
24
  } from "@earendil-works/pi-coding-agent";
24
25
  import { constants } from "node:fs";
25
- import { access as fsAccess, readFile as fsReadFile, writeFile as fsWriteFile } from "node:fs/promises";
26
+ import { access as fsAccess, readFile as fsReadFile, realpath as fsRealpath, writeFile as fsWriteFile } from "node:fs/promises";
26
27
  import { homedir } from "node:os";
27
- import { resolve } from "node:path";
28
+ import { isAbsolute, join, resolve } from "node:path";
29
+ import { fileURLToPath } from "node:url";
28
30
  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";
31
+ import { analyzeEdits, applyAnalysis, fuzzyFindText, normalizeEdits, type EditOp } from "./apply.ts";
32
+ import {
33
+ formatAutoDisambiguationSuccess,
34
+ formatEditFailure,
35
+ type AutoDisambiguation,
36
+ } from "./diagnostics.ts";
37
+ import { findLatestReadEvidence } from "./read-evidence.ts";
38
+ import {
39
+ detectLineEnding,
40
+ findAllOccurrences,
41
+ getLineSpans,
42
+ lineAt,
43
+ normalizeToLF,
44
+ restoreLineEndings,
45
+ splitBom,
46
+ } from "./text.ts";
32
47
 
33
48
  const replaceEditSchema = Type.Object({
34
49
  oldText: Type.String({
@@ -48,14 +63,29 @@ export const betterEditSchema = Type.Object({
48
63
 
49
64
  export type BetterEditInput = Static<typeof betterEditSchema>;
50
65
 
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));
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("/", "\\") ?? ""}`;
57
76
  }
58
- return resolve(cwd, path);
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);
59
89
  }
60
90
 
61
91
  function isSingleEditInput(value: unknown): value is { oldText: string; newText: string } {
@@ -114,7 +144,7 @@ export interface BetterEditSuccess {
114
144
  export async function executeBetterEdit(
115
145
  input: BetterEditInput,
116
146
  signal: AbortSignal | undefined,
117
- ctx: Pick<ExtensionContext, "cwd">,
147
+ ctx: Pick<ExtensionContext, "cwd"> & Partial<Pick<ExtensionContext, "sessionManager">>,
118
148
  ): Promise<BetterEditSuccess> {
119
149
  const edits: EditOp[] = input.edits ?? [];
120
150
  if (!Array.isArray(edits) || edits.length === 0) {
@@ -150,7 +180,56 @@ export async function executeBetterEdit(
150
180
  const originalEnding = detectLineEnding(content);
151
181
  const normalizedContent = normalizeToLF(content);
152
182
 
153
- const result = analyzeEdits(normalizedContent, edits);
183
+ const selections = new Map<number, number>();
184
+ const resolutions: AutoDisambiguation[] = [];
185
+ let result = analyzeEdits(normalizedContent, edits, { ambiguousSelections: selections });
186
+ let readEvidence: Awaited<ReturnType<typeof findLatestReadEvidence>> | undefined;
187
+ const normalizedEdits = normalizeEdits(edits);
188
+ const exactBase = normalizedEdits.every(
189
+ (edit) => !fuzzyFindText(normalizedContent, edit.oldText).usedFuzzyMatch,
190
+ );
191
+
192
+ while (!result.ok && result.failure.kind === "ambiguous" && exactBase) {
193
+ const editIndex = result.failure.editIndex;
194
+ if (selections.has(editIndex)) break;
195
+ const edit = normalizedEdits[editIndex];
196
+ const exactOffsets = findAllOccurrences(normalizedContent, edit.oldText);
197
+ // Fuzzy-equivalent aliases cannot be mapped safely to original offsets.
198
+ if (exactOffsets.length !== result.failure.occurrenceOffsets.length) break;
199
+
200
+ if (readEvidence === undefined) {
201
+ const canonicalTarget = await fsRealpath(absolutePath);
202
+ readEvidence = await findLatestReadEvidence(
203
+ ctx.sessionManager,
204
+ canonicalTarget,
205
+ normalizedContent,
206
+ async (readPath) => fsRealpath(resolveToCwd(readPath, ctx.cwd)),
207
+ );
208
+ }
209
+ if (!readEvidence) break;
210
+
211
+ const candidates = exactOffsets.filter(
212
+ (offset) =>
213
+ offset >= readEvidence!.startOffset &&
214
+ offset + edit.oldText.length <= readEvidence!.endOffset,
215
+ );
216
+ if (candidates.length !== 1) break;
217
+
218
+ const selectedOffset = candidates[0];
219
+ const spans = getLineSpans(normalizedContent);
220
+ selections.set(editIndex, selectedOffset);
221
+ resolutions.push({
222
+ editIndex,
223
+ oldText: edit.oldText,
224
+ chosenRange: {
225
+ start: lineAt(spans, selectedOffset) + 1,
226
+ end: lineAt(spans, selectedOffset + Math.max(1, edit.oldText.length) - 1) + 1,
227
+ },
228
+ readRange: { start: readEvidence.startLine, end: readEvidence.endLine },
229
+ });
230
+ result = analyzeEdits(normalizedContent, edits, { ambiguousSelections: selections });
231
+ }
232
+
154
233
  if (!result.ok) {
155
234
  throw new Error(
156
235
  formatEditFailure({
@@ -185,7 +264,11 @@ export async function executeBetterEdit(
185
264
  content: [
186
265
  {
187
266
  type: "text",
188
- text: `Successfully replaced ${edits.length} block(s) in ${input.path}.`,
267
+ text: formatAutoDisambiguationSuccess(
268
+ `Successfully replaced ${edits.length} block(s) in ${input.path}.`,
269
+ newContent,
270
+ resolutions,
271
+ ),
189
272
  },
190
273
  ],
191
274
  details: {
@@ -202,7 +285,7 @@ export function registerBetterEditTool(pi: ExtensionAPI): void {
202
285
  name: "edit",
203
286
  label: "edit",
204
287
  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.",
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.",
206
289
  promptSnippet:
207
290
  "Make precise file edits with exact text replacement; failures return recovery context (closest match or disambiguation snippets)",
208
291
  promptGuidelines: [
@@ -210,6 +293,7 @@ export function registerBetterEditTool(pi: ExtensionAPI): void {
210
293
  "When changing multiple separate locations in one file, use one edit call with multiple entries in edits[] instead of multiple edit calls",
211
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.",
212
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.",
213
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.",
214
298
  ],
215
299
  parameters: betterEditSchema,