pi-hashline-edit-pro 4.4.3 → 4.4.4
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 +270 -54
- package/index.ts +3 -2
- package/package.json +1 -1
- package/prompts/read-guidelines.md +2 -1
- package/src/anchor-registry.ts +128 -39
- package/src/auto-read-all.ts +3 -1
- package/src/batch.ts +7 -1
- package/src/commit.ts +5 -0
- package/src/constants.ts +3 -0
- package/src/edit-common.ts +2 -1
- package/src/grep.ts +3 -1
- package/src/hash-store.ts +1 -1
- package/src/paths.ts +5 -1
- package/src/read.ts +7 -5
- package/src/replace-undo.ts +4 -2
package/README.md
CHANGED
|
@@ -1,12 +1,73 @@
|
|
|
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
|
+
- [anchor_grep](#anchor_grep)
|
|
35
|
+
- [undo_last_change](#undo_last_change)
|
|
36
|
+
- [Batching](#batching)
|
|
37
|
+
- [Auto-read](#auto-read)
|
|
38
|
+
- [Auto-read all](#auto-read-all)
|
|
39
|
+
- [Configuration](#configuration)
|
|
40
|
+
- [Limits](#limits)
|
|
41
|
+
- [Tool result details](#tool-result-details)
|
|
42
|
+
- [Error and warning codes](#error-and-warning-codes)
|
|
43
|
+
- [Troubleshooting](#troubleshooting)
|
|
44
|
+
- [Privacy and on-disk state](#privacy-and-on-disk-state)
|
|
45
|
+
- [How anchors work](#how-anchors-work)
|
|
46
|
+
- [Benchmark](#benchmark)
|
|
47
|
+
- [Development](#development)
|
|
48
|
+
- [Credits](#credits)
|
|
49
|
+
- [License](#license)
|
|
50
|
+
|
|
51
|
+
## Why anchors
|
|
52
|
+
|
|
53
|
+
Line numbers shift when anything above them changes; fuzzy matching can silently pick a similar-looking line. Anchors avoid both problems:
|
|
54
|
+
|
|
55
|
+
| Dimension | Line numbers or fuzzy matching | Anchors (this extension) |
|
|
56
|
+
| --- | --- | --- |
|
|
57
|
+
| Address | a line number | a 4-character anchor minted for one line |
|
|
58
|
+
| 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 |
|
|
59
|
+
| 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 |
|
|
60
|
+
| Stale request | can apply silently | refused with `[E_STALE_ANCHOR]` or `[E_RANGE_STALE]`, with fresh anchors returned for the retry |
|
|
61
|
+
|
|
62
|
+
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.
|
|
63
|
+
|
|
64
|
+
## Install
|
|
65
|
+
|
|
66
|
+
Prerequisites:
|
|
67
|
+
|
|
68
|
+
- [pi-coding-agent](https://github.com/earendil-works/pi) `>= 0.84.0` (`@earendil-works/pi-coding-agent`).
|
|
69
|
+
- Node.js 22.19 or newer, or a Bun build that ships `bun:sqlite`.
|
|
70
|
+
- 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
71
|
|
|
11
72
|
```bash
|
|
12
73
|
pi install npm:pi-hashline-edit-pro
|
|
@@ -18,9 +79,21 @@ To install from a local checkout:
|
|
|
18
79
|
pi install /path/to/pi-hashline-edit-pro
|
|
19
80
|
```
|
|
20
81
|
|
|
21
|
-
|
|
82
|
+
### What changes in your session
|
|
83
|
+
|
|
84
|
+
| Built-in | Effect |
|
|
85
|
+
| --- | --- |
|
|
86
|
+
| `read` | overridden, returns `anchor│content` rows |
|
|
87
|
+
| `edit` | disabled |
|
|
88
|
+
| `grep` | disabled while `anchor_grep` is enabled |
|
|
89
|
+
| `write` | kept; an auto-read block with fresh anchors is appended to its result |
|
|
90
|
+
| `bash` | untouched |
|
|
91
|
+
|
|
92
|
+
If anything of yours expects line-numbered `read` output (prompts, skills, hooks), account for the override before installing.
|
|
22
93
|
|
|
23
|
-
|
|
94
|
+
### Verify
|
|
95
|
+
|
|
96
|
+
After install, `read` any file and confirm the rows look like this:
|
|
24
97
|
|
|
25
98
|
```text
|
|
26
99
|
Dafo│function hello() {
|
|
@@ -28,17 +101,54 @@ Emno│ console.log("world");
|
|
|
28
101
|
HDtm│}
|
|
29
102
|
```
|
|
30
103
|
|
|
31
|
-
|
|
104
|
+
Then replace `Emno` and confirm the result is a post-edit diff with fresh anchors.
|
|
32
105
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
"replacement_lines": [" console.log('hi');"]
|
|
38
|
-
}
|
|
106
|
+
### Uninstall
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
pi uninstall npm:pi-hashline-edit-pro
|
|
39
110
|
```
|
|
40
111
|
|
|
41
|
-
|
|
112
|
+
## Quickstart
|
|
113
|
+
|
|
114
|
+
1. Read a file. Every line comes back as `anchor│content`:
|
|
115
|
+
|
|
116
|
+
```text
|
|
117
|
+
Dafo│function hello() {
|
|
118
|
+
Emno│ console.log("world");
|
|
119
|
+
HDtm│}
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
2. Replace a line by its anchor. One edit per call, fields at the top level:
|
|
123
|
+
|
|
124
|
+
```json
|
|
125
|
+
{
|
|
126
|
+
"remove_from": "Emno",
|
|
127
|
+
"remove_to": "Emno",
|
|
128
|
+
"replacement_lines": [" console.log('hi');"]
|
|
129
|
+
}
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
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:
|
|
133
|
+
|
|
134
|
+
```text
|
|
135
|
+
...
|
|
136
|
+
-Emno│ console.log("world");
|
|
137
|
+
+Qwer│ console.log('hi');
|
|
138
|
+
...
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
4. Revert the last `replace` or `insert` on the file with `undo_last_change`, whose one argument is the path:
|
|
142
|
+
|
|
143
|
+
```json
|
|
144
|
+
{ "path": "src/hello.ts" }
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
Undo is single-level and survives restarts.
|
|
148
|
+
|
|
149
|
+
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.
|
|
150
|
+
|
|
151
|
+
## Tools
|
|
42
152
|
|
|
43
153
|
The extension registers five tools: `read`, `replace`, `insert`, `anchor_grep`, and `undo_last_change`. The built-in `edit` tool is disabled. `replace` and `insert` 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` and `insert` for RPC visibility (for example pimacs.el); anchors still resolve the target and `path` must match.
|
|
44
154
|
|
|
@@ -87,21 +197,18 @@ Example: read showed `Hasu│old` and `arvm│old2`; to replace both:
|
|
|
87
197
|
|
|
88
198
|
Single line: use the same anchor for `remove_from` and `remove_to`. `replace_from`/`replace_to` and `from`/`to` work as aliases.
|
|
89
199
|
|
|
90
|
-
The
|
|
200
|
+
The extension checks the request before any file I/O, so a bad request never touches the file.
|
|
201
|
+
|
|
202
|
+
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
203
|
|
|
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
204
|
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
205
|
|
|
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
|
|
206
|
+
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
207
|
|
|
97
208
|
An edit that produces identical content reports `No changes made` and leaves the anchors alone.
|
|
98
209
|
|
|
99
210
|
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
211
|
|
|
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
212
|
### insert
|
|
106
213
|
|
|
107
214
|
`insert` adds lines after or before an existing line without removing anything. Like `replace`, there is no `path` parameter.
|
|
@@ -114,6 +221,12 @@ Batched calls must target disjoint ranges; overlapping ranges, or any failing ca
|
|
|
114
221
|
|
|
115
222
|
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
223
|
|
|
224
|
+
Example: add a line after `Emno│`:
|
|
225
|
+
|
|
226
|
+
```json
|
|
227
|
+
{ "anchor": "Emno", "direction": "after", "lines": [" // log the greeting"] }
|
|
228
|
+
```
|
|
229
|
+
|
|
117
230
|
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
231
|
|
|
119
232
|
### anchor_grep
|
|
@@ -149,8 +262,24 @@ Output is capped at `limit` matched lines, 2000 rows, and 50KB of text, whicheve
|
|
|
149
262
|
- 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
263
|
- If the file was deleted since the last edit, `undo_last_change` restores it from the recorded pre-edit content.
|
|
151
264
|
- 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.
|
|
265
|
+
- 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.
|
|
266
|
+
|
|
267
|
+
## Batching
|
|
268
|
+
|
|
269
|
+
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.
|
|
270
|
+
|
|
271
|
+
- A call outside a batch commits before its result returns.
|
|
272
|
+
- 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.
|
|
273
|
+
- If a batch aborts, an earlier member's row renders the abort message instead of the placeholder. Nothing commits until the last call succeeds.
|
|
274
|
+
- A batch member accepts the same request shapes and auto-fixes as a standalone call.
|
|
275
|
+
|
|
276
|
+
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]`.
|
|
152
277
|
|
|
153
|
-
|
|
278
|
+
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.
|
|
279
|
+
|
|
280
|
+
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`.
|
|
281
|
+
|
|
282
|
+
## Auto-read
|
|
154
283
|
|
|
155
284
|
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
285
|
|
|
@@ -158,29 +287,19 @@ After `replace`, `insert`, and `undo_last_change`, the result shows the post-edi
|
|
|
158
287
|
|
|
159
288
|
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
289
|
|
|
161
|
-
|
|
290
|
+
## Auto-read all
|
|
162
291
|
|
|
163
292
|
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
293
|
|
|
165
294
|
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
295
|
|
|
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.
|
|
296
|
+
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
297
|
|
|
170
|
-
|
|
298
|
+
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.
|
|
171
299
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
All five tools return machine-readable metadata in `details` alongside the model-visible text.
|
|
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). |
|
|
300
|
+
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
301
|
|
|
183
|
-
##
|
|
302
|
+
## Configuration
|
|
184
303
|
|
|
185
304
|
| Command | Description |
|
|
186
305
|
| --- | --- |
|
|
@@ -201,35 +320,69 @@ Settings live in `~/.config/pi-hashline-edit-pro/config.json`, created when a se
|
|
|
201
320
|
}
|
|
202
321
|
```
|
|
203
322
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
323
|
+
| Key | `/hashline-config` label | Default | Effect |
|
|
324
|
+
| --- | --- | --- | --- |
|
|
325
|
+
| `autoRead` | Auto-read | `true` | Append the auto-read block after `write` and show post-edit diffs. |
|
|
326
|
+
| `autoReadAll` | Auto-read all | `"off"` | Attachment mode: `"off"`, `"on"`, or `"git"`. |
|
|
327
|
+
| `autoReadAllIgnore` | Ignore folders/files | `[]` | Extra folder names, file names, or globs skipped by auto-read all. |
|
|
328
|
+
| `anchorGrepEnabled` | Anchor grep | `true` | Register `anchor_grep` and disable the built-in grep while it is on. |
|
|
329
|
+
| `requirePath` | Require path | `false` | `replace` and `insert` require a `path` argument that must match anchor ownership. |
|
|
330
|
+
| `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. |
|
|
331
|
+
| `diffContextLines` | Diff context | `1` | Surrounding lines in post-edit diffs, 0-10 (needs Auto-read). |
|
|
332
|
+
|
|
333
|
+
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).
|
|
334
|
+
|
|
335
|
+
## Limits
|
|
336
|
+
|
|
337
|
+
| Limit | Value | Applies to |
|
|
338
|
+
| --- | --- | --- |
|
|
339
|
+
| Output cap | 2000 lines and 50KB | `read`, auto-read after `write`, post-edit diffs, patches, previews, `details.patch` |
|
|
340
|
+
| Oversized row | 50KB per `anchor│content` row | replaced by an anchor-keeping marker you can still edit through |
|
|
341
|
+
| Line cap | 1,353,139 lines per file | `read`, `replace`, `insert` (`[E_FILE_TOO_LARGE]`) |
|
|
342
|
+
| File size | 100MB | all tools (`[E_FILE_TOO_LARGE]`) |
|
|
343
|
+
| Hash window | first 500 bytes of a line | anchor identity for long lines |
|
|
344
|
+
| Patch guard | 1MB of pre-edit + post-edit text | patch generation is skipped and `patchTruncated` is set |
|
|
345
|
+
| Grep matches | 100 matched lines by default (`limit`) | `anchor_grep` |
|
|
346
|
+
| Grep output | 2000 rows and 50KB | `anchor_grep` |
|
|
347
|
+
| Grep row fragment | 500 bytes | long match and context rows shown as `...` fragments |
|
|
348
|
+
| Auto-read all files | 500 files, 200KB per file | files larger than 200KB and files past the cap are omitted |
|
|
349
|
+
| Auto-read all budget | 200KB floor, 2MB ceiling | total injection size, derived from the model context window |
|
|
350
|
+
| Stale-range feedback | first 100 lines | rows returned with `[E_RANGE_STALE]` |
|
|
351
|
+
| 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]`) |
|
|
352
|
+
|
|
353
|
+
`anchor_grep` uses the same 100MB file-size cutoff as `read`. Files over the line cap are skipped silently in directory searches.
|
|
209
354
|
|
|
210
|
-
|
|
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.
|
|
355
|
+
## Tool result details
|
|
213
356
|
|
|
214
|
-
|
|
357
|
+
All five tools return machine-readable metadata in `details` alongside the model-visible text.
|
|
215
358
|
|
|
216
|
-
|
|
359
|
+
| Tool | `details` |
|
|
360
|
+
| --- | --- |
|
|
361
|
+
| `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`. |
|
|
362
|
+
| `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`. |
|
|
363
|
+
| `undo_last_change` | `diff` (the undo diff with restored anchors), `patch`, `patchTruncated`, and `metrics` in the same shape as `replace`. |
|
|
364
|
+
| `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
365
|
|
|
218
|
-
|
|
366
|
+
`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
367
|
|
|
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.
|
|
368
|
+
## Error and warning codes
|
|
222
369
|
|
|
223
|
-
|
|
370
|
+
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
371
|
|
|
225
|
-
|
|
372
|
+
Most common, with the fix:
|
|
226
373
|
|
|
227
|
-
|
|
374
|
+
- `[E_STALE_ANCHOR]`: the anchor is not owned in this session. Call `read` for fresh anchors and retry.
|
|
375
|
+
- `[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.
|
|
376
|
+
- `[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.
|
|
377
|
+
- `[E_STORE_UNAVAILABLE]`: no SQLite runtime. Run pi under Node 22.19+ or a Bun build that ships `bun:sqlite`.
|
|
378
|
+
- `[E_WRITE_HASH_ECHO]`: a `write` content line contains a copied served row. Remove the anchors and retry.
|
|
379
|
+
- `[E_GREP_TIMEOUT]`: ripgrep timed out after 10 seconds. Narrow `path` or simplify `pattern` and retry.
|
|
228
380
|
|
|
229
|
-
|
|
381
|
+
Full reference:
|
|
230
382
|
|
|
231
383
|
| Code | Meaning |
|
|
232
384
|
| --- | --- |
|
|
385
|
+
| `[E_CONFIG]` | `PI_HASHLINE_DIR` is nonempty but not an absolute path. |
|
|
233
386
|
| `[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
387
|
| `[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
388
|
| `[E_BAD_REF]` | An anchor in `remove_from`/`remove_to` is not a bare 4-character anchor (the anchor table is letters only). |
|
|
@@ -237,6 +390,7 @@ Codes starting with `E_` are errors: nothing was written — except `File was wr
|
|
|
237
390
|
| `[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
391
|
| `[W_INVALID_PATCH]` | A `replacement_lines` element is a diff-preview row (`+anchor│`, `-anchor│`, `- │`). The marker is stripped automatically with a warning. |
|
|
239
392
|
| `[W_BARE_HASH_PREFIX]` | A `replacement_lines` element starts with an `anchor│` prefix. The prefix is stripped automatically with a warning. |
|
|
393
|
+
| `[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. |
|
|
240
394
|
| `[E_WOULD_EMPTY]` | An edit would empty a non-empty file; use `write` instead. |
|
|
241
395
|
| `[E_NOT_FOUND]` | The path does not exist. |
|
|
242
396
|
| `[E_ACCESS]` | The file is not readable or writable. |
|
|
@@ -266,6 +420,66 @@ Codes starting with `E_` are errors: nothing was written — except `File was wr
|
|
|
266
420
|
- Corrupt store. If the store fails its health check it is renamed to `hash-store.sqlite.corrupt-<timestamp>` and rebuilt automatically.
|
|
267
421
|
- 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
422
|
- 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.
|
|
423
|
+
- 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).
|
|
424
|
+
|
|
425
|
+
## Privacy and on-disk state
|
|
426
|
+
|
|
427
|
+
All state lives under the config directory (see [Configuration](#configuration)):
|
|
428
|
+
|
|
429
|
+
| Path | Contents |
|
|
430
|
+
| --- | --- |
|
|
431
|
+
| `config.json` | The settings from [Configuration](#configuration). |
|
|
432
|
+
| `hash-store.sqlite` (+ `-wal`, `-shm`) | Per-file snapshots of allocated anchors keyed by content checksum, plus the undo table. |
|
|
433
|
+
| `sessions/<key>.registry.jsonl` | The session's anchor ownership log (`allocate`/`free`/`clear` events). |
|
|
434
|
+
|
|
435
|
+
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.
|
|
436
|
+
|
|
437
|
+
### Isolated state
|
|
438
|
+
|
|
439
|
+
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.
|
|
440
|
+
|
|
441
|
+
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.
|
|
442
|
+
|
|
443
|
+
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.
|
|
444
|
+
|
|
445
|
+
## How anchors work
|
|
446
|
+
|
|
447
|
+
### Allocation
|
|
448
|
+
|
|
449
|
+
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).
|
|
450
|
+
|
|
451
|
+
### Ownership and mapping across edits
|
|
452
|
+
|
|
453
|
+
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.
|
|
454
|
+
|
|
455
|
+
Two guarantees make this safe even with duplicated content:
|
|
456
|
+
|
|
457
|
+
- 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.
|
|
458
|
+
- "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.
|
|
459
|
+
|
|
460
|
+
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`.
|
|
461
|
+
|
|
462
|
+
### The anchor table is built for tokenizers
|
|
463
|
+
|
|
464
|
+
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`.
|
|
465
|
+
|
|
466
|
+
### Line checksums
|
|
467
|
+
|
|
468
|
+
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.
|
|
469
|
+
|
|
470
|
+
### Persistent state
|
|
471
|
+
|
|
472
|
+
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.
|
|
473
|
+
|
|
474
|
+
## Benchmark
|
|
475
|
+
|
|
476
|
+
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: replacements, insertions, deletions, copies, moves, large files, several file types, and Unicode edge cases. 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.
|
|
477
|
+
|
|
478
|
+
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.
|
|
479
|
+
|
|
480
|
+
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.
|
|
481
|
+
|
|
482
|
+
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
483
|
|
|
270
484
|
## Development
|
|
271
485
|
|
|
@@ -273,7 +487,9 @@ Requires [Node.js](https://nodejs.org) 22.19 or newer and npm.
|
|
|
273
487
|
|
|
274
488
|
```bash
|
|
275
489
|
npm install
|
|
276
|
-
npm test
|
|
490
|
+
npm test # full suite
|
|
491
|
+
npm run test:unit # unit suite (heavy stress and property tests excluded)
|
|
492
|
+
npm run test:coverage # full suite with coverage thresholds
|
|
277
493
|
npm run lint
|
|
278
494
|
npm run typecheck
|
|
279
495
|
```
|
package/index.ts
CHANGED
|
@@ -24,7 +24,7 @@ import {
|
|
|
24
24
|
setAutoReadAllIgnoreFromText,
|
|
25
25
|
} from "./src/config";
|
|
26
26
|
import { loadHashStore, pruneMissing } from "./src/hash-store";
|
|
27
|
-
import { initRegistry, gcRegistrySidecars, clearRegistry, freeAnchors, sessionKeyFor, withAnchorSession, releaseRegistrySession } from "./src/anchor-registry";
|
|
27
|
+
import { initRegistry, gcRegistrySidecars, clearRegistry, freeAnchors, sessionKeyFor, withAnchorSession, releaseRegistrySession, formatAnchorReclaimNotice, takeReclaimedPaths } from "./src/anchor-registry";
|
|
28
28
|
import { serveRows } from "./src/served";
|
|
29
29
|
import { finalizeTurn, planAssistantMessage } from "./src/batch";
|
|
30
30
|
import { currentEditFlags } from "./src/edit-common";
|
|
@@ -217,10 +217,11 @@ export default function (pi: ExtensionAPI): void {
|
|
|
217
217
|
);
|
|
218
218
|
const fileLines = splitLines(normalized);
|
|
219
219
|
serveRows(absolutePath, fileHashes, fileLines, preview.servedHashes);
|
|
220
|
+
const reclaimNotice = formatAnchorReclaimNotice(takeReclaimedPaths());
|
|
220
221
|
return {
|
|
221
222
|
content: [
|
|
222
223
|
...(event.content ?? []),
|
|
223
|
-
{ type: "text", text: `\n\n--- Auto-read (hashline anchors) ---\n${preview.text}` },
|
|
224
|
+
{ type: "text", text: `\n\n--- Auto-read (hashline anchors) ---\n${preview.text}${reclaimNotice !== undefined ? `\n\n${reclaimNotice}` : ""}` },
|
|
224
225
|
],
|
|
225
226
|
};
|
|
226
227
|
} catch (error) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-hashline-edit-pro",
|
|
3
|
-
"version": "4.4.
|
|
3
|
+
"version": "4.4.4",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Hash-anchored read/replace/insert/grep tools for pi-coding-agent. Every line gets a unique 4-char tokenizer-friendly anchor that stays stable across edits; stale or ambiguous anchors are rejected, never fuzzy-matched. Undo persists across restarts.",
|
|
6
6
|
"main": "index.ts",
|
|
@@ -1,2 +1,3 @@
|
|
|
1
1
|
- `read`: call again after an edit when you need anchors you lack — post-edit diff `+anchor│`/` anchor│` rows and any served `anchor│content` rows already carry fresh anchors for the changed range.
|
|
2
|
-
- `read`: `E_AUTO_READ_ALL` on an attached file means its content is still exactly as it was when attached at the start of this session.
|
|
2
|
+
- `read`: `E_AUTO_READ_ALL` on an attached file means its content is still exactly as it was when attached at the start of this session.
|
|
3
|
+
- `read`: `[W_ANCHOR_RECLAIMED]` means the session's anchor quota was tight and the listed files' anchors were freed; read a freed file again before editing it.
|
package/src/anchor-registry.ts
CHANGED
|
@@ -11,7 +11,7 @@ import { errCode, splitLines } from "./utils";
|
|
|
11
11
|
import * as Diff from "diff";
|
|
12
12
|
import { lineChecksum } from "./hashline";
|
|
13
13
|
import { getAllocatedState, persistSnapshot, type HashStore } from "./hash-store";
|
|
14
|
-
import { ANCHOR_POOL_EXHAUSTED_PREFIX } from "./constants";
|
|
14
|
+
import { ANCHOR_POOL_EXHAUSTED_PREFIX, ANCHOR_RECLAIM_WARNING_CODE, MAX_RECLAIMED_PATHS_REPORTED } from "./constants";
|
|
15
15
|
|
|
16
16
|
export type RegistryEvent =
|
|
17
17
|
| { kind: "session"; sessionFile: string }
|
|
@@ -153,6 +153,10 @@ interface SessionState {
|
|
|
153
153
|
everMinted: AnchorMintedSet;
|
|
154
154
|
probe: number;
|
|
155
155
|
allocatedChecksum: Map<string, string>;
|
|
156
|
+
lastUsedAt: Map<string, number>;
|
|
157
|
+
usageClock: number;
|
|
158
|
+
reclaimed: string[];
|
|
159
|
+
shadow?: boolean;
|
|
156
160
|
}
|
|
157
161
|
|
|
158
162
|
const SIDECAR_SUFFIX = ".registry.jsonl";
|
|
@@ -176,7 +180,7 @@ function newSessionState(seed?: string): SessionState {
|
|
|
176
180
|
probe = (probe * 256 + byte) % ANCHOR_COUNT;
|
|
177
181
|
}
|
|
178
182
|
}
|
|
179
|
-
return { owned: new Map(), served: new Map(), everMinted: new Set(), probe, allocatedChecksum: new Map() };
|
|
183
|
+
return { owned: new Map(), served: new Map(), everMinted: new Set(), probe, allocatedChecksum: new Map(), lastUsedAt: new Map(), usageClock: 0, reclaimed: [] };
|
|
180
184
|
}
|
|
181
185
|
|
|
182
186
|
function seedServedFromOwned(state: SessionState): void {
|
|
@@ -195,9 +199,12 @@ export function foldRegistryEvents(events: RegistryEvent[], seed?: string): Sess
|
|
|
195
199
|
for (const event of events) {
|
|
196
200
|
if (event.kind === "clear") {
|
|
197
201
|
state.owned.clear();
|
|
202
|
+
state.lastUsedAt.clear();
|
|
198
203
|
} else if (event.kind === "minted") {
|
|
199
204
|
for (const anchor of event.anchors) state.everMinted.add(anchor);
|
|
200
205
|
} else if (event.kind === "allocate") {
|
|
206
|
+
state.usageClock += 1;
|
|
207
|
+
state.lastUsedAt.set(event.path, state.usageClock);
|
|
201
208
|
for (const [anchor, checksum] of event.rows) {
|
|
202
209
|
state.owned.set(anchor, { path: event.path, checksum });
|
|
203
210
|
state.everMinted.add(anchor);
|
|
@@ -213,9 +220,15 @@ export function foldRegistryEvents(events: RegistryEvent[], seed?: string): Sess
|
|
|
213
220
|
state.owned.delete(anchor);
|
|
214
221
|
}
|
|
215
222
|
}
|
|
223
|
+
state.lastUsedAt.delete(event.path);
|
|
216
224
|
}
|
|
217
225
|
}
|
|
218
226
|
}
|
|
227
|
+
const ownedPaths = new Set<string>();
|
|
228
|
+
for (const entry of state.owned.values()) ownedPaths.add(entry.path);
|
|
229
|
+
for (const path of [...state.lastUsedAt.keys()]) {
|
|
230
|
+
if (!ownedPaths.has(path)) state.lastUsedAt.delete(path);
|
|
231
|
+
}
|
|
219
232
|
return state;
|
|
220
233
|
}
|
|
221
234
|
|
|
@@ -468,32 +481,96 @@ function fingerprintIndex(state: SessionState, path: string): Map<string, string
|
|
|
468
481
|
|
|
469
482
|
export const MINT_PROBE_LIMIT = 8192;
|
|
470
483
|
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
484
|
+
function touchPath(state: SessionState, path: string): void {
|
|
485
|
+
state.usageClock += 1;
|
|
486
|
+
state.lastUsedAt.set(path, state.usageClock);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function hasOwnedPath(state: SessionState, path: string): boolean {
|
|
490
|
+
for (const entry of state.owned.values()) {
|
|
491
|
+
if (entry.path === path) return true;
|
|
492
|
+
}
|
|
493
|
+
return false;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
function evictPath(state: SessionState, path: string): string[] {
|
|
497
|
+
const anchors: string[] = [];
|
|
498
|
+
for (const [anchor, entry] of [...state.owned]) {
|
|
499
|
+
if (entry.path === path) {
|
|
500
|
+
anchors.push(anchor);
|
|
501
|
+
state.owned.delete(anchor);
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
state.served.delete(path);
|
|
505
|
+
state.lastUsedAt.delete(path);
|
|
506
|
+
if (anchors.length > 0 && state === current()) {
|
|
507
|
+
appendEvent({ kind: "free", path, anchors });
|
|
508
|
+
}
|
|
509
|
+
return anchors;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
export function reclaimAnchorSpace(state: SessionState, protectPath?: string): string | undefined {
|
|
513
|
+
if (state.shadow) return undefined;
|
|
514
|
+
let victim: string | undefined;
|
|
515
|
+
let victimTime = Number.POSITIVE_INFINITY;
|
|
516
|
+
for (const [, entry] of state.owned) {
|
|
517
|
+
if (entry.path === protectPath) continue;
|
|
518
|
+
const time = state.lastUsedAt.get(entry.path) ?? -1;
|
|
519
|
+
if (time < victimTime) {
|
|
520
|
+
victimTime = time;
|
|
521
|
+
victim = entry.path;
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
if (victim === undefined) return undefined;
|
|
525
|
+
evictPath(state, victim);
|
|
526
|
+
state.reclaimed.push(victim);
|
|
527
|
+
return victim;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
export function mintAnchor(state: SessionState, protectPath?: string): string {
|
|
531
|
+
for (;;) {
|
|
532
|
+
for (let probe = 0; probe < MINT_PROBE_LIMIT; probe++) {
|
|
533
|
+
state.probe = (state.probe + HASH_PROBE_STRIDE) % ANCHOR_COUNT;
|
|
534
|
+
const candidate = anchorAt(state.probe);
|
|
535
|
+
if (!state.owned.has(candidate) && !state.everMinted.has(candidate)) {
|
|
536
|
+
return candidate;
|
|
537
|
+
}
|
|
477
538
|
}
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
539
|
+
for (let step = 1; step <= ANCHOR_COUNT; step++) {
|
|
540
|
+
const candidate = anchorAt((state.probe + step) % ANCHOR_COUNT);
|
|
541
|
+
if (!state.owned.has(candidate)) {
|
|
542
|
+
for (const served of state.served.values()) served.delete(candidate);
|
|
543
|
+
return candidate;
|
|
544
|
+
}
|
|
484
545
|
}
|
|
546
|
+
if (reclaimAnchorSpace(state, protectPath) === undefined) break;
|
|
485
547
|
}
|
|
486
548
|
throw new Error(
|
|
487
549
|
`${ANCHOR_POOL_EXHAUSTED_PREFIX}; use write for very large files.`,
|
|
488
550
|
);
|
|
489
551
|
}
|
|
490
552
|
|
|
553
|
+
export function takeReclaimedPaths(): string[] {
|
|
554
|
+
const state = current();
|
|
555
|
+
if (!state || state.reclaimed.length === 0) return [];
|
|
556
|
+
return state.reclaimed.splice(0, state.reclaimed.length);
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
export function formatAnchorReclaimNotice(paths: string[]): string | undefined {
|
|
560
|
+
const unique = [...new Set(paths)];
|
|
561
|
+
if (unique.length === 0) return undefined;
|
|
562
|
+
const shown = unique.slice(0, MAX_RECLAIMED_PATHS_REPORTED);
|
|
563
|
+
const more = unique.length - shown.length;
|
|
564
|
+
const list = more > 0 ? `${shown.join(", ")} (+${more} more)` : shown.join(", ");
|
|
565
|
+
return `${ANCHOR_RECLAIM_WARNING_CODE} The session's anchor quota is nearly exhausted; freed all anchors of ${list}, the files least recently read or edited. Read each freed file again before editing it; for read-heavy work, ask the user to run /clear-anchors or start a new session.`;
|
|
566
|
+
}
|
|
567
|
+
|
|
491
568
|
export function allocateAnchor(path: string, checksum: string): string {
|
|
492
569
|
const state = current();
|
|
493
570
|
if (!state) {
|
|
494
571
|
throw new Error("[E_REGISTRY] The anchor registry is not initialized; call initRegistry on session_start first.");
|
|
495
572
|
}
|
|
496
|
-
const anchor = mintAnchor(state);
|
|
573
|
+
const anchor = mintAnchor(state, path);
|
|
497
574
|
state.everMinted.add(anchor);
|
|
498
575
|
state.owned.set(anchor, { path, checksum });
|
|
499
576
|
appendEvent({ kind: "allocate", path, rows: [[anchor, checksum]] });
|
|
@@ -505,26 +582,21 @@ export function allocateAnchor(path: string, checksum: string): string {
|
|
|
505
582
|
export function freeAnchors(path: string, anchors?: string[]): void {
|
|
506
583
|
const state = current();
|
|
507
584
|
if (!state) return;
|
|
585
|
+
if (!anchors) {
|
|
586
|
+
evictPath(state, path);
|
|
587
|
+
return;
|
|
588
|
+
}
|
|
508
589
|
const freed: string[] = [];
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
state.served.get(path)?.delete(anchor);
|
|
515
|
-
}
|
|
590
|
+
for (const anchor of anchors) {
|
|
591
|
+
if (state.owned.has(anchor)) {
|
|
592
|
+
freed.push(anchor);
|
|
593
|
+
state.owned.delete(anchor);
|
|
594
|
+
state.served.get(path)?.delete(anchor);
|
|
516
595
|
}
|
|
517
|
-
} else {
|
|
518
|
-
for (const [anchor, entry] of [...state.owned]) {
|
|
519
|
-
if (entry.path === path) {
|
|
520
|
-
freed.push(anchor);
|
|
521
|
-
state.owned.delete(anchor);
|
|
522
|
-
}
|
|
523
|
-
}
|
|
524
|
-
state.served.delete(path);
|
|
525
596
|
}
|
|
526
597
|
if (freed.length > 0) {
|
|
527
|
-
|
|
598
|
+
if (!hasOwnedPath(state, path)) state.lastUsedAt.delete(path);
|
|
599
|
+
appendEvent({ kind: "free", path, anchors });
|
|
528
600
|
}
|
|
529
601
|
}
|
|
530
602
|
|
|
@@ -534,6 +606,8 @@ export function clearRegistry(): void {
|
|
|
534
606
|
state.owned.clear();
|
|
535
607
|
state.served.clear();
|
|
536
608
|
state.allocatedChecksum.clear();
|
|
609
|
+
state.lastUsedAt.clear();
|
|
610
|
+
state.reclaimed.length = 0;
|
|
537
611
|
appendEvent({ kind: "clear" });
|
|
538
612
|
}
|
|
539
613
|
|
|
@@ -609,6 +683,10 @@ export function shadowStateFrom(state: SessionState): SessionState {
|
|
|
609
683
|
everMinted: new ShadowMintedSet(state.everMinted),
|
|
610
684
|
probe: state.probe,
|
|
611
685
|
allocatedChecksum: state.allocatedChecksum,
|
|
686
|
+
lastUsedAt: new Map(state.lastUsedAt),
|
|
687
|
+
usageClock: state.usageClock,
|
|
688
|
+
reclaimed: [],
|
|
689
|
+
shadow: true,
|
|
612
690
|
};
|
|
613
691
|
}
|
|
614
692
|
|
|
@@ -721,7 +799,7 @@ export function alignOwnershipWithSpans(
|
|
|
721
799
|
}
|
|
722
800
|
for (let k = 0; k < span.replacementCount; k++) {
|
|
723
801
|
if (replacement[k] !== undefined) continue;
|
|
724
|
-
const anchor = mintAnchor(state);
|
|
802
|
+
const anchor = mintAnchor(state, path);
|
|
725
803
|
const checksum = newChecksums[start + k]!;
|
|
726
804
|
minted.push({ index: start + k, anchor, checksum });
|
|
727
805
|
state.owned.set(anchor, { path, checksum });
|
|
@@ -769,7 +847,7 @@ export function alignOwnership(
|
|
|
769
847
|
if (part.added) {
|
|
770
848
|
for (let k = 0; k < count; k++) {
|
|
771
849
|
const checksum = newChecksums[newIdx + k]!;
|
|
772
|
-
const anchor = mintAnchor(state);
|
|
850
|
+
const anchor = mintAnchor(state, path);
|
|
773
851
|
state.everMinted.add(anchor);
|
|
774
852
|
state.owned.set(anchor, { path, checksum });
|
|
775
853
|
minted.push({ index: newIdx + k, anchor, checksum });
|
|
@@ -792,7 +870,7 @@ export function alignOwnership(
|
|
|
792
870
|
const entry = state.owned.get(anchor);
|
|
793
871
|
const checksum = newChecksums[newIdx + k]!;
|
|
794
872
|
if (entry && entry.path !== path) {
|
|
795
|
-
const fresh = mintAnchor(state);
|
|
873
|
+
const fresh = mintAnchor(state, path);
|
|
796
874
|
state.owned.set(fresh, { path, checksum });
|
|
797
875
|
anchors[newIdx + k] = fresh;
|
|
798
876
|
} else {
|
|
@@ -834,6 +912,7 @@ export async function allocateFileAnchors(
|
|
|
834
912
|
ensureRegistry();
|
|
835
913
|
const registry = current();
|
|
836
914
|
const shadow = options?.shadow === true;
|
|
915
|
+
if (!shadow && registry) touchPath(registry, path);
|
|
837
916
|
const lines = splitLines(content);
|
|
838
917
|
const checksums = lines.map(lineChecksum);
|
|
839
918
|
if (options?.previous?.spans) {
|
|
@@ -870,7 +949,7 @@ export async function allocateFileAnchors(
|
|
|
870
949
|
const candidates = reuseIndex.get(checksum) ?? [];
|
|
871
950
|
const taken = reuseTaken.get(checksum) ?? 0;
|
|
872
951
|
reuseTaken.set(checksum, taken + 1);
|
|
873
|
-
const anchor = taken < candidates.length ? candidates[taken]! : mintAnchor(state);
|
|
952
|
+
const anchor = taken < candidates.length ? candidates[taken]! : mintAnchor(state, path);
|
|
874
953
|
state.everMinted.add(anchor);
|
|
875
954
|
state.owned.set(anchor, { path, checksum });
|
|
876
955
|
return anchor;
|
|
@@ -890,6 +969,7 @@ export async function allocateFileAnchors(
|
|
|
890
969
|
export function adoptAnchors(path: string, entries: Map<string, string>): void {
|
|
891
970
|
ensureRegistry();
|
|
892
971
|
const state = current()!;
|
|
972
|
+
touchPath(state, path);
|
|
893
973
|
let served = state.served.get(path);
|
|
894
974
|
if (!served) {
|
|
895
975
|
served = new Map();
|
|
@@ -967,12 +1047,17 @@ function isClaimedSidecar(sidecar: string): boolean {
|
|
|
967
1047
|
return false;
|
|
968
1048
|
}
|
|
969
1049
|
|
|
1050
|
+
function isExpectedAccessError(error: unknown): boolean {
|
|
1051
|
+
const code = errCode(error);
|
|
1052
|
+
return code === "EPERM" || code === "EACCES";
|
|
1053
|
+
}
|
|
1054
|
+
|
|
970
1055
|
export async function gcRegistrySidecars(): Promise<void> {
|
|
971
1056
|
let names: string[];
|
|
972
1057
|
try {
|
|
973
1058
|
names = await readdir(sessionClaimsDir());
|
|
974
1059
|
} catch (error) {
|
|
975
|
-
if (errCode(error) !== "ENOENT") console.error("Failed to list registry sidecars:", error);
|
|
1060
|
+
if (errCode(error) !== "ENOENT" && !isExpectedAccessError(error)) console.error("Failed to list registry sidecars:", error);
|
|
976
1061
|
return;
|
|
977
1062
|
}
|
|
978
1063
|
for (const name of names) {
|
|
@@ -982,7 +1067,7 @@ export async function gcRegistrySidecars(): Promise<void> {
|
|
|
982
1067
|
const tmpStat = await stat(tmpPath);
|
|
983
1068
|
if (Date.now() - tmpStat.mtimeMs > 60 * 60 * 1000) await rm(tmpPath, { force: true });
|
|
984
1069
|
} catch (error) {
|
|
985
|
-
if (errCode(error) !== "ENOENT") console.error("Failed to inspect registry sidecar:", error);
|
|
1070
|
+
if (errCode(error) !== "ENOENT" && !isExpectedAccessError(error)) console.error("Failed to inspect registry sidecar:", error);
|
|
986
1071
|
}
|
|
987
1072
|
continue;
|
|
988
1073
|
}
|
|
@@ -995,8 +1080,12 @@ export async function gcRegistrySidecars(): Promise<void> {
|
|
|
995
1080
|
await stat(sessionFile);
|
|
996
1081
|
} catch (error) {
|
|
997
1082
|
if (errCode(error) === "ENOENT") {
|
|
998
|
-
|
|
999
|
-
|
|
1083
|
+
try {
|
|
1084
|
+
await rm(sidecar, { force: true });
|
|
1085
|
+
} catch (removeError) {
|
|
1086
|
+
if (!isExpectedAccessError(removeError)) throw removeError;
|
|
1087
|
+
}
|
|
1088
|
+
} else if (!isExpectedAccessError(error)) {
|
|
1000
1089
|
console.error("Failed to inspect registry sidecar:", error);
|
|
1001
1090
|
}
|
|
1002
1091
|
}
|
package/src/auto-read-all.ts
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
} from "./constants";
|
|
12
12
|
import { normalizeAutoReadAllIgnoreEntry, type AutoReadAllMode } from "./config";
|
|
13
13
|
import { serveRows } from "./served";
|
|
14
|
+
import { formatAnchorReclaimNotice, takeReclaimedPaths } from "./anchor-registry";
|
|
14
15
|
import { readNormFile, safeSnapId } from "./file-reader";
|
|
15
16
|
import { resolveRgPath } from "./grep";
|
|
16
17
|
import { globToRegex } from "./glob";
|
|
@@ -438,7 +439,8 @@ export async function buildAutoReadAllInjection(cwd: string, budgetBytes: number
|
|
|
438
439
|
const coverage = `[coverage: ${completeFiles} complete]`;
|
|
439
440
|
const completeNames = sections.map((section) => section.file);
|
|
440
441
|
const machineList = `[files complete: ${JSON.stringify(completeNames)} omitted: ${JSON.stringify(omitted)}]`;
|
|
441
|
-
const
|
|
442
|
+
const reclaimNotice = formatAnchorReclaimNotice(takeReclaimedPaths());
|
|
443
|
+
const text = `${HEADER}\n\n${coverage}\n${machineList}\n\n${sectionTexts.join("\n\n")}\n\n${buildFooter(sections.length, discovery, omitted)}${reclaimNotice !== undefined ? `\n${reclaimNotice}` : ""}`;
|
|
442
444
|
return { text, files: sections.length, bytes, omitted, completeFiles };
|
|
443
445
|
}
|
|
444
446
|
|
package/src/batch.ts
CHANGED
|
@@ -16,7 +16,7 @@ import {
|
|
|
16
16
|
type HEdit,
|
|
17
17
|
type PlannedEdit,
|
|
18
18
|
} from "./hashline";
|
|
19
|
-
import { adoptAnchors, servedForPath } from "./anchor-registry";
|
|
19
|
+
import { adoptAnchors, servedForPath, formatAnchorReclaimNotice, takeReclaimedPaths } from "./anchor-registry";
|
|
20
20
|
import { restoreEndings, stripBOM, toLF, type LineEnding } from "./normalize";
|
|
21
21
|
import { assertInsertReq, assertReq, normReq } from "./payload-contract";
|
|
22
22
|
import { saveUndo } from "./replace-undo";
|
|
@@ -638,6 +638,8 @@ async function finishBatch(member: PlannedMember, signal?: AbortSignal): Promise
|
|
|
638
638
|
if (error instanceof Error) error.message = withAbortSuffix(error.message, runtime.display);
|
|
639
639
|
throw error;
|
|
640
640
|
}
|
|
641
|
+
const reclaimNotice = formatAnchorReclaimNotice(takeReclaimedPaths());
|
|
642
|
+
if (reclaimNotice !== undefined) warnings.push(reclaimNotice);
|
|
641
643
|
if (composed === base.content) {
|
|
642
644
|
const snapshotId = await safeSnapId(paths.absolutePath, "noop edit");
|
|
643
645
|
return combinedNoop(paths.displayPath, member, runtime, snapshotId);
|
|
@@ -700,6 +702,8 @@ async function finishBatch(member: PlannedMember, signal?: AbortSignal): Promise
|
|
|
700
702
|
const detail = error instanceof Error ? error.message : String(error);
|
|
701
703
|
throw new Error(`${detail} File was written; anchor finalization failed. One undo reverts. Call read for fresh anchors.`);
|
|
702
704
|
}
|
|
705
|
+
const writeReclaim = formatAnchorReclaimNotice(takeReclaimedPaths());
|
|
706
|
+
if (writeReclaim !== undefined) warnings.push(writeReclaim);
|
|
703
707
|
const range = changedRange(base.content, composed);
|
|
704
708
|
let added = 0;
|
|
705
709
|
let removed = 0;
|
|
@@ -746,6 +750,8 @@ async function finishBatch(member: PlannedMember, signal?: AbortSignal): Promise
|
|
|
746
750
|
async function combinedNoop(path: string, member: PlannedMember, runtime: BatchState, snapshotId: string | undefined): Promise<TResult> {
|
|
747
751
|
const executed = runtime.applied + runtime.noops;
|
|
748
752
|
const warnings = [...runtime.warnings];
|
|
753
|
+
const reclaimNotice = formatAnchorReclaimNotice(takeReclaimedPaths());
|
|
754
|
+
if (reclaimNotice !== undefined) warnings.push(reclaimNotice);
|
|
749
755
|
const noop = buildNoop(
|
|
750
756
|
{
|
|
751
757
|
path,
|
package/src/commit.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { saveUndo } from "./replace-undo";
|
|
|
6
6
|
import { getDiffContextLines } from "./config";
|
|
7
7
|
import { safeSnapId } from "./file-reader";
|
|
8
8
|
import { writeAtomic } from "./fs-write";
|
|
9
|
+
import { formatAnchorReclaimNotice, takeReclaimedPaths } from "./anchor-registry";
|
|
9
10
|
import { servedHashesFromDiff, serveRows } from "./served";
|
|
10
11
|
import { lineHashes } from "./hashline";
|
|
11
12
|
import { spanForEdit } from "./replace";
|
|
@@ -27,6 +28,8 @@ export async function commitEdit(pipe: PipelineResult, meta: CommitMeta): Promis
|
|
|
27
28
|
const { path, absolutePath, mutationTargetPath, signal } = meta;
|
|
28
29
|
const warnings = [...(meta.prefixWarnings ?? []), ...pipe.warnings];
|
|
29
30
|
const editsAttempted = 1;
|
|
31
|
+
const readReclaim = formatAnchorReclaimNotice(takeReclaimedPaths());
|
|
32
|
+
if (readReclaim !== undefined) warnings.push(readReclaim);
|
|
30
33
|
|
|
31
34
|
if (pipe.result === pipe.originalNormalized) {
|
|
32
35
|
const noopSnapshotId = await safeSnapId(absolutePath, "noop edit");
|
|
@@ -120,6 +123,8 @@ export async function commitEdit(pipe: PipelineResult, meta: CommitMeta): Promis
|
|
|
120
123
|
const detail = error instanceof Error ? error.message : String(error);
|
|
121
124
|
throw new Error(`${detail} File was written; anchor finalization failed. One undo reverts. Call read for fresh anchors.`);
|
|
122
125
|
}
|
|
126
|
+
const writeReclaim = formatAnchorReclaimNotice(takeReclaimedPaths());
|
|
127
|
+
if (writeReclaim !== undefined) warnings.push(writeReclaim);
|
|
123
128
|
const successInput = {
|
|
124
129
|
path,
|
|
125
130
|
originalNormalized: pipe.originalNormalized,
|
package/src/constants.ts
CHANGED
|
@@ -19,6 +19,9 @@ export const NUL_CONTENT_MSG =
|
|
|
19
19
|
export const ANCHOR_POOL_EXHAUSTED_PREFIX =
|
|
20
20
|
"[E_FILE_TOO_LARGE] The session's anchor pool is exhausted";
|
|
21
21
|
|
|
22
|
+
export const ANCHOR_RECLAIM_WARNING_CODE = "[W_ANCHOR_RECLAIMED]";
|
|
23
|
+
export const MAX_RECLAIMED_PATHS_REPORTED = 10;
|
|
24
|
+
|
|
22
25
|
export const AUTO_READ_ALL_CUSTOM_TYPE = "hashline-auto-read-all";
|
|
23
26
|
export const AUTO_READ_ALL_MAX_FILES = 500;
|
|
24
27
|
export const AUTO_READ_ALL_MAX_FILE_BYTES = 200_000;
|
package/src/edit-common.ts
CHANGED
|
@@ -163,8 +163,9 @@ export async function resolveEditTargetWithRequirement(input: PathRequirementInp
|
|
|
163
163
|
return anchorTarget;
|
|
164
164
|
}
|
|
165
165
|
|
|
166
|
+
const AUTO_FIX_WARNING_CODES = ["[W_BAD_SHAPE]", "[W_BAD_REF]", "[W_INVALID_PATCH]", "[W_BARE_HASH_PREFIX]"];
|
|
166
167
|
export async function throwIfStrictInput(warnings: string[]): Promise<void> {
|
|
167
|
-
const fixes = warnings.filter((warning) => warning.startsWith(
|
|
168
|
+
const fixes = warnings.filter((warning) => AUTO_FIX_WARNING_CODES.some((code) => warning.startsWith(code)));
|
|
168
169
|
if (fixes.length === 0) return;
|
|
169
170
|
const { strictInput } = await readConfig();
|
|
170
171
|
if (strictInput === true) {
|
package/src/grep.ts
CHANGED
|
@@ -14,7 +14,7 @@ import { toCwd, toDisplayPath } from "./paths";
|
|
|
14
14
|
import { loadP, loadGuide } from "./prompts";
|
|
15
15
|
import { normReq } from "./payload-contract";
|
|
16
16
|
import { abortIf, clipLine, errCode, gutterWidth, isRec, makePrepareArguments, rejectUnknownFields, truncateToBytes, visLines } from "./utils";
|
|
17
|
-
import { withAnchorSession } from "./anchor-registry";
|
|
17
|
+
import { withAnchorSession, formatAnchorReclaimNotice, takeReclaimedPaths } from "./anchor-registry";
|
|
18
18
|
import { serveRows } from "./served";
|
|
19
19
|
import { Text } from "@earendil-works/pi-tui";
|
|
20
20
|
import { expandHint, getResultText, reuseText, type CallT, type FgT } from "./replace-render";
|
|
@@ -660,6 +660,8 @@ export function regGrep(pi: ExtensionAPI): void {
|
|
|
660
660
|
.map((hit) => `=== ${hit.displayPath} ===\n${hit.rows.join("\n")}`)
|
|
661
661
|
.join("\n");
|
|
662
662
|
const notes: string[] = [];
|
|
663
|
+
const reclaimNotice = formatAnchorReclaimNotice(takeReclaimedPaths());
|
|
664
|
+
if (reclaimNotice !== undefined) notes.push(reclaimNotice);
|
|
663
665
|
if (rowTruncated) notes.push(`[grep: output truncated at ${DEFAULT_MAX_LINES} rows or ${formatSize(DEFAULT_MAX_BYTES)}; refine the pattern to see more.]`);
|
|
664
666
|
if (limitTruncated) notes.push(`[grep: showing first ${limit} matches; increase limit to see more.]`);
|
|
665
667
|
if (linesReplaced > 0) notes.push(`[grep: ${linesReplaced} line(s) exceed ${formatSize(MAX_GREP_LINE_BYTES)} and are shown as truncated fragments; use read to see the full lines.]`);
|
package/src/hash-store.ts
CHANGED
|
@@ -588,7 +588,7 @@ async function statMissing(rows: { path: string }[]): Promise<string[]> {
|
|
|
588
588
|
} catch (error: unknown) {
|
|
589
589
|
const code = errCode(error);
|
|
590
590
|
if (code !== "ENOENT" && code !== "ENOTDIR") {
|
|
591
|
-
console.error("Failed to stat hash store path:", row.path, error);
|
|
591
|
+
if (code !== "EPERM" && code !== "EACCES") console.error("Failed to stat hash store path:", row.path, error);
|
|
592
592
|
return undefined;
|
|
593
593
|
}
|
|
594
594
|
return row.path;
|
package/src/paths.ts
CHANGED
|
@@ -16,7 +16,11 @@ function configBase(): string {
|
|
|
16
16
|
}
|
|
17
17
|
|
|
18
18
|
export function configDir(): string {
|
|
19
|
-
|
|
19
|
+
const override = process.env.PI_HASHLINE_DIR;
|
|
20
|
+
if (override && !isAbsolute(override)) {
|
|
21
|
+
throw new Error("[E_CONFIG] PI_HASHLINE_DIR must be an absolute path");
|
|
22
|
+
}
|
|
23
|
+
return override || join(configBase(), "pi-hashline-edit-pro");
|
|
20
24
|
}
|
|
21
25
|
|
|
22
26
|
export function configPath(): string {
|
package/src/read.ts
CHANGED
|
@@ -19,7 +19,7 @@ import { withReadPrompts, DEFAULT_EDIT_FLAGS, type EditToolFlags } from "./edit-
|
|
|
19
19
|
import { valAccess } from "./validation";
|
|
20
20
|
import { readConfig } from "./config";
|
|
21
21
|
import { resolveTarget } from "./fs-write";
|
|
22
|
-
import { withAnchorSession, servedForPath, sessionKeyFor } from "./anchor-registry";
|
|
22
|
+
import { withAnchorSession, servedForPath, sessionKeyFor, formatAnchorReclaimNotice, takeReclaimedPaths } from "./anchor-registry";
|
|
23
23
|
import { serveRows } from "./served";
|
|
24
24
|
import { getAutoReadAllSnapshot } from "./auto-read-all-state";
|
|
25
25
|
import { Text } from "@earendil-works/pi-tui";
|
|
@@ -258,10 +258,12 @@ export function regRead(pi: ExtensionAPI, flags: EditToolFlags = DEFAULT_EDIT_FL
|
|
|
258
258
|
);
|
|
259
259
|
serveRows(resolvedPath, fileHashes, fileLines, preview.servedHashes);
|
|
260
260
|
const snapshotId = await safeSnapId(absolutePath, "read");
|
|
261
|
-
const
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
261
|
+
const reclaimNotice = formatAnchorReclaimNotice(takeReclaimedPaths());
|
|
262
|
+
const previewText = [
|
|
263
|
+
preview.text,
|
|
264
|
+
hadUtf8DecodeErrors ? "[Non-UTF-8 bytes shown as U+FFFD; editing rewrites the file as UTF-8.]" : undefined,
|
|
265
|
+
reclaimNotice,
|
|
266
|
+
].filter((part): part is string => part !== undefined).join("\n\n");
|
|
265
267
|
|
|
266
268
|
return {
|
|
267
269
|
content: [{ type: "text", text: previewText }],
|
package/src/replace-undo.ts
CHANGED
|
@@ -6,7 +6,7 @@ import { Type } from "typebox";
|
|
|
6
6
|
import { loadHashStore, persistSnapshot, upsertUndo, getUndoEntry, deleteUndo, type UndoRecord } from "./hash-store";
|
|
7
7
|
import { servedHashesFromDiff, serveRows } from "./served";
|
|
8
8
|
import { lineChecksum } from "./hashline";
|
|
9
|
-
import { freeAnchors, adoptAnchors, withAnchorSession } from "./anchor-registry";
|
|
9
|
+
import { freeAnchors, adoptAnchors, withAnchorSession, formatAnchorReclaimNotice, takeReclaimedPaths } from "./anchor-registry";
|
|
10
10
|
import { resolveInCwd, writeAtomic, type FileIdentity } from "./fs-write";
|
|
11
11
|
import { toLF, stripBOM, restoreEndings, type LineEnding } from "./normalize";
|
|
12
12
|
import { genDiff, genPatch, spansFromHashes } from "./replace-diff";
|
|
@@ -237,6 +237,8 @@ export function regUndo(pi: ExtensionAPI, flags: EditToolFlags = DEFAULT_EDIT_FL
|
|
|
237
237
|
parts.push(
|
|
238
238
|
"Call read for fresh anchors.",
|
|
239
239
|
);
|
|
240
|
+
const reclaimNotice = formatAnchorReclaimNotice(takeReclaimedPaths());
|
|
241
|
+
if (reclaimNotice !== undefined) parts.push(reclaimNotice);
|
|
240
242
|
|
|
241
243
|
const patchResult = genPatch(path, currentNormalized, undo.content);
|
|
242
244
|
return {
|
|
@@ -255,7 +257,7 @@ export function regUndo(pi: ExtensionAPI, flags: EditToolFlags = DEFAULT_EDIT_FL
|
|
|
255
257
|
classification: "applied",
|
|
256
258
|
editsAttempted: 1,
|
|
257
259
|
noopEditsCount: 0,
|
|
258
|
-
warningsCount: 0,
|
|
260
|
+
warningsCount: reclaimNotice !== undefined ? 1 : 0,
|
|
259
261
|
firstChangedLine: restoredRange?.firstChangedLine,
|
|
260
262
|
lastChangedLine: restoredRange?.lastChangedLine,
|
|
261
263
|
addedLines: linesRemovedByReplace,
|