pi-hashline-edit-pro 5.0.0 → 5.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/README.md CHANGED
@@ -188,7 +188,7 @@ Edge cases:
188
188
  | --- | --- |
189
189
  | `remove_from` | 4-char anchor marking the FIRST line to remove (inclusive). |
190
190
  | `remove_to` | 4-char anchor marking the LAST line to remove (inclusive). |
191
- | `replacement_lines` | The exact text to write in place of the removed range, as one string: `""` deletes the range, `"\n"` is one blank line, and a trailing line break sets the last line's ending instead of adding a blank line. Embedded `\r\n`/`\r`/`\n` are preserved; escapes decode once — `\uXXXX` is the character, `\\uXXXX` the literal text. Legacy arrays are converted to text (elements joined with LF); prefer the string form. |
191
+ | `replacement_lines` | The exact text to write in place of the removed range, as one string: `""` deletes the range, `"\n"` is one blank line, and a trailing line break sets the last line's ending instead of adding a blank line. Embedded `\r\n`/`\r`/`\n` are preserved; JSON decoding happens once, before the tool; the tool writes the string it receives and never decodes — `\uXXXX` is the character, `\\uXXXX` the literal text. Legacy arrays are converted to text (elements joined with LF); prefer the string form. |
192
192
 
193
193
  Example: read showed `Hasu│old` and `arvm│old2`; to replace both:
194
194
 
@@ -201,6 +201,7 @@ Example: read showed `Hasu│old` and `arvm│old2`; to replace both:
201
201
  ```
202
202
 
203
203
  Single line: use the same anchor for `remove_from` and `remove_to`. `replace_from`/`replace_to` and `from`/`to` work as aliases.
204
+ A deletion keeps blank lines at the edges of the removed range, so the separators around a block survive the edit; target a blank line on its own to delete it.
204
205
 
205
206
  The extension checks the request before any file I/O, so a bad request never touches the file.
206
207
 
@@ -208,7 +209,7 @@ Auto-fixable slips fall into two groups. Fixed silently: a reversed range, embed
208
209
 
209
210
  Content containing a NUL byte (`U+0000`) is rejected with `[E_BAD_SHAPE]` before any file I/O: writing it would make the file binary, so use an empty replacement to delete. This applies to `replace`'s `replacement_lines` and `insert`'s `lines`.
210
211
 
211
- Every line in the removed range must match what was last shown to you. The extension records the `anchor│content` rows it serves (`read` output, `anchor_grep` output, the auto-read block after `write`, the `+anchor│` and ` anchor│` rows of post-edit diffs, the current-range rows of `[E_RANGE_STALE]` feedback, and the context rows of stale-anchor feedback) and verifies the whole range against that record before writing. A line that changed on disk since it was shown, or an anchor that is not owned in this session, refuses the edit with `[E_RANGE_STALE]` or `[E_STALE_ANCHOR]` and returns the current range with fresh anchors, so the retry needs no `read`. An owned anchor enters the served record when its row is shown (after a restart, restored ownership counts as shown), so a file with no owned anchors cannot be edited by anchor at all; call `read` first. An owned line that was never shown, for example beyond an auto-read preview's truncation cap, is refused with `[E_RANGE_STALE]` and returns the current range, so the retry still needs no `read`.
212
+ Every line in the removed range must match what was last shown to you, except that a pure deletion (an empty replacement) verifies only the first and last line of the range and removes the interior as it currently stands. The extension records the `anchor│content` rows it serves (`read` output, `anchor_grep` output, the auto-read block after `write`, the `+anchor│` and ` anchor│` rows of post-edit diffs, the current-range rows of `[E_RANGE_STALE]` feedback, and the context rows of stale-anchor feedback) and verifies the whole range against that record before writing. A line that changed on disk since it was shown, or an anchor that is not owned in this session, refuses the edit with `[E_RANGE_STALE]` or `[E_STALE_ANCHOR]` and returns the current range with fresh anchors, so the retry needs no `read`. An owned anchor enters the served record when its row is shown (after a restart, restored ownership counts as shown), so a file with no owned anchors cannot be edited by anchor at all; call `read` first. An owned line that was never shown, for example beyond an auto-read preview's truncation cap, is refused with `[E_RANGE_STALE]` and returns the current range, so the retry still needs no `read`; only lines strictly between the boundaries of a pure deletion are exempt.
212
213
 
213
214
  An edit that changes neither content nor line endings reports `No changes made` and leaves the anchors alone.
214
215
 
@@ -230,9 +231,9 @@ A `replace_within` call is never grouped into a batch; it commits on its own lik
230
231
  | --- | --- |
231
232
  | `anchor` | 4-char anchor marking the line next to which the lines go. The anchor line is preserved. A pasted `+Hasu│x` diff row or `anchor│` prefix is stripped automatically with a warning. |
232
233
  | `direction` | `"after"` inserts below the anchor line, `"before"` above it. |
233
- | `lines` | The exact text to insert, as one string: `""` inserts nothing, `"\n"` is one blank line, and a trailing line break sets the last line's ending instead of adding a blank line. Never include the anchor line. Embedded `\r\n`/`\r`/`\n` are preserved; escapes decode once — `\uXXXX` is the character, `\\uXXXX` the literal text. Legacy arrays are converted to text (elements joined with LF); prefer the string form. |
234
+ | `lines` | The exact text to insert, as one string: `""` inserts one blank line (the same as `"\n"`), and a trailing line break sets the last line's ending instead of adding a blank line. Never include the anchor line. Embedded `\r\n`/`\r`/`\n` are preserved; JSON decoding happens once, before the tool; the tool writes the string it receives and never decodes — `\uXXXX` is the character, `\\uXXXX` the literal text. Legacy arrays are converted to text (elements joined with LF); prefer the string form. |
234
235
 
235
- Nothing is removed and the inserted lines are written exactly as given; the anchor line and every other line stay in place. Inserting nothing (`lines: ""`) reports a noop. To seed an empty file, read it and insert after the `anchor│` empty-line row.
236
+ Nothing is removed and the inserted lines are written exactly as given; the anchor line and every other line stay in place. An empty `lines` payload inserts one blank line. To seed an empty file, read it and insert after the `anchor│` empty-line row.
236
237
 
237
238
  Example: add a line after `Emno│`:
238
239
 
@@ -244,7 +245,7 @@ The same safety machinery as `replace` applies: undo is saved before the write (
244
245
 
245
246
  ### copy
246
247
 
247
- `copy` duplicates a range of lines to another position without removing the source. `source_from` and `source_to` select lines in the source file; `insert_after` selects the destination line, and it may live in a different file. An empty destination file is seeded with the copied lines. It is a served-anchor edit like `replace` and `insert`: the source lines and the destination anchor line must come from rows you were shown, and the request is refused if they changed on disk or were never served. With `requirePath` on, `path` must match the source or the destination file.
248
+ `copy` duplicates a range of lines to another position without removing the source. `source_from` and `source_to` select lines in the source file; `insert_after` selects the destination line, and it may live in a different file. An empty destination file is seeded with the copied lines. It is a served-anchor edit like `replace` and `insert`: the source's first and last lines and the destination anchor line must come from rows you were shown, and the request is refused if they changed on disk or were never served; the interior of the range is transferred verbatim and does not need to have been shown. With `requirePath` on, `path` must match the source or the destination file.
248
249
 
249
250
  | Field | Description |
250
251
  | --- | --- |
@@ -308,8 +309,8 @@ Multiple `replace` and `insert` calls on the same file in one assistant message
308
309
 
309
310
  - A call outside a batch commits before its result returns.
310
311
  - A `copy`, `move`, or `replace_within` call is never grouped into a batch: it commits on its own, and a pending same-file batch aborts safely with `[E_OP_ABORTED]` if the file changed under it.
311
- - A batch validates every call against the pre-batch state and commits once, during the batch's last call: earlier calls reply `In batch N`, and the batch's last call shows the combined diff, with one undo reverting the whole batch.
312
- - If a batch aborts, an earlier member's row renders the abort message instead of the placeholder. Nothing commits until the last call succeeds.
312
+ - A batch validates every call against the pre-batch state and commits once, during the batch's last call: earlier calls reply `In batch N (queued)`, and the batch's last call shows the combined diff, with one undo reverting the whole batch.
313
+ - If a batch aborts, nothing is written: the failing call's error ends with `Aborts batch N.` and reports that the whole batch was discarded, and an earlier member's row renders the abort message instead of the queued placeholder. Nothing commits until the last call succeeds.
313
314
  - A batch member accepts the same request shapes and auto-fixes as a standalone call.
314
315
 
315
316
  Batched calls must target disjoint ranges; overlapping ranges, or any failing call, aborts the whole batch unwritten. One `insert` with `direction: "before"` and one with `direction: "after"` may target the same anchor line: the pair composes into a single insertion. A batch member that fails aborts its batch-mates with `[E_OP_ABORTED]`.
@@ -322,7 +323,7 @@ The hashline tools are sequential in pi, so a message that contains one runs all
322
323
 
323
324
  Auto-read is enabled by default. After a successful `write`, the extension reads the file and appends an `--- Auto-read (hashline anchors) ---` block, so you get fresh `anchor│content` anchors without a separate `read` call.
324
325
 
325
- After `replace`, `replace_within`, `insert`, `copy`, `move`, and `undo_last_change`, the result shows the post-edit diff. Inside a same-message batch, only the batch's last call shows the combined diff, headed by a `batch N:` line; earlier calls reply `In batch N`. The `+anchor│` and ` anchor│` rows carry the current anchors, so follow-up edits can anchor on the diff directly. The `-anchor│` rows show removed lines with their old anchors, which are stale after the edit. When the context line next to a change is blank or whitespace-only, one more context line is shown in that direction, so the change stays anchored to visible content. Call `read` when you want the full file's anchors.
326
+ After `replace`, `replace_within`, `insert`, `copy`, `move`, and `undo_last_change`, the result shows the post-edit diff. Inside a same-message batch, only the batch's last call shows the combined diff, headed by a `batch N:` line; earlier calls reply `In batch N (queued)`. The `+anchor│` and ` anchor│` rows carry the current anchors, so follow-up edits can anchor on the diff directly. The `-anchor│` rows show removed lines with their old anchors, which are stale after the edit. When the context line next to a change is blank or whitespace-only, one more context line is shown in that direction, so the change stays anchored to visible content. Call `read` when you want the full file's anchors.
326
327
 
327
328
  An edit that changes only line endings has no content diff; the result still reports `applied`, and one `undo_last_change` reverts it.
328
329
 
@@ -419,7 +420,7 @@ Codes starting with `E_` are errors: nothing was written, with one exception. `F
419
420
  Most common, with the fix:
420
421
 
421
422
  - `[E_STALE_ANCHOR]`: the anchor is not owned in this session. Call `read` for fresh anchors and retry.
422
- - `[E_RANGE_STALE]`: a line in the replaced range changed on disk or was never shown. The error already returns the current range with fresh anchors; retry with those.
423
+ - `[E_RANGE_STALE]`: a line in the replaced range changed on disk or was never shown (a pure deletion checks only its first and last line). The error already returns the current range with fresh anchors; retry with those.
423
424
  - `[E_FILE_TOO_LARGE]`: the file exceeds the 1,353,139-line hashline limit or the 100MB size limit. Use `write` for very large files.
