# 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 ```sh 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](#releases) for the tag and versioning rules: ```sh 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: ```sh 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: ```yaml # 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 a `Config` schema whose every field is `.volatile()`. That schema *is* the settings page's description — the Harness keys one form per profile entry by that row's `id` (`dsh-recall` in `cordis.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 `#` — 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: ```sh 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. ```yaml # 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_modules` inside 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 ``` ```yaml 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 `status` lists 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 `undefined` for "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: ```sh 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//` — 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 `read` of 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.mjs` is 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`** (default `5m`) 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 status` issues 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: ```jsonc "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 ```sh 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.