@xynogen/pix-edit 0.2.15 → 0.2.17
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/package.json +3 -3
- package/src/edit.ts +30 -493
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xynogen/pix-edit",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.17",
|
|
4
4
|
"description": "Pi tool — precise text replacement edit with diff rendering",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -39,8 +39,8 @@
|
|
|
39
39
|
"access": "public"
|
|
40
40
|
},
|
|
41
41
|
"dependencies": {
|
|
42
|
-
"@xynogen/pix-pretty": "^
|
|
43
|
-
"@xynogen/pix-runtime": "^0.
|
|
42
|
+
"@xynogen/pix-pretty": "^2.0.0",
|
|
43
|
+
"@xynogen/pix-runtime": "^0.19.0"
|
|
44
44
|
},
|
|
45
45
|
"peerDependencies": {
|
|
46
46
|
"typebox": "*",
|
package/src/edit.ts
CHANGED
|
@@ -1,15 +1,9 @@
|
|
|
1
|
-
import { constants } from "node:fs";
|
|
2
|
-
import { access, readFile, writeFile } from "node:fs/promises";
|
|
3
|
-
import { resolve } from "node:path";
|
|
4
|
-
import { fileURLToPath } from "node:url";
|
|
5
|
-
import { validateToolArguments } from "@earendil-works/pi-ai";
|
|
6
1
|
import type {
|
|
7
2
|
AgentToolUpdateCallback,
|
|
8
3
|
EditToolInput,
|
|
9
4
|
ExtensionContext,
|
|
10
5
|
ToolRenderResultOptions,
|
|
11
6
|
} from "@earendil-works/pi-coding-agent";
|
|
12
|
-
import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
|
|
13
7
|
import { resolveBaseBackground } from "@xynogen/pix-pretty/ansi";
|
|
14
8
|
import { MAX_RENDER_LINES } from "@xynogen/pix-pretty/config";
|
|
15
9
|
import type { ToolContext } from "@xynogen/pix-pretty/context";
|
|
@@ -36,13 +30,13 @@ import {
|
|
|
36
30
|
BODY_PAD,
|
|
37
31
|
dotJoin,
|
|
38
32
|
fillToolBackground,
|
|
39
|
-
formatToolCallTitle,
|
|
40
33
|
frameToolResult,
|
|
41
34
|
getErrorMessage,
|
|
42
35
|
getTextContent,
|
|
43
36
|
hideCollapsedToolCall,
|
|
44
37
|
isTextContent,
|
|
45
|
-
|
|
38
|
+
renderCollapsed,
|
|
39
|
+
renderTitle,
|
|
46
40
|
renderToolError,
|
|
47
41
|
setResultDetails,
|
|
48
42
|
termW,
|
|
@@ -50,14 +44,20 @@ import {
|
|
|
50
44
|
} from "@xynogen/pix-pretty/utils";
|
|
51
45
|
import { type CollapseState, tickCollapse } from "@xynogen/pix-runtime/collapse";
|
|
52
46
|
import {
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
type
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
47
|
+
type EditInput,
|
|
48
|
+
editPath,
|
|
49
|
+
editSchema,
|
|
50
|
+
editText,
|
|
51
|
+
type HashParams,
|
|
52
|
+
prepareEditArguments,
|
|
53
|
+
} from "@xynogen/pix-runtime/files";
|
|
54
|
+
|
|
55
|
+
export {
|
|
56
|
+
editPath,
|
|
57
|
+
freshAnchors,
|
|
58
|
+
prepareEditArguments,
|
|
59
|
+
prepareHashArguments,
|
|
60
|
+
} from "@xynogen/pix-runtime/files";
|
|
61
61
|
|
|
62
62
|
// ── Helpers ────────────────────────────────────────────────────────────
|
|
63
63
|
|
|
@@ -95,447 +95,6 @@ export function getEditOperations(input: EditParams): EditOperation[] {
|
|
|
95
95
|
return oldText && oldText !== newText ? [{ oldText, newText }] : [];
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
-
type HashEdit = {
|
|
99
|
-
op: "replace" | "insert_before" | "insert_after";
|
|
100
|
-
pos: string;
|
|
101
|
-
end?: string;
|
|
102
|
-
lines: string[];
|
|
103
|
-
};
|
|
104
|
-
type HashParams = { path: string; edits: HashEdit[] };
|
|
105
|
-
type ExactParams = { path: string; edits: EditOperation[] };
|
|
106
|
-
type EditInput = HashParams | ExactParams;
|
|
107
|
-
|
|
108
|
-
const hashFields = {
|
|
109
|
-
pos: Type.String({
|
|
110
|
-
pattern: "^[1-9][0-9]*#[0-9A-F]{3}$",
|
|
111
|
-
description: "Original LINE#HASH anchor from read",
|
|
112
|
-
}),
|
|
113
|
-
lines: Type.Array(
|
|
114
|
-
Type.String({
|
|
115
|
-
description:
|
|
116
|
-
"One literal source line per item. No newline or LINE#HASH| prefix. Empty array deletes.",
|
|
117
|
-
}),
|
|
118
|
-
),
|
|
119
|
-
};
|
|
120
|
-
const hashEdits = Type.Array(
|
|
121
|
-
Type.Union([
|
|
122
|
-
Type.Object(
|
|
123
|
-
{
|
|
124
|
-
op: Type.Literal("replace"),
|
|
125
|
-
...hashFields,
|
|
126
|
-
end: Type.Optional(
|
|
127
|
-
Type.String({
|
|
128
|
-
pattern: "^[1-9][0-9]*#[0-9A-F]{3}$",
|
|
129
|
-
description: "Inclusive end anchor. Omit for one line.",
|
|
130
|
-
}),
|
|
131
|
-
),
|
|
132
|
-
},
|
|
133
|
-
{ additionalProperties: false },
|
|
134
|
-
),
|
|
135
|
-
Type.Object(
|
|
136
|
-
{
|
|
137
|
-
op: Type.Union([Type.Literal("insert_before"), Type.Literal("insert_after")]),
|
|
138
|
-
...hashFields,
|
|
139
|
-
},
|
|
140
|
-
{ additionalProperties: false },
|
|
141
|
-
),
|
|
142
|
-
]),
|
|
143
|
-
{ minItems: 1 },
|
|
144
|
-
);
|
|
145
|
-
const exactEdits = Type.Array(
|
|
146
|
-
Type.Object(
|
|
147
|
-
{
|
|
148
|
-
oldText: Type.String({
|
|
149
|
-
minLength: 1,
|
|
150
|
-
description:
|
|
151
|
-
"Fallback only after a hash-anchor edit fails. Exact current source text; must match once.",
|
|
152
|
-
}),
|
|
153
|
-
newText: Type.String({ description: "Replacement text. Empty string deletes." }),
|
|
154
|
-
},
|
|
155
|
-
{ additionalProperties: false },
|
|
156
|
-
),
|
|
157
|
-
{ minItems: 1 },
|
|
158
|
-
);
|
|
159
|
-
const path = Type.String({ minLength: 1 });
|
|
160
|
-
const editSchema = Type.Object(
|
|
161
|
-
{
|
|
162
|
-
path,
|
|
163
|
-
// ponytail: union whole arrays so the schema rejects mixed formats before execution.
|
|
164
|
-
edits: Type.Union([hashEdits, exactEdits], {
|
|
165
|
-
description:
|
|
166
|
-
"Try hash-anchor edits first. Use exact-text edits only after a hash-anchor edit fails. Never mix formats.",
|
|
167
|
-
}),
|
|
168
|
-
},
|
|
169
|
-
{ additionalProperties: false },
|
|
170
|
-
);
|
|
171
|
-
// One branch each, so a bad call reports only the errors of the format it meant.
|
|
172
|
-
const hashSchema = Type.Object({ path, edits: hashEdits }, { additionalProperties: false });
|
|
173
|
-
const exactSchema = Type.Object({ path, edits: exactEdits }, { additionalProperties: false });
|
|
174
|
-
|
|
175
|
-
const parseJson = (value: string): unknown => {
|
|
176
|
-
try {
|
|
177
|
-
return JSON.parse(value);
|
|
178
|
-
} catch {
|
|
179
|
-
return value;
|
|
180
|
-
}
|
|
181
|
-
};
|
|
182
|
-
|
|
183
|
-
export function prepareHashArguments(input: unknown): unknown {
|
|
184
|
-
if (!input || typeof input !== "object" || Array.isArray(input)) return input;
|
|
185
|
-
const args = { ...input } as Record<string, unknown>;
|
|
186
|
-
if (typeof args.edits === "string") args.edits = parseJson(args.edits);
|
|
187
|
-
if (args.edits && typeof args.edits === "object" && !Array.isArray(args.edits))
|
|
188
|
-
args.edits = [args.edits];
|
|
189
|
-
// Top recorded failure: one edit item sent as a JSON string.
|
|
190
|
-
if (Array.isArray(args.edits))
|
|
191
|
-
args.edits = args.edits.map((item) => {
|
|
192
|
-
if (typeof item !== "string") return item;
|
|
193
|
-
const parsed = parseJson(item);
|
|
194
|
-
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : item;
|
|
195
|
-
});
|
|
196
|
-
return args;
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
const EDIT_FIX = "Fix: resend only the broken field; keep one format per call.";
|
|
200
|
-
|
|
201
|
-
function editFix(format: "hash" | "exact", path: string, problem: string): string {
|
|
202
|
-
if (/\.(pos|end)$/.test(path) && problem.startsWith("expected to match"))
|
|
203
|
-
return "LINE#HASH from read, hash is uppercase hex (12#A3F)";
|
|
204
|
-
if (/\.op$/.test(path)) return "op is replace, insert_before or insert_after";
|
|
205
|
-
if (/\.lines$/.test(path) && problem === "missing")
|
|
206
|
-
return "missing; add lines: [] of literal source lines ([] deletes)";
|
|
207
|
-
if (/\.lines(\.\d+)?$/.test(path)) return "lines is an array of strings, one per line";
|
|
208
|
-
if (/\.oldText$/.test(path) && problem !== "missing")
|
|
209
|
-
return "oldText is non-empty exact current source text";
|
|
210
|
-
if (problem === "unknown field") {
|
|
211
|
-
if (!path.startsWith("edits.")) return "unknown field; send only path and edits";
|
|
212
|
-
return format === "hash"
|
|
213
|
-
? "unknown field; hash items take op, pos, end?, lines"
|
|
214
|
-
: "unknown field; exact items take oldText, newText";
|
|
215
|
-
}
|
|
216
|
-
if (/^edits\.\d+$/.test(path)) return `${problem}; each edit is one object`;
|
|
217
|
-
if (path === "edits") return `${problem}; edits is a non-empty array of objects`;
|
|
218
|
-
return problem;
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
/**
|
|
222
|
-
* Normalize, then check with the host validator. A bad call fails with a few
|
|
223
|
-
* lines that name the field and the fix, for the format the call meant.
|
|
224
|
-
*/
|
|
225
|
-
export function prepareEditArguments(input: unknown): unknown {
|
|
226
|
-
const args = prepareHashArguments(input);
|
|
227
|
-
const edits = (args as { edits?: unknown } | undefined)?.edits;
|
|
228
|
-
const items = Array.isArray(edits) ? edits : [];
|
|
229
|
-
const kind = (item: unknown) =>
|
|
230
|
-
item && typeof item === "object"
|
|
231
|
-
? ["op", "pos", "end", "lines"].some((k) => k in item)
|
|
232
|
-
? "hash"
|
|
233
|
-
: ["oldText", "newText"].some((k) => k in item)
|
|
234
|
-
? "exact"
|
|
235
|
-
: undefined
|
|
236
|
-
: undefined;
|
|
237
|
-
const kinds = items.map(kind);
|
|
238
|
-
const at = (k: string) => kinds.flatMap((x, i) => (x === k ? [i] : [])).join(",");
|
|
239
|
-
if (kinds.includes("hash") && kinds.includes("exact"))
|
|
240
|
-
throw new Error(
|
|
241
|
-
badArgs(
|
|
242
|
-
"edit",
|
|
243
|
-
[`- edits: mixed formats (hash at [${at("hash")}], exact at [${at("exact")}])`],
|
|
244
|
-
EDIT_FIX,
|
|
245
|
-
),
|
|
246
|
-
);
|
|
247
|
-
const format = kinds.includes("exact") ? "exact" : "hash";
|
|
248
|
-
checkArgs(
|
|
249
|
-
"edit",
|
|
250
|
-
format === "hash" ? hashSchema : exactSchema,
|
|
251
|
-
args,
|
|
252
|
-
(path, problem) => editFix(format, path, problem),
|
|
253
|
-
EDIT_FIX,
|
|
254
|
-
);
|
|
255
|
-
return args;
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
// ponytail: pix-ask has a copy of this host-error reformatter (clip/badArgs/checkArgs). Move it
|
|
259
|
-
// to pix-runtime when a third tool needs it (public API addition, minor bump).
|
|
260
|
-
const clip = (text: string, max: number) =>
|
|
261
|
-
text.length > max ? `${text.slice(0, max - 1)}…` : text;
|
|
262
|
-
|
|
263
|
-
function badArgs(tool: string, rows: string[], footer: string): string {
|
|
264
|
-
const head =
|
|
265
|
-
rows.length > 5 ? `${tool}: ${rows.length} bad arguments, first 5` : `${tool}: bad arguments`;
|
|
266
|
-
return [head, ...rows.slice(0, 5), footer].join("\n");
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
/** Host-validate `args`; on failure throw a short field/fix list, not the full-argument echo. */
|
|
270
|
-
function checkArgs(
|
|
271
|
-
tool: string,
|
|
272
|
-
schema: object,
|
|
273
|
-
args: unknown,
|
|
274
|
-
fix: (path: string, problem: string, value: unknown) => string,
|
|
275
|
-
footer: string,
|
|
276
|
-
): void {
|
|
277
|
-
try {
|
|
278
|
-
validateToolArguments(
|
|
279
|
-
{ name: tool, description: "", parameters: schema },
|
|
280
|
-
{ type: "toolCall", id: "", name: tool, arguments: args as never },
|
|
281
|
-
);
|
|
282
|
-
return;
|
|
283
|
-
} catch (error) {
|
|
284
|
-
// ponytail: reads pi-ai's " - path: problem" rows; an unknown format rethrows the host error.
|
|
285
|
-
const text = (error instanceof Error ? error.message : String(error)).split(
|
|
286
|
-
"\n\nReceived arguments:",
|
|
287
|
-
)[0];
|
|
288
|
-
const seen = new Set<string>();
|
|
289
|
-
const rows = [...(text ?? "").matchAll(/^ {2}- (\S+): (.+)$/gm)].map(
|
|
290
|
-
([, path = "", problem = ""]) => {
|
|
291
|
-
const value =
|
|
292
|
-
path === "root"
|
|
293
|
-
? args
|
|
294
|
-
: path
|
|
295
|
-
.split(".")
|
|
296
|
-
.reduce<unknown>(
|
|
297
|
-
(v, k) =>
|
|
298
|
-
v && typeof v === "object" ? (v as Record<string, unknown>)[k] : undefined,
|
|
299
|
-
args,
|
|
300
|
-
);
|
|
301
|
-
const shown =
|
|
302
|
-
value === undefined ? "" : ` ${clip(JSON.stringify(value) ?? String(value), 40)}`;
|
|
303
|
-
// State a repeated fix once, so five rows stay a few hundred bytes.
|
|
304
|
-
const advice = fix(path, problem, value);
|
|
305
|
-
const said = seen.has(advice);
|
|
306
|
-
seen.add(advice);
|
|
307
|
-
return `- ${path.replace(/\.(\d+)/g, "[$1]")}${shown}: ${said ? "same fix" : advice}`;
|
|
308
|
-
},
|
|
309
|
-
);
|
|
310
|
-
if (!rows.length) throw error;
|
|
311
|
-
throw new Error(badArgs(tool, rows, footer));
|
|
312
|
-
}
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
export function editPath(path: string, cwd: string): string {
|
|
316
|
-
let value = path.replace(/[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g, " ").replace(/^@/, "");
|
|
317
|
-
if (
|
|
318
|
-
process.platform === "win32" &&
|
|
319
|
-
value.startsWith("/") &&
|
|
320
|
-
!value.startsWith("//") &&
|
|
321
|
-
!value.includes("\\")
|
|
322
|
-
) {
|
|
323
|
-
value = value.replace(
|
|
324
|
-
/^\/(?:mnt\/|cygdrive\/)?([a-z])(?:\/(.*))?$/i,
|
|
325
|
-
(_all, drive: string, tail: string | undefined) =>
|
|
326
|
-
`${drive.toUpperCase()}:\\${(tail ?? "").replaceAll("/", "\\")}`,
|
|
327
|
-
);
|
|
328
|
-
}
|
|
329
|
-
value = expandHome(value);
|
|
330
|
-
if (value.startsWith("file://")) value = fileURLToPath(value);
|
|
331
|
-
return resolve(cwd, value);
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
function applyHashEdits(bytes: Buffer, input: unknown) {
|
|
335
|
-
const args = prepareHashArguments(input) as HashParams;
|
|
336
|
-
if (
|
|
337
|
-
!args ||
|
|
338
|
-
typeof args.path !== "string" ||
|
|
339
|
-
!args.path ||
|
|
340
|
-
!Array.isArray(args.edits) ||
|
|
341
|
-
!args.edits.length
|
|
342
|
-
)
|
|
343
|
-
throw new Error("Invalid hashline edit input");
|
|
344
|
-
if (Object.keys(args).some((key) => !["path", "edits"].includes(key)))
|
|
345
|
-
throw new Error("Legacy exact-text fields are not supported");
|
|
346
|
-
const source = decodeLines(bytes);
|
|
347
|
-
// Stale/out-of-bounds anchors carry the current lines around the target so the
|
|
348
|
-
// model can retry at once instead of spending a read round-trip.
|
|
349
|
-
const nearby = (line: number) => {
|
|
350
|
-
const from = Math.max(1, Math.min(line, source.lines.length) - 2);
|
|
351
|
-
const rows = source.lines.slice(from - 1, from + 4).map((l, i) => {
|
|
352
|
-
const text = l.text.length > 200 ? `${l.text.slice(0, 200)}…` : l.text;
|
|
353
|
-
return `${anchor(from + i, l.text)}|${text}`;
|
|
354
|
-
});
|
|
355
|
-
return rows.length ? `\nCurrent lines:\n${rows.join("\n")}` : "";
|
|
356
|
-
};
|
|
357
|
-
const check = (value: unknown) => {
|
|
358
|
-
const parsed = parseAnchor(value);
|
|
359
|
-
const line = source.lines[parsed.line - 1];
|
|
360
|
-
if (!line)
|
|
361
|
-
throw new Error(
|
|
362
|
-
`Anchor out of bounds: ${String(value)} (file has ${source.lines.length} lines).${nearby(parsed.line)}`,
|
|
363
|
-
);
|
|
364
|
-
if (hashLine(line.text) !== parsed.hash)
|
|
365
|
-
throw new Error(
|
|
366
|
-
`Stale anchor: ${String(value)}. Use the current anchors below, or read again if the target moved.${nearby(parsed.line)}`,
|
|
367
|
-
);
|
|
368
|
-
return parsed.line;
|
|
369
|
-
};
|
|
370
|
-
const replacements: Array<{ start: number; end: number; lines: string[] }> = [];
|
|
371
|
-
const gaps = new Map<number, string[]>();
|
|
372
|
-
for (const edit of args.edits) {
|
|
373
|
-
if (
|
|
374
|
-
!edit ||
|
|
375
|
-
typeof edit !== "object" ||
|
|
376
|
-
Object.keys(edit).some((key) => !["op", "pos", "end", "lines"].includes(key))
|
|
377
|
-
)
|
|
378
|
-
throw new Error("Legacy or invalid edit fields");
|
|
379
|
-
if (
|
|
380
|
-
!["replace", "insert_before", "insert_after"].includes(edit.op) ||
|
|
381
|
-
!Array.isArray(edit.lines) ||
|
|
382
|
-
edit.lines.some(
|
|
383
|
-
(line) =>
|
|
384
|
-
typeof line !== "string" ||
|
|
385
|
-
/[\r\n\0]|^[1-9]\d*#[0-9A-F]{3}\|/.test(line) ||
|
|
386
|
-
/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/u.test(line),
|
|
387
|
-
)
|
|
388
|
-
)
|
|
389
|
-
throw new Error("Invalid edit operation or source lines");
|
|
390
|
-
const start = check(edit.pos);
|
|
391
|
-
if (edit.op === "replace") {
|
|
392
|
-
const end = edit.end === undefined ? start : check(edit.end);
|
|
393
|
-
if (end < start) throw new Error("Reversed replacement range");
|
|
394
|
-
replacements.push({ start, end, lines: edit.lines });
|
|
395
|
-
} else {
|
|
396
|
-
if (edit.end !== undefined) throw new Error("end is only allowed for replace");
|
|
397
|
-
const gap = edit.op === "insert_before" ? start - 1 : start;
|
|
398
|
-
gaps.set(gap, [...(gaps.get(gap) ?? []), ...edit.lines]);
|
|
399
|
-
}
|
|
400
|
-
}
|
|
401
|
-
replacements.sort((a, b) => a.start - b.start);
|
|
402
|
-
for (let i = 0; i < replacements.length; i++) {
|
|
403
|
-
const range = replacements[i]!;
|
|
404
|
-
if (i && replacements[i - 1]!.end >= range.start)
|
|
405
|
-
throw new Error("Overlapping replacement ranges");
|
|
406
|
-
for (const gap of gaps.keys())
|
|
407
|
-
if (gap >= range.start && gap < range.end)
|
|
408
|
-
throw new Error("Insertion overlaps a replacement range");
|
|
409
|
-
}
|
|
410
|
-
const output: SourceLine[] = [];
|
|
411
|
-
// 1-based output line spans each op wrote, for the fresh anchors in the result.
|
|
412
|
-
const spans: Array<[number, number]> = [];
|
|
413
|
-
const ops: Array<EditOperation & { editLine: number }> = [];
|
|
414
|
-
const append = (lines: string[]) =>
|
|
415
|
-
output.push(...lines.map((text) => ({ text, ending: source.delimiter })));
|
|
416
|
-
let rangeIndex = 0;
|
|
417
|
-
for (let gap = 0; gap <= source.lines.length; ) {
|
|
418
|
-
const insertion = gaps.get(gap);
|
|
419
|
-
if (insertion?.length) {
|
|
420
|
-
const context = source.lines[Math.max(0, gap - 1)]!.text;
|
|
421
|
-
ops.push({
|
|
422
|
-
oldText: context,
|
|
423
|
-
newText:
|
|
424
|
-
gap === 0 ? [...insertion, context].join("\n") : [context, ...insertion].join("\n"),
|
|
425
|
-
editLine: Math.max(1, output.length + (gap === 0 ? 1 : 0)),
|
|
426
|
-
});
|
|
427
|
-
spans.push([output.length + 1, output.length + insertion.length]);
|
|
428
|
-
append(insertion);
|
|
429
|
-
}
|
|
430
|
-
if (gap === source.lines.length) break;
|
|
431
|
-
const range = replacements[rangeIndex];
|
|
432
|
-
if (range?.start === gap + 1) {
|
|
433
|
-
ops.push({
|
|
434
|
-
oldText: source.lines
|
|
435
|
-
.slice(gap, range.end)
|
|
436
|
-
.map((line) => line.text)
|
|
437
|
-
.join("\n"),
|
|
438
|
-
newText: range.lines.join("\n"),
|
|
439
|
-
editLine: output.length + 1,
|
|
440
|
-
});
|
|
441
|
-
// A deletion has no new lines; mark the line that now sits in its place.
|
|
442
|
-
spans.push([output.length + 1, output.length + Math.max(1, range.lines.length)]);
|
|
443
|
-
append(range.lines);
|
|
444
|
-
gap = range.end;
|
|
445
|
-
rangeIndex++;
|
|
446
|
-
} else {
|
|
447
|
-
output.push({ ...source.lines[gap]! });
|
|
448
|
-
gap++;
|
|
449
|
-
}
|
|
450
|
-
}
|
|
451
|
-
for (let i = 0; i < output.length; i++) {
|
|
452
|
-
if (i === output.length - 1)
|
|
453
|
-
output[i]!.ending = source.finalNewline ? output[i]!.ending || source.delimiter : "";
|
|
454
|
-
else if (!output[i]!.ending) output[i]!.ending = source.delimiter;
|
|
455
|
-
}
|
|
456
|
-
return {
|
|
457
|
-
content: source.bom + output.map((line) => line.text + line.ending).join(""),
|
|
458
|
-
ops,
|
|
459
|
-
spans,
|
|
460
|
-
};
|
|
461
|
-
}
|
|
462
|
-
|
|
463
|
-
function applyExactEdits(bytes: Buffer, args: ExactParams) {
|
|
464
|
-
if (Object.keys(args).some((key) => !["path", "edits"].includes(key)))
|
|
465
|
-
throw new Error("Invalid exact-text edit fields");
|
|
466
|
-
const source = decodeLines(bytes);
|
|
467
|
-
const original = source.lines.map((line) => line.text + line.ending).join("");
|
|
468
|
-
// ponytail: exact bytes only. Add newline normalization only if callers need it.
|
|
469
|
-
const ranges = args.edits
|
|
470
|
-
.map((edit) => {
|
|
471
|
-
if (
|
|
472
|
-
!edit ||
|
|
473
|
-
typeof edit !== "object" ||
|
|
474
|
-
Object.keys(edit).some((key) => !["oldText", "newText"].includes(key)) ||
|
|
475
|
-
typeof edit.oldText !== "string" ||
|
|
476
|
-
!edit.oldText ||
|
|
477
|
-
typeof edit.newText !== "string" ||
|
|
478
|
-
[edit.oldText, edit.newText].some((text) =>
|
|
479
|
-
/\0|[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/u.test(text),
|
|
480
|
-
)
|
|
481
|
-
)
|
|
482
|
-
throw new Error("Invalid exact-text edit");
|
|
483
|
-
const start = original.indexOf(edit.oldText);
|
|
484
|
-
if (start < 0) throw new Error("oldText was not found. Use exact source text.");
|
|
485
|
-
if (original.indexOf(edit.oldText, start + 1) >= 0)
|
|
486
|
-
throw new Error("oldText matches more than once. Include more context.");
|
|
487
|
-
return { ...edit, start, end: start + edit.oldText.length };
|
|
488
|
-
})
|
|
489
|
-
.sort((a, b) => a.start - b.start);
|
|
490
|
-
let content = "";
|
|
491
|
-
let offset = 0;
|
|
492
|
-
const ops: Array<EditOperation & { editLine: number }> = [];
|
|
493
|
-
const spans: Array<[number, number]> = [];
|
|
494
|
-
const lineCount = (text: string) => text.match(/\r\n|\r|\n/g)?.length ?? 0;
|
|
495
|
-
for (const range of ranges) {
|
|
496
|
-
if (range.start < offset) throw new Error("Overlapping exact-text edits");
|
|
497
|
-
content += original.slice(offset, range.start);
|
|
498
|
-
const editLine = lineCount(content) + 1;
|
|
499
|
-
ops.push({ oldText: range.oldText, newText: range.newText, editLine });
|
|
500
|
-
spans.push([editLine, editLine + lineCount(range.newText)]);
|
|
501
|
-
content += range.newText;
|
|
502
|
-
offset = range.end;
|
|
503
|
-
}
|
|
504
|
-
return { content: source.bom + content + original.slice(offset), ops, spans };
|
|
505
|
-
}
|
|
506
|
-
|
|
507
|
-
/** Context lines around each changed span, and the cap on anchored lines per result. */
|
|
508
|
-
const FRESH_CONTEXT = 2;
|
|
509
|
-
const FRESH_MAX_LINES = 20;
|
|
510
|
-
|
|
511
|
-
/**
|
|
512
|
-
* Current `LINE#HASH|text` rows around the changed spans, so a chained edit on
|
|
513
|
-
* the same file needs no `read` between calls (SWE-agent shows the edited
|
|
514
|
-
* window after every edit). Gaps between spans are marked with `…`.
|
|
515
|
-
*/
|
|
516
|
-
export function freshAnchors(content: string, spans: Array<[number, number]>): string {
|
|
517
|
-
const lines = decodeLines(Buffer.from(content, "utf8")).lines;
|
|
518
|
-
const wanted = new Set<number>();
|
|
519
|
-
for (const [from, to] of spans)
|
|
520
|
-
for (
|
|
521
|
-
let n = Math.max(1, from - FRESH_CONTEXT);
|
|
522
|
-
n <= Math.min(lines.length, to + FRESH_CONTEXT);
|
|
523
|
-
n++
|
|
524
|
-
)
|
|
525
|
-
wanted.add(n);
|
|
526
|
-
const picked = [...wanted].sort((a, b) => a - b);
|
|
527
|
-
const shown = picked.slice(0, FRESH_MAX_LINES);
|
|
528
|
-
const rows: string[] = [];
|
|
529
|
-
for (const [i, n] of shown.entries()) {
|
|
530
|
-
if (i && n !== (shown[i - 1] ?? 0) + 1) rows.push("…");
|
|
531
|
-
const text = lines[n - 1]?.text ?? "";
|
|
532
|
-
rows.push(`${anchor(n, text)}|${text.length > 200 ? `${text.slice(0, 200)}…` : text}`);
|
|
533
|
-
}
|
|
534
|
-
if (picked.length > shown.length)
|
|
535
|
-
rows.push(`… ${picked.length - shown.length} more changed lines; read for anchors`);
|
|
536
|
-
return rows.join("\n");
|
|
537
|
-
}
|
|
538
|
-
|
|
539
98
|
export function summarizeEditOperations(operations: EditOperation[]) {
|
|
540
99
|
const diffs = operations.map((e) => parseDiff(e.oldText, e.newText));
|
|
541
100
|
const totalAdded = diffs.reduce((sum, d) => sum + d.added, 0);
|
|
@@ -588,40 +147,18 @@ export function registerEditTool(
|
|
|
588
147
|
try {
|
|
589
148
|
if (!fp) throw new Error("path must be a nonempty string");
|
|
590
149
|
const absolutePath = editPath(fp, toolCtx?.cwd || cwd);
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
const applied = args.edits.every(
|
|
604
|
-
(edit) => edit && typeof edit === "object" && "oldText" in edit,
|
|
605
|
-
)
|
|
606
|
-
? applyExactEdits(bytes, args as ExactParams)
|
|
607
|
-
: applyHashEdits(bytes, args);
|
|
608
|
-
operations = applied.ops;
|
|
609
|
-
abort();
|
|
610
|
-
// ponytail: in-place writes preserve links and mode, not crash atomicity. Use a separate atomic-write design if needed.
|
|
611
|
-
await writeFile(absolutePath, applied.content, "utf8");
|
|
612
|
-
abort();
|
|
613
|
-
const fresh = freshAnchors(applied.content, applied.spans);
|
|
614
|
-
const text = `Edited ${fp}: ${operations.length} operations.`;
|
|
615
|
-
return {
|
|
616
|
-
content: [
|
|
617
|
-
{
|
|
618
|
-
type: "text",
|
|
619
|
-
text: fresh ? `${text}\nCurrent anchors:\n${fresh}` : text,
|
|
620
|
-
},
|
|
621
|
-
],
|
|
622
|
-
details: undefined,
|
|
623
|
-
};
|
|
624
|
-
});
|
|
150
|
+
const applied = await editText(absolutePath, params, undefined, sig);
|
|
151
|
+
operations = applied.ops;
|
|
152
|
+
const text = `Edited ${fp}: ${operations.length} operations.`;
|
|
153
|
+
result = {
|
|
154
|
+
content: [
|
|
155
|
+
{
|
|
156
|
+
type: "text",
|
|
157
|
+
text: applied.fresh ? `${text}\nCurrent anchors:\n${applied.fresh}` : text,
|
|
158
|
+
},
|
|
159
|
+
],
|
|
160
|
+
details: undefined,
|
|
161
|
+
};
|
|
625
162
|
} catch (error) {
|
|
626
163
|
const text = getErrorMessage(error);
|
|
627
164
|
if (sig?.aborted || /aborted/i.test(text)) throw error;
|
|
@@ -690,7 +227,7 @@ export function registerEditTool(
|
|
|
690
227
|
)
|
|
691
228
|
)
|
|
692
229
|
return text;
|
|
693
|
-
const hdr = `${
|
|
230
|
+
const hdr = `${renderTitle(theme, "edit", renderCtx)} ${theme.fg("dim", sp(fp))}`;
|
|
694
231
|
|
|
695
232
|
if (Array.isArray(args.edits) && args.edits.some((edit) => "pos" in edit)) {
|
|
696
233
|
text.setText(
|
|
@@ -753,7 +290,7 @@ export function registerEditTool(
|
|
|
753
290
|
filePath = String(ops?.[0]?.filePath ?? "");
|
|
754
291
|
}
|
|
755
292
|
text.setText(
|
|
756
|
-
|
|
293
|
+
renderCollapsed(
|
|
757
294
|
theme,
|
|
758
295
|
"edit",
|
|
759
296
|
sp(filePath),
|