@d3ara1n/pi-hashline-edit
Hashline-style file editing for pi — line-anchored edits verified by content hash, replacing oldText/newText matching
Package details
Install @d3ara1n/pi-hashline-edit from npm and Pi will load the resources declared by the package manifest.
$ pi install npm:@d3ara1n/pi-hashline-edit- Package
@d3ara1n/pi-hashline-edit- Version
0.3.2- Published
- Aug 4, 2026
- Downloads
- 859/mo · 332/wk
- Author
- d3ara1n
- License
- MIT
- Types
- extension
- Size
- 112 KB
- Dependencies
- 0 dependencies · 1 peer
Pi manifest JSON
{
"extensions": [
"./src/index.ts"
]
}Security note
Pi packages can execute code and influence agent behavior. Review the source before installing third-party packages.
README
@d3ara1n/pi-hashline-edit
Hashline-style file editing for pi — line-anchored edits verified by content hash (replacing
oldText/newTextmatching), plus a location-blindreplacetool for bulk + regex transforms.
Edits reference lines by LINE#HASH anchors (copied from read/grep output) instead of retyping the code to be changed — eliminating string-not-found loops and whitespace battles at the root.
Why hashline?
The built-in edit matches oldText/newText exactly. When the model can't reproduce the source verbatim — wrong indentation, a non-unique snippet, or a line that drifted since the read — the edit fails and you loop. Hashline sidesteps all of it:
- No string-not-found loops — you edit by reference (
LINE#HASH), not by retyping the line you want to change. - No whitespace battles — the new content is the only thing you type; nothing has to match what's already there. Indentation mistakes on the old code are impossible.
- Unique-by-construction hashes — the line number is folded into the hash, so identical lines (blank lines,
}) never share a hash and never collide. - Chain edits without re-reading — a successful
editreturns fresh anchors for the lines it produced, so the next edit cites them directly instead of forcing a full re-read. - Grep-to-edit, no detour — search results carry the same
LINE#HASHanchors (grouped by file, context lines included); grab one and edit directly, skipping the read you'd otherwise need. - Surgical drift detection — each cited anchor is rechecked against the current line only; an unrelated change elsewhere never blocks your edit.
- Self-healing stale anchors — a drifted anchor (content shifted by an edit above it) doesn't force a full re-read: the applicator rescans ±lines for the original content and hands back a fresh
LINE#HASHto retry with, only asking for a re-read when the content genuinely changed.
When to use it
Routine local code editing in pi — the common case. If you spend turns fighting "old_string not found" or fixing indentation the model dropped, this is the fix.
When to turn it off
Set hashlineEdit.enabled = false (or uninstall) to fall back to the built-in read/edit/grep when you need remote or custom-storage files — the overrides read/write/search the local filesystem directly, so pi's custom ReadOperations/GrepOperations (SSH, etc.) aren't supported. The same switch lets you opt out per-project. All four tools — read, grep, edit, replace — are one set governed by this switch: when disabled, read/grep/edit delegate to the built-ins and replace refuses (it has no built-in counterpart).
Gotchas (vs. the built-in read/edit)
Once hashline overrides the built-ins, a few things behave differently:
readis globally overridden. Every read shows theLINE#HASH│prefix on each line — even reads that won't lead to an edit. This is expected (it's the substrate the reliability is built on), just don't be surprised when the format changes for all files.- Conservative overlap. Two ops whose ranges touch (e.g.
insert_afterimmediately followed byreplaceat the same line) are rejected to avoid backfill ambiguity — issue them as two separateeditcalls.
replace — bulk + regex
A separate, location-blind tool for transforms edit can't express: replace all occurrences of a string/regex across the whole file in one call. Use it for renames, normalizations, and pattern-based rewrites that would otherwise need many individual anchored ops.
- Two modes —
regex: false(default) treatsfindas a literal substring (replaceAll; the replacement is inserted verbatim, no$expansion);regex: truetreatsfindas a JavaScript pattern source andreplacesupports$1,$2,$&, … - Flags —
flagsadds regex flags in both modes (gis always forced so every occurrence is replaced):i(case-insensitive),m(per-line^/$),s(dotall,.matches\n),u(unicode). - Safety — a
maxMatchescap (default 2000) errors before writing if exceeded, so a runaway pattern can't produce a catastrophic write.0matches is an error (no silent no-op). - Shares the edit queue —
replaceandediton the same file are serialized via the same mutation queue, so concurrent edits never interleave. - Returns a diff + fresh anchors for the changed region, so a follow-up
editcan chain on the new content without a re-read (when the region is small).
edit vs replace: edit is surgical and verified (you point at a LINE#HASH and the tool confirms the line is unchanged before rewriting it). replace is global and unverified (you give a pattern, it rewrites every match sight-unseen). Pick by intent: change a known spot → edit; transform every occurrence → replace.
Design
- Per-line hash + line number, dual anchor:
readshows each line as3#aF3│code;editreferencesLINE#HASH. The line number is the address; the hash is a checksum that the line at that address is still what was read. - Line folded into the hash: each line's hash mixes its 1-based line number into its content, so every line is unique by construction — no in-file collisions, no length extension. The hash changes only when the line's own content changes, never when a neighbor changes.
- Live, surgical verification: at apply time each cited anchor's hash is recomputed from the current line content and compared — no stored snapshot, no whole-file stale check. A line that changed (or was misremembered) fails its own anchor; an unrelated change elsewhere never blocks the edit. No fuzzy matching, no boundary repair.
- Shifted-anchor recovery: a mismatched anchor isn't a dead end. The applicator rescans ±
shiftRadiuslines for the original content — holding the original line number fixed and re-hashing each candidate (hash(line, candidate) === citediff the candidate is the original) — and returns a ready-to-resend anchor on a unique hit, the candidate list when ambiguous, or the cited line's live content when nothing matches. The model retries without a re-read in the common drift case. - Atomic batches, all failures collected: every op in one
editis verified against the same snapshot; if any anchor fails, all failures (each with its recovery) are returned together and nothing is written — partial writes would shift lines and invalidate the very recovery info just returned. - Chain edits without re-reading: a successful
editreturnsUpdated anchorsfor the lines it produced (and the line that shifted into a deletion gap), so the next edit can cite them directly. - No legacy compatibility on
edit:editaccepts only structured hashline ops; sending legacyoldText/newTextis rejected at the schema layer (never silently degrades) — so you always know whether hashline is actually in use. Bulk/regex replacement is a separate tool,replace, not aneditmode (see below).
Protocol
read output (each line anchored):
src/foo.ts · 6 lines
1#aF3│import { compute } from "./util"
2#7Qk│
3#mP0│export function foo(x: number) {
grep output (results grouped by file, each line anchored — copy LINE#HASH straight into an edit):
src/foo.ts · 2 matches
3#mP0│export function foo(x: number) {
4#kLp│ return x + 1
src/util.ts · 1 match
10#aF3│ const z = compute(x)
edit takes path + edits (an array of ops, each with op, anchor/end {line, hash} from read, and body string[]):
{
"path": "src/foo.ts",
"edits": [
{ "op": "replace", "anchor": { "line": 4, "hash": "kLp" }, "body": [" return x + 1"] },
{ "op": "insert_after", "anchor": { "line": 6, "hash": "b2H" }, "body": ["", "export const bar = foo"] }
]
}
Ops: replace · delete · insert_after · insert_before · append · prepend. anchor/end = {line, hash} from read; body = new content lines (string[], omit for delete).
replace takes path, find, replace (+ optional regex, flags, maxMatches) and substitutes every match:
{
"path": "src/foo.ts",
"find": "oldName",
"replace": "newName"
}
Regex with a capture group (rename getName() → get_name() everywhere):
{ "path": "src/foo.ts", "find": "get([A-Z]\w*)", "replace": "get_$1", "regex": true }
Case-insensitive literal rename across the whole file:
{ "path": "src/foo.ts", "find": "TODO", "replace": "FIXME", "flags": "i" }
Configuration
Add a hashlineEdit field to ~/.pi/agent/settings.json (global) or .pi/settings.json in a project (project replaces global):
{
"hashlineEdit": {
"enabled": true, // set false to fall back to the built-in read/edit
"hashLen": 4, // hash length, 2–8 (default 4)
"shiftRadius": 15 // ±lines scanned to rescue a stale anchor (default 15; 0 disables)
}
}
Installation
pi install npm:@d3ara1n/pi-hashline-edit
Or add to ~/.pi/agent/settings.json:
{
"extensions": [
"/absolute/path/to/pi-extensions/packages/pi-hashline-edit"
]
}
Dependencies
- No additional
@d3ara1n/pi-*dependencies; peer@earendil-works/pi-coding-agentships with pi (framework-level, not listed by convention).