pi-hashline-edit-pro 6.5.1 → 7.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +86 -64
- package/index.ts +31 -28
- package/package.json +2 -2
- package/src/anchor-registry.ts +15 -22
- package/src/auto-read-all.ts +3 -3
- package/src/config-ui.ts +6 -1
- package/src/config.ts +6 -1
- package/src/constants.ts +1 -1
- package/src/copy-move.ts +11 -10
- package/src/edit-common.ts +23 -21
- package/src/edit-fidelity.ts +3 -3
- package/src/grep.ts +0 -9
- package/src/hashline/alphabet.ts +3 -3
- package/src/hashline/anchor-table.json +1 -1
- package/src/hashline/parse.ts +21 -14
- package/src/hashline/resolve.ts +23 -9
- package/src/insert.ts +7 -6
- package/src/model-gate.ts +1 -0
- package/src/outline/parsers.ts +0 -1
- package/src/outline/render.ts +154 -82
- package/src/outline/vendor/README.md +4 -0
- package/src/outline/vendor/parsers/blade.ts +1 -1
- package/src/outline/vendor/parsers/java.ts +144 -331
- package/src/outline/vendor/parsers/manager.ts +12 -183
- package/src/outline/vendor/parsers/php.ts +16 -81
- package/src/outline/vendor/parsers/sass.ts +0 -11
- package/src/outline/vendor/parsers/scss.ts +0 -8
- package/src/outline/vendor/types.ts +0 -1
- package/src/outline-tool.ts +184 -0
- package/src/payload-contract.ts +27 -15
- package/src/prompts.ts +2 -1
- package/src/read.ts +4 -5
- package/src/replace-diff.ts +1 -1
- package/src/replace-match.ts +36 -28
- package/src/replace.ts +12 -2
- package/src/structured.ts +32 -0
- package/src/write-hook.ts +4 -3
- package/tool-prompts/copy-guidelines.md +1 -1
- package/tool-prompts/grep-guidelines.md +1 -2
- package/tool-prompts/insert.md +1 -1
- package/tool-prompts/move.md +2 -2
- package/tool-prompts/outline-guidelines.md +2 -0
- package/tool-prompts/outline-snippet.md +1 -0
- package/tool-prompts/outline.md +3 -0
- package/tool-prompts/read-guidelines.md +1 -1
- package/tool-prompts/read.md +1 -1
- package/tool-prompts/replace-match-guidelines.md +1 -1
- package/tool-prompts/replace-match-snippet.md +1 -1
- package/tool-prompts/replace-match.md +1 -1
package/README.md
CHANGED
|
@@ -4,20 +4,20 @@
|
|
|
4
4
|
|
|
5
5
|
[](https://www.npmjs.com/package/pi-hashline-edit-pro) [](https://www.npmjs.com/package/pi-hashline-edit-pro) [](https://huggingface.co/spaces/alexshpunt/benchmark-explorer?card=harness%3Api-hashline-edit-pro%40latest)
|
|
6
6
|
|
|
7
|
-
pi-hashline-edit-pro is an extension for [pi-coding-agent](https://github.com/earendil-works/pi) that edits files by anchor. Every line a tool serves gets a unique
|
|
7
|
+
pi-hashline-edit-pro is an extension for [pi-coding-agent](https://github.com/earendil-works/pi) that edits files by anchor. Every line a tool serves gets a unique 5-character anchor, and you edit by anchor. Edits are never addressed by line number and nothing is fuzzy-matched, so an edit lands on the line you meant.
|
|
8
8
|
|
|
9
|
-
It is a fork of [pi-hashline-edit](https://github.com/RimuruW/pi-hashline-edit) by RimuruW, extended with
|
|
9
|
+
It is a fork of [pi-hashline-edit](https://github.com/RimuruW/pi-hashline-edit) by RimuruW, extended with 5-character tokenizer-friendly anchors and allocation-based anchor identity.
|
|
10
10
|
|
|
11
11
|
```text
|
|
12
12
|
read a file; every line comes back as anchor│content:
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
14
|
+
dafox│function hello() {
|
|
15
|
+
emnox│ console.log("world");
|
|
16
|
+
hdtmx│}
|
|
17
17
|
|
|
18
18
|
replace one line by its anchor:
|
|
19
19
|
|
|
20
|
-
{ "remove_from": "
|
|
20
|
+
{ "remove_from": "emnox", "remove_to": "emnox", "text": " console.log('hi');" }
|
|
21
21
|
|
|
22
22
|
the result is the post-edit diff with fresh anchors, so the next edit needs no re-read.
|
|
23
23
|
```
|
|
@@ -29,6 +29,7 @@ the result is the post-edit diff with fresh anchors, so the next edit needs no r
|
|
|
29
29
|
- [Quickstart](#quickstart)
|
|
30
30
|
- [Tools](#tools)
|
|
31
31
|
- [read](#read)
|
|
32
|
+
- [outline](#outline)
|
|
32
33
|
- [replace](#replace)
|
|
33
34
|
- [replace_match](#replace_match)
|
|
34
35
|
- [insert](#insert)
|
|
@@ -57,12 +58,12 @@ Line numbers shift when anything above them changes; fuzzy matching can silently
|
|
|
57
58
|
|
|
58
59
|
| Dimension | Line numbers or fuzzy matching | Anchors (this extension) |
|
|
59
60
|
| --- | --- | --- |
|
|
60
|
-
| Address | a line number | a
|
|
61
|
+
| Address | a line number | a 5-character anchor minted for one line |
|
|
61
62
|
| After an edit | downstream numbers shift, so stale numbers can hit the wrong line | untouched lines keep their anchors; only changed lines get new ones |
|
|
62
63
|
| Wrong target | a fuzzy match may land on a similar line | an anchor resolves to exactly one file and line, or the edit is refused |
|
|
63
64
|
| Stale request | can apply silently | refused with `[E_STALE_ANCHOR]` or `[E_RANGE_STALE]`, with fresh anchors returned for the retry |
|
|
64
65
|
|
|
65
|
-
What "pro" adds over upstream `pi-hashline-edit`: the anchor table is
|
|
66
|
+
What "pro" adds over upstream `pi-hashline-edit`: the anchor table is a curated set of 5-letter lowercase anchors whose `anchor│` unit stays within three tokens (so an anchored row and an edit call stay cheap in tokens), and anchors are allocated per line and never derived from content, so byte-identical lines never share an anchor and an edit cannot re-target a different line.
|
|
66
67
|
|
|
67
68
|
## Install
|
|
68
69
|
|
|
@@ -87,6 +88,7 @@ pi install /path/to/pi-hashline-edit-pro
|
|
|
87
88
|
| Built-in | Effect |
|
|
88
89
|
| --- | --- |
|
|
89
90
|
| `read` | overridden, returns `anchor│content` rows |
|
|
91
|
+
| `outline` | new opt-in tool; returns one file's structural outline |
|
|
90
92
|
| `edit` | disabled |
|
|
91
93
|
| `grep` | replaced by an anchored `grep`; pi's own `grep` returns when Anchor grep is off |
|
|
92
94
|
| `copy`, `move` | disabled while Copy/move is off |
|
|
@@ -101,12 +103,12 @@ If anything of yours expects line-numbered `read` output (prompts, skills, hooks
|
|
|
101
103
|
After install, `read` any file and confirm the rows look like this:
|
|
102
104
|
|
|
103
105
|
```text
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
106
|
+
dafox│function hello() {
|
|
107
|
+
emnox│ console.log("world");
|
|
108
|
+
hdtmx│}
|
|
107
109
|
```
|
|
108
110
|
|
|
109
|
-
Then replace `
|
|
111
|
+
Then replace `emnox` and confirm the result is a post-edit diff with fresh anchors.
|
|
110
112
|
|
|
111
113
|
### Uninstall
|
|
112
114
|
|
|
@@ -119,17 +121,17 @@ pi uninstall npm:pi-hashline-edit-pro
|
|
|
119
121
|
1. Read a file. Every line comes back as `anchor│content`:
|
|
120
122
|
|
|
121
123
|
```text
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
124
|
+
dafox│function hello() {
|
|
125
|
+
emnox│ console.log("world");
|
|
126
|
+
hdtmx│}
|
|
125
127
|
```
|
|
126
128
|
|
|
127
129
|
2. Replace a line by its anchor. One edit per call, fields at the top level:
|
|
128
130
|
|
|
129
131
|
```json
|
|
130
132
|
{
|
|
131
|
-
"remove_from": "
|
|
132
|
-
"remove_to": "
|
|
133
|
+
"remove_from": "emnox",
|
|
134
|
+
"remove_to": "emnox",
|
|
133
135
|
"text": " console.log('hi');"
|
|
134
136
|
}
|
|
135
137
|
```
|
|
@@ -138,8 +140,8 @@ pi uninstall npm:pi-hashline-edit-pro
|
|
|
138
140
|
|
|
139
141
|
```text
|
|
140
142
|
...
|
|
141
|
-
-
|
|
142
|
-
+
|
|
143
|
+
-emnox│ console.log("world");
|
|
144
|
+
+qwerx│ console.log('hi');
|
|
143
145
|
...
|
|
144
146
|
```
|
|
145
147
|
|
|
@@ -155,7 +157,7 @@ Nothing commits until an edit call returns: the extension validates the request
|
|
|
155
157
|
|
|
156
158
|
## Tools
|
|
157
159
|
|
|
158
|
-
The extension registers
|
|
160
|
+
The extension registers nine tools: `read`, `outline`, `replace`, `replace_match`, `insert`, `copy`, `move`, `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. `outline` is off by default; turn Outline tool on in `/hashline-config` to enable it, which also activates it when the host's default tool list omits it. `replace`, `replace_match`, `insert`, `copy`, and `move` do not need a `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. A `path` passed anyway is checked against anchor ownership: a matching path is accepted, a mismatch is rejected. 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
161
|
|
|
160
162
|
### read
|
|
161
163
|
|
|
@@ -167,7 +169,7 @@ The extension registers eight tools: `read`, `replace`, `replace_match`, `insert
|
|
|
167
169
|
| `offset` | Line number (1-indexed) or a served anchor to start reading from. |
|
|
168
170
|
| `limit` | Maximum number of lines to return from `offset`. |
|
|
169
171
|
|
|
170
|
-
Output is capped at 2000 lines and 50KB. Paged output ends with a continuation hint, for example `[Showing lines 1-50 of 120. Use offset=
|
|
172
|
+
Output is capped at 2000 lines and 50KB. Paged output ends with a continuation hint, for example `[Showing lines 1-50 of 120. Use offset=emnox to continue.]`. When the `outline` tool is enabled, a truncated read appends a notice pointing at it.
|
|
171
173
|
|
|
172
174
|
A line whose `anchor│content` row exceeds 50KB is replaced by a marker that keeps the line's anchor: `anchor│[Line N is 2.2MB, exceeds 50.0KB; content not shown. Use bash: sed -n 'Np' <path> | head -c 51200]`. The marker is served like a normal row, so the whole line can still be replaced through it.
|
|
173
175
|
|
|
@@ -180,22 +182,33 @@ Edge cases:
|
|
|
180
182
|
- BOMs are stripped for display. Non-UTF-8 bytes are shown as `U+FFFD`; editing such a file rewrites it as UTF-8, with a warning.
|
|
181
183
|
- Files over 1,353,139 lines or 100MB are rejected with `[E_FILE_TOO_LARGE]`.
|
|
182
184
|
|
|
185
|
+
### outline
|
|
186
|
+
|
|
187
|
+
`outline` returns the structural outline of one file: a `=== path (language) — N lines ===` header, then one `anchor│content` row per symbol whose content is the first line of that symbol's declaration (annotations and attributes excluded). A `[limit N]` line under a multi-line symbol is the span to pass as `read`'s `limit` when reading the body from that anchor, and an `[N nested]` line marks children hidden by `depth`. Hiding children that way is a view choice rather than truncation, so `truncated` stays false and the section still counts as complete. The structured result reports `complete`, which is true only when no row or byte cap cut the outline, every rendered row was served, and no line was skipped for a missing anchor; a head-and-tail preview that omits lines is incomplete. Files the parser cannot outline fall back to the same head-and-tail preview auto-read all uses. A row whose line is longer than 200 characters is clipped and is not served for editing, so read that line first. The tool is offered only while Outline tool is on in `/hashline-config`.
|
|
188
|
+
|
|
189
|
+
| Parameter | Description |
|
|
190
|
+
| --- | --- |
|
|
191
|
+
| `path` | Path to the file to outline (relative or absolute). |
|
|
192
|
+
| `depth` | Nesting depth: `0` lists top-level symbols only, `1` adds their members, and the default is `2` (maximum `10`). |
|
|
193
|
+
|
|
194
|
+
Rows are the file's real lines, so an outline anchor is editable with `replace` or `insert` without a separate `read`, exactly like an auto-read-all outline row. The outline shows only each symbol's declaration line, so an edit inside a body still needs a `read` of that span.
|
|
195
|
+
|
|
183
196
|
### replace
|
|
184
197
|
|
|
185
198
|
`replace` removes a range of lines and puts new lines in their place. One edit per call, with the fields at the top level:
|
|
186
199
|
|
|
187
200
|
| Field | Description |
|
|
188
201
|
| --- | --- |
|
|
189
|
-
| `remove_from` |
|
|
190
|
-
| `remove_to` |
|
|
202
|
+
| `remove_from` | 5-char anchor marking the FIRST line to remove (inclusive). |
|
|
203
|
+
| `remove_to` | 5-char anchor marking the LAST line to remove (inclusive). |
|
|
191
204
|
| `text` | The exact text to write in place of the removed range, as one string: `""` deletes the range. A trailing line break sets the newline of the last line written instead of adding a blank line; a lone newline writes one blank line. Embedded `\r\n`/`\r`/`\n` are preserved; escapes in the tool call are decoded once when the call is parsed, and the tool then writes the string it receives as-is, never decoding again — `\uXXXX` is the character, `\\uXXXX` the literal text. Legacy arrays are converted to text (elements joined with LF); prefer the string form. |
|
|
192
205
|
|
|
193
|
-
Example: read showed `
|
|
206
|
+
Example: read showed `hasux│old` and `arvmx│old2`; to replace both:
|
|
194
207
|
|
|
195
208
|
```json
|
|
196
209
|
{
|
|
197
|
-
"remove_from": "
|
|
198
|
-
"remove_to": "
|
|
210
|
+
"remove_from": "hasux",
|
|
211
|
+
"remove_to": "arvmx",
|
|
199
212
|
"text": "new line 1\nnew line 2"
|
|
200
213
|
}
|
|
201
214
|
```
|
|
@@ -205,11 +218,11 @@ A deletion keeps blank lines at the edges of the removed range, so the separator
|
|
|
205
218
|
|
|
206
219
|
The extension checks the request before any file I/O, so a bad request never touches the file.
|
|
207
220
|
|
|
208
|
-
Auto-fixable slips fall into two groups. Fixed silently: embedded newlines and a legacy array payload (a single-element array that holds stringified array text, even with a trailing JS method call, for example `[…].map(s => s)`, is unwrapped). Fixed with a warning or a hint: a reversed `remove_from`/`remove_to` range is applied in document order with an `[H_RANGE_REVERSED]` hint; a leftover `anchor│` prefix in `text` or the anchor fields (a prefix
|
|
221
|
+
Auto-fixable slips fall into two groups. Fixed silently: embedded newlines and a legacy array payload (a single-element array that holds stringified array text, even with a trailing JS method call, for example `[…].map(s => s)`, is unwrapped). Fixed with a warning or a hint: a reversed `remove_from`/`remove_to` range is applied in document order with an `[H_RANGE_REVERSED]` hint; a leftover `anchor│` prefix in `text` or a pasted served row in the anchor fields (a bare `abdef│` prefix, a line-numbered row such as `12:│ abdef│content`, or an `abdef (line 12)` suffix) and diff-preview rows pasted into the replacement are stripped with a warning.
|
|
209
222
|
|
|
210
223
|
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`.
|
|
211
224
|
|
|
212
|
-
Every line in the removed range must match what was last shown to you, except that a pure deletion (an empty replacement) verifies only the first and last line of the range and removes the interior as it currently stands. The extension records the `anchor│content` rows it serves (`read` output, `grep` output, the auto-read block after `write`, the `+anchor│` and ` anchor│` rows of post-edit diffs, the current-range rows of `[E_RANGE_STALE]` feedback, and the context rows of stale-anchor feedback) and verifies the whole range against that record before writing. A line that changed on disk since it was shown, or an anchor that is not
|
|
225
|
+
Every line in the removed range must match what was last shown to you, except that a pure deletion (an empty replacement) verifies only the first and last line of the range and removes the interior as it currently stands. The extension records the `anchor│content` rows it serves (`read` output, `grep` output, the auto-read block after `write`, the `+anchor│` and ` anchor│` rows of post-edit diffs, the current-range rows of `[E_RANGE_STALE]` feedback, and the context rows of stale-anchor feedback) and verifies the whole range against that record before writing. A line that changed on disk since it was shown, or an anchor that is not live in this session, refuses the edit with `[E_RANGE_STALE]` or `[E_STALE_ANCHOR]` and returns the current range with fresh anchors, so the retry needs no `read`. An owned anchor enters the served record when its row is shown (after a restart, restored ownership counts as shown), so a file with no owned anchors cannot be edited by anchor at all; call `read` first. An owned line that was never shown, for example beyond an auto-read preview's truncation cap, is refused with `[E_RANGE_STALE]` and returns the current range, so the retry still needs no `read`; only lines strictly between the boundaries of a pure deletion are exempt.
|
|
213
226
|
|
|
214
227
|
An edit that changes neither content nor line endings reports `No changes made` and leaves the anchors alone.
|
|
215
228
|
|
|
@@ -217,9 +230,9 @@ After a successful edit, the diff is capped at 50KB. A row over 50KB is shown as
|
|
|
217
230
|
|
|
218
231
|
### replace_match
|
|
219
232
|
|
|
220
|
-
`replace_match` changes part of a line — or the same fragment on several lines of a range — without retyping the rest. `replace_from` and `replace_to` are bare anchors from served `anchor│content` rows marking the first and last line of the range searched (the same anchor for a single line). `match` is the exact text to find inside that range, and `replacement` replaces every occurrence of it; every other character stays untouched.
|
|
233
|
+
`replace_match` changes part of a line — or the same fragment on several lines of a range — without retyping the rest. `replace_from` and `replace_to` are bare anchors from served `anchor│content` rows marking the first and last line of the range searched (the same anchor for a single line). `match` is the exact text to find inside that range, and `replacement` replaces every occurrence of it; every other character stays untouched. `match` stays within one line: a single trailing line break is accepted as the line's own terminator and dropped, so the file keeps its separator, while a line break anywhere else is refused with `[E_BAD_SHAPE]` (a `match` that is only that trailing line break leaves nothing to find and is refused). `replacement` may span lines: a single trailing line break is its terminator and is dropped, a value that is only a line break counts as `""` and deletes the matches, and leading or interior breaks split the replacement into lines; new lines inherit the file's line endings. Send `match` as only the fragment that changes — never include text that `replacement` leaves unchanged — and use `replace` with the same anchors when the change covers whole lines; if `match` is an entire single-line range except indentation and trailing punctuation, that is a whole-line change. The anchors bound the search, so occurrences outside the selected range are left alone. That makes it the tool for a change the request quotes as a substring: a whole-line `replace` has to reproduce the rest of the lines, so a slipped character becomes a wrong byte, while `replace_match` leaves everything the request did not name untouched. It is enabled by default; turn Replace match off in `/hashline-config` to remove the tool.
|
|
221
234
|
|
|
222
|
-
`match` is matched against each selected line's text (LF line breaks) and every non-overlapping occurrence is replaced, left to right
|
|
235
|
+
`match` is matched against each selected line's text (LF line breaks) and every non-overlapping occurrence is replaced, left to right. Each occurrence expands to the replacement's lines: the first line continues the text before the match, the last line continues the text after it, and middle lines sit between them. A missing match is refused with `[E_SUBSTRING_NOT_FOUND]` and the current `anchor│content` rows, so the retry needs no `read`. The two boundary anchors are verified against what was last shown; lines strictly inside the range are searched as they currently stand on disk.
|
|
223
236
|
|
|
224
237
|
In a same-message batch it joins the other calls on its file: the batch validates everything against the pre-batch state and commits once, with one undo. A missing `match` fails that call on its own and is left out of the batch; the rest of the batch still commits. The post-edit diff carries fresh anchors, and the edit is undoable with `undo_last_change`.
|
|
225
238
|
|
|
@@ -229,16 +242,16 @@ In a same-message batch it joins the other calls on its file: the batch validate
|
|
|
229
242
|
|
|
230
243
|
| Field | Description |
|
|
231
244
|
| --- | --- |
|
|
232
|
-
| `anchor` |
|
|
245
|
+
| `anchor` | 5-char anchor marking the line next to which the lines go. The anchor line is preserved. A pasted `+hasux│x` diff row or `anchor│` prefix is stripped automatically with a warning. |
|
|
233
246
|
| `direction` | `"after"` inserts below the anchor line, `"before"` above it. |
|
|
234
247
|
| `text` | The exact text to insert, as one string: `""` inserts one blank line (the same as a lone newline). A trailing line break sets the newline of the last line written instead of adding a blank line. Never include the anchor line. Embedded `\r\n`/`\r`/`\n` are preserved; escapes in the tool call are decoded once when the call is parsed, and the tool then writes the string it receives as-is, never decoding again — `\uXXXX` is the character, `\\uXXXX` the literal text. Legacy arrays are converted to text (elements joined with LF); prefer the string form. |
|
|
235
248
|
|
|
236
249
|
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.
|
|
237
250
|
|
|
238
|
-
Example: add a line after `
|
|
251
|
+
Example: add a line after `emnox│`:
|
|
239
252
|
|
|
240
253
|
```json
|
|
241
|
-
{ "anchor": "
|
|
254
|
+
{ "anchor": "emnox", "direction": "after", "text": " // log the greeting" }
|
|
242
255
|
```
|
|
243
256
|
|
|
244
257
|
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.
|
|
@@ -249,22 +262,22 @@ The same safety machinery as `replace` applies: undo is saved before the write (
|
|
|
249
262
|
|
|
250
263
|
| Field | Description |
|
|
251
264
|
| --- | --- |
|
|
252
|
-
| `source_from` |
|
|
253
|
-
| `source_to` |
|
|
254
|
-
| `anchor` |
|
|
265
|
+
| `source_from` | 5-char anchor marking the FIRST source line to copy (inclusive). |
|
|
266
|
+
| `source_to` | 5-char anchor marking the LAST source line to copy (inclusive). |
|
|
267
|
+
| `anchor` | 5-char anchor of the destination line the block goes before or after, resolved in the destination file. |
|
|
255
268
|
| `direction` | `"after"` places the block below the anchor line, `"before"` above it. Within one file the insertion point must fall before the source range's first line or after its last. |
|
|
256
269
|
|
|
257
|
-
Example: read served `
|
|
270
|
+
Example: read served `hasux│old` in `a.ts` and `qwerx│top` in `b.ts`; to copy `old` into `b.ts` below `top`:
|
|
258
271
|
|
|
259
272
|
```json
|
|
260
|
-
{ "source_from": "
|
|
273
|
+
{ "source_from": "hasux", "source_to": "hasux", "anchor": "qwerx", "direction": "after" }
|
|
261
274
|
```
|
|
262
275
|
|
|
263
276
|
The source lines stay in place and keep their anchors; the copied lines are minted fresh anchors in the destination's post-edit diff and keep their source line endings. A cross-file copy writes only the destination, so one `undo_last_change` on it reverts the copy. In a same-message batch, a same-file copy and a cross-file copy join the destination file's batch; the copy still duplicates the content its source anchors were served from, not the result of a sibling edit. Anchoring the block's last line with `direction: "after"` (or its first line with `direction: "before"`) duplicates the block right beside itself.
|
|
264
277
|
|
|
265
278
|
### move
|
|
266
279
|
|
|
267
|
-
`move` relocates a range of lines in one call: the range is removed from the source file and written before or after `anchor`, which may live in a different file. It takes the same fields as `copy`, and an empty destination file is seeded with the moved lines. Within one file, the destination position must sit outside the source range, and moving a range to where it already sits reports `No changes made` and leaves the anchors alone. A same-file `move` and a cross-file `move` join the same-message batch of the destination file; a cross-file `move` whose source file also
|
|
280
|
+
`move` relocates a range of lines in one call: the range is removed from the source file and written before or after `anchor`, which may live in a different file. It takes the same fields as `copy`, and an empty destination file is seeded with the moved lines. Within one file, the destination position must sit outside the source range, and moving a range to where it already sits reports `No changes made` and leaves the anchors alone. A same-file `move` and a cross-file `move` join the same-message batch of the destination file; a cross-file `move` whose source file also gets other edits in the message runs outside any batch, so same-message edits to its destination file can then either abort or make the move fail; send such a move in a separate message. A batched cross-file `move` commits its source removal with the batch but shows only the destination diff; read the source file for fresh anchors. Lines between the source and the target keep their content but may be re-anchored; the moved lines keep their source line endings, and a cross-file move that removes every source line leaves the source file empty.
|
|
268
281
|
|
|
269
282
|
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.
|
|
270
283
|
|
|
@@ -318,7 +331,7 @@ Multiple `replace`, `replace_match`, `insert`, `copy`, and `move` calls on the s
|
|
|
318
331
|
|
|
319
332
|
Batched calls must target disjoint ranges; overlapping ranges abort the whole batch unwritten. A `copy`'s `anchor` line is the copy's destination range, so replacing or moving that same line in the batch is an overlap. One `insert`, `copy`, or cross-file `move` with `direction: "before"` and one with `direction: "after"` may target the same anchor line: the pair composes into a single insertion. A batch member that fails a batch-level step aborts its batch-mates with `[E_OP_ABORTED]`; a call left out of the batch does not.
|
|
320
333
|
|
|
321
|
-
A call whose anchors resolve nowhere never joins a batch: it runs on its own and fails with its own error (`[E_STALE_ANCHOR]`, or `[E_BAD_SHAPE]` when its request cannot be parsed), while the same-file batch in the message still commits. Calls with one stale anchor and a valid co-anchor, or with a `requirePath` path hint, are grouped into their file's batch; when their own request then fails validation against the pre-batch state, they are left out of the batch and fail on their own instead of applying partially. A `copy` or `move` whose source anchors no longer resolve cannot be grouped, because the source file cannot be identified; it runs on its own and fails with its own error. A cross-file `move` whose source file
|
|
334
|
+
A call whose anchors resolve nowhere never joins a batch: it runs on its own and fails with its own error (`[E_STALE_ANCHOR]`, or `[E_BAD_SHAPE]` when its request cannot be parsed), while the same-file batch in the message still commits. Calls with one stale anchor and a valid co-anchor, or with a `requirePath` path hint, are grouped into their file's batch; when their own request then fails validation against the pre-batch state, they are left out of the batch and fail on their own instead of applying partially. A `copy` or `move` whose source anchors no longer resolve cannot be grouped, because the source file cannot be identified; it runs on its own and fails with its own error. A cross-file `move` whose source file gets other edits in the message is also left out of the batch and commits on its own; same-message edits to its destination file can then either abort or make the move fail. An error that aborts a batch ends with `Aborts batch N.`; an aborted call reads `[E_OP_ABORTED] Batch N aborted: [<kind>] Call Nr <X> errored [<code>]`, naming the failing call and its error code (or `[E_OP_ABORTED] Batch N aborted.` when the failing error carries no code). Anchor capacity is preflighted before writing; if anchor finalization fails after the write, the error states the file was written with one undo available. Verify each batch diff before the next turn's edits on that file.
|
|
322
335
|
|
|
323
336
|
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`.
|
|
324
337
|
|
|
@@ -338,9 +351,9 @@ Auto-read all is off by default and has three modes, selected in `/hashline-conf
|
|
|
338
351
|
|
|
339
352
|
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; `resources/views/vendor` is kept so Laravel view overrides stay attached), 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, _ide_helper.php, _ide_helper_models.php, .phpstorm.meta.php, .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; `full` also caps the budget at 2MB, while `outline` follows the context-derived budget because outlines stay compact. Files are ranked before the budget applies: working-tree changes first, then manifest and entry files, then the dominant source tree, then everything else, and no single top-level directory may take more than 40% of the budget in the first pass, so one large subtree cannot crowd out the rest. Skipped and not-attached files are named at the end of the message so the model can `read` them on demand. A model matched by `disableOnModels` skips the injection entirely.
|
|
340
353
|
|
|
341
|
-
Each attached file starts with a header (`=== path ===` in `full` mode, `=== path (language) — lines ===` in `outline` mode) followed by its anchor rows. Edit directly from the attachment with replace and insert, so no `read` is needed. `full` attaches files whole; `outline` attaches each file's structural outline within the budget. In outline mode
|
|
354
|
+
Each attached file starts with a header (`=== path ===` in `full` mode, `=== path (language) — lines ===` in `outline` mode) followed by its anchor rows. Edit directly from the attachment with replace and insert, so no `read` is needed. `full` attaches files whole; `outline` attaches each file's structural outline within the budget. In outline mode each symbol appears as the first line of its declaration, a `[limit N]` line under it gives the span to read when the symbol spans several lines, and `[N nested]` marks children hidden by auto-read all's six-level depth cap.
|
|
342
355
|
|
|
343
|
-
A coverage line after the header reports how many attached files were served in full; a section truncated by the per-file budget is not counted as complete. Each attached file begins with a `=== path ===` header, and files that were not attached are named in the footer.
|
|
356
|
+
A coverage line after the header reports how many attached files were served in full; a section truncated by the per-file budget, a preview that omitted lines, or one that includes a clipped row is not counted as complete. Children hidden by the outline depth cap are marked `[N nested]` and do not make a section incomplete. Each attached file begins with a `=== path ===` header, and files that were not attached are named in the footer.
|
|
344
357
|
|
|
345
358
|
The setting lives in `/hashline-config` as Auto-read all and in `config.json` as `autoReadAll` (`"off"`, `"outline"`, or `"full"`; older configs with `"on"` or `true` are read as `"full"`, `"git"` as `"full"`, `"outline(git)"` as `"outline"` — both with Git repos only on — and `false` as `"off"`), and as `autoReadAllRequireGit`, the Git repos only switch (on by default). 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 `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.
|
|
346
359
|
|
|
@@ -348,7 +361,7 @@ The setting lives in `/hashline-config` as Auto-read all and in `config.json` as
|
|
|
348
361
|
|
|
349
362
|
| Command | Description |
|
|
350
363
|
| --- | --- |
|
|
351
|
-
| `/hashline-config` | Open the settings window: auto-read anchors, auto-read all mode, git repos only, ignore folders/files, disable on models, read on disabled models, diff context lines, `grep` tool, copy/move tools, replace_match tool, required `path`, strict input, and undo retention. Persists across sessions. |
|
|
364
|
+
| `/hashline-config` | Open the settings window: auto-read anchors, auto-read all mode, git repos only, ignore folders/files, disable on models, read on disabled models, diff context lines, `grep` tool, copy/move tools, replace_match tool, outline tool, required `path`, strict input, and undo retention. Persists across sessions. |
|
|
352
365
|
| `/clear-anchors` | Clear the session's anchor claims. Anchors are re-claimed on the next `read`. |
|
|
353
366
|
|
|
354
367
|
Settings live in `~/.config/pi-hashline-edit-pro/config.json`, created when a setting is first changed in `/hashline-config`:
|
|
@@ -362,6 +375,7 @@ Settings live in `~/.config/pi-hashline-edit-pro/config.json`, created when a se
|
|
|
362
375
|
"anchorGrepEnabled": true,
|
|
363
376
|
"copyMoveEnabled": true,
|
|
364
377
|
"replaceMatchEnabled": true,
|
|
378
|
+
"outlineEnabled": false,
|
|
365
379
|
"requirePath": false,
|
|
366
380
|
"strictInput": false,
|
|
367
381
|
"diffContextLines": 1,
|
|
@@ -380,14 +394,15 @@ Settings live in `~/.config/pi-hashline-edit-pro/config.json`, created when a se
|
|
|
380
394
|
| `anchorGrepEnabled` | Anchor grep | `true` | Register an anchored `grep` in place of pi's own `grep` while it is on. |
|
|
381
395
|
| `copyMoveEnabled` | Copy/move | `true` | Offer the `copy` and `move` tools; when off, both are removed from the active tools. |
|
|
382
396
|
| `replaceMatchEnabled` | Replace match | `true` | Offer the `replace_match` tool; when off, it is removed from the active tools. |
|
|
397
|
+
| `outlineEnabled` | Outline tool | `false` | Register the `outline` tool for one file's structure on demand; when off, it is removed from the active tools. |
|
|
383
398
|
| `requirePath` | Require path | `false` | `replace`, `replace_match`, `insert`, `copy`, and `move` require a `path` argument that must match anchor ownership. |
|
|
384
|
-
| `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. |
|
|
399
|
+
| `strictInput` | Strict input | `false` | Reject auto-fixable slips (`[W_BAD_SHAPE]`, `[W_BAD_REF]`, `[W_INVALID_PATCH]`, `[W_BARE_HASH_PREFIX]`, `[W_ANCHOR_CASE]`) with `[E_BAD_SHAPE]` instead of applying them with a warning. |
|
|
385
400
|
| `diffContextLines` | Diff context | `1` | Surrounding lines in post-edit diffs, 0-10 (needs Auto-read). |
|
|
386
401
|
| `disableOnModels` | Disable on models | `[]` | Model globs matched case-insensitively against `provider/id`, the bare `id`, and `api`, with `*` and `?` wildcards. A matching model gets no anchored edit tools, no auto-read, and no write-echo refusal; `read` stays available as pi's ordinary read unless Read on disabled models is `remove`. |
|
|
387
402
|
| `readOnDisabledModels` | Read on disabled models | `"vanilla"` | `read` for a model matched by `disableOnModels`: `"vanilla"` keeps pi's ordinary `read` active while the anchored edit tools stay off; `"remove"` disables `read` with the rest of the anchored tools. |
|
|
388
403
|
| `undoRetentionDays` | Undo retention | `30` | Days to keep undo history; records older than this are deleted when the store opens. `0` keeps them forever (0-3650). |
|
|
389
404
|
|
|
390
|
-
`disableOnModels` turns off the whole anchored surface for the models you name, so another edit tool (`apply_patch`, a shell read) can own the session without competing instructions. A match removes `replace`, `replace_match`, `insert`, `copy`, `move`, `grep`, and `undo_last_change` from the active tools, and also `read` when Read on disabled models is `remove`, registering pi's plain `grep` for those models, and it also skips the auto-read-all injection, the auto-read block after `write`, the post-edit diff substitution, and the `write` hook that refuses a `write` echoing a served anchor. Because the extension's `read` replaces the built-in one, a matched model has no `read` at all under `remove` unless another extension provides one; the default `vanilla` gives those models pi's ordinary `read` while the anchored edit tools stay off. The list is checked on `session_start`, on `model_select`, and again on `before_agent_start`, so a model change inside a session switches the surface immediately and another extension cannot re-add the tools mid-session. The default `[]` leaves every model unchanged.
|
|
405
|
+
`disableOnModels` turns off the whole anchored surface for the models you name, so another edit tool (`apply_patch`, a shell read) can own the session without competing instructions. A match removes `outline`, `replace`, `replace_match`, `insert`, `copy`, `move`, `grep`, and `undo_last_change` from the active tools, and also `read` when Read on disabled models is `remove`, registering pi's plain `grep` for those models, and it also skips the auto-read-all injection, the auto-read block after `write`, the post-edit diff substitution, and the `write` hook that refuses a `write` echoing a served anchor. Because the extension's `read` replaces the built-in one, a matched model has no `read` at all under `remove` unless another extension provides one; the default `vanilla` gives those models pi's ordinary `read` while the anchored edit tools stay off. The list is checked on `session_start`, on `model_select`, and again on `before_agent_start`, so a model change inside a session switches the surface immediately and another extension cannot re-add the tools mid-session. The default `[]` leaves every model unchanged.
|
|
391
406
|
|
|
392
407
|
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).
|
|
393
408
|
|
|
@@ -397,7 +412,7 @@ When `PI_HASHLINE_DIR` is unset or empty, non-Windows platforms honor `XDG_CONFI
|
|
|
397
412
|
| --- | --- | --- |
|
|
398
413
|
| Output cap | 2000 lines and 50KB | `read`, auto-read after `write`, post-edit diffs, patches, previews, `details.patch` |
|
|
399
414
|
| Oversized row | 50KB per `anchor│content` row | replaced by an anchor-keeping marker you can still edit through |
|
|
400
|
-
| Line cap | 1,353,139 lines per file | `read`, `replace`, `replace_match`, `insert`, `copy`, `move` (`[E_FILE_TOO_LARGE]`) |
|
|
415
|
+
| Line cap | 1,353,139 lines per file | `read`, `outline`, `replace`, `replace_match`, `insert`, `copy`, `move` (`[E_FILE_TOO_LARGE]`) |
|
|
401
416
|
| File size | 100MB | all tools (`[E_FILE_TOO_LARGE]`) |
|
|
402
417
|
| Hash window | first 500 bytes of a line | anchor identity for long lines |
|
|
403
418
|
| Patch guard | 1MB of pre-edit + post-edit text | patch generation is skipped and `patchTruncated` is set |
|
|
@@ -405,6 +420,7 @@ When `PI_HASHLINE_DIR` is unset or empty, non-Windows platforms honor `XDG_CONFI
|
|
|
405
420
|
| Grep output | 2000 rows and 50KB | `grep` |
|
|
406
421
|
| Grep row fragment | 500 bytes | long match and context rows shown as `...` fragments |
|
|
407
422
|
| Auto-read all files | 500 files, 200KB per file | files larger than 200KB and files past the cap are omitted |
|
|
423
|
+
| Outline rows | 400 symbol rows and 50KB of content per file (a truncation footer may follow) | `outline`, auto-read all `outline` mode |
|
|
408
424
|
| Auto-read all budget | 200KB floor; 2MB ceiling in `full` | total injection size derived from the model context window; `outline` is not capped at 2MB |
|
|
409
425
|
| Stale-range feedback | first 100 lines | rows returned with `[E_RANGE_STALE]` |
|
|
410
426
|
| 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]`) |
|
|
@@ -414,14 +430,16 @@ When `PI_HASHLINE_DIR` is unset or empty, non-Windows platforms honor `XDG_CONFI
|
|
|
414
430
|
|
|
415
431
|
## Tool result details
|
|
416
432
|
|
|
417
|
-
All
|
|
433
|
+
All nine tools return machine-readable metadata in `details` alongside the model-visible text.
|
|
418
434
|
|
|
419
|
-
All
|
|
435
|
+
All nine tools also declare an `outputSchema` and return `structuredContent`, so codemode scripts receive anchors, line numbers, diffs, and errors as data instead of parsing the rendered text. In a codemode session `tools.read()` resolves to the file's text, like the built-in `read`; pass `anchors: true` to receive `{ text, lines, notices }`, where `text` is the raw file content of the served window and `lines` carries the anchored rows. The model still receives `content`. Every structured result is either `{ ok: true, kind, ... }` or `{ ok: false, kind: "error", error: { code, message } }`; a failed call is still a tool error (`isError: true`) and also carries `structuredContent`, so a script can branch on the code without matching the message.
|
|
420
436
|
|
|
421
437
|
`read` returns `{ kind: "read", path, text, lines, totalLines, startLine, nextOffset, truncated, blockedByLongLine, hadUtf8DecodeErrors, notices }`, where `text` is the raw file content of the served window (no anchors, no footers) and each entry of `lines` is `{ line, text, anchor, rendered }` for a row that was served (`text` there is what was shown, so an oversized line appears as its marker while keeping its anchor). `notices` carries non-file notes such as the UTF-8 rewrite note and `[W_ANCHOR_RECLAIMED]`. An image read returns `{ type: "image", data, mimeType, note }`.
|
|
422
438
|
|
|
423
439
|
`replace`, `replace_match`, `insert`, `copy`, `move`, and `undo_last_change` return `{ kind: "edit", verb, classification, path, text, diff, warnings, hints, firstChangedLine, anchors, anchorsOmitted }`, where `verb` is `"replaced"`, `"inserted"`, `"copied"`, `"moved"`, or `"undone"`, `anchors` are the live rows of the post-edit diff, and `anchorsOmitted` is true when the diff was truncated or carried no rows, meaning a fresh read is needed. A cross-file move reports the destination file and its anchors.
|
|
424
440
|
|
|
441
|
+
`outline` returns `{ kind: "outline", path, text, totalLines, depth, lines, truncated, complete, notices }`, where `text` is the outline body without the notices, `lines` is the anchored rows it served, `complete` is true only when no cap cut the outline, every rendered row was served, and no line was skipped for a missing anchor; `notices` carries non-file notes such as the UTF-8 rewrite note and `[W_ANCHOR_RECLAIMED]`; in a codemode session `tools.outline()` resolves to the served outline text (with notices) unless `anchors: true` is passed.
|
|
442
|
+
|
|
425
443
|
`grep` returns `{ kind: "grep", text, matches, files, truncated, results }`, where each entry of `results` is `{ path, matchLines, lines, hadUtf8DecodeErrors }` and `lines` uses the same `{ line, text, anchor, rendered }` shape as `read`.
|
|
426
444
|
|
|
427
445
|
| Tool | `details` |
|
|
@@ -431,6 +449,7 @@ All eight tools also declare an `outputSchema` and return `structuredContent`, s
|
|
|
431
449
|
| `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. |
|
|
432
450
|
| `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. |
|
|
433
451
|
| `undo_last_change` | `diff` (the undo diff with restored anchors), `patch`, `patchTruncated`, and `metrics` in the same shape as `replace`. |
|
|
452
|
+
| `outline` | `truncated` (true when the row or byte cap cut the outline), `complete` (true only when no cap cut the outline, every rendered row was served, and no line was skipped for a missing anchor), `snapshotId`, and `metrics` with `rows` (every rendered symbol row, including clipped ones) and `truncated`; structured `lines` holds only the rows that were served for editing. |
|
|
434
453
|
| `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). |
|
|
435
454
|
|
|
436
455
|
`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`; hint `[H_*]` notices live in `details.hints` and are not counted.
|
|
@@ -441,7 +460,7 @@ Codes starting with `E_` are errors: nothing was written, with one exception. `F
|
|
|
441
460
|
|
|
442
461
|
Most common, with the fix:
|
|
443
462
|
|
|
444
|
-
- `[E_STALE_ANCHOR]`: the anchor is
|
|
463
|
+
- `[E_STALE_ANCHOR]`: the anchor is stale: it was never served in this session, or its line was edited or its anchors were freed since. Call `read` for fresh anchors and retry.
|
|
445
464
|
- `[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.
|
|
446
465
|
- `[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.
|
|
447
466
|
- `[E_STORE_UNAVAILABLE]`: no SQLite runtime. Run pi under Node 22.19+ or a Bun build that ships `bun:sqlite`.
|
|
@@ -453,22 +472,23 @@ Full reference:
|
|
|
453
472
|
| Code | Meaning |
|
|
454
473
|
| --- | --- |
|
|
455
474
|
| `[E_CONFIG]` | `PI_HASHLINE_DIR` is nonempty but not an absolute path. |
|
|
456
|
-
| `[E_BAD_SHAPE]` | Request envelope or edit item has unknown, missing, or wrongly-typed fields (for example `text` must be a string holding the exact text), content contains a NUL byte (`U+0000`), which would make the file binary, a grep `glob` has invalid bracket or brace syntax, or a `replace_match`
|
|
475
|
+
| `[E_BAD_SHAPE]` | Request envelope or edit item has unknown, missing, or wrongly-typed fields (for example `text` must be a string holding the exact text), content contains a NUL byte (`U+0000`), which would make the file binary, a grep `glob` has invalid bracket or brace syntax, or a `replace_match` `match` spans lines: `match` contains a line break after the optional trailing one. |
|
|
457
476
|
| `[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). |
|
|
458
|
-
| `[E_BAD_REF]` | An anchor
|
|
477
|
+
| `[E_BAD_REF]` | An anchor field does not yield a 5-character anchor. Anchors come from read or grep output and are exactly 5 lowercase letters; a pasted served row in an anchor field is reduced to its anchor first. |
|
|
459
478
|
| `[E_SUBSTRING_NOT_FOUND]` | `replace_match` did not find `match` in the selected range. The current `anchor│content` rows are returned, and when `match` looks like a retyped copy of a single-line range the message names the first mismatching column; copy only the fragment that changes from the served row and retry, or use `replace` for a whole-line rewrite. |
|
|
460
|
-
| `[W_BAD_REF]` | A pasted `anchor
|
|
461
|
-
| `[
|
|
479
|
+
| `[W_BAD_REF]` | A pasted served row (bare `anchor│content`, a line-numbered grep row, or an `anchor (line N)` suffix) or diff-preview marker was stripped from an anchor field with a warning. |
|
|
480
|
+
| `[W_ANCHOR_CASE]` | An edit anchor field used uppercase or mixed case; it was lowercased to the lowercase anchor with a warning (strict input rejects it). `read`'s `offset` lowercases a case-only mismatch silently. |
|
|
481
|
+
| `[E_STALE_ANCHOR]` | An anchor is stale: it was never served in this session, or its line was edited, the file was rewritten, or its anchors were freed since; the message names the case. Call `read` for fresh anchors. |
|
|
462
482
|
| `[W_INVALID_PATCH]` | A `text` line is a diff-preview row (`+anchor│`, `-anchor│`, `- │`). The marker is stripped automatically with a warning. |
|
|
463
483
|
| `[W_BARE_HASH_PREFIX]` | A `text` line starts with an `anchor│` prefix. The prefix is stripped automatically with a warning. |
|
|
464
484
|
| `[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. |
|
|
465
|
-
| `[H_LITERAL_ESCAPE]` | A payload field contains literal escaped text such as `\uXXXX`, `\n`, `\t`, `\r`, or `\"` (one hint per distinct escape, up to three). The file receives those backslash characters as written, because escapes in the tool call are decoded once when the call is parsed (`\uXXXX` → the character), so a doubled escape (`\\uXXXX`) lands literally. The hint is one line: `text: "\u200b" written as literal text (
|
|
466
|
-
| `[H_UNICODE_LOST]` | The new text is missing an invisible or look-alike character (for example `U+200B`, `U+2060`, `U+00A0`, or a smart quote) that a replaced row has, or that a context row sharing a long run with an inserted line has. The edit applied as sent; the one-line hint names the live anchor of the offending result row, when one can be identified, and the character as its JSON escape, for example `[H_UNICODE_LOST]
|
|
467
|
-
| `[H_UNICODE_SWAPPED]` | The new text uses a different invisible or look-alike character than the line it matches (for example `U+200D` where that line has `U+200B`, or an ASCII stand-in such as `.` for `。` or a space for `U+00A0`). The edit applied as sent; the one-line hint names the live anchor of the offending result row with both characters as JSON escapes, for example `[H_UNICODE_SWAPPED]
|
|
468
|
-
| `[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;
|
|
469
|
-
| `[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;
|
|
470
|
-
| `[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
|
|
471
|
-
| `[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
|
|
485
|
+
| `[H_LITERAL_ESCAPE]` | A payload field contains literal escaped text such as `\uXXXX`, `\n`, `\t`, `\r`, or `\"` (one hint per distinct escape, up to three). The file receives those backslash characters as written, because escapes in the tool call are decoded once when the call is parsed (`\uXXXX` → the character), so a doubled escape (`\\uXXXX`) lands literally. The hint is one line: `text: "\u200b" written as literal text (kqafq│ col 31).` names the field, the escape, and up to three affected anchors with their columns; when more than three rows carry it, it gives `on 10 rows; undo_last_change if unintended.` instead of the anchor list. |
|
|
486
|
+
| `[H_UNICODE_LOST]` | The new text is missing an invisible or look-alike character (for example `U+200B`, `U+2060`, `U+00A0`, or a smart quote) that a replaced row has, or that a context row sharing a long run with an inserted line has. The edit applied as sent; the one-line hint names the live anchor of the offending result row, when one can be identified, and the character as its JSON escape, for example `[H_UNICODE_LOST] zbbbx│ missing "\u2060"; kqafq│ has it at col 31.` The column is attributed to the row that had the character: `; kqafq│ has it at col N` while that row's anchor is still valid, otherwise `; the replaced row had it at col N`, and a dead anchor is never named. When another look-alike character (including `U+FFFD`) takes its place, the hint reports the substitute with `[H_UNICODE_SWAPPED]` wording. |
|
|
487
|
+
| `[H_UNICODE_SWAPPED]` | The new text uses a different invisible or look-alike character than the line it matches (for example `U+200D` where that line has `U+200B`, or an ASCII stand-in such as `.` for `。` or a space for `U+00A0`). The edit applied as sent; the one-line hint names the live anchor of the offending result row with both characters as JSON escapes, for example `[H_UNICODE_SWAPPED] zbbbx│ has "\u200d" at col 21; expected "\u200b".` A surviving reference row appears as `; kqafq│ has "\u200b"`; a replaced row's dead anchor is never named. The substitute may also be `U+FFFD`, for example where a row has `U+00A0`. |
|
|
488
|
+
| `[H_TRAILING_WHITESPACE]` | The new text differs from the replaced line only in trailing whitespace. Anchor checksums trim trailing whitespace, so the change does not invalidate the anchor; the one-line hint names the old and new trailing-whitespace counts and the column, for example `[H_TRAILING_WHITESPACE] 1 trailing whitespace character at col 7; kqafq│ had 0.` |
|
|
489
|
+
| `[H_INDENT_MISMATCH]` | The new line has fewer leading whitespace characters than a structurally similar row (a reference row near the anchor line for an insert, or the replaced line). The edit applied as sent; the one-line hint names both counts and the reference anchor, for example `[H_INDENT_MISMATCH] new line has 0 leading whitespace characters; kqafq│ has 2.` Copied or moved blocks are not checked, because their indentation comes from the source lines. |
|
|
490
|
+
| `[H_SEPARATOR_MOVED]` | An insert landed its text directly against the anchor line, and the blank line that separated the anchor from its neighbor was displaced to the other side of the inserted text. The edit applied as sent; the one-line hint names the anchor and which side lost the blank line, for example `[H_SEPARATOR_MOVED] blank separator above kqafq│ was displaced; add a blank line before kqafq│ if unintended.` |
|
|
491
|
+
| `[H_SEPARATOR_LOST]` | A pure deletion removed a run of blank lines that sat between two content lines. The edit applied as sent; the one-line hint names the two surviving anchors and the removed count, for example `[H_SEPARATOR_LOST] deletion removed 2 blank lines between aaaaz│ and ddddz│.` |
|
|
472
492
|
| `[H_RANGE_REVERSED]` | The `remove_from` anchor resolved to a line after `remove_to`. The call applied the range in document order; the one-line hint names the line range used. |
|
|
473
493
|
| `[E_NOT_FOUND]` | The path does not exist. |
|
|
474
494
|
| `[E_ACCESS]` | The file is not readable or writable. |
|
|
@@ -484,12 +504,12 @@ Full reference:
|
|
|
484
504
|
| `[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. |
|
|
485
505
|
| `[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. |
|
|
486
506
|
| `[E_OP_ABORTED]` | The edit was aborted before writing: the file changed or was deleted after it was read (a single edit or a cross-file move reports the file; a batch reports the batch and the failing call), the base read failed, a cross-file member could not be prepared, or the composed result exceeded a limit. Nothing was written; a call whose own request is invalid is left out of the batch instead. Fix the cause and retry, otherwise call `read` for fresh anchors and retry. The batch abort names the failing call and its error code when one is known. |
|
|
487
|
-
| `[E_GREP_FAILED]` | `grep` could not start ripgrep or ripgrep exited with an error (for example a pattern with backreferences
|
|
507
|
+
| `[E_GREP_FAILED]` | `grep` could not start ripgrep or ripgrep exited with an error (for example a pattern with backreferences, look-around, or invalid regex syntax, which ripgrep's regex engine does not support); the message carries ripgrep's output. Retry with `literal: true` or simplify the pattern. |
|
|
488
508
|
| `[E_GREP_TIMEOUT]` | `grep` timed out after 10 seconds; narrow `path` or simplify `pattern` and retry. |
|
|
489
509
|
|
|
490
510
|
## Troubleshooting
|
|
491
511
|
|
|
492
|
-
- Stale anchors. `[E_STALE_ANCHOR]` means an anchor
|
|
512
|
+
- Stale anchors. `[E_STALE_ANCHOR]` means an anchor no longer addresses a line: it was never served in this session, or its line was edited, the file was rewritten, or its anchors were freed since. The message names the case. Call `read` for fresh anchors and retry.
|
|
493
513
|
- 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`.
|
|
494
514
|
- 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.
|
|
495
515
|
- 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.
|
|
@@ -540,7 +560,7 @@ edit call ──► resolve anchors ──► apply ──► commit ──► p
|
|
|
540
560
|
|
|
541
561
|
### Allocation
|
|
542
562
|
|
|
543
|
-
Anchors are allocated, never derived. Every line that is served to you, by `read`, `grep`, the auto-read block after `write`, or a post-edit diff, gets the next free anchor from the session's pool, claimed by walking the table with a stride of 836,286 entries (coprime to the 1,353,139-entry table), so consecutively minted anchors land in unrelated regions of the table instead of sharing leading characters. Each session seeds its walk from its own offset (derived from the session key and the process id), so concurrent sessions mint different sequences instead of identical ones: an anchor minted in one session is unknown in another and is rejected with `[E_STALE_ANCHOR]` rather than resolving to a different file. Ownership is exclusive: an anchor is owned by one file's line until it is freed (the line was edited, the file was written or deleted, you ran `/clear-anchors`, or the session's quota ran out and the file was the least recently read or edited, which frees all of its anchors and reports it in `[W_ANCHOR_RECLAIMED]`). Minting prefers anchors the session has never used; when a bounded fresh-anchor probe finds nothing, freed anchors are recycled after their stale served records are purged, so an anchor is never shared by two live lines. Because ownership is exclusive, an anchor resolves to exactly one file. Two byte-identical lines never share an anchor, and that guarantee sets the file size cap: the pool is the shipped table's 1,353,139 entries (not all
|
|
563
|
+
Anchors are allocated, never derived. Every line that is served to you, by `read`, `grep`, the auto-read block after `write`, or a post-edit diff, gets the next free anchor from the session's pool, claimed by walking the table with a stride of 836,286 entries (coprime to the 1,353,139-entry table), so consecutively minted anchors land in unrelated regions of the table instead of sharing leading characters. Each session seeds its walk from its own offset (derived from the session key and the process id), so concurrent sessions mint different sequences instead of identical ones: an anchor minted in one session is unknown in another and is rejected with `[E_STALE_ANCHOR]` rather than resolving to a different file. Ownership is exclusive: an anchor is owned by one file's line until it is freed (the line was edited, the file was written or deleted, you ran `/clear-anchors`, or the session's quota ran out and the file was the least recently read or edited, which frees all of its anchors and reports it in `[W_ANCHOR_RECLAIMED]`). Minting prefers anchors the session has never used; when a bounded fresh-anchor probe finds nothing, freed anchors are recycled after their stale served records are purged, so an anchor is never shared by two live lines. Because ownership is exclusive, an anchor resolves to exactly one file. Two byte-identical lines never share an anchor, and that guarantee sets the file size cap: the pool is the shipped table's 1,353,139 entries (not all 26⁵ letter combinations), so a file can hold at most 1,353,139 lines, beyond which `read`, `replace`, `replace_match`, `insert`, `copy`, and `move` reject with `[E_FILE_TOO_LARGE]` (use `write` for very large files).
|
|
544
564
|
|
|
545
565
|
### Ownership and mapping across edits
|
|
546
566
|
|
|
@@ -555,7 +575,7 @@ A no-op replace never changes the file, so anchors remain valid. On first run af
|
|
|
555
575
|
|
|
556
576
|
### The anchor table is built for tokenizers
|
|
557
577
|
|
|
558
|
-
Every anchor is
|
|
578
|
+
Every anchor is a 5-letter lowercase string selected so that the anchor alone tokenizes as at most two tokens and the whole `anchor│` unit as at most three tokens in each of nine modern open-weights tokenizers (DeepSeek V4 and V4.1, Qwen 3.5, Gemma 4, GLM 5.3 Flash, Tencent Hy4-preview, MiniMax M3, MiMo V2.5, Kimi K3). The shipped table is the intersection that satisfies the criterion on all of them; it also has one anchor per case-fold class, so no two anchors differ only by case. Nemotron 3 Ultra is the one modern tokenizer excluded. An anchor therefore costs at most 2 tokens, and `anchor│` at most 3, in both read rows and edit calls. Anchors are lowercase letters only. The table is generated offline and its 1,353,139-entry count is frozen so the per-file line cap stays stable; it is shipped as `src/hashline/anchor-table.json`.
|
|
559
579
|
|
|
560
580
|
### Line checksums
|
|
561
581
|
|
|
@@ -565,6 +585,8 @@ Each line also carries a content checksum. The line is canonicalized (carriage r
|
|
|
565
585
|
|
|
566
586
|
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.
|
|
567
587
|
|
|
588
|
+
Upgrading from a 6.x release invalidates the old 4-character anchors: the hash store version changes, so snapshots and undo history are cleared, old sidecar anchors are ignored, and anchors are re-minted on the next read.
|
|
589
|
+
|
|
568
590
|
## Benchmark
|
|
569
591
|
|
|
570
592
|
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).
|