@khanhicetea/pi-better-tool 0.1.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 pi-better-tool contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,120 @@
1
+ # pi-better-tool
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.
4
+
5
+ ## Why
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:
8
+
9
+ ```
10
+ Could not find edits[1] in /code/app.go. The oldText must match exactly including all whitespace and newlines.
11
+ ```
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` keeps the refusal (writing the wrong occurrence would be worse) but returns **recovery context** so the next call succeeds without re-reading:
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).
17
+
18
+ ## Install
19
+
20
+ ```bash
21
+ pi install npm:@khanhicetea/pi-better-tool
22
+ ```
23
+
24
+ For local development from this monorepo:
25
+
26
+ ```bash
27
+ pi install /absolute/path/to/pi-kit/packages/pi-better-tool
28
+ ```
29
+
30
+ It is also registered in the root `package.json` under `pi.extensions`.
31
+
32
+ ## Example: ambiguous oldText
33
+
34
+ ```text
35
+ Found 2 occurrences of the text in dup.go. The text must be unique. Please provide more context to make it unique.
36
+
37
+ Occurrences:
38
+ 1. lines 2-3
39
+ 2. lines 6-7
40
+
41
+ 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:
42
+
43
+ Occurrence 1 (lines 2-3) — minimum context: 0 lines before, 1 line after:
44
+ ```
45
+ log()
46
+ }
47
+
48
+ func second() {
49
+ ```
50
+
51
+ Occurrence 2 (lines 6-7) — minimum context: 0 lines before, 0 lines after:
52
+ ```
53
+ log()
54
+ }
55
+ ```
56
+
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
+ No changes were written — the file was not modified.
60
+ ```
61
+
62
+ ## Example: text not found
63
+
64
+ ```text
65
+ Could not find the exact text in tabs.go. The old text must match exactly including all whitespace and newlines.
66
+
67
+ Closest match in the file: lines 3-5 (~91% line similarity).
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")
72
+
73
+ Exact file content at lines 3-5 — retry using this text as oldText (then apply your change to newText):
74
+ ```
75
+ func main() {
76
+ fmt.Println("hi")
77
+ }
78
+ ```
79
+
80
+ Possible cause:
81
+ - whitespace mismatch: the text matches when ALL whitespace is removed — check tabs vs spaces and indentation width
82
+
83
+ No changes were written — the file was not modified.
84
+ ```
85
+
86
+ ## Behavior
87
+
88
+ Happy-path semantics are **identical** to the built-in `edit` tool:
89
+
90
+ - 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`
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
94
+
95
+ Failure behavior is the difference: errors carry the recovery context described above, and nothing is written on failure (edits remain atomic).
96
+
97
+ 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.
98
+
99
+ ## Development
100
+
101
+ ```bash
102
+ npm run check # typecheck + tests
103
+ npm test # vitest only
104
+ ```
105
+
106
+ ## Publishing
107
+
108
+ From the repository root, verify the package and inspect its tarball before publishing:
109
+
110
+ ```bash
111
+ npm run check --workspace=@khanhicetea/pi-better-tool
112
+ npm pack --dry-run --workspace=@khanhicetea/pi-better-tool
113
+ npm publish --workspace=@khanhicetea/pi-better-tool
114
+ ```
115
+
116
+ The package is configured for public publishing under the `@khanhicetea` scope. npm authentication is required.
117
+
118
+ ## License
119
+
120
+ MIT
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "@khanhicetea/pi-better-tool",
3
+ "version": "0.1.0",
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
+ "type": "module",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/khanhicetea/pi-kit.git",
10
+ "directory": "packages/pi-better-tool"
11
+ },
12
+ "homepage": "https://github.com/khanhicetea/pi-kit/tree/main/packages/pi-better-tool",
13
+ "bugs": {
14
+ "url": "https://github.com/khanhicetea/pi-kit/issues"
15
+ },
16
+ "keywords": ["pi-package", "pi-extension", "edit", "tools"],
17
+ "files": ["src", "README.md", "LICENSE"],
18
+ "scripts": {
19
+ "test": "vitest run",
20
+ "typecheck": "tsc --noEmit",
21
+ "check": "npm run typecheck && npm test",
22
+ "prepublishOnly": "npm run check"
23
+ },
24
+ "peerDependencies": {
25
+ "@earendil-works/pi-coding-agent": "*",
26
+ "typebox": "*"
27
+ },
28
+ "devDependencies": {
29
+ "@earendil-works/pi-coding-agent": "0.82.1",
30
+ "@types/node": "^24.0.0",
31
+ "typebox": "^1.0.0",
32
+ "typescript": "^5.9.0",
33
+ "vitest": "^3.2.0"
34
+ },
35
+ "pi": {
36
+ "extensions": ["./src/index.ts"]
37
+ },
38
+ "engines": {
39
+ "node": ">=20"
40
+ },
41
+ "publishConfig": {
42
+ "access": "public"
43
+ }
44
+ }
package/src/apply.ts ADDED
@@ -0,0 +1,277 @@
1
+ /**
2
+ * Edit matching and application engine.
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.
8
+ *
9
+ * Matching semantics are kept identical to the built-in tool:
10
+ * - exact match first, then fuzzy-normalized fallback (trailing whitespace,
11
+ * smart quotes, dashes, unicode spaces)
12
+ * - uniqueness is always checked in fully fuzzy-normalized space
13
+ * - all edits match against the same original content (not incrementally)
14
+ * - overlap detection, empty-oldText and no-change detection
15
+ */
16
+
17
+ import {
18
+ countFuzzyOccurrences,
19
+ findAllOccurrences,
20
+ getLineSpans,
21
+ lineAt,
22
+ normalizeForFuzzyMatch,
23
+ normalizeToLF,
24
+ splitLinesWithEndings,
25
+ type LineSpan,
26
+ } from "./text.ts";
27
+
28
+ export interface EditOp {
29
+ oldText: string;
30
+ newText: string;
31
+ }
32
+
33
+ /** 1-based inclusive line range. */
34
+ export interface LineRange {
35
+ start: number;
36
+ end: number;
37
+ }
38
+
39
+ export type EditFailure =
40
+ | { kind: "empty-old-text"; editIndex: number }
41
+ | { kind: "not-found"; editIndex: number }
42
+ | {
43
+ kind: "ambiguous";
44
+ editIndex: number;
45
+ /** Offsets of every occurrence, enumerated in fully fuzzy-normalized space. */
46
+ occurrenceOffsets: number[];
47
+ }
48
+ | {
49
+ kind: "overlap";
50
+ firstEditIndex: number;
51
+ secondEditIndex: number;
52
+ firstRange: LineRange;
53
+ secondRange: LineRange;
54
+ }
55
+ | { kind: "no-change" };
56
+
57
+ export interface Replacement {
58
+ editIndex: number;
59
+ matchIndex: number;
60
+ matchLength: number;
61
+ newText: string;
62
+ }
63
+
64
+ export interface EditAnalysis {
65
+ /** Sorted by matchIndex ascending. */
66
+ replacements: Replacement[];
67
+ usedFuzzyMatch: boolean;
68
+ }
69
+
70
+ export interface FuzzyFindResult {
71
+ found: boolean;
72
+ index: number;
73
+ matchLength: number;
74
+ usedFuzzyMatch: boolean;
75
+ contentForReplacement: string;
76
+ }
77
+
78
+ export type AnalyzeResult = { ok: true; analysis: EditAnalysis } | { ok: false; failure: EditFailure };
79
+
80
+ export function normalizeEdits(edits: EditOp[]): EditOp[] {
81
+ return edits.map((edit) => ({
82
+ oldText: normalizeToLF(edit.oldText),
83
+ newText: normalizeToLF(edit.newText),
84
+ }));
85
+ }
86
+
87
+ /** Find oldText in content, trying exact match first, then fuzzy match. */
88
+ export function fuzzyFindText(content: string, oldText: string): FuzzyFindResult {
89
+ const exactIndex = content.indexOf(oldText);
90
+ if (exactIndex !== -1) {
91
+ return {
92
+ found: true,
93
+ index: exactIndex,
94
+ matchLength: oldText.length,
95
+ usedFuzzyMatch: false,
96
+ contentForReplacement: content,
97
+ };
98
+ }
99
+ const fuzzyContent = normalizeForFuzzyMatch(content);
100
+ const fuzzyOldText = normalizeForFuzzyMatch(oldText);
101
+ const fuzzyIndex = fuzzyContent.indexOf(fuzzyOldText);
102
+ if (fuzzyIndex === -1) {
103
+ return {
104
+ found: false,
105
+ index: -1,
106
+ matchLength: 0,
107
+ usedFuzzyMatch: false,
108
+ contentForReplacement: content,
109
+ };
110
+ }
111
+ return {
112
+ found: true,
113
+ index: fuzzyIndex,
114
+ matchLength: fuzzyOldText.length,
115
+ usedFuzzyMatch: true,
116
+ contentForReplacement: fuzzyContent,
117
+ };
118
+ }
119
+
120
+ function rangeOf(spans: LineSpan[], matchIndex: number, matchLength: number): LineRange {
121
+ return {
122
+ start: lineAt(spans, matchIndex) + 1,
123
+ end: lineAt(spans, matchIndex + Math.max(1, matchLength) - 1) + 1,
124
+ };
125
+ }
126
+
127
+ export function analyzeEdits(normalizedContent: string, rawEdits: EditOp[]): AnalyzeResult {
128
+ const edits = normalizeEdits(rawEdits);
129
+ for (let i = 0; i < edits.length; i++) {
130
+ if (edits[i].oldText.length === 0) {
131
+ return { ok: false, failure: { kind: "empty-old-text", editIndex: i } };
132
+ }
133
+ }
134
+
135
+ const initialMatches = edits.map((edit) => fuzzyFindText(normalizedContent, edit.oldText));
136
+ const usedFuzzyMatch = initialMatches.some((match) => match.usedFuzzyMatch);
137
+ const base = usedFuzzyMatch ? normalizeForFuzzyMatch(normalizedContent) : normalizedContent;
138
+ const fuzzyBase = normalizeForFuzzyMatch(base);
139
+ const spans = getLineSpans(base);
140
+
141
+ const replacements: Replacement[] = [];
142
+ for (let i = 0; i < edits.length; i++) {
143
+ const edit = edits[i];
144
+ const matchResult = fuzzyFindText(base, edit.oldText);
145
+ if (!matchResult.found) {
146
+ return { ok: false, failure: { kind: "not-found", editIndex: i } };
147
+ }
148
+ const occurrences = countFuzzyOccurrences(fuzzyBase, edit.oldText);
149
+ if (occurrences > 1) {
150
+ const occurrenceOffsets = findAllOccurrences(fuzzyBase, normalizeForFuzzyMatch(edit.oldText));
151
+ return {
152
+ ok: false,
153
+ failure: { kind: "ambiguous", editIndex: i, occurrenceOffsets },
154
+ };
155
+ }
156
+ replacements.push({
157
+ editIndex: i,
158
+ matchIndex: matchResult.index,
159
+ matchLength: matchResult.matchLength,
160
+ newText: edit.newText,
161
+ });
162
+ }
163
+
164
+ replacements.sort((a, b) => a.matchIndex - b.matchIndex);
165
+ for (let i = 1; i < replacements.length; i++) {
166
+ const previous = replacements[i - 1];
167
+ const current = replacements[i];
168
+ if (previous.matchIndex + previous.matchLength > current.matchIndex) {
169
+ return {
170
+ ok: false,
171
+ failure: {
172
+ kind: "overlap",
173
+ firstEditIndex: previous.editIndex,
174
+ secondEditIndex: current.editIndex,
175
+ firstRange: rangeOf(spans, previous.matchIndex, previous.matchLength),
176
+ secondRange: rangeOf(spans, current.matchIndex, current.matchLength),
177
+ },
178
+ };
179
+ }
180
+ }
181
+
182
+ return { ok: true, analysis: { replacements, usedFuzzyMatch } };
183
+ }
184
+
185
+ function applyReplacements(content: string, replacements: Replacement[], offset = 0): string {
186
+ let result = content;
187
+ for (let i = replacements.length - 1; i >= 0; i--) {
188
+ const replacement = replacements[i];
189
+ const matchIndex = replacement.matchIndex - offset;
190
+ result =
191
+ result.substring(0, matchIndex) + replacement.newText + result.substring(matchIndex + replacement.matchLength);
192
+ }
193
+ return result;
194
+ }
195
+
196
+ /** 0-based, end-exclusive window of lines used by the fuzzy overlay. */
197
+ interface InternalLineWindow {
198
+ startLine: number;
199
+ endLine: number;
200
+ }
201
+
202
+ function getReplacementLineRange(lines: LineSpan[], replacement: Replacement): InternalLineWindow {
203
+ const replacementStart = replacement.matchIndex;
204
+ const replacementEnd = replacement.matchIndex + replacement.matchLength;
205
+ let startLine = -1;
206
+ for (let i = 0; i < lines.length; i++) {
207
+ const line = lines[i];
208
+ if (replacementStart >= line.start && replacementStart < line.end) {
209
+ startLine = i;
210
+ break;
211
+ }
212
+ }
213
+ if (startLine === -1) {
214
+ throw new Error("Replacement range is outside the base content.");
215
+ }
216
+ let endLine = startLine;
217
+ while (endLine < lines.length && lines[endLine].end < replacementEnd) {
218
+ endLine++;
219
+ }
220
+ if (endLine >= lines.length) {
221
+ throw new Error("Replacement range is outside the base content.");
222
+ }
223
+ return { startLine, endLine: endLine + 1 };
224
+ }
225
+
226
+ /**
227
+ * Apply replacements matched against `baseContent` to `originalContent` while
228
+ * preserving unchanged line blocks from the original (fuzzy-match overlay).
229
+ */
230
+ function applyReplacementsPreservingUnchangedLines(
231
+ originalContent: string,
232
+ baseContent: string,
233
+ replacements: Replacement[],
234
+ ): string {
235
+ const originalLines = splitLinesWithEndings(originalContent);
236
+ const baseLines = getLineSpans(baseContent);
237
+ if (originalLines.length !== baseLines.length) {
238
+ throw new Error("Cannot preserve unchanged lines because the base content has a different line count.");
239
+ }
240
+
241
+ const groups: Array<InternalLineWindow & { replacements: Replacement[] }> = [];
242
+ const sortedReplacements = [...replacements].sort((a, b) => a.matchIndex - b.matchIndex);
243
+ for (const replacement of sortedReplacements) {
244
+ const range = getReplacementLineRange(baseLines, replacement);
245
+ const current = groups[groups.length - 1];
246
+ if (current && range.startLine < current.endLine) {
247
+ current.endLine = Math.max(current.endLine, range.endLine);
248
+ current.replacements.push(replacement);
249
+ continue;
250
+ }
251
+ groups.push({ ...range, replacements: [replacement] });
252
+ }
253
+
254
+ let originalLineIndex = 0;
255
+ let result = "";
256
+ for (const group of groups) {
257
+ result += originalLines.slice(originalLineIndex, group.startLine).join("");
258
+ const groupStartOffset = baseLines[group.startLine].start;
259
+ const groupEndOffset = baseLines[group.endLine - 1].end;
260
+ result += applyReplacements(baseContent.slice(groupStartOffset, groupEndOffset), group.replacements, groupStartOffset);
261
+ originalLineIndex = group.endLine;
262
+ }
263
+ result += originalLines.slice(originalLineIndex).join("");
264
+ return result;
265
+ }
266
+
267
+ export function applyAnalysis(normalizedContent: string, analysis: EditAnalysis): { baseContent: string; newContent: string } {
268
+ const baseContent = normalizedContent;
269
+ const newContent = analysis.usedFuzzyMatch
270
+ ? applyReplacementsPreservingUnchangedLines(
271
+ normalizedContent,
272
+ normalizeForFuzzyMatch(normalizedContent),
273
+ analysis.replacements,
274
+ )
275
+ : applyReplacements(baseContent, analysis.replacements);
276
+ return { baseContent, newContent };
277
+ }