pi-hashline-edit-pro 2.0.1 → 2.1.1

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 CHANGED
@@ -1,35 +1,21 @@
1
1
  # pi-hashline-edit-pro
2
2
 
3
- A [pi-coding-agent](https://github.com/badlogic/pi-mono/tree/main/packages/coding-agent) extension that replaces the built-in `read` and `edit` tools with a hash-anchored editing workflow. Every line of a file is tagged with a unique 3-character content hash; `replace` targets lines by those hashes instead of raw text, so stale context is caught and rejected before it reaches the file.
3
+ Hash-anchored `read` and `replace` tools for [pi-coding-agent](https://github.com/badlogic/pi-mono/tree/main/packages/coding-agent). Every line of a file gets a unique 3-character hash, and you edit by hash. No line numbers, no fuzzy matching, no edits landing on the wrong line.
4
4
 
5
- Fork of [pi-hashline-edit](https://github.com/RimuruW/pi-hashline-edit) by RimuruW, extending it with 3-character hashes and collision resolution — see [Hashing](#hashing).
5
+ Fork of [pi-hashline-edit](https://github.com/RimuruW/pi-hashline-edit) by RimuruW, extended with 3-character hashes and collision resolution.
6
6
 
7
- ## Features
7
+ ## What you get
8
8
 
9
- - **Hash-anchored reads.** `read` returns every line as `HASH│content`.
10
- - **Precise edits.** `replace` targets a line range by hash. Mismatched anchors fail loudly with `[E_STALE_ANCHOR]` never a silent "close enough" relocation.
11
- - **Stable anchors.** Editing one part of a file leaves the hashes of untouched lines unchanged, so anchors from earlier reads stay valid.
12
- - **Autocorrection with warnings.** Unambiguous copy-paste mistakes hash prefixes, diff-preview rows, reversed ranges are fixed automatically and reported.
13
- - **Safe writes.** Atomic temp-file-then-rename writes preserve permissions, BOMs, line endings, symlinks, and hard links.
14
- - **Auto-read.** Fresh anchors are appended to the result of every `write` that changes the file; after `replace` and `undo_last_replace`, the post-edit diff is shown instead.
15
-
16
- ## Installation
17
-
18
- From npm:
19
-
20
- ```bash
21
- pi install npm:pi-hashline-edit-pro
22
- ```
23
-
24
- From a local checkout:
25
-
26
- ```bash
27
- pi install /path/to/pi-hashline-edit-pro
28
- ```
9
+ - **Read with anchors.** Every line comes back as `HASH│content`. The hash is the line's address.
10
+ - **Edit by hash.** `replace` targets a range of hashes, so edits always land on the lines you meant.
11
+ - **Anchors that stay put.** Edit one part of a file and the hashes of the rest stay the same. Read once, keep editing.
12
+ - **Fresh anchors, automatically.** After every `write` you get the new anchors. After every `replace` you get the diff with the new hashes.
13
+ - **Undo when you need it.** The last replace on a file can be reverted, even after a restart.
14
+ - **Safe writes.** Permissions, line endings, BOMs, symlinks, and hard links survive every edit.
29
15
 
30
16
  ## Quick start
31
17
 
32
- 1. Read a file. Every line comes back with a hash prefix (no line numbers — the hash is the address):
18
+ 1. Read a file:
33
19
 
34
20
  ```text
35
21
  ve7│function hello() {
@@ -47,37 +33,47 @@ kQm│}
47
33
  }
48
34
  ```
49
35
 
50
- 3. Keep editing. Anchors for untouched lines remain valid across edits, so hashes from earlier reads keep working; changed lines get fresh anchors, which auto-read appends after each `write`.
36
+ 3. Keep editing. Anchors for lines you didn't touch stay valid, and auto-read hands you fresh anchors after each change.
51
37
 
52
- ## The `read` tool
38
+ ## Installation
53
39
 
54
- Returns a text file with every line prefixed by `HASH│content`. The hash is a 3-character content hash from the alphabet `A-Za-z0-9` (e.g. `aB3`).
40
+ ```bash
41
+ pi install npm:pi-hashline-edit-pro
42
+ ```
55
43
 
56
- Optional parameters:
44
+ From a local checkout:
45
+
46
+ ```bash
47
+ pi install /path/to/pi-hashline-edit-pro
48
+ ```
49
+
50
+ ## The read tool
51
+
52
+ `read` returns a text file with every line prefixed by `HASH│content`. The hash is 3 characters from `A-Za-z0-9` (for example `aB3`).
57
53
 
58
54
  | Parameter | Description |
59
55
  | --- | --- |
60
56
  | `offset` | Start reading from this line number (1-indexed). |
61
57
  | `limit` | Maximum number of lines to return. |
62
58
 
63
- Paged output ends with a continuation hint, e.g. `[Showing lines 1-50 of 120. Use offset=51 to continue.]`.
59
+ Paged output ends with a continuation hint, for example `[Showing lines 1-50 of 120. Use offset=51 to continue.]`.
64
60
 
65
- Lines up to 200KB are displayed in full; larger lines are replaced by a marker with a bash inspection hint (`sed -n 'Np' <path> | head -c 204800`) since hash anchors require full lines.
61
+ Lines up to 200KB are shown in full. Larger lines are replaced by a marker with a bash inspection hint (`sed -n 'Np' <path> | head -c 204800`), because hash anchors need full lines.
66
62
 
67
63
  Edge cases:
68
64
 
69
- - **Images** (JPEG, PNG, GIF, WebP) are passed through as visual attachments and don't participate in the hashline protocol.
70
- - **Binary and directory paths** are rejected with a descriptive error.
71
- - **UTF-16/UTF-32 encoded text** (detected via BOM) is rejected with `[E_NOT_TEXT]` — editing such a file would decode it as `U+FFFD` garbage and rewrite it as corrupted UTF-8.
72
- - **Empty files** are returned as a single empty-line hash (`HASH│`); use `replace` on that hash to insert content.
73
- - **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).
74
- - **Files over 238,328 lines** are rejected with `[E_FILE_TOO_LARGE]` (see [Hashing](#hashing)).
65
+ - Images (JPEG, PNG, GIF, WebP) come back as visual attachments.
66
+ - Binary files and directories are rejected with a descriptive error.
67
+ - UTF-16 and UTF-32 text (detected via BOM) is rejected, since editing it would corrupt the file.
68
+ - Empty files come back as a single empty-line hash (`HASH│`); use `replace` on that hash to insert content.
69
+ - 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.
70
+ - Files over 238,328 lines are rejected with `[E_FILE_TOO_LARGE]`.
75
71
 
76
- ## The `replace` tool
72
+ ## The replace tool
77
73
 
78
- The built-in `edit` tool is disabled `replace` is the only edit path; call it with the hash anchors from `read` output.
74
+ The built-in `edit` tool is disabled. `replace` is the only edit path, and it takes the hash anchors from `read` output.
79
75
 
80
- Exactly one edit per call, with `hash_bounds` and `new_content` at the top level of the request:
76
+ One edit per call, with `hash_bounds` and `new_content` at the top level:
81
77
 
82
78
  ```json
83
79
  {
@@ -92,59 +88,39 @@ Exactly one edit per call, with `hash_bounds` and `new_content` at the top level
92
88
  | `hash_bounds` | Pair of 3-char hashes from `read` output marking the first and last line of the range to replace (inclusive). |
93
89
  | `new_content` | Replacement content as a single string with `\n` line separators; a trailing newline is the last line's ending, not an extra empty line. Use `""` to delete the range. |
94
90
 
95
- Behavior:
91
+ Notes:
96
92
 
97
- - **Validation before any file I/O.** Unknown fields, missing fields, wrong types, and malformed anchors are rejected with `[E_BAD_SHAPE]` / `[E_BAD_REF]`. The edit applies against the pre-edit snapshot, so all hashes in the request come from one consistent file state.
98
- - **Autocorrections** (all accompanied by a warning unless noted):
99
- - A `HASH│` prefix accidentally left on a `new_content` line is stripped.
100
- - Diff-preview rows (`+HASH│…`, `-HASH│…`, `- │…`) pasted into `new_content` have their markers stripped. Numbered deletion rows (`-1 foo`) and unified-diff lines are written literally — never silently altered.
101
- - A reversed range (start hash after end hash) is swapped and applied.
102
- - A duplicated boundary line — the classic `}`, `});`, or `} else {` pasted twice — is silently removed; the duplicate never reaches the file.
103
- - `file_path` is accepted as an alias for `path`.
104
- - **Response.** With auto-read enabled (the default), a successful edit returns the post-edit diff — the same `+HASH│` / `- │` / ` HASH│` rows the user sees — instead of the summary. With auto-read disabled, the edit reports `Successfully replaced in {path}. Added X line(s), removed Y line(s).` plus any warnings, and no diff is shown to the model. Warnings are appended in both modes. An edit that produces identical content reports `No changes made` and never rotates anchors. The post-edit diff is exposed to the host UI via `details.diff` — the TUI always shows it — and reaches the model-visible text only while auto-read is on.
105
- - **Undo.** Every successful replace is undoable once via `undo_last_replace` — see [Undo](#undo).
93
+ - The request is checked before any file I/O, so a bad request never touches the file.
94
+ - Common copy-paste slips are fixed automatically and reported: a leftover `HASH│` prefix in `new_content` or `hash_bounds`, diff-preview rows pasted into the replacement, a reversed range, or a boundary line pasted twice. `file_path` works as an alias for `path` in all three tools.
95
+ - An edit that produces identical content reports `No changes made` and leaves the anchors alone.
96
+ - After a successful edit you get the post-edit diff with fresh anchors, so you can keep editing without re-reading.
106
97
 
107
- ## Anchor stability
108
-
109
- Hashes are stored in a persistent per-file store (`~/.config/pi-hashline-edit-pro/hash-store.sqlite`) that preserves the hashes of unchanged lines across edits. When a range is replaced, the runtime maps the old content onto the new content and copies hashes for lines that survived; only genuinely new lines get fresh hashes.
110
-
111
- Two guarantees make this safe even with duplicated content:
98
+ ## Undo
112
99
 
113
- - **An edited range never borrows a hash from a line outside it.** Lines outside the replaced range keep their hashes unconditionally, even when their content is byte-identical to lines inside the range.
114
- - **Re-inserted identical text keeps its hash.** If replacement content matches a line that was just removed, the removed line's hash is reused — "replace X with X" doesn't rotate the anchor.
100
+ `undo_last_replace` reverts the most recent successful `replace` on a file, restoring the exact previous content, BOM and line endings included, plus the previous anchors.
115
101
 
116
- 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`.
102
+ - History is per-file and single-level: only the most recent replace can be reverted.
103
+ - History is persisted and survives session restarts. A failed `write` does not clear it.
104
+ - Every applied replace is undoable: the undo record is saved before the edit is written.
105
+ - A successful `write` clears the history for that file.
106
+ - If the file was modified or deleted since the last replace, the undo is refused rather than overwriting those changes.
117
107
 
118
108
  ## Auto-read
119
109
 
120
- Enabled by default. After a successful `write` that changes the file, the extension reads the file and appends an `--- Auto-read (hashline anchors) ---` block to the result, so the model gets immediate `HASH│content` anchors without a separate `read` call.
110
+ Enabled by default. After a successful `write` that changes the file, the extension reads the file and appends an `--- Auto-read (hashline anchors) ---` block to the result, so you get fresh `HASH│content` anchors without a separate `read` call.
121
111
 
122
- - A no-op `replace` produces no diff the file is unchanged, so existing anchors remain valid.
123
- - After `replace` / `undo_last_replace`, the success summary is replaced by the post-edit diff (the same `+HASH│` / `- │` / ` HASH│` rows used for replace) plus any warnings, so the model sees the change like a git diff instead of line counts; no anchor block is appended — the diff rows themselves are the fresh anchors (`+HASH│` and ` HASH│` rows carry the current hashes, and unchanged lines keep their previous hashes), so follow-up edits can anchor on the diff directly; call `read` when you want the full file's anchors.
124
- - With auto-read disabled, `replace` / `undo_last_replace` results keep the plain summary in the model-visible text no diff and no anchor block reach the model (the post-edit diff is still shown to the user).
125
- - After `write`, the block dumps from the top of the file. For files over 2000 lines, the dump is truncated with a pagination hint — use `read` with `offset` to continue.
126
- - Auto-read keeps a 50KB display budget: lines over 50KB are skipped with a marker instead of their content (use `read` for lines up to 200KB).
112
+ - After `replace` and `undo_last_replace`, the result shows the post-edit diff. The `+HASH│` and ` HASH│` rows carry the current hashes, so follow-up edits can anchor on the diff directly. Call `read` when you want the full file's anchors.
113
+ - After `write`, the block dumps from the top of the file. For files over 2000 lines, the dump is truncated with a pagination hint; use `read` with `offset` to continue.
114
+ - Auto-read keeps a 50KB display budget. Lines over 50KB are skipped with a marker instead of their content (use `read` for lines up to 200KB).
127
115
  - Toggle at runtime with `/toggle-auto-read`; the setting persists across sessions.
128
- - If the auto-read itself fails (e.g. the file was deleted between the write and the read), a short `--- Auto-read failed: ... ---` notice is appended instead of the anchor block, so the model knows the anchors are missing.
129
116
 
130
- ## Undo
131
-
132
- `undo_last_replace` reverts the most recent successful `replace` on a file, restoring the exact previous content — BOM and line endings included — and the previous anchors.
133
-
134
- - History is per-file and single-level: only the most recent replace can be reverted.
135
- - History is persisted in the hash store (`~/.config/pi-hashline-edit-pro/hash-store.sqlite`) and survives session restarts; a failed `write` does not clear it.
136
- - **Undo is a precondition, not a convenience.** The undo record is persisted *before* the edit is written; if it cannot be persisted, the `replace` is refused with `[E_UNDO_UNAVAILABLE]` and the file is not touched, so every applied edit is undoable. If the file write itself then fails, the previous undo record is restored, so a refused edit never destroys earlier undo history.
137
- - A successful `write` clears the history for that file.
138
- - With auto-read enabled, the model sees the post-edit diff after an undo, just like a replace; with auto-read disabled it sees the plain summary. No anchors are appended after an undo — call `read` to get fresh anchors for follow-up edits.
139
- - **Safety guard.** If the file was modified or deleted since the last replace, `undo_last_replace` refuses with `[E_UNDO_STALE]` rather than overwriting those changes.
140
-
141
- ## Commands and configuration
117
+ ## Settings
142
118
 
143
119
  | Command | Description |
144
120
  | --- | --- |
145
121
  | `/toggle-auto-read` | Toggle automatic hashline anchors after write and post-edit diffs after replace and undo_last_replace operations. Persists across sessions. |
146
122
 
147
- Settings live in `~/.config/pi-hashline-edit-pro/config.json`, created automatically when a setting is toggled:
123
+ Settings live in `~/.config/pi-hashline-edit-pro/config.json`, created automatically when a setting is toggled. On non-Windows platforms, the config directory honors `XDG_CONFIG_HOME` when set (falling back to `~/.config`); on Windows it always uses `~/.config`:
148
124
 
149
125
  ```json
150
126
  {
@@ -152,17 +128,34 @@ Settings live in `~/.config/pi-hashline-edit-pro/config.json`, created automatic
152
128
  }
153
129
  ```
154
130
 
131
+ ## How anchors work
132
+
133
+ Each line is canonicalized (carriage returns stripped, trailing whitespace trimmed) and hashed with [xxhash-wasm](https://github.com/jungomi/xxhash-wasm) (xxHash32), then mapped to a 3-character string over `A-Za-z0-9`, which gives 62³ = 238,328 possible anchors. The canonicalization keeps anchors stable across editor-save cycles that add or remove trailing whitespace.
134
+
135
+ The alphabet is sized for an LLM consumer: the model tokenizes rather than squinting at glyphs, so case and digits are all included. The URL-safe specials `-` and `_` are deliberately excluded. A hash starting with `-` is shape-identical to a diff-preview deletion row, and `-`/`_` at a line start are markdown-active, inviting mis-copying and false autocorrections.
136
+
137
+ Unique anchors by construction. If a line's base hash collides with an already-assigned hash, the next free hash is allocated from a bitset by probing with a stride coprime to the hash space (O(1) amortized). The stride is `62² + 62 + 1`, so consecutive collisions, runs of blank lines, repeated `}`, land on anchors that differ in all three characters instead of sharing a prefix. Every line in a file therefore gets a unique anchor; two byte-identical lines (repeated `}`, repeated `import` statements) never share one. The same guarantee sets the file size cap: at most 238,328 lines per file, beyond which `read` and `replace` reject with `[E_FILE_TOO_LARGE]` (use `write` for very large files).
138
+
139
+ Hashes live in a persistent per-file store (`~/.config/pi-hashline-edit-pro/hash-store.sqlite`) that keeps the hashes of unchanged lines across edits. When a range is replaced, the runtime maps the old content onto the new content and copies hashes for lines that survived; only genuinely new lines get fresh hashes.
140
+
141
+ Two guarantees make this safe even with duplicated content:
142
+
143
+ - An edited range never borrows a hash from a line outside it. Lines outside the replaced range keep their hashes unconditionally, even when their content is byte-identical to lines inside the range.
144
+ - Re-inserted identical text keeps its hash. If replacement content matches a line that was just removed, the removed line's hash is reused. "Replace X with X" doesn't rotate the anchor.
145
+
146
+ 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`.
147
+
155
148
  ## Error codes
156
149
 
157
150
  | Code | Meaning |
158
151
  | --- | --- |
159
- | `[E_BAD_SHAPE]` | Request envelope or edit item has unknown, missing, or wrongly-typed fields (e.g. `new_content` must be a string with `\n` line separators). |
152
+ | `[E_BAD_SHAPE]` | Request envelope or edit item has unknown, missing, or wrongly-typed fields (for example `new_content` must be a string with `\n` line separators). |
160
153
  | `[E_BAD_REF]` | An anchor in `hash_bounds` is not a bare 3-char hash. |
161
154
  | `[E_STALE_ANCHOR]` | An anchor does not match any line in the current file; call `read` for fresh anchors. |
162
155
  | `[E_AMBIGUOUS_ANCHOR]` | An anchor matches multiple lines; call `read` for fresh anchors. |
163
- | `[E_INVALID_PATCH]` | A `new_content` line is a diff-preview row (`+HASH│`, `-HASH│`, `- │`) the marker is stripped automatically with a warning. |
164
- | `[E_BARE_HASH_PREFIX]` | A `new_content` line starts with a hash-like `HASH│` prefix the prefix is stripped automatically with a warning. |
165
- | `[E_BAD_OP]` | Range start line is after range end line the pair is swapped automatically with a warning. |
156
+ | `[E_INVALID_PATCH]` | A `new_content` line is a diff-preview row (`+HASH│`, `-HASH│`, `- │`). The marker is stripped automatically with a warning. |
157
+ | `[E_BARE_HASH_PREFIX]` | A `new_content` line starts with a hash-like `HASH│` prefix. The prefix is stripped automatically with a warning. |
158
+ | `[E_BAD_OP]` | Range start line is after range end line. The pair is swapped automatically with a warning. |
166
159
  | `[E_WOULD_EMPTY]` | An edit would empty a non-empty file; use `write` instead. |
167
160
  | `[E_NOT_FOUND]` | The path does not exist. |
168
161
  | `[E_ACCESS]` | The file is not readable or writable. |
@@ -171,30 +164,12 @@ Settings live in `~/.config/pi-hashline-edit-pro/config.json`, created automatic
171
164
  | `[E_UNDO_UNAVAILABLE]` | Undo history could not be persisted to the hash store; the `replace` was refused and the file was left unchanged. |
172
165
  | `[E_FILE_TOO_LARGE]` | The file exceeds the 238,328-line hashline limit. |
173
166
 
174
- ## Hashing
175
-
176
- Each line is canonicalized (carriage returns stripped, trailing whitespace trimmed) and hashed with [xxhash-wasm](https://github.com/jungomi/xxhash-wasm) (xxHash32), then mapped to a 3-character string over `A-Za-z0-9` — 62³ = 238,328 possible anchors. The canonicalization keeps anchors stable across editor-save cycles that add or remove trailing whitespace.
177
-
178
- The alphabet is sized for an LLM consumer: the model tokenizes rather than squinting at glyphs, so case and digits are all included. The URL-safe specials `-` and `_` are deliberately excluded — a hash starting with `-` is shape-identical to a diff-preview deletion row, and `-`/`_` at a line start are markdown-active, inviting mis-copying and false autocorrections.
179
-
180
- **Unique anchors by construction.** If a line's base hash collides with an already-assigned hash, the next free hash is allocated from a bitset by probing with a stride coprime to the hash space (O(1) amortized). The stride is `62² + 62 + 1`, so consecutive collisions — runs of blank lines, repeated `}` — land on anchors that differ in all three characters instead of sharing a prefix. Every line in a file therefore gets a unique anchor — two byte-identical lines (repeated `}`, repeated `import` statements) never share one. The same guarantee sets the file size cap: at most 238,328 lines per file, beyond which `read` and `replace` reject with `[E_FILE_TOO_LARGE]` (use `write` for very large files).
181
-
182
- ## Design decisions
183
-
184
- - **Stale anchors fail, per line.** A hash mismatch means that line's content changed since the last `read`. The error says so and, when only one anchor of a pair is stale, shows the current lines around the still-valid anchor so the range can be re-located without a full re-read. Mismatched anchors are never silently relocated to a "close enough" line — correctness over convenience.
185
- - **Autocorrection only when the intent is unambiguous**, and always visible: hash-prefix and diff-row stripping produce a warning; the boundary-duplication fix is silent because the duplicate never reaches the file. Literal content is never silently altered when the intent is ambiguous (numbered deletion rows and unified-diff lines are written verbatim).
186
- - **Byte-exact preservation.** UTF-8 BOMs, CRLF, LF, and CR-only line endings, file permissions, and trailing newlines survive edits and undo; files with mixed line endings are normalized to a single line ending on edit.
187
- - **Atomic and ordered writes.** Files are written via temp-file-then-rename; symlink chains are resolved so the target is updated without replacing the symlink; hard-linked files are updated in place; concurrent edits to the same underlying file serialize through a per-target mutation queue.
188
- - **One edit per call.** The request shape stays `{path, hash_bounds, new_content}` from schema through validation to application; there is no batching dialect.
189
-
190
167
  ## Troubleshooting
191
168
 
192
- - **Stale anchors.** `[E_STALE_ANCHOR]` / `[E_AMBIGUOUS_ANCHOR]` mean the file changed since the anchors were read, or an earlier `read` never happened. Call `read` for fresh anchors and retry.
193
- - **Reset the hash store.** Anchors live in `~/.config/pi-hashline-edit-pro/hash-store.sqlite` (with `-wal`/`-shm` sidecars). Quit pi, delete those three files, and the store is rebuilt on the next session. Anchor history is lost, but no project files are touched.
194
- - **Upgrading.** A hash-allocation change clears the hash store once on the first run after upgrade anchors are rebuilt on the next read and undo history is lost, but no project files are touched.
195
- - **Corrupt store.** If the store fails its health check it is renamed to `hash-store.sqlite.corrupt-<timestamp>` (plus `-wal`/`-shm` variants) and rebuilt automatically; the quarantined files can be deleted once a healthy store exists.
196
- - **Legacy migration.** On first run after upgrading from an older version, the previous `hash-store.json` is imported once and renamed to `hash-store.json.bak`, which can be deleted. Legacy snapshots containing duplicate hashes are skipped and rebuilt on the next read.
197
- - **`[E_UNDO_UNAVAILABLE]`.** The edit was refused because the undo record could not be written — check disk space and that the config directory is writable, then retry.
169
+ - Stale anchors. `[E_STALE_ANCHOR]` or `[E_AMBIGUOUS_ANCHOR]` mean the file changed since the anchors were read. Call `read` for fresh anchors and retry.
170
+ - Reset the hash store. Anchors live in `~/.config/pi-hashline-edit-pro/hash-store.sqlite` (with `-wal`/`-shm` sidecars). Quit pi, delete those three files, and the store is rebuilt on the next session. Anchor history is lost, but no project files are touched.
171
+ - Corrupt store. If the store fails its health check it is renamed to `hash-store.sqlite.corrupt-<timestamp>` and rebuilt automatically.
172
+ - Config directory moved. On non-Windows platforms, if `XDG_CONFIG_HOME` is set, the config directory (and the hash store 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.
198
173
 
199
174
  ## Development
200
175
 
@@ -211,8 +186,8 @@ Set `PI_HASHLINE_DEBUG=1` to show an "active" notification at session start.
211
186
 
212
187
  ## Credits
213
188
 
214
- - [RimuruW](https://github.com/RimuruW) original `pi-hashline-edit` and the strict-semantics policy
215
- - [can1357](https://github.com/can1357) original [oh-my-pi](https://github.com/can1357/oh-my-pi) implementation and the hashline concept
189
+ - [RimuruW](https://github.com/RimuruW), original `pi-hashline-edit` and the strict-semantics policy
190
+ - [can1357](https://github.com/can1357), original [oh-my-pi](https://github.com/can1357/oh-my-pi) implementation and the hashline concept
216
191
 
217
192
  ## License
218
193
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-hashline-edit-pro",
3
- "version": "2.0.1",
3
+ "version": "2.1.1",
4
4
  "type": "module",
5
5
  "description": "Strict hashline read/replace tool for pi-coding-agent with hash-anchored edits (3-char, 62-symbol, perfect hashing)",
6
6
  "main": "index.ts",
@@ -49,7 +49,8 @@
49
49
  "test:watch": "vitest",
50
50
  "test:coverage": "vitest run --coverage --coverage.thresholds.lines=90 --coverage.thresholds.statements=90 --coverage.thresholds.functions=85 --coverage.thresholds.branches=80",
51
51
  "lint": "eslint \"src/**/*.ts\" \"index.ts\" \"test/**/*.ts\"",
52
- "typecheck": "tsc --noEmit"
52
+ "typecheck": "tsc --noEmit",
53
+ "prepublishOnly": "npm run typecheck && npm run lint && npm test"
53
54
  },
54
55
  "devDependencies": {
55
56
  "@earendil-works/pi-coding-agent": "^0.84.0",
@@ -51,6 +51,7 @@ export interface ReadNormOptions {
51
51
  preloadedFile?: LFile;
52
52
  maxLines?: number;
53
53
  store?: HashStore;
54
+ noPersist?: boolean;
54
55
  }
55
56
 
56
57
  export async function readNormFile(
@@ -83,7 +84,7 @@ export async function readNormFile(
83
84
  }
84
85
  }
85
86
 
86
- const fileHashes = await lineHashes(normalized, resolvedPath, undefined, options?.store);
87
+ const fileHashes = await lineHashes(normalized, resolvedPath, undefined, options?.store, options?.noPersist !== true);
87
88
  return {
88
89
  absolutePath: resolvedPath,
89
90
  normalized,
package/src/hash-store.ts CHANGED
@@ -4,6 +4,7 @@ import { DatabaseSync } from "node:sqlite";
4
4
  import { hashStorePath, hashStoreDir, legacyHashStorePath } from "./paths";
5
5
  import { errCode, splitLines } from "./utils";
6
6
  import { initHasher, contentChecksum } from "./hashline/hasher";
7
+ import { HASH_RE } from "./hashline/alphabet";
7
8
  import { HASH_STORE_VERSION, HASH_STORE_BUSY_TIMEOUT } from "./constants";
8
9
  type SqlParams = (string | number)[];
9
10
 
@@ -35,15 +36,19 @@ interface LegacySnapshot {
35
36
  hashes: string[];
36
37
  }
37
38
 
39
+ function isValidHashList(value: unknown): value is string[] {
40
+ if (!Array.isArray(value)) return false;
41
+ for (const hash of value) {
42
+ if (typeof hash !== "string" || !HASH_RE.test(hash)) return false;
43
+ }
44
+ return true;
45
+ }
46
+
38
47
  function isValidSnapshot(value: unknown): value is LegacySnapshot {
39
48
  if (typeof value !== "object" || value === null) return false;
40
49
  const v = value as Record<string, unknown>;
41
50
  if (typeof v.content !== "string") return false;
42
- if (!Array.isArray(v.hashes)) return false;
43
- for (const h of v.hashes) {
44
- if (typeof h !== "string") return false;
45
- }
46
- return true;
51
+ return isValidHashList(v.hashes);
47
52
  }
48
53
 
49
54
  export function isCorruptionError(error: unknown): boolean {
@@ -355,6 +360,7 @@ export function getSnapshot(
355
360
  store: HashStore,
356
361
  path: string,
357
362
  content: string,
363
+ deleteCorrupt = true,
358
364
  ): string[] | undefined {
359
365
  const checksum = contentChecksum(content);
360
366
  const lineCount = splitLines(content).length;
@@ -362,10 +368,11 @@ export function getSnapshot(
362
368
  if (!row) return undefined;
363
369
  try {
364
370
  const parsed = JSON.parse(row.hashes as string);
365
- return Array.isArray(parsed) && parsed.every((h) => typeof h === "string")
366
- ? (parsed as string[])
367
- : undefined;
371
+ if (isValidHashList(parsed)) return parsed;
372
+ if (deleteCorrupt) store.stmts.deleteOne(path);
373
+ return undefined;
368
374
  } catch {
375
+ if (deleteCorrupt) store.stmts.deleteOne(path);
369
376
  return undefined;
370
377
  }
371
378
  }
@@ -397,7 +404,7 @@ export function getUndoEntry(store: HashStore, path: string): UndoRecord | undef
397
404
  if (!row) return undefined;
398
405
  try {
399
406
  const parsed = JSON.parse(row.hashes as string);
400
- if (!Array.isArray(parsed) || !parsed.every((h) => typeof h === "string")) {
407
+ if (!isValidHashList(parsed)) {
401
408
  store.stmts.undoDelete(path);
402
409
  return undefined;
403
410
  }
@@ -0,0 +1,12 @@
1
+ export const HASH_LEN = 3;
2
+
3
+ export const ALPH =
4
+ "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
5
+
6
+ const ALPH_SAFE = ALPH.replace(/-/g, "\\-");
7
+
8
+ export const ALPH_RE = new RegExp(`^[${ALPH_SAFE}]+$`);
9
+
10
+ export const HASH_CLASS = `[${ALPH_SAFE}]{${HASH_LEN}}`;
11
+
12
+ export const HASH_RE = new RegExp(`^${HASH_CLASS}$`);
@@ -6,19 +6,13 @@ import {
6
6
  upsertSnapshot,
7
7
  } from "../hash-store";
8
8
  import { xxh32, contentChecksum, initHasher } from "./hasher";
9
- export { initHasher };
9
+ import { HASH_LEN, ALPH, ALPH_RE, HASH_CLASS } from "./alphabet";
10
+ export { initHasher, HASH_LEN, ALPH_RE, HASH_CLASS };
10
11
 
11
- export const HASH_LEN = 3;
12
12
  export const ANCHOR_LEN = HASH_LEN;
13
13
 
14
14
  export const HASH_SEP = "│";
15
15
 
16
- const ALPH =
17
- "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
18
- const ALPH_SAFE = ALPH.replace(/-/g, "\\-");
19
- const ALPH_RE = new RegExp(`^[${ALPH_SAFE}]+$`);
20
- export const HASH_CLASS = `[${ALPH_SAFE}]{${HASH_LEN}}`;
21
-
22
16
  export const HASH_SPACE = ALPH.length ** HASH_LEN;
23
17
  export const MAX_HASH_LINES = HASH_SPACE;
24
18
 
@@ -138,7 +132,7 @@ export async function lineHashes(
138
132
 
139
133
  let cached: string[] | undefined;
140
134
  try {
141
- cached = getSnapshot(hashStore, path, content);
135
+ cached = getSnapshot(hashStore, path, content, persist !== false);
142
136
  } catch (error) {
143
137
  console.error("Failed to read hash store snapshot:", error);
144
138
  }
@@ -290,5 +284,3 @@ function mapStableHashes(
290
284
 
291
285
  return newHashes;
292
286
  }
293
-
294
- export { ALPH_RE };
@@ -1,5 +1,5 @@
1
1
  import { abortIf, rejectUnknownFields, lastNonEmpty, firstNonEmpty, clipLine } from "../utils";
2
- import { HL_BARE_PREFIX_RE, HL_PREFIX_PLUS_RE, HL_PREFIX_MINUS_RE } from "./hash";
2
+ import { HASH_CLASS, HL_BARE_PREFIX_RE, HL_PREFIX_PLUS_RE, HL_PREFIX_MINUS_RE } from "./hash";
3
3
  import { parseHashRef, parseText, type Anchor } from "./parse";
4
4
  import { NEW_CONTENT_NOT_STRING_MSG } from "../constants";
5
5
 
@@ -161,13 +161,32 @@ function assertItem(edit: Record<string, unknown>): void {
161
161
  }
162
162
  }
163
163
 
164
- export function resEdit(edit: HTEdit): HEdit {
164
+ const ANCHOR_ROW_RE = new RegExp(`^([+-]?)(${HASH_CLASS})│`);
165
+
166
+ export function resEdit(edit: HTEdit, warnings?: string[]): HEdit {
165
167
  assertItem(edit as Record<string, unknown>);
166
168
 
167
169
  const replaceLines = parseText(edit.new_content);
170
+ const bounds = edit.hash_bounds.map((ref) => {
171
+ const trimmed = ref.trim();
172
+ const match = trimmed.match(ANCHOR_ROW_RE);
173
+ if (match) {
174
+ let message: string;
175
+ if (match[1] === "+") {
176
+ message = `[E_BAD_REF] Autocorrected: stripped diff-preview marker copied from the diff preview in hash_bounds entry "${trimmed}".`;
177
+ } else if (match[1] === "-") {
178
+ message = `[E_BAD_REF] Autocorrected: stripped leading "-" marker in hash_bounds entry "${trimmed}".`;
179
+ } else {
180
+ message = `[E_BAD_REF] Autocorrected: stripped "HASH│" prefix copied from read output in hash_bounds entry "${trimmed}".`;
181
+ }
182
+ warnings?.push(message);
183
+ return match[2]!;
184
+ }
185
+ return ref;
186
+ }) as [string, string];
168
187
  return {
169
188
  content_lines: replaceLines,
170
- hash_bounds: [parseHashRef(edit.hash_bounds[0]), parseHashRef(edit.hash_bounds[1])],
189
+ hash_bounds: [parseHashRef(bounds[0]), parseHashRef(bounds[1])],
171
190
  };
172
191
  }
173
192
 
package/src/paths.ts CHANGED
@@ -7,8 +7,16 @@ function homeBase(): string {
7
7
  return envHome && envHome.length > 0 ? envHome : homedir();
8
8
  }
9
9
 
10
+ function configBase(): string {
11
+ if (process.platform !== "win32") {
12
+ const xdg = process.env.XDG_CONFIG_HOME;
13
+ if (xdg && xdg.length > 0) return xdg;
14
+ }
15
+ return join(homeBase(), ".config");
16
+ }
17
+
10
18
  export function configDir(): string {
11
- return join(homeBase(), ".config", "pi-hashline-edit-pro");
19
+ return join(configBase(), "pi-hashline-edit-pro");
12
20
  }
13
21
 
14
22
  export function configPath(): string {
package/src/read.ts CHANGED
@@ -12,7 +12,7 @@ import { loadFileKindAndText } from "./file-kind";
12
12
  import { readNormFile } from "./file-reader";
13
13
  import { lineHashes, fmtRegion, HASH_SEP, MAX_HASH_LINES } from "./hashline";
14
14
  import { toCwd } from "./paths";
15
- import { abortIf } from "./utils";
15
+ import { abortIf, isRec, normalizeFilePath } from "./utils";
16
16
  import { fileSnap } from "./file-reader";
17
17
  import { visLines } from "./utils";
18
18
  import { loadP, loadGuide } from "./prompts";
@@ -154,6 +154,12 @@ export function regRead(pi: ExtensionAPI): void {
154
154
  description: R_DESC,
155
155
  promptSnippet: R_SNIPPET,
156
156
  promptGuidelines: readGuide(),
157
+ prepareArguments: (args: unknown) => {
158
+ if (!isRec(args)) return args as any;
159
+ const record = { ...args };
160
+ normalizeFilePath(record);
161
+ return record;
162
+ },
157
163
  parameters: Type.Object({
158
164
  path: Type.String({
159
165
  description: "Path to the file to read (relative or absolute)",
@@ -204,8 +210,12 @@ export function regRead(pi: ExtensionAPI): void {
204
210
  fileHashes,
205
211
  absolutePath,
206
212
  );
207
- const snapshot = await fileSnap(absolutePath);
208
-
213
+ let snapshotId: string | undefined;
214
+ try {
215
+ snapshotId = (await fileSnap(absolutePath)).snapshotId;
216
+ } catch (error) {
217
+ console.error("Failed to compute snapshot for read:", error);
218
+ }
209
219
  const previewText =
210
220
  hadUtf8DecodeErrors
211
221
  ? `${preview.text}\n\n[Non-UTF-8 bytes shown as U+FFFD; editing rewrites the file as UTF-8.]`
@@ -215,7 +225,7 @@ export function regRead(pi: ExtensionAPI): void {
215
225
  content: [{ type: "text", text: previewText }],
216
226
  details: {
217
227
  truncation: preview.truncation,
218
- snapshotId: snapshot.snapshotId,
228
+ snapshotId,
219
229
  ...(preview.nextOffset !== undefined
220
230
  ? { nextOffset: preview.nextOffset }
221
231
  : {}),
@@ -1,11 +1,4 @@
1
- import { isRec } from "./utils";
2
-
3
- export function normalizeFilePath(record: Record<string, unknown>): void {
4
- if (typeof record.path !== "string" && typeof record.file_path === "string") {
5
- record.path = record.file_path;
6
- delete record.file_path;
7
- }
8
- }
1
+ import { isRec, normalizeFilePath } from "./utils";
9
2
 
10
3
  export function normReq(input: unknown): unknown {
11
4
  if (!isRec(input)) {
@@ -163,62 +163,8 @@ function trimEmpty(lines: string[]): string[] {
163
163
  return lines.slice(start, end);
164
164
  }
165
165
 
166
- function isSectionBoundary(line: string): boolean {
167
- return (
168
- line === "--- Anchors ---" ||
169
- line === "Warnings:" ||
170
- line === "Structure outline:" ||
171
- /^--- Range \d+ ---$/.test(line)
172
- );
173
- }
174
-
175
166
  export function fmtResultMd(text: string): string {
176
- const lines = text.split("\n");
177
- const sections: string[] = [];
178
- let plainLines: string[] = [];
179
-
180
- const flush = () => {
181
- const trimmed = trimEmpty(plainLines);
182
- if (trimmed.length > 0) {
183
- sections.push(trimmed.join("\n"));
184
- }
185
- plainLines = [];
186
- };
187
-
188
- let index = 0;
189
- while (index < lines.length) {
190
- const line = lines[index]!;
191
-
192
- if (line.startsWith("--- Anchors ")) {
193
- flush();
194
- const title = line.replace(/^---\s*/, "").replace(/\s*---$/, "");
195
- index++;
196
- const bodyLines: string[] = [];
197
- while (
198
- index < lines.length &&
199
- !isSectionBoundary(lines[index]!)
200
- ) {
201
- bodyLines.push(lines[index]!);
202
- index++;
203
- }
204
- sections.push(
205
- [
206
- `#### ${title}`,
207
- "```text",
208
- ...trimEmpty(bodyLines),
209
- "```",
210
- ].join("\n"),
211
- );
212
- continue;
213
- }
214
-
215
- plainLines.push(line);
216
- index++;
217
- }
218
-
219
- flush();
220
-
221
- return sections.join("\n\n");
167
+ return trimEmpty(text.split("\n")).join("\n");
222
168
  }
