dsh-recall
Local-embedding code recall for DeepSeek Harness. One tool, recall, that
answers "where is this handled?" with ranked excerpts — file path, line range,
and a bounded slice of the code — instead of you reading whole files into the
conversation.
Why this exists
A long agent session does not get expensive because the model reasons. It gets expensive because every step re-sends the accumulated transcript, and in a coding session most of that transcript is file contents the agent has already read.
Measured on one real DSH install over 32 days, from the dsh-cost-meter ledger:
| total spend | $160.27 (32 days — $5.01/day, ~$150/month) |
| model calls | 39,804 |
| prompt cache hit rate | 99.18% — caching was already saturated |
| cost from re-reading cached context | 65.1% |
| cost from new input | 16.9% |
| cost from output (60% of it reasoning) | 18.0% |
| share of context that is message history | 96.0% (system 0.8%, tools 3.2%) |
| average context re-read per call | 332,000 tokens |
That reframes the problem. Caching had nothing left to give, and trimming the system prompt or tool definitions would have chased 4% of the context. The money was in how much history each step carried. A tool that adds a 40-line excerpt instead of a 900-line file does not just save those tokens once — it saves them on every subsequent step of the session, because the context it never added is never re-sent.
What it does, measured
Run against ha-runtime (684 indexable files) with the default 8-result limit:
query : how does the occupancy state machine decide a room is occupied
returned : 13,075 chars ~3,269 tokens
source : 78,669 chars ~19,667 tokens across the 4 files the results came from
ratio : 6.0x smaller
All eight results landed inside src/controllers/room/occupancy/state_machine/
— the correct module — best cosine similarity 0.61. Scoring a warm index takes
about a millisecond; the only per-query network cost is one embedding call.
Install
dsh plugin --profile web add git+https://git.notexpectedyet.com/Not-Expected-Yet/dsh-recall.git
That form installs whatever the default branch holds, pinned to a commit in the
profile's lockfile. The semver fragment installs the newest release tag instead,
and is what makes dsh plugin update follow releases — see
Releases for the tag and versioning rules:
dsh plugin --profile web add 'git+https://git.notexpectedyet.com/Not-Expected-Yet/dsh-recall.git#semver:^0.1.0'
To run your own copy instead, add the directory — that becomes a link:
dependency which tracks the working tree, so an edit needs no reinstall:
dsh plugin --profile web add /path/to/dsh-recall
Then point it at your embedding endpoint. Anything Ollama-compatible works, and
the endpoint must be stated because 127.0.0.1:11434 is a convention rather than
a fact:
# your profile's cordis.patch.yml
- id: dsh-recall
name: dsh-recall
config:
embeddingApiBase: http://127.0.0.1:11434 # replace with your endpoint
Or edit every setting from the UI: Plugins in the sidebar → the dsh-recall card → its settings are on that card, below the description. (Not Settings → Built-in plugins: that section is a read-only inventory of what the deployment ships.) The plugin ships two halves, and the section appears only when both are present and the deployment serves the namespace:
- the host half (
index.js) exports aConfigschema whose every field is.volatile(). That schema is the settings page's description — the Harness keys one form per profile entry by that row'sid(dsh-recallincordis.patch.yml), and it skips an entry with no volatile field entirely, so a schema without those markers has no settings surface at all rather than an empty one; - the browser half (
client/client.js) renders that form into the Plugins panel's per-bundle configuration seat,plugins.bundle.config, keyed by the bundle's package name. Registering it is what draws the section; without it there is no settings UI however complete the schema is.
The sibling seat plugins.row.config — keyed <package>#<row id> — draws one
row's configuration on that row's own page. That is the right home for one row
of a bundle that declares several; this bundle declares one, so its settings
belong on the card.
A served namespace with no page draws nothing, and a page whose namespace is not served is deliberately not registered — so both are required. A restart is needed for a newly installed browser half to appear, because client bundle bytes are snapshotted when the profile activates.
Editing in the UI writes to the profile's own patch layer through the settings
document, so the value survives a restart and stays visible in
cordis.patch.yml.
Every value is also checkable from the tool, without touching a file:
recall action=configure config={"embeddingApiBase":"http://127.0.0.1:11434"}
The API key is declared role('secret'), so it is stripped from every settings
response: the page can show that a key is set without ever receiving it, and a
blank draft writes nothing — leaving it alone means "unchanged", never "clear".
Pull the model once:
ollama pull unclemusclez/jina-embeddings-v2-base-code
The tool
recall — three actions plus configuration.
| action | what it does |
|---|---|
search (default) |
answers query, warming a bounded slice of the index first |
index |
builds or refreshes the whole index; background job by default |
status |
index and endpoint state, touching nothing |
configure |
writes settings through the settings document |
Parameters: query, action, limit, path (restrict to a subdirectory),
run_in_background.
What it shows you
Two surfaces, both reading a host projection rather than polling:
- a chip in the session header's utilities —
Recall 3 · 40× · 457 KB kept out. No price on it, deliberately: at the measured rate a whole conversation's avoided context is a fraction of a penny, and a chip advertising that reads as the tool being useless rather than as the arithmetic being honest. The tooltip carries the full comparison. - a right-sidebar Recall tab — the same figures with room for what they are not: "An estimate, and an upper bound: it assumes a whole file would have been read each time. Token counts are bytes ÷ 4."
Both render nothing when there is nothing to say — a conversation that never used
recall costs no layout at all. The chip is an entry in the header's utilities
list, carrying its own id. The far-right corner is deliberately left alone: it is
a single cell that the product's own right-sidebar expand control occupies, so a
registration there shadows shipped UI rather than sitting beside it.
The data path is presentationMeta → the tool/result event's meta → a
projection unit (recallUsage) with a wire view the browser reads through
useProjection. No new event type, no side channel, and nothing leaves the
machine.
The tool description steers the model on when not to use it: prefer grep when
you already know the symbol, read when you need the whole file, and do not
follow up with a read of a range you were just handed. It also claims the one
move it exists to replace: reaching for it before reading files to find out
what a module contains, because that is a search — and one search that warms a
bounded slice of the index costs less than the first round of reads it avoids.
Extra source roots: indexing the code you write against
A workspace index answers questions about the code you are writing. It cannot answer questions about the library that code is written against — the framework, the SDK, the installed platform — because none of that is under the workspace.
Measured on one real plugin-development session, 64 of 80 tool calls went to
the installed harness checkout under /usr/local/lib/node_modules to look up the
contract being implemented: "Read workspace slot contract", "Locate
DirectoryListing definition", "Read UiWorkspace service type". That is the only
authoritative copy of the API, and every one of those reads went into the
conversation the expensive way.
# your profile's cordis.patch.yml
- id: dsh-recall
name: dsh-recall
config:
sourceRoots:
- /usr/local/lib/node_modules/@deepseek-ai/dsh/node_modules/@deepseek-ai
# or, with a chosen label and a workspace-relative path:
# - vendor=../packages/node_modules/@vendor
Registered through the settings form it is a comma-separated list, as every list-valued setting is.
An entry is a path, optionally prefixed with name=:
- an absolute path is taken as given — what a deployment's installed tree needs;
- a relative path resolves against the workspace, which is what makes a checked-in configuration portable;
- the name defaults to the directory's basename and becomes the first segment of every result path from that root, so a result says which tree it came from.
The root is entered; its dependencies are not
A root is usually a directory of packages, which is the one shape the workspace
walk handles worst: node_modules and friends are skipped at every depth, so the
interesting files are exactly the ones it refuses to enter. So the rule is
asymmetric, and the asymmetry is the feature:
- the root itself is entered whatever it is called — a path ending in
node_modules/...is the normal way to name a package-manager tree; - below the root the usual skip list applies, so a
node_modulesinside one of those packages is not indexed. A dependency is somebody else's code, and it is the same code the root already holds.
Measured against the real harness packages (@deepseek-ai): 2,927 files into
11,926 chunks, a 113 ms walk, and zero indexed paths containing
node_modules. A query — "how does a plugin register a settings section the
client renders" — returned its top six hits inside dsh-client-ui-settings*, the
correct module, at 11 KB of excerpts against 373 KB across the 7 files they came
from.
How a result from a root is addressed
The paths a search returns are what the caller hands to read, which resolves
them against the workspace. A file under an extra root is not under the workspace,
so no prefixing makes @dsh/dsh-agent/lib/index.js readable. Each such result
therefore carries both: the labelled path that says where it came from, and an
absolute sourcePath:
--- 3. @dsh/dsh-client-ui-settings/lib/types/client/contract/slots.d.ts:128-169 similarity 0.47
at /usr/local/lib/node_modules/@deepseek-ai/.../slots.d.ts
Reading that absolute path may still need a scope grant from the caller's own policy — a facet guard, say. This plugin cannot grant one and does not pretend to.
An unusable entry — a typo, a path that is a file rather than a directory, two roots claiming one name, or a name that collides with the active focus — is reported in the result and skipped. One bad entry must not cost the other roots, and it must not look like the feature doing nothing.
Warming it before you need it
An extra root makes the index bigger, and a cold index is the one call that is
slow. index runs as a background job by default for exactly that reason — and
it is what makes the first search of a session cheap:
recall action=index
The job is owned by the session that started it, so its completion reaches that conversation and its cancellation follows the session.
Focus: letting a conversation see less
A workspace-wide index has to rank across everything, and in a codebase built from many near-identical modules that is where embedding similarity fails. On the repository this was written against there are 18 controllers — same idioms, same naming, same shape — across 228k lines, and a query about one returns plausible fragments of all of them.
Narrowing the index to one of them removes the confusability rather than ranking through it:
| median controller scope | 114 chunks, 2.4% of the workspace |
| candidate pool | 4,738 → 114 chunks, a 41.6× smaller haystack |
| cold start (projected from measured throughput) | 3.1 min → 5s |
Set it per call, or configure a default:
recall action=index focus=src/controllers/room/occupancy
focus: src/controllers/room/occupancy # or leave empty to resolve by marker
focusMarkers: [CONTROLLER.md] # files that make a directory its own scope
Resolution is most-explicit-first: an explicit focus, then the nearest
enclosing directory carrying a marker file, then the workspace. The last fallback
is the pre-focus behaviour, so adopting this cannot regress a deployment that
never configures it. recall status lists every scope it can find, so
choosing one is a matter of reading the list.
Two things worth knowing, both measured:
- Scoping hides shared code. 41.6% of that workspace — 1,972 chunks of shared types, services and infrastructure — sits outside every controller. A weak result inside a scope says so and tells you to retry without it, because a scope is a window, not a relevance filter.
- Choosing a scope is explicit, never inferred. A session sitting at the
repository root is not thereby about any one controller, and silently
confining it to whichever scope sorted first would hide code it needs. So
statuslists them and a caller names one.
Two opt-in local-model steps
Both are off by default, and both are built so that any failure is a fallback rather than an error:
| setting | what it does | default |
|---|---|---|
expansionEnabled |
asks the local model for alternative phrasings, ranks each, and fuses the orders | false |
rerankEnabled |
has the local model score the top candidates against the query | false |
They are off because their benefit has not been measured against a real
model, and a feature that spends latency on an unquantified gain is not a
default worth having. Turn them on and run scripts/baseline.mjs if you want to
find out.
Why the failures matter more than the features
Both run on the critical path of a search, against a model on another box that can be slow, wedged, or absent. That is not hypothetical here — the endpoint this was developed against spent the whole project in exactly that state. So the contracts are narrow:
- expansion always returns the original query, first, whatever happens. The worst case is a search with no expansion, never a failed search.
- re-ranking returns
undefinedfor "no opinion", which is deliberately not the same as zero. Replacing a good embedding score with a zero because the model timed out would turn a working search into an empty one. - both use their own deadline inside the caller's signal, so a slow local model gives up and lets the search continue: cancelling mean stop, being slow does not.
- a non-finite timeout is clamped, not propagated —
AbortSignal.timeout(NaN)throws, and the catch below it would report that as "the model had nothing to say". A misconfiguration must not wear the disguise of a slow model.
Tested, including the wedged case: a socket that opens and never answers must give up on its own deadline rather than pass the wait on.
Measuring it
scripts/baseline.mjs is the before/after instrument, and it reads two stores
because they answer different questions:
node scripts/baseline.mjs # human report
node scripts/baseline.mjs --json # machine readable
- the cost ledger knows requests, cache-read tokens, and therefore what each request carried;
- the session projection cache knows the surface each session reached, and whether it used recall at all.
BASELINE
sessions 109
requests 39,706
avg context/request (pooled) 339,182 every request counts once
avg context/request (typical) 155,175 every session counts once
cost $160.11
Both averages are reported on purpose. They differ by a factor of two here, because the largest sessions dominate the pooled figure — quoting only one would mislead in whichever direction it happened to favour. Which to use depends on the question: pooled is what maps to money, typical is what a new conversation should expect.
Sessions that used recall form the "after" group; everything else is the baseline. While no session has used it, the report says so rather than printing a comparison of nothing against nothing.
How it works
Chunking is structure-aware. Splitting on a fixed line count produces units that begin mid-function; their embedding sits between two topics and matches neither. Chunks are cut at column-0 declarations instead, neighbouring small declarations are merged, and a chunk that absorbs several of them is labelled with all of their names — because for a small file that label is the module's only description in the index. Minified and generated files are detected and skipped rather than indexed as noise.
Ranking is hybrid, fused by rank. Pure vector search is weak on code: an
embedding of getUserById sits near every other lookup in the repo. Lexical
scoring is weak in the opposite direction. The two are combined by reciprocal
rank fusion rather than by adding normalised scores — min-max normalising a
cluster of unrelated similarities stretches noise into a confident-looking
ranking, and rank fusion cannot make that mistake.
The index is incremental and lives outside your repository. Files are keyed by
content hash; only changed files are re-embedded. The SQLite index sits in
$DSH_HOME/dsh-recall/workspaces/<key>/ — never in the repo, because for a
300k-line project it is tens of megabytes. Vectors are stored unit-length, so
scoring is a dot product.
Cold start is bounded, and honest. A search reads and hashes everything
(fast), then embeds only what fits its time budget, and reports exactly what is
still pending. apply never blocks on embedding a repository. In the measurement
above, the first search spent its 90s budget on 309 of 684 files (2,257 chunks);
a full index finished the remainder in about 75s more.
Changing the model or the vector width discards the index rather than silently mixing two incompatible vector spaces into one ranking.
Honest limitations
- The 6.0x figure is a comparison, not a guarantee. It measures what the tool returned against the whole files those results came from. An unusually careful agent might have read only one of those files, so treat it as an upper bound on the saving rather than a promise.
- Token figures are
characters / 4. An approximation, not a measurement. - Excerpts are capped at 40 lines by default, so most results in the
measurement above were truncated. The excerpt identifies the location; a
targeted follow-up
readof that range is still sometimes the right move. The saving is in not reading the other 90% of each file. - A locally built index costs minutes of CPU on a large repository. It costs no API money, and only once.
- Retrieval quality is the model's quality. A different embedding model will
rank differently.
scripts/smoke.mjsis the check that the top hit is actually correct. - A file edited since it was embedded is flagged
[stale], not silently trusted.
Operational notes
- The only outbound request this plugin makes is to the embedding endpoint you configure. No source code goes anywhere else, and there is no telemetry.
- The endpoint receives your source code. Point it at infrastructure you trust — that is the whole reason it is explicit configuration rather than a default.
- The workspace walk never follows a symlink, so a link out of the repository cannot cause the machine to be indexed.
- The index is a cache. Deleting
$DSH_HOME/dsh-recall/costs one re-embed and nothing else.
Give the endpoint room
A cold index is a sustained CPU load — hundreds of batched requests. The defaults
(embeddingBatchSize: 16, embeddingConcurrency: 2) are deliberately
conservative, and they were chosen the hard way.
What happened during development. An index run at concurrency 4 against a
small Ollama host that was also holding a generation model resident
(expires_at set to the year 2319 — pinned forever) left the host unable to load
the embedding model. It did not error: it accepted connections and never
answered. ollama ps afterwards showed only the pinned model. That needs a
restart of the Ollama service on that host; nothing this plugin can do reaches it.
Three things now guard against it:
- Lower concurrency and batch size by default. If you raise them, raise them on hardware you know.
embeddingKeepAlive(default5m) keeps the embedding model resident for the length of a run instead of letting it be evicted and reloaded between requests.- A short timeout, and a probe. A wedged endpoint does not refuse a
connection, so a long timeout turns a broken backend into a tool call that
appears to hang.
recall statusissues a single no-retry probe with a 10-second ceiling and reports whether the endpoint answered — which is a different claim from "is configured", and the one worth having.
If status reports endpoint did NOT answer, check ollama ps on that host
before assuming the plugin is at fault.
Releases
.gitea/workflows/release.yml runs on every push to master: it works out the
range since the last tag, bumps package.json, prepends a CHANGELOG.md
section, commits and tags vX.Y.Z, then creates the Gitea release with the
commit subjects as the body.
The bug it fixes is not tidiness. Installed as a bare git+https://…/repo.git,
a plugin is pinned to a commit in the profile's lockfile, so with no tags
there is nothing for dsh plugin update to move to. Point the profile at
#semver:^0.1.0 and the updates follow releases instead:
"dsh-recall": "git+https://git.notexpectedyet.com/Not-Expected-Yet/dsh-recall.git#semver:^0.1.0"
The bump is read from the commit subjects being released:
| in a commit subject | effect |
|---|---|
feat, as its own word (feat:, feat(x):) |
minor — 0.1.0 → 0.2.0 |
feat!: / fix(x)!: / BREAKING |
major — 0.1.0 → 1.0.0 |
fix:, docs:, prose, anything else |
patch — 0.1.0 → 0.1.1 |
[minor] / [major] |
force the bump when the subject cannot carry it |
[skip release] |
this push publishes nothing |
Only the subject line is read, and feat has to be a word — Fix the feature flag leak is a patch, not a minor. With no tags yet, the version already in
package.json is what gets tagged, so the first release matches the code.
The logic lives in .gitea/scripts/release.sh so it runs without a runner:
npm run test:release exercises it — an empty range, a repository with no tags
yet, a feat that is only a word — npm run release:plan says whether the
current HEAD would release, and npm run release:notes prints the notes it
would publish.
Development
npm test # the whole suite; no network required
node scripts/smoke.mjs [workspace] [query] # live retrieval check
node scripts/measure.mjs [workspace] [query] [budgetMs]
Both live scripts default to the endpoint the schema defaults to
(http://127.0.0.1:11434) and to a model ollama pull can fetch, so they need
no configuration on a local Ollama. Point them at another host with
RECALL_API_BASE and RECALL_MODEL; [workspace] defaults to the working
directory.
The lib/ tests run anywhere. test/config.test.js and test/plugin.test.js
import index.js, which imports @deepseek-ai/dsh-tools and
@deepseek-ai/schemastery — resolvable once the plugin is installed into a
profile (or via a local node_modules link to the profile's copies, which the
.gitignore excludes).
test/plugin.test.js validates every tool result against the tool's own
declared output schema, which is additionalProperties: false. That is not
pedantry: the host enforces the schema before the model sees a result, so an
internal field leaking into the returned object is a failed call. It caught
exactly that during development.
test/config.test.js covers the load-time contract. Cordis rejects a Config
before apply ever runs, so a broken schema cannot be reported from inside the
plugin — the profile simply refuses to boot. Every other test in the suite calls
apply directly and would not notice. It also pins the second half of that
contract: that every field is volatile(), because that marker is what makes
the Harness serve a settings page at all, and its absence is invisible from
inside the process.
test/client.test.js runs the browser half in Node by stubbing window,
require('react'), and the settings-form primitives. That is worth more than it
sounds: the failure modes most likely to reach you silently are structural — a
page registered into a slot nobody declares (which is what the 0.1.5 seat,
settings.plugin.item, became), a right-sidebar body with no tab type behind it,
and a field list that has drifted from the schema — and all three are invisible
until someone opens Plugins. It also pins that an untouched API key is never
written, and that the field list and Config name the same settings.