- Accueil
- Plugins
- Outils et capacités
- dsh-hashline-edittool
dsh-hashline-edittool
hyperion2144/dsh-hashline-edittool
Line-anchored read/edit/batch_edit/grep/undo_last_edit tools for DeepSeek Harness (dsh). Every line is addressed by `<line>:<anchor>` so chained edits skip a re-read; stale or ambiguous anchors are rejected and a fresh `<line>:<anchor>` is served back. Un
Installer
dsh plugin --profile web add github:hyperion2144/dsh-hashline-edittoolREADME
dsh-hashline-edittool
Line-anchored edit tool for DeepSeek Harness
Powered by variable-length content anchors — no line numbers, no re-typing old code, fewer tokens, more context space for real work.
English · 简体中文
Quick Start • Why Hashline • Benchmark • Tools • Acknowledgments
Forked from Rianico/dsh-better-edit — maintained independently from here on.
"The harness — not the model — is the bottleneck." — Can Bölük, The Harness Problem
Most edit tools ask the model to echo the old code token-for-token before it can change anything — and that's exactly where agents fail: 46–51% patch-format failure rates for several models with replace-style edits. dsh-hashline-edittool goes deeper. Every line of a file gets a unique variable-length Base62 anchor (shortest-first: 2 chars up to 3,844 lines, growing with file size), and edits target those markers. The old text is never echoed, anchors survive edits above (session-internal stability), and every resolved range is verified against exactly what the model saw — wrong-line edits cannot silently land, and the post-edit diff rows carry fresh anchors for the next chained edit.
Why you need this
str_replace makes the model re-type the code it's replacing — pure transcription cost (output tokens, billed ~5-6× input), and where agents fail most: 46–51% patch failures on real models, worse on bigger blocks, each failure costing a re-read and a retry.
Hashline sends two variable-length anchors instead of the old text — fewer edit tokens than str_replace — and verifies every range against what the model saw: an edit lands where you meant, or fails loudly with fresh anchors. Anchors are session-stable content addresses; the post-edit diff serves fresh markers for the next edit — and a leaner context keeps the model's attention on the code, not on re-transcribing it.
Not for one-line touch-ups (near parity) or new files (write). It pays off in long sessions and structural edits — anywhere an edit must not land on the wrong line.
Quick Start
Install
npx @deepseek-ai/dsh plugin --profile web add github:hyperion2144/dsh-hashline-edittool # from github
npx @deepseek-ai/dsh plugin --profile web add dsh-hashline-edittool # from npm
npx @deepseek-ai/dsh plugin --profile web add /path/to/dsh-hashline-edittool # from a local checkout
The profile's next session runs with the hashline tools installed — the web UI cards
ship in the same package: the dsh web read card shows <line>:<anchor> gutters, the edit
card renders the applied multi-hunk diff with anchor hints, and the grep card adds a file
tab bar (one tab even for a single match) with the matched text highlighted in each row —
all from the same official card primitives (no upstream changes; see
client/ and docs/adr/0005).
To verify the layer is active:
dsh --profile <name> --dump-config # shows a "# == dsh-hashline-edittool" layer
| Requirement | |
|---|---|
| Node | ^22.19.0 || >=24.0.0 (dsh's requirement; the store uses node:sqlite) |
| Profile | a dsh profile (dsh plugin initializes one on first use) |
| Backends | sandboxed / remote filesystems supported (writes go through ctx.fs) |
read returns every line as <anchor>|<content> (separator configurable, default :). Anchors are variable-length Base62 — 2 characters for the first 3,844 lines, growing only as the file demands. The response opens with a ANCHOR:FILELINE header that separates the marker column from the verbatim file content:
ANCHOR:FILELINE
3hA:function hello() {
4fK: console.log("world");
5mR:}
edit targets one or more ranges of anchors via an edits:[] array, each with an op semantic (ins / del / replace). The contract is exact:
replacetakesanchor_endoptionally: omitting it defaults to a single-line replace (range = theanchor_startline); passing the same marker twice is still valid.lineshas any length — the whole range is swapped for it (shrink and expand are single-hunkreplaces). A multi-line replacement (lines.length > 1) MUST passanchor_end— the tool will not guess the range from the replacement length.insinserts into the gap after its anchor line (the anchor line's content is untouched) and may anchor on another hunk's range END line (half-openN ∉ [hs, he)) — never its start or interior.deldeletes the range (lines must be empty).
A single-line replace:
{
"path": "src/main.ts",
"edits": [
{ "op": "replace", "anchor_start": "4fK", "lines": [" console.log('hi');"] }
]
}
and produces a diff with fresh anchors — the next edit chains from the diff rows without a re-read (there is no Shift: block in v2.0):
ANCHOR:FILELINE
+ 4nP: console.log('hi');
- 4fK: console.log("world");
Configuration
The plugin reads a hashline namespace from the dsh settings service (persisted to ~/.dsh/settings.yaml). All keys are optional; live edits to settings.yaml take effect immediately (the hash shape recompiles, tools switch output format on the next call).
hashline:
separator: ":" # column separator between the marker and the content (default ":")
output_format: text # "text" (hashline rows) | "json" (pure JSON)
context_lines: 3 # context rows echoed around stale anchors / diffs / grep (default 3, 0..20)
require_line_content: false # when true, edit anchors become `{ anchor, line }` pairs (see below)
require_line_content (default false)
Hardening switch against wrong-anchor edits. When enabled, the edit tool's schema changes (live, per agent): every edits[] anchor must be a { anchor, line } pair — anchor as usual, plus line: your declaration of that line's CURRENT full text (single line, verbatim; trailing whitespace may be omitted and a copied read-row marker prefix is tolerated). Every declaration is verified after the stale-anchor check; a mismatch rejects the whole call with [E_CONTENT_MISMATCH], echoing the actual line and where your declared content currently lives. With the switch off, anchors stay plain strings and a passed object is rejected as a shape error.
text output (default)
Every read/grep/diff/echo starts with a header row (ANCHOR:FILELINE) describing the row format, the left variable-length anchor, and that the text after the separator is the verbatim file content — including the rule: to modify the file, pass the content after the separator, never the anchor part. Rows render as <line>:<anchor> by default (line number on since #69) — informational only, the anchor stays authoritative; pass line_numbers: false per call for bare <anchor> rows.
json output
Set output_format: json for pure-JSON tool outputs — the model parses the JSON directly:
- read returns
{path, offset, totalLines, lines: {anchor: content}}— eachlineskey is a variable-length Base62 edit anchor, each value is the verbatim file content. - edit returns
{ok: true, path, diff: {key: content}, hints, warnings, errors: []}on success —diffis the text diff as an anchor-keyed dict: removed rows keyed"-<old line#old hash>", added rows"+<final line#new hash>", context rows keyed by the BARE anchor (aligned with read'slines; context count followscontext_lines). Rejected edits fail loudly (throw, isError) in json mode exactly as in text mode — the model receives theE_code + message through the failure channel.
Legacy │-separated rows still parse in both modes.
Configuring Guidance per Preset
The tool:read / tool:edit / tool:undo_last_edit / tool:grep guidance sections are
plain-markdown files, overridable per agent preset. Override files live in the plugin's shared
home — never the workspace store:
$DSH_HOME/plugins/dsh-hashline-edittool/<preset>/<section>.md
(default home ~/.dsh, so ~/.dsh/plugins/dsh-hashline-edittool/). The section table:
| File | Section | Default order |
|---|---|---|
read.md | tool:read | 130 |
edit.md | tool:edit | 131 |
| undo_last_edit.md | tool:undo_last_edit | 132 |
On first boot the plugin seeds the four shipped presets — standard/, code/,
minimal/, cordis/ — each with the compiled guidance as editable files (plus
order front-matter), so every preset's guidance starts editable rather than
blank. A README.md at the plugin-home root documents the scheme. Files are
seeded once and never rewritten, so your edits survive — a reset is the one
exception (see Reset / restore defaults below). A preset directory may
hold only the sections you want to override — the rest fall through to the
compiled defaults.
A file is pure prose unless it opens with an order front-matter fence, which moves the section in
the assembled system prompt:
---
order: 150
---
<section text>
Per section, resolution reads <preset>/<section>.md, else the compiled
default. Files are read once per agent at session-start, so edits apply to new
sessions — never mid-session. A preset with no seeded directory (e.g. a
user-authored one) falls back to the compiled defaults unless you copy a seeded
dir to its name. A deployment without the agentPresets service (no preset
roster) keeps the compiled defaults and never touches these files; presets are
never required.
Reset / restore defaults
Emptying or deleting an override file restores that section's compiled default guidance and order: the default renders at session-start, and the file re-seeds at next boot.
- Reset = delete the file, or empty it AND remove the front-matter fence. A whitespace-only file with no fence means "I want the default" — the compiled default renders, and the file re-seeds at next boot for any preset dir, shipped or custom.
- Blank on purpose = keep a valid fence. Any well-formed
---fence — even a keyless---\n---\n, even an empty body — is a deliberate-intent signal: the file is explicit content and is never reset or re-seeded. - Broken fence = fast fail. A
---fence that does not parse (missing closing---, non-integerorder, unknown key) is rejected: the malformed text is never injected into the context, the compiled default renders, a warning names the file and the reason, and the file is left untouched on disk for repair. - Shipped vs custom. Shipped preset files (
standard,code,minimal,cordis) re-seed at boot; a deleted custom-preset override stays absent — absence is no override. Deleting a whole<preset>/directory re-seeds all four section files at boot (shipped presets). - Reset restores the current bundle defaults — a plugin upgrade yields new defaults.
Re-seeding happens at boot, never mid-session.
Why Hashline
Token-saving. An edit call carries anchor_start / anchor_end (two variable-length Base62 markers)
plus the replacement text — it never echoes the text being replaced. A str_replace call must
reproduce that text verbatim — and these are output tokens, billed at ~5-6× the input
rate. See the benchmark.
But this was never about “fewest tokens.” Savings scale with the replaced text — near parity on the shortest one-line touch-ups — and a compact patch language like @oh-my-pi/hashline can emit a lighter payload still (42–53% on the same session). The point is the right kind of edit call: no re-typing old code, and nothing for the model to track except two stable content addresses (anchors stay stable across edits in the session; the post-edit diff serves fresh markers for the next edit).
Correctness. Every resolved edit range is verified against the exact lines the model was shown.
A stale, never-served, or ambiguous range is hard-rejected before anything is written, and the
current range is echoed back as fresh anchors (reject-and-serve) — the retry needs no read.
One call = one snapshot, and it is atomic. All anchors in one edit call resolve against the original file snapshot — never shift them to positions a previous hunk would produce in sequence (there is no "after the previous edit" coordinate); the response's diff rows show the final positions. The batch is all-or-nothing: any hunk failure rejects the whole call ([E_BATCH_ABORT]) and nothing is written — already-resolved hunks are not applied, so there is nothing to roll back.
A modern edit pattern for agents. Anchors are session-stable content addresses: unchanged
lines keep their anchors across edits, so consecutive edits chain without re-reads. Every hunk in one
edit call resolves its anchors against the same file snapshot, so multiple non-overlapping
edits apply atomically in a single pass; overlapping ranges are rejected up front
([E_BATCH_CONFLICT]). The post-edit diff rows carry fresh anchors, so follow-up edits copy
the markers straight from the diff.
How It Compares
hashline edit | str_replace (Claude Code / Codex) | @oh-my-pi/hashline patch | |
|---|---|---|---|
| Replaced text never echoed in the call | ✅ 2 hashes only | ❌ verbatim | ✅ + rows only |
| Lines addressed by | line number + content hash | text match | number + file-content tag |
| Verified against what the model saw | ✅ every line | ❌ first match wins | ~ file version only |
| Stale file detected | ✅ rejects, fresh anchors | ❌ may match wrong spot | ✅ tag mismatch → refuse or 3-way merge |
| Anchors survive edits above | ✅ session-stable anchors (unchanged lines keep theirs) | ✅ content-based | ❌ renumber + new tag |
| Chained edits without re-reads | ✅ fresh anchors in the post-edit diff | ~ | ~ via edit-response numbers |
| Unambiguous when text repeats | ✅ boundary anchors verified | ❌ first occurrence | ~ position, unverified per line |
| Wrong-line edit never lands silently | ✅ every line verified | ❌ first match wins | ~ possible in principle (tag checks version, not lines) |
Block ops / registers / MV / REM | ❌ | ❌ | ✅ |
| One document per change | ❌ per-edit call | ❌ per-edit call | ✅ multi-hunk patch |
| Runtime | ✅ Node (dsh) | — | ⚠️ Bun only |
| Undo | ✅ persisted | ❌ | ❌ not in scope |
~= occasionally / inconsistently.@oh-my-pi/hashlineis a compact line-anchored patch language (npm, repo):[path#tag]headers bind each hunk to a full-file content hash,PUT N.=M:addresses lines by number, and every edit renumbers — take the next numbers and tag from the edit response or a freshread.
Different jobs, same lineage. Both descend from the
harness-problem insight that the model should never
re-type old code. @oh-my-pi/hashline is a patch-language library — payload-light (42% saved
per edit, 53% in a single batch document, see benchmark), with syntactic block ops
(PUT N*:), registers, REM/MV, multi-hunk documents, a pluggable filesystem for any backend,
and session-aware 3-way-merge recovery on stale tags. This plugin is a dsh tool pair: read
hands the model variable-length content anchors, edit takes two of them, and every resolved line is verified
against the served state — no line numbers to renumber, no tag to re-fetch, a wrong anchor can never
land on the wrong line, and undo_last_edit survives restarts. Its trade-offs: a JSON envelope per
edit costs a little payload, there are no block ops, and it lives inside dsh (Node) rather than as a
standalone patcher (Bun). Pick hashline-the-library for a cross-backend patch format; pick
hashline-the-tool for verified, content-addressed edits in your agent.
Correctness in edge cases
The token benchmark measures the payload the model emits — it assumes the model gets every address right, for free. Correctness is where the two hashline implementations actually diverge. These are the real failure modes from the harness-problem literature (wrong-line edits, drift, repeated text), and what each tool does when they hit:
| Edge case | hashline edit (this plugin) | @oh-my-pi/hashline patch |
|---|---|---|
| Wrong address (off-by-one anchor / line number) | Impossible — anchors resolve to specific lines; every resolved line is verified against served state, rejected before anything is written | Possible — a wrong line number against a current tag applies silently at the wrong place; the tag proves the file version, never the lines |
| File changed on disk after the model's view | Hard reject + fresh anchors echoed (reject-and-serve); retry needs no read | Tag mismatch → refuse or best-effort 3-way merge onto unknown current content |
| An edit above shifts the file | Nothing shifts — anchors are content addresses; the diff serves fresh anchors | Every edit renumbers — “RE-GROUND AFTER EVERY EDIT” is the format's own #1 rule; the model carries the bookkeeping |
| Repeated / identical text | Every line gets a DISTINCT anchor (collision-resolved); no ambiguity — copy the exact marker | Position-based, so repeats don't confuse it — but the position itself is unverified |
| Lines never shown to the model | [E_RANGE_UNSERVED] — hard reject with fresh anchors | Undisplayed hunks rejected — same reliance on the model knowing what it saw |
| Mid-expression / wrong block node | Irrelevant — any verified line range is valid | Grammar rules + PUT N*: node choice; mispointing (anchoring def orphans its decorator) silently lands wrong; no syntax check |
| Multi-edit batch fails mid-way | edit's edits array — atomic, all-or-nothing; the failing item is echoed as fresh serves | Multi-section patches preflighted up front — also atomic |
The 42–53% oh-my-pi payload saving is a lighter wire format; the table above is what that format asks the model to hold in its head instead — renumbering, tag-chasing, node choice — the exact component that fails most (46–51% patch-failure rates on replace-style edits). This The exact percentage is being re-measured on the v2.0 variable-length anchors; the price is a contract where a wrong edit cannot land, and any rejection needs no re-read.
Benchmark
Measured on the same 103-line file with the same 12 replacements (8 single-line, 4 multi-line of
3/6/10/15 lines), tokenized with the pinned js-tiktoken cl100k_base. Three arms emit the same
replacements: this plugin's edit in its v2.0 payload shape (edits:[{op, anchor_start, anchor_end, lines}] with two BARE variable-length anchors — exactly 2 chars each at this corpus
size), a str_replace tool (old text echoed verbatim), and
@oh-my-pi/hashline in both of its modes —
one [path#tag] section per edit (seq) and one multi-hunk batch document (batch):
| Criterion | hashline | str_replace | oh-my-pi seq / batch |
|---|---|---|---|
| Replaced text sent over the wire | ✅ never | ❌ every edit | ✅ never |
| Output tokens saved (12-edit session) | ✅ 26% (v2.0 measured) | ❌ 0% | ✅ 42% / 53% |
| Multi-line range savings (3–15 lines) | ✅ 29–47% | ❌ 0% | ✅ 40–53% |
| Effective cost at 5× output pricing | ✅ ~1.4× less | ❌ 1× | ✅ ~1.7× / ~2.1× less |
| Ranges verified against served state | ✅ 100% | ❌ none | ~ file version only |
| Line numbers the model must track | ✅ none — content anchors | ✅ none — text match | ❌ renumber every edit |
| Deterministic, reproducible locally | ✅ npm run benchmark | — | — |
Reproducible
The numbers above are deterministic and you can reproduce them locally — npm run benchmark:
| Scenario | Lines | hashline | str_replace | oh-my-pi seq | oh-my-pi batch |
|---|---|---|---|---|---|
| single-line ×8 | 1 | 349 | 324 | 241 | — |
| multi-line ×4 | 3–15 | 404 | 691 | 349 | — |
| TOTAL ×12 | 753 | 1015 | 590 | 480 |
Saved vs str_replace: hashline (v2.0 variable-length anchors) 262 (26%) · oh-my-pi per-edit 425 (42%) · oh-my-pi batch 535 (53%). (The v1.0 fixed 3-char form measured 266/26% — v2.0's edits[] envelope costs a few tokens more while the anchors themselves are shorter; net, a wash.)
The numbers above are measured on the v2.0 variable-length anchor contract (every anchor is 2 chars at this corpus size; payloads use the
edits[]shape). The qualitative conclusion: hashline saves 24–45% on multi-line ranges and is roughly at parity withstr_replaceon the shortest single-line edits (the fixededits[]envelope dominates there), while remaining the only arm that verifies every resolved range against the served state and never lands a wrong-line edit silently. Seebenchmark/README.mdfor the per-scenario breakdown and methodology.
Output-format overhead: text vs json (model input tokens)
benchmark/text-json.mjs measures the model-side INPUT tokens the two output formats feed back into the context (js-tiktoken cl100k_base, same corpus — run node benchmark/text-json.mjs):
| scenario | text | json | json/text |
|---|---|---|---|
| read · whole file (104 lines) | 1,087 | 1,225 | 1.13× |
| read · 50-line window | 578 | 593 | 1.03× |
| read · 10-line window | 233 | 152 | 0.65× |
| edit · 1→1 single line | 180 | 143 | 0.79× |
| edit · shrink 10→2 | 243 | 138 | 0.57× |
| edit · expand 2→10 | 244 | 174 | 0.71× |
json's read dict repeats each anchor key per line (large windows +3–13%); small read windows and ALL edit responses win for json (edits 21–43% cheaper — no header teaching overhead, and the diff dict is keyed by anchor directly).
The script is deterministic by construction: a frozen corpus, a content-addressed edit script that
self-checks (a reformatted corpus throws instead of silently changing what's measured), a pinned
tokenizer, and oh-my-pi payloads validated against the package's published grammar before counting.
Because everything is fixed, npm run benchmark gives everyone the same result — the numbers in
this README are a snapshot of that run; regenerate, don't trust.
Scope & honesty. The benchmark measures request-payload tokens — what the model emits per edit call — with identical read traffic excluded (it cancels) and identical replacement text. It does not model transcription failure and retries, which is where the real-world gap is largest: the original harness-problem post reported a 61% output-token reduction and patch-failure drops from 46–51% to near zero after switching to anchored edits. It also does not model what a line-numbered format costs the model between calls — renumbering and re-fetching the file tag after every edit — nor block-op power, nor the Bun-vs-Node runtime difference, nor the fact that
@oh-my-pi/hashlineis a standalone patcher while this plugin is a dsh tool pair withread/edit/undo. Full methodology, the per-edit table, and the complete limitation list inbenchmark/README.md. The correctness gap behind those numbers is spelled out above in Correctness in edge cases.
Tools
| Tool | What it does |
|---|---|
read | Returns a file as ANCHOR:FILELINE header + <anchor>:<content> rows (anchors are variable-length Base62, unique per line; line_numbers defaults to true (a <line>: prefix is added as a positional hint; line_numbers: false gives bare anchors). Parameters: offset (1-based), limit. Paged output ends with [Showing lines N-M of T. Use offset=… to continue.]. Lines >200KB are shown as a marker with a sed hint — anchors need full lines. |
edit | Applies one or more edits atomically via { path, edits: [{ op, anchor_start, anchor_end?, lines? }, …] }. op is ins (insert lines after anchor_start), del (delete the anchor_start..anchor_end range, or the single anchor_start line when anchor_end is omitted), or replace (swap the anchor_start..anchor_end range with lines — anchor_end optional, defaults to the single anchor_start line; REQUIRED when lines has more than one line). Anchors are variable-length Base62 (<anchor> or <line>:<anchor>); identical content lines get DISTINCT anchors. Verifies each resolved range against served state (anchor + content); [E_RANGE_UNSERVED] / [E_RANGE_UNVERIFIED] / [E_STALE] reject-and-serve fresh anchors. There is no Shift: block — re-read for fresh anchors after an edit. Replaces the legacy batch_edit tool (up to 32 edits per call, per-item path for multi-file). |
grep | Search for a pattern in one or more files. Parameters: path · pattern (JavaScript-flavre regex by default; regex: false for literal substring) · -C N (context rows) · limit. Output mirrors read: one section per file, header + <anchor>:<content> rows carrying the full line (no truncation — a hit is directly editable). Only a row exceeding 200KB is hidden with a sed pointer, exactly like read. Grep is observed + recorded as served so a hit can be edited directly without a separate read. |
undo_last_edit | { path } reverts the last hashline edit, only while the file still matches the stored post-edit content; survives restarts. |
Error codes
| Code | Meaning |
|---|---|
[E_ACCESS] | File exists but is not readable/writable by the tool. |
[E_BAD_OP] | Range end precedes range start (autocorrected when the pair was reversed). |
[E_BAD_REF] | anchor_start/anchor_end is not a variable-length Base62 anchor copied from the leftmost column of a read/grep/diff row. The legacy <line>#<hash> form is rejected. |
[E_BAD_SHAPE] | Request/field shape is wrong (unknown fields, missing path, non-string text, …). |
[E_BARE_HASH_PREFIX] | An anchor-prefixed row pasted into lines (e.g. a <line>:<anchor>:content read/diff row); the prefix is stripped when the anchor exists in the file — with a warning. Literal look-alike content is never rewritten. |
[E_BATCH_ABORT] | A batch item failed; the whole batch was rejected, nothing written. |
[E_BATCH_CONFLICT] | Two batch items' row ranges overlap on the same file snapshot; split or merge them, nothing written (an ins may anchor on a range's END line, never its start/interior). |
[E_INS_ANCHOR_DUP] | op:"ins" lines[0] matches the anchor_start line content — ins inserts after the anchor line (preserved automatically); including it in lines creates a duplicate. Warning only; the edit proceeds. |
[E_LINE_HINT] | A <line>:<anchor> hint disagreed with the anchor's resolved position; the anchor is authoritative and the edit proceeds. |
[E_PASTE_DUP] | A replacement line exactly matches an adjacent file line (possible pasted read/diff row); the line is KEPT verbatim and the edit proceeds — the tool never silently drops content. |
[E_INVALID_PATCH] | Diff-preview +/- markers pasted into lines; the marker prefix is stripped with a warning. |
[E_NOOP_LOOP] | The exact same edit keeps producing no change; resubmitting is rejected. |
[E_OP_INS] | op:"ins" — inserted lines placed after the anchor; informational. |
[E_NOT_FOUND] | File does not exist. |
[E_NOT_OBSERVED] | The file has not been observed in this session (read-before-write policy); call read first. |
[E_NOT_TEXT] | Path is a directory, binary, or non-UTF-8 file; hashline edits only text. |
[E_CONTENT_MISMATCH] | hashline.require_line_content is on and a declared line does not match the anchor's current line; the actual line (and where the declared content lives) is echoed. |
[E_RANGE_STALE] | A served line differs on disk since it was read; the range is echoed fresh. |
[E_RANGE_UNSERVED] | The range includes lines never served to the model. |
[E_RANGE_UNVERIFIED] | Boundary anchor cannot be verified against served state. |
[E_STALE] | The resolved anchor no longer matches the served content (the line changed since it was read); call read for fresh anchors. |
[E_UNDO_STALE] | Cannot undo: the file was modified (or deleted) after the edit. |
[E_UNDO_UNAVAILABLE] | Undo history could not be persisted; the edit was not applied. |
[E_WIN_REPLACE] | Windows atomic replace failed (ReplaceFileW / error 1175): the target is held open by another process (IDE watcher, antivirus, sync) or is write-protected — close it and retry. |
[E_WOULD_EMPTY] | An edit would empty a non-empty file; use write to clear it. |
[E_HASH_SPACE] | Anchor space exhausted (file > 62^8 lines) — practically unreachable; layers auto-expand. |
How It Replaces the Built-in Tools
dsh's tool registry resolves per scope: an agent sees agent → preset → global, and its own
layer always wins. The built-in read/edit live on the agent-preset layer, so a plain global
registration cannot replace them. This plugin:
- Mounts as a host-plane Cordis plugin via its
cordis.patch.ymlbundle patch. - On
agent/session-start, registers the hashline tools and thetool:read/tool:editprompt sections on the agent's own scope layer — they shadow the preset's built-ins for that agent and unwind automatically when the agent is disposed. - Leaves the built-in
writein place, but a scopedtools/post-executelistener appends the hashline auto-read to write results.
Store
Hash snapshots, served-state rows, and undo history live in one SQLite store co-located with the workspace being edited — one store per session cwd:
<workspace>/.dsh_hashline_edittool/hash-store.sqlite
Parallel sessions in different workspaces keep separate stores (the session cwd is carried through
each tool call), so one project's anchors and undo history never leak into another's. Outside a tool
call (tests, previews) the store falls back to the shared DeepSeek Harness home
($DSH_HOME/plugins/dsh-hashline-edittool/hash-store.sqlite).
A 7-day TTL prunes served rows; missing-file snapshots are pruned at startup. Corrupt stores are quarantined and rebuilt automatically. Moving to the per-workspace layout does not migrate earlier undo history from the shared home — treat any pre-0.1.2 undo entries as gone.
Project Structure
dsh-hashline-edittool/
├── src/
: ├── hashline/ # hash + served-state core
: ├── tool-read.ts # read — anchor:content, offset/limit paging, line_numbers opt
: ├── tool-edit.ts # edit — range-by-anchor, reject-and-serve, batch atomic
: ├── tool-batch-edit.ts
: ├── tool-grep.ts # grep — anchor:content under header per file
: ├── tool-undo.ts # undo_last_edit
: ├── sandbox.ts # FsSandboxController mirror (sandbox_permissions/justification)
: ├── write-hook.ts # auto-read appended to write results
: ├── served-store.ts # per-workspace SQLite store (node:sqlite)
: └── workspace.ts # session-cwd AsyncLocalStorage carrier
├── benchmark/ # reproducible hashline-vs-str_replace-vs-oh-my-pi token benchmark
: └── corpus/ # frozen 103-line fixture
├── test/ # 687 tests
├── cordis.patch.yml # bundle patch
└── package.json # dsh.bundle manifest
Development
npm install
npm run typecheck # tsc --noEmit
npm test # vitest run (687 tests)
npm run build # tsc → lib/
npm run benchmark # reproducible token-cost benchmark (benchmark/)
Releasing (tag-first)
npm run release -- 0.2.0 # bump + CHANGELOG move + commit + tag + push → GitHub release
npm publish --registry https://registry.npmjs.org # blocked until the version is tagged
npm run release bumps package.json/lockfile, moves the CHANGELOG [Unreleased] section to the
version, commits, tags vX.Y.Z, and pushes — the tag push creates the GitHub release from the
changelog. npm publish refuses to run until that tag exists (prepublishOnly gate), so every npm
version is always already tagged and released.
The test suite drives the dsh tool builders directly over a local filesystem bridge.
Roadmap
Current state: variable-length content anchors (2-char layer → 3,844 lines, layer growth, session-stable across edits), line-numbers-by-default render (line_numbers: false opts out), grep tool,
per-workspace store, sandbox policy participation, the served-tail truncation fix, reproducible
benchmark, EN + 中文 READMEs, published on npm. Test count: 687 passing.
Next
- Close or justify the gap vs @oh-my-pi/hashline (reference:
../oh-my-pi.md). The sibling patch language is payload-lighter — 42%/53% vs our 26% vsstr_replaceon the benchmark, because a bare patch document skips the JSON envelope we pay per call — and offers four abilities we do not support: syntactic block ops (PUT N*:), registers +REM/MV, one multi-hunk document per change, and a pluggable filesystem. The counterweight is correctness: its line numbers are unverified (a wrong number on a current tag lands silently), every edit renumbers, stale tags trigger best-effort 3-way merge instead of verification, and the grammar raises the model skill floor. Decide each ability reject-or-adopt on its own merits — the payload gap alone is not a reason to switch formats. - Verify 0.1.6 live in a dsh session after the served-tail fix.
- Re-check plugin wiring against the next dsh release (pinned to
0.1.0-rc.6; dsh is in developer preview and promises breaking changes).
Contributing
See CONTRIBUTING.md (or just open an issue). The most valuable contributions right now are more benchmark scenarios and edge-case tests for the served-state verification.
License
MIT License — see LICENSE for details.
Acknowledgments
Hash-anchored editing descends from Can Bölük's The Harness Problem — the post that showed the harness, not the model, is the bottleneck, and that anchored edits beat search-and-replace. This project stands on the shoulders of:
- pi-hashline-edit by RimuruW — the original pi-coding-agent extension that introduced content hashes and collision resolution.
- pi-hashline-edit-pro by YuGiMob — the hardened fork the hashline core here is ported from.
Related reading: Hash anchors + Myers diff + single-token anchors (dirac.run) (a design review of the O(S+R) → O(R) edit-call saving) and an independent hashline-vs-replace benchmark.
Star History
⭐ If hashline editing made your agent edit better, give it a star!
Plugins associés
archify (deepseek-harness)
tt-a1i/archify
WeKnora (dsh-weknora)
tencent/weknora
weknora
tencent/weknora
BrowserSkill (dsh-plugin-browserskill)
tencent/browserskill