@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.
@@ -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
- * 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
- 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 offsets = findAllOccurrences(newContent, oldText);
94
- if (offsets.length === 0) {
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 (${Math.min(offsets.length, MAX_REMAINING_OCCURRENCES)} shown${offsets.length > MAX_REMAINING_OCCURRENCES ? `, ${offsets.length - MAX_REMAINING_OCCURRENCES} more omitted` : ""}):`);
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 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.]`;
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.occurrenceOffsets.length} occurrences of the text in ${path}. The text must be unique. Please provide more context to make it unique.`
160
- : `Found ${failure.occurrenceOffsets.length} occurrences of edits[${failure.editIndex}] in ${path}. Each oldText must be unique. Please provide more context to make it unique.`;
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
- return `edits[${firstEditIndex}] and edits[${secondEditIndex}] overlap in ${path} (edits[${firstEditIndex}] covers lines ${firstRange.start}-${firstRange.end}, edits[${secondEditIndex}] covers lines ${secondRange.start}-${secondRange.end}). Merge them into one edit or target disjoint regions.`;
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.occurrenceOffsets.length > listed.length) {
201
- lines.push(` … and ${failure.occurrenceOffsets.length - listed.length} more`);
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 collapsed = line.replace(/\t/g, "→tab→");
416
- return collapsed.length > 120 ? `${collapsed.slice(0, 117)}…` : collapsed;
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
- * Currently ships one override:
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.
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 { BetterEditInput } from "./tool.ts";
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
+ }
@@ -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 actually shown to the model. */
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 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.
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 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
- });
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 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
-
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
- continue;
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
- // 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.
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
- const endOffset = offsetAtLine(content, endLine + 1);
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