The plugin was developed against an Ollama on a private LAN address, and that address had reached places that ship: - the `config` parameter description the MODEL reads on every request; - the commented example in `cordis.patch.yml`, the layer every profile applies; - `scripts/smoke.mjs` and `scripts/measure.mjs` endpoint defaults; - both measure scripts' default workspace, which was an absolute local path and named a private repository; - README examples and test fixtures. All of them now use `http://127.0.0.1:11434`, which is Ollama's documented default and belongs to no particular machine — kept rather than emptied because `validateSettings` requires an http(s) URL, so an empty default would turn every call into INVALID_SETTINGS until someone configured it. Test fixtures use 192.0.2.10 instead: TEST-NET-1 is reserved for documentation and cannot point at a host anybody runs. The `focus` and `config` examples are generic, and the workspace defaults are the working directory. Two script bugs surfaced once the defaults stopped pointing at a live box, and are fixed here. `measure.mjs` built its own configuration literal, which was ten fields short of the schema — harmless while nothing validated it, and a refused call the moment `execute` began checking the section it was handed; it now resolves its configuration through the plugin's own `Config`, so it cannot drift again. It also crashed on a failed search (`first.index.scanned` of a result that has no index) instead of reporting the endpoint it could not reach, which is now the ordinary case. `scope-measure.mjs` divided by a scope that does not exist in any workspace without focus markers, and now says so. Also corrects `cordis.patch.yml`'s description of what the row registers: in 0.1.7 the exported `Config` is the settings registration, not a namespace on `ctx.settings`.
433 lines
20 KiB
Markdown
433 lines
20 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 /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 → dsh-recall → Configure**. The
|
||
plugin ships **two halves**, and the page 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-row configuration seat, `plugins.row.config`, keyed
|
||
`<bundle package>#<row id>` — here `dsh-recall#dsh-recall`. Registering it is
|
||
what adds the **Configure** control to the plugin's row.
|
||
|
||
A served namespace with no page draws no control, 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.
|