pi-hashline-edit-pro 6.5.0 → 7.0.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
@@ -4,20 +4,20 @@
4
4
 
5
5
  [![npm version](https://img.shields.io/npm/v/pi-hashline-edit-pro.svg)](https://www.npmjs.com/package/pi-hashline-edit-pro) [![npm downloads](https://img.shields.io/npm/dm/pi-hashline-edit-pro.svg)](https://www.npmjs.com/package/pi-hashline-edit-pro) [![Explicit Edit Benchmark](https://img.shields.io/endpoint?url=https://huggingface.co/datasets/alexshpunt/explicit-edit-benchmark/resolve/main/badges/pi-hashline-edit-pro.json&style=flat-square)](https://huggingface.co/spaces/alexshpunt/benchmark-explorer?card=harness%3Api-hashline-edit-pro%40latest)
6
6
 
7
- pi-hashline-edit-pro is an extension for [pi-coding-agent](https://github.com/earendil-works/pi) that edits files by anchor. Every line a tool serves gets a unique 4-character anchor, and you edit by anchor. Edits are never addressed by line number and nothing is fuzzy-matched, so an edit lands on the line you meant.
7
+ pi-hashline-edit-pro is an extension for [pi-coding-agent](https://github.com/earendil-works/pi) that edits files by anchor. Every line a tool serves gets a unique 5-character anchor, and you edit by anchor. Edits are never addressed by line number and nothing is fuzzy-matched, so an edit lands on the line you meant.
8
8
 
9
- It is a fork of [pi-hashline-edit](https://github.com/RimuruW/pi-hashline-edit) by RimuruW, extended with 4-character tokenizer-friendly anchors and allocation-based anchor identity.
9
+ It is a fork of [pi-hashline-edit](https://github.com/RimuruW/pi-hashline-edit) by RimuruW, extended with 5-character tokenizer-friendly anchors and allocation-based anchor identity.
10
10
 
11
11
  ```text
12
12
  read a file; every line comes back as anchor│content:
13
13
 
14
- Dafo│function hello() {
15
- Emno│ console.log("world");
16
- HDtm│}
14
+ dafox│function hello() {
15
+ emnox│ console.log("world");
16
+ hdtmx│}
17
17
 
18
18
  replace one line by its anchor:
19
19
 
20
- { "remove_from": "Emno", "remove_to": "Emno", "text": " console.log('hi');" }
20
+ { "remove_from": "emnox", "remove_to": "emnox", "text": " console.log('hi');" }
21
21
 
22
22
  the result is the post-edit diff with fresh anchors, so the next edit needs no re-read.
23
23
  ```
@@ -57,12 +57,12 @@ Line numbers shift when anything above them changes; fuzzy matching can silently
57
57
 
58
58
  | Dimension | Line numbers or fuzzy matching | Anchors (this extension) |
59
59
  | --- | --- | --- |
60
- | Address | a line number | a 4-character anchor minted for one line |
60
+ | Address | a line number | a 5-character anchor minted for one line |
61
61
  | After an edit | downstream numbers shift, so stale numbers can hit the wrong line | untouched lines keep their anchors; only changed lines get new ones |
62
62
  | Wrong target | a fuzzy match may land on a similar line | an anchor resolves to exactly one file and line, or the edit is refused |
63
63
  | Stale request | can apply silently | refused with `[E_STALE_ANCHOR]` or `[E_RANGE_STALE]`, with fresh anchors returned for the retry |
64
64
 
65
- What "pro" adds over upstream `pi-hashline-edit`: the anchor table is built from pieces that each tokenize as one token (so an anchored row and an edit call stay cheap in tokens), and anchors are allocated per line and never derived from content, so byte-identical lines never share an anchor and an edit cannot re-target a different line.
65
+ What "pro" adds over upstream `pi-hashline-edit`: the anchor table is a curated set of 5-letter lowercase anchors whose `anchor│` unit stays within three tokens (so an anchored row and an edit call stay cheap in tokens), and anchors are allocated per line and never derived from content, so byte-identical lines never share an anchor and an edit cannot re-target a different line.
66
66
 
67
67
  ## Install
68
68
 
@@ -101,12 +101,12 @@ If anything of yours expects line-numbered `read` output (prompts, skills, hooks
101
101
  After install, `read` any file and confirm the rows look like this:
102
102
 
103
103
  ```text
104
- Dafo│function hello() {
105
- Emno│ console.log("world");
106
- HDtm│}
104
+ dafox│function hello() {
105
+ emnox│ console.log("world");
106
+ hdtmx│}
107
107
  ```
108
108
 
109
- Then replace `Emno` and confirm the result is a post-edit diff with fresh anchors.
109
+ Then replace `emnox` and confirm the result is a post-edit diff with fresh anchors.
110
110
 
111
111
  ### Uninstall
112
112
 
@@ -119,17 +119,17 @@ pi uninstall npm:pi-hashline-edit-pro
119
119
  1. Read a file. Every line comes back as `anchor│content`:
120
120
 
121
121
  ```text
122
- Dafo│function hello() {
123
- Emno│ console.log("world");
124
- HDtm│}
122
+ dafox│function hello() {
123
+ emnox│ console.log("world");
124
+ hdtmx│}
125
125
  ```
126
126
 
127
127
  2. Replace a line by its anchor. One edit per call, fields at the top level:
128
128
 
129
129
  ```json
130
130
  {
131
- "remove_from": "Emno",
132
- "remove_to": "Emno",
131
+ "remove_from": "emnox",
132
+ "remove_to": "emnox",
133
133
  "text": " console.log('hi');"
134
134
  }
135
135
  ```
@@ -138,8 +138,8 @@ pi uninstall npm:pi-hashline-edit-pro
138
138
 
139
139
  ```text
140
140
  ...
141
- -Emno│ console.log("world");
142
- +Qwer│ console.log('hi');
141
+ -emnox│ console.log("world");
142
+ +qwerx│ console.log('hi');
143
143
  ...
144
144
  ```
145
145
 
@@ -167,7 +167,7 @@ The extension registers eight tools: `read`, `replace`, `replace_match`, `insert
167
167
  | `offset` | Line number (1-indexed) or a served anchor to start reading from. |
168
168
  | `limit` | Maximum number of lines to return from `offset`. |
169
169
 
170
- Output is capped at 2000 lines and 50KB. Paged output ends with a continuation hint, for example `[Showing lines 1-50 of 120. Use offset=Emno to continue.]`.
170
+ Output is capped at 2000 lines and 50KB. Paged output ends with a continuation hint, for example `[Showing lines 1-50 of 120. Use offset=emnox to continue.]`.
171
171
 
172
172
  A line whose `anchor│content` row exceeds 50KB is replaced by a marker that keeps the line's anchor: `anchor│[Line N is 2.2MB, exceeds 50.0KB; content not shown. Use bash: sed -n 'Np' <path> | head -c 51200]`. The marker is served like a normal row, so the whole line can still be replaced through it.
173
173
 
@@ -186,18 +186,16 @@ Edge cases:
186
186
 
187
187
  | Field | Description |
188
188
  | --- | --- |
189
- | `remove_from` | 4-char anchor marking the FIRST line to remove (inclusive). |
190
- | `remove_to` | 4-char anchor marking the LAST line to remove (inclusive). |
189
+ | `remove_from` | 5-char anchor marking the FIRST line to remove (inclusive). |
190
+ | `remove_to` | 5-char anchor marking the LAST line to remove (inclusive). |
191
191
  | `text` | The exact text to write in place of the removed range, as one string: `""` deletes the range. A trailing line break sets the newline of the last line written instead of adding a blank line; a lone newline writes one blank line. Embedded `\r\n`/`\r`/`\n` are preserved; escapes in the tool call are decoded once when the call is parsed, and the tool then writes the string it receives as-is, never decoding again — `\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
- `replace_from` and `replace_to` are accepted as aliases for `remove_from` and `remove_to`.
194
-
195
- Example: read showed `Hasu│old` and `arvm│old2`; to replace both:
193
+ Example: read showed `hasux│old` and `arvmx│old2`; to replace both:
196
194
 
197
195
  ```json
198
196
  {
199
- "remove_from": "Hasu",
200
- "remove_to": "arvm",
197
+ "remove_from": "hasux",
198
+ "remove_to": "arvmx",
201
199
  "text": "new line 1\nnew line 2"
202
200
  }
203
201
  ```
@@ -207,11 +205,11 @@ A deletion keeps blank lines at the edges of the removed range, so the separator
207
205
 
208
206
  The extension checks the request before any file I/O, so a bad request never touches the file.
209
207
 
210
- Auto-fixable slips fall into two groups. Fixed silently: embedded newlines and a legacy array payload (a single-element array that holds stringified array text, even with a trailing JS method call, for example `[…].map(s => s)`, is unwrapped). Fixed with a warning or a hint: a reversed `remove_from`/`remove_to` range is applied in document order with an `[H_RANGE_REVERSED]` hint; a leftover `anchor│` prefix in `text` or the anchor fields (a prefix of 4 to 5 letters before `│`, for example `abde│`) and diff-preview rows pasted into the replacement are stripped with a warning.
208
+ Auto-fixable slips fall into two groups. Fixed silently: embedded newlines and a legacy array payload (a single-element array that holds stringified array text, even with a trailing JS method call, for example `[…].map(s => s)`, is unwrapped). Fixed with a warning or a hint: a reversed `remove_from`/`remove_to` range is applied in document order with an `[H_RANGE_REVERSED]` hint; a leftover `anchor│` prefix in `text` or a pasted served row in the anchor fields (a bare `abdef│` prefix, a line-numbered row such as `12:│ abdef│content`, or an `abdef (line 12)` suffix) and diff-preview rows pasted into the replacement are stripped with a warning.
211
209
 
212
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 the `text` field of both `replace` and `insert`.
213
211
 
214
- 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, `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
+ 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, `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 live 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.
215
213
 
216
214
  An edit that changes neither content nor line endings reports `No changes made` and leaves the anchors alone.
217
215
 
@@ -219,11 +217,9 @@ After a successful edit, the diff is capped at 50KB. A row over 50KB is shown as
219
217
 
220
218
  ### replace_match
221
219
 
222
- `replace_match` changes part of a line — or the same fragment on several lines of a range — 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 searched (the same anchor for a single line). `match` is the exact text to find inside that range, and `replacement` replaces every occurrence of it; every other character stays untouched. Both stay within one line: a single trailing line break is accepted as the line's own terminator and dropped, so the file keeps its separator, while a line break anywhere else is refused with `[E_BAD_SHAPE]` (a `match` that is only that trailing line break leaves nothing to find and is refused; in `replacement` it counts as `""` and deletes the matches). Send `match` as only the fragment that changes — never include text that `replacement` leaves unchanged — and use `replace` with the same anchors when the change covers whole lines; if `match` is an entire single-line range except indentation and trailing punctuation, that is a whole-line change. The anchors bound the search, so `match` only needs to be unique inside the selected range, not file-wide. That makes it the tool for a change the request quotes as a substring: a whole-line `replace` has to reproduce the rest of the lines, so a slipped character becomes a wrong byte, while `replace_match` leaves everything the request did not name untouched. It is enabled by default; turn Replace match off in `/hashline-config` to remove the tool.
223
-
224
- `remove_from` and `remove_to` are accepted as aliases for `replace_from` and `replace_to`.
220
+ `replace_match` changes part of a line — or the same fragment on several lines of a range — 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 searched (the same anchor for a single line). `match` is the exact text to find inside that range, and `replacement` replaces every occurrence of it; every other character stays untouched. `match` stays within one line: a single trailing line break is accepted as the line's own terminator and dropped, so the file keeps its separator, while a line break anywhere else is refused with `[E_BAD_SHAPE]` (a `match` that is only that trailing line break leaves nothing to find and is refused). `replacement` may span lines: a single trailing line break is its terminator and is dropped, a value that is only a line break counts as `""` and deletes the matches, and leading or interior breaks split the replacement into lines; new lines inherit the file's line endings. Send `match` as only the fragment that changes — never include text that `replacement` leaves unchanged — and use `replace` with the same anchors when the change covers whole lines; if `match` is an entire single-line range except indentation and trailing punctuation, that is a whole-line change. The anchors bound the search, so occurrences outside the selected range are left alone. That makes it the tool for a change the request quotes as a substring: a whole-line `replace` has to reproduce the rest of the lines, so a slipped character becomes a wrong byte, while `replace_match` leaves everything the request did not name untouched. It is enabled by default; turn Replace match off in `/hashline-config` to remove the tool.
225
221
 
226
- `match` is matched against each selected line's text (LF line breaks) and every non-overlapping occurrence is replaced, left to right — since `match` stays within one line, so does each replacement. A missing match is refused with `[E_SUBSTRING_NOT_FOUND]` and the current `anchor│content` rows, so the retry needs no `read`. The two boundary anchors are verified against what was last shown; lines strictly inside the range are searched as they currently stand on disk.
222
+ `match` is matched against each selected line's text (LF line breaks) and every non-overlapping occurrence is replaced, left to right. Each occurrence expands to the replacement's lines: the first line continues the text before the match, the last line continues the text after it, and middle lines sit between them. A missing match is refused with `[E_SUBSTRING_NOT_FOUND]` and the current `anchor│content` rows, so the retry needs no `read`. The two boundary anchors are verified against what was last shown; lines strictly inside the range are searched as they currently stand on disk.
227
223
 
228
224
  In a same-message batch it joins the other calls on its file: the batch validates everything against the pre-batch state and commits once, with one undo. A missing `match` fails that call on its own and is left out of the batch; the rest of the batch still commits. The post-edit diff carries fresh anchors, and the edit is undoable with `undo_last_change`.
229
225
 
@@ -233,16 +229,16 @@ In a same-message batch it joins the other calls on its file: the batch validate
233
229
 
234
230
  | Field | Description |
235
231
  | --- | --- |
236
- | `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
+ | `anchor` | 5-char anchor marking the line next to which the lines go. The anchor line is preserved. A pasted `+hasux│x` diff row or `anchor│` prefix is stripped automatically with a warning. |
237
233
  | `direction` | `"after"` inserts below the anchor line, `"before"` above it. |
238
234
  | `text` | The exact text to insert, as one string: `""` inserts one blank line (the same as a lone newline). A trailing line break sets the newline of the last line written instead of adding a blank line. Never include the anchor line. Embedded `\r\n`/`\r`/`\n` are preserved; escapes in the tool call are decoded once when the call is parsed, and the tool then writes the string it receives as-is, never decoding again — `\uXXXX` is the character, `\\uXXXX` the literal text. Legacy arrays are converted to text (elements joined with LF); prefer the string form. |
239
235
 
240
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 `text` payload inserts one blank line. To seed an empty file, read it and insert after the `anchor│` empty-line row.
241
237
 
242
- Example: add a line after `Emno│`:
238
+ Example: add a line after `emnox│`:
243
239
 
244
240
  ```json
245
- { "anchor": "Emno", "direction": "after", "text": " // log the greeting" }
241
+ { "anchor": "emnox", "direction": "after", "text": " // log the greeting" }
246
242
  ```
247
243
 
248
244
  The same safety machinery as `replace` applies: undo is saved before the write (a failed write restores the previous undo record), and line endings and BOMs survive.
@@ -253,22 +249,22 @@ The same safety machinery as `replace` applies: undo is saved before the write (
253
249
 
254
250
  | Field | Description |
255
251
  | --- | --- |
256
- | `source_from` | 4-char anchor marking the FIRST source line to copy (inclusive). |
257
- | `source_to` | 4-char anchor marking the LAST source line to copy (inclusive). |
258
- | `anchor` | 4-char anchor of the destination line the block goes before or after, resolved in the destination file. |
252
+ | `source_from` | 5-char anchor marking the FIRST source line to copy (inclusive). |
253
+ | `source_to` | 5-char anchor marking the LAST source line to copy (inclusive). |
254
+ | `anchor` | 5-char anchor of the destination line the block goes before or after, resolved in the destination file. |
259
255
  | `direction` | `"after"` places the block below the anchor line, `"before"` above it. Within one file the insertion point must fall before the source range's first line or after its last. |
260
256
 
261
- Example: read served `Hasu│old` in `a.ts` and `Qwer│top` in `b.ts`; to copy `old` into `b.ts` below `top`:
257
+ Example: read served `hasux│old` in `a.ts` and `qwerx│top` in `b.ts`; to copy `old` into `b.ts` below `top`:
262
258
 
263
259
  ```json
264
- { "source_from": "Hasu", "source_to": "Hasu", "anchor": "Qwer", "direction": "after" }
260
+ { "source_from": "hasux", "source_to": "hasux", "anchor": "qwerx", "direction": "after" }
265
261
  ```
266
262
 
267
263
  The source lines stay in place and keep their anchors; the copied lines are minted fresh anchors in the destination's post-edit diff and keep their source line endings. A cross-file copy writes only the destination, so one `undo_last_change` on it reverts the copy. In a same-message batch, a same-file copy and a cross-file copy join the destination file's batch; the copy still duplicates the content its source anchors were served from, not the result of a sibling edit. Anchoring the block's last line with `direction: "after"` (or its first line with `direction: "before"`) duplicates the block right beside itself.
268
264
 
269
265
  ### move
270
266
 
271
- `move` relocates a range of lines in one call: the range is removed from the source file and written before or after `anchor`, which may live in a different file. It takes the same fields as `copy`, and an empty destination file is seeded with the moved lines. Within one file, the destination position must sit outside the source range, and moving a range to where it already sits reports `No changes made` and leaves the anchors alone. A same-file `move` and a cross-file `move` join the same-message batch of the destination file; a cross-file `move` whose source file also has batched edits in the message commits on its own. A batched cross-file `move` commits its source removal with the batch but shows only the destination diff; read the source file for fresh anchors. Lines between the source and the target keep their content but may be re-anchored; the moved lines keep their source line endings, and a cross-file move that removes every source line leaves the source file empty.
267
+ `move` relocates a range of lines in one call: the range is removed from the source file and written before or after `anchor`, which may live in a different file. It takes the same fields as `copy`, and an empty destination file is seeded with the moved lines. Within one file, the destination position must sit outside the source range, and moving a range to where it already sits reports `No changes made` and leaves the anchors alone. A same-file `move` and a cross-file `move` join the same-message batch of the destination file; a cross-file `move` whose source file also gets other edits in the message runs outside any batch, so same-message edits to its destination file can then either abort or make the move fail; send such a move in a separate message. A batched cross-file `move` commits its source removal with the batch but shows only the destination diff; read the source file for fresh anchors. Lines between the source and the target keep their content but may be re-anchored; the moved lines keep their source line endings, and a cross-file move that removes every source line leaves the source file empty.
272
268
 
273
269
  The same safety machinery as `replace` applies to both tools: undo is saved before the write (a failed write restores the previous undo record), and line endings and BOMs survive. A cross-file `move` writes two files and records one undo entry per file; undo both sides to revert the whole move, because undoing one side alone leaves the moved lines duplicated or missing. When the move is part of a batch, the destination side is reverted by that batch's undo and the source side by its own entry.
274
270
 
@@ -322,7 +318,7 @@ Multiple `replace`, `replace_match`, `insert`, `copy`, and `move` calls on the s
322
318
 
323
319
  Batched calls must target disjoint ranges; overlapping ranges abort the whole batch unwritten. A `copy`'s `anchor` line is the copy's destination range, so replacing or moving that same line in the batch is an overlap. One `insert`, `copy`, or cross-file `move` 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 a batch-level step aborts its batch-mates with `[E_OP_ABORTED]`; a call left out of the batch does not.
324
320
 
325
- A call whose anchors resolve nowhere never joins a batch: it runs on its own and fails with its own error (`[E_STALE_ANCHOR]`, or `[E_BAD_SHAPE]` when its request cannot be parsed), while the same-file batch in the message still commits. Calls with one stale anchor and a valid co-anchor, or with a `requirePath` path hint, are grouped into their file's batch; when their own request then fails validation against the pre-batch state, they are left out of the batch and fail on their own instead of applying partially. A `copy` or `move` whose source anchors no longer resolve cannot be grouped, because the source file cannot be identified; it runs on its own and fails with its own error. A cross-file `move` whose source file has batched edits in the message is also left out of the batch and commits on its own. An error that aborts a batch ends with `Aborts batch N.`; an aborted call reads `[E_OP_ABORTED] Batch N aborted: [<kind>] Call Nr <X> errored [<code>]`, naming the failing call and its error code (or `[E_OP_ABORTED] Batch N aborted.` when the failing error carries no code). Anchor capacity is preflighted before writing; if anchor finalization fails after the write, the error states the file was written with one undo available. Verify each batch diff before the next turn's edits on that file.
321
+ A call whose anchors resolve nowhere never joins a batch: it runs on its own and fails with its own error (`[E_STALE_ANCHOR]`, or `[E_BAD_SHAPE]` when its request cannot be parsed), while the same-file batch in the message still commits. Calls with one stale anchor and a valid co-anchor, or with a `requirePath` path hint, are grouped into their file's batch; when their own request then fails validation against the pre-batch state, they are left out of the batch and fail on their own instead of applying partially. A `copy` or `move` whose source anchors no longer resolve cannot be grouped, because the source file cannot be identified; it runs on its own and fails with its own error. A cross-file `move` whose source file gets other edits in the message is also left out of the batch and commits on its own; same-message edits to its destination file can then either abort or make the move fail. An error that aborts a batch ends with `Aborts batch N.`; an aborted call reads `[E_OP_ABORTED] Batch N aborted: [<kind>] Call Nr <X> errored [<code>]`, naming the failing call and its error code (or `[E_OP_ABORTED] Batch N aborted.` when the failing error carries no code). Anchor capacity is preflighted before writing; if anchor finalization fails after the write, the error states the file was written with one undo available. Verify each batch diff before the next turn's edits on that file.
326
322
 
327
323
  The hashline tools are sequential in pi, so a message that contains one runs all of its tool calls one at a time in the order given; a `read` or shell `cat` issued before the edit commits can still observe the pre-commit state, so verify in the next message with the post-edit diff or a fresh `read`.
328
324
 
@@ -385,7 +381,7 @@ Settings live in `~/.config/pi-hashline-edit-pro/config.json`, created when a se
385
381
  | `copyMoveEnabled` | Copy/move | `true` | Offer the `copy` and `move` tools; when off, both are removed from the active tools. |
386
382
  | `replaceMatchEnabled` | Replace match | `true` | Offer the `replace_match` tool; when off, it is removed from the active tools. |
387
383
  | `requirePath` | Require path | `false` | `replace`, `replace_match`, `insert`, `copy`, and `move` require a `path` argument that must match anchor ownership. |
388
- | `strictInput` | Strict input | `false` | Reject auto-fixable slips (`[W_BAD_SHAPE]`, `[W_BAD_REF]`, `[W_INVALID_PATCH]`, `[W_BARE_HASH_PREFIX]`) with `[E_BAD_SHAPE]` instead of applying them with a warning. |
384
+ | `strictInput` | Strict input | `false` | Reject auto-fixable slips (`[W_BAD_SHAPE]`, `[W_BAD_REF]`, `[W_INVALID_PATCH]`, `[W_BARE_HASH_PREFIX]`, `[W_ANCHOR_CASE]`) with `[E_BAD_SHAPE]` instead of applying them with a warning. |
389
385
  | `diffContextLines` | Diff context | `1` | Surrounding lines in post-edit diffs, 0-10 (needs Auto-read). |
390
386
  | `disableOnModels` | Disable on models | `[]` | Model globs matched case-insensitively against `provider/id`, the bare `id`, and `api`, with `*` and `?` wildcards. A matching model gets no anchored edit tools, no auto-read, and no write-echo refusal; `read` stays available as pi's ordinary read unless Read on disabled models is `remove`. |
391
387
  | `readOnDisabledModels` | Read on disabled models | `"vanilla"` | `read` for a model matched by `disableOnModels`: `"vanilla"` keeps pi's ordinary `read` active while the anchored edit tools stay off; `"remove"` disables `read` with the rest of the anchored tools. |
@@ -445,7 +441,7 @@ Codes starting with `E_` are errors: nothing was written, with one exception. `F
445
441
 
446
442
  Most common, with the fix:
447
443
 
448
- - `[E_STALE_ANCHOR]`: the anchor is not owned in this session. Call `read` for fresh anchors and retry.
444
+ - `[E_STALE_ANCHOR]`: the anchor is stale: it was never served in this session, or its line was edited or its anchors were freed since. Call `read` for fresh anchors and retry.
449
445
  - `[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.
450
446
  - `[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.
451
447
  - `[E_STORE_UNAVAILABLE]`: no SQLite runtime. Run pi under Node 22.19+ or a Bun build that ships `bun:sqlite`.
@@ -457,22 +453,23 @@ Full reference:
457
453
  | Code | Meaning |
458
454
  | --- | --- |
459
455
  | `[E_CONFIG]` | `PI_HASHLINE_DIR` is nonempty but not an absolute path. |
460
- | `[E_BAD_SHAPE]` | Request envelope or edit item has unknown, missing, or wrongly-typed fields (for example `text` must be a string holding the exact text), content contains a NUL byte (`U+0000`), which would make the file binary, a grep `glob` has invalid bracket or brace syntax, or a `replace_match` string spans lines: `match`/`replacement` contains a line break after the optional trailing one. |
456
+ | `[E_BAD_SHAPE]` | Request envelope or edit item has unknown, missing, or wrongly-typed fields (for example `text` must be a string holding the exact text), content contains a NUL byte (`U+0000`), which would make the file binary, a grep `glob` has invalid bracket or brace syntax, or a `replace_match` `match` spans lines: `match` contains a line break after the optional trailing one. |
461
457
  | `[W_BAD_SHAPE]` | Auto-corrected request slip reported as a warning (for example legacy array text that could not be parsed and was kept as one literal line). |
462
- | `[E_BAD_REF]` | An anchor in `remove_from`/`remove_to` is not a bare 4-character anchor (the anchor table is letters only). |
458
+ | `[E_BAD_REF]` | An anchor field does not yield a 5-character anchor. Anchors come from read or grep output and are exactly 5 lowercase letters; a pasted served row in an anchor field is reduced to its anchor first. |
463
459
  | `[E_SUBSTRING_NOT_FOUND]` | `replace_match` did not find `match` in the selected range. The current `anchor│content` rows are returned, and when `match` looks like a retyped copy of a single-line range the message names the first mismatching column; copy only the fragment that changes from the served row and retry, or use `replace` for a whole-line rewrite. |
464
- | `[W_BAD_REF]` | A pasted `anchor│` or diff-preview marker was stripped from an anchor field with a warning. |
465
- | `[E_STALE_ANCHOR]` | An anchor is not owned in this session (it was never shown to you, or its line was edited or the file was rewritten); call `read` for fresh anchors. |
460
+ | `[W_BAD_REF]` | A pasted served row (bare `anchor│content`, a line-numbered grep row, or an `anchor (line N)` suffix) or diff-preview marker was stripped from an anchor field with a warning. |
461
+ | `[W_ANCHOR_CASE]` | An edit anchor field used uppercase or mixed case; it was lowercased to the lowercase anchor with a warning (strict input rejects it). `read`'s `offset` lowercases a case-only mismatch silently. |
462
+ | `[E_STALE_ANCHOR]` | An anchor is stale: it was never served in this session, or its line was edited, the file was rewritten, or its anchors were freed since; the message names the case. Call `read` for fresh anchors. |
466
463
  | `[W_INVALID_PATCH]` | A `text` line is a diff-preview row (`+anchor│`, `-anchor│`, `- │`). The marker is stripped automatically with a warning. |
467
464
  | `[W_BARE_HASH_PREFIX]` | A `text` line starts with an `anchor│` prefix. The prefix is stripped automatically with a warning. |
468
465
  | `[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. |
469
- | `[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 escapes in the tool call are decoded once when the call is parsed (`\uXXXX` → the character), so a doubled escape (`\\uXXXX`) lands literally. The hint is one line: `text: "\u200b" written as literal text (Kq3f│ col 31).` names the field, the escape, and up to three affected anchors with their columns; when more than three rows carry it, it gives `on 10 rows; undo_last_change if unintended.` instead of the anchor list. |
470
- | `[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 live anchor of the offending result row, when one can be identified, and the character as its JSON escape, for example `[H_UNICODE_LOST] Zbbb│ missing "\u2060"; Kq3f│ has it at col 31.` The column is attributed to the row that had the character: `; Kq3f│ has it at col N` while that row's anchor is still valid, otherwise `; the replaced row had it at col N`, and a dead anchor is never named. When another look-alike character (including `U+FFFD`) takes its place, the hint reports the substitute with `[H_UNICODE_SWAPPED]` wording. |
471
- | `[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 the live anchor of the offending result row with both characters as JSON escapes, for example `[H_UNICODE_SWAPPED] Zbbb│ has "\u200d" at col 21; expected "\u200b".` A surviving reference row appears as `; Kq3f│ has "\u200b"`; a replaced row's dead anchor is never named. The substitute may also be `U+FFFD`, for example where a row has `U+00A0`. |
472
- | `[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.` |
473
- | `[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. |
474
- | `[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.` |
475
- | `[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│.` |
466
+ | `[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 escapes in the tool call are decoded once when the call is parsed (`\uXXXX` → the character), so a doubled escape (`\\uXXXX`) lands literally. The hint is one line: `text: "\u200b" written as literal text (kqafq│ col 31).` names the field, the escape, and up to three affected anchors with their columns; when more than three rows carry it, it gives `on 10 rows; undo_last_change if unintended.` instead of the anchor list. |
467
+ | `[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 live anchor of the offending result row, when one can be identified, and the character as its JSON escape, for example `[H_UNICODE_LOST] zbbbx│ missing "\u2060"; kqafq│ has it at col 31.` The column is attributed to the row that had the character: `; kqafq│ has it at col N` while that row's anchor is still valid, otherwise `; the replaced row had it at col N`, and a dead anchor is never named. When another look-alike character (including `U+FFFD`) takes its place, the hint reports the substitute with `[H_UNICODE_SWAPPED]` wording. |
468
+ | `[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 the live anchor of the offending result row with both characters as JSON escapes, for example `[H_UNICODE_SWAPPED] zbbbx│ has "\u200d" at col 21; expected "\u200b".` A surviving reference row appears as `; kqafq│ has "\u200b"`; a replaced row's dead anchor is never named. The substitute may also be `U+FFFD`, for example where a row has `U+00A0`. |
469
+ | `[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; kqafq│ had 0.` |
470
+ | `[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; kqafq│ has 2.` Copied or moved blocks are not checked, because their indentation comes from the source lines. |
471
+ | `[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 kqafq│ was displaced; add a blank line before kqafq│ if unintended.` |
472
+ | `[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 aaaaz│ and ddddz│.` |
476
473
  | `[H_RANGE_REVERSED]` | The `remove_from` anchor resolved to a line after `remove_to`. The call applied the range in document order; the one-line hint names the line range used. |
477
474
  | `[E_NOT_FOUND]` | The path does not exist. |
478
475
  | `[E_ACCESS]` | The file is not readable or writable. |
@@ -488,12 +485,12 @@ Full reference:
488
485
  | `[E_PATH_CHANGED]` | A write target changed identity after it was read; the write was refused to avoid following a swapped symlink or overwriting a replacement file. |
489
486
  | `[E_BATCH_OVERLAP]` | Batched edit calls target overlapping ranges; the whole batch was refused. One `before` plus one `after` insert on the same anchor line is not an overlap. Retry with disjoint ranges. |
490
487
  | `[E_OP_ABORTED]` | The edit was aborted before writing: the file changed or was deleted after it was read (a single edit or a cross-file move reports the file; a batch reports the batch and the failing call), the base read failed, a cross-file member could not be prepared, or the composed result exceeded a limit. Nothing was written; a call whose own request is invalid is left out of the batch instead. Fix the cause and retry, otherwise call `read` for fresh anchors and retry. The batch abort names the failing call and its error code when one is known. |
491
- | `[E_GREP_FAILED]` | `grep` could not start ripgrep or ripgrep exited with an error (for example a pattern with backreferences or look-around, which ripgrep's regex engine does not support); the message carries ripgrep's output. Retry with `literal: true` or simplify the pattern. |
488
+ | `[E_GREP_FAILED]` | `grep` could not start ripgrep or ripgrep exited with an error (for example a pattern with backreferences, look-around, or invalid regex syntax, which ripgrep's regex engine does not support); the message carries ripgrep's output. Retry with `literal: true` or simplify the pattern. |
492
489
  | `[E_GREP_TIMEOUT]` | `grep` timed out after 10 seconds; narrow `path` or simplify `pattern` and retry. |
493
490
 
494
491
  ## Troubleshooting
495
492
 
496
- - 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.
493
+ - Stale anchors. `[E_STALE_ANCHOR]` means an anchor no longer addresses a line: it was never served in this session, or its line was edited, the file was rewritten, or its anchors were freed since. The message names the case. Call `read` for fresh anchors and retry.
497
494
  - 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`.
498
495
  - 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.
499
496
  - 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.
@@ -544,7 +541,7 @@ edit call ──► resolve anchors ──► apply ──► commit ──► p
544
541
 
545
542
  ### Allocation
546
543
 
547
- Anchors are allocated, never derived. Every line that is served to you, by `read`, `grep`, the auto-read block after `write`, or a post-edit diff, gets the next free anchor from the session's pool, claimed by walking the table with a stride of 836,286 entries (coprime to the 1,353,139-entry table), so consecutively minted anchors land in unrelated regions of the table instead of sharing leading characters. Each session seeds its walk from its own offset (derived from the session key and the process id), so concurrent sessions mint different sequences instead of identical ones: an anchor minted in one session is unknown in another and is rejected with `[E_STALE_ANCHOR]` rather than resolving to a different file. Ownership is exclusive: an anchor is owned by one file's line until it is freed (the line was edited, the file was written or deleted, you ran `/clear-anchors`, or the session's quota ran out and the file was the least recently read or edited, which frees all of its anchors and reports it in `[W_ANCHOR_RECLAIMED]`). Minting prefers anchors the session has never used; when a bounded fresh-anchor probe finds nothing, freed anchors are recycled after their stale served records are purged, so an anchor is never shared by two live lines. Because ownership is exclusive, an anchor resolves to exactly one file. Two byte-identical lines never share an anchor, and that guarantee sets the file size cap: the pool is the shipped table's 1,353,139 entries (not all 52⁴ letter combinations), so a file can hold at most 1,353,139 lines, beyond which `read`, `replace`, `replace_match`, `insert`, `copy`, and `move` reject with `[E_FILE_TOO_LARGE]` (use `write` for very large files).
544
+ Anchors are allocated, never derived. Every line that is served to you, by `read`, `grep`, the auto-read block after `write`, or a post-edit diff, gets the next free anchor from the session's pool, claimed by walking the table with a stride of 836,286 entries (coprime to the 1,353,139-entry table), so consecutively minted anchors land in unrelated regions of the table instead of sharing leading characters. Each session seeds its walk from its own offset (derived from the session key and the process id), so concurrent sessions mint different sequences instead of identical ones: an anchor minted in one session is unknown in another and is rejected with `[E_STALE_ANCHOR]` rather than resolving to a different file. Ownership is exclusive: an anchor is owned by one file's line until it is freed (the line was edited, the file was written or deleted, you ran `/clear-anchors`, or the session's quota ran out and the file was the least recently read or edited, which frees all of its anchors and reports it in `[W_ANCHOR_RECLAIMED]`). Minting prefers anchors the session has never used; when a bounded fresh-anchor probe finds nothing, freed anchors are recycled after their stale served records are purged, so an anchor is never shared by two live lines. Because ownership is exclusive, an anchor resolves to exactly one file. Two byte-identical lines never share an anchor, and that guarantee sets the file size cap: the pool is the shipped table's 1,353,139 entries (not all 26⁵ letter combinations), so a file can hold at most 1,353,139 lines, beyond which `read`, `replace`, `replace_match`, `insert`, `copy`, and `move` reject with `[E_FILE_TOO_LARGE]` (use `write` for very large files).
548
545
 
549
546
  ### Ownership and mapping across edits
550
547
 
@@ -559,7 +556,7 @@ A no-op replace never changes the file, so anchors remain valid. On first run af
559
556
 
560
557
  ### The anchor table is built for tokenizers
561
558
 
562
- Every anchor is the concatenation of two 2-character pieces that each encode as a single token, and beside the `│` separator the whole 5-character `anchor│` unit is verified to tokenize as exactly three tokens in each of eight modern open-weights tokenizers (Qwen 3.5, DeepSeek V4, Gemma 4, GLM 5.3 Flash, Tencent Hy4-preview, MiniMax M3, MiMo V2.5, Kimi K3). The shipped table is the intersection that satisfies the criterion on all of them; Nemotron 3 Ultra is the one modern tokenizer excluded. Model names are as published for the 4.4.x table build. An anchor therefore costs 2 tokens on a read row and 2 in an edit call, with the `│` separator as the third. Anchors are letters only. The table is shipped as `src/hashline/anchor-table.json`.
559
+ Every anchor is a 5-letter lowercase string selected so that the anchor alone tokenizes as at most two tokens and the whole `anchor│` unit as at most three tokens in each of nine modern open-weights tokenizers (DeepSeek V4 and V4.1, Qwen 3.5, Gemma 4, GLM 5.3 Flash, Tencent Hy4-preview, MiniMax M3, MiMo V2.5, Kimi K3). The shipped table is the intersection that satisfies the criterion on all of them; it also has one anchor per case-fold class, so no two anchors differ only by case. Nemotron 3 Ultra is the one modern tokenizer excluded. An anchor therefore costs at most 2 tokens, and `anchor│` at most 3, in both read rows and edit calls. Anchors are lowercase letters only. The table is generated offline and its 1,353,139-entry count is frozen so the per-file line cap stays stable; it is shipped as `src/hashline/anchor-table.json`.
563
560
 
564
561
  ### Line checksums
565
562
 
@@ -569,6 +566,8 @@ Each line also carries a content checksum. The line is canonicalized (carriage r
569
566
 
570
567
  Allocated anchors live in a persistent per-file snapshot (`~/.config/pi-hashline-edit-pro/hash-store.sqlite`) keyed by content checksum, so resume-after-restart and cross-session edits reuse ownership instead of minting duplicates. Each session also appends an ownership log (`allocate`/`free`/`clear` events) to a sidecar file under `~/.config/pi-hashline-edit-pro/sessions/`; that log is the session's record, sidecars whose session file is gone are garbage-collected at startup (never the sidecar of a session that is currently loaded in this process), and the in-memory ownership of a session is released when that session shuts down and rebuilt from its sidecar on next use.
571
568
 
569
+ Upgrading from a 6.x release invalidates the old 4-character anchors: the hash store version changes, so snapshots and undo history are cleared, old sidecar anchors are ignored, and anchors are re-minted on the next read.
570
+
572
571
  ## Benchmark
573
572
 
574
573
  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).
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "pi-hashline-edit-pro",
3
- "version": "6.5.0",
3
+ "version": "7.0.0",
4
4
  "type": "module",
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.",
5
+ "description": "Hash-anchored read/replace/insert/grep tools for pi-coding-agent. Every line gets a unique 5-character lowercase 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",
7
7
  "repository": {
8
8
  "type": "git",
@@ -5,7 +5,7 @@ import { createHash } from "node:crypto";
5
5
  import { AsyncLocalStorage } from "node:async_hooks";
6
6
  import { sessionClaimsDir } from "./paths";
7
7
  import { contentChecksum } from "./hashline/hasher";
8
- import { ANCHOR_COUNT, anchorAt } from "./hashline/alphabet";
8
+ import { ANCHOR_COUNT, HASH_RE, anchorAt } from "./hashline/alphabet";
9
9
  import { HASH_PROBE_STRIDE, MAX_HASH_LINES } from "./hashline/hash";
10
10
  import { assertLineCount, errCode, isRec, splitLines, spliceAll } from "./utils";
11
11
  import * as Diff from "diff";
@@ -249,8 +249,12 @@ export function foldRegistryEvents(events: RegistryEvent[], seed?: string): Sess
249
249
  return state;
250
250
  }
251
251
 
252
- function isStringArray(value: unknown): value is string[] {
253
- return Array.isArray(value) && value.every((entry) => typeof entry === "string");
252
+ function isAnchor(value: unknown): value is string {
253
+ return typeof value === "string" && HASH_RE.test(value);
254
+ }
255
+
256
+ function isAnchorList(value: unknown): value is string[] {
257
+ return Array.isArray(value) && value.every(isAnchor);
254
258
  }
255
259
 
256
260
  function isRegistryEvent(value: unknown): value is RegistryEvent {
@@ -260,10 +264,10 @@ function isRegistryEvent(value: unknown): value is RegistryEvent {
260
264
  if (value.kind === "allocate") {
261
265
  return typeof value.path === "string"
262
266
  && Array.isArray(value.rows)
263
- && value.rows.every((row) => Array.isArray(row) && row.length === 2 && typeof row[0] === "string" && typeof row[1] === "string");
267
+ && value.rows.every((row) => Array.isArray(row) && row.length === 2 && isAnchor(row[0]) && typeof row[1] === "string");
264
268
  }
265
- if (value.kind === "free") return typeof value.path === "string" && (value.anchors === undefined || isStringArray(value.anchors));
266
- if (value.kind === "minted") return isStringArray(value.anchors);
269
+ if (value.kind === "free") return typeof value.path === "string" && (value.anchors === undefined || isAnchorList(value.anchors));
270
+ if (value.kind === "minted") return isAnchorList(value.anchors);
267
271
  return false;
268
272
  }
269
273
 
@@ -386,7 +390,8 @@ async function loadRegistryState(key: string, sessionFile: string, token: object
386
390
  registries.set(key, folded);
387
391
  sidecarByKey.set(key, sidecar);
388
392
  if (rawLog.length > 0) {
389
- await compactSidecarIfNeeded(sidecar, rawLog, sessionFile, folded, firstLineSessionFile(rawLog) !== sessionFile);
393
+ const loggedEvents = rawLog.split("\n").filter((line) => line.trim().length > 0).length;
394
+ await compactSidecarIfNeeded(sidecar, rawLog, sessionFile, folded, loggedEvents > events.length || firstLineSessionFile(rawLog) !== sessionFile);
390
395
  }
391
396
  try {
392
397
  await mkdir(sessionClaimsDir(), { recursive: true, mode: 0o700 });
@@ -709,22 +714,9 @@ export function ownerOf(anchor: string): OwnedAnchor | undefined {
709
714
  return current()?.owned.get(anchor);
710
715
  }
711
716
 
712
- export interface CaseFoldedOwner {
713
- anchor: string;
714
- path: string;
715
- }
716
-
717
- export function ownersDifferingOnlyByCase(anchor: string, paths?: ReadonlySet<string>): CaseFoldedOwner[] {
717
+ export function anchorWasMinted(anchor: string): boolean {
718
718
  const state = current();
719
- if (!state) return [];
720
- const lower = anchor.toLowerCase();
721
- const matches: CaseFoldedOwner[] = [];
722
- for (const [owned, entry] of state.owned) {
723
- if (owned === anchor || owned.toLowerCase() !== lower) continue;
724
- if (paths && paths.size > 0 && !paths.has(entry.path)) continue;
725
- matches.push({ anchor: owned, path: entry.path });
726
- }
727
- return matches;
719
+ return state !== undefined && state.everMinted.has(anchor);
728
720
  }
729
721
 
730
722
  export function ownersForPath(path: string): Map<string, string> {
@@ -1058,6 +1050,7 @@ export function adoptAnchors(path: string, entries: Map<string, string>): void {
1058
1050
  }
1059
1051
  const adopted: Array<[string, string]> = [];
1060
1052
  for (const [anchor, checksum] of entries) {
1053
+ if (!HASH_RE.test(anchor)) continue;
1061
1054
  const existing = state.owned.get(anchor);
1062
1055
  if (existing && existing.path !== path) continue;
1063
1056
  state.owned.set(anchor, { path, checksum });
@@ -28,10 +28,10 @@ const SCAN_LIMIT_MULTIPLIER = 4;
28
28
  const MAX_REPORTED_OMISSIONS = 50;
29
29
 
30
30
  const HEADER =
31
- "[hashline auto-read-all] Every non-ignored project file is attached below as `=== path ===` then `anchor│content` rows with live anchors.\nEdit directly from the attachment with replace and insert; do not call read for attached files.\nFiles listed as not attached can be read normally.\nAnchors are case-sensitive and stay valid until their line is edited.";
31
+ "[hashline auto-read-all] Every non-ignored project file is attached below as `=== path ===` then `anchor│content` rows with live anchors.\nEdit directly from the attachment with replace and insert; do not call read for attached files.\nFiles listed as not attached can be read normally.\nAnchors are lowercase and stay valid until their line is edited.";
32
32
 
33
33
  const OUTLINE_HEADER =
34
- "[hashline auto-read-all] Every non-ignored project file is outlined below as `=== path (language) — lines ===` then `anchor│...` rows with live anchors: multi-line symbols show a label with `[limit N]`, the span to request when reading from the anchor, and single-line symbols show the line content.\nEdit a row's anchor with replace or insert, or read from it with `offset` set to that anchor (and the row's `limit` when it shows one); do not call read for attached files.\nFiles listed as not attached can be read normally.\nAnchors are case-sensitive and stay valid until their line is edited.";
34
+ "[hashline auto-read-all] Every non-ignored project file is outlined below as `=== path (language) — lines ===` then `anchor│...` rows with live anchors: multi-line symbols show a label with `[limit N]`, the span to request when reading from the anchor, and single-line symbols show the line content.\nEdit a row's anchor with replace or insert, or read from it with `offset` set to that anchor (and the row's `limit` when it shows one); do not call read for attached files.\nFiles listed as not attached can be read normally.\nAnchors are lowercase and stay valid until their line is edited.";
35
35
 
36
36
  const IMAGE_EXTENSIONS = new Set([
37
37
  ".avif",
package/src/constants.ts CHANGED
@@ -12,7 +12,7 @@ export const TEXT_ENDING_NOTE =
12
12
  "A trailing line break sets the newline of the last line written instead of adding a blank line; a lone newline writes one blank line.";
13
13
 
14
14
  export const HASH_STORE_BUSY_TIMEOUT = 1000;
15
- export const HASH_STORE_VERSION = 9;
15
+ export const HASH_STORE_VERSION = 10;
16
16
  export const NEW_CONTENT_NOT_ARRAY_MSG =
17
17
  `[E_BAD_SHAPE] "text" must be an array of strings, one per line (use [] to delete).`;
18
18
 
package/src/copy-move.ts CHANGED
@@ -94,9 +94,9 @@ export function parseTransferAnchors(req: TransferReq): { refs: TransferRefs; wa
94
94
  const destination = stripAnchorRow(req.anchor.trim(), "anchor entry", warnings);
95
95
  return {
96
96
  refs: {
97
- sourceFrom: parseHashRef(from),
98
- sourceTo: parseHashRef(to),
99
- anchor: parseHashRef(destination),
97
+ sourceFrom: parseHashRef(from, warnings, "source_from"),
98
+ sourceTo: parseHashRef(to, warnings, "source_to"),
99
+ anchor: parseHashRef(destination, warnings, "anchor"),
100
100
  direction: req.direction,
101
101
  },
102
102
  warnings,
@@ -890,20 +890,21 @@ function getTransferInput(args: unknown): { path?: string; source_from?: string;
890
890
 
891
891
  const transferSourceFromSchema = Type.String({
892
892
  description:
893
- "4-char anchor of the FIRST source line to copy.",
893
+ "5-char anchor of the FIRST source line.",
894
894
  });
895
895
  const transferSourceToSchema = Type.String({
896
896
  description:
897
- "4-char anchor of the LAST source line to copy.",
897
+ "5-char anchor of the LAST source line.",
898
898
  });
899
899
  const transferAnchorSchema = Type.String({
900
900
  description:
901
- "4-char anchor of the destination line; the block lands before or after it and it may live in another file.",
901
+ "5-char anchor of the destination line; the block lands before or after it and it may live in another file.",
902
+ });
903
+ const transferDirectionSchema = Type.Unsafe<"after" | "before">({
904
+ type: "string",
905
+ enum: ["after", "before"],
906
+ description: '"after" places the block below the anchor line, "before" above it',
902
907
  });
903
- const transferDirectionSchema = Type.Union(
904
- [Type.Literal("after"), Type.Literal("before")],
905
- { description: '"after" places the block below the anchor line, "before" above it' },
906
- );
907
908
  const transferPathRequiredSchema = Type.String({
908
909
  description:
909
910
  "Path to the source or destination file the anchors were served for; required and must match anchor ownership.",
@@ -1,7 +1,7 @@
1
1
  import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
2
2
  import { resolveInCwd } from "./fs-write";
3
3
  import { abortIf, makePrepareArguments } from "./utils";
4
- import { ownerOf, ownersDifferingOnlyByCase, type OwnedAnchor } from "./anchor-registry";
4
+ import { anchorWasMinted, ownerOf } from "./anchor-registry";
5
5
  import { assertRangeServed, lineChecksum, parseHashRef, stripAnchorRow } from "./hashline";
6
6
  import { readConfig } from "./config";
7
7
  import { makeRenderCall, renderEditResult, type RPreview, type FgT } from "./replace-render";
@@ -80,7 +80,6 @@ const UNDO_WRITE_CLEARS_ID = "undo-write-clears";
80
80
  const UNDO_CROSS_FILE_MOVE_ID = "undo-cross-file-move";
81
81
  const NEUTRAL_READ_REREAD = "`read`: call again after an edit when you need anchors you lack.";
82
82
  const NEUTRAL_UNDO_WRITE_CLEARS = "`undo_last_change`: only the last `replace`/`replace_match`/`insert`/`copy`/`move` per file is undoable; a `write` clears it, so undo before any other edit or write.";
83
- const GREP_OPS_PHRASE = "replace, insert, copy, or move";
84
83
  const UNDO_OPS_PHRASE = "replace, replace_match, insert, copy, or move";
85
84
  const UNDO_GUIDELINE_OPS_PHRASE = "`replace`/`replace_match`/`insert`/`copy`/`move`";
86
85
 
@@ -92,12 +91,12 @@ function batchGuideline(flags: EditToolFlags): string {
92
91
  const tools = operationNames(SHARED_EDIT_OPS, flags);
93
92
  const outcome = flags.autoRead ? "diff" : "result";
94
93
  const script = flags.codemode ? ` ${SCRIPT_BATCH_GUIDELINE}` : "";
95
- return `${tools}: same-file calls in one message commit as one batch; earlier calls reply \`In batch N (queued)\`, the last shows the combined ${outcome}, and one undo reverts all. A request-invalid call (anchors, shape, or \`match\`) fails alone; a failure while the batch runs aborts it.${script}`;
94
+ return `${tools}: same-file calls in one message commit as one batch. If you already know all the edits and their anchors, send them together instead of one turn at a time; an edit that needs anchors from a previous result waits for the next turn. Earlier calls reply \`In batch N (queued)\`, the last shows the combined ${outcome}, and one undo reverts all. A request-invalid call (anchors, shape, or \`match\`) fails alone; a failure while the batch runs aborts it.${script}`;
96
95
  }
97
96
 
98
97
  function diffGuideline(flags: EditToolFlags): string {
99
98
  const tools = operationNames(SHARED_DIFF_OPS, flags);
100
- return `${tools}: in the post-edit diff, \`-anchor│\` rows are dead anchors; \`+anchor│\` and \` anchor│\` rows carry live anchors for the next edit.`;
99
+ return `${tools}: in the post-edit diff, \`-anchor│\` rows are dead anchors; \`+anchor│\` and \` anchor│\` rows carry live anchors for the next edit. Rows the diff does not show keep their anchors, so a follow-up edit on them needs no re-read.`;
101
100
  }
102
101
 
103
102
  function pathGuideline(flags: EditToolFlags): string {
@@ -181,9 +180,7 @@ function joinOps(ops: string[], options?: { backtick?: boolean; separator?: "/"
181
180
  export function withGrepPrompts(base: { description: string; snippet: string; guidelines: Guideline[] }, flags: EditToolFlags): { description: string; snippet: string; guidelines: string[] } {
182
181
  const guidelines = base.guidelines.map((guideline) => guideline.text);
183
182
  if (flags.codemode) guidelines.push(GREP_SCRIPT_GUIDELINE);
184
- if (flags.copyMoveEnabled) return { description: base.description, snippet: base.snippet, guidelines };
185
- const description = base.description.replaceAll(GREP_OPS_PHRASE, joinOps(gatedEditOps(["replace", "insert", "copy", "move"], flags)));
186
- return { description, snippet: base.snippet, guidelines };
183
+ return { description: base.description, snippet: base.snippet, guidelines };
187
184
  }
188
185
 
189
186
  export function withUndoPrompts(base: { description: string; snippet: string; guidelines: Guideline[] }, flags: EditToolFlags): { description: string; snippet: string; guidelines: string[] } {
@@ -209,17 +206,11 @@ export function withTransferPrompts(base: { description: string; snippet: string
209
206
  return { description: base.description, snippet: base.snippet, guidelines };
210
207
  }
211
208
 
212
- function staleAnchorMessage(ref: string, hash: string, owners: Array<OwnedAnchor | undefined>): string {
213
- const knownPaths = new Set<string>();
214
- for (const owner of owners) {
215
- if (owner) knownPaths.add(owner.path);
216
- }
217
- const folded = ownersDifferingOnlyByCase(hash, knownPaths);
218
- const hint =
219
- folded.length > 0
220
- ? ` Anchors are case-sensitive; ${folded.map((match) => `"${match.anchor}"`).join(", ")} differs only in case.`
221
- : "";
222
- return `[E_STALE_ANCHOR] "${ref}" is not owned in this session.${hint} Call read() on the target file first.`;
209
+ export function staleAnchorMessage(hash: string, next = "Call read() on the target file for fresh anchors."): string {
210
+ const state = anchorWasMinted(hash)
211
+ ? "is stale: it was valid earlier in this session, but its line was edited or its anchors were freed since."
212
+ : "was never served in this session, so it addresses no line here.";
213
+ return `[E_STALE_ANCHOR] "${hash}" ${state} ${next}`;
223
214
  }
224
215
 
225
216
  export function resolveEditTarget(removeFrom: string, removeTo?: string): string {
@@ -228,7 +219,7 @@ export function resolveEditTarget(removeFrom: string, removeTo?: string): string
228
219
  const owners = hashes.map((hash) => ownerOf(hash));
229
220
  const missing = owners.findIndex((owner) => !owner);
230
221
  if (missing >= 0) {
231
- throw new Error(staleAnchorMessage(refs[missing]!, hashes[missing]!, owners));
222
+ throw new Error(staleAnchorMessage(hashes[missing]!));
232
223
  }
233
224
  const paths = new Set(owners.map((owner) => owner!.path));
234
225
  if (paths.size > 1) {
@@ -297,7 +288,7 @@ export async function resolveEditTargetWithRequirement(input: PathRequirementInp
297
288
  return anchorTarget;
298
289
  }
299
290
 
300
- const STRICT_INPUT_REJECT_CODES = ["[W_BAD_SHAPE]", "[W_BAD_REF]", "[W_INVALID_PATCH]", "[W_BARE_HASH_PREFIX]"];
291
+ const STRICT_INPUT_REJECT_CODES = ["[W_BAD_SHAPE]", "[W_BAD_REF]", "[W_INVALID_PATCH]", "[W_BARE_HASH_PREFIX]", "[W_ANCHOR_CASE]"];
301
292
  export async function throwIfStrictInput(warnings: string[]): Promise<void> {
302
293
  const fixes = warnings.filter((warning) => STRICT_INPUT_REJECT_CODES.some((code) => warning.startsWith(code)));
303
294
  if (fixes.length === 0) return;