pi-hashline-edit-pro 4.4.3 → 4.5.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 +312 -63
- package/index.ts +22 -6
- package/package.json +4 -3
- package/prompts/copy-guidelines.md +1 -0
- package/prompts/copy-snippet.md +1 -0
- package/prompts/copy.md +3 -0
- package/prompts/grep.md +1 -1
- package/prompts/insert.md +1 -1
- package/prompts/move-guidelines.md +1 -0
- package/prompts/move-snippet.md +1 -0
- package/prompts/move.md +3 -0
- package/prompts/read-guidelines.md +2 -1
- package/prompts/replace-guidelines.md +0 -2
- package/prompts/replace.md +1 -1
- package/prompts/undo-last-change-guidelines.md +2 -1
- package/prompts/undo-last-change-snippet.md +1 -1
- package/prompts/undo-last-change.md +1 -1
- package/src/anchor-registry.ts +128 -39
- package/src/auto-read-all.ts +3 -1
- package/src/batch.ts +35 -7
- package/src/commit.ts +22 -4
- package/src/config-ui.ts +2 -1
- package/src/config.ts +6 -1
- package/src/constants.ts +4 -1
- package/src/copy-move.ts +793 -0
- package/src/edit-common.ts +21 -1
- package/src/file-reader.ts +4 -0
- package/src/grep.ts +3 -1
- package/src/hash-store/validation.ts +22 -0
- package/src/hash-store.ts +33 -5
- package/src/hashline/apply.ts +8 -3
- package/src/hashline/index.ts +3 -0
- package/src/hashline/parse.ts +22 -10
- package/src/hashline/resolve.ts +23 -8
- package/src/insert.ts +28 -10
- package/src/line-endings.ts +151 -0
- package/src/paths.ts +23 -3
- package/src/payload-contract.ts +29 -1
- package/src/read.ts +7 -5
- package/src/replace-render.ts +4 -4
- package/src/replace-undo.ts +13 -4
- package/src/replace.ts +17 -5
- package/src/utils.ts +40 -5
package/README.md
CHANGED
|
@@ -1,12 +1,75 @@
|
|
|
1
1
|
# pi-hashline-edit-pro
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+