424
425
  - `[E_STORE_UNAVAILABLE]`: no SQLite runtime. Run pi under Node 22.19+ or a Bun build that ships `bun:sqlite`.
425
426
  - `[E_WRITE_HASH_ECHO]`: a `write` content line contains a copied served row. Remove the anchors and retry.
@@ -440,15 +441,20 @@ Full reference:
440
441
  | `[W_INVALID_PATCH]` | A `replacement_lines` line is a diff-preview row (`+anchor│`, `-anchor│`, `- │`). The marker is stripped automatically with a warning. |
441
442
  | `[W_BARE_HASH_PREFIX]` | A `replacement_lines` line starts with an `anchor│` prefix. The prefix is stripped automatically with a warning. |
442
443
  | `[W_ANCHOR_RECLAIMED]` | The session's anchor quota was exhausted, so all anchors of the listed files (the least recently read or edited) were freed to make room. Read those files again before editing them. |
443
- | `[H_LITERAL_ESCAPE]` | `lines` or `replacement_lines` contains literal escaped text such as `\uXXXX` or `\n`; the file receives those backslash characters as written. Escapes decode once in the tool call (`\uXXXX` → the character), so a doubled escape (`\\uXXXX`) lands literally — resend with the real character if that was not intended. |
444
- | `[H_UNICODE_LOST]` | The removed line contained an invisible or look-alike character (for example `U+200B` zero-width space, `U+00A0` no-break space, or a smart quote) that the replacement does not. The edit applied as sent; copy the character from the served row if the request did not ask to remove it. |
444
+ | `[H_LITERAL_ESCAPE]` | A payload field contains literal escaped text such as `\uXXXX`, `\n`, `\t`, `\r`, or `\"` (one hint per distinct escape, up to three). The file receives those backslash characters as written, because JSON decoding happens once, before the tool call (`\uXXXX` → the character), so a doubled escape (`\\uXXXX`) lands literally. The hint is one line: `lines: "\u200b" written as literal text (Kq3f│ col 31); resend with U+200B if unintended.` names the field, the escape, and up to three affected anchors with their columns, then the fix; when more than three rows carry it, it gives `on 10 rows; undo_last_change + resend with U+200B if unintended.` instead of the anchor list. |
445
+ | `[H_UNICODE_LOST]` | The new text is missing an invisible or look-alike character (for example `U+200B`, `U+2060`, `U+00A0`, or a smart quote) that a replaced row has, or that a context row sharing a long run with an inserted line has. The edit applied as sent; the one-line hint names the character, its column, and the reference anchor, for example `[H_UNICODE_LOST] U+2060 missing at col 31; Kq3f│ has it; resend with U+2060 if unintended.` When another look-alike character (including `U+FFFD`) takes its place, the hint reports the substitute with `[H_UNICODE_SWAPPED]` wording. |
446
+ | `[H_UNICODE_SWAPPED]` | The new text uses a different invisible or look-alike character than the line it matches (for example `U+200D` where that line has `U+200B`, or an ASCII stand-in such as `.` for `。` or a space for `U+00A0`). The edit applied as sent; the one-line hint names both code points, the column, and the reference anchor, for example `[H_UNICODE_SWAPPED] U+200D at col 21 where Kq3f│ has U+200B; resend with U+200B if unintended.` The substitute may also be `U+FFFD`, for example where a row has `U+00A0`. |
447
+ | `[H_TRAILING_WHITESPACE]` | The new text differs from the replaced line only in trailing whitespace. Anchor checksums trim trailing whitespace, so the change does not invalidate the anchor; the one-line hint names the old and new trailing-whitespace counts and the column, for example `[H_TRAILING_WHITESPACE] 1 trailing whitespace character at col 7; Kq3f│ had 0.` |
448
+ | `[H_INDENT_MISMATCH]` | The new line has fewer leading whitespace characters than a structurally similar row (a reference row near the anchor line for an insert, or the replaced line). The edit applied as sent; the one-line hint names both counts and the reference anchor, for example `[H_INDENT_MISMATCH] new line has 0 leading whitespace characters; Kq3f│ has 2.` Copied or moved blocks are not checked, because their indentation comes from the source lines. |
449
+ | `[H_SEPARATOR_MOVED]` | An insert landed its text directly against the anchor line, and the blank line that separated the anchor from its neighbor was displaced to the other side of the inserted text. The edit applied as sent; the one-line hint names the anchor and which side lost the blank line, for example `[H_SEPARATOR_MOVED] blank separator above Kq3f│ was displaced; add a blank line before Kq3f│ if unintended.` |
450
+ | `[H_SEPARATOR_LOST]` | A pure deletion removed a run of blank lines that sat between two content lines. The edit applied as sent; the one-line hint names the two surviving anchors and the removed count, for example `[H_SEPARATOR_LOST] deletion removed 2 blank lines between Aaaa│ and Dddd│.` |
445
451
  | `[E_WOULD_EMPTY]` | An edit would empty a non-empty file; use `write` instead. A cross-file `move` may empty its source file. |
446
452
  | `[E_NOT_FOUND]` | The path does not exist. |
447
453
  | `[E_ACCESS]` | The file is not readable or writable. |
448
454
  | `[E_NOT_TEXT]` | The path is a directory, binary file, image, or UTF-16/UTF-32 encoded text; hashline editing only supports text files. |
449
455
  | `[E_UNDO_STALE]` | `undo_last_change` refused: the file was modified after the last edit. The undo record is kept until the file matches the edited state again or a new edit replaces it. |
450
456
  | `[E_UNDO_UNAVAILABLE]` | Undo history could not be persisted to the hash store; the edit was refused and the file was left unchanged. |
451
- | `[E_RANGE_STALE]` | A line in the replaced range no longer matches what was last shown (the file changed on disk, or the line was never shown). The edit was refused; the current range is returned with fresh anchors. |
457
+ | `[E_RANGE_STALE]` | A line in the replaced range no longer matches what was last shown (the file changed on disk, or the line was never shown; a pure deletion checks only its first and last line). The edit was refused; the current range is returned with fresh anchors. |
452
458
  | `[E_FILE_TOO_LARGE]` | The file exceeds the 1,353,139-line hashline limit or the 100MB size limit. |
453
459
  | `[E_REGISTRY]` | The anchor registry was not initialized; a serve or edit ran outside an initialized session. |
454
460
  | `[E_STORE_UNAVAILABLE]` | No SQLite runtime could be loaded: the host exposes neither `node:sqlite` (Node 22.19+) nor `bun:sqlite`. The pi release binary's bundled Bun lacks `node:sqlite`; run pi under Node or a Bun build that ships SQLite. |
@@ -464,7 +470,7 @@ Full reference:
464
470
  ## Troubleshooting
465
471
 
466
472
  - Stale anchors. `[E_STALE_ANCHOR]` means an anchor is not owned in this session: it was never shown to you, or its line was edited or the file was rewritten since. Call `read` for fresh anchors and retry.
467
- - Range changed on disk. `[E_RANGE_STALE]` means a line inside the replaced range changed after it was last shown to you (or was never shown). Nothing was modified; the error carries the current range with fresh anchors, so retry with those without a `read`.
473
+ - Range changed on disk. `[E_RANGE_STALE]` means a line inside the replaced range changed after it was last shown to you (or was never shown; a pure deletion only needs its first and last line shown). Nothing was modified; the error carries the current range with fresh anchors, so retry with those without a `read`.
468
474
  - Multi-conversation hosts. Anchors, served records, and ownership logs are resolved per calling session, so a tool call in one conversation is never answered by another conversation's registry; a foreign anchor fails with `[E_STALE_ANCHOR]`. Interactive previews are the one exception: pi does not pass the session into render callbacks, so when one process serves several conversations at once a preview can fall back to the most recently active session and show a stale or wrong-file diff. Previews never write files or claim anchors; run the call for the authoritative result.
469
475
  - Undo scope. `undo_last_change` records are keyed by file path, not by session, so in a multi-conversation host any conversation that names the file can revert its most recent `replace` or `insert`, even one made by another conversation. Anchor ownership remains session-scoped; only undo is shared.
470
476
  - Reset the anchor state. Anchors live in `~/.config/pi-hashline-edit-pro/hash-store.sqlite` (with `-wal`/`-shm` sidecars) and in per-session ownership logs under `~/.config/pi-hashline-edit-pro/sessions/`. Quit pi, delete those files, and everything is rebuilt on the next session. Anchor history is lost, but no project files are touched.
