@xynogen/pix-edit 0.2.11 → 0.2.13

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.
Files changed (2) hide show
  1. package/package.json +2 -2
  2. 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.11",
3
+ "version": "0.2.13",
4
4
  "description": "Pi tool \u2014 precise text replacement edit with diff rendering",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -40,7 +40,7 @@
40
40
  },
41
41
  "dependencies": {
42
42
  "@xynogen/pix-pretty": "^1.32.0",
43
- "@xynogen/pix-runtime": "^0.14.0"
43
+ "@xynogen/pix-runtime": "^0.16.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 { decodeLines, hashLine, parseAnchor, type SourceLine } from "@xynogen/pix-runtime/hashline";
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) throw new Error(`Anchor out of bounds: ${String(value)}`);
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(`Stale anchor: ${String(value)}. Read the file again.`);
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 { content: source.bom + output.map((line) => line.text + line.ending).join(""), ops };
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
- ops.push({
335
- oldText: range.oldText,
336
- newText: range.newText,
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: [{ type: "text", text: `Edited ${fp}: ${operations.length} operations.` }],
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
  });