pi-hashline-edit-pro 5.0.0 → 6.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 +60 -61
- package/index.ts +10 -10
- package/package.json +1 -1
- package/prompts/copy.md +2 -0
- package/prompts/insert-guidelines.md +1 -1
- package/prompts/insert-snippet.md +1 -1
- package/prompts/insert.md +2 -2
- package/prompts/move-guidelines.md +0 -1
- package/prompts/move.md +2 -0
- package/prompts/read-guidelines.md +0 -2
- package/prompts/replace-guidelines.md +4 -2
- package/prompts/replace-match-guidelines.md +1 -0
- package/prompts/replace-match-snippet.md +1 -0
- package/prompts/replace-match.md +7 -0
- package/prompts/replace-snippet.md +1 -1
- package/prompts/replace.md +4 -4
- package/prompts/transfer-guidelines.md +2 -0
- package/prompts/undo-last-change-guidelines.md +1 -1
- package/prompts/undo-last-change-snippet.md +1 -1
- package/prompts/undo-last-change.md +1 -1
- package/src/batch.ts +323 -66
- package/src/commit.ts +4 -2
- package/src/config-ui.ts +2 -2
- package/src/config.ts +6 -6
- package/src/constants.ts +5 -5
- package/src/copy-move.ts +305 -97
- package/src/edit-common.ts +85 -65
- package/src/edit-fidelity.ts +440 -35
- package/src/glob.ts +2 -20
- package/src/grep.ts +2 -9
- package/src/hash-store.ts +0 -3
- package/src/hashline/apply.ts +0 -9
- package/src/hashline/index.ts +1 -1
- package/src/hashline/parse.ts +1 -1
- package/src/hashline/resolve.ts +38 -16
- package/src/insert.ts +13 -15
- package/src/payload-contract.ts +60 -87
- package/src/prompts.ts +12 -1
- package/src/replace-diff.ts +1 -5
- package/src/replace-match.ts +259 -0
- package/src/replace-render.ts +1 -1
- package/src/replace-response.ts +14 -7
- package/src/replace.ts +16 -13
- package/src/utils.ts +16 -32
- package/src/write-hook.ts +3 -5
- package/prompts/replace-within-guidelines.md +0 -3
- package/prompts/replace-within-snippet.md +0 -1
- package/prompts/replace-within.md +0 -5
- package/src/replace-within.ts +0 -227
package/README.md
CHANGED
|
@@ -17,7 +17,7 @@ HDtm│}
|
|
|
17
17
|
|
|
18
18
|
replace one line by its anchor:
|
|
19
19
|
|
|
20
|
-
{ "remove_from": "Emno", "remove_to": "Emno", "
|
|
20
|
+
{ "remove_from": "Emno", "remove_to": "Emno", "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
|
```
|
|
@@ -30,7 +30,7 @@ the result is the post-edit diff with fresh anchors, so the next edit needs no r
|
|
|
30
30
|
- [Tools](#tools)
|
|
31
31
|
- [read](#read)
|
|
32
32
|
- [replace](#replace)
|
|
33
|
-
- [
|
|
33
|
+
- [replace_match](#replace_match)
|
|
34
34
|
- [insert](#insert)
|
|
35
35
|
- [copy](#copy)
|
|
36
36
|
- [move](#move)
|
|
@@ -90,7 +90,7 @@ pi install /path/to/pi-hashline-edit-pro
|
|
|
90
90
|
| `edit` | disabled |
|
|
91
91
|
| `grep` | disabled while `anchor_grep` is enabled |
|
|
92
92
|
| `copy`, `move` | disabled while Copy/move is off |
|
|
93
|
-
| `
|
|
93
|
+
| `replace_match` | disabled while Replace match is off |
|
|
94
94
|
| `write` | kept; an auto-read block with fresh anchors is appended to its result |
|
|
95
95
|
| `bash` | untouched |
|
|
96
96
|
|
|
@@ -130,7 +130,7 @@ pi uninstall npm:pi-hashline-edit-pro
|
|
|
130
130
|
{
|
|
131
131
|
"remove_from": "Emno",
|
|
132
132
|
"remove_to": "Emno",
|
|
133
|
-
"
|
|
133
|
+
"text": " console.log('hi');"
|
|
134
134
|
}
|
|
135
135
|
```
|
|
136
136
|
|
|
@@ -155,7 +155,7 @@ Nothing commits until an edit call returns: the extension validates the request
|
|
|
155
155
|
|
|
156
156
|
## Tools
|
|
157
157
|
|
|
158
|
-
The extension registers eight tools: `read`, `replace`, `
|
|
158
|
+
The extension registers eight tools: `read`, `replace`, `replace_match`, `insert`, `copy`, `move`, `anchor_grep`, and `undo_last_change`. The built-in `edit` tool is disabled. `copy` and `move` are enabled by default; turn Copy/move off in `/hashline-config` to remove both. `replace_match` is enabled by default; turn Replace match off in `/hashline-config` to remove it. `replace`, `replace_match`, `insert`, `copy`, and `move` take no `path` parameter by default: the file is resolved from the anchors' session ownership alone, so an edit can only land on the file the anchors were served for. Opt in with `/hashline-config` to require `path` in `replace`, `replace_match`, `insert`, `copy`, and `move` for RPC visibility (for example pimacs.el); anchors still resolve the target and `path` must match.
|
|
159
159
|
|
|
160
160
|
### read
|
|
161
161
|
|
|
@@ -188,7 +188,7 @@ Edge cases:
|
|
|
188
188
|
| --- | --- |
|
|
189
189
|
| `remove_from` | 4-char anchor marking the FIRST line to remove (inclusive). |
|
|
190
190
|
| `remove_to` | 4-char anchor marking the LAST line to remove (inclusive). |
|
|
191
|
-
| `
|
|
191
|
+
| `text` | The exact text to write in place of the removed range, as one string: `""` deletes the range, `"\n"` is one blank line, and a trailing line break sets the last line's ending instead of adding a blank line. Embedded `\r\n`/`\r`/`\n` are preserved; JSON decoding happens once, before the tool; the tool writes the string it receives and never decodes — `\uXXXX` is the character, `\\uXXXX` the literal text. Legacy arrays are converted to text (elements joined with LF); prefer the string form. |
|
|
192
192
|
|
|
193
193
|
Example: read showed `Hasu│old` and `arvm│old2`; to replace both:
|
|
194
194
|
|
|
@@ -196,31 +196,32 @@ Example: read showed `Hasu│old` and `arvm│old2`; to replace both:
|
|
|
196
196
|
{
|
|
197
197
|
"remove_from": "Hasu",
|
|
198
198
|
"remove_to": "arvm",
|
|
199
|
-
"
|
|
199
|
+
"text": "new line 1\nnew line 2"
|
|
200
200
|
}
|
|
201
201
|
```
|
|
202
202
|
|
|
203
|
-
Single line: use the same anchor for `remove_from` and `remove_to`.
|
|
203
|
+
Single line: use the same anchor for `remove_from` and `remove_to`.
|
|
204
|
+
A deletion keeps blank lines at the edges of the removed range, so the separators around a block survive the edit; target a blank line on its own to delete it. Deleting every line empties the file; the result names the new empty-line anchor, so a follow-up `replace` on it can seed content without a `read`.
|
|
204
205
|
|
|
205
206
|
The extension checks the request before any file I/O, so a bad request never touches the file.
|
|
206
207
|
|
|
207
|
-
Auto-fixable slips fall into two groups. Fixed silently: a reversed range, 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: a leftover `anchor│` prefix in `
|
|
208
|
+
Auto-fixable slips fall into two groups. Fixed silently: a reversed range, 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: 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.
|
|
208
209
|
|
|
209
|
-
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 `
|
|
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`.
|
|
210
211
|
|
|
211
|
-
Every line in the removed range must match what was last shown to you. The extension records the `anchor│content` rows it serves (`read` output, `anchor_grep` output, the auto-read block after `write`, the `+anchor│` and ` anchor│` rows of post-edit diffs, the current-range rows of `[E_RANGE_STALE]` feedback, and the context rows of stale-anchor feedback) and verifies the whole range against that record before writing. A line that changed on disk since it was shown, or an anchor that is not owned in this session, refuses the edit with `[E_RANGE_STALE]` or `[E_STALE_ANCHOR]` and returns the current range with fresh anchors, so the retry needs no `read`. An owned anchor enters the served record when its row is shown (after a restart, restored ownership counts as shown), so a file with no owned anchors cannot be edited by anchor at all; call `read` first. An owned line that was never shown, for example beyond an auto-read preview's truncation cap, is refused with `[E_RANGE_STALE]` and returns the current range, so the retry still needs no `read
|
|
212
|
+
Every line in the removed range must match what was last shown to you, except that a pure deletion (an empty replacement) verifies only the first and last line of the range and removes the interior as it currently stands. The extension records the `anchor│content` rows it serves (`read` output, `anchor_grep` output, the auto-read block after `write`, the `+anchor│` and ` anchor│` rows of post-edit diffs, the current-range rows of `[E_RANGE_STALE]` feedback, and the context rows of stale-anchor feedback) and verifies the whole range against that record before writing. A line that changed on disk since it was shown, or an anchor that is not owned in this session, refuses the edit with `[E_RANGE_STALE]` or `[E_STALE_ANCHOR]` and returns the current range with fresh anchors, so the retry needs no `read`. An owned anchor enters the served record when its row is shown (after a restart, restored ownership counts as shown), so a file with no owned anchors cannot be edited by anchor at all; call `read` first. An owned line that was never shown, for example beyond an auto-read preview's truncation cap, is refused with `[E_RANGE_STALE]` and returns the current range, so the retry still needs no `read`; only lines strictly between the boundaries of a pure deletion are exempt.
|
|
212
213
|
|
|
213
214
|
An edit that changes neither content nor line endings reports `No changes made` and leaves the anchors alone.
|
|
214
215
|
|
|
215
216
|
After a successful edit, the diff is capped at 50KB. A row over 50KB is shown as a marker that keeps the row's anchor, and only the rows shown in the capped diff are recorded as served. The same caps apply to the `insert` and `undo_last_change` diffs, to the interactive previews, and to `details.patch`.
|
|
216
217
|
|
|
217
|
-
###
|
|
218
|
+
### replace_match
|
|
218
219
|
|
|
219
|
-
`
|
|
220
|
+
`replace_match` changes part of a line (or a range of lines) without retyping the rest. `replace_from` and `replace_to` are bare anchors marking the first and last line of the range; use the same anchor for a single line. `old_string` is the exact text to find inside that range, and `new_string` replaces every occurrence of it; every other character stays untouched. 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 line, 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.
|
|
220
221
|
|
|
221
|
-
`
|
|
222
|
+
`old_string` is matched against the range's text (LF line breaks, no final terminator) and every non-overlapping occurrence is replaced, left to right. 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 matched against the file as it currently stands on disk.
|
|
222
223
|
|
|
223
|
-
|
|
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 `old_string` aborts the whole batch unwritten. The post-edit diff carries fresh anchors, and the edit is undoable with `undo_last_change`.
|
|
224
225
|
|
|
225
226
|
### insert
|
|
226
227
|
|
|
@@ -230,21 +231,21 @@ A `replace_within` call is never grouped into a batch; it commits on its own lik
|
|
|
230
231
|
| --- | --- |
|
|
231
232
|
| `anchor` | 4-char anchor marking the line next to which the lines go. The anchor line is preserved. A pasted `+Hasu│x` diff row or `anchor│` prefix is stripped automatically with a warning. |
|
|
232
233
|
| `direction` | `"after"` inserts below the anchor line, `"before"` above it. |
|
|
233
|
-
| `
|
|
234
|
+
| `text` | The exact text to insert, as one string: `""` inserts one blank line (the same as `"\n"`), and a trailing line break sets the last line's ending instead of adding a blank line. Never include the anchor line. Embedded `\r\n`/`\r`/`\n` are preserved; JSON decoding happens once, before the tool; the tool writes the string it receives and never decodes — `\uXXXX` is the character, `\\uXXXX` the literal text. Legacy arrays are converted to text (elements joined with LF); prefer the string form. |
|
|
234
235
|
|
|
235
|
-
Nothing is removed and the inserted lines are written exactly as given; the anchor line and every other line stay in place.
|
|
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.
|
|
236
237
|
|
|
237
238
|
Example: add a line after `Emno│`:
|
|
238
239
|
|
|
239
240
|
```json
|
|
240
|
-
{ "anchor": "Emno", "direction": "after", "
|
|
241
|
+
{ "anchor": "Emno", "direction": "after", "text": " // log the greeting" }
|
|
241
242
|
```
|
|
242
243
|
|
|
243
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.
|
|
244
245
|
|
|
245
246
|
### copy
|
|
246
247
|
|
|
247
|
-
`copy` duplicates a range of lines to another position without removing the source. `source_from` and `source_to` select lines in the source file; `insert_after` selects the destination line, and it may live in a different file. An empty destination file is seeded with the copied lines. It is a served-anchor edit like `replace` and `insert`: the source lines and the destination anchor line must come from rows you were shown, and the request is refused if they changed on disk or were never served. With `requirePath` on, `path` must match the source or the destination file.
|
|
248
|
+
`copy` duplicates a range of lines to another position without removing the source. `source_from` and `source_to` select lines in the source file; `insert_after` selects the destination line, and it may live in a different file. An empty destination file is seeded with the copied lines. It is a served-anchor edit like `replace` and `insert`: the source's first and last lines and the destination anchor line must come from rows you were shown, and the request is refused if they changed on disk or were never served; the interior of the range is transferred verbatim and does not need to have been shown. With `requirePath` on, `path` must match the source or the destination file.
|
|
248
249
|
|
|
249
250
|
| Field | Description |
|
|
250
251
|
| --- | --- |
|
|
@@ -258,13 +259,13 @@ Example: read served `Hasu│old` in `a.ts` and `Qwer│top` in `b.ts`; to copy
|
|
|
258
259
|
{ "source_from": "Hasu", "source_to": "Hasu", "insert_after": "Qwer" }
|
|
259
260
|
```
|
|
260
261
|
|
|
261
|
-
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.
|
|
262
|
+
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.
|
|
262
263
|
|
|
263
264
|
### move
|
|
264
265
|
|
|
265
|
-
`move` relocates a range of lines in one call: the range is removed from the source file and written after `insert_after`, 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, `insert_after` must sit outside the source range, and moving a range to where it already sits reports `No changes made` and leaves the anchors alone. 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.
|
|
266
|
+
`move` relocates a range of lines in one call: the range is removed from the source file and written after `insert_after`, 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, `insert_after` 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.
|
|
266
267
|
|
|
267
|
-
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.
|
|
268
|
+
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.
|
|
268
269
|
|
|
269
270
|
### anchor_grep
|
|
270
271
|
|
|
@@ -275,7 +276,7 @@ Every matching line, and each requested context line, is returned as `lineNumber
|
|
|
275
276
|
| Field | Description |
|
|
276
277
|
| --- | --- |
|
|
277
278
|
| `pattern` | Search pattern (regex, or literal text when `literal` is true). |
|
|
278
|
-
| `path` | File or directory to search (default: the current working directory).
|
|
279
|
+
| `path` | File or directory to search (default: the current working directory). |
|
|
279
280
|
| `glob` | Filter files by glob; `*` matches across directories, for example `*.ts` or `**/*.spec.ts`. A leading `/` is ignored, and the pattern may be relative to the search root or the current directory. |
|
|
280
281
|
| `ignoreCase` | Case-insensitive search (default: false). |
|
|
281
282
|
| `literal` | Treat the pattern as literal text instead of a regex (default: false). |
|
|
@@ -290,12 +291,12 @@ Output is capped at `limit` matched lines, 2000 rows, and 50KB of text, whicheve
|
|
|
290
291
|
|
|
291
292
|
### undo_last_change
|
|
292
293
|
|
|
293
|
-
`undo_last_change` reverts the most recent successful `replace`, `
|
|
294
|
+
`undo_last_change` reverts the most recent successful `replace`, `replace_match`, `insert`, `copy`, or `move` on a file, restoring the exact previous content, BOM and line endings included, plus the previous anchors.
|
|
294
295
|
|
|
295
|
-
- History is per-file and single-level: only the most recent `replace`, `
|
|
296
|
+
- History is per-file and single-level: only the most recent `replace`, `replace_match`, `insert`, `copy`, or `move` can be reverted. A same-message batch of `replace`/`insert` calls on one file counts as one entry: one undo reverts the whole batch.
|
|
296
297
|
- History is persisted and survives session restarts. A failed `write` does not clear it.
|
|
297
|
-
- Every applied `replace`, `
|
|
298
|
-
- A cross-file `move` stores one undo entry per file; `undo_last_change` reverts the file you name, so revert both sides to undo the whole move.
|
|
298
|
+
- Every applied `replace`, `replace_match`, `insert`, `copy`, or `move` is undoable; the undo record is saved before the edit is written.
|
|
299
|
+
- A cross-file `move` stores one undo entry per file; `undo_last_change` reverts the file you name, so revert both sides to undo the whole move; a batched cross-file `move` reverts its destination side with the batch's undo and its source side with its own entry.
|
|
299
300
|
- A successful `write` clears the history for that file.
|
|
300
301
|
- If the file was modified since the last edit, the undo is refused with `[E_UNDO_STALE]` rather than overwriting those changes, and the record is kept. Once the file matches the edited state again, `undo_last_change` succeeds.
|
|
301
302
|
- If the file was deleted since the last edit, `undo_last_change` restores it from the recorded pre-edit content.
|
|
@@ -304,17 +305,18 @@ Output is capped at `limit` matched lines, 2000 rows, and 50KB of text, whicheve
|
|
|
304
305
|
|
|
305
306
|
## Batching
|
|
306
307
|
|
|
307
|
-
Multiple `replace` and `
|
|
308
|
+
Multiple `replace`, `replace_match`, `insert`, `copy`, and `move` calls on the same file in one assistant message are grouped per file into one batch. A cross-file `copy` and a cross-file `move` are grouped with the edits of their destination file; a cross-file `move` whose source file also has batched edits in the same message is not grouped. The batch unit is the message, not the turn: calls from separate messages in the same turn run on their own, one after another.
|
|
308
309
|
|
|
309
310
|
- A call outside a batch commits before its result returns.
|
|
310
|
-
- A
|
|
311
|
-
- A
|
|
312
|
-
-
|
|
311
|
+
- A cross-file `move` whose source file also has batched edits in the same message is not grouped: it commits on its own, and a pending same-file batch aborts safely with `[E_OP_ABORTED]` if the file changed under it.
|
|
312
|
+
- A batched cross-file `move` displays only the destination diff: the arrival is covered by the batch's undo, and the source removal is committed with the same batch but is not shown. Read the source file for fresh anchors; it keeps its own undo entry, and `details.patch` still contains the source patch.
|
|
313
|
+
- A batch validates every call against the pre-batch state and commits once, during the batch's last call: earlier calls reply `In batch N (queued)`, and the batch's last call shows the combined diff, with one undo reverting the whole batch. A `copy` always duplicates the content its source anchors were served from: when the source file is also edited in the message, the copy reads that file's pre-batch state, even if the source batch commits before the copy runs. A batched cross-file `move` defers the source-file removal to the batch commit, so if the batch aborts, the source file is untouched.
|
|
314
|
+
- If a batch aborts, nothing is written: the failing call's error ends with `Aborts batch N.` and reports that the whole batch was discarded, and an earlier member's row renders the abort message instead of the queued placeholder. Nothing commits until the last call succeeds.
|
|
313
315
|
- A batch member accepts the same request shapes and auto-fixes as a standalone call.
|
|
314
316
|
|
|
315
|
-
Batched calls must target disjoint ranges; overlapping ranges, or any failing call, aborts the whole batch unwritten. One `insert` with `direction: "before"` and one with `direction: "after"` may target the same anchor line: the pair composes into a single insertion. A batch member that fails aborts its batch-mates with `[E_OP_ABORTED]`.
|
|
317
|
+
Batched calls must target disjoint ranges; overlapping ranges, or any failing call, aborts the whole batch unwritten. A `copy`'s `insert_after` line is the copy's destination range, so replacing or moving that same line in the batch is an overlap. One `insert` with `direction: "before"` and one with `direction: "after"` may target the same anchor line: the pair composes into a single insertion. A batch member that fails aborts its batch-mates with `[E_OP_ABORTED]`.
|
|
316
318
|
|
|
317
|
-
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 and abort it instead of applying partially. 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.
|
|
319
|
+
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 and abort it 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.
|
|
318
320
|
|
|
319
321
|
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`.
|
|
320
322
|
|
|
@@ -322,7 +324,7 @@ The hashline tools are sequential in pi, so a message that contains one runs all
|
|
|
322
324
|
|
|
323
325
|
Auto-read is enabled by default. After a successful `write`, the extension reads the file and appends an `--- Auto-read (hashline anchors) ---` block, so you get fresh `anchor│content` anchors without a separate `read` call.
|
|
324
326
|
|
|
325
|
-
After `replace`, `
|
|
327
|
+
After `replace`, `replace_match`, `insert`, `copy`, `move`, and `undo_last_change`, the result shows the post-edit diff. Inside a same-message batch, only the batch's last call shows the combined diff, headed by a `batch N:` line; earlier calls reply `In batch N (queued)`. The `+anchor│` and ` anchor│` rows carry the current anchors, so follow-up edits can anchor on the diff directly. The `-anchor│` rows show removed lines with their old anchors, which are stale after the edit. When the context line next to a change is blank or whitespace-only, one more context line is shown in that direction, so the change stays anchored to visible content. Call `read` when you want the full file's anchors.
|
|
326
328
|
|
|
327
329
|
An edit that changes only line endings has no content diff; the result still reports `applied`, and one `undo_last_change` reverts it.
|
|
328
330
|
|
|
@@ -344,7 +346,7 @@ The setting lives in `/hashline-config` as Auto-read all and in `config.json` as
|
|
|
344
346
|
|
|
345
347
|
| Command | Description |
|
|
346
348
|
| --- | --- |
|
|
347
|
-
| `/hashline-config` | Open the settings window: auto-read anchors, auto-read all mode, ignore folders/files, diff context lines, `anchor_grep` tool, copy/move tools,
|
|
349
|
+
| `/hashline-config` | Open the settings window: auto-read anchors, auto-read all mode, ignore folders/files, diff context lines, `anchor_grep` tool, copy/move tools, replace_match tool, required `path`, and strict input. Persists across sessions. |
|
|
348
350
|
| `/clear-anchors` | Clear the session's anchor claims. Anchors are re-claimed on the next `read`. |
|
|
349
351
|
|
|
350
352
|
Settings live in `~/.config/pi-hashline-edit-pro/config.json`, created when a setting is first changed in `/hashline-config`:
|
|
@@ -356,7 +358,7 @@ Settings live in `~/.config/pi-hashline-edit-pro/config.json`, created when a se
|
|
|
356
358
|
"autoReadAllIgnore": [],
|
|
357
359
|
"anchorGrepEnabled": true,
|
|
358
360
|
"copyMoveEnabled": true,
|
|
359
|
-
"
|
|
361
|
+
"replaceMatchEnabled": true,
|
|
360
362
|
"requirePath": false,
|
|
361
363
|
"strictInput": false,
|
|
362
364
|
"diffContextLines": 1
|
|
@@ -370,8 +372,8 @@ Settings live in `~/.config/pi-hashline-edit-pro/config.json`, created when a se
|
|
|
370
372
|
| `autoReadAllIgnore` | Ignore folders/files | `[]` | Extra folder names, file names, or globs skipped by auto-read all. |
|
|
371
373
|
| `anchorGrepEnabled` | Anchor grep | `true` | Register `anchor_grep` and disable the built-in grep while it is on. |
|
|
372
374
|
| `copyMoveEnabled` | Copy/move | `true` | Offer the `copy` and `move` tools; when off, both are removed from the active tools. |
|
|
373
|
-
| `
|
|
374
|
-
| `requirePath` | Require path | `false` | `replace`, `
|
|
375
|
+
| `replaceMatchEnabled` | Replace match | `true` | Offer the `replace_match` tool; when off, it is removed from the active tools. |
|
|
376
|
+
| `requirePath` | Require path | `false` | `replace`, `replace_match`, `insert`, `copy`, and `move` require a `path` argument that must match anchor ownership. |
|
|
375
377
|
| `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. |
|
|
376
378
|
| `diffContextLines` | Diff context | `1` | Surrounding lines in post-edit diffs, 0-10 (needs Auto-read). |
|
|
377
379
|
|
|
@@ -383,7 +385,7 @@ When `PI_HASHLINE_DIR` is unset or empty, non-Windows platforms honor `XDG_CONFI
|
|
|
383
385
|
| --- | --- | --- |
|
|
384
386
|
| Output cap | 2000 lines and 50KB | `read`, auto-read after `write`, post-edit diffs, patches, previews, `details.patch` |
|
|
385
387
|
| Oversized row | 50KB per `anchor│content` row | replaced by an anchor-keeping marker you can still edit through |
|
|
386
|
-
| Line cap | 1,353,139 lines per file | `read`, `replace`, `
|
|
388
|
+
| Line cap | 1,353,139 lines per file | `read`, `replace`, `replace_match`, `insert`, `copy`, `move` (`[E_FILE_TOO_LARGE]`) |
|
|
387
389
|
| File size | 100MB | all tools (`[E_FILE_TOO_LARGE]`) |
|
|
388
390
|
| Hash window | first 500 bytes of a line | anchor identity for long lines |
|
|
389
391
|
| Patch guard | 1MB of pre-edit + post-edit text | patch generation is skipped and `patchTruncated` is set |
|
|
@@ -405,7 +407,7 @@ All eight tools return machine-readable metadata in `details` alongside the mode
|
|
|
405
407
|
| --- | --- |
|
|
406
408
|
| `read` | `truncation` (set when output was truncated), `snapshotId` (a `v2\|path\|ino\|mtime\|ctime\|size` fingerprint), `nextOffset` (use as the next `offset`), and `metrics` with `truncated` and `next_offset`. |
|
|
407
409
|
| `replace`, `insert` | `diff` (post-edit diff, capped, with current anchors on `+anchor│` and ` anchor│` rows; a same-message batch reports the combined diff on its last call and an empty diff on earlier calls), `patch` (a standard unified patch for external tools, capped like the diff), `patchTruncated` (true when the patch was cut or skipped for a pair over 1MB and can no longer be applied as-is), `firstChangedLine`, `snapshotId`, `classification` (`"noop"` when nothing changed), `batch` (`{ id, size, last, total }` marking same-message batch membership; earlier members also carry `aborted: true` and `abortMessage` after a batch abort), `hints` (informative `[H_*]` notices, for example literal escaped text written as sent), and `metrics`: `edits_attempted`, `edits_noop`, `warnings`, `classification` (`"applied"` or `"noop"`), `changed_lines` (`{ first, last }`), `added_lines`, `removed_lines`. |
|
|
408
|
-
| `
|
|
410
|
+
| `replace_match` | Same shape as `replace`: `diff` (post-edit diff with current anchors), `patch`, `patchTruncated`, `firstChangedLine`, `snapshotId`, `classification` (`"noop"` when nothing changed), and `metrics` with the same counters. |
|
|
409
411
|
| `copy`, `move` | Same shape as `replace`: `diff` (post-edit diff with current anchors), `patch`, `patchTruncated`, `firstChangedLine`, `snapshotId`, `classification` (`"noop"` when a move changes nothing), and `metrics` with the same counters. |
|
|
410
412
|
| `undo_last_change` | `diff` (the undo diff with restored anchors), `patch`, `patchTruncated`, and `metrics` in the same shape as `replace`. |
|
|
411
413
|
| `anchor_grep` | `metrics` with `matches` (capped at `limit`), `files`, and `truncated`; `truncation` (the standard pi truncation report) when output was cut; and `linesTruncated` (true when long lines were shown as fragments). |
|
|
@@ -419,7 +421,7 @@ Codes starting with `E_` are errors: nothing was written, with one exception. `F
|
|
|
419
421
|
Most common, with the fix:
|
|
420
422
|
|
|
421
423
|
- `[E_STALE_ANCHOR]`: the anchor is not owned in this session. Call `read` for fresh anchors and retry.
|
|
422
|
-
- `[E_RANGE_STALE]`: a line in the replaced range changed on disk or was never shown. The error already returns the current range with fresh anchors; retry with those.
|
|
424
|
+
- `[E_RANGE_STALE]`: a line in the replaced range changed on disk or was never shown (a pure deletion checks only its first and last line). The error already returns the current range with fresh anchors; retry with those.
|
|
423
425
|
- `[E_FILE_TOO_LARGE]`: the file exceeds the 1,353,139-line hashline limit or the 100MB size limit. Use `write` for very large files.
|
|
424
426
|
- `[E_STORE_UNAVAILABLE]`: no SQLite runtime. Run pi under Node 22.19+ or a Bun build that ships `bun:sqlite`.
|
|
425
427
|
- `[E_WRITE_HASH_ECHO]`: a `write` content line contains a copied served row. Remove the anchors and retry.
|
|
@@ -430,31 +432,34 @@ Full reference:
|
|
|
430
432
|
| Code | Meaning |
|
|
431
433
|
| --- | --- |
|
|
432
434
|
| `[E_CONFIG]` | `PI_HASHLINE_DIR` is nonempty but not an absolute path. |
|
|
433
|
-
| `[E_BAD_SHAPE]` | Request envelope or edit item has unknown, missing, or wrongly-typed fields (for example `
|
|
435
|
+
| `[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, or a grep `glob` has invalid bracket or brace syntax. |
|
|
434
436
|
| `[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). |
|
|
435
437
|
| `[E_BAD_REF]` | An anchor in `remove_from`/`remove_to` is not a bare 4-character anchor (the anchor table is letters only). |
|
|
436
|
-
| `[E_SUBSTRING_NOT_FOUND]` | `
|
|
437
|
-
| `[E_SUBSTRING_AMBIGUOUS]` | `replace_within` found `replace_old` more than once in the selected range. Narrow `replace_from`/`replace_to` or extend `replace_old` so it matches exactly once. |
|
|
438
|
+
| `[E_SUBSTRING_NOT_FOUND]` | `replace_match` did not find `old_string` in the selected range. The current `anchor│content` rows are returned; copy `old_string` exactly from the served row and retry. |
|
|
438
439
|
| `[W_BAD_REF]` | A pasted `anchor│` or diff-preview marker was stripped from an anchor field with a warning. |
|
|
439
440
|
| `[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. |
|
|
440
|
-
| `[W_INVALID_PATCH]` | A `
|
|
441
|
-
| `[W_BARE_HASH_PREFIX]` | A `
|
|
441
|
+
| `[W_INVALID_PATCH]` | A `text` line is a diff-preview row (`+anchor│`, `-anchor│`, `- │`). The marker is stripped automatically with a warning. |
|
|
442
|
+
| `[W_BARE_HASH_PREFIX]` | A `text` line starts with an `anchor│` prefix. The prefix is stripped automatically with a warning. |
|
|
442
443
|
| `[W_ANCHOR_RECLAIMED]` | The session's anchor quota was exhausted, so all anchors of the listed files (the least recently read or edited) were freed to make room. Read those files again before editing them. |
|
|
443
|
-
| `[H_LITERAL_ESCAPE]` |
|
|
444
|
-
| `[H_UNICODE_LOST]` | The
|
|
445
|
-
| `[
|
|
444
|
+
| `[H_LITERAL_ESCAPE]` | A payload field contains literal escaped text such as `\uXXXX`, `\n`, `\t`, `\r`, or `\"` (one hint per distinct escape, up to three). The file receives those backslash characters as written, because JSON decoding happens once, before the tool call (`\uXXXX` → the character), so a doubled escape (`\\uXXXX`) lands literally. The hint is one line: `text: "\u200b" written as literal text (Kq3f│ col 31); resend with U+200B if unintended.` names the field, the escape, and up to three affected anchors with their columns, then the fix; when more than three rows carry it, it gives `on 10 rows; undo_last_change + resend with U+200B if unintended.` instead of the anchor list. |
|
|
445
|
+
| `[H_UNICODE_LOST]` | The new text is missing an invisible or look-alike character (for example `U+200B`, `U+2060`, `U+00A0`, or a smart quote) that a replaced row has, or that a context row sharing a long run with an inserted line has. The edit applied as sent; the one-line hint names the character, its column, and the reference anchor, for example `[H_UNICODE_LOST] U+2060 missing at col 31; Kq3f│ has it; resend with U+2060 if unintended.` When another look-alike character (including `U+FFFD`) takes its place, the hint reports the substitute with `[H_UNICODE_SWAPPED]` wording. |
|
|
446
|
+
| `[H_UNICODE_SWAPPED]` | The new text uses a different invisible or look-alike character than the line it matches (for example `U+200D` where that line has `U+200B`, or an ASCII stand-in such as `.` for `。` or a space for `U+00A0`). The edit applied as sent; the one-line hint names both code points, the column, and the reference anchor, for example `[H_UNICODE_SWAPPED] U+200D at col 21 where Kq3f│ has U+200B; resend with U+200B if unintended.` The substitute may also be `U+FFFD`, for example where a row has `U+00A0`. |
|
|
447
|
+
| `[H_TRAILING_WHITESPACE]` | The new text differs from the replaced line only in trailing whitespace. Anchor checksums trim trailing whitespace, so the change does not invalidate the anchor; the one-line hint names the old and new trailing-whitespace counts and the column, for example `[H_TRAILING_WHITESPACE] 1 trailing whitespace character at col 7; Kq3f│ had 0.` |
|
|
448
|
+
| `[H_INDENT_MISMATCH]` | The new line has fewer leading whitespace characters than a structurally similar row (a reference row near the anchor line for an insert, or the replaced line). The edit applied as sent; the one-line hint names both counts and the reference anchor, for example `[H_INDENT_MISMATCH] new line has 0 leading whitespace characters; Kq3f│ has 2.` Copied or moved blocks are not checked, because their indentation comes from the source lines. |
|
|
449
|
+
| `[H_SEPARATOR_MOVED]` | An insert landed its text directly against the anchor line, and the blank line that separated the anchor from its neighbor was displaced to the other side of the inserted text. The edit applied as sent; the one-line hint names the anchor and which side lost the blank line, for example `[H_SEPARATOR_MOVED] blank separator above Kq3f│ was displaced; add a blank line before Kq3f│ if unintended.` |
|
|
450
|
+
| `[H_SEPARATOR_LOST]` | A pure deletion removed a run of blank lines that sat between two content lines. The edit applied as sent; the one-line hint names the two surviving anchors and the removed count, for example `[H_SEPARATOR_LOST] deletion removed 2 blank lines between Aaaa│ and Dddd│.` |
|
|
446
451
|
| `[E_NOT_FOUND]` | The path does not exist. |
|
|
447
452
|
| `[E_ACCESS]` | The file is not readable or writable. |
|
|
448
453
|
| `[E_NOT_TEXT]` | The path is a directory, binary file, image, or UTF-16/UTF-32 encoded text; hashline editing only supports text files. |
|
|
449
454
|
| `[E_UNDO_STALE]` | `undo_last_change` refused: the file was modified after the last edit. The undo record is kept until the file matches the edited state again or a new edit replaces it. |
|
|
450
455
|
| `[E_UNDO_UNAVAILABLE]` | Undo history could not be persisted to the hash store; the edit was refused and the file was left unchanged. |
|
|
451
|
-
| `[E_RANGE_STALE]` | A line in the replaced range no longer matches what was last shown (the file changed on disk, or the line was never shown). The edit was refused; the current range is returned with fresh anchors. |
|
|
456
|
+
| `[E_RANGE_STALE]` | A line in the replaced range no longer matches what was last shown (the file changed on disk, or the line was never shown; a pure deletion checks only its first and last line). The edit was refused; the current range is returned with fresh anchors. |
|
|
452
457
|
| `[E_FILE_TOO_LARGE]` | The file exceeds the 1,353,139-line hashline limit or the 100MB size limit. |
|
|
453
458
|
| `[E_REGISTRY]` | The anchor registry was not initialized; a serve or edit ran outside an initialized session. |
|
|
454
459
|
| `[E_STORE_UNAVAILABLE]` | No SQLite runtime could be loaded: the host exposes neither `node:sqlite` (Node 22.19+) nor `bun:sqlite`. The pi release binary's bundled Bun lacks `node:sqlite`; run pi under Node or a Bun build that ships SQLite. |
|
|
455
460
|
| `[E_WRITE_HASH_ECHO]` | A `write` `content` line reproduces a served row for this file (a bare `anchor│` read row, a `+anchor│`, ` anchor│`, or `-anchor│` diff row, or a `lineNumber │ anchor│content` grep row). The write is refused, file byte-identical; retry with bare content (remove the copied anchors). |
|
|
456
461
|
| `[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. |
|
|
457
|
-
| `[E_BATCH_OVERLAP]` | Batched
|
|
462
|
+
| `[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. |
|
|
458
463
|
| `[E_OP_ABORTED]` | An edit aborted (a same-message batch member failed, or the file changed or was deleted after the edit started). Nothing was written. Fix the sibling failure and retry the batch, otherwise call `read` for fresh anchors and retry. The abort names the failing call and its error code when one is known. |
|
|
459
464
|
| `[E_UNSAFE_REGEX]` | A grep regex can trigger excessive backtracking; simplify it or search with `literal: true`. |
|
|
460
465
|
| `[E_GREP_FAILED]` | `anchor_grep` could not start ripgrep or ripgrep exited with an error (for example a pattern valid in JavaScript but unsupported by ripgrep's regex engine); the message carries ripgrep's output. Retry with `literal: true` or simplify the pattern. |
|
|
@@ -464,13 +469,13 @@ Full reference:
|
|
|
464
469
|
## Troubleshooting
|
|
465
470
|
|
|
466
471
|
- Stale anchors. `[E_STALE_ANCHOR]` means an anchor is not owned in this session: it was never shown to you, or its line was edited or the file was rewritten since. Call `read` for fresh anchors and retry.
|
|
467
|
-
- Range changed on disk. `[E_RANGE_STALE]` means a line inside the replaced range changed after it was last shown to you (or was never shown). Nothing was modified; the error carries the current range with fresh anchors, so retry with those without a `read`.
|
|
472
|
+
- Range changed on disk. `[E_RANGE_STALE]` means a line inside the replaced range changed after it was last shown to you (or was never shown; a pure deletion only needs its first and last line shown). Nothing was modified; the error carries the current range with fresh anchors, so retry with those without a `read`.
|
|
468
473
|
- Multi-conversation hosts. Anchors, served records, and ownership logs are resolved per calling session, so a tool call in one conversation is never answered by another conversation's registry; a foreign anchor fails with `[E_STALE_ANCHOR]`. Interactive previews are the one exception: pi does not pass the session into render callbacks, so when one process serves several conversations at once a preview can fall back to the most recently active session and show a stale or wrong-file diff. Previews never write files or claim anchors; run the call for the authoritative result.
|
|
469
474
|
- Undo scope. `undo_last_change` records are keyed by file path, not by session, so in a multi-conversation host any conversation that names the file can revert its most recent `replace` or `insert`, even one made by another conversation. Anchor ownership remains session-scoped; only undo is shared.
|
|
470
475
|
- Reset the anchor state. Anchors live in `~/.config/pi-hashline-edit-pro/hash-store.sqlite` (with `-wal`/`-shm` sidecars) and in per-session ownership logs under `~/.config/pi-hashline-edit-pro/sessions/`. Quit pi, delete those files, and everything is rebuilt on the next session. Anchor history is lost, but no project files are touched.
|
|
471
476
|
- Corrupt store. If the store fails its health check it is renamed to `hash-store.sqlite.corrupt-<timestamp>` and rebuilt automatically.
|
|
472
477
|
- Config directory moved. If `XDG_CONFIG_HOME` is set on a non-Windows platform, the config directory (and the anchor state inside it) lives at `$XDG_CONFIG_HOME/pi-hashline-edit-pro` instead of `~/.config/pi-hashline-edit-pro`. An existing store is not migrated automatically. To keep anchor and undo history, move the old `hash-store.sqlite` files (plus `-wal`/`-shm` sidecars) into the new directory before the first run.
|
|
473
|
-
- Windows drives in WSL. Editing a file under a Windows mount (`/mnt/c`, drvfs/9p) can fail with `EPERM` from `fchmod` because those filesystems do not store POSIX modes. Mode preservation is best-effort there, so `replace`, `
|
|
478
|
+
- Windows drives in WSL. Editing a file under a Windows mount (`/mnt/c`, drvfs/9p) can fail with `EPERM` from `fchmod` because those filesystems do not store POSIX modes. Mode preservation is best-effort there, so `replace`, `replace_match`, `insert`, and `undo_last_change` still write the edit.
|
|
474
479
|
- Not sure what the extension changed. `read` returns anchored rows and the built-in `edit` is gone; that is expected. See [What changes in your session](#what-changes-in-your-session).
|
|
475
480
|
|
|
476
481
|
## Privacy and on-disk state
|
|
@@ -497,7 +502,7 @@ Background snapshot pruning and registry sidecar GC skip `EPERM`/`EACCES` withou
|
|
|
497
502
|
|
|
498
503
|
### Allocation
|
|
499
504
|
|
|
500
|
-
Anchors are allocated, never derived. Every line that is served to you, by `read`, `anchor_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`, `
|
|
505
|
+
Anchors are allocated, never derived. Every line that is served to you, by `read`, `anchor_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).
|
|
501
506
|
|
|
502
507
|
### Ownership and mapping across edits
|
|
503
508
|
|
|
@@ -524,13 +529,7 @@ Allocated anchors live in a persistent per-file snapshot (`~/.config/pi-hashline
|
|
|
524
529
|
|
|
525
530
|
## Benchmark
|
|
526
531
|
|
|
527
|
-
pi-hashline-edit-pro is
|
|
528
|
-
|
|
529
|
-
The current release scores 98.7% in the explorer's harness-family view for `pi-hashline-edit-pro`, with 99.6% or better final exactness on every model route.
|
|
530
|
-
|
|
531
|
-
First exact means the first attempt, with no recovery round, reproduced the expected bytes exactly. Final exact is the same after the benchmark's allowed recovery rounds. Quality is `0.75 × first exact + 0.25 × final exact`, so first-attempt accuracy dominates. The family rollup is the median quality across the complete, eligible model-route configurations scored with this harness; partial and quarantined configurations stay visible in the dataset but do not enter the median.
|
|
532
|
-
|
|
533
|
-
Per-model and per-version numbers, plus the spread behind the headline value, are in the [benchmark explorer](https://huggingface.co/spaces/alexshpunt/benchmark-explorer?card=harness%3Api-hashline-edit-pro%40latest); raw observations and scoring rules are in the [dataset](https://huggingface.co/datasets/alexshpunt/explicit-edit-benchmark) and the [methodology](https://github.com/alexshpunt/explicit-edit-benchmark/blob/main/docs/methodology.md). Treat the score as one measurement of this suite at one point in time.
|
|
532
|
+
pi-hashline-edit-pro is scored on the [Explicit Edit Benchmark](https://github.com/alexshpunt/explicit-edit-benchmark), an open third-party suite of 226 deterministic, byte-exact editing tasks. Per-model results and the scoring rules are in the [benchmark explorer](https://huggingface.co/spaces/alexshpunt/benchmark-explorer?card=harness%3Api-hashline-edit-pro%40latest) and the [dataset](https://huggingface.co/datasets/alexshpunt/explicit-edit-benchmark).
|
|
534
533
|
|
|
535
534
|
## Development
|
|
536
535
|
|
package/index.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES } from "@earendil-works/pi-coding-agent";
|
|
3
3
|
import { initHasher } from "./src/hashline";
|
|
4
|
-
import {
|
|
4
|
+
import { regReplaceMatch } from "./src/replace-match";
|
|
5
5
|
import { regReplace } from "./src/replace";
|
|
6
6
|
import { regInsert } from "./src/insert";
|
|
7
7
|
import { regCopy, regMove } from "./src/copy-move";
|
|
@@ -21,7 +21,7 @@ import {
|
|
|
21
21
|
cycleAutoReadAllMode,
|
|
22
22
|
toggleAnchorGrep,
|
|
23
23
|
toggleCopyMove,
|
|
24
|
-
|
|
24
|
+
toggleReplaceMatch,
|
|
25
25
|
toggleRequirePath,
|
|
26
26
|
toggleStrictInput,
|
|
27
27
|
adjustDiffContextLines,
|
|
@@ -45,7 +45,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
45
45
|
regRead(pi);
|
|
46
46
|
|
|
47
47
|
regReplace(pi);
|
|
48
|
-
|
|
48
|
+
regReplaceMatch(pi);
|
|
49
49
|
regInsert(pi);
|
|
50
50
|
regCopy(pi);
|
|
51
51
|
regMove(pi);
|
|
@@ -64,7 +64,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
64
64
|
const flags = await currentEditFlags();
|
|
65
65
|
regRead(pi, flags);
|
|
66
66
|
regReplace(pi, flags);
|
|
67
|
-
|
|
67
|
+
regReplaceMatch(pi, flags);
|
|
68
68
|
regInsert(pi, flags);
|
|
69
69
|
regCopy(pi, flags);
|
|
70
70
|
regMove(pi, flags);
|
|
@@ -104,7 +104,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
104
104
|
pi.getActiveTools().filter((t) => {
|
|
105
105
|
if (config.anchorGrepEnabled ? t === "grep" : t === "anchor_grep") return false;
|
|
106
106
|
if (config.copyMoveEnabled === false && (t === "copy" || t === "move")) return false;
|
|
107
|
-
if (config.
|
|
107
|
+
if (config.replaceMatchEnabled === false && t === "replace_match") return false;
|
|
108
108
|
return true;
|
|
109
109
|
}),
|
|
110
110
|
);
|
|
@@ -139,7 +139,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
139
139
|
}));
|
|
140
140
|
|
|
141
141
|
pi.registerCommand("hashline-config", {
|
|
142
|
-
description: "Open the hashline settings window (auto-read, auto-read all, ignore folders/files, diff context, grep, copy/move,
|
|
142
|
+
description: "Open the hashline settings window (auto-read, auto-read all, ignore folders/files, diff context, grep, copy/move, replace_match, path, strict input)",
|
|
143
143
|
handler: async (_args, ctx) => {
|
|
144
144
|
if (!ctx.hasUI) {
|
|
145
145
|
ctx.ui.notify("/hashline-config requires interactive mode", "error");
|
|
@@ -165,10 +165,10 @@ export default function (pi: ExtensionAPI): void {
|
|
|
165
165
|
const active = pi.getActiveTools();
|
|
166
166
|
pi.setActiveTools(enabled ? [...new Set([...active, "copy", "move"])] : active.filter((t) => t !== "copy" && t !== "move"));
|
|
167
167
|
}
|
|
168
|
-
else if (key === "
|
|
169
|
-
const enabled = await
|
|
168
|
+
else if (key === "replaceMatchEnabled") {
|
|
169
|
+
const enabled = await toggleReplaceMatch();
|
|
170
170
|
const active = pi.getActiveTools();
|
|
171
|
-
pi.setActiveTools(enabled ? [...new Set([...active, "
|
|
171
|
+
pi.setActiveTools(enabled ? [...new Set([...active, "replace_match"])] : active.filter((t) => t !== "replace_match"));
|
|
172
172
|
}
|
|
173
173
|
else if (key === "requirePath") await toggleRequirePath();
|
|
174
174
|
else if (key === "strictInput") await toggleStrictInput();
|
|
@@ -262,7 +262,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
262
262
|
|
|
263
263
|
if (
|
|
264
264
|
event.toolName !== "replace" &&
|
|
265
|
-
event.toolName !== "
|
|
265
|
+
event.toolName !== "replace_match" &&
|
|
266
266
|
event.toolName !== "insert" &&
|
|
267
267
|
event.toolName !== "copy" &&
|
|
268
268
|
event.toolName !== "move" &&
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-hashline-edit-pro",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "6.0.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Hash-anchored read/replace/insert/grep tools for pi-coding-agent. Every line gets a unique 4-char tokenizer-friendly anchor that stays stable across edits; stale or ambiguous anchors are rejected, never fuzzy-matched. Undo persists across restarts.",
|
|
6
6
|
"main": "index.ts",
|
package/prompts/copy.md
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
1
|
Copy a range of lines to another position, targeted by 4-character anchors from any served anchor│content row. Give `source_from` and `source_to` as bare anchors marking the first and last line to copy in the source file, and `insert_after` as the bare anchor of the destination line after which the copy goes. `source_from` and `source_to` may belong to a different file than `insert_after`: the lines are copied from the source file and inserted into the destination file, and an empty destination file is seeded with the copied lines. The source lines stay in place, and the copied lines are written exactly as the source file holds them.
|
|
2
2
|
|
|
3
3
|
Example: read served `Hasu│old` in `a.ts` and `Qwer│top` in `b.ts`. Call { "source_from": "Hasu", "source_to": "Hasu", "insert_after": "Qwer" } copies `old` from `a.ts` into `b.ts` below `top`; the source `Hasu│old` row stays live.
|
|
4
|
+
|
|
5
|
+
Same-file calls in one message batch, and a cross-file copy joins the destination file's batch: earlier calls reply `In batch N (queued)` and the last call shows the combined diff, with one undo for the whole batch. A copy always duplicates the content its source anchors were served from, so a same-message edit to the source file does not change what is copied.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
- `insert`:
|
|
1
|
+
- `insert`: compare both edges of the inserted block in the post-edit diff against the request; a missing or extra `+anchor│` blank row at either boundary is the classic insert slip.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
Insert a text block after or before an anchor line: the anchor line stays; `
|
|
1
|
+
Insert a text block after or before an anchor line: the anchor line stays; `text` is the exact text without anchor prefixes
|
package/prompts/insert.md
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
Insert text after or before one existing line in a text file, addressed by a bare anchor from any served anchor│content row.
|
|
1
|
+
Insert text after or before one existing line in a text file, addressed by a bare anchor from any served anchor│content row. `text` goes after the anchor line with `direction: "after"` or before it with `direction: "before"`. `text` is one string holding the exact text to insert. JSON decoding happens once, before the tool; the tool writes the string it receives and never decodes — `\uXXXX` is the character, `\\uXXXX` the literal text. A trailing line break sets the last line's ending instead of adding a blank line, so a blank line next to the anchor line must be an extra break in `text`: `"x\n"` leaves the anchor line directly after `x`, and `"x\n\n"` leaves one blank line between them (`direction: "after"` mirrors this at the start of `text`).
|
|
2
2
|
|
|
3
|
-
Same-file calls in one message batch: earlier calls reply `In batch N` and the last call shows the combined diff, with one undo for the whole batch.
|
|
3
|
+
Same-file calls in one message batch: earlier calls reply `In batch N (queued)` and the last call shows the combined diff, with one undo for the whole batch.
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
- `move`: a cross-file move records one undo entry per file — undo both sides. Lines between source and target may be re-anchored.
|
package/prompts/move.md
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
1
|
Move a range of lines to another position, targeted by 4-character anchors from any served anchor│content row. Give `source_from` and `source_to` as bare anchors marking the first and last line to move in the source file, and `insert_after` as the bare anchor of the destination line after which the range goes. `source_from` and `source_to` may belong to a different file than `insert_after`: the range is removed from the source file and inserted into the destination file in one call, and an empty destination file is seeded with the moved lines. `insert_after` must sit outside the source range when both anchors share a file; moving a range in one file to where it already sits reports no change.
|
|
2
2
|
|
|
3
3
|
Example: read served `Hasu│a` in `lib.ts` and `Qwer│top` in `app.ts`. Call { "source_from": "Hasu", "source_to": "Hasu", "insert_after": "Qwer" } moves `a` from `lib.ts` into `app.ts` below `top`.
|
|
4
|
+
|
|
5
|
+
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.
|
|
@@ -1,3 +1 @@
|
|
|
1
1
|
- `read`: call again after an edit when you need anchors you lack — post-edit diff `+anchor│`/` anchor│` rows and any served `anchor│content` rows already carry fresh anchors for the changed range.
|
|
2
|
-
- `read`: `E_AUTO_READ_ALL` on an attached file means its content is still exactly as it was when attached at the start of this session.
|
|
3
|
-
- `read`: `[W_ANCHOR_RECLAIMED]` means the session's anchor quota was tight and the listed files' anchors were freed; read a freed file again before editing it.
|
|
@@ -1,2 +1,4 @@
|
|
|
1
|
-
- `replace`: same-
|
|
2
|
-
- `replace`: `
|
|
1
|
+
- `replace`: same-message calls must target disjoint ranges; a call whose anchors resolve nowhere fails on its own while the rest of the batch still commits.
|
|
2
|
+
- `replace`: a pure deletion (`text: ""`) is the cheap way to clear a large range: it verifies only the first and last line against the served record and removes the interior as it currently stands.
|
|
3
|
+
- `replace`: to delete several blocks, `anchor_grep` serves each block's first line and a short `read` around each block serves its closing line; then one message of pure-deletion `replace` calls (`remove_from`/`remove_to` per block, `text: ""`) removes them all as one batch.
|
|
4
|
+
- `replace`: for a single line, use the same anchor for `remove_from` and `remove_to`; a pasted `anchor│` prefix in `text` is stripped.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
- `replace_match`: every occurrence of `old_string` inside the range is replaced; scope the range with anchors when only some occurrences should change, and check the post-edit diff.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
Replace part of a line or range by anchor: every occurrence of `old_string` inside `replace_from`/`replace_to` is replaced by `new_string`, leaving the rest of the text untouched
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
Replace part of a line (or a range of lines) without retyping the rest. `replace_from` and `replace_to` are bare anchors from served `anchor│content` rows marking the first and last line of the range; use the same anchor for one line. `old_string` is the text to find inside that range, and `new_string` replaces every occurrence of it.
|
|
2
|
+
|
|
3
|
+
Example: read served `Hasu│ {"name": "widget", "size": "small"},`. Call { "replace_from": "Hasu", "replace_to": "Hasu", "old_string": "small", "new_string": "large" }. The line becomes ` {"name": "widget", "size": "large"},` and the post-edit diff carries fresh anchors.
|
|
4
|
+
|
|
5
|
+
JSON decoding happens once, before the tool; the tool writes the string it receives and never decodes — `\uXXXX` is the character, `\\uXXXX` the literal text. A missing match is refused with the current rows, so the retry needs no read. The two boundary anchors must still match what was last shown; lines strictly inside the range are matched against the file as it is on disk.
|
|
6
|
+
|
|
7
|
+
Same-file calls in one message batch: earlier calls reply `In batch N (queued)` and the last call shows the combined diff, with one undo for the whole batch. A missing `old_string` aborts the whole batch unwritten.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
Replace lines by anchor: bare anchors in `remove_from`/`remove_to`, the exact replacement text in `
|
|
1
|
+
Replace lines by anchor: bare anchors in `remove_from`/`remove_to`, the exact replacement text in `text` (one edit per call)
|
package/prompts/replace.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Replace a range of lines (or a single line) in a text file by anchor. `remove_from` and `remove_to` are the 4-character anchors of the first and last line to remove, and `
|
|
2
|
-
To change only part of a line without retyping the rest, use `
|
|
1
|
+
Replace a range of lines (or a single line) in a text file by anchor. `remove_from` and `remove_to` are the 4-character anchors of the first and last line to remove, and `text` is one string holding the exact replacement text. JSON decoding happens once, before the tool; the tool writes the string it receives and never decodes — `\uXXXX` is the character, `\\uXXXX` the literal text. The text is written exactly as given, and nothing else in the file changes.
|
|
2
|
+
To change only part of a line without retyping the rest, use `replace_match` instead; it preserves every character the request does not name. Deleting every line empties the file; the result names the new empty-line anchor, so a follow-up `replace` on it can seed content without a `read`.
|
|
3
3
|
|
|
4
|
-
Same-file calls in one message batch: earlier calls reply `In batch N` and the last call shows the combined diff, with one undo for the whole batch.
|
|
4
|
+
Same-file calls in one message batch: earlier calls reply `In batch N (queued)` and the last call shows the combined diff, with one undo for the whole batch.
|
|
5
5
|
|
|
6
|
-
Example: read served `Hasu│old` and `arvm│old2`. Call { "remove_from": "Hasu", "remove_to": "arvm", "
|
|
6
|
+
Example: read served `Hasu│old` and `arvm│old2`. Call { "remove_from": "Hasu", "remove_to": "arvm", "text": "new line 1\nnew line 2" }. The post-edit diff shows `-Hasu│old`, `-arvm│old2`, `+Qwer│new line 1`: the `-` rows are dead anchors now; the `+` and ` ` rows are live anchors for the next edit.
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
- `copy`/`move`: for a block transfer, only the source's two boundary rows and the destination line need serving; the interior transfers verbatim and the block lands in one commit. To append at the end, use the destination's last served line as `insert_after`.
|
|
2
|
+
- `move`: a batched cross-file move does not display the source file's diff; its removal commits with the batch, and the source file needs a read for fresh anchors.
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
- `undo_last_change`:
|
|
1
|
+
- `undo_last_change`: a `write` clears the history, so undo right after a bad diff — review the diff's `-anchor│` rows first to confirm what you're restoring.
|
|
2
2
|
- `undo_last_change`: a cross-file `move` records one undo entry per file; undo both the source and the destination to revert the whole move, because undoing one side alone leaves the moved lines duplicated or missing.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
Single-level undo: reverts a file's last `replace`, `
|
|
1
|
+
Single-level undo: reverts a file's last `replace`, `replace_match`, `insert`, `copy`, or `move`
|