@khanhicetea/pi-better-tool 0.2.1 → 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 CHANGED
@@ -1,20 +1,16 @@
1
1
  # pi-better-tool
2
2
 
3
- Better built-in tools for the [pi coding agent](https://github.com/earendil-works/pi-mono) — starting with an `edit` override that turns failed edits into recoverable ones.
3
+ Better built-in tools for the [pi coding agent](https://github.com/earendil-works/pi-mono) — starting with an `edit` override that turns many failed edits into recoverable ones.
4
4
 
5
5
  ## Why
6
6
 
7
- The built-in `edit` tool requires `edits[].oldText` to match **exactly and uniquely**. When it doesn't, the tool fails with a bare error:
7
+ Pi's built-in `edit` requires every `edits[].oldText` to identify one non-overlapping region. When matching fails, this override returns bounded context that can make the next action safer:
8
8
 
9
- ```
10
- Could not find edits[1] in /code/app.go. The oldText must match exactly including all whitespace and newlines.
11
- ```
9
+ - **Ambiguous literal match after a bounded read** — selects an occurrence only when exactly one tracked literal occurrence is fully contained in the newest verified stored-context `read` of the same file.
10
+ - **Other ambiguous matches** reports bounded occurrence ranges and whole-line prefix/suffix expansions that are unique under edit matching.
11
+ - **Text not found** — reports a bounded closest-region comparison. It gives direct-retry wording only when the exact candidate is unique, sufficiently similar, and meaningfully better than a distinct runner-up.
12
12
 
13
- That failure wastes a whole loop: the model has to `read` the file again, guess a larger context, and retry — sometimes failing again. `pi-better-tool` resolves only ambiguity supported by verified evidence and otherwise returns **recovery context** so the next call succeeds without re-reading:
14
-
15
- - **Ambiguous literal match after a bounded read** — when exactly one occurrence was fully visible in the latest successful, byte-verified `read` of the same file, edit selects that occurrence. Its success message reports the selected/read ranges and up to four remaining occurrences with effective prefix/suffix context and retryable snippets.
16
- - **Other ambiguous matches (2+ occurrences)** — bounded occurrence line numbers plus the **minimum prefix/suffix context** that makes the first few occurrences unique, rendered as ready-to-use `oldText` snippets when they fit safely.
17
- - **Text not found** — the closest matching region (fuzzy line similarity), a line-by-line comparison against your `oldText`, the exact file bytes to retry with, and likely causes (tabs vs spaces, indentation, case).
13
+ Low-confidence, competing, stale, oversized, or omitted candidates tell the model to read the referenced range instead of retrying blindly.
18
14
 
19
15
  ## Install
20
16
 
@@ -32,14 +28,14 @@ It is also registered in the root `package.json` under `pi.extensions`.
32
28
 
33
29
  ## Example: ambiguous oldText
34
30
 
35
- ```text
31
+ ````text
36
32
  Found 2 occurrences of the text in dup.go. The text must be unique. Please provide more context to make it unique.
37
33
 
38
34
  Occurrences:
39
35
  1. lines 2-3
40
36
  2. lines 6-7
41
37
 
42
- 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:
38
+ 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:
43
39
 
44
40
  Occurrence 1 (lines 2-3) — minimum context: 0 lines before, 1 line after:
45
41
  ```
@@ -49,54 +45,82 @@ Occurrence 1 (lines 2-3) — minimum context: 0 lines before, 1 line after:
49
45
  func second() {
50
46
  ```
51
47
 
52
- Occurrence 2 (lines 6-7) minimum context: 0 lines before, 0 lines after:
48
+ Tip: only fenced snippets explicitly presented as retryable should be copied into oldText.
49
+
50
+ No changes were written — the file was not modified.
51
+ ````
52
+
53
+ ## Example: competing closest matches
54
+
55
+ ````text
56
+ Could not find the exact text in handlers.ts. The old text must match exactly including all whitespace and newlines.
57
+
58
+ Closest match in the file: lines 10-12 (~91% line similarity).
59
+ ...
60
+ Candidate file content at lines 10-12 is not safe for a direct retry (a distinct candidate at lines 30-32 has a similar heuristic score (~90%)). Read and verify this range before editing.
53
61
  ```
54
- log()
62
+ function firstHandler() {
63
+ work();
55
64
  }
56
65
  ```
57
66
 
58
- 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).
59
-
60
67
  No changes were written — the file was not modified.
61
- ```
68
+ ````
62
69
 
63
- ## Example: text not found
70
+ Similarity scores are heuristics, not probabilities.
64
71
 
65
- ```text
66
- Could not find the exact text in tabs.go. The old text must match exactly including all whitespace and newlines.
72
+ ## Behavior and compatibility
67
73
 
68
- Closest match in the file: lines 3-5 (~91% line similarity).
69
- Differences vs your oldText (2 of 3 compared lines match):
70
- file line 4 differs from your oldText line 2:
71
- file: →tab→fmt.Println("hi")
72
- oldText: fmt.Println("hi")
74
+ The normal matching path follows Pi's built-in edit implementation:
73
75
 
74
- Exact file content at lines 3-5 retry using this text as oldText (then apply your change to newText):
75
- ```
76
- func main() {
77
- fmt.Println("hi")
78
- }
79
- ```
76
+ - exact match first, then fuzzy fallback for trailing whitespace, smart quotes, dashes, Unicode compatibility forms, and Unicode spaces
77
+ - uniqueness checked in fuzzy-normalized space
78
+ - all edits matched against the original content rather than applied incrementally
79
+ - overlap and no-change detection
80
+ - CRLF restoration and UTF-8 BOM preservation
81
+ - built-in-compatible success details (`details.diff`, `details.patch`, and `details.firstChangedLine`)
82
+ - no custom renderers, so Pi's built-in edit renderer is inherited
80
83
 
81
- Possible cause:
82
- - whitespace mismatch: the text matches when ALL whitespace is removed — check tabs vs spaces and indentation width
84
+ Intentional safety/compatibility differences are:
83
85
 
84
- No changes were written the file was not modified.
85
- ```
86
+ - 1–100 edits are accepted per call; empty batches are rejected by the public schema
87
+ - empty and fuzzy-normalized-empty `oldText` values are rejected
88
+ - invalid UTF-8 and NUL-containing files are rejected rather than silently transcoded
89
+ - conservative stored-read-based selection may resolve repeated literal text
90
+ - self-overlapping string occurrences retain Pi's non-overlapping counting policy
91
+
92
+ The argument compatibility shim accepts `edits` as an array, JSON string, single edit object, or legacy top-level `oldText`/`newText`. Preparation is pure and idempotent.
93
+
94
+ ### Read-evidence boundary
95
+
96
+ Read evidence is taken from Pi's active, compaction-aware **stored session context**. Retained-tail messages are handled when the host exposes them. The newest same-file read must have a matching successful result and reproduce built-in read formatting for the current LF-normalized content. Missing, failed, malformed, or stale newest evidence blocks fallback to older intent.
97
+
98
+ This is not proof of the final provider payload: another extension may remove messages in a `context` hook or rewrite the provider request. CRLF read output is intentionally compared after LF normalization, so this guarantee is content/format verification rather than literal byte identity. BOM-bearing read output is conservatively rejected because edit matching strips the BOM. Fuzzy/Unicode-equivalent ambiguity and highly repetitive files also fail closed.
99
+
100
+ ### Local filesystem and commit guarantees
86
101
 
87
- ## Behavior
102
+ This override uses local Node.js filesystem operations. It does **not** inherit an SSH, container, sandbox, or other custom edit backend.
88
103
 
89
- Normal unique-match semantics are **identical** to the built-in `edit` tool. The one intentional extension is conservative read-based selection for repeated literal text:
104
+ All replacements are analyzed before writing, so a matching/overlap/no-change/diagnostic failure starts no write. Immediately before writing, the tool performs a best-effort content and file-identity recheck to catch many external modifications.
90
105
 
91
- - same schema (`path` + `edits[{oldText,newText}]`), including the compatibility shim for models that send `edits` as a JSON string, a single edit object, or legacy top-level `oldText`/`newText`
92
- - same matching engine ported from pi's `edit-diff.ts`: exact match first, fuzzy fallback (trailing whitespace, smart quotes, dashes, unicode spaces), uniqueness checked in fuzzy-normalized space, all edits matched against the original content, overlap/empty/no-change detection
93
- - same BOM and CRLF handling
94
- - same success result shape (`details.diff` / `details.patch` / `details.firstChangedLine`), and no custom renderers — the built-in diff renderer is inherited
95
- - read-based selection fails closed unless the newest same-file read is still in active context, its stored output exactly matches the current bytes and built-in read formatting, and exactly one complete literal occurrence lies inside the visible range; fuzzy/Unicode-equivalent ambiguity, same-line ambiguity, stale reads, and broad reads containing multiple occurrences still refuse
106
+ The final write is still an in-place filesystem overwrite:
96
107
 
97
- Failure behavior is the other difference: errors carry the recovery context described above, and nothing is written on failure (edits remain atomic).
108
+ - it is not a cross-process lock or race-free compare-and-swap
109
+ - it is not rollback- or crash-safe filesystem atomicity
110
+ - another process can modify the file after the pre-write check
111
+ - a rejected write may leave the file unchanged, partially written, or fully written; inspect it before retrying
98
112
 
99
- Diagnostics degrade gracefully: files over ~2 MB skip the analysis and return the plain built-in-style error; repeated blocks that cannot be disambiguated within 12 context lines get a guidance note instead of snippets. Complete diagnostic output is bounded below pi's 50 KB / 2,000-line tool-output limit. Oversized exact snippets are omitted with a line range instead of being presented as copyable text, and low-confidence or non-unique closest matches require verification before retrying.
113
+ A resolved write is the tool's commit boundary. The extension returns the committed result rather than throwing a post-write cancellation error. A host may still suppress result delivery when cancelling the surrounding tool run; cancellation cannot roll back a completed filesystem write. Temporary-file/rename replacement is deliberately not used because it can replace symlinks, break hard-link semantics, or alter metadata without a carefully defined cross-platform policy.
114
+
115
+ ### Output and resource bounds
116
+
117
+ Diagnostic text is kept below Pi's 50 KB / 2,000-line tool-output limits. Markdown snippets are added atomically so a fence is never cut; oversized snippets are omitted with read guidance. Success expansion is skipped for oversized files. Closest-match work has explicit query, line, and operation budgets and falls back to concise read guidance when exhausted.
118
+
119
+ `details.diff` and `details.patch` remain complete for renderer compatibility and are not blindly truncated as diagnostic text.
120
+
121
+ ## Host compatibility
122
+
123
+ Pi's packaging guidance requires wildcard peer dependencies for Pi core packages. This package follows that guidance rather than bundling Pi. Version 0.2.1 is typechecked and tested against `@earendil-works/pi-coding-agent` 0.82.1; host upgrades should run the read-format, exported-helper, renderer-shape, and session-context compatibility tests.
100
124
 
101
125
  ## Development
102
126
 
@@ -107,7 +131,7 @@ npm test # vitest only
107
131
 
108
132
  ## Publishing
109
133
 
110
- From the repository root, verify the package and inspect its tarball before publishing:
134
+ From the repository root:
111
135
 
112
136
  ```bash
113
137
  npm run check --workspace=@khanhicetea/pi-better-tool
@@ -115,8 +139,6 @@ npm pack --dry-run --workspace=@khanhicetea/pi-better-tool
115
139
  npm publish --workspace=@khanhicetea/pi-better-tool
116
140
  ```
117
141
 
118
- The package is configured for public publishing under the `@khanhicetea` scope. npm authentication is required.
119
-
120
142
  ## License
121
143
 
122
144
  MIT
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@khanhicetea/pi-better-tool",
3
- "version": "0.2.1",
3
+ "version": "0.2.2",
4
4
  "description": "Better built-in tools for pi: an edit tool override that returns recovery context (closest match + disambiguation snippets) instead of bare failures",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/apply.ts CHANGED
@@ -1,12 +1,12 @@
1
1
  /**
2
2
  * Edit matching and application engine.
3
3
  *
4
- * A port of pi's built-in `applyEditsToNormalizedContent` with one key
5
- * difference: instead of throwing opaque errors, `analyzeEdits` returns a
6
- * structured failure describing *why* an edit failed, which the diagnostics
7
- * layer turns into actionable recovery context for the model.
4
+ * A port of pi's built-in `applyEditsToNormalizedContent`. Instead of throwing
5
+ * opaque errors, `analyzeEdits` returns a structured failure describing *why*
6
+ * an edit failed. It also rejects fuzzy-normalized-empty needles and can accept
7
+ * a conservatively verified literal selection as intentional safety extensions.
8
8
  *
9
- * Matching semantics are kept identical to the built-in tool:
9
+ * Otherwise matching semantics follow the built-in tool:
10
10
  * - exact match first, then fuzzy-normalized fallback (trailing whitespace,
11
11
  * smart quotes, dashes, unicode spaces)
12
12
  * - uniqueness is always checked in fully fuzzy-normalized space
@@ -18,10 +18,11 @@ import {
18
18
  countFuzzyOccurrences,
19
19
  findAllOccurrences,
20
20
  getLineSpans,
21
+ getLogicalLineSpans,
21
22
  lineAt,
22
23
  normalizeForFuzzyMatch,
23
24
  normalizeToLF,
24
- splitLinesWithEndings,
25
+ splitLogicalLinesWithEndings,
25
26
  type LineSpan,
26
27
  } from "./text.ts";
27
28
 
@@ -42,7 +43,9 @@ export type EditFailure =
42
43
  | {
43
44
  kind: "ambiguous";
44
45
  editIndex: number;
45
- /** Offsets of every occurrence, enumerated in fully fuzzy-normalized space. */
46
+ /** Total non-overlapping occurrences in fully fuzzy-normalized space. */
47
+ occurrenceCount: number;
48
+ /** A bounded prefix of occurrence offsets for diagnostics/selection. */
46
49
  occurrenceOffsets: number[];
47
50
  }
48
51
  | {
@@ -85,6 +88,9 @@ export interface AnalyzeOptions {
85
88
  ambiguousSelections?: ReadonlyMap<number, number>;
86
89
  }
87
90
 
91
+ /** Avoid materializing unbounded offset arrays for highly repetitive files. */
92
+ const MAX_TRACKED_OCCURRENCE_OFFSETS = 256;
93
+
88
94
  export function normalizeEdits(edits: EditOp[]): EditOp[] {
89
95
  return edits.map((edit) => ({
90
96
  oldText: normalizeToLF(edit.oldText),
@@ -159,7 +165,11 @@ export function analyzeEdits(normalizedContent: string, rawEdits: EditOp[], opti
159
165
  const occurrences = countFuzzyOccurrences(fuzzyBase, edit.oldText);
160
166
  let selectedMatch = matchResult;
161
167
  if (occurrences > 1) {
162
- const occurrenceOffsets = findAllOccurrences(fuzzyBase, normalizeForFuzzyMatch(edit.oldText));
168
+ const occurrenceOffsets = findAllOccurrences(
169
+ fuzzyBase,
170
+ normalizeForFuzzyMatch(edit.oldText),
171
+ MAX_TRACKED_OCCURRENCE_OFFSETS,
172
+ );
163
173
  const selectedOffset = options.ambiguousSelections?.get(i);
164
174
  if (
165
175
  selectedOffset === undefined ||
@@ -167,7 +177,7 @@ export function analyzeEdits(normalizedContent: string, rawEdits: EditOp[], opti
167
177
  ) {
168
178
  return {
169
179
  ok: false,
170
- failure: { kind: "ambiguous", editIndex: i, occurrenceOffsets },
180
+ failure: { kind: "ambiguous", editIndex: i, occurrenceCount: occurrences, occurrenceOffsets },
171
181
  };
172
182
  }
173
183
  selectedMatch = {
@@ -257,10 +267,10 @@ function applyReplacementsPreservingUnchangedLines(
257
267
  baseContent: string,
258
268
  replacements: Replacement[],
259
269
  ): string {
260
- const originalLines = splitLinesWithEndings(originalContent);
261
- const baseLines = getLineSpans(baseContent);
270
+ const originalLines = splitLogicalLinesWithEndings(originalContent);
271
+ const baseLines = getLogicalLineSpans(baseContent);
262
272
  if (originalLines.length !== baseLines.length) {
263
- throw new Error("Cannot preserve unchanged lines because the base content has a different line count.");
273
+ throw new Error("Cannot preserve unchanged lines because the base content has a different logical line count.");
264
274
  }
265
275
 
266
276
  const groups: Array<InternalLineWindow & { replacements: Replacement[] }> = [];
@@ -8,10 +8,9 @@
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";
17
16
  import { findClosestRegion, lineSimilarity, probeMatchCauses } from "./similarity.ts";
@@ -41,6 +40,8 @@ const MAX_OUTPUT_LINES = 1_500;
41
40
  const MAX_OUTPUT_BYTES = 48 * 1024;
42
41
  /** Minimum score at which a unique closest region may be suggested directly. */
43
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;
44
45
  /** Max non-equal alignment ops rendered. */
45
46
  const MAX_DIFF_OPS_SHOWN = 12;
46
47
 
@@ -81,22 +82,32 @@ export function formatAutoDisambiguationSuccess(
81
82
  newContent: string,
82
83
  resolutions: AutoDisambiguation[],
83
84
  ): string {
84
- if (resolutions.length === 0) return baseMessage;
85
+ if (resolutions.length === 0) return boundCompleteOutput(baseMessage);
85
86
  const lines = [baseMessage, ""];
86
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
+ }
87
97
 
88
98
  for (const resolution of shownResolutions) {
89
99
  lines.push(
90
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)}.`,
91
101
  );
92
102
  const oldText = normalizeToLF(resolution.oldText);
93
- const offsets = findAllOccurrences(newContent, oldText);
94
- if (offsets.length === 0) {
103
+ const remainingCount = countLiteralOccurrences(newContent, oldText);
104
+ const offsets = findAllOccurrences(newContent, oldText, MAX_REMAINING_OCCURRENCES);
105
+ if (remainingCount === 0) {
95
106
  lines.push("No exact occurrences of the original oldText remain after this edit.", "");
96
107
  continue;
97
108
  }
98
109
 
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` : ""}):`);
110
+ lines.push(`Remaining exact occurrences of the original oldText (${offsets.length} shown${remainingCount > offsets.length ? `, ${remainingCount - offsets.length} more omitted` : ""}):`);
100
111
  const fuzzyContent = normalizeForFuzzyMatch(newContent);
101
112
  const spans = getLineSpans(newContent);
102
113
  for (const [index, offset] of offsets.slice(0, MAX_REMAINING_OCCURRENCES).entries()) {
@@ -119,14 +130,11 @@ export function formatAutoDisambiguationSuccess(
119
130
  if (resolutions.length > shownResolutions.length) {
120
131
  lines.push(`${resolutions.length - shownResolutions.length} more auto-disambiguated edits were applied; details omitted.`);
121
132
  }
122
- return lines.join("\n").trimEnd();
133
+ return boundCompleteOutput(lines.join("\n").trimEnd());
123
134
  }
124
135
 
125
136
  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.]`;
137
+ return boundCompleteOutput(formatEditFailureUnbounded(opts));
130
138
  }
131
139
 
132
140
  function formatEditFailureUnbounded(opts: FormatFailureOptions): string {
@@ -156,8 +164,8 @@ function formatEditFailureUnbounded(opts: FormatFailureOptions): string {
156
164
  case "ambiguous": {
157
165
  const head =
158
166
  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.`;
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.`;
161
169
  const body =
162
170
  normalizedContent.length <= MAX_CONTENT_FOR_DIAGNOSTICS
163
171
  ? formatAmbiguous(opts, failure)
@@ -197,8 +205,8 @@ function formatAmbiguous(opts: FormatFailureOptions, failure: Extract<EditFailur
197
205
  const range = rangeFromOffset(fuzzySpans, offset, fuzzyOld.length);
198
206
  lines.push(` ${i + 1}. ${describeLines(range.start, range.end)}`);
199
207
  });
200
- if (failure.occurrenceOffsets.length > listed.length) {
201
- lines.push(` … and ${failure.occurrenceOffsets.length - listed.length} more`);
208
+ if (failure.occurrenceCount > listed.length) {
209
+ lines.push(` … and ${failure.occurrenceCount - listed.length} more`);
202
210
  }
203
211
  lines.push("");
204
212
 
@@ -259,6 +267,17 @@ function plural(n: number, noun: string): string {
259
267
  return `${n} ${noun}${n === 1 ? "" : "s"}`;
260
268
  }
261
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
+
262
281
  /**
263
282
  * Find the smallest whole-line context expansion of the occurrence at `range`
264
283
  * whose text occurs exactly once in the file (checked in fuzzy space, exactly
@@ -353,9 +372,9 @@ function formatNotFound(opts: FormatFailureOptions, oldText: string): string {
353
372
  );
354
373
  }
355
374
  } else {
356
- 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):`);
357
376
  for (const op of diffOps.slice(0, MAX_DIFF_OPS_SHOWN)) {
358
- if (op.type === "changed") {
377
+ if (op.type === "changed" || op.type === "similar") {
359
378
  lines.push(` file line ${op.fileLine} differs from your oldText line ${op.oldLine}:`);
360
379
  lines.push(` file: ${truncateLine(op.fileText ?? "")}`);
361
380
  lines.push(` oldText: ${truncateLine(op.oldText ?? "")}`);
@@ -375,9 +394,12 @@ function formatNotFound(opts: FormatFailureOptions, oldText: string): string {
375
394
  const candidate = textFromLines(normalizedContent, closest.startLine, closest.endLine);
376
395
  const uniqueCandidate = verifyUniqueSnippet(normalizeForFuzzyMatch(normalizedContent), candidate);
377
396
  const safelyRenderable = isSnippetRenderable(uniqueCandidate ?? candidate);
397
+ const competitorGap = closest.competitor ? closest.score - closest.competitor.score : Number.POSITIVE_INFINITY;
378
398
  const directRetry =
379
399
  !closest.truncated &&
380
400
  closest.score >= MIN_DIRECT_RETRY_SCORE &&
401
+ !closest.competitionIncomplete &&
402
+ competitorGap >= MIN_DIRECT_RETRY_GAP &&
381
403
  uniqueCandidate !== null &&
382
404
  safelyRenderable;
383
405
  if (directRetry) {
@@ -389,6 +411,10 @@ function formatNotFound(opts: FormatFailureOptions, oldText: string): string {
389
411
  const reasons = [
390
412
  closest.truncated ? "only part of oldText was compared" : undefined,
391
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,
392
418
  uniqueCandidate === null ? "the candidate is not unique under edit matching" : undefined,
393
419
  !safelyRenderable ? "the exact candidate exceeds the safe output limit" : undefined,
394
420
  ].filter((reason): reason is string => reason !== undefined);
@@ -412,8 +438,85 @@ function formatNotFound(opts: FormatFailureOptions, oldText: string): string {
412
438
  }
413
439
 
414
440
  function truncateLine(line: string): string {
415
- const collapsed = line.replace(/\t/g, "→tab→");
416
- 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}`;
417
520
  }
418
521
 
419
522
  // ---------------------------------------------------------------------------
package/src/index.ts CHANGED
@@ -11,7 +11,12 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
11
11
  import { registerBetterEditTool } from "./tool.ts";
12
12
 
13
13
  export { registerBetterEditTool, executeBetterEdit, prepareEditArguments, betterEditSchema } from "./tool.ts";
14
- export type { BetterEditInput } from "./tool.ts";
14
+ export type {
15
+ BetterEditExecutionOptions,
16
+ BetterEditInput,
17
+ BetterEditOperations,
18
+ BetterEditSuccess,
19
+ } from "./tool.ts";
15
20
  export {
16
21
  formatAutoDisambiguationSuccess,
17
22
  formatEditFailure,