The page was registered into `plugins.row.config`, keyed
`<package>#<row id>`. That seat puts a page behind the ROW's title button on the
bundle's card — the right home for one row of a bundle that declares several,
and the reasoning the previous commit wrote down. What it missed is that this
bundle declares exactly one row, so its settings became reachable only by
opening the card and noticing that the row title had turned into a button.
Reported from a real install as "it shows the plugin just no settings", while
the sibling plugin's settings were visible: that one registers into
`plugins.bundle.config`.
`plugins.bundle.config` is the same document, dispatched on the card itself:
`PackageDetail` gates a section on `ledger.bundles.has(pkg.name)` and renders
`renderSlot("plugins.bundle.config", { view: "page" }, { entryKey: pkg.name })`
between the description and the rows. So the key becomes the bundle's package
name, and the form draws where someone opening "dsh-recall" is already looking.
The section gets no `form` prop at this seat, unlike the row page — which is
fine, because the card reads its own form from `configForms` rather than
depending on the one handed in.
Verified by serving the bundle from a profile that links this checkout and
reading the artifact back: the served `client.js` carries
`plugins.bundle.config` at the registration and names the retired seat only in
prose. The tests pin the new key and now fail if either the seat or the key
drifts.
456 lines
21 KiB
Markdown
456 lines
21 KiB
Markdown
# 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** — `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, which matters for the header corner because the
|
||
framework lays it out only while its occupant renders something.
|
||
|
||
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.
|
||
|
||
## 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.
|