pi-hashline-edit-pro 0.20.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,19 +1,17 @@
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 line-replacing workflow. Strict semantics, no silent relocation, no autocorrection, no fuzzy fallback. Every line gets a unique content hash, so edits stay precise and stale anchors are caught before they reach the file.
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.
4
4
 
5
- Fork of [pi-hashline-edit](https://github.com/RimuruW/pi-hashline-edit) by RimuruW. The strict-semantics policy is unchanged. This fork extends the upstream design with 3-character hashes and collision resolution for unique per-line anchors.
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).
6
6
 
7
- Every line returned by `read` carries a short content hash. Edits reference those hashes instead of raw text, so the tool can detect stale context and reject outdated changes before they reach the file.
7
+ ## Features
8
8
 
9
- ## Why fork?
10
-
11
- The original uses 2-character hashes of a 16-character alphabet, with the hash being a pure function of line content. That's 8 bits / 256 buckets, and two byte-identical lines (e.g. repeated `import` statements, repeated `}`) always share a hash because the hash is `xxHash32(content)`.
12
-
13
- This fork makes two changes that compound:
14
-
15
- 1. **3-character hash length** over a 62-char alphanumeric alphabet (up from 2 characters in the upstream), expanding the hash space from 256 to 238,328 buckets.
16
- 2. **Perfect hashing (collision resolution).** When computing hashes for a file, if a line's base hash collides with an already-assigned hash, the next available hash is assigned from a bitset (238,328 bits) using a hint cursor for O(1) amortized lookup. This ensures every line gets a unique anchor, even within a 3-character hash space. Two byte-identical lines (e.g. repeated `}` or repeated `import` statements) get different hashes automatically.
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`, `replace`, and `undo_last_replace`.
17
15
 
18
16
  ## Installation
19
17
 
@@ -29,133 +27,122 @@ From a local checkout:
29
27
  pi install /path/to/pi-hashline-edit-pro
30
28
  ```
31
29
 
32
- ## How It Works
30
+ ## Quick start
33
31
 
34
- ### `read` -- tagged line output
32
+ 1. Read a file. Every line comes back with a hash prefix (no line numbers — the hash is the address):
35
33
 
36
- Text files are returned with a `HASH│content` prefix on every line. The line number is not part of the wire format, only the 3-character hash followed by the `│` separator and the line content. Example output for the source below:
34
+ ```text
35
+ ve7│function hello() {
36
+ szJ│ console.log("world");
37
+ kQm│}
38
+ ```
39
+
40
+ 2. Replace a line by its hash:
37
41
 
38
- ```js
39
- function hello() {
40
- console.log("world");
42
+ ```json
43
+ {
44
+ "path": "src/main.ts",
45
+ "hash_range_inclusive": ["szJ", "szJ"],
46
+ "content_lines": [" console.log('hi');"]
41
47
  }
42
48
  ```
43
49
 
44
- would be returned as:
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 to each result.
45
51
 
46
- ```text
47
- 0qH│function hello() {
48
- szJ│ console.log("world");
49
- _zl│}
50
- ```
52
+ ## The `read` tool
51
53
 
52
- - `HASH` is a 3-character content hash from the alphanumeric alphabet `A-Za-z0-9` (e.g. `aB3`). See [Hashing](#hashing) for details.
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`).
53
55
 
54
56
  Optional parameters:
55
57
 
56
- - `offset` -- start reading from this line number (1-indexed).
57
- - `limit` -- maximum number of lines to return.
58
+ | Parameter | Description |
59
+ | --- | --- |
60
+ | `offset` | Start reading from this line number (1-indexed). |
61
+ | `limit` | Maximum number of lines to return. |
58
62
 
59
- Images (JPEG, PNG, GIF, WebP) are passed through as attachments and do not participate in the hashline protocol. Binary and directory paths are rejected with a descriptive error. Empty files are returned as a single empty-line hash (`HASH│`). Use replace on that hash to insert content.
63
+ Paged output ends with a continuation hint, e.g. `[Showing lines 1-50 of 120. Use offset=51 to continue.]`.
60
64
 
61
- ### `replace` -- hash-anchored modifications
65
+ Edge cases:
62
66
 