@@ -524,13 +530,7 @@ Allocated anchors live in a persistent per-file snapshot (`~/.config/pi-hashline
524
530
 
525
531
  ## Benchmark
526
532
 
527
- pi-hashline-edit-pro is measured on the [Explicit Edit Benchmark](https://github.com/alexshpunt/explicit-edit-benchmark), an open third-party benchmark maintained by [alexshpunt](https://github.com/alexshpunt). The suite has 226 deterministic, byte-exact editing tasks across many edit shapes and file types. A verifier compares the result file tree byte by byte, so nothing is graded on compilation or behavioral equivalence. Many harnesses are scored on it.
528
-
529
- The current release scores 98.7% in the explorer's harness-family view for `pi-hashline-edit-pro`, with 99.6% or better final exactness on every model route.
530
-
531
- First exact means the first attempt, with no recovery round, reproduced the expected bytes exactly. Final exact is the same after the benchmark's allowed recovery rounds. Quality is `0.75 × first exact + 0.25 × final exact`, so first-attempt accuracy dominates. The family rollup is the median quality across the complete, eligible model-route configurations scored with this harness; partial and quarantined configurations stay visible in the dataset but do not enter the median.
532
-
533
- Per-model and per-version numbers, plus the spread behind the headline value, are in the [benchmark explorer](https://huggingface.co/spaces/alexshpunt/benchmark-explorer?card=harness%3Api-hashline-edit-pro%40latest); raw observations and scoring rules are in the [dataset](https://huggingface.co/datasets/alexshpunt/explicit-edit-benchmark) and the [methodology](https://github.com/alexshpunt/explicit-edit-benchmark/blob/main/docs/methodology.md). Treat the score as one measurement of this suite at one point in time.
533
+ pi-hashline-edit-pro is scored on the [Explicit Edit Benchmark](https://github.com/alexshpunt/explicit-edit-benchmark), an open third-party suite of 226 deterministic, byte-exact editing tasks. Per-model results and the scoring rules are in the [benchmark explorer](https://huggingface.co/spaces/alexshpunt/benchmark-explorer?card=harness%3Api-hashline-edit-pro%40latest) and the [dataset](https://huggingface.co/datasets/alexshpunt/explicit-edit-benchmark).
534
534
 
535
535
  ## Development
536
536
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-hashline-edit-pro",
3
- "version": "5.0.0",
3
+ "version": "5.1.0",
4
4
  "type": "module",
5
5
  "description": "Hash-anchored read/replace/insert/grep tools for pi-coding-agent. Every line gets a unique 4-char tokenizer-friendly anchor that stays stable across edits; stale or ambiguous anchors are rejected, never fuzzy-matched. Undo persists across restarts.",
6
6
  "main": "index.ts",
@@ -1 +1,2 @@
1
1
  - `copy`: the same anchor in `source_from` and `source_to` copies one line; copied lines get fresh anchors in the post-edit diff and the source rows keep theirs.
2
+ - `copy`: for a block copy, this tool is the cheap path: only the source's two boundary rows and the destination line need serving, the interior transfers verbatim, and the block lands in one commit. Rebuilding the file with shell commands costs range verification, the post-edit diff, and undo; to append at the end, use the destination's last served line as `insert_after`.
@@ -1 +1 @@
1
- - `insert`: after inserting a quoted payload, check the post-edit diff for an extra `+anchor│` blank row before the next line.
1
+ - `insert`: compare both edges of the inserted block in the post-edit diff against the request; a missing or extra `+anchor│` blank row at either boundary is the classic insert slip.
package/prompts/insert.md CHANGED
@@ -1,3 +1,3 @@
1
- Insert text after or before one existing line in a text file, addressed by a bare anchor from any served anchor│content row. The anchor line is preserved: `lines` go after it with `direction: "after"` or before it with `direction: "before"`. `lines` is one string holding the exact text to insert; escapes decode once — `\uXXXX` is the character, `\\uXXXX` the literal text.
1
+ Insert text after or before one existing line in a text file, addressed by a bare anchor from any served anchor│content row. The anchor line is preserved: `lines` go after it with `direction: "after"` or before it with `direction: "before"`. `lines` is one string holding the exact text to insert (an empty string inserts one blank line); JSON decoding happens once, before the tool; the tool writes the string it receives and never decodes — `\uXXXX` is the character, `\\uXXXX` the literal text. A trailing line break sets the last line's ending instead of adding a blank line, so a blank line next to the anchor line must be an extra break in `lines`: `"x\n"` leaves the anchor line directly after `x`, and `"x\n\n"` leaves one blank line between them (`direction: "after"` mirrors this at the start of `lines`).
2
2
 
3
- Same-file calls in one message batch: earlier calls reply `In batch N` and the last call shows the combined diff, with one undo for the whole batch.
3
+ Same-file calls in one message batch: earlier calls reply `In batch N (queued)` and the last call shows the combined diff, with one undo for the whole batch.
@@ -1 +1,2 @@
1
1
  - `move`: a cross-file move records one undo entry per file — undo both sides. Lines between source and target may be re-anchored.
2
+ - `move`: for a block move, this tool is the cheap path: only the source's two boundary rows and the destination line need serving, the interior moves verbatim, and rebuilding the file with shell commands costs range verification, the post-edit diff, and undo; to append at the end, use the destination's last served line as `insert_after`.
@@ -1,2 +1,4 @@
1
1
  - `replace`: same-file same-message calls batch: disjoint ranges, one undo. A call whose anchors resolve nowhere fails alone.
2
+ - `replace`: a pure deletion (`replacement_lines: ""`) is the cheap way to clear a large range: it verifies only the first and last line against the served record and removes the interior as it currently stands.
3
+ - `replace`: to delete several blocks, `anchor_grep` serves each block's first line and a short `read` around each block serves its closing line; then one message of pure-deletion `replace` calls (`remove_from`/`remove_to` per block, `replacement_lines: ""`) removes them all as one batch.
2
4
  - `replace`: `replacement_lines` is one string; a pasted `anchor│` prefix is stripped; single line: same anchor for `remove_from` and `remove_to`.
@@ -1,3 +1,3 @@
1
1
  - `replace_within`: use it instead of `replace` to change part of a line, so every character the request does not name is preserved as served.
2
2
  - `replace_within`: `replace_old` must be copied exactly from the served row and occur once in the range; a missing or repeated match is refused and returns the current rows.
3
- - `replace_within`: a batch never groups it; it commits on its own, like `copy` and `move`.
3
+ - `replace_within`: when the same `replace_old` occurs many times, `anchor_grep` with `literal: true` serves every matching row in one call, and one `replace_within` per row is cheap — only the named bytes change, so a wrong match costs one line, not a rewritten file.
@@ -1,5 +1,5 @@
1
1
  Replace part of a line (or a range of lines) without retyping the rest. `replace_from` and `replace_to` are bare anchors from served `anchor│content` rows marking the first and last line of the range; use the same anchor for one line. `replace_old` is the exact text to find inside that range, copied from the served row; it must occur exactly once. `replace_new` replaces just that match, and every other character stays untouched.
2
2
 
3
- Example: read served `Hasu│ {"id": "checkout-5", "feature": "legacyCheckout", "retries": 3},`. Call { "replace_from": "Hasu", "replace_to": "Hasu", "replace_old": "legacyCheckout", "replace_new": "stableCheckout" }. The line becomes ` {"id": "checkout-5", "feature": "stableCheckout", "retries": 3},` and the post-edit diff carries fresh anchors.
3
+ Example: read served `Hasu│ {"name": "widget", "size": "small"},`. Call { "replace_from": "Hasu", "replace_to": "Hasu", "replace_old": "small", "replace_new": "large" }. The line becomes ` {"name": "widget", "size": "large"},` and the post-edit diff carries fresh anchors.
4
4
 
5
- Both strings are exact text; escapes decode once — `\uXXXX` is the character, `\\uXXXX` the literal text. Matching uses LF line breaks and excludes the last line's terminator. A missing match is refused with the current rows, a repeated match with the matching line numbers, so the retry needs no read. Nothing but the matched text changes.
5
+ Both strings are exact text; JSON decoding happens once, before the tool; the tool writes the string it receives and never decodes — `\uXXXX` is the character, `\\uXXXX` the literal text. Matching uses LF line breaks and excludes the last line's terminator. A missing match is refused with the current rows, a repeated match with the matching line numbers, so the retry needs no read. Nothing but the matched text changes.
@@ -1,6 +1,6 @@
1
- Replace a range of lines (or a single line) in a text file by anchor. `remove_from` and `remove_to` are the 4-character anchors of the first and last line to remove, and `replacement_lines` is one string with the exact replacement text: `""` deletes the range, and escapes decode once — `\uXXXX` is the character, `\\uXXXX` the literal text. The text is written exactly as given, and nothing else in the file changes.
1
+ Replace a range of lines (or a single line) in a text file by anchor. `remove_from` and `remove_to` are the 4-character anchors of the first and last line to remove, and `replacement_lines` is one string with the exact replacement text: `""` deletes the range. JSON decoding happens once, before the tool; the tool writes the string it receives and never decodes — `\uXXXX` is the character, `\\uXXXX` the literal text. The text is written exactly as given, and nothing else in the file changes. A trailing line break sets the last line's ending instead of adding a blank line: `"x\n"` leaves the next line directly after `x`, and `"x\n\n"` leaves one blank line between them.
2
2
  To change only part of a line without retyping the rest, use `replace_within` instead; it preserves every character the request does not name.
3
3
 
4
- Same-file calls in one message batch: earlier calls reply `In batch N` and the last call shows the combined diff, with one undo for the whole batch.
4
+ Same-file calls in one message batch: earlier calls reply `In batch N (queued)` and the last call shows the combined diff, with one undo for the whole batch.
5
5
 
6
6
  Example: read served `Hasu│old` and `arvm│old2`. Call { "remove_from": "Hasu", "remove_to": "arvm", "replacement_lines": "new line 1\nnew line 2" }. The post-edit diff shows `-Hasu│old`, `-arvm│old2`, `+Qwer│new line 1`: the `-` rows are dead anchors now; the `+` and ` ` rows are live anchors for the next edit.
package/src/batch.ts CHANGED
@@ -12,6 +12,7 @@ import {
12
12
  changedRange,
13
13
  lineHashes,
14
14
  planEdit,
15
+ preserveDeletionSeparators,
15
16
  MAX_HASH_LINES,
16
17
  type HEdit,
17
18
  type PlannedEdit,
@@ -121,6 +122,7 @@ type NormalizedEditArgs =
121
122
  | { kind: "insert"; anchor: string; path?: string };
122
123
 
123
124
  const MAX_TRACKED_BATCHES = 256;
125
+ const BATCH_DISCARDED_NOTE = "Nothing was written; the whole batch was discarded.";
124
126
 
125
127
  const plan = new Map<string, PlannedMember>();
126
128
  const batches = new Map<number, BatchState>();
@@ -346,7 +348,7 @@ function batchPlaceholder(member: PlannedMember, piece: BatchPiece, snapshotId:
346
348
  content: [
347
349
  {
348
350
  type: "text",
349
- text: `In batch ${member.display}`,
351
+ text: `In batch ${member.display} (queued)`,
350
352
  },
351
353
  ],
352
354
  details: {
@@ -364,7 +366,8 @@ function batchPlaceholder(member: PlannedMember, piece: BatchPiece, snapshotId:
364
366
  export function withAbortSuffix(message: string, display: number): string {
365
367
  const suffix = `Aborts batch ${display}.`;
366
368
  if (message.includes(suffix)) return message;
367
- return message.endsWith(".") ? `${message} ${suffix}` : `${message}. ${suffix}`;
369
+ const ended = message.endsWith(".") ? `${message} ${suffix}` : `${message}. ${suffix}`;
370
+ return `${ended} ${BATCH_DISCARDED_NOTE}`;
368
371
  }
369
372
 
370
373
  const ERROR_CODE_RE = /\[(E_[A-Z0-9_]+)\]/;
@@ -393,7 +396,9 @@ function firstFailureCause(runtime: BatchState): string | undefined {
393
396
  const error = runtime.firstError;
394
397
  if (!(error instanceof Error)) return undefined;
395
398
  const suffix = ` Aborts batch ${runtime.display}.`;
396
- const message = error.message.endsWith(suffix) ? error.message.slice(0, -suffix.length) : error.message;
399
+ const discarded = ` ${BATCH_DISCARDED_NOTE}`;
400
+ const withoutDiscarded = error.message.endsWith(discarded) ? error.message.slice(0, -discarded.length) : error.message;
401
+ const message = withoutDiscarded.endsWith(suffix) ? withoutDiscarded.slice(0, -suffix.length) : withoutDiscarded;
397
402
  const firstLine = message.split("\n")[0]?.trim() ?? "";
398
403
  if (firstLine.length === 0) return undefined;
399
404
  if (!firstLine.endsWith(":")) return firstLine;
@@ -404,10 +409,12 @@ function firstFailureCause(runtime: BatchState): string | undefined {
404
409
  function abortedBatchMessage(runtime: BatchState): string {
405
410
  const failure = runtime.failure;
406
411
  if (failure?.code !== undefined) {
407
- return `[E_OP_ABORTED] Batch ${runtime.display} aborted: [${failure.kind}] Call Nr ${failure.order} errored [${failure.code}]`;
412
+ return `[E_OP_ABORTED] Batch ${runtime.display} aborted: [${failure.kind}] Call Nr ${failure.order} errored [${failure.code}]. ${BATCH_DISCARDED_NOTE}`;
408
413
  }
409
414
  const cause = firstFailureCause(runtime);
410
- return cause ? `[E_OP_ABORTED] Batch ${runtime.display} aborted: ${cause}` : `[E_OP_ABORTED] Batch ${runtime.display} aborted.`;
415
+ if (cause === undefined) return `[E_OP_ABORTED] Batch ${runtime.display} aborted. ${BATCH_DISCARDED_NOTE}`;
416
+ const ended = cause.endsWith(".") || cause.endsWith("!") || cause.endsWith("?") ? cause : `${cause}.`;
417
+ return `[E_OP_ABORTED] Batch ${runtime.display} aborted: ${ended} ${BATCH_DISCARDED_NOTE}`;
411
418
  }
412
419
 
413
420
  function batchAbortedError(runtime: BatchState): Error {
@@ -482,9 +489,10 @@ export async function executeBatchMember(input: BatchMemberInput): Promise<TResu
482
489
  throw error;
483
490
  }
484
491
  const displayPath = runtime.paths?.displayPath ?? input.targetPath;
492
+ const effectiveHedit = preserveDeletionSeparators(input.hedit, base.baseLines, base.hashes);
485
493
  let planned: PlannedEdit;
486
494
  try {
487
- planned = planEdit(base.content, input.hedit, base.hashes, {
495
+ planned = planEdit(base.content, effectiveHedit, base.hashes, {
488
496
  filePath: displayPath,
489
497
  servedHashes: runtime.served,
490
498
  signal: input.signal,
@@ -519,8 +527,8 @@ export async function executeBatchMember(input: BatchMemberInput): Promise<TResu
519
527
  ...(carryIndex !== undefined ? { carryIndex } : {}),
520
528
  start,
521
529
  end,
522
- fromHash: input.hedit.hash_bounds[0].hash,
523
- toHash: input.hedit.hash_bounds[1].hash,
530
+ fromHash: planned.resolved.hash_bounds[0].hash,
531
+ toHash: planned.resolved.hash_bounds[1].hash,
524
532
  newLines: [...newLines],
525
533
  ...(separators !== undefined ? { separators } : {}),
526
534
  warnings: [...input.extraWarnings, ...planned.warnings],
package/src/constants.ts CHANGED
@@ -17,10 +17,10 @@ export const NEW_CONTENT_NOT_STRING_MSG =
17
17
  `[E_BAD_SHAPE] "replacement_lines" must be a string holding the exact text to write. Use "" to delete the range and "\\n" for one blank line; line breaks inside the string separate lines.`;
18
18
 
19
19
  export const LINES_NOT_STRING_MSG =
20
- `[E_BAD_SHAPE] "lines" must be a string holding the exact text to insert. Use "" to insert nothing and "\\n" for one blank line; line breaks inside the string separate lines.`;
20
+ `[E_BAD_SHAPE] "lines" must be a string holding the exact text to insert. Use "\\n" for one blank line; line breaks inside the string separate lines.`;
21
21
 
22
22
  export const NUL_CONTENT_MSG =
23
- `[E_BAD_SHAPE] Content contains a NUL byte (U+0000); a text file cannot contain NUL, and writing it would break further reads and edits. Remove the NUL byte and retry. An empty replacement ([]) deletes a range or inserts nothing.`;
23
+ `[E_BAD_SHAPE] Content contains a NUL byte (U+0000); a text file cannot contain NUL, and writing it would break further reads and edits. Remove the NUL byte and retry. An empty replacement ([]) deletes a range.`;
24
24
 
25
25
  export const ANCHOR_POOL_EXHAUSTED_PREFIX =
26
26
  "[E_FILE_TOO_LARGE] The session's anchor pool is exhausted";
package/src/copy-move.ts CHANGED
@@ -158,7 +158,8 @@ export function buildTransferEdit(input: {
158
158
  );
159
159
  }
160
160
  assertRangeVerified(fileLines, preload.fileHashes, insertLine, insertLine, served, displayPath);
161
- assertRangeVerified(fileLines, preload.fileHashes, sourceStart, sourceEnd, served, displayPath);
161
+ assertRangeVerified(fileLines, preload.fileHashes, sourceStart, sourceStart, served, displayPath);
162
+ assertRangeVerified(fileLines, preload.fileHashes, sourceEnd, sourceEnd, served, displayPath);
162
163
  const sourceLines = fileLines.slice(sourceStart - 1, sourceEnd);
163
164
  const sourceEndings = endingsForRange(preload.endingSeparators, sourceStart, sourceEnd);
164
165
  if (kind === "copy") {
@@ -272,7 +273,8 @@ async function prepareCrossTransfer(input: {
272
273
  throw error;
273
274
  }
274
275
  try {
275
- assertRangeVerified(sourceLines, sourcePreload.fileHashes, sourceStart, sourceEnd, sourceServed, sourceDisplay);
276
+ assertRangeVerified(sourceLines, sourcePreload.fileHashes, sourceStart, sourceStart, sourceServed, sourceDisplay);
277
+ assertRangeVerified(sourceLines, sourcePreload.fileHashes, sourceEnd, sourceEnd, sourceServed, sourceDisplay);
276
278
  } catch (error) {
277
279
  await adopt(sourcePreload.absolutePath, error);
278
280
  throw error;
@@ -567,6 +569,7 @@ async function executeCrossFile(
567
569
  signal,
568
570
  preloadedNorm: prepared.sourcePreload,
569
571
  allowEmpty: true,
572
+ preserveDeletionSeparators: false,
570
573
  });
571
574
  return commitMovePair({
572
575
  source: { pipe: sourcePipe, displayPath: prepared.sourceDisplay, absolutePath: source.absolute, mutationTargetPath: source.resolved, foldedAnchorLines: 0 },
@@ -631,6 +634,7 @@ export async function transferPreview(kind: TransferKind, request: unknown, cwd:
631
634
  noPersist: true,
632
635
  preloadedNorm: prepared.sourcePreload,
633
636
  allowEmpty: true,
637
+ preserveDeletionSeparators: false,
634
638
  signal,
635
639
  });
636
640
  const destinationPreview = previewFromPipe(destinationPipe);
@@ -38,10 +38,15 @@ export async function currentEditFlags(): Promise<EditToolFlags> {
38
38
  };
39
39
  }
40
40
 
41
+ function preferenceGuideline(flags: EditToolFlags): string {
42
+ const tools = gatedEditOps(["read", "replace", "replace_within", "insert", "copy", "move", "undo_last_change"], flags);
43
+ return `Prefer the hashline tools for anything that touches files: ${joinOps(tools, { backtick: true })}.`;
44
+ }
45
+
41
46
  export function withReplacePrompts(base: { description: string; snippet: string; guidelines: string[] }, flags: EditToolFlags): { description: string; snippet: string; guidelines: string[] } {
42
47
  let description = base.description;
43
48
  const snippetParts = [base.snippet];
44
- let guidelines = [...base.guidelines];
49
+ let guidelines = [preferenceGuideline(flags), ...base.guidelines];
45
50
  if (!flags.autoRead) {
46
51
  description = description.replace(/\n\nExample:[\s\S]*$/, "");
47
52
  guidelines = guidelines.filter((guideline) => !guideline.includes("post-edit diff"));
@@ -64,14 +69,15 @@ export function withReplacePrompts(base: { description: string; snippet: string;
64
69
  }
65
70
 
66
71
  export function withReadPrompts(base: { description: string; snippet: string; guidelines: string[] }, flags: EditToolFlags): { description: string; snippet: string; guidelines: string[] } {
72
+ const preference = preferenceGuideline(flags);
67
73
  if (flags.autoReadAllActive) {
68
74
  const rewritten = base.guidelines
69
75
  .filter((guideline) => !guideline.includes("call again after an edit"))
70
- return { description: base.description, snippet: base.snippet, guidelines: [...rewritten] };
76
+ return { description: base.description, snippet: base.snippet, guidelines: [preference, ...rewritten] };
71
77
  }
72
78
  const withoutAutoReadAll = base.guidelines.filter((guideline) => !guideline.includes("E_AUTO_READ_ALL"))
73
- if (flags.autoRead) return { description: base.description, snippet: base.snippet, guidelines: [...withoutAutoReadAll] };
74
- const guidelines = [...withoutAutoReadAll];
79
+ if (flags.autoRead) return { description: base.description, snippet: base.snippet, guidelines: [preference, ...withoutAutoReadAll] };
80
+ const guidelines = [preference, ...withoutAutoReadAll];
75
81
  const mapped = guidelines.map((guideline) => guideline.startsWith("`read`: call again after an edit") ? "`read`: call again after an edit when you need anchors you lack." : guideline);
76
82
  return { description: base.description, snippet: base.snippet, guidelines: mapped };
77
83
  }
@@ -96,7 +102,7 @@ export function withInsertPrompts(base: { description: string; snippet: string;
96
102
  export function withReplaceWithinPrompts(base: { description: string; snippet: string; guidelines: string[] }, flags: EditToolFlags): { description: string; snippet: string; guidelines: string[] } {
97
103
  const descriptionParts = [base.description];
98
104
  const snippetParts = [base.snippet];
99
- const guidelines = base.guidelines.map((guideline) => flags.copyMoveEnabled ? guideline : guideline.replace(", like `copy` and `move`", ""));
105
+ const guidelines = [...base.guidelines];
100
106
  if (flags.requirePath) {
101
107
  descriptionParts.push("Also give `path` matching the file the anchors were served for; it is required and must match anchor ownership.");
102
108
  snippetParts.push("; include `path` (required)");
@@ -1,24 +1,43 @@
1
1
  import { splitLines } from "./utils";
2
+ import { HASH_SEP, canon } from "./hashline";
2
3
  import type { DiffSpan } from "./replace-diff";
3
4
 
4
5
  const INVISIBLE_RE = /\p{Default_Ignorable_Code_Point}/u;
5
6
  const LOOKALIKE_SPACE_RE = /[\u00a0\u1680\u2000-\u200a\u202f\u205f\u3000]/u;
6
7
  const LOOKALIKE_DASH_RE = /[\u2010-\u2015\u2212]/u;
7
8
  const LOOKALIKE_QUOTE_RE = /[\u2018\u2019\u201c\u201d]/u;
8
- const MAX_LISTED_CHARS = 3;
9
-
10
- const CHAR_NAMES: Readonly<Record<number, string>> = {
11
- 0x00a0: "no-break space",
12
- 0x200b: "zero-width space",
13
- 0x200c: "zero-width non-joiner",
14
- 0x200d: "zero-width joiner",
15
- 0x200e: "left-to-right mark",
16
- 0x200f: "right-to-left mark",
17
- 0x2011: "non-breaking hyphen",
18
- 0x202f: "narrow no-break space",
19
- 0x2060: "word joiner",
20
- 0xfeff: "zero-width no-break space",
9
+ const FULLWIDTH_PUNCT_RANGES: ReadonlyArray<readonly [number, number]> = [
10
+ [0xff01, 0xff0f],
11
+ [0xff1a, 0xff20],
12
+ [0xff3b, 0xff40],
13
+ [0xff5b, 0xff5e],
14
+ ];
15
+ const IDEOGRAPHIC_PUNCT_SUBSTITUTES: Readonly<Record<string, string>> = {
16
+ "\u3001": ",",
17
+ "\u3002": ".",
18
+ "\uff61": ".",
19
+ "\uff64": ",",
21
20
  };
21
+ const FULLWIDTH_PUNCT_SUBSTITUTES: Readonly<Record<string, string>> = Object.fromEntries(
22
+ FULLWIDTH_PUNCT_RANGES.flatMap(([start, end]): Array<[string, string]> =>
23
+ Array.from({ length: end - start + 1 }, (_, offset): [string, string] => {
24
+ const code = start + offset;
25
+ return [String.fromCodePoint(code), String.fromCodePoint(code - 0xfee0)];
26
+ }),
27
+ ),
28
+ );
29
+ const LOOKALIKE_PUNCT_RE = new RegExp(
30
+ `[${FULLWIDTH_PUNCT_RANGES.map(([start, end]) => `${String.fromCodePoint(start)}-${String.fromCodePoint(end)}`).join("")}${Object.keys(IDEOGRAPHIC_PUNCT_SUBSTITUTES).join("")}]`,
31
+ "u",
32
+ );
33
+ const MAX_FIDELITY_HINTS = 3;
34
+ const INSERT_REFERENCE_WINDOW = 12;
35
+ const INDENT_REFERENCE_WINDOW = 1;
36
+ const SIMILARITY_RUN = 10;
37
+ const ECHO_TEXT_RE = /[\p{L}\p{N}]/u;
38
+ const INDENT_SIMILARITY_RUN = 14;
39
+ const CALL_HEAD_RE = /^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*\s*\(/;
40
+ const INSERT_GRAM_BUDGET = 200_000;
22
41
 
23
42
  const LOOKALIKE_SUBSTITUTES: Readonly<Record<string, string>> = {
24
43
  "\u00a0": " ",
@@ -48,23 +67,33 @@ const LOOKALIKE_SUBSTITUTES: Readonly<Record<string, string>> = {
48
67
  "\u2019": "'",
49
68
  "\u201c": "\"",
50
69
  "\u201d": "\"",
70
+ ...FULLWIDTH_PUNCT_SUBSTITUTES,
71
+ ...IDEOGRAPHIC_PUNCT_SUBSTITUTES,
51
72
  };
52
73
 
74
+ const REPLACEMENT_CHAR = String.fromCodePoint(0xfffd);
53
75
  export function isFidelitySensitiveChar(char: string): boolean {
54
- return INVISIBLE_RE.test(char) || LOOKALIKE_SPACE_RE.test(char) || LOOKALIKE_DASH_RE.test(char) || LOOKALIKE_QUOTE_RE.test(char);
76
+ return char === REPLACEMENT_CHAR || INVISIBLE_RE.test(char) || LOOKALIKE_SPACE_RE.test(char) || LOOKALIKE_DASH_RE.test(char) || LOOKALIKE_QUOTE_RE.test(char) || LOOKALIKE_PUNCT_RE.test(char);
55
77
  }
56
78
 
57
- function describeChar(char: string): string {
58
- const codePoint = char.codePointAt(0)!;
59
- const hex = `U+${codePoint.toString(16).toUpperCase().padStart(4, "0")}`;
60
- const name = CHAR_NAMES[codePoint];
61
- return name === undefined ? hex : `${hex} (${name})`;
79
+ function formatCodePoint(char: string): string {
80
+ return `U+${char.codePointAt(0)!.toString(16).toUpperCase().padStart(4, "0")}`;
62
81
  }
63
82
 
64
- function unicodeLostHint(chars: string[]): string {
65
- const listed = chars.slice(0, MAX_LISTED_CHARS).map(describeChar).join(", ");
66
- const more = chars.length > MAX_LISTED_CHARS ? ` (+${chars.length - MAX_LISTED_CHARS} more)` : "";
67
- return `[H_UNICODE_LOST] The removed line contained ${listed}${more}, which the replacement does not. If the request did not ask to remove it, copy the character from the served row.`;
83
+ function plural(count: number, noun: string): string {
84
+ return `${count} ${noun}${count === 1 ? "" : "s"}`;
85
+ }
86
+
87
+ interface ReferenceRow {
88
+ line: string;
89
+ index: number;
90
+ }
91
+
92
+ function hiddenCharHint(char: string, reference: ReferenceRow, anchor: string | undefined): string {
93
+ const column = [...reference.line].indexOf(char) + 1;
94
+ const codePoint = formatCodePoint(char);
95
+ const referenceNote = anchor === undefined ? "" : `; ${anchor}${HASH_SEP} has it`;
96
+ return `[H_UNICODE_LOST] ${codePoint} missing at col ${column}${referenceNote}; resend with ${codePoint} if unintended.`;
68
97
  }
69
98
 
70
99
  function withCharsRestored(line: string, chars: readonly string[], replacementFor: (char: string) => string): string {
@@ -79,30 +108,410 @@ function isDeliberateCharFix(oldLine: string, newLine: string, lostChars: readon
79
108
  return withCharsRestored(oldLine, lostChars, () => "") === newLine;
80
109
  }
81
110
 
82
- export function fidelityHints(originalContent: string, resultContent: string, spans: readonly DiffSpan[] | undefined): string[] {
111
+ interface IndexedLine {
112
+ line: string;
113
+ index: number;
114
+ }
115
+
116
+ interface SwappedChar {
117
+ oldChar: string;
118
+ newChar: string;
119
+ column: number;
120
+ }
121
+
122
+ interface SwapCandidate {
123
+ reference: ReferenceRow;
124
+ swapped: SwappedChar;
125
+ }
126
+
127
+ function containsSensitiveChar(line: string): boolean {
128
+ return line.includes(REPLACEMENT_CHAR) || INVISIBLE_RE.test(line) || LOOKALIKE_SPACE_RE.test(line) || LOOKALIKE_DASH_RE.test(line) || LOOKALIKE_QUOTE_RE.test(line) || LOOKALIKE_PUNCT_RE.test(line);
129
+ }
130
+
131
+ function normalizeSensitiveChars(line: string): string {
132
+ let normalized = "";
133
+ for (const char of line) normalized += isFidelitySensitiveChar(char) ? "?" : char;
134
+ return normalized;
135
+ }
136
+
137
+ function isLookalikeSwap(oldChar: string, newChar: string): boolean {
138
+ if (isFidelitySensitiveChar(oldChar) && isFidelitySensitiveChar(newChar)) return true;
139
+ return LOOKALIKE_SUBSTITUTES[newChar] === oldChar;
140
+ }
141
+ function sharesFidelityClass(left: string, right: string): boolean {
142
+ if (left === REPLACEMENT_CHAR || right === REPLACEMENT_CHAR) return isFidelitySensitiveChar(left) || isFidelitySensitiveChar(right);
143
+ return (
144
+ (INVISIBLE_RE.test(left) && INVISIBLE_RE.test(right)) ||
145
+ (LOOKALIKE_SPACE_RE.test(left) && LOOKALIKE_SPACE_RE.test(right)) ||
146
+ (LOOKALIKE_DASH_RE.test(left) && LOOKALIKE_DASH_RE.test(right)) ||
147
+ (LOOKALIKE_QUOTE_RE.test(left) && LOOKALIKE_QUOTE_RE.test(right)) ||
148
+ (LOOKALIKE_PUNCT_RE.test(left) && LOOKALIKE_PUNCT_RE.test(right)) ||
149
+ LOOKALIKE_SUBSTITUTES[left] === right
150
+ );
151
+ }
152
+
153
+ function findSubstitute(char: string, reference: ReferenceRow, payload: readonly string[]): string | undefined {
154
+ const referenceChars = [...reference.line];
155
+ const column = referenceChars.indexOf(char);
156
+ for (const line of payload) {
157
+ const candidateChars = [...line];
158
+ if (candidateChars.length !== referenceChars.length) continue;
159
+ const candidate = candidateChars[column];
160
+ if (candidate !== undefined && candidate !== char && sharesFidelityClass(char, candidate)) return candidate;
161
+ }
162
+ for (const line of payload) {
163
+ for (const candidate of line) {
164
+ if (candidate !== char && sharesFidelityClass(char, candidate)) return candidate;
165
+ }
166
+ }
167
+ return undefined;
168
+ }
169
+
170
+ function swappedCharPair(oldLine: string, newLine: string): SwappedChar | undefined {
171
+ const oldChars = [...oldLine];
172
+ const newChars = [...newLine];
173
+ if (oldChars.length !== newChars.length) return undefined;
174
+ let swapped: SwappedChar | undefined;
175
+ for (let index = 0; index < oldChars.length; index += 1) {
176
+ const oldChar = oldChars[index]!;
177
+ const newChar = newChars[index]!;
178
+ if (oldChar === newChar) continue;
179
+ if (!isLookalikeSwap(oldChar, newChar)) return undefined;
180
+ swapped ??= { oldChar, newChar, column: index + 1 };
181
+ }
182
+ return swapped;
183
+ }
184
+
185
+ function swappedCandidate(line: string, matches: IndexedLine[], spanStart: number, spanEnd: number): SwapCandidate | undefined {
186
+ const candidates: SwapCandidate[] = [];
187
+ for (const match of matches) {
188
+ if (match.line === line) continue;
189
+ const swapped = swappedCharPair(match.line, line);
190
+ if (swapped === undefined) continue;
191
+ candidates.push({ reference: { line: match.line, index: match.index }, swapped });
192
+ }
193
+ return candidates.find((candidate) => candidate.reference.index >= spanStart && candidate.reference.index <= spanEnd) ?? candidates[0];
194
+ }
195
+
196
+ function swappedCharHint(swapped: SwappedChar, anchor: string | undefined): string {
197
+ const label = anchor === undefined ? "the replaced line" : `${anchor}${HASH_SEP}`;
198
+ const expected = formatCodePoint(swapped.oldChar);
199
+ return `[H_UNICODE_SWAPPED] ${formatCodePoint(swapped.newChar)} at col ${swapped.column} where ${label} has ${expected}; resend with ${expected} if unintended.`;
200
+ }
201
+
202
+ function trailingWhitespaceHint(oldLine: string, newLine: string, anchor: string | undefined): string {
203
+ const oldTrail = oldLine.length - oldLine.trimEnd().length;
204
+ const newTrail = newLine.length - newLine.trimEnd().length;
205
+ const column = newLine.trimEnd().length + 1;
206
+ const label = anchor === undefined ? "the replaced line" : `${anchor}${HASH_SEP}`;
207
+ return `[H_TRAILING_WHITESPACE] ${plural(newTrail, "trailing whitespace character")} at col ${column}; ${label} had ${oldTrail}.`;
208
+ }
209
+
210
+ function leadingWhitespace(line: string): string {
211
+ const trimmed = line.trimStart();
212
+ return line.slice(0, line.length - trimmed.length);
213
+ }
214
+
215
+ function sameCallHead(payloadLine: string, referenceLine: string): boolean {
216
+ const left = CALL_HEAD_RE.exec(payloadLine.trimStart())?.[0];
217
+ const right = CALL_HEAD_RE.exec(referenceLine.trimStart())?.[0];
218
+ return left !== undefined && left === right;
219
+ }
220
+
221
+ function indentMismatchHint(payloadLine: string, reference: ReferenceRow, anchor: string | undefined): string | undefined {
222
+ const payloadIndent = leadingWhitespace(payloadLine);
223
+ const referenceIndent = leadingWhitespace(reference.line);
224
+ if (payloadIndent.length >= referenceIndent.length || !referenceIndent.startsWith(payloadIndent)) return undefined;
225
+ const grams = gramSet([payloadLine], INDENT_SIMILARITY_RUN);
226
+ const sharesLongRun = grams !== undefined && sharesRun(reference.line, grams, INDENT_SIMILARITY_RUN);
227
+ if (!sharesLongRun && !sameCallHead(payloadLine, reference.line)) return undefined;
228
+ const label = anchor === undefined ? "the nearby line" : `${anchor}${HASH_SEP}`;
229
+ return `[H_INDENT_MISMATCH] new line has ${plural(payloadIndent.length, "leading whitespace character")}; ${label} has ${referenceIndent.length}.`;
230
+ }
231
+
232
+ function referenceRows(lines: string[], start: number, end: number): ReferenceRow[] {
233
+ const from = Math.max(0, start - INSERT_REFERENCE_WINDOW);
234
+ const to = Math.min(lines.length - 1, end + INSERT_REFERENCE_WINDOW);
235
+ const rows: ReferenceRow[] = [];
236
+ for (let index = from; index <= to; index++) rows.push({ line: lines[index]!, index });
237
+ return rows;
238
+ }
239
+
240
+ function isBlankLine(line: string | undefined): boolean {
241
+ return (line ?? "").trim().length === 0;
242
+ }
243
+
244
+ function separatorMovedHint(
245
+ oldLines: string[],
246
+ anchorIndex: number,
247
+ carried: number | undefined,
248
+ payload: string[],
249
+ anchor: string | undefined,
250
+ ): string | undefined {
251
+ if (carried === undefined || payload.length < 2) return undefined;
252
+ const anchorLine = oldLines[anchorIndex] ?? "";
253
+ if (anchorLine.trim().length === 0) return undefined;
254
+ let side: "before" | "after";
255
+ if (carried === 0) {
256
+ if (anchorIndex + 1 >= oldLines.length || !isBlankLine(oldLines[anchorIndex + 1])) return undefined;
257
+ if ((payload[0] ?? "").trim().length === 0) return undefined;
258
+ side = "after";
259
+ } else if (carried === payload.length) {
260
+ if (anchorIndex === 0 || !isBlankLine(oldLines[anchorIndex - 1])) return undefined;
261
+ if ((payload[payload.length - 1] ?? "").trim().length === 0) return undefined;
262
+ side = "before";
263
+ } else {
264
+ return undefined;
265
+ }
266
+ const label = anchor === undefined ? "the anchor line" : `${anchor}${HASH_SEP}`;
267
+ return `[H_SEPARATOR_MOVED] blank separator ${side === "before" ? "above" : "below"} ${label} was displaced; add a blank line ${side} ${label} if unintended.`;
268
+ }
269
+
270
+ function gramSet(lines: readonly string[], size: number, requireText = false): Set<string> | undefined {
271
+ const grams = new Set<string>();
272
+ let total = 0;
273
+ for (const line of lines) {
274
+ total += line.length;
275
+ if (total > INSERT_GRAM_BUDGET) return undefined;
276
+ const chars = [...line];
277
+ for (let index = 0; index + size <= chars.length; index += 1) {
278
+ const gram = chars.slice(index, index + size).join("");
279
+ if (requireText && !ECHO_TEXT_RE.test(gram)) continue;
280
+ grams.add(gram);
281
+ }
282
+ }
283
+ return grams;
284
+ }
285
+
286
+ function sharesRun(line: string, grams: Set<string>, size: number): boolean {
287
+ const chars = [...line];
288
+ for (let index = 0; index + size <= chars.length; index += 1) {
289
+ if (grams.has(chars.slice(index, index + size).join(""))) return true;
290
+ }
291
+ return false;
292
+ }
293
+
294
+ const LITERAL_ESCAPE_HINT_PREFIX = "[H_LITERAL_ESCAPE] ";
295
+ const LITERAL_ESCAPE_TEXT_RE = /^\[H_LITERAL_ESCAPE\] [^:]*: "(.*)" written as literal text$/;
296
+ const MAX_LITERAL_ESCAPE_ROWS = 3;
297
+
298
+ function literalEscapeColumn(line: string, escape: string): number {
299
+ const index = line.indexOf(escape);
300
+ return index < 0 ? 1 : [...line.slice(0, index)].length + 1;
301
+ }
302
+
303
+ function literalEscapeHintRows(
304
+ resultContent: string,
305
+ spans: readonly DiffSpan[],
306
+ resultHashes: readonly string[],
307
+ escape: string,
308
+ ): ReferenceRow[] {
309
+ const newLines = splitLines(resultContent);
310
+ const rows: ReferenceRow[] = [];
311
+ let offset = 0;
312
+ for (const span of [...spans].sort((a, b) => a.start - b.start)) {
313
+ const removedCount = span.end >= span.start ? span.end - span.start + 1 : 0;
314
+ const inserted = newLines.slice(span.start + offset, span.start + offset + span.replacementCount);
315
+ for (let index = 0; index < inserted.length; index += 1) {
316
+ if (span.carry === index) continue;
317
+ const line = inserted[index]!;
318
+ if (!line.includes(escape)) continue;
319
+ const resultIndex = span.start + offset + index;
320
+ if (resultIndex >= 0 && resultIndex < resultHashes.length) rows.push({ line, index: resultIndex });
321
+ }
322
+ offset += span.replacementCount - removedCount;
323
+ }
324
+ return rows;
325
+ }
326
+
327
+ function actualEscapeName(escape: string): string {
328
+ const hex = /^\\u([0-9a-fA-F]{4})$/.exec(escape)?.[1];
329
+ if (hex !== undefined) return `U+${hex.toUpperCase()}`;
330
+ if (escape.length === 2) {
331
+ if (escape[1] === "n") return "a line break";
332
+ if (escape[1] === "r") return "a carriage return";
333
+ if (escape[1] === "t") return "a tab";
334
+ if (escape[1] === '"') return "a double quote";
335
+ }
336
+ return "the character";
337
+ }
338
+
339
+ export function annotateLiteralEscapeHints(
340
+ hints: string[],
341
+ resultContent: string,
342
+ spans: readonly DiffSpan[] | undefined,
343
+ resultHashes: readonly string[],
344
+ ): string[] {
345
+ if (spans === undefined || spans.length === 0) return hints;
346
+ return hints.map((hint) => {
347
+ if (!hint.startsWith(LITERAL_ESCAPE_HINT_PREFIX)) return hint;
348
+ const escape = LITERAL_ESCAPE_TEXT_RE.exec(hint)?.[1];
349
+ if (escape === undefined || escape.length === 0) return hint;
350
+ const rows = literalEscapeHintRows(resultContent, spans, resultHashes, escape);
351
+ if (rows.length === 0) return hint;
352
+ if (rows.length > MAX_LITERAL_ESCAPE_ROWS) {
353
+ return `${hint} on ${plural(rows.length, "row")}; undo_last_change + resend with ${actualEscapeName(escape)} if unintended.`;
354
+ }
355
+ const locations = rows.map((row) => `${resultHashes[row.index]!}${HASH_SEP} col ${literalEscapeColumn(row.line, escape)}`).join(", ");
356
+ return `${hint} (${locations}); resend with ${actualEscapeName(escape)} if unintended.`;
357
+ });
358
+ }
359
+
360
+ function separatorLostHint(oldLines: string[], span: DiffSpan, originalHashes?: readonly string[]): string | undefined {
361
+ if (span.replacementCount !== 0 || span.end < span.start) return undefined;
362
+ const beforeIndex = span.start - 1;
363
+ const afterIndex = span.end + 1;
364
+ if (beforeIndex < 0 || afterIndex >= oldLines.length) return undefined;
365
+ for (let index = span.start; index <= span.end; index += 1) {
366
+ if (!isBlankLine(oldLines[index])) return undefined;
367
+ }
368
+ if (isBlankLine(oldLines[beforeIndex]) || isBlankLine(oldLines[afterIndex])) return undefined;
369
+ const before = originalHashes?.[beforeIndex];
370
+ const after = originalHashes?.[afterIndex];
371
+ if (before === undefined || after === undefined) return undefined;
372
+ return `[H_SEPARATOR_LOST] deletion removed ${plural(span.end - span.start + 1, "blank line")} between ${before}${HASH_SEP} and ${after}${HASH_SEP}.`;
373
+ }
374
+
375
+ export function fidelityHints(
376
+ originalContent: string,
377
+ resultContent: string,
378
+ spans: readonly DiffSpan[] | undefined,
379
+ originalHashes?: readonly string[],
380
+ options?: { separatorMoved?: boolean; indentHints?: boolean },
381
+ ): string[] {
83
382
  if (spans === undefined || spans.length === 0) return [];
84
383
  const oldLines = splitLines(originalContent);
85
384
  const newLines = splitLines(resultContent);
86
- const lost = new Set<string>();
385
+ const hints: string[] = [];
386
+ const seen = new Set<string>();
387
+ let sensitiveIndex: Map<string, IndexedLine[]> | undefined;
388
+ const sensitiveLines = (): Map<string, IndexedLine[]> => {
389
+ if (sensitiveIndex === undefined) {
390
+ sensitiveIndex = new Map();
391
+ for (let index = 0; index < oldLines.length; index += 1) {
392
+ const line = oldLines[index]!;
393
+ if (!containsSensitiveChar(line)) continue;
394
+ const key = normalizeSensitiveChars(line);
395
+ const list = sensitiveIndex.get(key) ?? [];
396
+ list.push({ line, index });
397
+ sensitiveIndex.set(key, list);
398
+ }
399
+ }
400
+ return sensitiveIndex;
401
+ };
87
402
  let offset = 0;
88
403
  for (const span of [...spans].sort((a, b) => a.start - b.start)) {
89
404
  const removedCount = span.end >= span.start ? span.end - span.start + 1 : 0;
90
405
  const removed = removedCount > 0 ? oldLines.slice(span.start, span.end + 1) : [];
91
406
  const inserted = newLines.slice(span.start + offset, span.start + offset + span.replacementCount);
92
407
  if (removed.length > 0 && inserted.length > 0) {
93
- const insertedText = inserted.join("\n");
94
- const dropped: string[] = [];
95
- for (const line of removed) {
96
- for (const char of line) {
97
- if (!dropped.includes(char) && isFidelitySensitiveChar(char) && !insertedText.includes(char)) dropped.push(char);
408
+ const carried = span.carry;
409
+ const payload = carried === undefined ? inserted : inserted.filter((_, index) => index !== carried);
410
+ const payloadText = payload.join("\n");
411
+ const references: ReferenceRow[] = carried === undefined
412
+ ? removed.map((line, index) => ({ line, index: span.start + index }))
413
+ : referenceRows(oldLines, span.start, span.end);
414
+ const insertGate = carried === undefined ? null : (gramSet(payload, SIMILARITY_RUN, true) ?? new Set<string>());
415
+ const swapKeys = new Set<string>();
416
+ const swaps: SwapCandidate[] = [];
417
+ let candidates: Map<string, IndexedLine[]> | undefined;
418
+ for (const line of payload) {
419
+ if (!containsSensitiveChar(line)) continue;
420
+ candidates ??= sensitiveLines();
421
+ const matches = [...(candidates.get(normalizeSensitiveChars(line)) ?? []), ...references];
422
+ const candidate = swappedCandidate(line, matches, span.start, span.end);
423
+ if (candidate === undefined) continue;
424
+ const key = `${candidate.reference.index}:${candidate.swapped.oldChar}`;
425
+ if (swapKeys.has(key)) continue;
426
+ swapKeys.add(key);
427
+ swaps.push(candidate);
428
+ }
429
+ const missing = new Map<string, ReferenceRow>();
430
+ for (const reference of references) {
431
+ if (insertGate !== null && !sharesRun(reference.line, insertGate, SIMILARITY_RUN)) continue;
432
+ for (const char of reference.line) {
433
+ if (missing.has(char) || !isFidelitySensitiveChar(char) || payloadText.includes(char)) continue;
434
+ if (swapKeys.has(`${reference.index}:${char}`)) continue;
435
+ missing.set(char, reference);
98
436
  }
99
437
  }
100
- const deliberateFix = removed.length === 1 && inserted.length === 1 && isDeliberateCharFix(removed[0]!, inserted[0]!, dropped);
101
- if (!deliberateFix) {
102
- for (const char of dropped) lost.add(char);
438
+ if (carried === undefined && removed.length === 1 && inserted.length === 1) {
439
+ const chars = [...missing.keys()];
440
+ if (isDeliberateCharFix(removed[0]!, inserted[0]!, chars)) missing.clear();
103
441
  }
442
+ for (const [char, reference] of missing) {
443
+ if (seen.has(char) || hints.length >= MAX_FIDELITY_HINTS) continue;
444
+ seen.add(char);
445
+ const substitute = findSubstitute(char, reference, payload);
446
+ if (substitute !== undefined) {
447
+ seen.add(`swap:${reference.index}:${char}`);
448
+ hints.push(swappedCharHint({ oldChar: char, newChar: substitute, column: [...reference.line].indexOf(char) + 1 }, originalHashes?.[reference.index]));
449
+ continue;
450
+ }
451
+ hints.push(hiddenCharHint(char, reference, originalHashes?.[reference.index]));
452
+ }
453
+ for (const candidate of swaps) {
454
+ if (hints.length >= MAX_FIDELITY_HINTS) break;
455
+ const key = `swap:${candidate.reference.index}:${candidate.swapped.oldChar}`;
456
+ if (seen.has(key)) continue;
457
+ seen.add(key);
458
+ hints.push(swappedCharHint(candidate.swapped, originalHashes?.[candidate.reference.index]));
459
+ }
460
+ const indentReferenceFor = (payloadIndex: number): ReferenceRow | undefined => {
461
+ if (options?.indentHints === false) return undefined;
462
+ if (carried !== undefined) {
463
+ let nearest: ReferenceRow | undefined;
464
+ let nearestDistance = Number.POSITIVE_INFINITY;
465
+ for (const candidate of references) {
466
+ if (candidate.index < span.start - INDENT_REFERENCE_WINDOW || candidate.index > span.end + INDENT_REFERENCE_WINDOW) continue;
467
+ if (indentMismatchHint(payload[payloadIndex]!, candidate, undefined) === undefined) continue;
468
+ const distance = Math.abs(candidate.index - span.start);
469
+ if (distance < nearestDistance) {
470
+ nearest = candidate;
471
+ nearestDistance = distance;
472
+ }
473
+ }
474
+ return nearest;
475
+ }
476
+ if (removed.length === payload.length) {
477
+ return { line: removed[payloadIndex]!, index: span.start + payloadIndex };
478
+ }
479
+ return undefined;
480
+ };
481
+ for (let index = 0; index < payload.length; index += 1) {
482
+ if (hints.length >= MAX_FIDELITY_HINTS) break;
483
+ const payloadLine = payload[index]!;
484
+ const reference = indentReferenceFor(index);
485
+ if (reference === undefined) continue;
486
+ const indentHint = indentMismatchHint(payloadLine, reference, originalHashes?.[reference.index]);
487
+ if (indentHint === undefined) continue;
488
+ const key = `indent:${reference.index}:${payloadLine}`;
489
+ if (seen.has(key)) continue;
490
+ seen.add(key);
491
+ hints.push(indentHint);
492
+ }
493
+ if (options?.separatorMoved) {
494
+ const separatorHint = separatorMovedHint(oldLines, span.start, carried, payload, originalHashes?.[span.start]);
495
+ if (separatorHint !== undefined && hints.length < MAX_FIDELITY_HINTS) hints.push(separatorHint);
496
+ }
497
+ if (carried === undefined && removed.length === inserted.length) {
498
+ for (let index = 0; index < removed.length; index += 1) {
499
+ if (hints.length >= MAX_FIDELITY_HINTS) break;
500
+ const oldLine = removed[index]!;
501
+ const newLine = inserted[index]!;
502
+ if (oldLine === newLine || canon(oldLine) !== canon(newLine)) continue;
503
+ const key = `trailing:${span.start + index}`;
504
+ if (seen.has(key)) continue;
505
+ seen.add(key);
506
+ hints.push(trailingWhitespaceHint(oldLine, newLine, originalHashes?.[span.start + index]));
507
+ }
508
+ }
509
+ }
510
+ if (removed.length > 0 && inserted.length === 0) {
511
+ const lost = separatorLostHint(oldLines, span, originalHashes);
512
+ if (lost !== undefined && hints.length < MAX_FIDELITY_HINTS) hints.push(lost);
104
513
  }
105
514
  offset += span.replacementCount - removedCount;
106
515
  }
107
- return lost.size > 0 ? [unicodeLostHint([...lost].sort())] : [];
516
+ return hints;
108
517
  }
@@ -38,6 +38,7 @@ export {
38
38
  stripDiffPrefixes,
39
39
  type StripWarningLocation,
40
40
  swapReversedRanges,
41
+ preserveDeletionSeparators,
41
42
  assertRangeServed,
42
43
  RangeStaleError,
43
44
  AnchorMismatchError,
@@ -272,6 +272,27 @@ export function swapReversedRanges(
272
272
  return { ...edit, hash_bounds: [endRef, startRef] as [Anchor, Anchor] };
273
273
  }
274
274
 
275
+ export function preserveDeletionSeparators(edit: HEdit, fileLines: string[], fileHashes: string[]): HEdit {
276
+ if (edit.content_lines.length > 0) return edit;
277
+ const lineByHash = new Map<string, number>();
278
+ for (let index = 0; index < fileHashes.length; index++) {
279
+ const hash = fileHashes[index]!;
280
+ if (!lineByHash.has(hash)) lineByHash.set(hash, index);
281
+ }
282
+ const fromLine = lineByHash.get(edit.hash_bounds[0].hash);
283
+ const toLine = lineByHash.get(edit.hash_bounds[1].hash);
284
+ if (fromLine === undefined || toLine === undefined) return edit;
285
+ const rangeStart = Math.min(fromLine, toLine);
286
+ const rangeEnd = Math.max(fromLine, toLine);
287
+ const isBlank = (index: number): boolean => (fileLines[index] ?? "").trim().length === 0;
288
+ let start = rangeStart;
289
+ let end = rangeEnd;
290
+ while (start <= end && isBlank(start)) start += 1;
291
+ while (end >= start && isBlank(end)) end -= 1;
292
+ if (start > end || (start === rangeStart && end === rangeEnd)) return edit;
293
+ return { ...edit, hash_bounds: [{ hash: fileHashes[start]! }, { hash: fileHashes[end]! }] };
294
+ }
295
+
275
296
  export function valEdit(
276
297
  edit: HEdit,
277
298
  fileLines: string[],
@@ -380,10 +401,12 @@ export function assertRangeServed(
380
401
  const startLine = resolved.hash_bounds[0].line;
381
402
  const endLine = resolved.hash_bounds[1].line;
382
403
  const mismatchLines: number[] = [];
404
+ const deletion = resolved.content_lines.length === 0;
383
405
  for (let line = startLine; line <= endLine; line++) {
384
406
  const hash = fileHashes[line - 1]!;
385
407
  const content = fileLines[line - 1]!;
386
408
  const servedContent = served?.get(hash);
409
+ if (servedContent === undefined && deletion && line !== startLine && line !== endLine) continue;
387
410
  if (servedContent === undefined || servedContent !== lineChecksum(content)) mismatchLines.push(line);
388
411
  }
389
412
  if (mismatchLines.length === 0) return;
package/src/insert.ts CHANGED
@@ -11,7 +11,7 @@ import { stripAnchorRow } from "./hashline/resolve";
11
11
  import { withAnchorSession } from "./anchor-registry";
12
12
  import { loadP, loadGuide } from "./prompts";
13
13
  import { assertInsertReq, normReq, type InsertReq } from "./payload-contract";
14
- import { isRec, literalEscapeHint, splitLines } from "./utils";
14
+ import { isRec, literalEscapeHints, splitLines } from "./utils";
15
15
  import { queuedEdit, editToolBase, editRenderCallWrapper, editRenderResultWrapper, resolveEditTargetWithRequirement, throwIfStrictInput, withInsertPrompts, DEFAULT_EDIT_FLAGS, type EditToolFlags } from "./edit-common";
16
16
  import type { RPreview, RRState } from "./replace-render";
17
17
  export { assertInsertReq, type InsertReq };
@@ -27,7 +27,7 @@ const insertDirectionSchema = Type.Union(
27
27
  );
28
28
  const insertLinesSchema = Type.String({
29
29
  description:
30
- 'The exact text to insert. "" inserts nothing, "\\n" is one blank line, and a trailing line break sets the last line\'s ending instead of adding a blank line. Never include the anchor line.',
30
+ 'The exact text to insert; an empty string inserts one blank line. "\\n" is one blank line, and a trailing line break sets the last line\'s ending instead of adding a blank line. Never include the anchor line.',
31
31
  });
32
32
 
33
33
  const insertPathRequiredSchema = Type.String({
@@ -70,7 +70,7 @@ export function buildInsertEdit(
70
70
  ref: Anchor,
71
71
  path: string,
72
72
  ): { editParams: HTEdit; anchorLine: string | undefined; contentSeparators: (LineEnding | undefined)[] } {
73
- const parsed = parsePayloadText(req.lines);
73
+ const parsed = parsePayloadText(req.lines.length === 0 ? "\n" : req.lines);
74
74
  const fileLines = splitLines(preload.normalized);
75
75
  const line = resolveAnchorLine(ref, fileLines, preload.fileHashes, path);
76
76
  const anchorLine = preload.normalized.length === 0 ? undefined : fileLines[line - 1];
@@ -177,9 +177,7 @@ export function buildInsertToolDef(flags: EditToolFlags = DEFAULT_EDIT_FLAGS): I
177
177
  const canonical = normReq(params);
178
178
  assertInsertReq(canonical);
179
179
  const req = canonical;
180
- const insertWarnings: string[] = [];
181
- const literalEscape = literalEscapeHint([req.lines], "lines");
182
- if (literalEscape !== undefined) insertWarnings.push(literalEscape);
180
+ const insertWarnings: string[] = [...literalEscapeHints([req.lines], "lines")];
183
181
  const targetPath = await resolveEditTargetWithRequirement({
184
182
  anchor: req.anchor,
185
183
  providedPath: req.path,
package/src/prompts.ts CHANGED
@@ -1,11 +1,22 @@
1
1
  import { readFileSync } from "node:fs";
2
+ import { errCode } from "./utils";
2
3
 
3
4
  export function loadP(relativePath: string): string {
4
5
  return readFileSync(new URL(relativePath, import.meta.url), "utf-8").trim();
5
6
  }
6
7
 
7
8
  export function loadGuide(relativePath: string): string[] {
8
- return readFileSync(new URL(relativePath, import.meta.url), "utf-8")
9
+ let raw: string;
10
+ try {
11
+ raw = readFileSync(new URL(relativePath, import.meta.url), "utf-8");
12
+ } catch (error) {
13
+ if (errCode(error) === "ENOENT") {
14
+ console.warn(`hashline: missing prompt file ${relativePath}; continuing without those guidelines`);
15
+ return [];
16
+ }
17
+ throw error;
18
+ }
19
+ return raw
9
20
  .split("\n")
10
21
  .map((line) => line.trim())
11
22
  .filter((line) => line.startsWith("- "))
@@ -380,7 +380,7 @@ export function renderEditResult(
380
380
  if (abortedMessage !== undefined) {
381
381
  return reuseText(context, `\n${highlightBatchRefs(abortedMessage, theme)}`);
382
382
  }
383
- return reuseText(context, theme.fg("warning", `In batch ${batch.id}`));
383
+ return reuseText(context, theme.fg("warning", `In batch ${batch.id} (queued)`));
384
384
  }
385
385
  if (context.isError) {
386
386
  return renderedText
@@ -2,7 +2,7 @@ import type { NEdit } from "./hashline";
2
2
  import type { ReplaceDetails } from "./replace";
3
3
  import { genDiff, genPatch, type DiffSpan } from "./replace-diff";
4
4
  import { visLines, clipLine } from "./utils";
5
- import { fidelityHints } from "./edit-fidelity";
5
+ import { annotateLiteralEscapeHints, fidelityHints } from "./edit-fidelity";
6
6
 
7
7
  export type TResult = {
8
8
  content: Array<{ type: "text"; text: string }>;
@@ -140,9 +140,10 @@ export function buildChanged(input: SuccessInput, verb = "replaced", diffContext
140
140
  const diffResult = genDiff(originalNormalized, result, diffContextLines, resultHashes, originalHashes, undefined, spans);
141
141
  const addedLines = editMeta.addedLines;
142
142
  const removedLines = editMeta.removedLines;
143
- const fidelity = fidelityHints(originalNormalized, result, spans);
143
+ const fidelity = fidelityHints(originalNormalized, result, spans, originalHashes, { separatorMoved: verb === "inserted" || verb === "edited", indentHints: verb !== "copied" && verb !== "moved" });
144
144
  const { warnings: noticeWarnings, hints } = splitNotices(fidelity.length > 0 ? [...(warnings ?? []), ...fidelity] : warnings);
145
- const noticesBlock = `${warnBlock(noticeWarnings)}${hintBlock(hints)}`;
145
+ const annotatedHints = annotateLiteralEscapeHints(hints, result, spans, resultHashes);
146
+ const noticesBlock = `${warnBlock(noticeWarnings)}${hintBlock(annotatedHints)}`;
146
147
  const successPrefix = `Successfully ${verb} in ${path}.`;
147
148
  const lineSummary = addedLines > 0 || removedLines > 0
148
149
  ? ` Added ${addedLines} line(s), removed ${removedLines} line(s).`
@@ -177,7 +178,7 @@ export function buildChanged(input: SuccessInput, verb = "replaced", diffContext
177
178
  metrics,
178
179
  diffLineNumbers: diffResult.lineNumbers.map((line) => line ?? null),
179
180
  ...(noticeWarnings.length ? { warnings: [...noticeWarnings] } : {}),
180
- ...(hints.length ? { hints: [...hints] } : {}),
181
+ ...(annotatedHints.length ? { hints: [...annotatedHints] } : {}),
181
182
  },
182
183
  };
183
184
  }
@@ -13,7 +13,7 @@ import {
13
13
  normalizeReplaceWithinRequest,
14
14
  type ReplaceWithinReq,
15
15
  } from "./payload-contract";
16
- import { literalEscapeHint, splitLines } from "./utils";
16
+ import { literalEscapeHints, splitLines } from "./utils";
17
17
  import { toLF } from "./normalize";
18
18
  import { MAX_RANGE_STALE_LINES } from "./constants";
19
19
  import {
@@ -178,10 +178,7 @@ export function buildReplaceWithinToolDef(flags: EditToolFlags = DEFAULT_EDIT_FL
178
178
  const req = normalized;
179
179
  const { refs, warnings } = parseWithinAnchors(req);
180
180
  await throwIfStrictInput(warnings);
181
- const hints = [
182
- literalEscapeHint([req.replace_old], "replace_old"),
183
- literalEscapeHint([req.replace_new], "replace_new"),
184
- ].filter((hint): hint is string => hint !== undefined);
181
+ const hints = [...literalEscapeHints([req.replace_old], "replace_old"), ...literalEscapeHints([req.replace_new], "replace_new")];
185
182
  const targetPath = await resolveEditTargetWithRequirement({
186
183
  removeFrom: req.replace_from,
187
184
  removeTo: req.replace_to,
package/src/replace.ts CHANGED
@@ -10,12 +10,13 @@ import {
10
10
  } from "./replace-diff";
11
11
  import { readNormFile, type NormFile } from "./file-reader";
12
12
  import { editToolSchema, buildEditToolSchema, type ReqParams, type RawReqParams, assertReq, normReq } from "./payload-contract";
13
- import { literalEscapeHint, splitLines } from "./utils";
13
+ import { literalEscapeHints, splitLines } from "./utils";
14
14
  import { loadP, loadGuide } from "./prompts";
15
15
  import { type FileIdentity } from "./fs-write";
16
16
  import { applyEdit,
17
17
  lineHashes,
18
18
  resEdit,
19
+ preserveDeletionSeparators,
19
20
  MAX_HASH_LINES,
20
21
  RangeStaleError,
21
22
  AnchorMismatchError,
@@ -84,6 +85,7 @@ export interface ExecPipelineOptions {
84
85
  allowEmpty?: boolean;
85
86
  stripWarning?: StripWarningLocation;
86
87
  endingOverrides?: (LineEnding | undefined)[];
88
+ preserveDeletionSeparators?: boolean;
87
89
  }
88
90
 
89
91
  export function hashSpan(hashes: string[], from: string, to: string): [number, number] | undefined {
@@ -157,12 +159,15 @@ export async function execPipeline(
157
159
  targetPath, cwd, { signal: options?.signal, accessMode: options?.accessMode, maxLines: MAX_HASH_LINES, store: hashStore, noPersist: options?.noPersist, allocation: options?.noPersist ? "shadow" : "real", preloadedNorm: options?.preloadedNorm },
158
160
  );
159
161
  const displayPath = toDisplayPath(cwd, absolutePath, targetPath);
162
+ const effectiveEdit = options?.preserveDeletionSeparators === false
163
+ ? anchoredEdit
164
+ : preserveDeletionSeparators(anchoredEdit, splitLines(originalNormalized), originalHashes);
160
165
 
161
166
  let anchorResult: ReturnType<typeof applyEdit>;
162
167
  try {
163
168
  anchorResult = applyEdit(
164
169
  originalNormalized,
165
- anchoredEdit,
170
+ effectiveEdit,
166
171
  options?.signal,
167
172
  originalHashes,
168
173
  displayPath,
@@ -187,10 +192,10 @@ export async function execPipeline(
187
192
  const warnings = [...editWarnings, ...(anchorResult.warnings ?? [])];
188
193
  await throwIfStrictInput(warnings);
189
194
  const { totalAddedLines, totalRemovedLines } = countLineChanges(
190
- edit, originalHashes, isNoop,
195
+ effectiveEdit, originalHashes, isNoop,
191
196
  );
192
197
 
193
- const pipeSpan = isNoop ? undefined : spanForEdit(originalHashes, edit.hash_bounds[0].hash, edit.hash_bounds[1].hash, result);
198
+ const pipeSpan = isNoop ? undefined : spanForEdit(originalHashes, effectiveEdit.hash_bounds[0].hash, effectiveEdit.hash_bounds[1].hash, result);
194
199
  const pipeSpans = pipeSpan ? [pipeSpan] : undefined;
195
200
  return {
196
201
  path: displayPath,
@@ -210,7 +215,7 @@ export async function execPipeline(
210
215
  totalRemovedLines,
211
216
  identity,
212
217
  ...(pipeSpans ? { spans: pipeSpans } : {}),
213
- ...(anchoredEdit.content_separators !== undefined ? { contentSeparators: anchoredEdit.content_separators } : {}),
218
+ ...(effectiveEdit.content_separators !== undefined ? { contentSeparators: effectiveEdit.content_separators } : {}),
214
219
  };
215
220
  }
216
221
 
@@ -285,7 +290,7 @@ export function buildToolDef(flags: EditToolFlags = DEFAULT_EDIT_FLAGS): ToolDef
285
290
  const canonical = normReq(params);
286
291
  assertReq(canonical);
287
292
  const normalizedParams = canonical;
288
- const literalEscape = literalEscapeHint([normalizedParams.replacement_lines], "replacement_lines");
293
+ const literalEscapes = literalEscapeHints([normalizedParams.replacement_lines], "replacement_lines");
289
294
  const targetPath = await resolveEditTargetWithRequirement({
290
295
  removeFrom: normalizedParams.remove_from,
291
296
  removeTo: normalizedParams.remove_to,
@@ -310,7 +315,7 @@ export function buildToolDef(flags: EditToolFlags = DEFAULT_EDIT_FLAGS): ToolDef
310
315
  absolutePath,
311
316
  mutationTargetPath,
312
317
  editAnchors: [normalizedParams.remove_from, normalizedParams.remove_to],
313
- prefixWarnings: literalEscape !== undefined ? [literalEscape] : [],
318
+ prefixWarnings: literalEscapes,
314
319
  signal,
315
320
  });
316
321
  }
@@ -329,7 +334,7 @@ export function buildToolDef(flags: EditToolFlags = DEFAULT_EDIT_FLAGS): ToolDef
329
334
  cwd: ctx.cwd,
330
335
  signal,
331
336
  hedit: built.edit,
332
- extraWarnings: [...(literalEscape !== undefined ? [literalEscape] : []), ...built.warnings],
337
+ extraWarnings: [...literalEscapes, ...built.warnings],
333
338
  });
334
339
  });
335
340
  });
package/src/utils.ts CHANGED
@@ -380,6 +380,7 @@ function normalizeEditLines(record: Record<string, unknown>): void {
380
380
  const LITERAL_ESCAPE_RE = /\\(?:u([0-9a-fA-F]{4})|([ntr"]))/g;
381
381
 
382
382
  const REAL_LINE_BREAK_RE = /[\n\r]/;
383
+ const MAX_LITERAL_ESCAPE_HINTS = 3;
383
384
 
384
385
  function isSurrogateEscapePair(line: string, index: number, hex: string): boolean {
385
386
  const code = Number.parseInt(hex, 16);
@@ -398,7 +399,9 @@ function isSurrogateEscapePair(line: string, index: number, hex: string): boolea
398
399
  return false;
399
400
  }
400
401
 
401
- export function literalEscapeHint(lines: string[], label: string): string | undefined {
402
+ export function literalEscapeHints(lines: string[], label: string): string[] {
403
+ const hints: string[] = [];
404
+ const seen = new Set<string>();
402
405
  for (const line of lines) {
403
406
  if (!line.includes("\\")) continue;
404
407
  const hasRealBreak = REAL_LINE_BREAK_RE.test(line);
@@ -410,8 +413,12 @@ export function literalEscapeHint(lines: string[], label: string): string | unde
410
413
  if (hex.toLowerCase() === "dddd") continue;
411
414
  if (isSurrogateEscapePair(line, match.index, hex)) continue;
412
415
  }
413
- return `[H_LITERAL_ESCAPE] "${label}" contains the literal escaped text "${match[0]}"`;
416
+ const text = match[0];
417
+ if (seen.has(text)) continue;
418
+ seen.add(text);
419
+ hints.push(`[H_LITERAL_ESCAPE] ${label}: "${text}" written as literal text`);
420
+ if (hints.length >= MAX_LITERAL_ESCAPE_HINTS) return hints;
414
421
  }
415
422
  }
416
- return undefined;
423
+ return hints;
417
424
  }