pi-hashline-edit-pro
Strict hashline read/replace tool for pi-coding-agent with hash-anchored edits (3-char, 18-bit, perfect hashing)
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
0.16.13- Published
- Jul 16, 2026
- Downloads
- 10.5K/mo · 2,007/wk
- Author
- yugimob
- License
- MIT
- Types
- extension
- Size
- 113.1 KB
- Dependencies
- 3 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
A pi-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.
Fork of 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.
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.
Why fork?
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).
This fork makes two changes that compound:
- 3-character hash length over a 64-char URL-safe base64 alphabet (up from 2 characters in the upstream), expanding the hash space from 256 to 262,144 buckets.
- Perfect hashing (collision resolution). When computing hashes for a file, if a line's base hash collides with an already-assigned hash, the hash is incremented (using a retry counter:
:R{retry}) until a unique hash is found. This ensures every line gets a unique anchor, even within a 3-character hash space. Two byte-identical lines (e.g. repeated}or repeatedimportstatements) get different hashes automatically.
Installation
From npm:
pi install npm:pi-hashline-edit-pro
From a local checkout:
pi install /path/to/pi-hashline-edit-pro
How It Works
read -- tagged line output
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:
function hello() {
console.log("world");
}
would be returned as:
0qH│function hello() {
szJ│ console.log("world");
_zl│}
HASHis a 3-character content hash from the URL-safe base64 alphabetA-Za-z0-9-_(e.g.aB3). See Hashing for details.
Optional parameters:
offset-- start reading from this line number (1-indexed).limit-- maximum number of lines to return.
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.
replace -- hash-anchored modifications
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):
Bulk mode (default): hash_range_inclusive and content_lines go inside a changes array, supporting multiple edits in one call.
{
"changes": [
{ "content_lines": [" console.log('hashline');"], "hash_range_inclusive": ["ve7", "ve7"] }
],
"path": "src/main.ts"
}
Flat mode: hash_range_inclusive and content_lines sit at the top level. Only one edit per call.
{
"content_lines": [" console.log('hashline');"],
"hash_range_inclusive": ["ve7", "ve7"],
"path": "src/main.ts"
}
| Field | Description |
|---|---|
hash_range_inclusive |
Inclusive line range [start_hash, end_hash] (required). |
content_lines |
Literal replacement content, one string per line (use [] to delete the range). |
- Request structure validation. The request envelope (
path,changesin bulk mode;path,hash_range_inclusive,content_linesin 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]. - Legacy dialect rejected. The native top-level
oldText/newText(andold_text/new_text) dialect is rejected with[E_LEGACY_SHAPE]. The error message tells the model to callreadfirst and send{content_lines: [...], hash_range_inclusive: ["<START>", "<END>"]}. - 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
readcall remain valid across all edits in the batch.
Stable hashing across edits
Hashes are now computed with a persistent store (~/.config/pi-hashline-edit-pro/hash-store.json) 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.
The store contains per-file snapshots: the last known content and hashes for each file. On read, if the file content matches the snapshot, the saved hashes are returned immediately. Stale snapshots (for files that no longer exist) are pruned on session start.
Chained edits
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.
Auto-read after write and replace
Auto-read is disabled by default. When enabled, after a successful write or 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:
writea file, result includes hashline anchorsreplaceusing those anchors directly
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.
For large files (>2000 lines), the auto-read output is truncated with a pagination hint. Use read with offset to see more.
Diff for the host
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.
Commands
| Command | Description |
|---|---|
/toggle-replace-mode |
Switch between bulk mode (changes array) and flat mode (top-level fields). Persists across sessions. |
/toggle-auto-read |
Toggle automatic hashline anchors after write and replace operations. Persists across sessions. |
Config file
Settings are stored in ~/.config/pi-hashline-edit-pro/config.json:
{
"replaceMode": "bulk",
"autoRead": false
}
The file is created automatically when any setting is toggled. Both fields are independent — toggling one never clobbers the other.
Design Decisions
- 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 callread()to get fresh anchors, then copy the 3-character HASH of the start and end of the range being replaced intohash_range_inclusiveof the next replace call. 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. - No fallback relocation. Mismatched anchors are never silently relocated to a "close enough" line. This trades convenience for correctness.
- Strict patch content. If
content_linescontains+HASH│display prefixes (or-Nnumbered 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. BareHASH│content (the first 4 chars of acontent_linesentry looking like 3 base64 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. - 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.
- 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.
- 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_linesbefore 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. - Flat mode normalization. When flat mode is active, the tool's
executefunction wraps the top-levelhash_range_inclusiveandcontent_linesinto a single-elementchangesarray internally, then runs the same pipeline as bulk mode. ThenormReqfunction inreplace-normalize.tsalso handles flat format directly, so any code path that normalizes input (e.g.compPreview) works with both formats. - Persistent hash store.
lineHashesis async and uses a persistent store to preserve hashes for unchanged lines across edits. The store is at~/.config/pi-hashline-edit-pro/hash-store.jsonand is auto-created on first use. It contains per-file snapshots (last known content+hashes). 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 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.
Hashing
Hashes are computed with xxhash-wasm (xxHash32 via WebAssembly), then mapped to a 3-character string from the URL-safe base64 alphabet A-Za-z0-9-_. That's 64 distinct characters, 6 bits per position, 18 bits of entropy per anchor.
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. The full 64 chars give maximum entropy per character, with case and digits included.
Perfect hashing (collision resolution): When computing hashes for a file, if a line's base hash collides with an already-assigned hash, the hash is incremented (using a retry counter: :R{retry}) until a unique hash is found. 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.
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.
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.
Bare-prefix detector
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.
Development
Requires Node.js and npm.
npm install
npm test
Set PI_HASHLINE_DEBUG=1 to show an "active" notification at session start.
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).
Credits
- RimuruW -- original
pi-hashline-editand the strict-semantics policy - can1357 -- original oh-my-pi implementation and the hashline concept