pi-edit-file 0.2.3 → 0.3.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/README.md CHANGED
@@ -128,10 +128,12 @@ Not-found is the most expensive failure because it is the one models retry blind
128
128
  | Typo in one character (`1000` → `1001`) | `closest line 2 (95% similar): "const timeout = 1000;"` |
129
129
  | Hand-written `\n` inside a line | `Escaping note: a literal "\n" in a before-line is a backslash followed by "n", not a line break …` |
130
130
  | Block occurs more than once in the file | `not unique — the before-block matches at lines 12, 45 (exact match, 2 copies)` + *include more surrounding lines … a line number cannot choose between identical blocks* |
131
- | Every hunk is a no-op (`before == after`) | `nothing applied — every hunk of this patch is a no-op …, so the file was NOT written` |
131
+ | Every hunk is a no-op (`before == after`) or empty (`@@@` with nothing on either side) | `nothing applied — every hunk of this patch is empty or a no-op …`; inside a batch each one is `SKIPPED — no-op …` / `SKIPPED — empty hunk …` and the rest still applies |
132
132
  | No hunk header at all (chain form), a header where the closing delimiter belongs, or an unterminated hunk | the blocks are located in the file and re-emitted as a ready-to-paste numbered skeleton (`NOT UNIQUE` / `NOT FOUND` placeholders when a block cannot be pinned), with the note that a single unique block may start with a bare delimiter line instead of a number |
133
+ | **One hunk whose opening delimiter line was left out** (`old` / delim / `new` / delim) | not called a chain: `the opening delimiter line is missing … Only that first line is missing`, with both legal forms spelled out and no placeholders (24 of 27 rejections in one session were this shape) |
134
+ | Closing delimiter missing inside the patch | the skeleton's preamble says so — `the hunk header is there, but a "@@@" line is missing inside the patch` — instead of claiming the patch opened with content |
133
135
 
134
- Grammar failures get the same treatment as not-found ones: the patch is never applied, and the reply rebuilds the model's own blocks into legal form instead of stopping at a parse error. Not-found failures also include the nearest candidate region with per-line `=` / `≠` markers, and a **ready-to-paste corrected hunk** built from the real file content:
136
+ Grammar failures get the same treatment as not-found ones: the patch is never applied, and the reply rebuilds the model's own blocks into legal form instead of stopping at a parse error. A block that is nowhere in the file is reported with its closest line by similarity (`closest line N (87% similar)`), in both the not-found diagnosis and the rebuilt skeleton. Not-found failures also include the nearest candidate region with per-line `=` / `≠` markers, and a **ready-to-paste corrected hunk** built from the real file content:
135
137
 
