dsh-recall
Русская версия · English
A context-reclaim plugin for the DeepSeek Harness (dsh). A big tool result keeps its first
600 characters in the conversation; the rest is written to disk under a short object id, and the
model can pull any slice of it back with recall("<id>"). A strip under the composer reports the
three numbers that decide whether the trade is worth it: how much was moved out, how many tokens
that kept out of the context, and how often the model actually went back for more.
Why
Measured on 8 504 tool results from real sessions:
| fact | value |
|---|---|
| results larger than 6 KB | 4.6% of results |
| …and their share of all tool-result characters | 40.7% |
| …that are never opened again | 76% |
So the expensive part of a coding session is a handful of huge results that nobody reads twice.
Offloading them removes tokens that would otherwise be re-sent on every later step: the saving
is removed_tokens × remaining_steps × cache_price, not removed_tokens.
Ceiling, stated plainly: tool results are roughly 46% of the context, so offloading all of them still caps at about 18% of the context. On a route that bills cache reads near input price that is a real 20–25% of a long session; on a first-party route with a cheap cache it is single digits, and there the lever is fresh input and step count instead.
What it does, exactly
- Hooks
tools/post-executeand looks at the session surface. - Touches only results older than the newest two (
KEEP = 2) and longer than 2 500 characters (THRESHOLD). - Replaces the result with
head (600 chars) + a [recall] noteusing the same replay-safe pair the stock pruner uses — acompaction/pruneevent that prices and cites the shadowed node, then atool/resultevent withsurfaceOp: {op:'replace'}. The durable log keeps the original. - Writes the full text to
~/.local/share/dsh-recall/objects/<id>.txtand appends one line toindex.jsonl. - Registers the
recalltool:recall(id),recall(id, start, count); default slice 8 000 chars, hard cap 24 000 so a recall cannot re-inflate the context it was built to shrink. Ids are validated (/^[0-9a-f]{6,32}$/i) before the filesystem is touched — the model will invent ids. - Is idempotent: a result that already contains the
[recall]marker is skipped, so restarts and repeated passes do not stack pointers.
What this is not: the 15% compaction
dsh-recall never decides when the context gets compressed and never touches the context window.
The percentage-based compression is the stock @deepseek-ai/dsh-compaction-basic plugin, configured in
the profile's own patch layer (~/.dsh/profiles/<profile>/cordis.patch.yml):
- id: compaction-basic
name: "@deepseek-ai/dsh-compaction-basic"
config:
thresholdRatio: 0.15
retainRatio: 0.05
thresholdRatio is the share of the model's window at which a compaction pass starts; retainRatio is
the share kept afterwards. The stock defaults are 0.8 / 0.16. On this machine the desktop profile
runs 0.15 / 0.05, so a 256k window compacts at ~38k tokens and keeps ~13k — a much smaller working
window than the model allows. That is the knob to raise if you need more room:
thresholdRatio: 0.6 # compact at 60% of the window instead of 15%
retainRatio: 0.15
The two mechanisms are independent and complementary: compaction rewrites the whole history into a
summary once the window fills up, while dsh-recall leaves the history intact and only shortens
individual large tool results — which is what delays the moment compaction has to run. Disabling recall
does not change the 15%, and raising the 15% does not change what recall does.
The pill
A compact pill in shell.overlay that stacks directly above the dsh-tidewatch badge when that
plugin is installed — it reads the tide chip's rect every 500 ms and aligns itself to it, so the two
look like one card, and falls back to the bottom-right corner when tidewatch is absent. Nothing is
shared with tidewatch beyond a CSS class name, so the two plugins stay independent packages with
their own releases. Copy follows the interface language (en/ru). Click to expand the last 12
offloads with tool name, size and age. Data comes from GET /recall/api/stats on the host's
loopback port, polled every 12 s:
{"offloaded":196,"charsSaved":1207034,"tokensSaved":301759,"recalls":0,"threshold":2500,"recent":[…]}
offloaded and charsSaved are recomputed from index.jsonl, so they survive restarts; recalls
is the only counter that lives in memory and is mirrored to counters.json.
Install
Web / TUI profile:
dsh plugin --profile web add github:sh-222/dsh-recall
The Electron desktop profile cannot be managed from the CLI by design. Add the dependency and
the bundle row to ~/.dsh/profiles/desktop/package.json, then install:
{
"dependencies": { "dsh-recall": "github:sh-222/dsh-recall" },
"dsh": { "profile": { "bundles": ["…", "dsh-recall"] } }
}
cd ~/.dsh/profiles/desktop && pnpm install --ignore-scripts
Restart the harness. A plugin only loads at boot.
Installing from a local checkout
dsh plugin --profile <p> add ~/Projects/dsh-recall writes a link: dependency, and node
resolves @deepseek-ai/* from the real path, not through the profile — so a link:ed checkout
dies with ERR_MODULE_NOT_FOUND unless you create the symlinks once:
cd ~/Projects/dsh-recall && mkdir -p node_modules/@deepseek-ai
ln -sfn /usr/lib/node_modules/@deepseek-ai/dsh/node_modules/@deepseek-ai/dsh-llm node_modules/@deepseek-ai/
ln -sfn /usr/lib/node_modules/@deepseek-ai/dsh/node_modules/@deepseek-ai/dsh-tools node_modules/@deepseek-ai/
Tuning the knobs
THRESHOLD and HEAD are constants at the top of index.js. Chosen from the size distribution of
12 real sessions (1 150 results, 487 k tokens), not by feel:
| threshold | head | results hit | saved | share of volume | recall objects |
|---|---|---|---|---|---|
| 6 000 | 1 200 | 40 | 114 k tok | 23.5% | 40 |
| 4 000 | 600 | 103 | 186 k tok | 38.1% | 103 |
| 2 500 | 600 | 218 | 258 k tok | 53.0% | 218 |
| 1 500 | 600 | 368 | 308 k tok | 63.3% | 368 |
2 500 / 600 is the knee: coverage doubles over 6 000 while the object count stays around 18 per
session. Going to 1 500 buys ten points of volume for three times the objects — more chances for the
model to chase a pointer where re-reading would have been cheaper. Never drop HEAD below a few
hundred characters: the model must still see the shape of the result.
How to read the strip: the ratio recalls / offloaded is the experiment. Near zero means the
content was never needed. Close to 1 means the head is too small — raise HEAD to ~2 000 before
touching anything else. Anything in between is the tool earning its keep.
Storage
~/.local/share/dsh-recall/objects/<id>.txt full text, one file per offloaded result
~/.local/share/dsh-recall/index.jsonl {session, seq, tool, id, chars, ts} per offload
~/.local/share/dsh-recall/counters.json {hits, charsSaved, recalls}
One flat pool per $HOME, shared by every project; the owning session is in the index. Ids are
md5(sessionId:seq:length), so identical-sized results from different sessions never collide.
Measured cost: ~8 KB per object, 1.3 MB for a 196-offload session.
There is no rotation. Objects are never deleted. At 8 KB each that is ~120 MB per 10 000
offloads — irrelevant next to the session logs themselves, but if you want a cap, delete
objects/* older than N days and rebuild index.jsonl from what is left.
Honest limits
- A pointer is only useful while the head still tells the model what the result was. That is why the note carries the tool name and the total size.
- Sessions resumed from a previous process start with an empty
callId → tool namemap, so an old result offloaded right after a restart may be labelledtoolinstead ofread. Cosmetic. - Only results already in the surface are touched; the newest two are always intact.
counters.jsonis written by every runtime that mounts the plugin (host and web). Last writer wins, sorecallscan be clobbered by a second instance; the strip's other numbers are rebuilt from the index and stay correct.
Test
npm run check
HOME=$(mktemp -d) node test/reaper.mjs # HOME is overridden so the real state dir is untouched
The test drives the hook with a hand-rolled session: it asserts that only the old, oversized result
is replaced, that the pointer carries the real tool name from the matching tool/call, that a fresh
big result is left alone, and that recall returns the requested slice and bumps the counters.
License
MIT.
No comments yet. Be the first to write one.