@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 +69 -47
- package/package.json +1 -1
- package/src/apply.ts +22 -12
- package/src/diagnostics.ts +122 -19
- package/src/index.ts +6 -1
- package/src/read-evidence.ts +56 -32
- package/src/similarity.ts +140 -63
- package/src/text.ts +36 -7
- package/src/tool.ts +176 -78
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
70
|
+
Similarity scores are heuristics, not probabilities.
|
|
64
71
|
|
|
65
|
-
|
|
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
|
-
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
/**
|
|
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(
|
|
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 =
|
|
261
|
-
const baseLines =
|
|
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[] }> = [];
|
package/src/diagnostics.ts
CHANGED
|
@@ -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
|
-
*
|
|
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
|
|
94
|
-
|
|
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 (${
|
|
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
|
-
|
|
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.
|
|
160
|
-
: `Found ${failure.
|
|
167
|
+
? `Found ${failure.occurrenceCount} occurrences of the text in ${path}. The text must be unique. Please provide more context to make it unique.`
|
|
168
|
+
: `Found ${failure.occurrenceCount} occurrences of edits[${failure.editIndex}] in ${path}. Each oldText must be unique. Please provide more context to make it unique.`;
|
|
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.
|
|
201
|
-
lines.push(` … and ${failure.
|
|
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
|
|
416
|
-
|
|
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 {
|
|
14
|
+
export type {
|
|
15
|
+
BetterEditExecutionOptions,
|
|
16
|
+
BetterEditInput,
|
|
17
|
+
BetterEditOperations,
|
|
18
|
+
BetterEditSuccess,
|
|
19
|
+
} from "./tool.ts";
|
|
15
20
|
export {
|
|
16
21
|
formatAutoDisambiguationSuccess,
|
|
17
22
|
formatEditFailure,
|