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 +59 -60
- package/package.json +2 -2
- package/src/anchor-registry.ts +15 -22
- package/src/auto-read-all.ts +2 -2
- package/src/constants.ts +1 -1
- package/src/copy-move.ts +11 -10
- package/src/edit-common.ts +11 -20
- package/src/edit-fidelity.ts +20 -1
- package/src/grep.ts +1 -10
- package/src/hashline/alphabet.ts +3 -3
- package/src/hashline/anchor-table.json +1 -1
- package/src/hashline/parse.ts +21 -14
- package/src/hashline/resolve.ts +23 -9
- package/src/insert.ts +9 -13
- package/src/payload-contract.ts +30 -22
- package/src/prompts.ts +1 -1
- package/src/read.ts +4 -6
- package/src/replace-diff.ts +1 -1
- package/src/replace-match.ts +36 -28
- package/src/replace-response.ts +5 -4
- package/src/replace.ts +12 -2
- package/src/write-hook.ts +4 -3
- package/tool-prompts/copy-guidelines.md +1 -1
- package/tool-prompts/copy.md +1 -1
- package/tool-prompts/grep-guidelines.md +1 -2
- package/tool-prompts/grep.md +1 -1
- package/tool-prompts/insert.md +1 -1
- package/tool-prompts/move.md +2 -2
- package/tool-prompts/read.md +1 -1
- package/tool-prompts/replace-match-guidelines.md +1 -1
- package/tool-prompts/replace-match-snippet.md +1 -1
- package/tool-prompts/replace-match.md +1 -1
- package/tool-prompts/replace.md +1 -1
- package/tool-prompts/transfer-guidelines.md +1 -1
package/README.md
CHANGED
|
@@ -4,20 +4,20 @@
|
|
|
4
4
|
|
|
5
5
|
[](https://www.npmjs.com/package/pi-hashline-edit-pro) [](https://www.npmjs.com/package/pi-hashline-edit-pro) [](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
|
|
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
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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": "
|
|
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
|
|
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
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
104
|
+
dafox│function hello() {
|
|
105
|
+
emnox│ console.log("world");
|
|
106
|
+
hdtmx│}
|
|
107
107
|
```
|
|
108
108
|
|
|
109
|
-
Then replace `
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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": "
|
|
132
|
-
"remove_to": "
|
|
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
|
-
-
|
|
142
|
-
+
|
|
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=
|
|
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` |
|
|
190
|
-
| `remove_to` |
|
|
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
|
-
`
|
|
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": "
|
|
200
|
-
"remove_to": "
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
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` |
|
|
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 `
|
|
238
|
+
Example: add a line after `emnox│`:
|
|
243
239
|
|
|
244
240
|
```json
|
|
245
|
-
{ "anchor": "
|
|
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` |
|
|
257
|
-
| `source_to` |
|
|
258
|
-
| `anchor` |
|
|
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 `
|
|
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": "
|
|
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
|
|
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
|
|
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
|
|
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`
|
|
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
|
|
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
|
|
465
|
-
| `[
|
|
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 (
|
|
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]
|
|
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]
|
|
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;
|
|
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;
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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": "
|
|
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
|
|
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",
|
package/src/anchor-registry.ts
CHANGED
|
@@ -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
|
|
253
|
-
return
|
|
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 &&
|
|
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 ||
|
|
266
|
-
if (value.kind === "minted") return
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 });
|
package/src/auto-read-all.ts
CHANGED
|
@@ -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
|
|
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
|
|
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 =
|
|
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
|
-
"
|
|
893
|
+
"5-char anchor of the FIRST source line.",
|
|
894
894
|
});
|
|
895
895
|
const transferSourceToSchema = Type.String({
|
|
896
896
|
description:
|
|
897
|
-
"
|
|
897
|
+
"5-char anchor of the LAST source line.",
|
|
898
898
|
});
|
|
899
899
|
const transferAnchorSchema = Type.String({
|
|
900
900
|
description:
|
|
901
|
-
"
|
|
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.",
|
package/src/edit-common.ts
CHANGED
|
@@ -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 {
|
|
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;
|
|
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
|
-
|
|
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(
|
|
213
|
-
const
|
|
214
|
-
|
|
215
|
-
|
|
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(
|
|
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;
|