223
169
 
224
170
  export function mkMdTheme(theme: MdTheme) {
@@ -7,7 +7,7 @@ import { contentChecksum } from "./hashline/hasher";
7
7
  import { resolveTarget, writeAtomic } from "./fs-write";
8
8
  import { toCwd } from "./paths";
9
9
  import { toLF, stripBOM, genDiff, restoreEndings, type LineEnding } from "./replace-diff";
10
- import { cntDiff, splitLines, errCode } from "./utils";
10
+ import { cntDiff, splitLines, errCode, isRec, normalizeFilePath } from "./utils";
11
11
  import { loadP, loadGuide } from "./prompts";
12
12
  import { buildMetrics } from "./replace-response";
13
13
  import { changedRange } from "./hashline";
@@ -91,6 +91,12 @@ export function regReplaceUndo(pi: ExtensionAPI): void {
91
91
  description: loadP("../prompts/undo-last-replace.md"),
92
92
  promptSnippet: loadP("../prompts/undo-last-replace-snippet.md"),
93
93
  promptGuidelines: loadGuide("../prompts/undo-last-replace-guidelines.md"),
94
+ prepareArguments: (args: unknown) => {
95
+ if (!isRec(args)) return args as any;
96
+ const record = { ...args };
97
+ normalizeFilePath(record);
98
+ return record;
99
+ },
94
100
  parameters: Type.Object({
95
101
  path: Type.String({
96
102
  description: "Path to the file to undo",
package/src/replace.ts CHANGED
@@ -12,8 +12,8 @@ import {
12
12
  type LineEnding,
13
13
  } from "./replace-diff";
14
14
  import { readNormFile } from "./file-reader";
15
- import { normReq, normalizeFilePath } from "./replace-normalize";
16
- import { isRec, rejectUnknownFields, abortIf } from "./utils";
15
+ import { normReq } from "./replace-normalize";
16
+ import { isRec, rejectUnknownFields, abortIf, normalizeFilePath } from "./utils";
17
17
  import { resolveTarget, writeAtomic } from "./fs-write";
18
18
  import { applyEdit,
19
19
  lineHashes,
@@ -175,14 +175,18 @@ export async function execPipeline(
175
175
 
176
176
  const path = params.path;
177
177
 
178
- const edit = resEdit({
179
- hash_bounds: params.hash_bounds,
180
- new_content: params.new_content,
181
- });
178
+ const editWarnings: string[] = [];
179
+ const edit = resEdit(
180
+ {
181
+ hash_bounds: params.hash_bounds,
182
+ new_content: params.new_content,
183
+ },
184
+ editWarnings,
185
+ );
182
186
 
183
187
  const hashStore = options?.store ?? await loadHashStore();
184
188
  const { normalized: originalNormalized, bom, originalEnding, fileHashes: originalHashes, hadUtf8DecodeErrors, absolutePath } = await readNormFile(
185
- path, cwd, { signal: options?.signal, accessMode: options?.accessMode, maxLines: MAX_HASH_LINES, store: hashStore },
189
+ path, cwd, { signal: options?.signal, accessMode: options?.accessMode, maxLines: MAX_HASH_LINES, store: hashStore, noPersist: options?.noPersist },
186
190
  );
187
191
 
188
192
  const anchorResult = applyEdit(
@@ -207,8 +211,7 @@ export async function execPipeline(
207
211
  hashes: originalHashes,
208
212
  removedHashes,
209
213
  }, hashStore, noPersist !== true);
210
- const warnings = [...(anchorResult.warnings ?? [])];
211
-
214
+ const warnings = [...editWarnings, ...(anchorResult.warnings ?? [])];
212
215
  const { totalAddedLines, totalRemovedLines } = countLineChanges(
213
216
  edit, originalHashes, isNoop, anchorResult.autoFixes?.length ?? 0,
214
217
  );
package/src/utils.ts CHANGED
@@ -2,8 +2,11 @@ export function isRec(value: unknown): value is Record<string, unknown> {
2
2
  return typeof value === "object" && value !== null && !Array.isArray(value);
3
3
  }
4
4
 
5
- export function has(record: Record<string, unknown>, key: string): boolean {
6
- return Object.hasOwn(record, key);
5
+ export function normalizeFilePath(record: Record<string, unknown>): void {
6
+ if (typeof record.path !== "string" && typeof record.file_path === "string") {
7
+ record.path = record.file_path;
8
+ delete record.file_path;
9
+ }
7
10
  }
8
11
 
9
12
  export function splitLines(text: string): string[] {