@khanhicetea/pi-better-tool 0.1.0

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.
@@ -0,0 +1,350 @@
1
+ /**
2
+ * Failure diagnostics for the better edit tool.
3
+ *
4
+ * Turns structured matching failures into recovery context the model can act
5
+ * on immediately, without re-reading the file:
6
+ *
7
+ * - ambiguous oldText → every occurrence's line number plus the *minimal*
8
+ * prefix/suffix context expansion that makes that occurrence unique,
9
+ * rendered as a ready-to-use oldText snippet
10
+ * - not-found oldText → the closest matching region (fuzzy line similarity),
11
+ * a per-line comparison, and the exact file bytes to retry with
12
+ */
13
+
14
+ import type { EditFailure, EditOp, LineRange } from "./apply.ts";
15
+ import { normalizeEdits } from "./apply.ts";
16
+ import { findClosestRegion, lineSimilarity, probeMatchCauses } from "./similarity.ts";
17
+ import {
18
+ countFuzzyOccurrences,
19
+ getLineSpans,
20
+ lineAt,
21
+ normalizeForFuzzyMatch,
22
+ type LineSpan,
23
+ } from "./text.ts";
24
+
25
+ /** Skip expensive diagnostics for huge files; the plain error still works. */
26
+ const MAX_CONTENT_FOR_DIAGNOSTICS = 2_000_000;
27
+ /** Max total context lines tried when growing an occurrence to uniqueness. */
28
+ const MAX_TOTAL_CONTEXT_LINES = 12;
29
+ /** Occurrences listed with line numbers. */
30
+ const MAX_LISTED_OCCURRENCES = 8;
31
+ /** Occurrences that get a ready-to-use disambiguation snippet. */
32
+ const MAX_SNIPPET_OCCURRENCES = 3;
33
+ /** Max lines shown inside a snippet. */
34
+ const MAX_SNIPPET_LINES = 60;
35
+ /** Max non-equal alignment ops rendered. */
36
+ const MAX_DIFF_OPS_SHOWN = 12;
37
+
38
+ export interface Expansion {
39
+ /** Whole lines of prefix context included. */
40
+ prefixLines: number;
41
+ /** Whole lines of suffix context included. */
42
+ suffixLines: number;
43
+ /** Exact file bytes to use as the new oldText. */
44
+ text: string;
45
+ startLine: number;
46
+ endLine: number;
47
+ }
48
+
49
+ export interface FormatFailureOptions {
50
+ path: string;
51
+ /** LF-normalized, BOM-stripped file content. */
52
+ normalizedContent: string;
53
+ /** Raw edits as provided by the model. */
54
+ edits: EditOp[];
55
+ failure: EditFailure;
56
+ }
57
+
58
+ export function formatEditFailure(opts: FormatFailureOptions): string {
59
+ const { path, normalizedContent, edits, failure } = opts;
60
+ const total = edits.length;
61
+
62
+ switch (failure.kind) {
63
+ case "empty-old-text": {
64
+ return total === 1
65
+ ? `oldText must not be empty in ${path}.`
66
+ : `edits[${failure.editIndex}].oldText must not be empty in ${path}.`;
67
+ }
68
+
69
+ case "not-found": {
70
+ const head =
71
+ total === 1
72
+ ? `Could not find the exact text in ${path}. The old text must match exactly including all whitespace and newlines.`
73
+ : `Could not find edits[${failure.editIndex}] in ${path}. The oldText must match exactly including all whitespace and newlines.`;
74
+ const normalized = normalizeEdits(edits)[failure.editIndex];
75
+ const body =
76
+ normalizedContent.length <= MAX_CONTENT_FOR_DIAGNOSTICS
77
+ ? formatNotFound(opts, normalized.oldText)
78
+ : "File is too large for closest-match diagnostics; read the relevant region and retry.";
79
+ return `${head}\n\n${body}\n\nNo changes were written — the file was not modified.`;
80
+ }
81
+
82
+ case "ambiguous": {
83
+ const head =
84
+ total === 1
85
+ ? `Found ${failure.occurrenceOffsets.length} occurrences of the text in ${path}. The text must be unique. Please provide more context to make it unique.`
86
+ : `Found ${failure.occurrenceOffsets.length} occurrences of edits[${failure.editIndex}] in ${path}. Each oldText must be unique. Please provide more context to make it unique.`;
87
+ const body =
88
+ normalizedContent.length <= MAX_CONTENT_FOR_DIAGNOSTICS
89
+ ? formatAmbiguous(opts, failure)
90
+ : "File is too large for disambiguation diagnostics; read the relevant region and retry with more context.";
91
+ return `${head}\n\n${body}\n\nNo changes were written — the file was not modified.`;
92
+ }
93
+
94
+ case "overlap": {
95
+ const { firstEditIndex, secondEditIndex, firstRange, secondRange } = failure;
96
+ return `edits[${firstEditIndex}] and edits[${secondEditIndex}] overlap in ${path} (edits[${firstEditIndex}] covers lines ${firstRange.start}-${firstRange.end}, edits[${secondEditIndex}] covers lines ${secondRange.start}-${secondRange.end}). Merge them into one edit or target disjoint regions.`;
97
+ }
98
+
99
+ case "no-change": {
100
+ return total === 1
101
+ ? `No changes made to ${path}. The replacement produced identical content. This might indicate an issue with special characters or the text not existing as expected.`
102
+ : `No changes made to ${path}. The replacements produced identical content.`;
103
+ }
104
+ }
105
+ }
106
+
107
+ // ---------------------------------------------------------------------------
108
+ // Ambiguous: per-occurrence minimal prefix/suffix expansion
109
+ // ---------------------------------------------------------------------------
110
+
111
+ function formatAmbiguous(opts: FormatFailureOptions, failure: Extract<EditFailure, { kind: "ambiguous" }>): string {
112
+ const { normalizedContent } = opts;
113
+ const oldText = normalizeEdits(opts.edits)[failure.editIndex].oldText;
114
+ const fuzzyContent = normalizeForFuzzyMatch(normalizedContent);
115
+ const fuzzyOld = normalizeForFuzzyMatch(oldText);
116
+ const fuzzySpans = getLineSpans(fuzzyContent);
117
+ const spans = getLineSpans(normalizedContent);
118
+
119
+ const lines: string[] = [];
120
+ const listed = failure.occurrenceOffsets.slice(0, MAX_LISTED_OCCURRENCES);
121
+ lines.push("Occurrences:");
122
+ listed.forEach((offset, i) => {
123
+ const range = rangeFromOffset(fuzzySpans, offset, fuzzyOld.length);
124
+ lines.push(` ${i + 1}. ${describeLines(range.start, range.end)}`);
125
+ });
126
+ if (failure.occurrenceOffsets.length > listed.length) {
127
+ lines.push(` … and ${failure.occurrenceOffsets.length - listed.length} more`);
128
+ }
129
+ lines.push("");
130
+
131
+ 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:",
133
+ );
134
+
135
+ let shown = 0;
136
+ let missingExpansionNote = false;
137
+ for (let i = 0; i < listed.length && shown < MAX_SNIPPET_OCCURRENCES; i++) {
138
+ const offset = listed[i];
139
+ const range = rangeFromOffset(fuzzySpans, offset, fuzzyOld.length);
140
+ const expansion = findMinimalUniqueExpansion(normalizedContent, fuzzyContent, spans, range);
141
+ if (!expansion) {
142
+ missingExpansionNote = true;
143
+ continue;
144
+ }
145
+ shown++;
146
+ const where = `minimum context: ${plural(expansion.prefixLines, "line")} before, ${plural(expansion.suffixLines, "line")} after`;
147
+ lines.push("");
148
+ lines.push(`Occurrence ${i + 1} (${describeLines(range.start, range.end)}) — ${where}:`);
149
+ lines.push(...renderSnippet(expansion.text, `lines ${expansion.startLine}-${expansion.endLine}`));
150
+ }
151
+
152
+ if (missingExpansionNote) {
153
+ lines.push("");
154
+ lines.push(
155
+ `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
+ );
157
+ }
158
+ if (shown === 0 && !missingExpansionNote) {
159
+ lines.push("", "Extend oldText with more surrounding lines until it matches exactly one location.");
160
+ }
161
+ lines.push("");
162
+ 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).",
164
+ );
165
+ return lines.join("\n");
166
+ }
167
+
168
+ function rangeFromOffset(spans: LineSpan[], offset: number, length: number): LineRange {
169
+ return { start: lineAt(spans, offset) + 1, end: lineAt(spans, offset + Math.max(1, length) - 1) + 1 };
170
+ }
171
+
172
+ function describeLines(start: number, end: number): string {
173
+ return start === end ? `line ${start}` : `lines ${start}-${end}`;
174
+ }
175
+
176
+ function plural(n: number, noun: string): string {
177
+ return `${n} ${noun}${n === 1 ? "" : "s"}`;
178
+ }
179
+
180
+ /**
181
+ * Find the smallest whole-line context expansion of the occurrence at `range`
182
+ * whose text occurs exactly once in the file (checked in fuzzy space, exactly
183
+ * like the matching engine's uniqueness rule).
184
+ *
185
+ * The search starts from the occurrence's own full lines (zero extra context),
186
+ * which also covers duplicates that share a line: whenever any anchored
187
+ * substring of those lines could be unique, the full lines are unique too.
188
+ */
189
+ export function findMinimalUniqueExpansion(
190
+ content: string,
191
+ fuzzyContent: string,
192
+ spans: LineSpan[],
193
+ range: LineRange,
194
+ ): Expansion | null {
195
+ const totalLines = spans.length;
196
+ const maxPrefix = range.start - 1;
197
+ const maxSuffix = totalLines - range.end;
198
+
199
+ for (let total = 0; total <= MAX_TOTAL_CONTEXT_LINES; total++) {
200
+ for (let prefix = 0; prefix <= total; prefix++) {
201
+ const suffix = total - prefix;
202
+ if (prefix > maxPrefix || suffix > maxSuffix) continue;
203
+ const startLine = range.start - prefix;
204
+ const endLine = range.end + suffix;
205
+ const candidate = sliceLines(content, spans, startLine, endLine);
206
+ const verified = verifyUniqueSnippet(fuzzyContent, candidate);
207
+ if (verified) {
208
+ return {
209
+ prefixLines: prefix,
210
+ suffixLines: suffix,
211
+ text: verified,
212
+ startLine,
213
+ endLine,
214
+ };
215
+ }
216
+ }
217
+ }
218
+ return null;
219
+ }
220
+
221
+ function sliceLines(content: string, spans: LineSpan[], startLine: number, endLine: number): string {
222
+ return content.slice(spans[startLine - 1].start, spans[endLine - 1].end);
223
+ }
224
+
225
+ /**
226
+ * A snippet is handed to the model inside a fenced block; strip one trailing
227
+ * newline so copying is unambiguous, but only if the stripped version is still
228
+ * unique (a trailing newline can participate in uniqueness).
229
+ */
230
+ function verifyUniqueSnippet(fuzzyContent: string, candidate: string): string | null {
231
+ const stripped = candidate.endsWith("\n") ? candidate.slice(0, -1) : candidate;
232
+ if (countFuzzyOccurrences(fuzzyContent, stripped) === 1) return stripped;
233
+ if (stripped !== candidate && countFuzzyOccurrences(fuzzyContent, candidate) === 1) return candidate;
234
+ return null;
235
+ }
236
+
237
+ // ---------------------------------------------------------------------------
238
+ // Not-found: closest region + per-line comparison + exact bytes
239
+ // ---------------------------------------------------------------------------
240
+
241
+ function formatNotFound(opts: FormatFailureOptions, oldText: string): string {
242
+ const { normalizedContent } = opts;
243
+ const closest = findClosestRegion(normalizedContent, oldText);
244
+ const causes = probeMatchCauses(normalizedContent, oldText);
245
+
246
+ const lines: string[] = [];
247
+ if (closest) {
248
+ lines.push(
249
+ `Closest match in the file: ${describeLines(closest.startLine, closest.endLine)} (~${Math.round(closest.score * 100)}% line similarity${closest.truncated ? `, compared against the first ${closest.totalOldLines} lines of your oldText` : ""}).`,
250
+ );
251
+ const diffOps = closest.ops.filter((op) => op.type !== "equal");
252
+ if (diffOps.length === 0) {
253
+ // All compared lines passed the similarity threshold; surface the
254
+ // least-similar pair so small content drift (punctuation, extra
255
+ // characters) is not misreported as a pure whitespace issue.
256
+ let worst: { fileLine?: number; fileText?: string; oldLine?: number; oldText?: string; sim: number } | null = null;
257
+ for (const op of closest.ops) {
258
+ if (op.type !== "equal" || op.fileText === undefined || op.oldText === undefined) continue;
259
+ const sim = lineSimilarity(op.fileText, op.oldText);
260
+ if (!worst || sim < worst.sim) worst = { ...op, sim };
261
+ }
262
+ if (worst && worst.sim < 0.999) {
263
+ lines.push(
264
+ `No structural differences, but file line ${worst.fileLine} and your oldText line ${worst.oldLine} are only ~${Math.round(worst.sim * 100)}% similar:`,
265
+ );
266
+ lines.push(` file: ${truncateLine(worst.fileText ?? "")}`);
267
+ lines.push(` oldText: ${truncateLine(worst.oldText ?? "")}`);
268
+ } else {
269
+ lines.push(
270
+ "Every compared line matches individually — the mismatch is probably in line boundaries or trailing whitespace.",
271
+ );
272
+ }
273
+ } else {
274
+ lines.push(`Differences vs your oldText (${closest.equalCount} of ${closest.totalOldLines} compared lines match):`);
275
+ for (const op of diffOps.slice(0, MAX_DIFF_OPS_SHOWN)) {
276
+ if (op.type === "changed") {
277
+ lines.push(` file line ${op.fileLine} differs from your oldText line ${op.oldLine}:`);
278
+ lines.push(` file: ${truncateLine(op.fileText ?? "")}`);
279
+ lines.push(` oldText: ${truncateLine(op.oldText ?? "")}`);
280
+ } else if (op.type === "file-only") {
281
+ lines.push(
282
+ ` file line ${op.fileLine} is missing from your oldText: ${truncateLine(op.fileText ?? "")}`,
283
+ );
284
+ } else {
285
+ lines.push(` your oldText line ${op.oldLine} is not present in the file: ${truncateLine(op.oldText ?? "")}`);
286
+ }
287
+ }
288
+ if (diffOps.length > MAX_DIFF_OPS_SHOWN) {
289
+ lines.push(` … ${diffOps.length - MAX_DIFF_OPS_SHOWN} more differing lines`);
290
+ }
291
+ }
292
+ 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));
297
+ } else {
298
+ lines.push("No similar region was found in the file (best similarity below threshold).");
299
+ lines.push("If you expected this text to exist, read the file around the expected location and retry.");
300
+ }
301
+
302
+ if (causes.length > 0) {
303
+ lines.push("");
304
+ lines.push("Possible cause:");
305
+ for (const cause of causes) lines.push(`- ${cause}`);
306
+ }
307
+ return lines.join("\n");
308
+ }
309
+
310
+ function truncateLine(line: string): string {
311
+ const collapsed = line.replace(/\t/g, "→tab→");
312
+ return collapsed.length > 120 ? `${collapsed.slice(0, 117)}…` : collapsed;
313
+ }
314
+
315
+ // ---------------------------------------------------------------------------
316
+ // Snippet rendering
317
+ // ---------------------------------------------------------------------------
318
+
319
+ function snippetFromLines(content: string, startLine: number, endLine: number): string[] {
320
+ 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}`);
324
+ }
325
+
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];
331
+ }
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
+ ];
342
+ }
343
+
344
+ /** Choose a fence longer than any backtick run inside the snippet. */
345
+ export function fenceFor(text: string): string {
346
+ let max = 2;
347
+ const runs = text.match(/`{3,}/g) ?? [];
348
+ for (const run of runs) max = Math.max(max, run.length);
349
+ return "`".repeat(Math.max(3, max + 1));
350
+ }
package/src/index.ts ADDED
@@ -0,0 +1,22 @@
1
+ /**
2
+ * pi-better-tool — better built-in tools for the pi coding agent.
3
+ *
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.
9
+ */
10
+
11
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
12
+ import { registerBetterEditTool } from "./tool.ts";
13
+
14
+ export { registerBetterEditTool, executeBetterEdit, prepareEditArguments, betterEditSchema } from "./tool.ts";
15
+ export type { BetterEditInput } from "./tool.ts";
16
+ export { formatEditFailure, findMinimalUniqueExpansion, fenceFor } from "./diagnostics.ts";
17
+ export { analyzeEdits, applyAnalysis } from "./apply.ts";
18
+ export type { EditFailure, EditOp, EditAnalysis } from "./apply.ts";
19
+
20
+ export default function (pi: ExtensionAPI) {
21
+ registerBetterEditTool(pi);
22
+ }
@@ -0,0 +1,249 @@
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.
5
+ */
6
+
7
+ import { toFuzzyLines } from "./text.ts";
8
+
9
+ export interface AlignOp {
10
+ type: "equal" | "changed" | "file-only" | "old-only";
11
+ /** 1-based line number in the file (for equal/changed/file-only). */
12
+ fileLine?: number;
13
+ fileText?: string;
14
+ /** 1-based line number in the model-provided oldText. */
15
+ oldLine?: number;
16
+ oldText?: string;
17
+ }
18
+
19
+ export interface ClosestRegion {
20
+ startLine: number;
21
+ endLine: number;
22
+ /** 0..1 mean line similarity. */
23
+ score: number;
24
+ ops: AlignOp[];
25
+ equalCount: number;
26
+ totalOldLines: number;
27
+ /** oldText was truncated for comparison (very large oldText). */
28
+ truncated: boolean;
29
+ }
30
+
31
+ /** Dice coefficient over character bigrams; 1.0 for identical strings. */
32
+ 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;
46
+ }
47
+
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);
53
+ }
54
+ return counts;
55
+ }
56
+
57
+ const MAX_COMPARE_LINES = 300;
58
+ const PREFILTER_FILE_LINES = 2000;
59
+ const MIN_SCORE = 0.3;
60
+ const MATCH_THRESHOLD = 0.75;
61
+
62
+ /**
63
+ * 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.
66
+ */
67
+ export function findClosestRegion(content: string, oldText: string): ClosestRegion | null {
68
+ const allQLines = toFuzzyLines(oldText);
69
+ const cLines = toFuzzyLines(content);
70
+ if (allQLines.length === 0 || cLines.length === 0) return null;
71
+
72
+ const truncated = allQLines.length > MAX_COMPARE_LINES;
73
+ const q = truncated ? allQLines.slice(0, MAX_COMPARE_LINES) : allQLines;
74
+ 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;
91
+ }
92
+ }
93
+ }
94
+
95
+ if (bestStart < 0 || bestScore < MIN_SCORE) return null;
96
+
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;
100
+ return {
101
+ startLine: bestStart + 1,
102
+ endLine: bestStart + bestSize,
103
+ score: bestScore,
104
+ ops,
105
+ equalCount,
106
+ totalOldLines: L,
107
+ truncated,
108
+ };
109
+ }
110
+
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;
132
+ }
133
+ return Math.max(positional, (bagSum / L) * 0.95);
134
+ }
135
+
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;
150
+ }
151
+ }
152
+ return false;
153
+ }
154
+
155
+ /**
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.
159
+ */
160
+ function alignLines(q: string[], w: string[], wOffset: number): AlignOp[] {
161
+ const n = q.length;
162
+ const m = w.length;
163
+ const dp: number[][] = Array.from({ length: n + 1 }, () => new Array<number>(m + 1).fill(0));
164
+ for (let i = n - 1; i >= 0; i--) {
165
+ 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
+ }
171
+ }
172
+ }
173
+
174
+ const raw: AlignOp[] = [];
175
+ let i = 0;
176
+ let j = 0;
177
+ while (i < n && j < m) {
178
+ if (lineSimilarity(q[i], w[j]) >= MATCH_THRESHOLD) {
179
+ raw.push({ type: "equal", fileLine: wOffset + j + 1, fileText: w[j], oldLine: i + 1, oldText: q[i] });
180
+ i++;
181
+ j++;
182
+ } 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
+ } else {
188
+ raw.push({ type: "file-only", fileLine: wOffset + j + 1, fileText: w[j] });
189
+ j++;
190
+ }
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
+ }
200
+
201
+ // Merge adjacent old-only/file-only runs into pairwise "changed" ops.
202
+ const merged: AlignOp[] = [];
203
+ for (let k = 0; k < raw.length; k++) {
204
+ 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
+ }
222
+ merged.push(op);
223
+ continue;
224
+ }
225
+ merged.push(op);
226
+ }
227
+ return merged;
228
+ }
229
+
230
+ /** Cheap probes for common causes of a failed match. */
231
+ export function probeMatchCauses(content: string, oldText: string): string[] {
232
+ const causes: string[] = [];
233
+ const stripWS = (s: string) => s.replace(/\s+/g, "");
234
+ 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
+ );
238
+ } else if (content.toLowerCase().includes(oldText.toLowerCase())) {
239
+ causes.push("letter-case mismatch: the text matches case-insensitively");
240
+ }
241
+ const fileHasTabs = content.includes("\t");
242
+ 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
+ }
248
+ return causes;
249
+ }