@khanhicetea/pi-better-tool 0.1.0 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +69 -45
- package/package.json +1 -1
- package/src/apply.ts +53 -18
- package/src/diagnostics.ts +239 -39
- package/src/index.ts +17 -7
- package/src/read-evidence.ts +174 -0
- package/src/similarity.ts +210 -156
- package/src/text.ts +36 -7
- package/src/tool.ts +265 -83
package/README.md
CHANGED
|
@@ -1,19 +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 match (2+ occurrences)** — every occurrence's line number plus the **minimum prefix/suffix context** that makes each occurrence unique, rendered as ready-to-use `oldText` snippets.
|
|
16
|
-
- **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.
|
|
17
14
|
|
|
18
15
|
## Install
|
|
19
16
|
|
|
@@ -31,14 +28,14 @@ It is also registered in the root `package.json` under `pi.extensions`.
|
|
|
31
28
|
|
|
32
29
|
## Example: ambiguous oldText
|
|
33
30
|
|
|
34
|
-
|
|
31
|
+
````text
|
|
35
32
|
Found 2 occurrences of the text in dup.go. The text must be unique. Please provide more context to make it unique.
|
|
36
33
|
|
|
37
34
|
Occurrences:
|
|
38
35
|
1. lines 2-3
|
|
39
36
|
2. lines 6-7
|
|
40
37
|
|
|
41
|
-
|
|
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:
|
|
42
39
|
|
|
43
40
|
Occurrence 1 (lines 2-3) — minimum context: 0 lines before, 1 line after:
|
|
44
41
|
```
|
|
@@ -48,53 +45,82 @@ Occurrence 1 (lines 2-3) — minimum context: 0 lines before, 1 line after:
|
|
|
48
45
|
func second() {
|
|
49
46
|
```
|
|
50
47
|
|
|
51
|
-
|
|
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.
|
|
52
61
|
```
|
|
53
|
-
|
|
62
|
+
function firstHandler() {
|
|
63
|
+
work();
|
|
54
64
|
}
|
|
55
65
|
```
|
|
56
66
|
|
|
57
|
-
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).
|
|
58
|
-
|
|
59
67
|
No changes were written — the file was not modified.
|
|
60
|
-
|
|
68
|
+
````
|
|
61
69
|
|
|
62
|
-
|
|
70
|
+
Similarity scores are heuristics, not probabilities.
|
|
63
71
|
|
|
64
|
-
|
|
65
|
-
Could not find the exact text in tabs.go. The old text must match exactly including all whitespace and newlines.
|
|
72
|
+
## Behavior and compatibility
|
|
66
73
|
|
|
67
|
-
|
|
68
|
-
Differences vs your oldText (2 of 3 compared lines match):
|
|
69
|
-
file line 4 differs from your oldText line 2:
|
|
70
|
-
file: →tab→fmt.Println("hi")
|
|
71
|
-
oldText: fmt.Println("hi")
|
|
74
|
+
The normal matching path follows Pi's built-in edit implementation:
|
|
72
75
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
|
79
83
|
|
|
80
|
-
|
|
81
|
-
- whitespace mismatch: the text matches when ALL whitespace is removed — check tabs vs spaces and indentation width
|
|
84
|
+
Intentional safety/compatibility differences are:
|
|
82
85
|
|
|
83
|
-
|
|
84
|
-
|
|
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
|
|
85
101
|
|
|
86
|
-
|
|
102
|
+
This override uses local Node.js filesystem operations. It does **not** inherit an SSH, container, sandbox, or other custom edit backend.
|
|
87
103
|
|
|
88
|
-
|
|
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.
|
|
89
105
|
|
|
90
|
-
|
|
91
|
-
- 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
|
|
92
|
-
- same BOM and CRLF handling
|
|
93
|
-
- same success result shape (`details.diff` / `details.patch` / `details.firstChangedLine`), and no custom renderers — the built-in diff renderer is inherited
|
|
106
|
+
The final write is still an in-place filesystem overwrite:
|
|
94
107
|
|
|
95
|
-
|
|
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
|
|
96
112
|
|
|
97
|
-
|
|
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.
|
|
98
124
|
|
|
99
125
|
## Development
|
|
100
126
|
|
|
@@ -105,7 +131,7 @@ npm test # vitest only
|
|
|
105
131
|
|
|
106
132
|
## Publishing
|
|
107
133
|
|
|
108
|
-
From the repository root
|
|
134
|
+
From the repository root:
|
|
109
135
|
|
|
110
136
|
```bash
|
|
111
137
|
npm run check --workspace=@khanhicetea/pi-better-tool
|
|
@@ -113,8 +139,6 @@ npm pack --dry-run --workspace=@khanhicetea/pi-better-tool
|
|
|
113
139
|
npm publish --workspace=@khanhicetea/pi-better-tool
|
|
114
140
|
```
|
|
115
141
|
|
|
116
|
-
The package is configured for public publishing under the `@khanhicetea` scope. npm authentication is required.
|
|
117
|
-
|
|
118
142
|
## License
|
|
119
143
|
|
|
120
144
|
MIT
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@khanhicetea/pi-better-tool",
|
|
3
|
-
"version": "0.
|
|
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
|
| {
|
|
@@ -77,6 +80,17 @@ export interface FuzzyFindResult {
|
|
|
77
80
|
|
|
78
81
|
export type AnalyzeResult = { ok: true; analysis: EditAnalysis } | { ok: false; failure: EditFailure };
|
|
79
82
|
|
|
83
|
+
export interface AnalyzeOptions {
|
|
84
|
+
/**
|
|
85
|
+
* Exact offsets chosen for otherwise-ambiguous edits. Offsets are accepted
|
|
86
|
+
* only when they point at the literal oldText in the matching base.
|
|
87
|
+
*/
|
|
88
|
+
ambiguousSelections?: ReadonlyMap<number, number>;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Avoid materializing unbounded offset arrays for highly repetitive files. */
|
|
92
|
+
const MAX_TRACKED_OCCURRENCE_OFFSETS = 256;
|
|
93
|
+
|
|
80
94
|
export function normalizeEdits(edits: EditOp[]): EditOp[] {
|
|
81
95
|
return edits.map((edit) => ({
|
|
82
96
|
oldText: normalizeToLF(edit.oldText),
|
|
@@ -124,10 +138,13 @@ function rangeOf(spans: LineSpan[], matchIndex: number, matchLength: number): Li
|
|
|
124
138
|
};
|
|
125
139
|
}
|
|
126
140
|
|
|
127
|
-
export function analyzeEdits(normalizedContent: string, rawEdits: EditOp[]): AnalyzeResult {
|
|
141
|
+
export function analyzeEdits(normalizedContent: string, rawEdits: EditOp[], options: AnalyzeOptions = {}): AnalyzeResult {
|
|
128
142
|
const edits = normalizeEdits(rawEdits);
|
|
129
143
|
for (let i = 0; i < edits.length; i++) {
|
|
130
|
-
|
|
144
|
+
// Fuzzy normalization can erase whitespace-only needles. Allowing an
|
|
145
|
+
// empty normalized needle makes String#indexOf match at offset zero and
|
|
146
|
+
// turns an intended replacement into an insertion.
|
|
147
|
+
if (edits[i].oldText.length === 0 || normalizeForFuzzyMatch(edits[i].oldText).length === 0) {
|
|
131
148
|
return { ok: false, failure: { kind: "empty-old-text", editIndex: i } };
|
|
132
149
|
}
|
|
133
150
|
}
|
|
@@ -146,17 +163,35 @@ export function analyzeEdits(normalizedContent: string, rawEdits: EditOp[]): Ana
|
|
|
146
163
|
return { ok: false, failure: { kind: "not-found", editIndex: i } };
|
|
147
164
|
}
|
|
148
165
|
const occurrences = countFuzzyOccurrences(fuzzyBase, edit.oldText);
|
|
166
|
+
let selectedMatch = matchResult;
|
|
149
167
|
if (occurrences > 1) {
|
|
150
|
-
const occurrenceOffsets = findAllOccurrences(
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
168
|
+
const occurrenceOffsets = findAllOccurrences(
|
|
169
|
+
fuzzyBase,
|
|
170
|
+
normalizeForFuzzyMatch(edit.oldText),
|
|
171
|
+
MAX_TRACKED_OCCURRENCE_OFFSETS,
|
|
172
|
+
);
|
|
173
|
+
const selectedOffset = options.ambiguousSelections?.get(i);
|
|
174
|
+
if (
|
|
175
|
+
selectedOffset === undefined ||
|
|
176
|
+
base.slice(selectedOffset, selectedOffset + edit.oldText.length) !== edit.oldText
|
|
177
|
+
) {
|
|
178
|
+
return {
|
|
179
|
+
ok: false,
|
|
180
|
+
failure: { kind: "ambiguous", editIndex: i, occurrenceCount: occurrences, occurrenceOffsets },
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
selectedMatch = {
|
|
184
|
+
found: true,
|
|
185
|
+
index: selectedOffset,
|
|
186
|
+
matchLength: edit.oldText.length,
|
|
187
|
+
usedFuzzyMatch: false,
|
|
188
|
+
contentForReplacement: base,
|
|
154
189
|
};
|
|
155
190
|
}
|
|
156
191
|
replacements.push({
|
|
157
192
|
editIndex: i,
|
|
158
|
-
matchIndex:
|
|
159
|
-
matchLength:
|
|
193
|
+
matchIndex: selectedMatch.index,
|
|
194
|
+
matchLength: selectedMatch.matchLength,
|
|
160
195
|
newText: edit.newText,
|
|
161
196
|
});
|
|
162
197
|
}
|
|
@@ -232,10 +267,10 @@ function applyReplacementsPreservingUnchangedLines(
|
|
|
232
267
|
baseContent: string,
|
|
233
268
|
replacements: Replacement[],
|
|
234
269
|
): string {
|
|
235
|
-
const originalLines =
|
|
236
|
-
const baseLines =
|
|
270
|
+
const originalLines = splitLogicalLinesWithEndings(originalContent);
|
|
271
|
+
const baseLines = getLogicalLineSpans(baseContent);
|
|
237
272
|
if (originalLines.length !== baseLines.length) {
|
|
238
|
-
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.");
|
|
239
274
|
}
|
|
240
275
|
|
|
241
276
|
const groups: Array<InternalLineWindow & { replacements: Replacement[] }> = [];
|