63
- Replaces using the `HASH│content` anchors from `read` output to target lines precisely. Two modes are available, toggled via `/toggle-replace-mode` (persists across sessions):
67
+ - **Images** (JPEG, PNG, GIF, WebP) are passed through as visual attachments and don't participate in the hashline protocol.
68
+ - **Binary and directory paths** are rejected with a descriptive error.
69
+ - **Empty files** are returned as a single empty-line hash (`HASH│`); use `replace` on that hash to insert content.
70
+ - **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).
71
+ - **Files over 238,328 lines** are rejected with `[E_FILE_TOO_LARGE]` (see [Hashing](#hashing)).
64
72
 
65
- **Bulk mode (default):** `hash_range_inclusive` and `content_lines` go inside a `changes` array, supporting multiple edits in one call.
73
+ ## The `replace` tool
66
74
 
67
- ```json
68
- {
69
- "path": "src/main.ts",
70
- "changes": [
71
- { "hash_range_inclusive": ["ve7", "ve7"], "content_lines": [" console.log('hashline');"] }
72
- ]
73
- }
74
- ```
75
-
76
- **Flat mode:** `hash_range_inclusive` and `content_lines` sit at the top level. Only one edit per call.
75
+ Exactly one edit per call, with `hash_range_inclusive` and `content_lines` at the top level of the request:
77
76
 
78
77
  ```json
79
78
  {
80
79
  "path": "src/main.ts",
81
- "hash_range_inclusive": ["ve7", "ve7"],
82
- "content_lines": [" console.log('hashline');"]
80
+ "hash_range_inclusive": ["szJ", "kQm"],
81
+ "content_lines": [" console.log('hi');", "}"]
83
82
  }
84
83
  ```
85
84
 
86
85
  | Field | Description |
87
86
  | --- | --- |
88
- | `hash_range_inclusive` | Inclusive line range `[start_hash, end_hash]` (required). |
89
- | `content_lines` | Literal replacement content, one string per line (use `[]` to delete the range). |
90
-
91
- - **Request structure validation.** The request envelope (`path`, `changes` in bulk mode; `path`, `hash_range_inclusive`, `content_lines` in flat mode) and individual edit items are validated before any file I/O. Unknown fields, missing required fields, invalid types, and malformed anchors are rejected with `[E_BAD_SHAPE]` or `[E_BAD_REF]`.
92
- - **Legacy dialect rejected.** The native top-level `oldText`/`newText` (and `old_text`/`new_text`) dialect is rejected with `[E_LEGACY_SHAPE]`. The error message tells the model to call `read` first and send `{hash_range_inclusive: ["<START>", "<END>"], content_lines: [...]}`.
93
- - **Batched atomicity (bulk mode).** All edits in a single call validate against the same pre-edit snapshot and apply bottom-up, so the hashes from a single `read` call remain valid across all edits in the batch.
87
+ | `hash_range_inclusive` | Pair of 3-char hashes from `read` output marking the first and last line of the range to replace (inclusive). |
88
+ | `content_lines` | Replacement content, one string per line. Use `[]` to delete the range. |
94
89
 
95
- ### Stable hashing across edits
90
+ Behavior:
96
91
 
97
- Hashes are now computed with a persistent store (`~/.config/pi-hashline-edit-pro/hash-store.sqlite`) that preserves hashes for unchanged lines across edits. When you replace lines in a file, the runtime maps the old content against the new content and copies hashes for unchanged lines to their new positions. This means editing one part of a file does not change the hashes of unrelated lines elsewhere — the model can keep using previously seen anchors for untouched regions. A replace that produces identical content (a no-op, reported as "No changes made") never rotates hashes: no file change means no anchor change, so previously read anchors remain valid after a no-op.
92
+ - **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.
93
+ - **Rejected dialects.** The `changes` array dialect and the legacy `oldText`/`newText` dialect are rejected with `[E_BAD_SHAPE]` / `[E_LEGACY_SHAPE]`; the error tells you to send `{hash_range_inclusive: ["<START>", "<END>"], content_lines: [...]}`.
94
+ - **Autocorrections** (all accompanied by a warning unless noted):
95
+ - A `HASH│` prefix accidentally left on a `content_lines` entry is stripped.
96
+ - Diff-preview rows (`+HASH│…`, `-HASH│…`, `- │…`) pasted into `content_lines` have their markers stripped. Numbered deletion rows (`-1 foo`) and unified-diff lines are written literally — never silently altered.
97
+ - A reversed range (start hash after end hash) is swapped and applied.
98
+ - A duplicated boundary line — the classic `}`, `});`, or `} else {` pasted twice — is silently removed; the duplicate never reaches the file.
99
+ - `file_path` is accepted as an alias for `path`; a JSON-string `content_lines` is parsed into an array.
100
+ - **Response.** A successful edit reports `Successfully replaced in {path}. Added X line(s), removed Y line(s).` plus any warnings. 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` only — it is intentionally not part of the model-visible text.
101
+ - **Undo.** Every successful replace is undoable once via `undo_last_replace` — see [Undo](#undo).
98
102
 
99
- Two guarantees make the mapping safe for duplicated content:
103
+ ## Anchor stability
100
104
 
101
- - **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. Previously, identical text in a replacement could "steal" the nearest sibling line's hash, silently relocating an anchor the model was still holding.
102
- - **Re-inserted identical text keeps its hash.** When replacement content matches a line that was just removed, the removed line's hash is reused for it (same canonical content, same meaning). Previously this was a coin flip: the hash was retired and a fresh one assigned, so "replace X with X" rotated the anchor even though nothing changed.
105
+ 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.
103
106
 
104
- The store is a SQLite database (WAL journal mode) keyed by canonical file path. Each snapshot stores a 64-bit content checksum (`xxhash64`) plus the per-line hashes, not the full text, so a cache hit is a single keyed lookup and a one-row write. Reads, replaces, undo, and pruning all share one transactional store, so concurrent Pi sessions editing different files never silently clobber each other's snapshots (per-path writers serialize via `BEGIN IMMEDIATE`; same-path concurrent edits still fail safe stale anchors are rejected by content matching). Stale snapshots (for files that no longer exist) are pruned on session start.
107
+ Two guarantees make this safe even with duplicated content:
105
108
 
106
- On first run after upgrading, a one-time migration imports the previous `hash-store.json` into the database and renames the old file to `hash-store.json.bak`; the old JSON store is otherwise discarded.
109
+ - **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.
110
+ - **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.
107
111
 
108
- ### Chained edits
112
+ 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`.
109
113
 
110
- After a successful replace, the response confirms with `Successfully replaced in {path}. Added X line(s), removed Y line(s).` (warnings are still shown if present). When auto-read is enabled, fresh anchors are appended automatically. Otherwise call `read` to get fresh anchors for follow-up edits.
111
- ### Auto-read after write, replace, and undo
114
+ ## Auto-read
112
115
 
113
- Auto-read is **disabled by default**. When enabled, after a successful `write`, `replace`, or `undo_last_replace` the extension automatically reads the file and appends a `--- Auto-read (hashline anchors) ---` block to the result. This gives the model immediate `HASH│content` anchors for the file without requiring a separate `read` call. The workflow becomes:
116
+ Enabled by default. After a successful `write`, `replace`, or `undo_last_replace`, 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.
114
117
 
115
- 1. `write` a file, result includes hashline anchors
116
- 2. `replace` using those anchors directly
118
+ - After `replace` / `undo_last_replace`, the block covers the changed span plus 2 lines of context above and below — the rest of the file keeps its anchors from the persistent store.
119
+ - 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.
120
+ - Toggle at runtime with `/toggle-auto-read`; the setting persists across sessions.
117
121
 
118
- Toggle at runtime with the `/toggle-auto-read` command. The setting persists across sessions in the config file (`~/.config/pi-hashline-edit-pro/config.json`). Set `PI_HASHLINE_AUTO_READ=1` to enable by default on first run.
122
+ ## Undo
119
123
 
120
- After a `replace` or `undo_last_replace`, the block is limited to the changed span plus 2 lines of context above and below it. Because the persistent hash store keeps anchors for unchanged lines stable across edits, the model's previously read anchors for the rest of the file remain valid only the edited region needs fresh anchors. `write` dumps from the top of the file, since the model has no prior anchors for that state.
124
+ `undo_last_replace` reverts the most recent successful `replace` on a file, restoring the exact previous contentBOM and line endings included and the previous anchors.
121
125
 
122
- For `write` on files over 2000 lines, the dump from the top is truncated with a pagination hint — use `read` with `offset` to see more. `replace` and `undo_last_replace` windows are small by construction (the changed span plus context), so they are not truncated except when the changed span itself exceeds 2000 lines.
123
-
124
- ### Undo
125
-
126
- `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 hash anchors. It is meant for immediate recovery from a bad edit:
127
-
128
- - Undo history is per-file and single-level: each successful `replace` replaces the previous undo entry, so only the most recent edit can be reverted.
129
- - Undo history is in-memory and is lost when the session ends or the extension reloads.
130
- - A successful `write` clears the undo history for that file — the write becomes the new source of truth, and reverting past it would be ambiguous.
131
- - After an undo, the hash-store snapshot is restored to match the reverted content, so anchors read from the previous state are valid again.
126
+ - History is per-file and single-level: only the most recent replace can be reverted.
127
+ - History is in-memory and is lost when the session ends or the extension reloads.
128
+ - A successful `write` clears the history for that file.
132
129
  - Call `read` after an undo to get fresh anchors for follow-up edits.
133
130
 
134
- ### Diff for the host
135
-
136
- The post-edit diff (with `+`/`-` markers) is exposed to the host UI via `details.diff`. It is intentionally not in the LLM-visible text. The model already knows what it changed and can call `read` for fresh anchors when needed.
137
-
138
- ### Commands
131
+ ## Commands and configuration
139
132
 
140
133
  | Command | Description |
141
134
  | --- | --- |
142
- | `/toggle-replace-mode` | Switch between bulk mode (`changes` array) and flat mode (top-level fields). Persists across sessions. |
143
135
  | `/toggle-auto-read` | Toggle automatic hashline anchors after write and replace operations. Persists across sessions. |
144
136
 
145
- ### Config file
146
-
147
- Settings are stored in `~/.config/pi-hashline-edit-pro/config.json`:
137
+ Settings live in `~/.config/pi-hashline-edit-pro/config.json`, created automatically when a setting is toggled:
148
138
 
149
139
  ```json
150
140
  {
151
- "replaceMode": "bulk",
152
- "autoRead": false
141
+ "autoRead": true
153
142
  }
154
143
  ```
155
144
 
156
- The file is created automatically when any setting is toggled. Both fields are independent — toggling one never clobbers the other.
157
-
158
- ### Error codes
145
+ ## Error codes
159
146
 
160
147
  | Code | Meaning |
161
148
  | --- | --- |
@@ -163,61 +150,46 @@ The file is created automatically when any setting is toggled. Both fields are i
163
150
  | `[E_BAD_REF]` | An anchor in `hash_range_inclusive` is not a bare 3-char hash. |
164
151
  | `[E_STALE_ANCHOR]` | An anchor does not match any line in the current file; call `read` for fresh anchors. |
165
152
  | `[E_AMBIGUOUS_ANCHOR]` | An anchor matches multiple lines; call `read` for fresh anchors. |
166
- | `[E_INVALID_PATCH]` | `content_lines` contains diff-preview rows (`+HASH│`, `-HASH│`, `- │`, `-N `). |
167
- | `[E_BARE_HASH_PREFIX]` | A `content_lines` entry starts with a hash-like `HASH│` prefix. |
153
+ | `[E_INVALID_PATCH]` | A `content_lines` entry is a diff-preview row (`+HASH│`, `-HASH│`, `- │`) — the marker is stripped automatically with a warning. |
154
+ | `[E_BARE_HASH_PREFIX]` | A `content_lines` entry starts with a hash-like `HASH│` prefix — the prefix is stripped automatically with a warning. |
168
155
  | `[E_LEGACY_SHAPE]` | The request uses the unsupported `oldText`/`newText` dialect. |
169
- | `[E_BAD_OP]` | Range start line is after range end line. |
170
- | `[E_EDIT_CONFLICT]` | Two edits in one batch overlap the same original lines. |
156
+ | `[E_BAD_OP]` | Range start line is after range end line — the pair is swapped automatically with a warning. |
171
157
  | `[E_WOULD_EMPTY]` | An edit would empty a non-empty file; use `write` instead. |
172
158
  | `[E_FILE_TOO_LARGE]` | The file exceeds the 238,328-line hashline limit. |
173
159
 
174
- ## Design Decisions
175
-
176
- - **Stale anchors fail (per-line).** A hash mismatch means that specific line's content changed since the last `read`; the error tells the model to call `read()` to get fresh anchors, then copy the 3-character HASH of the start and end of the range being replaced into `hash_range_inclusive` of the next replace call. When a range has one stale and one still-valid anchor, the error also shows the current lines (with fresh hashes) around the resolved anchor, so the model can re-locate the range without a full re-read. Because staleness is per-line, editing or appending lines does **not** invalidate anchors for lines whose content is unchanged — anchors for untouched regions stay valid across edits to other regions.
177
- - **No fallback relocation.** Mismatched anchors are never silently relocated to a "close enough" line. This trades convenience for correctness.
178
- - **Strict patch content.** If `content_lines` contains diff-preview rows — `+HASH│` addition prefixes, `-HASH│` or `- │` deletion rows (the padded format the diff preview emits), or `-N ` numbered deletion rows — the edit is rejected with `[E_INVALID_PATCH]`. This narrowly guards against pasting the tool's own diff-preview rows back as content; standard unified-diff lines (`+x`, `-x`, ` x`, `@@ … @@`) are **not** rejected — they are written literally, since literal content must never be silently altered. Bare `HASH│` content (the first 4 chars of a `content_lines` entry looking like 3 alphanumeric chars + `│`) is rejected with `[E_BARE_HASH_PREFIX]`. When the suspect's prefix happens to match a real file-line anchor, the error message flags that as strong evidence the model copied an anchor from the read output.
179
-
180
- - **BOM preservation.** A UTF-8 BOM is stripped for display and hashing but restored on write, so edits (and undo) never silently strip a BOM from a file that has one.
181
- - **Atomic writes.** Files are written via temp-file-then-rename to avoid corruption from interrupted writes. Symlink chains are resolved so the target file is updated without replacing the symlink. Hard-linked files are updated in place to preserve the shared inode. File permissions are preserved across atomic renames.
182
- - **Per-file mutation queue.** Edits queue by the canonical write target, so concurrent edits through different symlink paths still serialize onto the same underlying file.
183
- - **Boundary duplication auto-fix.** When the last line of a replacement matches the next surviving line (or the first line matches the preceding one), the runtime automatically strips the duplicate from `content_lines` before applying the edit. This catches a common LLM pattern where closing delimiters like `}`, `});`, or `} else {` are accidentally duplicated. The auto-fix is completely silent — the model sees a normal successful edit. The duplicate never reaches the file. Raw line comparison (not trimmed) avoids false positives when indentation differs.
184
- - **Flat mode normalization.** When flat mode is active, the tool's `execute` function wraps the top-level `hash_range_inclusive` and `content_lines` into a single-element `changes` array internally, then runs the same pipeline as bulk mode. The `normReq` function in `replace-normalize.ts` also handles flat format directly, so any code path that normalizes input (e.g. `compPreview`) works with both formats.
185
- - **Persistent hash store.** `lineHashes` is async and uses a persistent store to preserve hashes for unchanged lines across edits. The store is a SQLite database at `~/.config/pi-hashline-edit-pro/hash-store.sqlite` (per-path snapshots keyed by resolved path storing a 64-bit content checksum + line hashes; auto-created on first use). When called from the replace pipeline, it maps old vs new content and copies hashes for unchanged lines. When called from read, it returns saved hashes if the content's checksum matches, otherwise computes fresh hashes via `_lineHashesPure`. Stale snapshots are pruned on session start. This ensures that editing one part of a file does not cascade to change hashes of unrelated lines. Per-operation work scales with the target file, not cumulative history. If the database is corrupt or unreadable it is quarantined (renamed to `hash-store.sqlite.corrupt-<timestamp>`) and rebuilt from content on the next session start — the store is a cache, never a source of truth.
186
160
  ## Hashing
187
161
 
188
- Hashes are computed with [xxhash-wasm](https://github.com/jungomi/xxhash-wasm) (xxHash32 via WebAssembly), then mapped to a 3-character string from the alphanumeric alphabet `A-Za-z0-9`. That's 62 distinct characters, 62³ = 238,328 possible anchors (≈17.9 bits of entropy per anchor).
189
-
190
- The alphabet is sized for an LLM consumer. The model tokenizes, it doesn't squint at pixel glyphs, so the human-readability heuristics used by smaller hand-curated alphabets (no G/L/I/O because they look like digits, no vowels so the hash doesn't accidentally spell a word, no hex digits so it can't be confused with `0xFF`) don't apply — 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 (`-HASH│`), and `-`/`_` at a line start are markdown-active (list bullet, emphasis), so they invite mis-copying and false `[E_INVALID_PATCH]`/`[E_BARE_HASH_PREFIX]` rejections. The 9% hash-space cost (238,328 vs 262,144) is irrelevant for real files.
162
+ 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.
191
163
 
192
- Before hashing, each line is normalized: carriage returns are stripped and trailing whitespace is trimmed. This `canon()` normalization prevents insignificant whitespace changes from cascade-triggering hash churn across the file. Two lines that differ only in trailing spaces or `\r` characters produce the same hash, so anchor stability is preserved across editor-save cycles that add or remove trailing whitespace.
164
+ 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.
193
165
 
194
- **Perfect hashing (collision resolution):** When computing hashes for a file, if a line's base hash collides with an already-assigned hash, the next available hash is assigned from a bitset (238,328 bits) using a hint cursor for O(1) amortized lookup. This ensures every line in a file gets a unique anchor, even with the shorter 3-character hash space. Two byte-identical lines (e.g. repeated `}` or repeated `import` statements) get different hashes automatically.
195
- The runtime always precomputes the full per-line hash array for a file via `lineHashes(content, path)`, then looks up by line number during validation and during `read` / `replace` response formatting. There is no per-line recomputation that could disagree with what the model saw in its last read. When `path` is provided, `lineHashes` uses a persistent store to preserve hashes for unchanged lines across edits — see [Stable hashing across edits](#stable-hashing-across-edits).
196
- `HASH_LEN` in `src/hashline/hash.ts` sets the hash body length; bump it to 4 if you need even more entropy without collision resolution.
166
+ **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 (O(1) amortized). 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).
197
167
 
198
- The 3-character space holds 238,328 unique anchors, so files are capped at 238,328 lines: `read` and `replace` reject larger files with `[E_FILE_TOO_LARGE]` (use `write` or a non-line-based approach for very large files).
168
+ ## Design decisions
199
169
 
200
- ### Bare-prefix detector
201
-
202
- With the `│` delimiter format, the bare-prefix detector regex `^\s*([A-Za-z0-9_\-]{3})│` is highly specific. It only matches lines starting with a hash-like prefix. This eliminates false positives from common code patterns like `init:`, `data:`, `else:`, etc. The detector rejects edit lines matching this pattern with `[E_BARE_HASH_PREFIX]` to prevent the model from accidentally pasting hash anchors into file content.
170
+ - **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.
171
+ - **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).
172
+ - **Byte-exact preservation.** UTF-8 BOMs, CRLF vs LF endings, file permissions, and trailing newlines survive edits and undo.
173
+ - **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.
174
+ - **One edit per call.** The request shape stays `{path, hash_range_inclusive, content_lines}` from schema through validation to application; there is no batching dialect.
203
175
 
204
176
  ## Development
205
177
 
206
- Requires [Node.js](https://nodejs.org) and npm.
178
+ Requires [Node.js](https://nodejs.org) ≥ 22.13 and npm.
207
179
 
208
180
  ```bash
