@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.
@@ -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
- * a per-line comparison, and the exact file bytes to retry with
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.occurrenceOffsets.length} occurrences of the text in ${path}. The text must be unique. Please provide more context to make it unique.`
86
- : `Found ${failure.occurrenceOffsets.length} occurrences of edits[${failure.editIndex}] in ${path}. Each oldText must be unique. Please provide more context to make it unique.`;
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.occurrenceOffsets.length > listed.length) {
127
- lines.push(` … and ${failure.occurrenceOffsets.length - listed.length} more`);
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
- "Retry with a disambiguated oldText: pick ONE occurrence below and reuse its snippet exactly. Each snippet already includes the minimum surrounding context that makes it unique:",
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, `lines ${expansion.startLine}-${expansion.endLine}`));
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: use the snippet byte-for-byte as the new oldText, and make newText the snippet with your change applied (the snippet may span whole lines).",
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
- lines.push(
294
- `Exact file content at ${describeLines(closest.startLine, closest.endLine)} — retry using this text as oldText (then apply your change to newText):`,
295
- );
296
- lines.push(...snippetFromLines(normalizedContent, closest.startLine, closest.endLine));
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 in the file (best similarity below threshold).");
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 collapsed = line.replace(/\t/g, "→tab→");
312
- return collapsed.length > 120 ? `${collapsed.slice(0, 117)}…` : collapsed;
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 snippetFromLines(content: string, startLine: number, endLine: number): string[] {
526
+ function textFromLines(content: string, startLine: number, endLine: number): string {
320
527
  const spans = getLineSpans(content);
321
- const raw = content.slice(spans[startLine - 1].start, spans[endLine - 1].end);
322
- const text = raw.endsWith("\n") ? raw.slice(0, -1) : raw;
323
- return renderSnippet(text, `lines ${startLine}-${endLine}`);
528
+ return content.slice(spans[startLine - 1].start, spans[endLine - 1].end);
324
529
  }
325
530
 
326
- function renderSnippet(text: string, where: string): string[] {
327
- const fence = fenceFor(text);
328
- const allLines = text.split("\n");
329
- if (allLines.length <= MAX_SNIPPET_LINES) {
330
- return [fence, text, fence];
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 head = allLines.slice(0, MAX_SNIPPET_LINES - 10);
333
- const tail = allLines.slice(-10);
334
- const skipped = allLines.length - head.length - tail.length;
335
- return [
336
- fence,
337
- ...head,
338
- `… (${skipped} middle lines snipped — see ${where}; re-read that range if you need the full text)`,
339
- ...tail,
340
- fence,
341
- ];
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` — identical matching semantics to the built-in edit tool, but
6
- * failures return recovery context (closest matching region, per-occurrence
7
- * minimal disambiguation snippets) so the agent can retry immediately
8
- * without re-reading the file.
5
+ * - `edit` — built-in-compatible exact replacement with richer recovery
6
+ * context and conservative read-based resolution when a recent verified
7
+ * read contains exactly one of several literal occurrences.
9
8
  */
10
9
 
11
10
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
12
11
  import { registerBetterEditTool } from "./tool.ts";
13
12
 
14
13
  export { registerBetterEditTool, executeBetterEdit, prepareEditArguments, betterEditSchema } from "./tool.ts";
15
- export type { BetterEditInput } from "./tool.ts";
16
- export { formatEditFailure, findMinimalUniqueExpansion, fenceFor } from "./diagnostics.ts";
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
+ }