@xynogen/pix-edit 0.2.10 → 0.2.12
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 +80 -11
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xynogen/pix-edit",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.12",
|
|
4
4
|
"description": "Pi tool \u2014 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": "^1.
|
|
43
|
-
"@xynogen/pix-runtime": "^0.
|
|
42
|
+
"@xynogen/pix-pretty": "^1.32.0",
|
|
43
|
+
"@xynogen/pix-runtime": "^0.15.0"
|
|
44
44
|
},
|
|
45
45
|
"peerDependencies": {
|
|
46
46
|
"@earendil-works/pi-coding-agent": "*",
|
package/src/edit.ts
CHANGED
|
@@ -48,7 +48,13 @@ import {
|
|
|
48
48
|
unframeToolResult,
|
|
49
49
|
} from "@xynogen/pix-pretty/utils";
|
|
50
50
|
import { type CollapseState, tickCollapse } from "@xynogen/pix-runtime/collapse";
|
|
51
|
-
import {
|
|
51
|
+
import {
|
|
52
|
+
anchor,
|
|
53
|
+
decodeLines,
|
|
54
|
+
hashLine,
|
|
55
|
+
parseAnchor,
|
|
56
|
+
type SourceLine,
|
|
57
|
+
} from "@xynogen/pix-runtime/hashline";
|
|
52
58
|
import { expandHome } from "@xynogen/pix-runtime/paths";
|
|
53
59
|
import { Type } from "typebox";
|
|
54
60
|
|
|
@@ -206,12 +212,27 @@ function applyHashEdits(bytes: Buffer, input: unknown) {
|
|
|
206
212
|
if (Object.keys(args).some((key) => !["path", "edits"].includes(key)))
|
|
207
213
|
throw new Error("Legacy exact-text fields are not supported");
|
|
208
214
|
const source = decodeLines(bytes);
|
|
215
|
+
// Stale/out-of-bounds anchors carry the current lines around the target so the
|
|
216
|
+
// model can retry at once instead of spending a read round-trip.
|
|
217
|
+
const nearby = (line: number) => {
|
|
218
|
+
const from = Math.max(1, Math.min(line, source.lines.length) - 2);
|
|
219
|
+
const rows = source.lines.slice(from - 1, from + 4).map((l, i) => {
|
|
220
|
+
const text = l.text.length > 200 ? `${l.text.slice(0, 200)}…` : l.text;
|
|
221
|
+
return `${anchor(from + i, l.text)}|${text}`;
|
|
222
|
+
});
|
|
223
|
+
return rows.length ? `\nCurrent lines:\n${rows.join("\n")}` : "";
|
|
224
|
+
};
|
|
209
225
|
const check = (value: unknown) => {
|
|
210
226
|
const parsed = parseAnchor(value);
|
|
211
227
|
const line = source.lines[parsed.line - 1];
|
|
212
|
-
if (!line)
|
|
228
|
+
if (!line)
|
|
229
|
+
throw new Error(
|
|
230
|
+
`Anchor out of bounds: ${String(value)} (file has ${source.lines.length} lines).${nearby(parsed.line)}`,
|
|
231
|
+
);
|
|
213
232
|
if (hashLine(line.text) !== parsed.hash)
|
|
214
|
-
throw new Error(
|
|
233
|
+
throw new Error(
|
|
234
|
+
`Stale anchor: ${String(value)}. Use the current anchors below, or read again if the target moved.${nearby(parsed.line)}`,
|
|
235
|
+
);
|
|
215
236
|
return parsed.line;
|
|
216
237
|
};
|
|
217
238
|
const replacements: Array<{ start: number; end: number; lines: string[] }> = [];
|
|
@@ -255,6 +276,8 @@ function applyHashEdits(bytes: Buffer, input: unknown) {
|
|
|
255
276
|
throw new Error("Insertion overlaps a replacement range");
|
|
256
277
|
}
|
|
257
278
|
const output: SourceLine[] = [];
|
|
279
|
+
// 1-based output line spans each op wrote, for the fresh anchors in the result.
|
|
280
|
+
const spans: Array<[number, number]> = [];
|
|
258
281
|
const ops: Array<EditOperation & { editLine: number }> = [];
|
|
259
282
|
const append = (lines: string[]) =>
|
|
260
283
|
output.push(...lines.map((text) => ({ text, ending: source.delimiter })));
|
|
@@ -269,6 +292,7 @@ function applyHashEdits(bytes: Buffer, input: unknown) {
|
|
|
269
292
|
gap === 0 ? [...insertion, context].join("\n") : [context, ...insertion].join("\n"),
|
|
270
293
|
editLine: Math.max(1, output.length + (gap === 0 ? 1 : 0)),
|
|
271
294
|
});
|
|
295
|
+
spans.push([output.length + 1, output.length + insertion.length]);
|
|
272
296
|
append(insertion);
|
|
273
297
|
}
|
|
274
298
|
if (gap === source.lines.length) break;
|
|
@@ -282,6 +306,8 @@ function applyHashEdits(bytes: Buffer, input: unknown) {
|
|
|
282
306
|
newText: range.lines.join("\n"),
|
|
283
307
|
editLine: output.length + 1,
|
|
284
308
|
});
|
|
309
|
+
// A deletion has no new lines; mark the line that now sits in its place.
|
|
310
|
+
spans.push([output.length + 1, output.length + Math.max(1, range.lines.length)]);
|
|
285
311
|
append(range.lines);
|
|
286
312
|
gap = range.end;
|
|
287
313
|
rangeIndex++;
|
|
@@ -295,7 +321,11 @@ function applyHashEdits(bytes: Buffer, input: unknown) {
|
|
|
295
321
|
output[i]!.ending = source.finalNewline ? output[i]!.ending || source.delimiter : "";
|
|
296
322
|
else if (!output[i]!.ending) output[i]!.ending = source.delimiter;
|
|
297
323
|
}
|
|
298
|
-
return {
|
|
324
|
+
return {
|
|
325
|
+
content: source.bom + output.map((line) => line.text + line.ending).join(""),
|
|
326
|
+
ops,
|
|
327
|
+
spans,
|
|
328
|
+
};
|
|
299
329
|
}
|
|
300
330
|
|
|
301
331
|
function applyExactEdits(bytes: Buffer, args: ExactParams) {
|
|
@@ -328,18 +358,50 @@ function applyExactEdits(bytes: Buffer, args: ExactParams) {
|
|
|
328
358
|
let content = "";
|
|
329
359
|
let offset = 0;
|
|
330
360
|
const ops: Array<EditOperation & { editLine: number }> = [];
|
|
361
|
+
const spans: Array<[number, number]> = [];
|
|
362
|
+
const lineCount = (text: string) => text.match(/\r\n|\r|\n/g)?.length ?? 0;
|
|
331
363
|
for (const range of ranges) {
|
|
332
364
|
if (range.start < offset) throw new Error("Overlapping exact-text edits");
|
|
333
365
|
content += original.slice(offset, range.start);
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
editLine: (content.match(/\r\n|\r|\n/g)?.length ?? 0) + 1,
|
|
338
|
-
});
|
|
366
|
+
const editLine = lineCount(content) + 1;
|
|
367
|
+
ops.push({ oldText: range.oldText, newText: range.newText, editLine });
|
|
368
|
+
spans.push([editLine, editLine + lineCount(range.newText)]);
|
|
339
369
|
content += range.newText;
|
|
340
370
|
offset = range.end;
|
|
341
371
|
}
|
|
342
|
-
return { content: source.bom + content + original.slice(offset), ops };
|
|
372
|
+
return { content: source.bom + content + original.slice(offset), ops, spans };
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/** Context lines around each changed span, and the cap on anchored lines per result. */
|
|
376
|
+
const FRESH_CONTEXT = 2;
|
|
377
|
+
const FRESH_MAX_LINES = 20;
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Current `LINE#HASH|text` rows around the changed spans, so a chained edit on
|
|
381
|
+
* the same file needs no `read` between calls (SWE-agent shows the edited
|
|
382
|
+
* window after every edit). Gaps between spans are marked with `…`.
|
|
383
|
+
*/
|
|
384
|
+
export function freshAnchors(content: string, spans: Array<[number, number]>): string {
|
|
385
|
+
const lines = decodeLines(Buffer.from(content, "utf8")).lines;
|
|
386
|
+
const wanted = new Set<number>();
|
|
387
|
+
for (const [from, to] of spans)
|
|
388
|
+
for (
|
|
389
|
+
let n = Math.max(1, from - FRESH_CONTEXT);
|
|
390
|
+
n <= Math.min(lines.length, to + FRESH_CONTEXT);
|
|
391
|
+
n++
|
|
392
|
+
)
|
|
393
|
+
wanted.add(n);
|
|
394
|
+
const picked = [...wanted].sort((a, b) => a - b);
|
|
395
|
+
const shown = picked.slice(0, FRESH_MAX_LINES);
|
|
396
|
+
const rows: string[] = [];
|
|
397
|
+
for (const [i, n] of shown.entries()) {
|
|
398
|
+
if (i && n !== (shown[i - 1] ?? 0) + 1) rows.push("…");
|
|
399
|
+
const text = lines[n - 1]?.text ?? "";
|
|
400
|
+
rows.push(`${anchor(n, text)}|${text.length > 200 ? `${text.slice(0, 200)}…` : text}`);
|
|
401
|
+
}
|
|
402
|
+
if (picked.length > shown.length)
|
|
403
|
+
rows.push(`… ${picked.length - shown.length} more changed lines; read for anchors`);
|
|
404
|
+
return rows.join("\n");
|
|
343
405
|
}
|
|
344
406
|
|
|
345
407
|
export function summarizeEditOperations(operations: EditOperation[]) {
|
|
@@ -416,8 +478,15 @@ export function registerEditTool(
|
|
|
416
478
|
// ponytail: in-place writes preserve links and mode, not crash atomicity. Use a separate atomic-write design if needed.
|
|
417
479
|
await writeFile(absolutePath, applied.content, "utf8");
|
|
418
480
|
abort();
|
|
481
|
+
const fresh = freshAnchors(applied.content, applied.spans);
|
|
482
|
+
const text = `Edited ${fp}: ${operations.length} operations.`;
|
|
419
483
|
return {
|
|
420
|
-
content: [
|
|
484
|
+
content: [
|
|
485
|
+
{
|
|
486
|
+
type: "text",
|
|
487
|
+
text: fresh ? `${text}\nCurrent anchors:\n${fresh}` : text,
|
|
488
|
+
},
|
|
489
|
+
],
|
|
421
490
|
details: undefined,
|
|
422
491
|
};
|
|
423
492
|
});
|