Files
dsh-recall/README.md
T
NotExpectedYet de4d020dcb feat: index extra source roots beside the workspace, for the code you write against
The workspace index cannot answer questions about an installed framework or
SDK, and in plugin development that is where most of the reading goes: 64 of
80 tool calls in one real session went to the harness checkout under
/usr/local/lib/node_modules to look up the API being implemented.

A root is entered whatever it is called, and its dependencies are not: a
path ending in node_modules/... is the normal way to name a package tree,
while a node_modules below the root is somebody else's code. A result
carries the label that says which tree it came from and an absolute
sourcePath, the only form a follow-up read can resolve.

The tool description now also claims the move this replaces - searching
before reading files to find out what a module contains.
2026-09-27 07:16:21 +01:00

550 lines
25 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 `<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:
```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/<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 `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.