@khanhicetea/pi-better-tool 0.2.1 → 0.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +116 -69
- package/package.json +34 -5
- package/src/apply.ts +22 -12
- package/src/diagnostics.ts +159 -26
- package/src/index.ts +15 -5
- package/src/paths.ts +19 -0
- package/src/read-evidence.ts +74 -33
- package/src/read-symbol.ts +214 -0
- package/src/similarity.ts +140 -63
- package/src/source-file.ts +60 -0
- package/src/symbols.ts +228 -0
- package/src/text.ts +36 -7
- package/src/tool.ts +178 -106
package/src/diagnostics.ts
CHANGED
|
@@ -8,12 +8,12 @@
|
|
|
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
|
-
import { formatSize, truncateHead } from "@earendil-works/pi-coding-agent";
|
|
15
14
|
import type { EditFailure, EditOp, LineRange } from "./apply.ts";
|
|
16
15
|
import { normalizeEdits } from "./apply.ts";
|
|
16
|
+
import { languageForPath } from "./symbols.ts";
|
|
17
17
|
import { findClosestRegion, lineSimilarity, probeMatchCauses } from "./similarity.ts";
|
|
18
18
|
import {
|
|
19
19
|
countFuzzyOccurrences,
|
|
@@ -41,6 +41,8 @@ const MAX_OUTPUT_LINES = 1_500;
|
|
|
41
41
|
const MAX_OUTPUT_BYTES = 48 * 1024;
|
|
42
42
|
/** Minimum score at which a unique closest region may be suggested directly. */
|
|
43
43
|
const MIN_DIRECT_RETRY_SCORE = 0.75;
|
|
44
|
+
/** Required separation from a distinct runner-up before direct-retry wording. */
|
|
45
|
+
const MIN_DIRECT_RETRY_GAP = 0.1;
|
|
44
46
|
/** Max non-equal alignment ops rendered. */
|
|
45
47
|
const MAX_DIFF_OPS_SHOWN = 12;
|
|
46
48
|
|
|
@@ -81,22 +83,32 @@ export function formatAutoDisambiguationSuccess(
|
|
|
81
83
|
newContent: string,
|
|
82
84
|
resolutions: AutoDisambiguation[],
|
|
83
85
|
): string {
|
|
84
|
-
if (resolutions.length === 0) return baseMessage;
|
|
86
|
+
if (resolutions.length === 0) return boundCompleteOutput(baseMessage);
|
|
85
87
|
const lines = [baseMessage, ""];
|
|
86
88
|
const shownResolutions = resolutions.slice(0, MAX_SUCCESS_RESOLUTIONS);
|
|
89
|
+
if (newContent.length > MAX_CONTENT_FOR_DIAGNOSTICS) {
|
|
90
|
+
for (const resolution of shownResolutions) {
|
|
91
|
+
lines.push(
|
|
92
|
+
`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)}.`,
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
lines.push("Remaining-occurrence snippets were omitted because the edited file exceeds the diagnostic analysis limit.");
|
|
96
|
+
return boundCompleteOutput(lines.join("\n"));
|
|
97
|
+
}
|
|
87
98
|
|
|
88
99
|
for (const resolution of shownResolutions) {
|
|
89
100
|
lines.push(
|
|
90
101
|
`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
102
|
);
|
|
92
103
|
const oldText = normalizeToLF(resolution.oldText);
|
|
93
|
-
const
|
|
94
|
-
|
|
104
|
+
const remainingCount = countLiteralOccurrences(newContent, oldText);
|
|
105
|
+
const offsets = findAllOccurrences(newContent, oldText, MAX_REMAINING_OCCURRENCES);
|
|
106
|
+
if (remainingCount === 0) {
|
|
95
107
|
lines.push("No exact occurrences of the original oldText remain after this edit.", "");
|
|
96
108
|
continue;
|
|
97
109
|
}
|
|
98
110
|
|
|
99
|
-
lines.push(`Remaining exact occurrences of the original oldText (${
|
|
111
|
+
lines.push(`Remaining exact occurrences of the original oldText (${offsets.length} shown${remainingCount > offsets.length ? `, ${remainingCount - offsets.length} more omitted` : ""}):`);
|
|
100
112
|
const fuzzyContent = normalizeForFuzzyMatch(newContent);
|
|
101
113
|
const spans = getLineSpans(newContent);
|
|
102
114
|
for (const [index, offset] of offsets.slice(0, MAX_REMAINING_OCCURRENCES).entries()) {
|
|
@@ -119,14 +131,24 @@ export function formatAutoDisambiguationSuccess(
|
|
|
119
131
|
if (resolutions.length > shownResolutions.length) {
|
|
120
132
|
lines.push(`${resolutions.length - shownResolutions.length} more auto-disambiguated edits were applied; details omitted.`);
|
|
121
133
|
}
|
|
122
|
-
return lines.join("\n").trimEnd();
|
|
134
|
+
return boundCompleteOutput(lines.join("\n").trimEnd());
|
|
123
135
|
}
|
|
124
136
|
|
|
125
137
|
export function formatEditFailure(opts: FormatFailureOptions): string {
|
|
126
|
-
const
|
|
127
|
-
const
|
|
128
|
-
|
|
129
|
-
|
|
138
|
+
const { failure, edits } = opts;
|
|
139
|
+
const target = "editIndex" in failure ? `edits[${failure.editIndex}]` : failure.kind === "overlap" ? `edits[${failure.firstEditIndex}] and edits[${failure.secondEditIndex}]` : "the replacement text";
|
|
140
|
+
const batch = edits.length > 1
|
|
141
|
+
? `Batch status: 0/${edits.length} replacements written. Fix ${target} and resubmit the complete batch against the original file; no earlier replacement was applied.`
|
|
142
|
+
: "Write status: no changes were written by this call.";
|
|
143
|
+
return boundCompleteOutput(`[edit failure: ${failure.kind}]\n${batch}\n\n${formatEditFailureUnbounded(opts)}`);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Concrete next arguments prevent another call just to discover boundaries. */
|
|
147
|
+
function nextContextCall(path: string, start: number, end = start): string {
|
|
148
|
+
const read = `read ${JSON.stringify({ path, offset: Math.max(1, start - 3), limit: Math.min(1800, end - start + 7) })}`;
|
|
149
|
+
return languageForPath(path)
|
|
150
|
+
? `Next context call: read_symbol ${JSON.stringify({ path, line: start })} for the enclosing symbol; or ${read} for exact line context.`
|
|
151
|
+
: `Next context call: ${read}.`;
|
|
130
152
|
}
|
|
131
153
|
|
|
132
154
|
function formatEditFailureUnbounded(opts: FormatFailureOptions): string {
|
|
@@ -135,9 +157,7 @@ function formatEditFailureUnbounded(opts: FormatFailureOptions): string {
|
|
|
135
157
|
|
|
136
158
|
switch (failure.kind) {
|
|
137
159
|
case "empty-old-text": {
|
|
138
|
-
return total === 1
|
|
139
|
-
? `oldText must not be empty in ${path}.`
|
|
140
|
-
: `edits[${failure.editIndex}].oldText must not be empty in ${path}.`;
|
|
160
|
+
return `${total === 1 ? "oldText" : `edits[${failure.editIndex}].oldText`} must not be empty in ${path}. Copy non-empty source text as the anchor; for insertion, retain that anchor in newText.\n${nextContextCall(path, 1)}`;
|
|
141
161
|
}
|
|
142
162
|
|
|
143
163
|
case "not-found": {
|
|
@@ -156,8 +176,8 @@ function formatEditFailureUnbounded(opts: FormatFailureOptions): string {
|
|
|
156
176
|
case "ambiguous": {
|
|
157
177
|
const head =
|
|
158
178
|
total === 1
|
|
159
|
-
? `Found ${failure.
|
|
160
|
-
: `Found ${failure.
|
|
179
|
+
? `Found ${failure.occurrenceCount} occurrences of the text in ${path}. The text must be unique. Please provide more context to make it unique.`
|
|
180
|
+
: `Found ${failure.occurrenceCount} occurrences of edits[${failure.editIndex}] in ${path}. Each oldText must be unique. Please provide more context to make it unique.`;
|
|
161
181
|
const body =
|
|
162
182
|
normalizedContent.length <= MAX_CONTENT_FOR_DIAGNOSTICS
|
|
163
183
|
? formatAmbiguous(opts, failure)
|
|
@@ -167,13 +187,19 @@ function formatEditFailureUnbounded(opts: FormatFailureOptions): string {
|
|
|
167
187
|
|
|
168
188
|
case "overlap": {
|
|
169
189
|
const { firstEditIndex, secondEditIndex, firstRange, secondRange } = failure;
|
|
170
|
-
|
|
190
|
+
const start = Math.min(firstRange.start, secondRange.start);
|
|
191
|
+
const end = Math.max(firstRange.end, secondRange.end);
|
|
192
|
+
const head = `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.`;
|
|
193
|
+
const expansion = normalizedContent.length <= MAX_CONTENT_FOR_DIAGNOSTICS
|
|
194
|
+
? findMinimalUniqueExpansion(normalizedContent, normalizeForFuzzyMatch(normalizedContent), getLineSpans(normalizedContent), { start, end }) : null;
|
|
195
|
+
if (expansion && isSnippetRenderable(expansion.text)) {
|
|
196
|
+
return `${head}\n\nRetryable merged oldText at lines ${expansion.startLine}-${expansion.endLine}. Apply BOTH intended changes to this snippet in one newText; do not concatenate the previous replacements.\n${renderSnippet(expansion.text).join("\n")}`;
|
|
197
|
+
}
|
|
198
|
+
return `${head}\nMerged source snippet omitted or not unique. ${nextContextCall(path, start, end)}`;
|
|
171
199
|
}
|
|
172
200
|
|
|
173
201
|
case "no-change": {
|
|
174
|
-
return total === 1
|
|
175
|
-
? `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.`
|
|
176
|
-
: `No changes made to ${path}. The replacements produced identical content.`;
|
|
202
|
+
return `No changes made to ${path}. The replacement${total === 1 ? "" : "s"} produced identical content. Do not repeat the same call. If the intended change is already present, stop; otherwise change newText so it differs from the matched source.`;
|
|
177
203
|
}
|
|
178
204
|
}
|
|
179
205
|
}
|
|
@@ -196,9 +222,10 @@ function formatAmbiguous(opts: FormatFailureOptions, failure: Extract<EditFailur
|
|
|
196
222
|
listed.forEach((offset, i) => {
|
|
197
223
|
const range = rangeFromOffset(fuzzySpans, offset, fuzzyOld.length);
|
|
198
224
|
lines.push(` ${i + 1}. ${describeLines(range.start, range.end)}`);
|
|
225
|
+
lines.push(` ${nextContextCall(opts.path, range.start, range.end)}`);
|
|
199
226
|
});
|
|
200
|
-
if (failure.
|
|
201
|
-
lines.push(` … and ${failure.
|
|
227
|
+
if (failure.occurrenceCount > listed.length) {
|
|
228
|
+
lines.push(` … and ${failure.occurrenceCount - listed.length} more`);
|
|
202
229
|
}
|
|
203
230
|
lines.push("");
|
|
204
231
|
|
|
@@ -259,6 +286,17 @@ function plural(n: number, noun: string): string {
|
|
|
259
286
|
return `${n} ${noun}${n === 1 ? "" : "s"}`;
|
|
260
287
|
}
|
|
261
288
|
|
|
289
|
+
function countLiteralOccurrences(haystack: string, needle: string): number {
|
|
290
|
+
if (!needle) return 0;
|
|
291
|
+
let count = 0;
|
|
292
|
+
let index = haystack.indexOf(needle);
|
|
293
|
+
while (index !== -1) {
|
|
294
|
+
count++;
|
|
295
|
+
index = haystack.indexOf(needle, index + needle.length);
|
|
296
|
+
}
|
|
297
|
+
return count;
|
|
298
|
+
}
|
|
299
|
+
|
|
262
300
|
/**
|
|
263
301
|
* Find the smallest whole-line context expansion of the occurrence at `range`
|
|
264
302
|
* whose text occurs exactly once in the file (checked in fuzzy space, exactly
|
|
@@ -326,6 +364,14 @@ function formatNotFound(opts: FormatFailureOptions, oldText: string): string {
|
|
|
326
364
|
const causes = probeMatchCauses(normalizedContent, oldText);
|
|
327
365
|
|
|
328
366
|
const lines: string[] = [];
|
|
367
|
+
const edit = "editIndex" in opts.failure ? normalizeEdits(opts.edits)[opts.failure.editIndex] : undefined;
|
|
368
|
+
if (edit?.newText && edit.newText !== oldText) {
|
|
369
|
+
const offsets = findAllOccurrences(normalizedContent, edit.newText, 4);
|
|
370
|
+
if (offsets.length) {
|
|
371
|
+
const spans = getLineSpans(normalizedContent);
|
|
372
|
+
lines.push(`Replacement text already appears at ${offsets.map((offset) => { const range = rangeFromOffset(spans, offset, edit.newText.length); return describeLines(range.start, range.end); }).join(", ")} (up to 4 shown). The change may already be applied; verify intent before choosing another target.`, "");
|
|
373
|
+
}
|
|
374
|
+
}
|
|
329
375
|
if (closest) {
|
|
330
376
|
lines.push(
|
|
331
377
|
`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` : ""}).`,
|
|
@@ -353,9 +399,9 @@ function formatNotFound(opts: FormatFailureOptions, oldText: string): string {
|
|
|
353
399
|
);
|
|
354
400
|
}
|
|
355
401
|
} else {
|
|
356
|
-
lines.push(`Differences vs your oldText (${closest.equalCount} of ${closest.totalOldLines} compared lines match):`);
|
|
402
|
+
lines.push(`Differences vs your oldText (${closest.equalCount} of ${closest.totalOldLines} compared lines match exactly):`);
|
|
357
403
|
for (const op of diffOps.slice(0, MAX_DIFF_OPS_SHOWN)) {
|
|
358
|
-
if (op.type === "changed") {
|
|
404
|
+
if (op.type === "changed" || op.type === "similar") {
|
|
359
405
|
lines.push(` file line ${op.fileLine} differs from your oldText line ${op.oldLine}:`);
|
|
360
406
|
lines.push(` file: ${truncateLine(op.fileText ?? "")}`);
|
|
361
407
|
lines.push(` oldText: ${truncateLine(op.oldText ?? "")}`);
|
|
@@ -375,9 +421,12 @@ function formatNotFound(opts: FormatFailureOptions, oldText: string): string {
|
|
|
375
421
|
const candidate = textFromLines(normalizedContent, closest.startLine, closest.endLine);
|
|
376
422
|
const uniqueCandidate = verifyUniqueSnippet(normalizeForFuzzyMatch(normalizedContent), candidate);
|
|
377
423
|
const safelyRenderable = isSnippetRenderable(uniqueCandidate ?? candidate);
|
|
424
|
+
const competitorGap = closest.competitor ? closest.score - closest.competitor.score : Number.POSITIVE_INFINITY;
|
|
378
425
|
const directRetry =
|
|
379
426
|
!closest.truncated &&
|
|
380
427
|
closest.score >= MIN_DIRECT_RETRY_SCORE &&
|
|
428
|
+
!closest.competitionIncomplete &&
|
|
429
|
+
competitorGap >= MIN_DIRECT_RETRY_GAP &&
|
|
381
430
|
uniqueCandidate !== null &&
|
|
382
431
|
safelyRenderable;
|
|
383
432
|
if (directRetry) {
|
|
@@ -389,6 +438,10 @@ function formatNotFound(opts: FormatFailureOptions, oldText: string): string {
|
|
|
389
438
|
const reasons = [
|
|
390
439
|
closest.truncated ? "only part of oldText was compared" : undefined,
|
|
391
440
|
closest.score < MIN_DIRECT_RETRY_SCORE ? "similarity confidence is too low" : undefined,
|
|
441
|
+
closest.competitionIncomplete ? "the bounded search discarded another competitively scored window" : undefined,
|
|
442
|
+
competitorGap < MIN_DIRECT_RETRY_GAP && closest.competitor
|
|
443
|
+
? `a distinct candidate at ${describeLines(closest.competitor.startLine, closest.competitor.endLine)} has a similar heuristic score (~${Math.round(closest.competitor.score * 100)}%)`
|
|
444
|
+
: undefined,
|
|
392
445
|
uniqueCandidate === null ? "the candidate is not unique under edit matching" : undefined,
|
|
393
446
|
!safelyRenderable ? "the exact candidate exceeds the safe output limit" : undefined,
|
|
394
447
|
].filter((reason): reason is string => reason !== undefined);
|
|
@@ -396,11 +449,14 @@ function formatNotFound(opts: FormatFailureOptions, oldText: string): string {
|
|
|
396
449
|
`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
450
|
);
|
|
398
451
|
if (safelyRenderable) lines.push(...renderSnippet(uniqueCandidate ?? candidate));
|
|
452
|
+
lines.push(nextContextCall(opts.path, closest.startLine, closest.endLine));
|
|
453
|
+
if (closest.competitor && competitorGap < MIN_DIRECT_RETRY_GAP) lines.push(nextContextCall(opts.path, closest.competitor.startLine, closest.competitor.endLine));
|
|
399
454
|
}
|
|
400
455
|
|
|
401
456
|
} else {
|
|
402
457
|
lines.push("No reliable similar region was found within the bounded diagnostic search.");
|
|
403
458
|
lines.push("If you expected this text to exist, read the file around the expected location and retry.");
|
|
459
|
+
lines.push(languageForPath(opts.path) ? `Next context call: read_symbol ${JSON.stringify({ path: opts.path })} to locate the intended symbol without guessing line ranges.` : nextContextCall(opts.path, 1));
|
|
404
460
|
}
|
|
405
461
|
|
|
406
462
|
if (causes.length > 0) {
|
|
@@ -412,8 +468,85 @@ function formatNotFound(opts: FormatFailureOptions, oldText: string): string {
|
|
|
412
468
|
}
|
|
413
469
|
|
|
414
470
|
function truncateLine(line: string): string {
|
|
415
|
-
const
|
|
416
|
-
|
|
471
|
+
const visibleTrailing = line.replace(/[ \t]+$/, (suffix) =>
|
|
472
|
+
[...suffix].map((char) => (char === "\t" ? "→tab→" : "·")).join(""),
|
|
473
|
+
);
|
|
474
|
+
const visible = visibleTrailing.replace(/\t/g, "→tab→");
|
|
475
|
+
return visible.length > 120 ? `${visible.slice(0, 117)}…` : visible;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* Enforce the complete output budget without ever cutting a generated fenced
|
|
480
|
+
* snippet. Oversized snippets are omitted atomically and clearly marked.
|
|
481
|
+
*/
|
|
482
|
+
export function boundCompleteOutput(message: string): string {
|
|
483
|
+
const source = message.split("\n");
|
|
484
|
+
const output: string[] = [];
|
|
485
|
+
let bytes = 0;
|
|
486
|
+
let omitted = false;
|
|
487
|
+
const reservedBytes = 512;
|
|
488
|
+
const maxBodyBytes = MAX_OUTPUT_BYTES - reservedBytes;
|
|
489
|
+
const maxBodyLines = MAX_OUTPUT_LINES - 3;
|
|
490
|
+
|
|
491
|
+
const canAdd = (block: string[]) => {
|
|
492
|
+
const text = block.join("\n");
|
|
493
|
+
const addedBytes = Buffer.byteLength(text, "utf8") + (output.length > 0 ? 1 : 0);
|
|
494
|
+
return output.length + block.length <= maxBodyLines && bytes + addedBytes <= maxBodyBytes;
|
|
495
|
+
};
|
|
496
|
+
const add = (block: string[]) => {
|
|
497
|
+
const text = block.join("\n");
|
|
498
|
+
bytes += Buffer.byteLength(text, "utf8") + (output.length > 0 ? 1 : 0);
|
|
499
|
+
output.push(...block);
|
|
500
|
+
};
|
|
501
|
+
|
|
502
|
+
for (let index = 0; index < source.length; index++) {
|
|
503
|
+
const opener = source[index];
|
|
504
|
+
if (/^`{3,}$/.test(opener)) {
|
|
505
|
+
let closing = index + 1;
|
|
506
|
+
while (closing < source.length && source[closing] !== opener) closing++;
|
|
507
|
+
if (closing < source.length) {
|
|
508
|
+
const block = source.slice(index, closing + 1);
|
|
509
|
+
if (canAdd(block)) add(block);
|
|
510
|
+
else {
|
|
511
|
+
omitted = true;
|
|
512
|
+
const notice = ["[Exact fenced snippet omitted to keep the complete tool output within its byte/line budget.]" ];
|
|
513
|
+
if (canAdd(notice)) add(notice);
|
|
514
|
+
}
|
|
515
|
+
index = closing;
|
|
516
|
+
continue;
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
if (canAdd([opener])) {
|
|
521
|
+
add([opener]);
|
|
522
|
+
continue;
|
|
523
|
+
}
|
|
524
|
+
omitted = true;
|
|
525
|
+
const available = Math.max(0, maxBodyBytes - bytes - (output.length > 0 ? 1 : 0));
|
|
526
|
+
if (available > 4 && output.length < maxBodyLines) add([truncateUtf8(opener, available)]);
|
|
527
|
+
break;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
if (omitted) {
|
|
531
|
+
const notice = "[Diagnostic output was bounded. Omitted fenced snippets are not retryable; read the referenced range first.]";
|
|
532
|
+
if (output.length > 0) output.push("");
|
|
533
|
+
output.push(notice);
|
|
534
|
+
}
|
|
535
|
+
return output.join("\n").trimEnd();
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
function truncateUtf8(text: string, maxBytes: number): string {
|
|
539
|
+
if (Buffer.byteLength(text, "utf8") <= maxBytes) return text;
|
|
540
|
+
const suffix = "…";
|
|
541
|
+
const target = Math.max(0, maxBytes - Buffer.byteLength(suffix, "utf8"));
|
|
542
|
+
let low = 0;
|
|
543
|
+
let high = text.length;
|
|
544
|
+
while (low < high) {
|
|
545
|
+
const middle = Math.ceil((low + high) / 2);
|
|
546
|
+
if (Buffer.byteLength(text.slice(0, middle), "utf8") <= target) low = middle;
|
|
547
|
+
else high = middle - 1;
|
|
548
|
+
}
|
|
549
|
+
return `${text.slice(0, low)}${suffix}`;
|
|
417
550
|
}
|
|
418
551
|
|
|
419
552
|
// ---------------------------------------------------------------------------
|
package/src/index.ts
CHANGED
|
@@ -1,17 +1,26 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* pi-better-tool — better built-in tools for the pi coding agent.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* - `edit` —
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* Ships a safe edit override and a separate syntax-aware source reader:
|
|
5
|
+
* - `edit` — exact replacement with actionable recovery context.
|
|
6
|
+
* - `read_symbol` — whole symbols by containing line or exact name.
|
|
7
|
+
* Built-in `read` remains available for text, images, and explicit line ranges.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
11
11
|
import { registerBetterEditTool } from "./tool.ts";
|
|
12
|
+
import { registerReadSymbolTool } from "./read-symbol.ts";
|
|
13
|
+
|
|
14
|
+
export { executeReadSymbol, registerReadSymbolTool, readSymbolSchema } from "./read-symbol.ts";
|
|
15
|
+
export type { ReadSymbolInput, ReadSymbolResult } from "./read-symbol.ts";
|
|
12
16
|
|
|
13
17
|
export { registerBetterEditTool, executeBetterEdit, prepareEditArguments, betterEditSchema } from "./tool.ts";
|
|
14
|
-
export type {
|
|
18
|
+
export type {
|
|
19
|
+
BetterEditExecutionOptions,
|
|
20
|
+
BetterEditInput,
|
|
21
|
+
BetterEditOperations,
|
|
22
|
+
BetterEditSuccess,
|
|
23
|
+
} from "./tool.ts";
|
|
15
24
|
export {
|
|
16
25
|
formatAutoDisambiguationSuccess,
|
|
17
26
|
formatEditFailure,
|
|
@@ -24,4 +33,5 @@ export type { AnalyzeOptions, EditFailure, EditOp, EditAnalysis } from "./apply.
|
|
|
24
33
|
|
|
25
34
|
export default function (pi: ExtensionAPI) {
|
|
26
35
|
registerBetterEditTool(pi);
|
|
36
|
+
registerReadSymbolTool(pi);
|
|
27
37
|
}
|
package/src/paths.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { isAbsolute, join, resolve } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
|
|
5
|
+
const UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g;
|
|
6
|
+
|
|
7
|
+
/** Match Pi's built-in path normalization for all local tools in this package. */
|
|
8
|
+
export function resolveToolPath(input: string, cwd: string): string {
|
|
9
|
+
let path = input.replace(UNICODE_SPACES, " ");
|
|
10
|
+
if (path.startsWith("@")) path = path.slice(1);
|
|
11
|
+
if (process.platform === "win32" && path.startsWith("/") && !path.startsWith("//") && !path.includes("\\")) {
|
|
12
|
+
const match = path.match(/^\/(?:mnt\/|cygdrive\/)?([a-z])(?:\/(.*))?$/i);
|
|
13
|
+
if (match) path = `${match[1].toUpperCase()}:\\${match[2]?.replaceAll("/", "\\") ?? ""}`;
|
|
14
|
+
}
|
|
15
|
+
if (path === "~") path = homedir();
|
|
16
|
+
else if (path.startsWith("~/") || (process.platform === "win32" && path.startsWith("~\\"))) path = join(homedir(), path.slice(2));
|
|
17
|
+
if (/^file:\/\//.test(path)) path = fileURLToPath(path);
|
|
18
|
+
return isAbsolute(path) ? resolve(path) : resolve(cwd, path);
|
|
19
|
+
}
|
package/src/read-evidence.ts
CHANGED
|
@@ -6,34 +6,52 @@ import {
|
|
|
6
6
|
type ExtensionContext,
|
|
7
7
|
} from "@earendil-works/pi-coding-agent";
|
|
8
8
|
import { normalizeToLF } from "./text.ts";
|
|
9
|
+
import { buildSymbolRead, type ReadSymbolInput } from "./read-symbol.ts";
|
|
9
10
|
|
|
10
11
|
export interface ReadEvidence {
|
|
11
12
|
/** 0-based, end-exclusive offsets in LF-normalized, BOM-stripped content. */
|
|
12
13
|
startOffset: number;
|
|
13
14
|
endOffset: number;
|
|
14
|
-
/** 1-based inclusive range
|
|
15
|
+
/** 1-based inclusive range represented by the verified read output. */
|
|
15
16
|
startLine: number;
|
|
16
17
|
endLine: number;
|
|
17
18
|
}
|
|
18
19
|
|
|
19
20
|
interface ReadCall {
|
|
21
|
+
id: string;
|
|
22
|
+
name: "read" | "read_symbol";
|
|
23
|
+
arguments: Record<string, unknown>;
|
|
20
24
|
path: string;
|
|
21
25
|
offset?: number;
|
|
22
26
|
limit?: number;
|
|
23
27
|
}
|
|
24
28
|
|
|
25
29
|
interface StoredToolResult {
|
|
30
|
+
role: "toolResult";
|
|
26
31
|
toolCallId: string;
|
|
27
32
|
toolName: string;
|
|
28
33
|
isError: boolean;
|
|
29
34
|
content: Array<{ type: string; text?: string }>;
|
|
30
35
|
}
|
|
31
36
|
|
|
37
|
+
interface StoredAssistant {
|
|
38
|
+
role: "assistant";
|
|
39
|
+
content: Array<{ type: string; id?: string; name?: string; arguments?: unknown }>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface ContextEntryLike {
|
|
43
|
+
type: string;
|
|
44
|
+
message?: StoredAssistant | StoredToolResult | { role: string };
|
|
45
|
+
retainedTail?: Array<StoredAssistant | StoredToolResult | { role: string }>;
|
|
46
|
+
}
|
|
47
|
+
|
|
32
48
|
/**
|
|
33
|
-
* Find the newest
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
49
|
+
* Find the newest read call for targetPath in Pi's compaction-aware stored
|
|
50
|
+
* context. The call is accepted only when its successful result exactly
|
|
51
|
+
* matches the built-in read formatting for the current LF-normalized content.
|
|
52
|
+
*
|
|
53
|
+
* This establishes stored-context evidence, not proof of the final provider
|
|
54
|
+
* payload after other extensions' context/provider hooks.
|
|
37
55
|
*/
|
|
38
56
|
export async function findLatestReadEvidence(
|
|
39
57
|
sessionManager: Pick<ExtensionContext["sessionManager"], "buildContextEntries"> | undefined,
|
|
@@ -42,49 +60,70 @@ export async function findLatestReadEvidence(
|
|
|
42
60
|
resolvePath: (path: string) => Promise<string>,
|
|
43
61
|
): Promise<ReadEvidence | null> {
|
|
44
62
|
if (!sessionManager) return null;
|
|
45
|
-
const entries = sessionManager.buildContextEntries();
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
63
|
+
const entries = sessionManager.buildContextEntries() as ContextEntryLike[];
|
|
64
|
+
const messages = entries.flatMap((entry) => {
|
|
65
|
+
if (entry.type === "message" && entry.message) return [entry.message];
|
|
66
|
+
if (entry.type === "compaction" && Array.isArray(entry.retainedTail)) return entry.retainedTail;
|
|
67
|
+
return [];
|
|
68
|
+
});
|
|
69
|
+
const calls: ReadCall[] = [];
|
|
70
|
+
const results = new Map<string, StoredToolResult>();
|
|
71
|
+
|
|
72
|
+
for (const message of messages) {
|
|
73
|
+
if (message.role === "assistant") {
|
|
74
|
+
for (const item of (message as StoredAssistant).content) {
|
|
75
|
+
if (item.type !== "toolCall" || (item.name !== "read" && item.name !== "read_symbol") || typeof item.id !== "string") continue;
|
|
76
|
+
const args = item.arguments as Record<string, unknown> | undefined;
|
|
77
|
+
if (!args || typeof args.path !== "string") continue;
|
|
78
|
+
calls.push({
|
|
79
|
+
id: item.id,
|
|
80
|
+
name: item.name,
|
|
81
|
+
arguments: args,
|
|
82
|
+
path: args.path,
|
|
83
|
+
offset: typeof args.offset === "number" ? args.offset : undefined,
|
|
84
|
+
limit: typeof args.limit === "number" ? args.limit : undefined,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
} else if (message.role === "toolResult") {
|
|
88
|
+
const result = message as StoredToolResult;
|
|
89
|
+
if (result.toolName === "read" || result.toolName === "read_symbol") results.set(result.toolCallId, result);
|
|
59
90
|
}
|
|
60
91
|
}
|
|
61
92
|
|
|
62
|
-
for (let
|
|
63
|
-
const
|
|
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
|
-
|
|
93
|
+
for (let index = calls.length - 1; index >= 0; index--) {
|
|
94
|
+
const call = calls[index];
|
|
70
95
|
let readPath: string;
|
|
71
96
|
try {
|
|
72
97
|
readPath = await resolvePath(call.path);
|
|
73
98
|
} catch {
|
|
74
|
-
|
|
99
|
+
// Without a canonical identity we cannot prove that this newer read is
|
|
100
|
+
// unrelated, so fail closed instead of falling back to older intent.
|
|
101
|
+
return null;
|
|
75
102
|
}
|
|
76
103
|
if (readPath !== targetPath) continue;
|
|
77
104
|
|
|
78
|
-
//
|
|
79
|
-
//
|
|
105
|
+
// Never fall back to older intent when the newest same-file read is
|
|
106
|
+
// missing, failed, malformed, or stale.
|
|
107
|
+
const result = results.get(call.id);
|
|
108
|
+
if (!result || result.isError || result.toolName !== call.name) return null;
|
|
109
|
+
if (call.name === "read_symbol") {
|
|
110
|
+
if (result.content.length !== 1 || result.content[0].type !== "text") return null;
|
|
111
|
+
try {
|
|
112
|
+
// Regenerate from current source and original arguments, not untrusted
|
|
113
|
+
// details. Snapshot, selector, envelope, and visible bytes must agree.
|
|
114
|
+
const expected = await buildSymbolRead(call.arguments as ReadSymbolInput, normalizedContent);
|
|
115
|
+
if (result.content[0].text !== expected.content[0].text) return null;
|
|
116
|
+
return expected.details.visible ?? null;
|
|
117
|
+
} catch { return null; }
|
|
118
|
+
}
|
|
80
119
|
return evidenceFromBuiltinRead(normalizedContent, call, result.content);
|
|
81
120
|
}
|
|
82
121
|
return null;
|
|
83
122
|
}
|
|
84
123
|
|
|
85
|
-
function evidenceFromBuiltinRead(
|
|
124
|
+
export function evidenceFromBuiltinRead(
|
|
86
125
|
content: string,
|
|
87
|
-
call: ReadCall,
|
|
126
|
+
call: Pick<ReadCall, "path" | "offset" | "limit">,
|
|
88
127
|
blocks: Array<{ type: string; text?: string }>,
|
|
89
128
|
): ReadEvidence | null {
|
|
90
129
|
if (blocks.length !== 1 || blocks[0].type !== "text" || typeof blocks[0].text !== "string") return null;
|
|
@@ -131,7 +170,9 @@ function evidenceFromBuiltinRead(
|
|
|
131
170
|
const startLine = startIndex + 1;
|
|
132
171
|
const endLine = startLine + visibleLines - 1;
|
|
133
172
|
const startOffset = offsetAtLine(content, startLine);
|
|
134
|
-
|
|
173
|
+
// A line-limited/truncated result omits the separator after its final
|
|
174
|
+
// displayed line. Do not authorize an edit anchor through unseen bytes.
|
|
175
|
+
const endOffset = startOffset + truncation.content.length;
|
|
135
176
|
return { startOffset, endOffset, startLine, endLine };
|
|
136
177
|
}
|
|
137
178
|
|