@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 +21 -0
- package/README.md +120 -0
- package/package.json +44 -0
- package/src/apply.ts +277 -0
- package/src/diagnostics.ts +350 -0
- package/src/index.ts +22 -0
- package/src/similarity.ts +249 -0
- package/src/text.ts +129 -0
- package/src/tool.ts +221 -0
package/src/text.ts
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Text normalization utilities.
|
|
3
|
+
*
|
|
4
|
+
* These are ports of the helpers pi's built-in edit tool uses (edit-diff.ts),
|
|
5
|
+
* kept byte-for-byte compatible in behavior so that suggestions produced by
|
|
6
|
+
* this package's diagnostics are guaranteed to match what the same matching
|
|
7
|
+
* engine accepts.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export interface LineSpan {
|
|
11
|
+
/** Character offset of the line start (inclusive). */
|
|
12
|
+
start: number;
|
|
13
|
+
/** Character offset of the line end (exclusive, includes the trailing \n when present). */
|
|
14
|
+
end: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function splitBom(content: string): { bom: string; text: string } {
|
|
18
|
+
return content.startsWith("") ? { bom: "", text: content.slice(1) } : { bom: "", text: content };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function detectLineEnding(content: string): "\n" | "\r\n" {
|
|
22
|
+
const crlfIdx = content.indexOf("\r\n");
|
|
23
|
+
const lfIdx = content.indexOf("\n");
|
|
24
|
+
if (lfIdx === -1) return "\n";
|
|
25
|
+
if (crlfIdx === -1) return "\n";
|
|
26
|
+
return crlfIdx < lfIdx ? "\r\n" : "\n";
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function normalizeToLF(text: string): string {
|
|
30
|
+
return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function restoreLineEndings(text: string, ending: "\n" | "\r\n"): string {
|
|
34
|
+
return ending === "\r\n" ? text.replace(/\n/g, "\r\n") : text;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Normalize text for fuzzy matching. Applies progressive transformations:
|
|
39
|
+
* - NFKC normalization
|
|
40
|
+
* - Strip trailing whitespace from each line
|
|
41
|
+
* - Normalize smart quotes to ASCII equivalents
|
|
42
|
+
* - Normalize Unicode dashes/hyphens to ASCII hyphen
|
|
43
|
+
* - Normalize special Unicode spaces to regular space
|
|
44
|
+
*
|
|
45
|
+
* Line-count preserving and idempotent.
|
|
46
|
+
*/
|
|
47
|
+
export function normalizeForFuzzyMatch(text: string): string {
|
|
48
|
+
return (
|
|
49
|
+
text
|
|
50
|
+
.normalize("NFKC")
|
|
51
|
+
// Strip trailing whitespace per line
|
|
52
|
+
.split("\n")
|
|
53
|
+
.map((line) => line.trimEnd())
|
|
54
|
+
.join("\n")
|
|
55
|
+
// Smart single quotes -> '
|
|
56
|
+
.replace(/[\u2018\u2019\u201A\u201B]/g, "'")
|
|
57
|
+
// Smart double quotes -> "
|
|
58
|
+
.replace(/[\u201C\u201D\u201E\u201F]/g, '"')
|
|
59
|
+
// Various dashes/hyphens -> -
|
|
60
|
+
// U+2010 hyphen, U+2011 non-breaking hyphen, U+2012 figure dash,
|
|
61
|
+
// U+2013 en-dash, U+2014 em-dash, U+2015 horizontal bar, U+2212 minus
|
|
62
|
+
.replace(/[\u2010\u2011\u2012\u2013\u2014\u2015\u2212]/g, "-")
|
|
63
|
+
// Special spaces -> regular space
|
|
64
|
+
// U+00A0 NBSP, U+2002-U+200A various spaces, U+202F narrow NBSP,
|
|
65
|
+
// U+205F medium math space, U+3000 ideographic space
|
|
66
|
+
.replace(/[\u00A0\u2002-\u200A\u202F\u205F\u3000]/g, " ")
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function splitLinesWithEndings(content: string): string[] {
|
|
71
|
+
return content.match(/[^\n]*\n|[^\n]+/g) ?? [];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function getLineSpans(content: string): LineSpan[] {
|
|
75
|
+
let offset = 0;
|
|
76
|
+
return splitLinesWithEndings(content).map((line) => {
|
|
77
|
+
const span = { start: offset, end: offset + line.length };
|
|
78
|
+
offset = span.end;
|
|
79
|
+
return span;
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** 0-based index of the line containing `offset` (clamped to the last line). */
|
|
84
|
+
export function lineAt(spans: LineSpan[], offset: number): number {
|
|
85
|
+
let lo = 0;
|
|
86
|
+
let hi = spans.length - 1;
|
|
87
|
+
let result = spans.length - 1;
|
|
88
|
+
while (lo <= hi) {
|
|
89
|
+
const mid = (lo + hi) >> 1;
|
|
90
|
+
if (spans[mid].start <= offset) {
|
|
91
|
+
result = mid;
|
|
92
|
+
lo = mid + 1;
|
|
93
|
+
} else {
|
|
94
|
+
hi = mid - 1;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return result;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Count occurrences of `needle` in fully fuzzy-normalized space, using the
|
|
102
|
+
* same normalization the matching engine uses for its uniqueness check.
|
|
103
|
+
* `fuzzyContent` must already be `normalizeForFuzzyMatch`-ed.
|
|
104
|
+
*/
|
|
105
|
+
export function countFuzzyOccurrences(fuzzyContent: string, needle: string): number {
|
|
106
|
+
if (!needle) return 0;
|
|
107
|
+
return fuzzyContent.split(normalizeForFuzzyMatch(needle)).length - 1;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** All start offsets of `needle` in `haystack` (literal string search). */
|
|
111
|
+
export function findAllOccurrences(haystack: string, needle: string): number[] {
|
|
112
|
+
const offsets: number[] = [];
|
|
113
|
+
if (!needle) return offsets;
|
|
114
|
+
let idx = haystack.indexOf(needle);
|
|
115
|
+
while (idx !== -1) {
|
|
116
|
+
offsets.push(idx);
|
|
117
|
+
idx = haystack.indexOf(needle, idx + needle.length);
|
|
118
|
+
}
|
|
119
|
+
return offsets;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Split into lines for similarity comparison (fuzzy-normalized, no trailing-empty artifact). */
|
|
123
|
+
export function toFuzzyLines(text: string): string[] {
|
|
124
|
+
const lines = normalizeToLF(text).split("\n").map((line) => normalizeForFuzzyMatch(line));
|
|
125
|
+
if (lines.length > 0 && lines[lines.length - 1] === "") {
|
|
126
|
+
lines.pop();
|
|
127
|
+
}
|
|
128
|
+
return lines;
|
|
129
|
+
}
|
package/src/tool.ts
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The "better edit" tool: a drop-in override of pi's built-in edit tool.
|
|
3
|
+
*
|
|
4
|
+
* Happy-path behavior is identical to the built-in tool (same schema, same
|
|
5
|
+
* matching semantics, same result shapes so the built-in diff renderer is
|
|
6
|
+
* inherited). The difference is failure behavior: instead of a bare "Could
|
|
7
|
+
* not find edits[1]" error that forces the model to re-read the file and
|
|
8
|
+
* guess at larger context, failures include recovery context:
|
|
9
|
+
*
|
|
10
|
+
* - ambiguous oldText → occurrence line numbers + the minimal prefix/suffix
|
|
11
|
+
* context that disambiguates each occurrence, as copy-paste-ready snippets
|
|
12
|
+
* - not-found oldText → closest matching region with a per-line comparison
|
|
13
|
+
* and the exact file bytes to retry with
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
generateDiffString,
|
|
18
|
+
generateUnifiedPatch,
|
|
19
|
+
withFileMutationQueue,
|
|
20
|
+
type EditToolDetails,
|
|
21
|
+
type ExtensionAPI,
|
|
22
|
+
type ExtensionContext,
|
|
23
|
+
} from "@earendil-works/pi-coding-agent";
|
|
24
|
+
import { constants } from "node:fs";
|
|
25
|
+
import { access as fsAccess, readFile as fsReadFile, writeFile as fsWriteFile } from "node:fs/promises";
|
|
26
|
+
import { homedir } from "node:os";
|
|
27
|
+
import { resolve } from "node:path";
|
|
28
|
+
import { type Static, Type } from "typebox";
|
|
29
|
+
import { analyzeEdits, applyAnalysis, type EditOp } from "./apply.ts";
|
|
30
|
+
import { formatEditFailure } from "./diagnostics.ts";
|
|
31
|
+
import { detectLineEnding, normalizeToLF, restoreLineEndings, splitBom } from "./text.ts";
|
|
32
|
+
|
|
33
|
+
const replaceEditSchema = Type.Object({
|
|
34
|
+
oldText: Type.String({
|
|
35
|
+
description:
|
|
36
|
+
"Exact text for one targeted replacement. It must be unique in the original file and must not overlap with any other edits[].oldText in the same call.",
|
|
37
|
+
}),
|
|
38
|
+
newText: Type.String({ description: "Replacement text for this targeted edit." }),
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
export const betterEditSchema = Type.Object({
|
|
42
|
+
path: Type.String({ description: "Path to the file to edit (relative or absolute)" }),
|
|
43
|
+
edits: Type.Array(replaceEditSchema, {
|
|
44
|
+
description:
|
|
45
|
+
"One or more targeted replacements. Each edit is matched against the original file, not incrementally. Do not include overlapping or nested edits. If two changes touch the same block or nearby lines, merge them into one edit instead.",
|
|
46
|
+
}),
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
export type BetterEditInput = Static<typeof betterEditSchema>;
|
|
50
|
+
|
|
51
|
+
function resolveToCwd(filePath: string, cwd: string): string {
|
|
52
|
+
let path = filePath;
|
|
53
|
+
if (path === "~") {
|
|
54
|
+
path = homedir();
|
|
55
|
+
} else if (path.startsWith("~/")) {
|
|
56
|
+
path = resolve(homedir(), path.slice(2));
|
|
57
|
+
}
|
|
58
|
+
return resolve(cwd, path);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function isSingleEditInput(value: unknown): value is { oldText: string; newText: string } {
|
|
62
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
const edit = value as { oldText?: unknown; newText?: unknown };
|
|
66
|
+
return typeof edit.oldText === "string" && typeof edit.newText === "string";
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Compatibility shim, ported from the built-in tool: some models send edits as
|
|
71
|
+
* a JSON string, or send a single edit object instead of a one-element array,
|
|
72
|
+
* or use the legacy top-level oldText/newText shape.
|
|
73
|
+
*/
|
|
74
|
+
export function prepareEditArguments(input: unknown): unknown {
|
|
75
|
+
if (!input || typeof input !== "object") {
|
|
76
|
+
return input;
|
|
77
|
+
}
|
|
78
|
+
const args = input as {
|
|
79
|
+
edits?: unknown;
|
|
80
|
+
oldText?: unknown;
|
|
81
|
+
newText?: unknown;
|
|
82
|
+
[path: string]: unknown;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
if (typeof args.edits === "string") {
|
|
86
|
+
try {
|
|
87
|
+
const parsed: unknown = JSON.parse(args.edits);
|
|
88
|
+
if (Array.isArray(parsed)) {
|
|
89
|
+
args.edits = parsed;
|
|
90
|
+
} else if (isSingleEditInput(parsed)) {
|
|
91
|
+
args.edits = [parsed];
|
|
92
|
+
}
|
|
93
|
+
} catch {
|
|
94
|
+
// leave as-is; schema validation will report it
|
|
95
|
+
}
|
|
96
|
+
} else if (isSingleEditInput(args.edits)) {
|
|
97
|
+
args.edits = [args.edits];
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (typeof args.oldText === "string" && typeof args.newText === "string") {
|
|
101
|
+
const edits = Array.isArray(args.edits) ? [...(args.edits as EditOp[])] : [];
|
|
102
|
+
edits.push({ oldText: args.oldText, newText: args.newText });
|
|
103
|
+
const { oldText: _oldText, newText: _newText, ...rest } = args;
|
|
104
|
+
return { ...rest, edits };
|
|
105
|
+
}
|
|
106
|
+
return args;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export interface BetterEditSuccess {
|
|
110
|
+
content: Array<{ type: "text"; text: string }>;
|
|
111
|
+
details: EditToolDetails;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export async function executeBetterEdit(
|
|
115
|
+
input: BetterEditInput,
|
|
116
|
+
signal: AbortSignal | undefined,
|
|
117
|
+
ctx: Pick<ExtensionContext, "cwd">,
|
|
118
|
+
): Promise<BetterEditSuccess> {
|
|
119
|
+
const edits: EditOp[] = input.edits ?? [];
|
|
120
|
+
if (!Array.isArray(edits) || edits.length === 0) {
|
|
121
|
+
throw new Error("Edit tool input is invalid. edits must contain at least one replacement.");
|
|
122
|
+
}
|
|
123
|
+
const absolutePath = resolveToCwd(input.path, ctx.cwd);
|
|
124
|
+
|
|
125
|
+
return withFileMutationQueue(absolutePath, async () => {
|
|
126
|
+
// Do not reject from an abort event listener here: that would release the
|
|
127
|
+
// mutation queue while an in-flight filesystem operation may still finish.
|
|
128
|
+
const throwIfAborted = () => {
|
|
129
|
+
if (signal?.aborted) throw new Error("Operation aborted");
|
|
130
|
+
};
|
|
131
|
+
throwIfAborted();
|
|
132
|
+
|
|
133
|
+
try {
|
|
134
|
+
await fsAccess(absolutePath, constants.R_OK | constants.W_OK);
|
|
135
|
+
} catch (error) {
|
|
136
|
+
throwIfAborted();
|
|
137
|
+
const errorMessage =
|
|
138
|
+
error instanceof Error && "code" in error
|
|
139
|
+
? `Error code: ${(error as NodeJS.ErrnoException).code}`
|
|
140
|
+
: String(error);
|
|
141
|
+
throw new Error(`Could not edit file: ${input.path}. ${errorMessage}.`);
|
|
142
|
+
}
|
|
143
|
+
throwIfAborted();
|
|
144
|
+
|
|
145
|
+
const buffer = await fsReadFile(absolutePath);
|
|
146
|
+
const rawContent = buffer.toString("utf-8");
|
|
147
|
+
throwIfAborted();
|
|
148
|
+
|
|
149
|
+
const { bom, text: content } = splitBom(rawContent);
|
|
150
|
+
const originalEnding = detectLineEnding(content);
|
|
151
|
+
const normalizedContent = normalizeToLF(content);
|
|
152
|
+
|
|
153
|
+
const result = analyzeEdits(normalizedContent, edits);
|
|
154
|
+
if (!result.ok) {
|
|
155
|
+
throw new Error(
|
|
156
|
+
formatEditFailure({
|
|
157
|
+
path: input.path,
|
|
158
|
+
normalizedContent,
|
|
159
|
+
edits,
|
|
160
|
+
failure: result.failure,
|
|
161
|
+
}),
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const { baseContent, newContent } = applyAnalysis(normalizedContent, result.analysis);
|
|
166
|
+
if (baseContent === newContent) {
|
|
167
|
+
throw new Error(
|
|
168
|
+
formatEditFailure({
|
|
169
|
+
path: input.path,
|
|
170
|
+
normalizedContent,
|
|
171
|
+
edits,
|
|
172
|
+
failure: { kind: "no-change" },
|
|
173
|
+
}),
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
throwIfAborted();
|
|
177
|
+
|
|
178
|
+
const finalContent = bom + restoreLineEndings(newContent, originalEnding);
|
|
179
|
+
await fsWriteFile(absolutePath, finalContent, "utf-8");
|
|
180
|
+
throwIfAborted();
|
|
181
|
+
|
|
182
|
+
const diffResult = generateDiffString(baseContent, newContent);
|
|
183
|
+
const patch = generateUnifiedPatch(input.path, baseContent, newContent);
|
|
184
|
+
return {
|
|
185
|
+
content: [
|
|
186
|
+
{
|
|
187
|
+
type: "text",
|
|
188
|
+
text: `Successfully replaced ${edits.length} block(s) in ${input.path}.`,
|
|
189
|
+
},
|
|
190
|
+
],
|
|
191
|
+
details: {
|
|
192
|
+
diff: diffResult.diff,
|
|
193
|
+
patch,
|
|
194
|
+
firstChangedLine: diffResult.firstChangedLine,
|
|
195
|
+
} satisfies EditToolDetails,
|
|
196
|
+
};
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function registerBetterEditTool(pi: ExtensionAPI): void {
|
|
201
|
+
pi.registerTool({
|
|
202
|
+
name: "edit",
|
|
203
|
+
label: "edit",
|
|
204
|
+
description:
|
|
205
|
+
"Edit a single file using exact text replacement. Every edits[].oldText must match a unique, non-overlapping region of the original file. If two changes affect the same block or nearby lines, merge them into one edit instead of emitting overlapping edits. Do not include large unchanged regions just to connect distant changes. On failure the error includes recovery context (closest matching region or per-occurrence disambiguation snippets) so you can retry immediately without re-reading the file.",
|
|
206
|
+
promptSnippet:
|
|
207
|
+
"Make precise file edits with exact text replacement; failures return recovery context (closest match or disambiguation snippets)",
|
|
208
|
+
promptGuidelines: [
|
|
209
|
+
"Use edit for precise changes (edits[].oldText must match exactly)",
|
|
210
|
+
"When changing multiple separate locations in one file, use one edit call with multiple entries in edits[] instead of multiple edit calls",
|
|
211
|
+
"Each edits[].oldText is matched against the original file, not after earlier edits are applied. Do not emit overlapping or nested edits. Merge nearby changes into one edit.",
|
|
212
|
+
"Keep edits[].oldText as small as possible while still being unique in the file. Do not pad with large unchanged regions.",
|
|
213
|
+
"When an edit call fails, the error message already contains recovery context: the closest matching region with exact file bytes (not-found) or each occurrence with a ready-to-use disambiguated oldText (ambiguous match). Retry the edit using that text directly instead of re-reading the file.",
|
|
214
|
+
],
|
|
215
|
+
parameters: betterEditSchema,
|
|
216
|
+
prepareArguments: (args: unknown): BetterEditInput => prepareEditArguments(args) as BetterEditInput,
|
|
217
|
+
async execute(_toolCallId, input, signal, _onUpdate, ctx) {
|
|
218
|
+
return executeBetterEdit(input, signal, ctx);
|
|
219
|
+
},
|
|
220
|
+
});
|
|
221
|
+
}
|