@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 +7 -5
- package/package.json +1 -1
- package/src/apply.ts +32 -7
- package/src/diagnostics.ts +127 -30
- package/src/index.ts +11 -6
- package/src/read-evidence.ts +150 -0
- package/src/similarity.ts +116 -139
- package/src/tool.ts +102 -18
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`
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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:
|
|
159
|
-
matchLength:
|
|
183
|
+
matchIndex: selectedMatch.index,
|
|
184
|
+
matchLength: selectedMatch.matchLength,
|
|
160
185
|
newText: edit.newText,
|
|
161
186
|
});
|
|
162
187
|
}
|
package/src/diagnostics.ts
CHANGED
|
@@ -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
|
-
"
|
|
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
|
|
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:
|
|
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
|
-
|
|
294
|
-
|
|
295
|
-
);
|
|
296
|
-
|
|
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
|
|
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
|
|
423
|
+
function textFromLines(content: string, startLine: number, endLine: number): string {
|
|
320
424
|
const spans = getLineSpans(content);
|
|
321
|
-
|
|
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
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
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
|
|
333
|
-
|
|
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` —
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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 {
|
|
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
|
-
*
|
|
3
|
-
* oldText
|
|
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
|
|
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
|
-
|
|
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
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
|
59
|
+
return (2 * overlap) / (a.gramCount + b.gramCount);
|
|
55
60
|
}
|
|
56
61
|
|
|
57
62
|
const MAX_COMPARE_LINES = 300;
|
|
58
|
-
const
|
|
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
|
-
*
|
|
65
|
-
*
|
|
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
|
|
77
|
-
const
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
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(
|
|
98
|
-
const ops = alignLines(q, windowLines,
|
|
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:
|
|
102
|
-
endLine:
|
|
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
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
|
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
|
-
*
|
|
158
|
-
*
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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 = (
|
|
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
|
-
|
|
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 →
|
|
11
|
-
*
|
|
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 {
|
|
31
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
|
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,
|