209
181
  npm install
210
182
  npm test
183
+ npm run lint
184
+ npm run typecheck
211
185
  ```
212
186
 
213
187
  Set `PI_HASHLINE_DEBUG=1` to show an "active" notification at session start.
214
188
 
215
- Set `PI_HASHLINE_AUTO_READ=1` to enable auto-read after write and replace by default on first run (can still be toggled at runtime with `/toggle-auto-read`; the setting persists across sessions once toggled).
216
-
217
189
  ## Credits
218
190
 
219
- - [RimuruW](https://github.com/RimuruW) -- original `pi-hashline-edit` and the strict-semantics policy
220
- - [can1357](https://github.com/can1357) -- original [oh-my-pi](https://github.com/can1357/oh-my-pi) implementation and the hashline concept
191
+ - [RimuruW](https://github.com/RimuruW) original `pi-hashline-edit` and the strict-semantics policy
192
+ - [can1357](https://github.com/can1357) original [oh-my-pi](https://github.com/can1357/oh-my-pi) implementation and the hashline concept
221
193
 
222
194
  ## License
223
195
 
package/index.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import { initHasher } from "./src/hashline";
3
- import { regReplace, regReplaceFlat } from "./src/replace";
3
+ import { regReplace } from "./src/replace";
4
4
  import { regReplaceUndo, clearUndo } from "./src/replace-undo";
5
5
  import { regRead, fmtReadPreview } from "./src/read";
6
6
  import type { RMetrics } from "./src/replace-response";
@@ -8,30 +8,21 @@ import { AUTO_READ_MAX } from "./src/constants";
8
8
  import { MAX_HASH_LINES } from "./src/hashline";
9
9
  import {
10
10
  readConfig,
11
- toggleReplaceMode,
12
11
  toggleAutoRead,
13
12
  } from "./src/config";
14
13
  import { loadHashStore, pruneMissing } from "./src/hash-store";
15
14
  import { readNormFile } from "./src/file-reader";
16
15
  import { toCwd } from "./src/paths";
17
16
  import { resolveTarget } from "./src/fs-write";
18
- function registerReplaceTool(pi: ExtensionAPI, mode: string): void {
19
- if (mode === "flat") {
20
- regReplaceFlat(pi);
21
- } else {
22
- regReplace(pi);
23
- }
24
- }
25
17
 
26
18
  export default function (pi: ExtensionAPI): void {
27
- regRead(pi);
19
+ regRead(pi, { autoRead: true });
28
20
 
29
21
  regReplace(pi);
30
22
  regReplaceUndo(pi);
31
23
 
32
24
  const debugValue = process.env.PI_HASHLINE_DEBUG;
33
- const autoReadValue = process.env.PI_HASHLINE_AUTO_READ;
34
- let autoRead = autoReadValue === "1" || autoReadValue === "true";
25
+ let autoRead = true;
35
26
 
36
27
  pi.on("session_start", async (_event, ctx) => {
37
28
  const active = pi.getActiveTools();
@@ -44,31 +35,19 @@ export default function (pi: ExtensionAPI): void {
44
35
  console.error("Failed to load or prune hash store:", err);
45
36
  }
46
37
  const config = await readConfig();
47
- const mode = config.replaceMode;
48
38
  autoRead = config.autoRead;
49
- registerReplaceTool(pi, mode);
50
-
39
+ regRead(pi, { autoRead });
51
40
 
52
41
  if (debugValue === "1" || debugValue === "true") {
53
- ctx.ui.notify(`Hashline Edit mode active (${mode} replace)`, "info");
42
+ ctx.ui.notify(`Hashline Edit mode active`, "info");
54
43
  }
55
44
  });
56
45
 
57
- pi.registerCommand("toggle-replace-mode", {
58
- description: "Toggle replace tool between bulk (changes array) and flat (single edit at top level) mode",
59
- handler: async (_args, ctx) => {
60
- const mode = await toggleReplaceMode();
61
- registerReplaceTool(pi, mode);
62
- ctx.ui.notify(`Replace mode switched to: ${mode}`, "info");
63
- },
64
- });
65
-
66
46
  pi.registerCommand("toggle-auto-read", {
67
47
  description: "Toggle automatic hashline anchors after write and replace operations",
68
48
  handler: async (_args, ctx) => {
69
49
  autoRead = await toggleAutoRead();
70
- const mode = (await readConfig()).replaceMode;
71
- registerReplaceTool(pi, mode);
50
+ regRead(pi, { autoRead });
72
51
  const state = autoRead ? "enabled" : "disabled";
73
52
  ctx.ui.notify(`Auto-read after write/replace: ${state}`, "info");
74
53
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-hashline-edit-pro",
3
- "version": "0.20.0",
3
+ "version": "1.0.0",
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",
@@ -1,2 +1,2 @@
1
1
  - `read`: call before `replace` when you need fresh HASH anchors for a file.
2
- - `read`: call again after any edit to that file — changed lines get new anchors.
2
+ {{AUTO_READ_NOTE}}
@@ -1,3 +1,4 @@
1
- - `replace`: hash_range_inclusive must use only anchors from the most recent read of the same file; on [E_STALE_ANCHOR], re-read the file and retry with fresh anchors.
2
- - `replace`: content_lines is a native JSON array of strings — never a serialized JSON string; strip the HASH│ prefix from read output and keep leading whitespace exactly as shown after │; no line numbers or diff markers.
1
+ - `replace`: sends exactly one edit per call hash_range_inclusive and content_lines at the top level of the request, not inside a changes array.
2
+ - `replace`: hash_range_inclusive must use only anchors from the most recent read of the same file.
3
+ - `replace`: content_lines is a native JSON array of strings — never a serialized JSON string. When copying a line from read output, remove its HASH│ prefix and keep the leading whitespace exactly as shown.
3
4
  - `replace`: minimize the replaced range — anchor only the lines that actually change; for insertions use a single-line range (e.g. the line after the insertion point) instead of a whole block, so fewer unchanged lines must be reproduced byte-exact.
@@ -1 +1 @@
1
- Replace lines in a text file via HASH anchors from read, {{MODE_PREFIX}}
1
+ Replace lines in a text file via HASH anchors from read, performing one edit per tool call
@@ -1 +1 @@
1
- Replace lines in a text file using HASH anchors from read's HASH│content output.
1
+ Replace a range of lines in a text file, targeted by the 3-char HASH anchors from read's HASH│content output.
package/src/config.ts CHANGED
@@ -3,31 +3,21 @@ import { configPath } from "./paths";
3
3
  import { errCode } from "./utils";
4
4
  import { writeAtomic } from "./fs-write";
5
5
 
6
- export type ReplaceMode = "bulk" | "flat";
7
6
  export interface Config {
8
- replaceMode: ReplaceMode;
9
7
  autoRead: boolean;
10
8
  }
11
9
 
12
10
  const DEFAULT_CONFIG: Config = {
13
- replaceMode: "bulk",
14
- autoRead: false
11
+ autoRead: true
15
12
  };
16
13
 
17
14
  function parseConfig(content: string): Config {
18
15
  const parsed = JSON.parse(content) as Partial<Config>;
19
16
  return {
20
- replaceMode: parsed.replaceMode === "flat" ? "flat" : "bulk",
21
17
  autoRead: parsed.autoRead === true,
22
18
  };
23
19
  }
24
20
 
25
- function envDefaultConfig(): Partial<Config> {
26
- const autoReadValue = process.env.PI_HASHLINE_AUTO_READ;
27
- return autoReadValue === "1" || autoReadValue === "true"
28
- ? { autoRead: true }
29
- : {};
30
- }
31
21
 
32
22
  export async function readConfig(): Promise<Config> {
33
23
  try {
@@ -37,7 +27,7 @@ export async function readConfig(): Promise<Config> {
37
27
  if (errCode(error) !== "ENOENT") {
38
28
  console.error("Config file corrupted, using defaults:", error);
39
29
  }
40
- return { ...DEFAULT_CONFIG, ...envDefaultConfig() };
30
+ return { ...DEFAULT_CONFIG };
41
31
  }
42
32
  }
43
33
  export async function writeConfig(config: Config): Promise<void> {
@@ -45,14 +35,6 @@ export async function writeConfig(config: Config): Promise<void> {
45
35
  }
46
36
 
47
37
 
48
- export async function toggleReplaceMode(): Promise<ReplaceMode> {
49
- const config = await readConfig();
50
- config.replaceMode = config.replaceMode === "bulk" ? "flat" : "bulk";
51
- await writeConfig(config);
52
- return config.replaceMode;
53
- }
54
-
55
-
56
38
  export async function toggleAutoRead(): Promise<boolean> {
57
39
  const config = await readConfig();
58
40
  config.autoRead = !config.autoRead;