136
138
  ```
137
139
  Closest candidate: lines 2-4 — 2 of 3 line(s) match.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-edit-file",
3
- "version": "0.2.3",
3
+ "version": "0.3.0",
4
4
  "description": "Block-based atomic file editing for Pi agents: one flat patch string, precise match diagnostics, word-level diff rendering",
5
5
  "keywords": [
6
6
  "pi-package",
package/src/core.ts CHANGED
@@ -274,9 +274,10 @@ export function parsePatch(patch: string): Hunk[] {
274
274
  if (hint < 1) throw new EditError("insert-after needs NNN >= 1");
275
275
  }
276
276
 
277
- if (before.length === 0 && after.length === 0) {
278
- throw new EditError(`hunk ${hunks.length + 1} (${hintLabel(hint)}) is empty: both before and after blocks are blank`);
279
- }
277
+ // An empty hunk (both blocks blank — usually a stray delimiter pair after a
278
+ // terminator) is kept rather than rejected here: inside a batch the tool
279
+ // skips it with a note, like a no-op, and only a patch that is nothing but
280
+ // empty/no-op hunks is an error (Nik, 2026-10-08).
280
281
  hunks.push({ hint, before, after, ...(header.insertAfter ? { insertAfter: true } : {}) });
281
282
 
282
283
  if (terminated && i < lines.length && lines[i].trim() === "" && i === lines.length - 1) {
@@ -320,7 +321,7 @@ function locateBlock(fileLines: string[], before: string[]): { status: "ok"; lin
320
321
  /** Skeleton for a chain patch; fileLines are needed to number the headers.
321
322
  * Always returns model-facing text — the file is never written from a chain
322
323
  * patch; the model re-emits the skeleton with its own blocks. */
323
- export function chainSkeleton(patch: string, fileLines: string[]): string {
324
+ export function chainSkeleton(patch: string, fileLines: string[], situation?: string): string {
324
325
  const lines = patch.split("\n");
325
326
  if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
326
327
 
@@ -373,7 +374,8 @@ export function chainSkeleton(patch: string, fileLines: string[]): string {
373
374
  }
374
375
 
375
376
  const out: string[] = [
376
- `the patch opens with content instead of a hunk header, and the chain form ("old ${delimRun} new ${delimRun} old ${delimRun} new") is not legal input — every block needs its own header.`,
377
+ situation ??
378
+ `the patch opens with content instead of a hunk header, and the chain form ("old ${delimRun} new ${delimRun} old ${delimRun} new") is not legal input — every block needs its own header.`,
377
379
  `A block that occurs exactly once in the file needs no number: start the patch with a bare "${delimRun}" line.`,
378
380
  "Here is the same edit with the headers filled in — paste your blocks between the header and delimiter lines:",
379
381
  "",
@@ -405,7 +407,15 @@ export function chainSkeleton(patch: string, fileLines: string[]): string {
405
407
  const more = loc.lines.length > 5 ? ` … and ${loc.lines.length - 5} more` : "";
406
408
  headerLine = `<NOT UNIQUE: this before-block matches at lines ${capped.join(", ")}${more} — extend the before-block with surrounding lines to make it unique, then re-emit with the chosen NNN>`;
407
409
  } else {
408
- headerLine = `<NOT FOUND: this before-block does not appear in the file (exact, trimmed and whitespace-collapsed matching all failed) — re-read the file and copy the lines exactly>`;
410
+ // Symmetry with the not-found diagnosis: when the block is nowhere in the
411
+ // file, name the closest line by similarity too instead of only saying
412
+ // "re-read the file" (2026-10-08 feedback).
413
+ const distinctive = before.lines.reduce((best, l) => (l.trim().length > best.trim().length ? l : best), before.lines[0] ?? "");
414
+ const near = distinctive ? closestLine(fileLines, distinctive, before.hint ?? 1) : undefined;
415
+ const nearText = near
416
+ ? ` — closest line ${near.index + 1} (${Math.round(near.similarity * 100)}% similar): ${JSON.stringify(near.text)}`
417
+ : "";
418
+ headerLine = `<NOT FOUND: this before-block does not appear in the file (exact, trimmed and whitespace-collapsed matching all failed)${nearText} — re-read the file and copy the lines exactly>`;
409
419
  }
410
420
  }
411
421
  out.push(headerLine);
@@ -425,6 +435,37 @@ export function chainSkeleton(patch: string, fileLines: string[]): string {
425
435
  return out.join("\n");
426
436
  }
427
437
 
438
+ /** A patch that opens with content and holds exactly the delimiter lines of ONE
439
+ * hunk (its body plus the optional terminator) is not a chain: it is a single
440
+ * hunk whose opening delimiter line was left out. That shape gets called out
441
+ * precisely, with both legal forms spelled out and no placeholder skeleton —
442
+ * the chain-form diagnosis sent the model fixing a problem it did not have, and
443
+ * 24 of 27 rejections in one session were exactly this (2026-10-08). The
444
+ * one-delimiter variant of the same shape is accepted by headerlessSingleHunk(). */
445
+ export function openingDelimiterDiagnosis(patch: string): string | null {
446
+ const lines = patch.split("\n");
447
+ if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
448
+ // A numbered header anywhere means the author knows about headers — that is a
449
+ // mixed/chain patch, and the rebuilt skeleton with real numbers serves it.
450
+ if (lines.some((line) => HEADER_RE.test(line))) return null;
451
+
452
+ const runs = lines.map((line) => delimiterRun(line));
453
+ const at: number[] = [];
454
+ for (let i = 0; i < runs.length; i++) if (runs[i] !== null) at.push(i);
455
+ if (at.length !== 2) return null; // 1 delimiter: accepted; 0 or ≥3: no single-hunk reading
456
+ const run = runs[at[0]];
457
+ if (runs[at[1]] !== run) return null; // mixed runs: let the parser complain about that
458
+ if (!lines.slice(at[1] + 1).every((l) => l.trim() === "")) return null; // content after the 2nd: a chain
459
+
460
+ return (
461
+ `the patch opens with content instead of a hunk header — the opening delimiter line is missing (the blocks themselves are fine).\n` +
462
+ `A hunk carries that line in front:\n\n` +
463
+ `${run}\nold lines\n${run}\nnew lines\n${run}\n\n` +
464
+ `or, with a line-number hint instead of the bare first line:\n\n` +
465
+ `NNN ${run}\nold lines\n${run}\nnew lines\n${run}\n\n` +
466
+ `Only that first line is missing — the rest of the patch is already in the right shape.`
467
+ );
468
+ }
428
469
  const exactEq: LineEq = (a, b) => a === b;
429
470
  const trimEq: LineEq = (a, b) => a.trim() === b.trim();
430
471
  const collapseEq: LineEq = (a, b) => a.replace(/\s+/g, " ").trim() === b.replace(/\s+/g, " ").trim();
@@ -706,6 +747,20 @@ function resolveOne(hunk: Hunk, fileLines: string[], index: number): { ok: Resol
706
747
  const hint = hunk.hint;
707
748
  const kind: HunkKind = hunk.before.length === 0 ? "insert" : hunk.after.length === 0 ? "delete" : "replace";
708
749
 
750
+ // Both blocks blank: nothing to change. The extension skips these inside a
751
+ // batch, so reaching here means the patch was nothing but skipped hunks.
752
+ if (isEmptyHunk(hunk)) {
753
+ return {
754
+ fail: {
755
+ index,
756
+ hint,
757
+ kind,
758
+ reason: "empty hunk — no before-block and no after-block, so there is nothing to change (stray delimiter lines?)",
759
+ detail: "",
760
+ },
761
+ };
762
+ }
763
+
709
764
  if (hunk.before.length === 0) {
710
765
  if (hint === null) {
711
766
  return {
@@ -1272,6 +1327,12 @@ export function insertTip(resolved: ResolvedHunk[], numbers?: number[], delim =
1272
1327
  * (exact): applying it changes nothing, so it is skipped with a note instead
1273
1328
  * of killing the batch or silently doing busywork. Trim-equal blocks still
1274
1329
  * apply — whitespace normalization is a real change. */
1330
+ /** A hunk with nothing on either side — usually stray delimiter lines after a
1331
+ * terminator. Skipped inside a batch; a patch made only of these is an error. */
1332
+ export function isEmptyHunk(h: Hunk): boolean {
1333
+ return h.before.length === 0 && h.after.length === 0;
1334
+ }
1335
+
1275
1336
  export function isNoOpHunk(h: Hunk): boolean {
1276
1337
  return h.before.length > 0 && h.before.length === h.after.length && h.before.every((l, i) => l === h.after[i]);
1277
1338
  }
package/src/extension.ts CHANGED
@@ -43,6 +43,8 @@ import {
43
43
  reportCaveats,
44
44
  insertTip,
45
45
  appliedLine,
46
+ openingDelimiterDiagnosis,
47
+ isEmptyHunk,
46
48
  } from "./core.ts";
47
49
 
48
50
  const TOOL_NAME = "edit_file";
@@ -165,30 +167,45 @@ export default function editFileExtension(pi: { registerTool: (t: unknown) => vo
165
167
  // blocks on. Without one (only "NNN @@@" headers, no standalone
166
168
  // delimiter line) its fallback text is the generic "no delimiter
167
169
  // found" — then the precise diagnosis alone is more useful.
168
- const grammarError = err.code === "content-start" || err.code === "missing-separator" || err.code === "unterminated";
169
- if (grammarError && (err.code === "content-start" || patchDelimiter(params.patch) !== null)) {
170
- const skeleton = chainSkeleton(params.patch, readLines(absPath).lines);
171
- const diagnosis = err.code === "content-start" ? "" : `${err.message}\n\n`;
172
- throw new EditError(`patch rejected — nothing was written to the file.\n${diagnosis}${skeleton}`);
170
+ // A patch that opens with content gets a reply built from its own
171
+ // blocks. When it is ONE hunk missing only its opening delimiter line
172
+ // (the shape models reach for again and again), the diagnosis is
173
+ // precise and placeholder-free; a real chain gets the rebuilt skeleton.
174
+ if (err.code === "content-start") {
175
+ const precise = openingDelimiterDiagnosis(params.patch);
176
+ const body = precise ?? chainSkeleton(params.patch, readLines(absPath).lines);
177
+ throw new EditError(`patch rejected — nothing was written to the file.\n${body}`);
178
+ }
179
+ // A delimiter line inside the patch is missing: the skeleton is right,
180
+ // but its preamble must describe THIS failure — it used to claim the
181
+ // patch opened with content even when the header was there.
182
+ const delim = patchDelimiter(params.patch);
183
+ if ((err.code === "missing-separator" || err.code === "unterminated") && delim !== null) {
184
+ const situation = `the hunk header is there, but a "${delim}" line is missing inside the patch — every block needs its closing "${delim}" line.`;
185
+ const skeleton = chainSkeleton(params.patch, readLines(absPath).lines, situation);
186
+ throw new EditError(`patch rejected — nothing was written to the file.\n${err.message}\n\n${skeleton}`);
173
187
  }
174
188
  throw new EditError(`patch rejected — nothing was written to the file.\n${err.message}`);
175
189
  }
176
190
  throw err;
177
191
  }
178
192
 
179
- // No-op hunks (before == after, exact) carry no change at all. Inside a
180
- // batch each is skipped with a loud note; a patch made ONLY of them is
181
- // rejected — such a patch means the author copied the wrong lines, and
182
- // that signal must never come back as a success (Nik, 2026-10-02).
193
+ // Hunks that change nothing are skipped inside a batch, each with a loud
194
+ // note: no-ops (before == after) and empty ones (both blocks blank, usually
195
+ // stray delimiter lines after a terminator — one such hunk used to reject
196
+ // the whole batch). A patch made ONLY of them is rejected: that patch means
197
+ // the author copied the wrong lines, and such a signal must never come back
198
+ // as a success (Nik, 2026-10-02).
183
199
  const active: Array<{ hunk: Hunk; origIndex: number }> = [];
184
200
  const noOpNotes: string[] = [];
185
201
  parsed.forEach((h, i) => {
186
- if (isNoOpHunk(h)) noOpNotes.push(`hunk ${i + 1}: SKIPPED — no-op (the old block equals the new block); this hunk changes nothing`);
202
+ if (isEmptyHunk(h)) noOpNotes.push(`hunk ${i + 1}: SKIPPED — empty hunk (no old block, no new block); this hunk changes nothing`);
203
+ else if (isNoOpHunk(h)) noOpNotes.push(`hunk ${i + 1}: SKIPPED — no-op (the old block equals the new block); this hunk changes nothing`);
187
204
  else active.push({ hunk: h, origIndex: i });
188
205
  });
189
206
  if (active.length === 0) {
190
207
  throw new EditError(
191
- `nothing applied — every hunk of this patch is a no-op (the old block equals the new block), so the file was NOT written.\n` +
208
+ `nothing applied — every hunk of this patch is empty or a no-op (it changes nothing), so the file was NOT written.\n` +
192
209
  `${noOpNotes.join("\n")}\n` +
193
210
  `Check that the old block is the text you mean to replace, and that the new block differs from it.`,
194
211
  );