@khanhicetea/pi-better-tool 0.1.0 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +69 -45
- package/package.json +1 -1
- package/src/apply.ts +53 -18
- package/src/diagnostics.ts +239 -39
- package/src/index.ts +17 -7
- package/src/read-evidence.ts +174 -0
- package/src/similarity.ts +210 -156
- package/src/text.ts +36 -7
- package/src/tool.ts +265 -83
package/src/diagnostics.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* prefix/suffix context expansion that makes that occurrence unique,
|
|
9
9
|
* rendered as a ready-to-use oldText snippet
|
|
10
10
|
* - not-found oldText → the closest matching region (fuzzy line similarity),
|
|
11
|
-
*
|
|
11
|
+
* an original-text comparison, and exact LF-normalized retry text when safe
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
import type { EditFailure, EditOp, LineRange } from "./apply.ts";
|
|
@@ -16,9 +16,11 @@ import { normalizeEdits } from "./apply.ts";
|
|
|
16
16
|
import { findClosestRegion, lineSimilarity, probeMatchCauses } from "./similarity.ts";
|
|
17
17
|
import {
|
|
18
18
|
countFuzzyOccurrences,
|
|
19
|
+
findAllOccurrences,
|
|
19
20
|
getLineSpans,
|
|
20
21
|
lineAt,
|
|
21
22
|
normalizeForFuzzyMatch,
|
|
23
|
+
normalizeToLF,
|
|
22
24
|
type LineSpan,
|
|
23
25
|
} from "./text.ts";
|
|
24
26
|
|
|
@@ -30,8 +32,16 @@ const MAX_TOTAL_CONTEXT_LINES = 12;
|
|
|
30
32
|
const MAX_LISTED_OCCURRENCES = 8;
|
|
31
33
|
/** Occurrences that get a ready-to-use disambiguation snippet. */
|
|
32
34
|
const MAX_SNIPPET_OCCURRENCES = 3;
|
|
33
|
-
/** Max lines shown inside a snippet. */
|
|
35
|
+
/** Max lines/bytes shown inside a copyable snippet. */
|
|
34
36
|
const MAX_SNIPPET_LINES = 60;
|
|
37
|
+
const MAX_SNIPPET_BYTES = 12 * 1024;
|
|
38
|
+
/** Hard model-context bound for the complete tool error. */
|
|
39
|
+
const MAX_OUTPUT_LINES = 1_500;
|
|
40
|
+
const MAX_OUTPUT_BYTES = 48 * 1024;
|
|
41
|
+
/** Minimum score at which a unique closest region may be suggested directly. */
|
|
42
|
+
const MIN_DIRECT_RETRY_SCORE = 0.75;
|
|
43
|
+
/** Required separation from a distinct runner-up before direct-retry wording. */
|
|
44
|
+
const MIN_DIRECT_RETRY_GAP = 0.1;
|
|
35
45
|
/** Max non-equal alignment ops rendered. */
|
|
36
46
|
const MAX_DIFF_OPS_SHOWN = 12;
|
|
37
47
|
|
|
@@ -46,6 +56,13 @@ export interface Expansion {
|
|
|
46
56
|
endLine: number;
|
|
47
57
|
}
|
|
48
58
|
|
|
59
|
+
export interface AutoDisambiguation {
|
|
60
|
+
editIndex: number;
|
|
61
|
+
oldText: string;
|
|
62
|
+
chosenRange: LineRange;
|
|
63
|
+
readRange: LineRange;
|
|
64
|
+
}
|
|
65
|
+
|
|
49
66
|
export interface FormatFailureOptions {
|
|
50
67
|
path: string;
|
|
51
68
|
/** LF-normalized, BOM-stripped file content. */
|
|
@@ -55,7 +72,72 @@ export interface FormatFailureOptions {
|
|
|
55
72
|
failure: EditFailure;
|
|
56
73
|
}
|
|
57
74
|
|
|
75
|
+
const MAX_SUCCESS_RESOLUTIONS = 4;
|
|
76
|
+
const MAX_REMAINING_OCCURRENCES = 4;
|
|
77
|
+
const MAX_SUCCESS_SNIPPET_BYTES = 2_500;
|
|
78
|
+
|
|
79
|
+
/** Add verified read-based selections and retryable remaining candidates to a successful edit result. */
|
|
80
|
+
export function formatAutoDisambiguationSuccess(
|
|
81
|
+
baseMessage: string,
|
|
82
|
+
newContent: string,
|
|
83
|
+
resolutions: AutoDisambiguation[],
|
|
84
|
+
): string {
|
|
85
|
+
if (resolutions.length === 0) return boundCompleteOutput(baseMessage);
|
|
86
|
+
const lines = [baseMessage, ""];
|
|
87
|
+
const shownResolutions = resolutions.slice(0, MAX_SUCCESS_RESOLUTIONS);
|
|
88
|
+
if (newContent.length > MAX_CONTENT_FOR_DIAGNOSTICS) {
|
|
89
|
+
for (const resolution of shownResolutions) {
|
|
90
|
+
lines.push(
|
|
91
|
+
`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)}.`,
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
lines.push("Remaining-occurrence snippets were omitted because the edited file exceeds the diagnostic analysis limit.");
|
|
95
|
+
return boundCompleteOutput(lines.join("\n"));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
for (const resolution of shownResolutions) {
|
|
99
|
+
lines.push(
|
|
100
|
+
`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)}.`,
|
|
101
|
+
);
|
|
102
|
+
const oldText = normalizeToLF(resolution.oldText);
|
|
103
|
+
const remainingCount = countLiteralOccurrences(newContent, oldText);
|
|
104
|
+
const offsets = findAllOccurrences(newContent, oldText, MAX_REMAINING_OCCURRENCES);
|
|
105
|
+
if (remainingCount === 0) {
|
|
106
|
+
lines.push("No exact occurrences of the original oldText remain after this edit.", "");
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
lines.push(`Remaining exact occurrences of the original oldText (${offsets.length} shown${remainingCount > offsets.length ? `, ${remainingCount - offsets.length} more omitted` : ""}):`);
|
|
111
|
+
const fuzzyContent = normalizeForFuzzyMatch(newContent);
|
|
112
|
+
const spans = getLineSpans(newContent);
|
|
113
|
+
for (const [index, offset] of offsets.slice(0, MAX_REMAINING_OCCURRENCES).entries()) {
|
|
114
|
+
const range = rangeFromOffset(spans, offset, oldText.length);
|
|
115
|
+
const expansion = findMinimalUniqueExpansion(newContent, fuzzyContent, spans, range);
|
|
116
|
+
if (!expansion) {
|
|
117
|
+
lines.push(` ${index + 1}. ${describeLines(range.start, range.end)} — no unique prefix/suffix found within ${MAX_TOTAL_CONTEXT_LINES} context lines.`);
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
const where = `effective context: ${plural(expansion.prefixLines, "line")} before, ${plural(expansion.suffixLines, "line")} after`;
|
|
121
|
+
if (!isSnippetRenderable(expansion.text) || Buffer.byteLength(expansion.text, "utf8") > MAX_SUCCESS_SNIPPET_BYTES) {
|
|
122
|
+
lines.push(` ${index + 1}. ${describeLines(range.start, range.end)} — ${where}; snippet omitted, read lines ${expansion.startLine}-${expansion.endLine}.`);
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
lines.push(` ${index + 1}. ${describeLines(range.start, range.end)} — ${where}:`);
|
|
126
|
+
lines.push(...renderSnippet(expansion.text));
|
|
127
|
+
}
|
|
128
|
+
lines.push("Use one fenced snippet byte-for-byte as oldText if you want to edit another occurrence.", "");
|
|
129
|
+
}
|
|
130
|
+
if (resolutions.length > shownResolutions.length) {
|
|
131
|
+
lines.push(`${resolutions.length - shownResolutions.length} more auto-disambiguated edits were applied; details omitted.`);
|
|
132
|
+
}
|
|
133
|
+
return boundCompleteOutput(lines.join("\n").trimEnd());
|
|
134
|
+
}
|
|
135
|
+
|
|
58
136
|
export function formatEditFailure(opts: FormatFailureOptions): string {
|
|
137
|
+
return boundCompleteOutput(formatEditFailureUnbounded(opts));
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function formatEditFailureUnbounded(opts: FormatFailureOptions): string {
|
|
59
141
|
const { path, normalizedContent, edits, failure } = opts;
|
|
60
142
|
const total = edits.length;
|
|
61
143
|
|
|
@@ -82,8 +164,8 @@ export function formatEditFailure(opts: FormatFailureOptions): string {
|
|
|
82
164
|
case "ambiguous": {
|
|
83
165
|
const head =
|
|
84
166
|
total === 1
|
|
85
|
-
? `Found ${failure.
|
|
86
|
-
: `Found ${failure.
|
|
167
|
+
? `Found ${failure.occurrenceCount} occurrences of the text in ${path}. The text must be unique. Please provide more context to make it unique.`
|
|
168
|
+
: `Found ${failure.occurrenceCount} occurrences of edits[${failure.editIndex}] in ${path}. Each oldText must be unique. Please provide more context to make it unique.`;
|
|
87
169
|
const body =
|
|
88
170
|
normalizedContent.length <= MAX_CONTENT_FOR_DIAGNOSTICS
|
|
89
171
|
? formatAmbiguous(opts, failure)
|
|
@@ -123,17 +205,18 @@ function formatAmbiguous(opts: FormatFailureOptions, failure: Extract<EditFailur
|
|
|
123
205
|
const range = rangeFromOffset(fuzzySpans, offset, fuzzyOld.length);
|
|
124
206
|
lines.push(` ${i + 1}. ${describeLines(range.start, range.end)}`);
|
|
125
207
|
});
|
|
126
|
-
if (failure.
|
|
127
|
-
lines.push(` … and ${failure.
|
|
208
|
+
if (failure.occurrenceCount > listed.length) {
|
|
209
|
+
lines.push(` … and ${failure.occurrenceCount - listed.length} more`);
|
|
128
210
|
}
|
|
129
211
|
lines.push("");
|
|
130
212
|
|
|
131
213
|
lines.push(
|
|
132
|
-
"
|
|
214
|
+
"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
215
|
);
|
|
134
216
|
|
|
135
217
|
let shown = 0;
|
|
136
218
|
let missingExpansionNote = false;
|
|
219
|
+
let omittedSnippet = false;
|
|
137
220
|
for (let i = 0; i < listed.length && shown < MAX_SNIPPET_OCCURRENCES; i++) {
|
|
138
221
|
const offset = listed[i];
|
|
139
222
|
const range = rangeFromOffset(fuzzySpans, offset, fuzzyOld.length);
|
|
@@ -142,11 +225,18 @@ function formatAmbiguous(opts: FormatFailureOptions, failure: Extract<EditFailur
|
|
|
142
225
|
missingExpansionNote = true;
|
|
143
226
|
continue;
|
|
144
227
|
}
|
|
145
|
-
shown++;
|
|
146
228
|
const where = `minimum context: ${plural(expansion.prefixLines, "line")} before, ${plural(expansion.suffixLines, "line")} after`;
|
|
147
229
|
lines.push("");
|
|
230
|
+
if (!isSnippetRenderable(expansion.text)) {
|
|
231
|
+
omittedSnippet = true;
|
|
232
|
+
lines.push(
|
|
233
|
+
`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}.`,
|
|
234
|
+
);
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
shown++;
|
|
148
238
|
lines.push(`Occurrence ${i + 1} (${describeLines(range.start, range.end)}) — ${where}:`);
|
|
149
|
-
lines.push(...renderSnippet(expansion.text
|
|
239
|
+
lines.push(...renderSnippet(expansion.text));
|
|
150
240
|
}
|
|
151
241
|
|
|
152
242
|
if (missingExpansionNote) {
|
|
@@ -155,12 +245,12 @@ function formatAmbiguous(opts: FormatFailureOptions, failure: Extract<EditFailur
|
|
|
155
245
|
`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
246
|
);
|
|
157
247
|
}
|
|
158
|
-
if (shown === 0 && !missingExpansionNote) {
|
|
248
|
+
if (shown === 0 && !missingExpansionNote && !omittedSnippet) {
|
|
159
249
|
lines.push("", "Extend oldText with more surrounding lines until it matches exactly one location.");
|
|
160
250
|
}
|
|
161
251
|
lines.push("");
|
|
162
252
|
lines.push(
|
|
163
|
-
"Tip:
|
|
253
|
+
"Tip: only fenced snippets above are byte-for-byte retryable. Make newText from the chosen snippet with your intended change applied.",
|
|
164
254
|
);
|
|
165
255
|
return lines.join("\n");
|
|
166
256
|
}
|
|
@@ -177,6 +267,17 @@ function plural(n: number, noun: string): string {
|
|
|
177
267
|
return `${n} ${noun}${n === 1 ? "" : "s"}`;
|
|
178
268
|
}
|
|
179
269
|
|
|
270
|
+
function countLiteralOccurrences(haystack: string, needle: string): number {
|
|
271
|
+
if (!needle) return 0;
|
|
272
|
+
let count = 0;
|
|
273
|
+
let index = haystack.indexOf(needle);
|
|
274
|
+
while (index !== -1) {
|
|
275
|
+
count++;
|
|
276
|
+
index = haystack.indexOf(needle, index + needle.length);
|
|
277
|
+
}
|
|
278
|
+
return count;
|
|
279
|
+
}
|
|
280
|
+
|
|
180
281
|
/**
|
|
181
282
|
* Find the smallest whole-line context expansion of the occurrence at `range`
|
|
182
283
|
* whose text occurs exactly once in the file (checked in fuzzy space, exactly
|
|
@@ -271,9 +372,9 @@ function formatNotFound(opts: FormatFailureOptions, oldText: string): string {
|
|
|
271
372
|
);
|
|
272
373
|
}
|
|
273
374
|
} else {
|
|
274
|
-
lines.push(`Differences vs your oldText (${closest.equalCount} of ${closest.totalOldLines} compared lines match):`);
|
|
375
|
+
lines.push(`Differences vs your oldText (${closest.equalCount} of ${closest.totalOldLines} compared lines match exactly):`);
|
|
275
376
|
for (const op of diffOps.slice(0, MAX_DIFF_OPS_SHOWN)) {
|
|
276
|
-
if (op.type === "changed") {
|
|
377
|
+
if (op.type === "changed" || op.type === "similar") {
|
|
277
378
|
lines.push(` file line ${op.fileLine} differs from your oldText line ${op.oldLine}:`);
|
|
278
379
|
lines.push(` file: ${truncateLine(op.fileText ?? "")}`);
|
|
279
380
|
lines.push(` oldText: ${truncateLine(op.oldText ?? "")}`);
|
|
@@ -290,12 +391,41 @@ function formatNotFound(opts: FormatFailureOptions, oldText: string): string {
|
|
|
290
391
|
}
|
|
291
392
|
}
|
|
292
393
|
lines.push("");
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
);
|
|
296
|
-
|
|
394
|
+
const candidate = textFromLines(normalizedContent, closest.startLine, closest.endLine);
|
|
395
|
+
const uniqueCandidate = verifyUniqueSnippet(normalizeForFuzzyMatch(normalizedContent), candidate);
|
|
396
|
+
const safelyRenderable = isSnippetRenderable(uniqueCandidate ?? candidate);
|
|
397
|
+
const competitorGap = closest.competitor ? closest.score - closest.competitor.score : Number.POSITIVE_INFINITY;
|
|
398
|
+
const directRetry =
|
|
399
|
+
!closest.truncated &&
|
|
400
|
+
closest.score >= MIN_DIRECT_RETRY_SCORE &&
|
|
401
|
+
!closest.competitionIncomplete &&
|
|
402
|
+
competitorGap >= MIN_DIRECT_RETRY_GAP &&
|
|
403
|
+
uniqueCandidate !== null &&
|
|
404
|
+
safelyRenderable;
|
|
405
|
+
if (directRetry) {
|
|
406
|
+
lines.push(
|
|
407
|
+
`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):`,
|
|
408
|
+
);
|
|
409
|
+
lines.push(...renderSnippet(uniqueCandidate));
|
|
410
|
+
} else {
|
|
411
|
+
const reasons = [
|
|
412
|
+
closest.truncated ? "only part of oldText was compared" : undefined,
|
|
413
|
+
closest.score < MIN_DIRECT_RETRY_SCORE ? "similarity confidence is too low" : undefined,
|
|
414
|
+
closest.competitionIncomplete ? "the bounded search discarded another competitively scored window" : undefined,
|
|
415
|
+
competitorGap < MIN_DIRECT_RETRY_GAP && closest.competitor
|
|
416
|
+
? `a distinct candidate at ${describeLines(closest.competitor.startLine, closest.competitor.endLine)} has a similar heuristic score (~${Math.round(closest.competitor.score * 100)}%)`
|
|
417
|
+
: undefined,
|
|
418
|
+
uniqueCandidate === null ? "the candidate is not unique under edit matching" : undefined,
|
|
419
|
+
!safelyRenderable ? "the exact candidate exceeds the safe output limit" : undefined,
|
|
420
|
+
].filter((reason): reason is string => reason !== undefined);
|
|
421
|
+
lines.push(
|
|
422
|
+
`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.`,
|
|
423
|
+
);
|
|
424
|
+
if (safelyRenderable) lines.push(...renderSnippet(uniqueCandidate ?? candidate));
|
|
425
|
+
}
|
|
426
|
+
|
|
297
427
|
} else {
|
|
298
|
-
lines.push("No similar region was found
|
|
428
|
+
lines.push("No reliable similar region was found within the bounded diagnostic search.");
|
|
299
429
|
lines.push("If you expected this text to exist, read the file around the expected location and retry.");
|
|
300
430
|
}
|
|
301
431
|
|
|
@@ -308,37 +438,107 @@ function formatNotFound(opts: FormatFailureOptions, oldText: string): string {
|
|
|
308
438
|
}
|
|
309
439
|
|
|
310
440
|
function truncateLine(line: string): string {
|
|
311
|
-
const
|
|
312
|
-
|
|
441
|
+
const visibleTrailing = line.replace(/[ \t]+$/, (suffix) =>
|
|
442
|
+
[...suffix].map((char) => (char === "\t" ? "→tab→" : "·")).join(""),
|
|
443
|
+
);
|
|
444
|
+
const visible = visibleTrailing.replace(/\t/g, "→tab→");
|
|
445
|
+
return visible.length > 120 ? `${visible.slice(0, 117)}…` : visible;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* Enforce the complete output budget without ever cutting a generated fenced
|
|
450
|
+
* snippet. Oversized snippets are omitted atomically and clearly marked.
|
|
451
|
+
*/
|
|
452
|
+
function boundCompleteOutput(message: string): string {
|
|
453
|
+
const source = message.split("\n");
|
|
454
|
+
const output: string[] = [];
|
|
455
|
+
let bytes = 0;
|
|
456
|
+
let omitted = false;
|
|
457
|
+
const reservedBytes = 512;
|
|
458
|
+
const maxBodyBytes = MAX_OUTPUT_BYTES - reservedBytes;
|
|
459
|
+
const maxBodyLines = MAX_OUTPUT_LINES - 3;
|
|
460
|
+
|
|
461
|
+
const canAdd = (block: string[]) => {
|
|
462
|
+
const text = block.join("\n");
|
|
463
|
+
const addedBytes = Buffer.byteLength(text, "utf8") + (output.length > 0 ? 1 : 0);
|
|
464
|
+
return output.length + block.length <= maxBodyLines && bytes + addedBytes <= maxBodyBytes;
|
|
465
|
+
};
|
|
466
|
+
const add = (block: string[]) => {
|
|
467
|
+
const text = block.join("\n");
|
|
468
|
+
bytes += Buffer.byteLength(text, "utf8") + (output.length > 0 ? 1 : 0);
|
|
469
|
+
output.push(...block);
|
|
470
|
+
};
|
|
471
|
+
|
|
472
|
+
for (let index = 0; index < source.length; index++) {
|
|
473
|
+
const opener = source[index];
|
|
474
|
+
if (/^`{3,}$/.test(opener)) {
|
|
475
|
+
let closing = index + 1;
|
|
476
|
+
while (closing < source.length && source[closing] !== opener) closing++;
|
|
477
|
+
if (closing < source.length) {
|
|
478
|
+
const block = source.slice(index, closing + 1);
|
|
479
|
+
if (canAdd(block)) add(block);
|
|
480
|
+
else {
|
|
481
|
+
omitted = true;
|
|
482
|
+
const notice = ["[Exact fenced snippet omitted to keep the complete tool output within its byte/line budget.]" ];
|
|
483
|
+
if (canAdd(notice)) add(notice);
|
|
484
|
+
}
|
|
485
|
+
index = closing;
|
|
486
|
+
continue;
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
if (canAdd([opener])) {
|
|
491
|
+
add([opener]);
|
|
492
|
+
continue;
|
|
493
|
+
}
|
|
494
|
+
omitted = true;
|
|
495
|
+
const available = Math.max(0, maxBodyBytes - bytes - (output.length > 0 ? 1 : 0));
|
|
496
|
+
if (available > 4 && output.length < maxBodyLines) add([truncateUtf8(opener, available)]);
|
|
497
|
+
break;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
if (omitted) {
|
|
501
|
+
const notice = "[Diagnostic output was bounded. Omitted fenced snippets are not retryable; read the referenced range first.]";
|
|
502
|
+
if (output.length > 0) output.push("");
|
|
503
|
+
output.push(notice);
|
|
504
|
+
}
|
|
505
|
+
return output.join("\n").trimEnd();
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function truncateUtf8(text: string, maxBytes: number): string {
|
|
509
|
+
if (Buffer.byteLength(text, "utf8") <= maxBytes) return text;
|
|
510
|
+
const suffix = "…";
|
|
511
|
+
const target = Math.max(0, maxBytes - Buffer.byteLength(suffix, "utf8"));
|
|
512
|
+
let low = 0;
|
|
513
|
+
let high = text.length;
|
|
514
|
+
while (low < high) {
|
|
515
|
+
const middle = Math.ceil((low + high) / 2);
|
|
516
|
+
if (Buffer.byteLength(text.slice(0, middle), "utf8") <= target) low = middle;
|
|
517
|
+
else high = middle - 1;
|
|
518
|
+
}
|
|
519
|
+
return `${text.slice(0, low)}${suffix}`;
|
|
313
520
|
}
|
|
314
521
|
|
|
315
522
|
// ---------------------------------------------------------------------------
|
|
316
523
|
// Snippet rendering
|
|
317
524
|
// ---------------------------------------------------------------------------
|
|
318
525
|
|
|
319
|
-
function
|
|
526
|
+
function textFromLines(content: string, startLine: number, endLine: number): string {
|
|
320
527
|
const spans = getLineSpans(content);
|
|
321
|
-
|
|
322
|
-
const text = raw.endsWith("\n") ? raw.slice(0, -1) : raw;
|
|
323
|
-
return renderSnippet(text, `lines ${startLine}-${endLine}`);
|
|
528
|
+
return content.slice(spans[startLine - 1].start, spans[endLine - 1].end);
|
|
324
529
|
}
|
|
325
530
|
|
|
326
|
-
function
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
531
|
+
function isSnippetRenderable(text: string): boolean {
|
|
532
|
+
return text.split("\n").length <= MAX_SNIPPET_LINES && Buffer.byteLength(text, "utf8") <= MAX_SNIPPET_BYTES;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/** Render only complete snippets. Callers must check isSnippetRenderable first. */
|
|
536
|
+
function renderSnippet(text: string): string[] {
|
|
537
|
+
if (!isSnippetRenderable(text)) {
|
|
538
|
+
return ["[Exact snippet omitted because it exceeds the safe output limit; read the referenced range first.]" ];
|
|
331
539
|
}
|
|
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
|
-
];
|
|
540
|
+
const fence = fenceFor(text);
|
|
541
|
+
return [fence, text, fence];
|
|
342
542
|
}
|
|
343
543
|
|
|
344
544
|
/** Choose a fence longer than any backtick run inside the snippet. */
|
package/src/index.ts
CHANGED
|
@@ -2,20 +2,30 @@
|
|
|
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";
|
|
12
11
|
import { registerBetterEditTool } from "./tool.ts";
|
|
13
12
|
|
|
14
13
|
export { registerBetterEditTool, executeBetterEdit, prepareEditArguments, betterEditSchema } from "./tool.ts";
|
|
15
|
-
export type {
|
|
16
|
-
|
|
14
|
+
export type {
|
|
15
|
+
BetterEditExecutionOptions,
|
|
16
|
+
BetterEditInput,
|
|
17
|
+
BetterEditOperations,
|
|
18
|
+
BetterEditSuccess,
|
|
19
|
+
} from "./tool.ts";
|
|
20
|
+
export {
|
|
21
|
+
formatAutoDisambiguationSuccess,
|
|
22
|
+
formatEditFailure,
|
|
23
|
+
findMinimalUniqueExpansion,
|
|
24
|
+
fenceFor,
|
|
25
|
+
} from "./diagnostics.ts";
|
|
26
|
+
export type { AutoDisambiguation } from "./diagnostics.ts";
|
|
17
27
|
export { analyzeEdits, applyAnalysis } from "./apply.ts";
|
|
18
|
-
export type { EditFailure, EditOp, EditAnalysis } from "./apply.ts";
|
|
28
|
+
export type { AnalyzeOptions, EditFailure, EditOp, EditAnalysis } from "./apply.ts";
|
|
19
29
|
|
|
20
30
|
export default function (pi: ExtensionAPI) {
|
|
21
31
|
registerBetterEditTool(pi);
|
|
@@ -0,0 +1,174 @@
|
|
|
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 represented by the verified read output. */
|
|
15
|
+
startLine: number;
|
|
16
|
+
endLine: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface ReadCall {
|
|
20
|
+
id: string;
|
|
21
|
+
path: string;
|
|
22
|
+
offset?: number;
|
|
23
|
+
limit?: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface StoredToolResult {
|
|
27
|
+
role: "toolResult";
|
|
28
|
+
toolCallId: string;
|
|
29
|
+
toolName: string;
|
|
30
|
+
isError: boolean;
|
|
31
|
+
content: Array<{ type: string; text?: string }>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface StoredAssistant {
|
|
35
|
+
role: "assistant";
|
|
36
|
+
content: Array<{ type: string; id?: string; name?: string; arguments?: unknown }>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface ContextEntryLike {
|
|
40
|
+
type: string;
|
|
41
|
+
message?: StoredAssistant | StoredToolResult | { role: string };
|
|
42
|
+
retainedTail?: Array<StoredAssistant | StoredToolResult | { role: string }>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Find the newest read call for targetPath in Pi's compaction-aware stored
|
|
47
|
+
* context. The call is accepted only when its successful result exactly
|
|
48
|
+
* matches the built-in read formatting for the current LF-normalized content.
|
|
49
|
+
*
|
|
50
|
+
* This establishes stored-context evidence, not proof of the final provider
|
|
51
|
+
* payload after other extensions' context/provider hooks.
|
|
52
|
+
*/
|
|
53
|
+
export async function findLatestReadEvidence(
|
|
54
|
+
sessionManager: Pick<ExtensionContext["sessionManager"], "buildContextEntries"> | undefined,
|
|
55
|
+
targetPath: string,
|
|
56
|
+
normalizedContent: string,
|
|
57
|
+
resolvePath: (path: string) => Promise<string>,
|
|
58
|
+
): Promise<ReadEvidence | null> {
|
|
59
|
+
if (!sessionManager) return null;
|
|
60
|
+
const entries = sessionManager.buildContextEntries() as ContextEntryLike[];
|
|
61
|
+
const messages = entries.flatMap((entry) => {
|
|
62
|
+
if (entry.type === "message" && entry.message) return [entry.message];
|
|
63
|
+
if (entry.type === "compaction" && Array.isArray(entry.retainedTail)) return entry.retainedTail;
|
|
64
|
+
return [];
|
|
65
|
+
});
|
|
66
|
+
const calls: ReadCall[] = [];
|
|
67
|
+
const results = new Map<string, StoredToolResult>();
|
|
68
|
+
|
|
69
|
+
for (const message of messages) {
|
|
70
|
+
if (message.role === "assistant") {
|
|
71
|
+
for (const item of (message as StoredAssistant).content) {
|
|
72
|
+
if (item.type !== "toolCall" || item.name !== "read" || typeof item.id !== "string") continue;
|
|
73
|
+
const args = item.arguments as Record<string, unknown> | undefined;
|
|
74
|
+
if (!args || typeof args.path !== "string") continue;
|
|
75
|
+
calls.push({
|
|
76
|
+
id: item.id,
|
|
77
|
+
path: args.path,
|
|
78
|
+
offset: typeof args.offset === "number" ? args.offset : undefined,
|
|
79
|
+
limit: typeof args.limit === "number" ? args.limit : undefined,
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
} else if (message.role === "toolResult") {
|
|
83
|
+
const result = message as StoredToolResult;
|
|
84
|
+
if (result.toolName === "read") results.set(result.toolCallId, result);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
for (let index = calls.length - 1; index >= 0; index--) {
|
|
89
|
+
const call = calls[index];
|
|
90
|
+
let readPath: string;
|
|
91
|
+
try {
|
|
92
|
+
readPath = await resolvePath(call.path);
|
|
93
|
+
} catch {
|
|
94
|
+
// Without a canonical identity we cannot prove that this newer read is
|
|
95
|
+
// unrelated, so fail closed instead of falling back to older intent.
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
if (readPath !== targetPath) continue;
|
|
99
|
+
|
|
100
|
+
// Never fall back to older intent when the newest same-file read is
|
|
101
|
+
// missing, failed, malformed, or stale.
|
|
102
|
+
const result = results.get(call.id);
|
|
103
|
+
if (!result || result.isError) return null;
|
|
104
|
+
return evidenceFromBuiltinRead(normalizedContent, call, result.content);
|
|
105
|
+
}
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function evidenceFromBuiltinRead(
|
|
110
|
+
content: string,
|
|
111
|
+
call: Pick<ReadCall, "path" | "offset" | "limit">,
|
|
112
|
+
blocks: Array<{ type: string; text?: string }>,
|
|
113
|
+
): ReadEvidence | null {
|
|
114
|
+
if (blocks.length !== 1 || blocks[0].type !== "text" || typeof blocks[0].text !== "string") return null;
|
|
115
|
+
if (call.offset !== undefined && (!Number.isInteger(call.offset) || call.offset < 1)) return null;
|
|
116
|
+
if (call.limit !== undefined && (!Number.isInteger(call.limit) || call.limit < 1)) return null;
|
|
117
|
+
const actualOutput = normalizeToLF(blocks[0].text);
|
|
118
|
+
const allLines = content.split("\n");
|
|
119
|
+
const startIndex = call.offset ? Math.max(0, call.offset - 1) : 0;
|
|
120
|
+
if (startIndex >= allLines.length) return null;
|
|
121
|
+
|
|
122
|
+
let selectedContent: string;
|
|
123
|
+
let userLimitedLines: number | undefined;
|
|
124
|
+
if (call.limit !== undefined) {
|
|
125
|
+
const endIndex = Math.min(startIndex + call.limit, allLines.length);
|
|
126
|
+
selectedContent = allLines.slice(startIndex, endIndex).join("\n");
|
|
127
|
+
userLimitedLines = endIndex - startIndex;
|
|
128
|
+
} else {
|
|
129
|
+
selectedContent = allLines.slice(startIndex).join("\n");
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const truncation = truncateHead(selectedContent);
|
|
133
|
+
if (truncation.firstLineExceedsLimit) return null;
|
|
134
|
+
|
|
135
|
+
let expectedOutput = truncation.content;
|
|
136
|
+
let visibleLines = truncation.outputLines;
|
|
137
|
+
if (truncation.truncated) {
|
|
138
|
+
const startLine = startIndex + 1;
|
|
139
|
+
const endLine = startLine + truncation.outputLines - 1;
|
|
140
|
+
const nextOffset = endLine + 1;
|
|
141
|
+
if (truncation.truncatedBy === "lines") {
|
|
142
|
+
expectedOutput += `\n\n[Showing lines ${startLine}-${endLine} of ${allLines.length}. Use offset=${nextOffset} to continue.]`;
|
|
143
|
+
} else {
|
|
144
|
+
expectedOutput += `\n\n[Showing lines ${startLine}-${endLine} of ${allLines.length} (${formatSize(DEFAULT_MAX_BYTES)} limit). Use offset=${nextOffset} to continue.]`;
|
|
145
|
+
}
|
|
146
|
+
} else if (userLimitedLines !== undefined && startIndex + userLimitedLines < allLines.length) {
|
|
147
|
+
const remaining = allLines.length - (startIndex + userLimitedLines);
|
|
148
|
+
const nextOffset = startIndex + userLimitedLines + 1;
|
|
149
|
+
expectedOutput += `\n\n[${remaining} more lines in file. Use offset=${nextOffset} to continue.]`;
|
|
150
|
+
visibleLines = userLimitedLines;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (actualOutput !== expectedOutput || visibleLines <= 0 || visibleLines > DEFAULT_MAX_LINES) return null;
|
|
154
|
+
|
|
155
|
+
const startLine = startIndex + 1;
|
|
156
|
+
const endLine = startLine + visibleLines - 1;
|
|
157
|
+
const startOffset = offsetAtLine(content, startLine);
|
|
158
|
+
const endOffset = offsetAtLine(content, endLine + 1);
|
|
159
|
+
return { startOffset, endOffset, startLine, endLine };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Offset of a 1-based line; the line after EOF maps to content.length. */
|
|
163
|
+
function offsetAtLine(content: string, line: number): number {
|
|
164
|
+
if (line <= 1) return 0;
|
|
165
|
+
let currentLine = 1;
|
|
166
|
+
let offset = 0;
|
|
167
|
+
while (currentLine < line) {
|
|
168
|
+
const newline = content.indexOf("\n", offset);
|
|
169
|
+
if (newline === -1) return content.length;
|
|
170
|
+
offset = newline + 1;
|
|
171
|
+
currentLine++;
|
|
172
|
+
}
|
|
173
|
+
return offset;
|
|
174
|
+
}
|