pi-hashline-edit-pro
Hash-anchored read/replace/undo tools for pi-coding-agent. Every line gets a unique 3-char hash (A-Za-z0-9) that stays stable across edits; stale or ambiguous anchors are rejected, never fuzzy-matched. Undo persists across restarts.
Package details
Install pi-hashline-edit-pro from npm and Pi will load the resources declared by the package manifest.
$ pi install npm:pi-hashline-edit-pro- Package
pi-hashline-edit-pro- Version
2.6.5- Published
- Aug 23, 2026
- Downloads
- 19.1K/mo · 3,086/wk
- Author
- yugimob
- License
- MIT
- Types
- extension
- Size
- 156.6 KB
- Dependencies
- 4 dependencies · 2 peers
Pi manifest JSON
{
"extensions": [
"./index.ts"
]
}Security note
Pi packages can execute code and influence agent behavior. Review the source before installing third-party packages.
README
pi-hashline-edit-pro
Hash-anchored read and replace tools for pi-coding-agent. Every line of a file gets a unique 3-character hash, and you edit by hash. There are no line numbers and no fuzzy matching, so edits land on the lines you meant.
Fork of pi-hashline-edit by RimuruW, extended with 3-character hashes and collision resolution.
Features
readreturns every line asHASH│content. The hash is the line's address.replacetargets a range of hashes, so edits land on the lines you meant.- Editing one part of a file leaves the hashes of the rest unchanged, so anchors from an earlier read stay valid across edits.
- After a
writeyou get the new anchors. After areplaceyou get the diff with the new hashes. - The most recent replace on a file can be reverted, even after a restart.
- Permissions, line endings, BOMs, symlinks, and hard links survive every edit.
Quick start
- Read a file:
ve7│function hello() {
szJ│ console.log("world");
kQm│}
- Replace a line by its hash:
{
"path": "src/main.ts",
"remove_from": "szJ",
"remove_to": "szJ",
"replacement_lines": [" console.log('hi');"]
}
- Keep editing. Anchors for lines you didn't touch stay valid, and auto-read returns fresh anchors after each change.
Installation
pi install npm:pi-hashline-edit-pro
From a local checkout:
pi install /path/to/pi-hashline-edit-pro
The read tool
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).
| Parameter | Description |
|---|---|
offset |
Start reading from this line number (1-indexed). |
limit |
Maximum number of lines to return. |
Paged output ends with a continuation hint, for example [Showing lines 1-50 of 120. Use offset=51 to continue.].
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.
Edge cases:
- Images (JPEG, PNG, GIF, WebP, BMP) come back as visual attachments. Other image formats (for example AVIF, HEIC/HEIF, TIFF, ICO, JPEG 2000, JPEG XL, PSD, APNG) are rejected as binary, since the built-in renderer cannot attach them.
- Binary files and directories are rejected with a descriptive error. A magic-signature match is ignored when the sampled bytes contain no NUL bytes and decode as UTF-8, so a text file whose first bytes happen to match a binary or image signature (for example starting with
BMor8BPS) is still read as text. The NUL-byte check covers the whole file, not just the sampled bytes: a file with a NUL byte anywhere is rejected as binary. - UTF-16 and UTF-32 text (detected via BOM) is rejected, since editing it would corrupt the file.
- Empty files come back as a single empty-line hash (
HASH│); usereplaceon that hash to insert content. - 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. - Files over 238,328 lines or 100MB are rejected with
[E_FILE_TOO_LARGE].
The replace tool
The built-in edit tool is disabled. replace is the only edit path, and it takes the hash anchors from read output.
One edit per call, with remove_from, remove_to, and replacement_lines at the top level:
{
"path": "src/main.ts",
"remove_from": "szJ",
"remove_to": "kQm",
"replacement_lines": [" console.log('hi');", "}"]
}
| Field | Description |
|---|---|
remove_from |
3-char hash from read output marking the FIRST line to remove (inclusive). |
remove_to |
3-char hash from read output marking the LAST line to remove (inclusive). |
replacement_lines |
Replacement lines as an array of strings, one element per line. Mirror the removed lines exactly, blank lines included: use [] to delete the range, [""] for a single blank line, ["a", ""] for a line followed by a blank line, and ["", ""] for two blank lines. Do not embed \n inside an element: each element is exactly one line. |
Notes:
- The request is checked before any file I/O, so a bad request never touches the file.
- Common copy-paste slips are fixed automatically and reported: a leftover
HASH│prefix (including a truncated or expanded prefix of up to 6 characters, e.g.L3│orab12│) inreplacement_linesorremove_from/remove_to, diff-preview rows pasted into the replacement, a reversed range, or a boundary line pasted twice. New lines that re-include a block adjacent to the range are stripped automatically when that block is unique in the file. The whole run is stripped as one unit (including repeated structural lines like}), so re-including an unchanged block next to the range never duplicates it. A missingpathis resolved from the anchors when they uniquely identify a file in the hash store (reported as a warning); when the anchors match multiple known files the request is rejected with the candidate paths named.file_pathworks as an alias forpathin all three tools. - An edit that produces identical content reports
No changes madeand leaves the anchors alone. When such a noop happened because a boundary anti-duplication cut removed lines from the replacement (the cut blocked a line that duplicates the block next to the range from being added), the same replacement sent once more runs with the edge anti-duplication turned off for that single call and is applied literally. The duplicated lines are kept, and the result carries a[E_BOUNDARY_BYPASS]notice. The pending bypass is per file and keyed to that payload; copiedHASH│prefixes, diff markers, and stray whitespace in the resend are normalized before matching, so a copy-paste resend still hits it. Any applied edit clears it, and a successfulwritealso clears it. - Every line in the removed range must match what was last shown to you. The extension records the
HASH│contentrows it serves (readoutput, the auto-read block afterwrite, the+HASH│/HASH│rows of post-edit diffs (replace and undo), the current-range rows of[E_RANGE_STALE]feedback, and the context rows of stale/ambiguous-anchor feedback) and verifies the whole range against that record before writing. If an interior line changed on disk since it was shown (external editor, formatter-on-save, code generation) or was never shown, the edit is refused with[E_RANGE_STALE]and the current range is returned with fresh anchors, so the retry needs noread. Edits outside the served record are only possible for files that were never read (for example right after awritewith auto-read disabled); once the file has been served, every replaced line must have been shown. - After a successful edit you get the post-edit diff with fresh anchors, so you can keep editing without re-reading.
- Do not issue multiple replace calls on the same file in one message; parallel edits split attention across the post-edit diffs and removed lines are easy to miss. Verify each diff before the next edit on that file.
- Line endings and BOMs survive every edit. The file's line ending is detected from its first newline and restored on write; a file that mixes LF and CRLF (for example a WSL-edited file) is normalized to the first-seen ending.
- Files with multiple hard links (
nlink > 1) are rewritten in place rather than via a temp-file rename, so every link keeps seeing the same content; that write is direct rather than atomic.
Undo
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.
- History is per-file and single-level: only the most recent replace can be reverted.
- History is persisted and survives session restarts. A failed
writedoes not clear it. - Every applied replace is undoable: the undo record is saved before the edit is written.
- A successful
writeclears the history for that file. - If the file was modified or deleted since the last replace, the undo is refused rather than overwriting those changes.
Auto-read
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.
- After
replaceandundo_last_replace, the result shows the post-edit diff. The+HASH│andHASH│rows carry the current hashes, so follow-up edits can anchor on the diff directly. The-HASH│rows show removed lines with their old hashes, so you can see exactly which anchors were deleted (those hashes are stale after the edit). Callreadwhen you want the full file's anchors. - Auto-read keeps a 50KB display budget. Lines over 50KB are skipped with a marker instead of their content (use
readfor lines up to 200KB). - Toggle at runtime with
/toggle-auto-read; the setting persists across sessions.
Tool result details
All three tools return machine-readable metadata in details alongside the model-visible text:
read:details.truncation(set when the output was truncated),details.snapshotId(av2|path|ino|mtime|ctime|sizefingerprint of the file),details.nextOffset(use as the nextoffset), anddetails.metricswithtruncatedandnext_offset.replace:details.diff(the post-edit diff;+HASH│andHASH│rows carry the current anchors),details.patch(a standard unified patch of the changes, for external tools),details.firstChangedLine,details.snapshotId,details.classification("noop"when nothing changed), anddetails.metrics:edits_attempted,edits_noop,warnings,classification("applied"or"noop"),changed_lines({ first, last }),added_lines,removed_lines.undo_last_replace:details.diff(the undo diff with the restored anchors),details.patch(a standard unified patch of the restored changes), anddetails.metrics(same shape asreplace).
Settings
| Command | Description |
|---|---|
/toggle-auto-read |
Toggle auto-read anchors after write and post-edit diffs after replace and undo_last_replace. Persists across sessions. |
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:
{
"autoRead": true
}
How anchors work
Each line is canonicalized (carriage returns stripped, trailing whitespace trimmed) and hashed with 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.
The alphabet is sized for an LLM consumer: the model reads the hashes as tokens rather than inspecting glyph shapes, so letters and digits are all included. The URL-safe specials - and _ are deliberately excluded. A hash starting with - looks like a diff-preview deletion row, and -/_ at the start of a line are markdown-active, which invites mis-copying and false autocorrections.
Anchors are unique 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).
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.
The store also keeps a per-file record of the hashes the model was last served (read rows, auto-read blocks, post-edit diff rows), pruned to the file's current hashes on every update so removed lines' hashes do not accumulate. replace verifies every line of the resolved range against that record before writing; a line whose hash is missing from the record means it either changed on disk after it was shown or was never shown, and the edit is refused with [E_RANGE_STALE]. A write clears the record, so edits after a write are verified against whatever the next read or auto-read block serves.
Two guarantees make this safe even with duplicated content:
- 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.
- 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.
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.
Error codes
| Code | Meaning |
|---|---|
[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). |
[E_BAD_REF] |
An anchor in remove_from/remove_to is not a bare 3-char hash. |
[E_STALE_ANCHOR] |
An anchor does not match any line in the current file; call read for fresh anchors. |
[E_AMBIGUOUS_ANCHOR] |
An anchor matches multiple lines; call read for fresh anchors. |
[E_INVALID_PATCH] |
A replacement_lines element is a diff-preview row (+HASH│, -HASH│, - │). The marker is stripped automatically with a warning. |
[E_BARE_HASH_PREFIX] |
A replacement_lines element starts with a hash-like HASH│ prefix. The prefix is stripped automatically with a warning. |
[E_BAD_OP] |
Range start line is after range end line. The pair is swapped automatically with a warning. |
[E_WOULD_EMPTY] |
An edit would empty a non-empty file; use write instead. |
[E_NOT_FOUND] |
The path does not exist. |
[E_ACCESS] |
The file is not readable or writable. |
[E_NOT_TEXT] |
The path is a directory, binary file, image, or UTF-16/UTF-32 encoded text; hashline editing only supports text files. |
[E_UNDO_STALE] |
undo_last_replace refused: the file was modified or deleted after the last replace. |
[E_UNDO_UNAVAILABLE] |
Undo history could not be persisted to the hash store; the replace was refused and the file was left unchanged. |
[E_RANGE_STALE] |
A line in the replaced range no longer matches what was last shown (the file changed on disk, or the line was never shown). The edit was refused; the current range is returned with fresh anchors. |
[E_BOUNDARY_BYPASS] |
The boundary anti-duplication was turned off for one replace call (an identical replacement had previously been cut to a noop); the duplicate lines were applied literally. The dedup is restored for the next call. |
[E_FILE_TOO_LARGE] |
The file exceeds the 238,328-line hashline limit or the 100MB size limit. |
Troubleshooting
- Stale anchors.
[E_STALE_ANCHOR]or[E_AMBIGUOUS_ANCHOR]mean the file changed since the anchors were read. Callreadfor fresh anchors and retry. - Range changed on disk.
[E_RANGE_STALE]means a line inside the replaced range changed after it was last shown to you (or was never shown). Nothing was modified; the error carries the current range with fresh anchors, so retry with those without aread. - Reset the hash store. Anchors live in
~/.config/pi-hashline-edit-pro/hash-store.sqlite(with-wal/-shmsidecars). 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. - Corrupt store. If the store fails its health check it is renamed to
hash-store.sqlite.corrupt-<timestamp>and rebuilt automatically. - Config directory moved. On non-Windows platforms, if
XDG_CONFIG_HOMEis set, the config directory (and the hash store inside it) lives at$XDG_CONFIG_HOME/pi-hashline-edit-proinstead of~/.config/pi-hashline-edit-pro. An existing store is not migrated automatically. To keep anchor and undo history, move the oldhash-store.sqlitefiles (plus-wal/-shmsidecars) into the new directory before the first run.
Development
Requires Node.js ≥ 22.19 and npm.
npm install
npm test
npm run lint
npm run typecheck
Set PI_HASHLINE_DEBUG=1 to show an "active" notification at session start.
Credits
- RimuruW, original
pi-hashline-editand the strict-semantics policy - can1357, original oh-my-pi implementation and the hashline concept