|
|
4
4
|
|
|
5
|
-
pi-hashline-edit-pro
|
|
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
|
+
|
|
7
|
+
pi-hashline-edit-pro is an extension for [pi-coding-agent](https://github.com/earendil-works/pi) that edits files by anchor. Every line a tool serves gets a unique 4-character anchor, and you edit by anchor. Edits are never addressed by line number and nothing is fuzzy-matched, so an edit lands on the line you meant.
|
|
6
8
|
|
|
7
9
|
It is a fork of [pi-hashline-edit](https://github.com/RimuruW/pi-hashline-edit) by RimuruW, extended with 4-character tokenizer-friendly anchors and allocation-based anchor identity.
|
|
8
10
|
|
|
9
|
-
|
|
11
|
+
```text
|
|
12
|
+
read a file; every line comes back as anchor│content:
|
|
13
|
+
|
|
14
|
+
Dafo│function hello() {
|
|
15
|
+
Emno│ console.log("world");
|
|
16
|
+
HDtm│}
|
|
17
|
+
|
|
18
|
+
replace one line by its anchor:
|
|
19
|
+
|
|
20
|
+
{ "remove_from": "Emno", "remove_to": "Emno", "replacement_lines": [" console.log('hi');"] }
|
|
21
|
+
|
|
22
|
+
the result is the post-edit diff with fresh anchors, so the next edit needs no re-read.
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Contents
|
|
26
|
+
|
|
27
|
+
- [Why anchors](#why-anchors)
|
|
28
|
+
- [Install](#install)
|
|
29
|
+
- [Quickstart](#quickstart)
|
|
30
|
+
- [Tools](#tools)
|
|
31
|
+
- [read](#read)
|
|
32
|
+
- [replace](#replace)
|
|
33
|
+
- [insert](#insert)
|
|
34
|
+
- [copy](#copy)
|
|
35
|
+
- [move](#move)
|
|
36
|
+
- [anchor_grep](#anchor_grep)
|
|
37
|
+
- [undo_last_change](#undo_last_change)
|
|
38
|
+
- [Batching](#batching)
|
|
39
|
+
- [Auto-read](#auto-read)
|
|
40
|
+
- [Auto-read all](#auto-read-all)
|
|
41
|
+
- [Configuration](#configuration)
|
|
42
|
+
- [Limits](#limits)
|
|
43
|
+
- [Tool result details](#tool-result-details)
|
|
44
|
+
- [Error and warning codes](#error-and-warning-codes)
|
|
45
|
+
- [Troubleshooting](#troubleshooting)
|
|
46
|
+
- [Privacy and on-disk state](#privacy-and-on-disk-state)
|
|
47
|
+
- [How anchors work](#how-anchors-work)
|
|
48
|
+
- [Benchmark](#benchmark)
|
|
49
|
+
- [Development](#development)
|
|
50
|
+
- [Credits](#credits)
|
|
51
|
+
- [License](#license)
|
|
52
|
+
|
|
53
|
+
## Why anchors
|
|
54
|
+
|
|
55
|
+
Line numbers shift when anything above them changes; fuzzy matching can silently pick a similar-looking line. Anchors avoid both problems:
|
|
56
|
+
|
|
57
|
+
| Dimension | Line numbers or fuzzy matching | Anchors (this extension) |
|
|
58
|
+
| --- | --- | --- |
|
|
59
|
+
| Address | a line number | a 4-character anchor minted for one line |
|
|
60
|
+
| 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 |
|
|
61
|
+
| 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 |
|
|
62
|
+
| Stale request | can apply silently | refused with `[E_STALE_ANCHOR]` or `[E_RANGE_STALE]`, with fresh anchors returned for the retry |
|
|
63
|
+
|
|
64
|
+
What "pro" adds over upstream `pi-hashline-edit`: the anchor table is built from pieces that each tokenize as one token (so an anchored row and an edit call stay cheap in tokens), and anchors are allocated per line and never derived from content, so byte-identical lines never share an anchor and an edit cannot re-target a different line.
|
|
65
|
+
|
|
66
|
+
## Install
|
|
67
|
+
|
|
68
|
+
Prerequisites:
|
|
69
|
+
|
|
70
|
+
- [pi-coding-agent](https://github.com/earendil-works/pi) `>= 0.84.0` (`@earendil-works/pi-coding-agent`).
|
|
71
|
+
- Node.js 22.19 or newer, or a Bun build that ships `bun:sqlite`.
|
|
72
|
+
- An SQLite runtime. The extension uses `node:sqlite` on Node 22.19+ and falls back to `bun:sqlite`. The pi release binary's bundled Bun lacks `node:sqlite`, so run pi under Node or a Bun build that ships SQLite. Without a runtime, every tool fails with `[E_STORE_UNAVAILABLE]`.
|
|
10
73
|
|
|
11
74
|
```bash
|
|
12
75
|
pi install npm:pi-hashline-edit-pro
|
|
@@ -18,9 +81,22 @@ To install from a local checkout:
|
|
|
18
81
|
pi install /path/to/pi-hashline-edit-pro
|
|
19
82
|
```
|
|
20
83
|
|
|
21
|
-
|
|
84
|
+
### What changes in your session
|
|
85
|
+
|
|
86
|
+
| Built-in | Effect |
|
|
87
|
+
| --- | --- |
|
|
88
|
+
| `read` | overridden, returns `anchor│content` rows |
|
|
89
|
+
| `edit` | disabled |
|
|
90
|
+
| `grep` | disabled while `anchor_grep` is enabled |
|
|
91
|
+
| `copy`, `move` | disabled while Copy/move is off |
|
|
92
|
+
| `write` | kept; an auto-read block with fresh anchors is appended to its result |
|
|
93
|
+
| `bash` | untouched |
|
|
94
|
+
|
|
95
|
+
If anything of yours expects line-numbered `read` output (prompts, skills, hooks), account for the override before installing.
|
|
22
96
|
|
|
23
|
-
|
|
97
|
+
### Verify
|
|
98
|
+
|
|
99
|
+
After install, `read` any file and confirm the rows look like this:
|
|
24
100
|
|
|
25
101
|
```text
|
|
26
102
|
Dafo│function hello() {
|
|
@@ -28,19 +104,56 @@ Emno│ console.log("world");
|
|
|
28
104
|
HDtm│}
|
|
29
105
|
```
|
|
30
106
|
|
|
31
|
-
|
|
107
|
+
Then replace `Emno` and confirm the result is a post-edit diff with fresh anchors.
|
|
32
108
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
"replacement_lines": [" console.log('hi');"]
|
|
38
|
-
}
|
|
109
|
+
### Uninstall
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
pi uninstall npm:pi-hashline-edit-pro
|
|
39
113
|
```
|
|
40
114
|
|
|
41
|
-
|
|
115
|
+
## Quickstart
|
|
116
|
+
|
|
117
|
+
1. Read a file. Every line comes back as `anchor│content`:
|
|
118
|
+
|
|
119
|
+
```text
|
|
120
|
+
Dafo│function hello() {
|
|
121
|
+
Emno│ console.log("world");
|
|
122
|
+
HDtm│}
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
2. Replace a line by its anchor. One edit per call, fields at the top level:
|
|
126
|
+
|
|
127
|
+
```json
|
|
128
|
+
{
|
|
129
|
+
"remove_from": "Emno",
|
|
130
|
+
"remove_to": "Emno",
|
|
131
|
+
"replacement_lines": [" console.log('hi');"]
|
|
132
|
+
}
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
3. Read the post-edit diff. `-anchor│` rows are dead anchors; `+anchor│` and ` anchor│` rows are live, so you can keep editing without re-reading:
|
|
136
|
+
|
|
137
|
+
```text
|
|
138
|
+
...
|
|
139
|
+
-Emno│ console.log("world");
|
|
140
|
+
+Qwer│ console.log('hi');
|
|
141
|
+
...
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
4. Revert the last `replace` or `insert` on the file with `undo_last_change`, whose one argument is the path:
|
|
145
|
+
|
|
146
|
+
```json
|
|
147
|
+
{ "path": "src/hello.ts" }
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
Undo is single-level and survives restarts.
|
|
151
|
+
|
|
152
|
+
Nothing commits until an edit call returns: the extension validates the request before touching the file, and refuses the edit when the file's served range changed on disk.
|
|
153
|
+
|
|
154
|
+
## Tools
|
|
42
155
|
|
|
43
|
-
The extension registers
|
|
156
|
+
The extension registers seven tools: `read`, `replace`, `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`, `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`, `insert`, `copy`, and `move` for RPC visibility (for example pimacs.el); anchors still resolve the target and `path` must match.
|
|
44
157
|
|
|
45
158
|
### read
|
|
46
159
|
|
|
@@ -73,7 +186,7 @@ Edge cases:
|
|
|
73
186
|
| --- | --- |
|
|
74
187
|
| `remove_from` | 4-char anchor marking the FIRST line to remove (inclusive). |
|
|
75
188
|
| `remove_to` | 4-char anchor marking the LAST line to remove (inclusive). |
|
|
76
|
-
| `replacement_lines` | Replacement lines, one element per line. Mirror the removed lines exactly, blank lines included: `[]` deletes the range, `[""]` is a single blank line, `["a", ""]` is a line followed by a blank line.
|
|
189
|
+
| `replacement_lines` | Replacement lines, one element per line. Mirror the removed lines exactly, blank lines included: `[]` deletes the range, `[""]` is a single blank line, `["a", ""]` is a line followed by a blank line. One element is one line: a real line-break character (`\n`, `\r\n`, or `\r`) splits it and sets that line's ending; escape sequences such as `\n` or `\u200b` are not decoded. A lone string is accepted too: it is split on newlines, and stringified array text is unwrapped. |
|
|
77
190
|
|
|
78
191
|
Example: read showed `Hasu│old` and `arvm│old2`; to replace both:
|
|
79
192
|
|
|
@@ -87,21 +200,18 @@ Example: read showed `Hasu│old` and `arvm│old2`; to replace both:
|
|
|
87
200
|
|
|
88
201
|
Single line: use the same anchor for `remove_from` and `remove_to`. `replace_from`/`replace_to` and `from`/`to` work as aliases.
|
|
89
202
|
|
|
90
|
-
The
|
|
203
|
+
The extension checks the request before any file I/O, so a bad request never touches the file.
|
|
204
|
+
|
|
205
|
+
Auto-fixable slips fall into two groups. Fixed silently: a reversed range, stringified array text (even with a trailing JS method call, for example `[…].map(s => s)`), and embedded newlines. Fixed with a warning: a leftover `anchor│` prefix in `replacement_lines` or the anchor fields (a prefix of 4 to 5 letters before `│`, for example `abde│`), and diff-preview rows pasted into the replacement.
|
|
91
206
|
|
|
92
|
-
Common copy-paste slips are fixed automatically: a leftover `anchor│` prefix in `replacement_lines` or the anchor fields (a prefix of 4 to 5 letters before `│`, for example `abde│`), and diff-preview rows pasted into the replacement are reported as warnings, while a reversed range, stringified array text (even with a trailing JS method call, for example `[…].map(s => s)`), and embedded newlines are corrected silently.
|
|
93
207
|
Content containing a NUL byte (`U+0000`) is rejected with `[E_BAD_SHAPE]` before any file I/O: writing it would make the file binary, so use an empty replacement to delete. This applies to `replace`'s `replacement_lines` and `insert`'s `lines`.
|
|
94
208
|
|
|
95
|
-
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
|
|
209
|
+
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`.
|
|
96
210
|
|
|
97
211
|
An edit that produces identical content reports `No changes made` and leaves the anchors alone.
|
|
98
212
|
|
|
99
213
|
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`.
|
|
100
214
|
|
|
101
|
-
Multiple `replace` and `insert` calls on the same file in one assistant message are grouped per file into one batch. The batch unit is the message, not the turn: calls from separate messages in the same turn run solo, one after another. A solo edit commits before its result returns; a batch validates every call against the pre-batch state and commits once, during the batch's last call: earlier calls reply `In batch N` and the batch's last call shows the combined diff, with one undo reverting the whole batch. If the batch aborts, an earlier member's row renders the abort message instead of the placeholder. Nothing commits at turn end. A batch member accepts the same request shapes and auto-fixes as a solo call.
|
|
102
|
-
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`.
|
|
103
|
-
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]`. A call whose anchors resolve nowhere never joins a batch: it runs solo 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, join 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.
|
|
104
|
-
|
|
105
215
|
### insert
|
|
106
216
|
|
|
107
217
|
`insert` adds lines after or before an existing line without removing anything. Like `replace`, there is no `path` parameter.
|
|
@@ -110,12 +220,42 @@ Batched calls must target disjoint ranges; overlapping ranges, or any failing ca
|
|
|
110
220
|
| --- | --- |
|
|
111
221
|
| `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. |
|
|
112
222
|
| `direction` | `"after"` inserts below the anchor line, `"before"` above it. |
|
|
113
|
-
| `lines` | Lines to insert, one element per line. `[""]` is a blank line. Never include the anchor line
|
|
223
|
+
| `lines` | Lines to insert, one element per line. `[""]` is a blank line. Never include the anchor line. One element is one line: a real line-break character (`\n`, `\r\n`, or `\r`) splits it and sets that line's ending; escape sequences such as `\n` or `\u200b` are not decoded. A lone string is split on newlines, and stringified array text is unwrapped. |
|
|
114
224
|
|
|
115
225
|
Nothing is removed and the inserted lines are written exactly as given; the anchor line and every other line stay in place. Inserting nothing (`lines: []`) reports a noop. To seed an empty file, read it and insert after the `anchor│` empty-line row.
|
|
116
226
|
|
|
227
|
+
Example: add a line after `Emno│`:
|
|
228
|
+
|
|
229
|
+
```json
|
|
230
|
+
{ "anchor": "Emno", "direction": "after", "lines": [" // log the greeting"] }
|
|
231
|
+
```
|
|
232
|
+
|
|
117
233
|
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.
|
|
118
234
|
|
|
235
|
+
### copy
|
|
236
|
+
|
|
237
|
+
`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.
|
|
238
|
+
|
|
239
|
+
| Field | Description |
|
|
240
|
+
| --- | --- |
|
|
241
|
+
| `source_from` | 4-char anchor marking the FIRST source line to copy (inclusive). |
|
|
242
|
+
| `source_to` | 4-char anchor marking the LAST source line to copy (inclusive). |
|
|
243
|
+
| `insert_after` | 4-char anchor of the destination line after which the copy goes. Within one file it must sit outside the source range; passing `source_to` duplicates the range right after itself. |
|
|
244
|
+
|
|
245
|
+
Example: read served `Hasu│old` in `a.ts` and `Qwer│top` in `b.ts`; to copy `old` into `b.ts` below `top`:
|
|
246
|
+
|
|
247
|
+
```json
|
|
248
|
+
{ "source_from": "Hasu", "source_to": "Hasu", "insert_after": "Qwer" }
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
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.
|
|
252
|
+
|
|
253
|
+
### move
|
|
254
|
+
|
|
255
|
+
`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.
|
|
256
|
+
|
|
257
|
+
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.
|
|
258
|
+
|
|
119
259
|
### anchor_grep
|
|
120
260
|
|
|
121
261
|
`anchor_grep` is an anchored search backed by ripgrep. It is enabled by default; disable it in `/hashline-config` (or set `anchorGrepEnabled` to `false` in the config file). While it is enabled, the built-in grep is disabled. Disabling it removes the tool and restores the built-in grep only if that was active before the extension loaded.
|
|
@@ -140,51 +280,59 @@ Output is capped at `limit` matched lines, 2000 rows, and 50KB of text, whicheve
|
|
|
140
280
|
|
|
141
281
|
### undo_last_change
|
|
142
282
|
|
|
143
|
-
`undo_last_change` reverts the most recent successful `replace` or `
|
|
283
|
+
`undo_last_change` reverts the most recent successful `replace`, `insert`, `copy`, or `move` on a file, restoring the exact previous content, BOM and line endings included, plus the previous anchors.
|
|
144
284
|
|
|
145
|
-
- History is per-file and single-level: only the most recent `replace` or `
|
|
285
|
+
- History is per-file and single-level: only the most recent `replace`, `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.
|
|
146
286
|
- History is persisted and survives session restarts. A failed `write` does not clear it.
|
|
147
|
-
- Every applied `replace` or `
|
|
287
|
+
- Every applied `replace`, `insert`, `copy`, or `move` is undoable; the undo record is saved before the edit is written.
|
|
288
|
+
- 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.
|
|
148
289
|
- A successful `write` clears the history for that file.
|
|
149
290
|
- 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.
|
|
150
291
|
- If the file was deleted since the last edit, `undo_last_change` restores it from the recorded pre-edit content.
|
|
151
292
|
- Missing-file cleanup never touches the undo record. The per-session prune removes snapshots and served records of files that no longer exist (both are recomputed on the next read), but the undo history survives, even when the file is temporarily absent during a branch switch.
|
|
293
|
+
- 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 edit, even one made by another conversation.
|
|
294
|
+
|
|
295
|
+
## Batching
|
|
296
|
+
|
|
297
|
+
Multiple `replace` and `insert` calls on the same file in one assistant message are grouped per file into one batch. The batch unit is the message, not the turn: calls from separate messages in the same turn run on their own, one after another.
|
|
298
|
+
|
|
299
|
+
- A call outside a batch commits before its result returns.
|
|
300
|
+
- A `copy` or `move` call is never grouped into a batch: it commits on its own, and a pending same-file batch aborts safely with `[E_OP_ABORTED]` if the file changed under it.
|
|
301
|
+
- A batch validates every call against the pre-batch state and commits once, during the batch's last call: earlier calls reply `In batch N`, and the batch's last call shows the combined diff, with one undo reverting the whole batch.
|
|
302
|
+
- If a batch aborts, an earlier member's row renders the abort message instead of the placeholder. Nothing commits until the last call succeeds.
|
|
303
|
+
- A batch member accepts the same request shapes and auto-fixes as a standalone call.
|
|
152
304
|
|
|
153
|
-
|
|
305
|
+
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]`.
|
|
306
|
+
|
|
307
|
+
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.
|
|
308
|
+
|
|
309
|
+
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`.
|
|
310
|
+
|
|
311
|
+
## Auto-read
|
|
154
312
|
|
|
155
313
|
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.
|
|
156
314
|
|
|
157
|
-
After `replace`, `insert`, and `undo_last_change`, the result shows the post-edit diff. Inside a same-message batch, only the batch's last call shows the combined diff, headed by a `batch N:` line; earlier calls reply `In batch N`. The `+anchor│` and ` anchor│` rows carry the current anchors, so follow-up edits can anchor on the diff directly. The `-anchor│` rows show removed lines with their old anchors, which are stale after the edit. When the context line next to a change is blank or whitespace-only, one more context line is shown in that direction, so the change stays anchored to visible content. Call `read` when you want the full file's anchors.
|
|
315
|
+
After `replace`, `insert`, `copy`, `move`, and `undo_last_change`, the result shows the post-edit diff. Inside a same-message batch, only the batch's last call shows the combined diff, headed by a `batch N:` line; earlier calls reply `In batch N`. The `+anchor│` and ` anchor│` rows carry the current anchors, so follow-up edits can anchor on the diff directly. The `-anchor│` rows show removed lines with their old anchors, which are stale after the edit. When the context line next to a change is blank or whitespace-only, one more context line is shown in that direction, so the change stays anchored to visible content. Call `read` when you want the full file's anchors.
|
|
158
316
|
|
|
159
317
|
Auto-read keeps the same 50KB and 2000-line budget as `read`. Auto-read and Diff context live in `/hashline-config` and persist across sessions. The post-edit diff shows 1 surrounding line by default; change Diff context in `/hashline-config` (0-10, needs Auto-read) to show more or fewer.
|
|
160
318
|
|
|
161
|
-
|
|
319
|
+
## Auto-read all
|
|
162
320
|
|
|
163
321
|
Auto-read all is off by default and has three modes, selected in `/hashline-config`: `off` injects nothing, `on` discovers every file in the working directory that is not git-ignored (`git ls-files`, falling back to `ripgrep`, then to a directory walk), and `git` uses `git ls-files` only, injecting nothing when the working directory is not a git repository. On the first turn of a session, the extension discovers the files, reads each one, and attaches the resulting `anchor│content` rows to the conversation as one extension message before the model answers. Those anchors are served exactly like `read` output, so the model can `replace` and `insert` immediately without calling `read` first. The message is injected once per session; resumed, forked, and cloned sessions that already contain it skip the injection.
|
|
164
322
|
|
|
165
323
|
Files are filtered before injection: symlinks, directories, image extensions (including SVG), binary files (a NUL byte in the first 8KB), files over 200KB, any path with a vendored segment (vendor, node_modules, bower_components, third_party, thirdparty, jspm_packages, .venv, venv, site-packages, __pycache__, .tox, .gradle, .terraform, Pods, Carthage, DerivedData, coreui, coreui-icons, case-insensitive), and vendored or generated names and patterns (*.min.js, *.min.css, *.min.mjs, *-min.js, *-min.css, *.bundle.*, *.chunk.*, *.umd.js, *.map, *.lock, package-lock.json, yarn.lock, composer.lock, Gemfile.lock, Cargo.lock, poetry.lock, Pipfile.lock, go.sum, flake.lock, *.generated.*, *.gen.*, *_pb2.py, *.pb.go, *.g.dart, *.freezed.dart, *.designer.cs, *.g.cs, *.snap, .eslintcache, coreui-icons.*, coreui.css) are skipped. The attachment stops at 500 files or at a byte budget derived from the model context window (200KB floor, 2MB ceiling), and it never drops below one file. Skipped and not-attached files are named at the end of the message so the model can `read` them on demand.
|
|
166
324
|
|
|
167
|
-
Each attached file is shown as `=== path ===` followed by its `anchor│content` rows
|
|
168
|
-
A coverage line after the header reports the complete count. A `[files complete: [...] omitted: [...]]` line right after it lists every attached complete file and omitted file in one place — check that line instead of scanning sections.
|
|
325
|
+
Each attached file is shown as `=== path ===` followed by its `anchor│content` rows. Edit directly from the attachment with replace and insert, so no `read` is needed. Files attach whole.
|
|
169
326
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
## Tool result details
|
|
327
|
+
A coverage line after the header reports the complete count. A `[files complete: [...] omitted: [...]]` line right after it lists every attached complete file and omitted file in one place. Check that line instead of scanning sections.
|
|
173
328
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
| Tool | `details` |
|
|
177
|
-
| --- | --- |
|
|
178
|
-
| `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`. |
|
|
179
|
-
| `replace`, `insert` | `diff` (post-edit diff, capped, with current anchors on `+HASH│` and ` HASH│` 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), and `metrics`: `edits_attempted`, `edits_noop`, `warnings`, `classification` (`"applied"` or `"noop"`), `changed_lines` (`{ first, last }`), `added_lines`, `removed_lines`. |
|
|
180
|
-
| `undo_last_change` | `diff` (the undo diff with restored anchors), `patch`, `patchTruncated`, and `metrics` in the same shape as `replace`. |
|
|
181
|
-
| `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). |
|
|
329
|
+
The setting lives in `/hashline-config` as Auto-read all and in `config.json` as `autoReadAll` (`"off"`, `"on"`, or `"git"`; older configs with `true` or `false` are read as `"on"` or `"off"`). Extra folders and files are ignored via `/hashline-config` as Ignore folders/files and via `config.json` as `autoReadAllIgnore` (array of folder names, file names, or globs, for example `["docs", "scratch.md", "*.test.ts"]`). A single-segment entry skips any folder or file with that exact name (case-insensitive); an entry containing glob characters (`*`, `?`, `[`, `]`, `{`, `}`) is matched as a glob against the file name, or against the whole relative path when it also contains a slash, with the same syntax as `anchor_grep`'s `glob`; any other entry with a slash (for example `"src/tmp"`) skips that path. Custom ignores are counted with the vendor/name/pattern skips in the footer.
|
|
182
330
|
|
|
183
|
-
##
|
|
331
|
+
## Configuration
|
|
184
332
|
|
|
185
333
|
| Command | Description |
|
|
186
334
|
| --- | --- |
|
|
187
|
-
| `/hashline-config` | Open the settings window: auto-read anchors, auto-read all mode, ignore folders/files, diff context lines, `anchor_grep` tool, required `path`, and strict input. Persists across sessions. |
|
|
335
|
+
| `/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, required `path`, and strict input. Persists across sessions. |
|
|
188
336
|
| `/clear-anchors` | Clear the session's anchor claims. Anchors are re-claimed on the next `read`. |
|
|
189
337
|
|
|
190
338
|
Settings live in `~/.config/pi-hashline-edit-pro/config.json`, created when a setting is first changed in `/hashline-config`:
|
|
@@ -195,41 +343,78 @@ Settings live in `~/.config/pi-hashline-edit-pro/config.json`, created when a se
|
|
|
195
343
|
"autoReadAll": "off",
|
|
196
344
|
"autoReadAllIgnore": [],
|
|
197
345
|
"anchorGrepEnabled": true,
|
|
346
|
+
"copyMoveEnabled": true,
|
|
198
347
|
"requirePath": false,
|
|
199
348
|
"strictInput": false,
|
|
200
349
|
"diffContextLines": 1
|
|
201
350
|
}
|
|
202
351
|
```
|
|
203
352
|
|
|
204
|
-
|
|
353
|
+
| Key | `/hashline-config` label | Default | Effect |
|
|
354
|
+
| --- | --- | --- | --- |
|
|
355
|
+
| `autoRead` | Auto-read | `true` | Append the auto-read block after `write` and show post-edit diffs. |
|
|
356
|
+
| `autoReadAll` | Auto-read all | `"off"` | Attachment mode: `"off"`, `"on"`, or `"git"`. |
|
|
357
|
+
| `autoReadAllIgnore` | Ignore folders/files | `[]` | Extra folder names, file names, or globs skipped by auto-read all. |
|
|
358
|
+
| `anchorGrepEnabled` | Anchor grep | `true` | Register `anchor_grep` and disable the built-in grep while it is on. |
|
|
359
|
+
| `copyMoveEnabled` | Copy/move | `true` | Offer the `copy` and `move` tools; when off, both are removed from the active tools. |
|
|
360
|
+
| `requirePath` | Require path | `false` | `replace` and `insert` require a `path` argument that must match anchor ownership. |
|
|
361
|
+
| `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. |
|
|
362
|
+
| `diffContextLines` | Diff context | `1` | Surrounding lines in post-edit diffs, 0-10 (needs Auto-read). |
|
|
363
|
+
|
|
364
|
+
When `PI_HASHLINE_DIR` is unset or empty, non-Windows platforms honor `XDG_CONFIG_HOME` when set (falling back to `~/.config`); on Windows the directory always uses `~/.config`, where `~` is `%USERPROFILE%`. To move the directory explicitly, see [Isolated state](#isolated-state).
|
|
365
|
+
|
|
366
|
+
## Limits
|
|
367
|
+
|
|
368
|
+
| Limit | Value | Applies to |
|
|
369
|
+
| --- | --- | --- |
|
|
370
|
+
| Output cap | 2000 lines and 50KB | `read`, auto-read after `write`, post-edit diffs, patches, previews, `details.patch` |
|
|
371
|
+
| Oversized row | 50KB per `anchor│content` row | replaced by an anchor-keeping marker you can still edit through |
|
|
372
|
+
| Line cap | 1,353,139 lines per file | `read`, `replace`, `insert` (`[E_FILE_TOO_LARGE]`) |
|
|
373
|
+
| File size | 100MB | all tools (`[E_FILE_TOO_LARGE]`) |
|
|
374
|
+
| Hash window | first 500 bytes of a line | anchor identity for long lines |
|
|
375
|
+
| Patch guard | 1MB of pre-edit + post-edit text | patch generation is skipped and `patchTruncated` is set |
|
|
376
|
+
| Grep matches | 100 matched lines by default (`limit`) | `anchor_grep` |
|
|
377
|
+
| Grep output | 2000 rows and 50KB | `anchor_grep` |
|
|
378
|
+
| Grep row fragment | 500 bytes | long match and context rows shown as `...` fragments |
|
|
379
|
+
| Auto-read all files | 500 files, 200KB per file | files larger than 200KB and files past the cap are omitted |
|
|
380
|
+
| Auto-read all budget | 200KB floor, 2MB ceiling | total injection size, derived from the model context window |
|
|
381
|
+
| Stale-range feedback | first 100 lines | rows returned with `[E_RANGE_STALE]` |
|
|
382
|
+
| Session anchors | 1,353,139 anchors in use | all tools; the least recently read or edited files are freed when the quota is exhausted (`[W_ANCHOR_RECLAIMED]`) |
|
|
383
|
+
|
|
384
|
+
`anchor_grep` uses the same 100MB file-size cutoff as `read`. Files over the line cap are skipped silently in directory searches.
|
|
205
385
|
|
|
206
|
-
##
|
|
207
|
-
|
|
208
|
-
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 large stride (roughly the golden ratio of the anchor space) coprime to it, 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, or you ran `/clear-anchors`). 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: at most 1,353,139 lines per file, beyond which `read`, `replace`, and `insert` reject with `[E_FILE_TOO_LARGE]` (use `write` for very large files).
|
|
209
|
-
|
|
210
|
-
The table is curated for tokenizers, not for humans. Every anchor is the concatenation of two 2-character pieces that each encode as a single token, and beside the `│` separator the whole 5-character `anchor│` unit is verified to tokenize as exactly three tokens in each of eight modern open-weights tokenizers (Qwen 3.5, DeepSeek V4, Gemma 4, GLM 5.3 Flash, Tencent Hy4-preview, MiniMax M3, MiMo V2.5, Kimi K3). The shipped table is the intersection that satisfies the criterion on all of them; Nemotron 3 Ultra is the one modern tokenizer excluded. An anchor therefore costs 2 tokens on a read row and 2 in an edit call, with the `│` separator as the third. Anchors are letters only. The table is shipped as `src/hashline/anchor-table.json`.
|
|
211
|
-
|
|
212
|
-
Each line also carries a content checksum. The line is canonicalized (carriage returns stripped, trailing whitespace trimmed) and hashed with [xxhash-wasm](https://github.com/jungomi/xxhash-wasm). The canonicalization keeps the checksum stable across editor-save cycles that add or remove trailing whitespace. A line over 500 bytes is hashed from its first 500 bytes.
|
|
386
|
+
## Tool result details
|
|
213
387
|
|
|
214
|
-
|
|
388
|
+
All seven tools return machine-readable metadata in `details` alongside the model-visible text.
|
|
215
389
|
|
|
216
|
-
|
|
390
|
+
| Tool | `details` |
|
|
391
|
+
| --- | --- |
|
|
392
|
+
| `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`. |
|
|
393
|
+
| `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), and `metrics`: `edits_attempted`, `edits_noop`, `warnings`, `classification` (`"applied"` or `"noop"`), `changed_lines` (`{ first, last }`), `added_lines`, `removed_lines`. |
|
|
394
|
+
| `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. |
|
|
395
|
+
| `undo_last_change` | `diff` (the undo diff with restored anchors), `patch`, `patchTruncated`, and `metrics` in the same shape as `replace`. |
|
|
396
|
+
| `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). |
|
|
217
397
|
|
|
218
|
-
|
|
398
|
+
`snapshotId`, `firstChangedLine`, and `metrics.warnings` are the three fields external consumers most often read; `snapshotId` is a string fingerprint, `firstChangedLine` is a 1-based line number on the result file, and `metrics.warnings` counts the `[W_*]` notices in `details.warnings`.
|
|
219
399
|
|
|
220
|
-
|
|
221
|
-
- "Replace X with X" doesn't rotate the anchor: a line whose content is unchanged after an edit keeps its allocated anchor positionally. Every other line is minted fresh, so an anchor is never assigned by content matching.
|
|
400
|
+
## Error and warning codes
|
|
222
401
|
|
|
223
|
-
|
|
402
|
+
Codes starting with `E_` are errors: nothing was written, with one exception. `File was written; anchor finalization failed` means the file was written and one undo reverts it. Codes starting with `W_` are warnings: the call succeeded with an auto-fix notice or an anchor-reclaim notice; check `classification` (`applied` vs `noop`) in `details.metrics` to tell whether bytes changed. `[E_AUTO_READ_ALL]` is informational rather than a failure: the `read` was refused because the file is unchanged since the start-of-session auto-read, so the attached content is still exact.
|
|
224
403
|
|
|
225
|
-
|
|
404
|
+
Most common, with the fix:
|
|
226
405
|
|
|
227
|
-
|
|
406
|
+
- `[E_STALE_ANCHOR]`: the anchor is not owned in this session. Call `read` for fresh anchors and retry.
|
|
407
|
+
- `[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.
|
|
408
|
+
- `[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.
|
|
409
|
+
- `[E_STORE_UNAVAILABLE]`: no SQLite runtime. Run pi under Node 22.19+ or a Bun build that ships `bun:sqlite`.
|
|
410
|
+
- `[E_WRITE_HASH_ECHO]`: a `write` content line contains a copied served row. Remove the anchors and retry.
|
|
411
|
+
- `[E_GREP_TIMEOUT]`: ripgrep timed out after 10 seconds. Narrow `path` or simplify `pattern` and retry.
|
|
228
412
|
|
|
229
|
-
|
|
413
|
+
Full reference:
|
|
230
414
|
|
|
231
415
|
| Code | Meaning |
|
|
232
416
|
| --- | --- |
|
|
417
|
+
| `[E_CONFIG]` | `PI_HASHLINE_DIR` is nonempty but not an absolute path. |
|
|
233
418
|
| `[E_BAD_SHAPE]` | Request envelope or edit item has unknown, missing, or wrongly-typed fields (for example `replacement_lines` must be an array of strings, one element per line), content contains a NUL byte (`U+0000`), which would make the file binary, or a grep `glob` has invalid bracket or brace syntax. |
|
|
234
419
|
| `[W_BAD_SHAPE]` | Auto-corrected request slip reported as a warning (for example stringified array text that could not be parsed and was kept as one literal line). |
|
|
235
420
|
| `[E_BAD_REF]` | An anchor in `remove_from`/`remove_to` is not a bare 4-character anchor (the anchor table is letters only). |
|
|
@@ -237,7 +422,9 @@ Codes starting with `E_` are errors: nothing was written — except `File was wr
|
|
|
237
422
|
| `[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. |
|
|
238
423
|
| `[W_INVALID_PATCH]` | A `replacement_lines` element is a diff-preview row (`+anchor│`, `-anchor│`, `- │`). The marker is stripped automatically with a warning. |
|
|
239
424
|
| `[W_BARE_HASH_PREFIX]` | A `replacement_lines` element starts with an `anchor│` prefix. The prefix is stripped automatically with a warning. |
|
|
240
|
-
| `[
|
|
425
|
+
| `[W_LITERAL_ESCAPE]` | `lines` or `replacement_lines` contains literal escape text such as `\u200b` or `\n`; the file receives those characters as written. Decode the escapes first if the text came from a quoted prompt. |
|
|
426
|
+
| `[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. |
|
|
427
|
+
| `[E_WOULD_EMPTY]` | An edit would empty a non-empty file; use `write` instead. A cross-file `move` may empty its source file. |
|
|
241
428
|
| `[E_NOT_FOUND]` | The path does not exist. |
|
|
242
429
|
| `[E_ACCESS]` | The file is not readable or writable. |
|
|
243
430
|
| `[E_NOT_TEXT]` | The path is a directory, binary file, image, or UTF-16/UTF-32 encoded text; hashline editing only supports text files. |
|
|
@@ -266,6 +453,66 @@ Codes starting with `E_` are errors: nothing was written — except `File was wr
|
|
|
266
453
|
- Corrupt store. If the store fails its health check it is renamed to `hash-store.sqlite.corrupt-<timestamp>` and rebuilt automatically.
|
|
267
454
|
- 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.
|
|
268
455
|
- 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`, `insert`, and `undo_last_change` still write the edit.
|
|
456
|
+
- 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).
|
|
457
|
+
|
|
458
|
+
## Privacy and on-disk state
|
|
459
|
+
|
|
460
|
+
All state lives under the config directory (see [Configuration](#configuration)):
|
|
461
|
+
|
|
462
|
+
| Path | Contents |
|
|
463
|
+
| --- | --- |
|
|
464
|
+
| `config.json` | The settings from [Configuration](#configuration). |
|
|
465
|
+
| `hash-store.sqlite` (+ `-wal`, `-shm`) | Per-file snapshots of allocated anchors keyed by content checksum, plus the undo table. |
|
|
466
|
+
| `sessions/<key>.registry.jsonl` | The session's anchor ownership log (`allocate`/`free`/`clear` events). |
|
|
467
|
+
|
|
468
|
+
The undo table contains the complete pre-edit and post-edit text for the latest edit to each file, so treat the store as sensitive data. On POSIX systems the state directory is restricted to mode `0700` and the SQLite database plus its WAL/SHM sidecars to `0600`. Sidecar logs whose session file is gone are garbage-collected at startup (never the sidecar of a session that is currently loaded in this process); the in-memory ownership of a session is released when that session shuts down and rebuilt from its sidecar on next use. Served records live in memory only and are recomputed on the next read.
|
|
469
|
+
|
|
470
|
+
### Isolated state
|
|
471
|
+
|
|
472
|
+
Set `PI_HASHLINE_DIR` before starting pi to an absolute directory to override only this extension's state directory on all platforms. An unset or empty value preserves the XDG/home defaults; a nonempty relative value is rejected with `[E_CONFIG]`. Keep the value fixed for the lifetime of the process; the database remains a process-wide singleton.
|
|
473
|
+
|
|
474
|
+
Config, SQLite (including WAL/SHM and undo history), registry sidecars, and the legacy `hash-store.json` location all follow the override. A fresh directory starts without shared config or history: no data is copied or imported from the default directory. Legacy migration, if needed, reads only `hash-store.json` inside the selected directory. Use a separate, access-restricted directory for each isolation scope; undo contains full file text.
|
|
475
|
+
|
|
476
|
+
Background snapshot pruning and registry sidecar GC skip `EPERM`/`EACCES` without deleting records or logging each inaccessible path. Unexpected errors remain visible; tool file-access failures and SQLite errors are not silenced.
|
|
477
|
+
|
|
478
|
+
## How anchors work
|
|
479
|
+
|
|
480
|
+
### Allocation
|
|
481
|
+
|
|
482
|
+
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 26⁴ letter combinations), so a file can hold at most 1,353,139 lines, beyond which `read`, `replace`, and `insert` reject with `[E_FILE_TOO_LARGE]` (use `write` for very large files).
|
|
483
|
+
|
|
484
|
+
### Ownership and mapping across edits
|
|
485
|
+
|
|
486
|
+
When a range is edited, the mapping between old and new content is computed per span: lines whose content is unchanged keep their allocated anchors, anchors of removed lines are freed, and every genuinely new line is minted a fresh anchor. Anchors are never assigned by matching content; only positional survival across an edit preserves one.
|
|
487
|
+
|
|
488
|
+
Two guarantees make this safe even with duplicated content:
|
|
489
|
+
|
|
490
|
+
- An edited range never borrows an anchor from a line outside it. Lines outside the replaced range keep their anchors unconditionally, even when their content is byte-identical to lines inside the range.
|
|
491
|
+
- "Replace X with X" doesn't rotate the anchor: a line whose content is unchanged after an edit keeps its allocated anchor positionally. Every other line is minted fresh, so an anchor is never assigned by content matching.
|
|
492
|
+
|
|
493
|
+
A no-op replace never changes the file, so anchors remain valid. On first run after upgrading from an older version, the previous `hash-store.json` is imported once and renamed to `hash-store.json.bak`.
|
|
494
|
+
|
|
495
|
+
### The anchor table is built for tokenizers
|
|
496
|
+
|
|
497
|
+
Every anchor is the concatenation of two 2-character pieces that each encode as a single token, and beside the `│` separator the whole 5-character `anchor│` unit is verified to tokenize as exactly three tokens in each of eight modern open-weights tokenizers (Qwen 3.5, DeepSeek V4, Gemma 4, GLM 5.3 Flash, Tencent Hy4-preview, MiniMax M3, MiMo V2.5, Kimi K3). The shipped table is the intersection that satisfies the criterion on all of them; Nemotron 3 Ultra is the one modern tokenizer excluded. Model names are as published for the 4.4.x table build. An anchor therefore costs 2 tokens on a read row and 2 in an edit call, with the `│` separator as the third. Anchors are letters only. The table is shipped as `src/hashline/anchor-table.json`.
|
|
498
|
+
|
|
499
|
+
### Line checksums
|
|
500
|
+
|
|
501
|
+
Each line also carries a content checksum. The line is canonicalized (carriage returns stripped, trailing whitespace trimmed) and hashed with [xxhash-wasm](https://github.com/jungomi/xxhash-wasm). The canonicalization keeps the checksum stable across editor-save cycles that add or remove trailing whitespace. A line over 500 bytes is hashed from its first 500 bytes.
|
|
502
|
+
|
|
503
|
+
### Persistent state
|
|
504
|
+
|
|
505
|
+
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.
|
|
506
|
+
|
|
507
|
+
## Benchmark
|
|
508
|
+
|
|
509
|
+
pi-hashline-edit-pro is measured on the [Explicit Edit Benchmark](https://github.com/alexshpunt/explicit-edit-benchmark), an open third-party benchmark maintained by [alexshpunt](https://github.com/alexshpunt). The suite has 226 deterministic, byte-exact editing tasks across many edit shapes and file types. A verifier compares the result file tree byte by byte, so nothing is graded on compilation or behavioral equivalence. Many harnesses are scored on it.
|
|
510
|
+
|
|
511
|
+
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.
|
|
512
|
+
|
|
513
|
+
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.
|
|
514
|
+
|
|
515
|
+
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.
|
|
269
516
|
|
|
270
517
|
## Development
|
|
271
518
|
|
|
@@ -273,7 +520,9 @@ Requires [Node.js](https://nodejs.org) 22.19 or newer and npm.
|
|
|
273
520
|
|
|
274
521
|
```bash
|
|
275
522
|
npm install
|
|
276
|
-
npm test
|
|
523
|
+
npm test # full suite
|
|
524
|
+
npm run test:unit # unit suite (heavy stress and property tests excluded)
|
|
525
|
+
npm run test:coverage # full suite with coverage thresholds
|
|
277
526
|
npm run lint
|
|
278
527
|
npm run typecheck
|
|
279
528
|
```
|