This commit is contained in:
NotExpectedYet committed 2026-09-26 05:35:29 +01:00
commit 0964819cdc
34 files changed
+7912

No files matched your search

+4
View File
@@ -0,0 +1,4 @@
node_modules/
*.sqlite
*.sqlite-wal
*.sqlite-shm
+372
View File
@@ -0,0 +1,372 @@
# 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://10.50.1.2:11434
```
Or edit every setting from the UI. The plugin ships **two halves**, and the settings
page draws a card only when it has both:
- the **host half** (`index.js`) serves a `dsh-recall` settings namespace;
- the **browser half** (`client/client.js`) claims that same namespace with a
collapsible card under **Settings → Plugins → Plugin configuration**.
A served namespace with no card renders nothing at all, and a card whose namespace
is not served is never dispatched — 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.
Every value is also checkable from the tool, without touching a file:
```
recall action=configure config={"embeddingApiBase":"http://10.50.1.2:11434"}
```
The API key is declared `role('secret')` on the host, so it is stripped from every
settings response — the card can show that a key is set without ever receiving it,
and leaving it blank on save means "unchanged" rather than "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 namespace |
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.
## Development
```sh
npm test # 91 tests, no network required
node scripts/smoke.mjs [workspace] [query] # live retrieval check
node scripts/measure.mjs [workspace] [query] [budgetMs]
```
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.
`test/client.test.js` runs the browser half in Node by stubbing `window` and
`require('react')`. That is worth more than it sounds: the two failure modes most
likely to reach you silently are structural — a root element that is not an `<li>`,
and a card that does not start collapsed — and both are invisible until someone
opens Settings. It also pins that an untouched API key is never written or unset.
+644
View File
@@ -0,0 +1,644 @@
/**
* dsh-recall — browser half: the card in Settings → Plugins → Plugin configuration.
*
* The Host half registers a `dsh-recall` settings namespace. This file registers
* a card under that exact namespace key. Both are required: the tab pairs the
* namespaces the Host serves with the cards the browser claims, so a served
* namespace with no card draws nothing at all, and a card whose namespace is not
* served is never dispatched.
*
* The chrome follows the framework's `PluginCard`: the root is an `<li>` (the tab
* renders every card into a `<ul>`), the header is its own `<button>` that
* discloses the controls in place, the card starts collapsed, it marks itself
* when it holds unsaved edits, and it collapses again after a successful save.
* None of that is imported — `PluginCard` lives in that package's client bundle
* and the client purity gate forbids cross-plugin value imports — so the
* behaviour is reproduced here rather than borrowed.
*
* Hand-written plain JavaScript inside the module-loader wrapper, with no build
* step and no bundler. The only import is `react`, which is in the platform seed
* table. Styling is inline for the same reason: there is no stylesheet this file
* may reach.
*/
window.__ModuleLoader__.load({
id: 'dsh-recall',
factory: (require) => {
var module = { exports: {} }
var exports = module.exports
const react = require('react')
const NS = 'dsh-recall'
const h = react.createElement
const TITLE = 'Recall'
const DESCRIPTION = 'Local embedding endpoint, what gets indexed, and how many tokens a result may cost.'
/**
* The settings this card edits, grouped for scanning.
*
* This list mirrors the Host schema in index.js. It is duplicated rather
* than fetched because the card renders its own controls — there is no
* generic schema-to-form renderer in the framework — and a field the Host
* does not know is rejected there anyway, so drift shows up as a clear error
* rather than as silent data loss.
*/
const FIELDS = [
{
key: 'embeddingApiBase',
group: 'Endpoint',
label: 'Embedding endpoint',
hint: 'e.g. http://127.0.0.1:11434 for Ollama. Must be reachable from the machine running the harness.',
},
{
key: 'embeddingModel',
group: 'Endpoint',
label: 'Embedding model',
hint: 'The model id as the endpoint knows it. Changing this discards the existing index.',
},
{
key: 'embeddingDimensions',
group: 'Endpoint',
label: 'Embedding dimensions',
kind: 'number',
hint: 'Must match what the model returns. Changing this discards the existing index.',
},
{
key: 'embeddingApiKey',
group: 'Endpoint',
label: 'Embedding API key',
kind: 'secret',
hint: 'Stored as a secret and never returned to this page. Leave blank to keep the current value; use Reset to clear it.',
},
{
key: 'embeddingKeepAlive',
group: 'Endpoint',
label: 'Keep-alive',
hint: 'Ollama keep_alive, e.g. 5m. Keeps the model resident for the length of an index run.',
},
{
key: 'embeddingTimeoutMs',
group: 'Endpoint',
label: 'Request timeout (ms)',
kind: 'number',
hint: 'Short on purpose. A wedged endpoint accepts the connection and never answers, so a long timeout looks like a hang.',
},
{
key: 'embeddingBatchSize',
group: 'Endpoint',
label: 'Inputs per request',
kind: 'number',
},
{
key: 'embeddingConcurrency',
group: 'Endpoint',
label: 'Requests in flight',
kind: 'number',
hint: 'Keep this low on a small host. A cold index is a sustained CPU load, and too much of it can leave the endpoint unable to load the model.',
},
{
key: 'extensions',
group: 'What gets indexed',
label: 'Extensions',
kind: 'list',
hint: 'Comma separated. Empty uses the built-in code set (no prose or markup).',
},
{
key: 'exclude',
group: 'What gets indexed',
label: 'Extra exclude patterns',
kind: 'list',
hint: 'Comma separated globs, e.g. src/generated/. Added to the built-in directory list rather than replacing it.',
},
{
key: 'maxFileBytes',
group: 'What gets indexed',
label: 'Skip files larger than (bytes)',
kind: 'number',
hint: 'Generated and minified files are usually large; they are skipped rather than indexed as noise.',
},
{
key: 'maxFiles',
group: 'What gets indexed',
label: 'Most files to consider',
kind: 'number',
},
{ key: 'chunkLines', group: 'Chunking', label: 'Target lines per chunk', kind: 'number' },
{ key: 'maxChunkLines', group: 'Chunking', label: 'Hard cap before splitting', kind: 'number' },
{
key: 'overlapLines',
group: 'Chunking',
label: 'Lines shared when splitting',
kind: 'number',
hint: 'Must be smaller than the hard cap, or a split never advances.',
},
{
key: 'excerptLines',
group: 'Results',
label: 'Lines per excerpt',
kind: 'number',
hint: 'A chunk is a bounded semantic unit, so this defaults to the chunk cap and results come back whole. Truncating below it is how a search turns into a follow-up read that costs more than it saved.',
},
{
key: 'totalExcerptLines',
group: 'Results',
label: 'Total lines across one result set',
kind: 'number',
hint: 'Shared budget, allocated by rank, so the best answer gets the room and the response stays bounded.',
},
{ key: 'defaultLimit', group: 'Results', label: 'Excerpts returned', kind: 'number' },
{ key: 'maxLimit', group: 'Results', label: 'Most a caller may ask for', kind: 'number' },
{
key: 'perFileLimit',
group: 'Results',
label: 'Most chunks from one file',
kind: 'number',
hint: 'Stops one large file filling the whole result set.',
},
{
key: 'minSimilarity',
group: 'Results',
label: 'Weak-match threshold',
kind: 'number',
hint: 'A cosine similarity between 0 and 1. Below this best score, results are reported as a weak match and the model is told to prefer grep.',
},
{
key: 'syncBudgetMs',
group: 'Index',
label: 'Index warm-up budget per search (ms)',
kind: 'number',
hint: 'How long one search may spend embedding changed files before reporting the rest as pending.',
},
{
key: 'stateDir',
group: 'Index',
label: 'Index directory',
hint: 'Empty uses $DSH_HOME/dsh-recall/workspaces/<workspace>.',
},
]
/** Fields the Host redacts, so the page never holds their real value. */
const SECRET_KEYS = new Set(FIELDS.filter((field) => field.kind === 'secret').map((field) => field.key))
const BORDER = '1px solid var(--dsh-border, #3a3a3a)'
const S = {
// The tab renders into a <ul>, so the root must be an <li> or the list
// styling never reaches it.
card: { listStyle: 'none', border: BORDER, borderRadius: '8px', margin: '8px 0', overflow: 'hidden' },
cardOpen: { background: 'var(--dsh-surface, rgba(255,255,255,0.02))' },
header: {
display: 'flex',
alignItems: 'center',
gap: '12px',
width: '100%',
padding: '12px 16px',
border: 'none',
background: 'transparent',
color: 'inherit',
textAlign: 'left',
cursor: 'pointer',
font: 'inherit',
},
headText: { display: 'flex', flexDirection: 'column', gap: '2px', flex: '1 1 auto', minWidth: 0 },
name: { fontSize: '14px', fontWeight: 600 },
description: { fontSize: '11px', opacity: 0.65, lineHeight: 1.45 },
pending: {
flex: '0 0 auto',
fontSize: '10px',
textTransform: 'uppercase',
letterSpacing: '0.04em',
padding: '2px 6px',
borderRadius: '4px',
border: BORDER,
opacity: 0.8,
},
chevron: { flex: '0 0 auto', fontSize: '10px', opacity: 0.7, transition: 'transform 120ms ease' },
chevronOpen: { transform: 'rotate(180deg)' },
body: { padding: '0 16px 16px 16px' },
readOnly: { fontSize: '11px', opacity: 0.75, marginTop: 0, marginBottom: '12px' },
group: {
fontSize: '10px',
textTransform: 'uppercase',
letterSpacing: '0.06em',
opacity: 0.55,
marginTop: '18px',
marginBottom: '2px',
paddingBottom: '4px',
borderBottom: BORDER,
},
hint: { fontSize: '11px', opacity: 0.65, marginTop: '2px', lineHeight: 1.45 },
row: { marginTop: '12px' },
label: { display: 'block', fontSize: '12px', fontWeight: 500, marginBottom: '4px' },
input: {
width: '100%',
boxSizing: 'border-box',
padding: '6px 8px',
fontSize: '12px',
borderRadius: '6px',
border: BORDER,
background: 'transparent',
color: 'inherit',
},
actions: { display: 'flex', gap: '8px', alignItems: 'center', marginTop: '16px', flexWrap: 'wrap' },
button: {
padding: '6px 12px',
fontSize: '12px',
borderRadius: '6px',
border: BORDER,
background: 'transparent',
color: 'inherit',
cursor: 'pointer',
},
primary: { fontWeight: 600 },
note: { fontSize: '11px', opacity: 0.75, marginLeft: 'auto' },
footer: { fontSize: '11px', opacity: 0.6, marginTop: '16px', lineHeight: 1.5 },
}
/** The editable string form of one field's value. */
function toText(field, value) {
if (field.kind === 'list') return Array.isArray(value) ? value.join(', ') : ''
if (value === undefined || value === null) return ''
return String(value)
}
/** The value to write for one field, from its string form. */
function fromText(field, text) {
const trimmed = typeof text === 'string' ? text.trim() : ''
if (field.kind === 'number') return Number(trimmed)
if (field.kind === 'list') {
return trimmed === ''
? []
: trimmed
.split(/[,\n]/)
.map((part) => part.trim())
.filter((part) => part !== '')
}
return text
}
/** Build a draft of every field from a resolved section. */
function toDraft(value) {
const draft = {}
for (const field of FIELDS) draft[field.key] = toText(field, value?.[field.key])
return draft
}
function Field(props) {
const { field, value, overridden, disabled, onChange } = props
const common = {
style: S.input,
value,
disabled,
'aria-label': field.label,
onChange: (event) => onChange(event.target.value),
}
return h(
'div',
{ style: S.row },
h(
'label',
{ style: S.label },
field.label,
overridden ? h('span', { style: { opacity: 0.6, fontWeight: 400 } }, ' · overridden') : null,
),
h('input', {
...common,
type: field.kind === 'secret' ? 'password' : 'text',
...(field.kind === 'secret' ? { placeholder: '(unchanged)' } : {}),
}),
field.hint === undefined ? null : h('div', { style: S.hint }, field.hint),
)
}
function Card(props) {
const scope = props.scope
const [snapshot, setSnapshot] = react.useState(() => scope.getSnapshot())
const [draft, setDraft] = react.useState(null)
const [busy, setBusy] = react.useState(false)
const [note, setNote] = react.useState('')
// Collapsed on mount, like every other card in the tab.
const [open, setOpen] = react.useState(false)
react.useEffect(() => scope.subscribe(() => setSnapshot(scope.getSnapshot())), [scope])
const status = snapshot.status
const value = snapshot.value
const baseline = react.useMemo(() => toDraft(value), [value])
const current = draft ?? baseline
const user = snapshot.user
if (status === 'unavailable') {
// A deployment without the Host half, or a remote browser: show no trace.
return null
}
const dirtyKeys = FIELDS.filter((field) => current[field.key] !== baseline[field.key]).map((field) => field.key)
const overriddenKeys = user !== null && typeof user === 'object' ? Object.keys(user) : []
const writable = snapshot.writable === true
const save = async () => {
setBusy(true)
setNote('')
try {
for (const field of FIELDS) {
const next = current[field.key]
if (next === baseline[field.key]) continue
const trimmed = String(next).trim()
// A redacted secret always reads as blank. Blank therefore means
// "leave it alone", not "clear it" — otherwise opening this card and
// pressing Save would silently delete the key.
if (SECRET_KEYS.has(field.key) && trimmed === '') continue
// Clearing any other field is an unset, not a write of "", so the
// value re-inherits the composition layer instead of becoming empty.
if (trimmed === '') await scope.unset(field.key)
else await scope.set(field.key, fromText(field, next))
}
setDraft(null)
setNote('Saved.')
// A landed save folds the card back up. A failed or blocked one stays
// open with the drafts, which is the whole point of staging them.
setOpen(false)
} catch (error) {
setNote(`Save failed: ${error?.message ?? error}`)
} finally {
setBusy(false)
}
}
const reset = async () => {
setBusy(true)
setNote('')
try {
for (const key of overriddenKeys) await scope.unset(key)
setDraft(null)
setNote('Reset to the deployment defaults.')
} catch (error) {
setNote(`Reset failed: ${error?.message ?? error}`)
} finally {
setBusy(false)
}
}
const rows = []
let group
for (const field of FIELDS) {
if (field.group !== group) {
group = field.group
rows.push(h('div', { key: `group-${group}`, style: S.group }, group))
}
rows.push(
h(Field, {
key: field.key,
field,
value: current[field.key],
overridden: overriddenKeys.includes(field.key),
disabled: busy || !writable,
onChange: (next) => setDraft({ ...current, [field.key]: next }),
}),
)
}
return h(
'li',
{ style: open ? { ...S.card, ...S.cardOpen } : S.card },
h(
'button',
{
type: 'button',
style: S.header,
'aria-expanded': open,
'aria-label': `${open ? 'Collapse' : 'Expand'}: ${TITLE}`,
onClick: () => setOpen(!open),
},
h(
'span',
{ style: S.headText },
h('span', { style: S.name }, TITLE),
h('span', { style: S.description }, DESCRIPTION),
),
// Marked on the header, so unsaved edits are visible while collapsed.
dirtyKeys.length > 0 ? h('span', { style: S.pending }, 'unsaved') : null,
h('span', { style: open ? { ...S.chevron, ...S.chevronOpen } : S.chevron }, '▾'),
),
open
? h(
'div',
{ style: S.body },
writable ? null : h('p', { style: S.readOnly, role: 'status' }, 'This namespace is read-only here.'),
...rows,
h(
'div',
{ style: S.actions },
h(
'button',
{ type: 'button', style: { ...S.button, ...S.primary }, disabled: busy || dirtyKeys.length === 0, onClick: save },
dirtyKeys.length === 0 ? 'Save' : `Save ${dirtyKeys.length} change${dirtyKeys.length === 1 ? '' : 's'}`,
),
h(
'button',
{ type: 'button', style: S.button, disabled: busy || overriddenKeys.length === 0, onClick: reset },
`Reset ${overriddenKeys.length} override${overriddenKeys.length === 1 ? '' : 's'}`,
),
note === '' ? null : h('span', { style: S.note }, note),
),
h(
'p',
{ style: S.footer },
'Values are checked when saved, not when next used. The endpoint is not contacted here — run ' +
'recall with action "status" to probe it.',
),
)
: null,
)
}
/**
* Register the card, but only once a settings transport exists.
*
* `ctx.inject(['settingsScope'], …)` rather than a top-level `inject`,
* because a required dependency that is unsatisfied means this plugin never
* applies at all — the card would vanish along with everything else instead
* of simply not appearing.
*/
/** The projection key the host half registers. */
const USAGE_KEY = 'recallUsage'
/** Byte counts a person reads at a glance. */
function humanBytes(bytes) {
if (!Number.isFinite(bytes) || bytes <= 0) return '0'
if (bytes < 1024) return bytes + ' B'
if (bytes < 1024 * 1024) return Math.round(bytes / 1024) + ' KB'
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
}
/**
* Read the usage projection, whatever the slot can offer.
*
* Called unconditionally and before any early return, because it is a hook:
* a conditional call would break the moment the chip had nothing to show,
* which is its normal state in a conversation that has not used the tool.
*/
function useUsage(props) {
if (typeof props.useProjection !== 'function') return undefined
return props.useProjection(USAGE_KEY)
}
/**
* The one-line summary: what it kept out, not what it cost.
*
* The dollar figure is deliberately absent here. At the measured cache-read
* rate a whole conversation's avoided context is worth a fraction of a
* penny, and a chip advertising that would be read as the tool being
* useless rather than as the arithmetic being honest. The detail lives in
* the tab and in the tooltip.
*/
function chipText(usage) {
const parts = ['Recall ' + usage.searches]
if (usage.compression > 1) parts.push(usage.compression + '\u00d7')
if (usage.avoidedBytes > 0) parts.push(humanBytes(usage.avoidedBytes) + ' kept out')
return parts.join(' \u00b7 ')
}
/** Everything a hover can hold, including what is not worth a chip. */
function chipDetail(usage) {
const lines = [
usage.searches + ' search' + (usage.searches === 1 ? '' : 'es') + (usage.indexes ? ', ' + usage.indexes + ' index' : ''),
usage.excerpts + ' excerpt' + (usage.excerpts === 1 ? '' : 's') + ' from ' + usage.files + ' file' + (usage.files === 1 ? '' : 's'),
humanBytes(usage.returnedBytes) + ' returned where ' + humanBytes(usage.sourceBytes) + ' would have been read',
]
if (usage.rereadTokens > 0) {
lines.push(
'about ' + usage.rereadTokens.toLocaleString() + ' tokens re-read since, if those files had been read in full',
)
}
lines.push('estimate: reading every file the excerpts came from, not a measurement of what you would have done')
return lines.join('\n')
}
function RecallChip(props) {
const usage = useUsage(props)
// The corner is laid out only while its occupant renders something, so a
// conversation that never called the tool costs no layout at all.
if (usage === undefined || !usage.searches) return null
return h(
'span',
{
title: chipDetail(usage),
'aria-label': chipText(usage) + ' \u2014 recall usage for this conversation',
style: {
fontSize: '11px',
opacity: 0.75,
whiteSpace: 'nowrap',
padding: '2px 8px',
borderRadius: '999px',
border: '1px solid var(--dsh-border, #3a3a3a)',
},
},
chipText(usage),
)
}
function RecallTabTitle() {
return h('span', { style: { fontSize: '12px' } }, 'Recall')
}
/** One labelled figure in the panel. */
function Figure(props) {
const { label, value, note } = props
return h(
'div',
{ style: { marginBottom: '10px' } },
h('div', { style: { fontSize: '11px', opacity: 0.6 } }, label),
h('div', { style: { fontSize: '15px', fontWeight: 600 } }, value),
note === undefined ? null : h('div', { style: { fontSize: '11px', opacity: 0.6, lineHeight: 1.4 } }, note),
)
}
function RecallPanel(props) {
const usage = useUsage(props)
if (usage === undefined) {
// A host without the projection, or a session that predates it. Say so
// rather than showing zeroes, which would read as "this did nothing".
return h('div', { style: { padding: '16px', fontSize: '12px', opacity: 0.7 } }, 'Recall usage is not available for this conversation.')
}
if (!usage.searches && !usage.indexes) {
return h(
'div',
{ style: { padding: '16px', fontSize: '12px', opacity: 0.7, lineHeight: 1.5 } },
'This conversation has not used recall yet. It returns ranked excerpts instead of whole files, so context it keeps out is never re-sent on a later step.',
)
}
const head = usage.compression > 1 ? usage.compression + '\u00d7' : '\u2014'
return h(
'div',
{ style: { padding: '16px', fontSize: '12px' } },
h(Figure, {
label: 'Kept out of this conversation',
value: humanBytes(usage.avoidedBytes),
note: humanBytes(usage.returnedBytes) + ' returned, where reading those files would have been ' + humanBytes(usage.sourceBytes),
}),
h(Figure, {
label: 'Smaller than reading the files',
value: head,
note: 'The excerpt is a slice; the comparison is against the whole of each file it came from.',
}),
h(Figure, {
label: 'Re-read since',
value: usage.rereadTokens > 0 ? '~' + usage.rereadTokens.toLocaleString() + ' tokens' : 'not yet',
note: 'Context a conversation carries is re-sent on every later step. Avoided context is avoided every time.',
}),
h(Figure, {
label: 'Searches',
value: String(usage.searches) + (usage.indexes ? ' + ' + usage.indexes + ' indexed' : ''),
note: usage.excerpts + ' excerpts from ' + usage.files + ' files' + (usage.weak ? '; ' + usage.weak + ' too weak to use' : ''),
}),
h(
'p',
{ style: { fontSize: '11px', opacity: 0.6, lineHeight: 1.5, marginTop: '4px' } },
'An estimate, and an upper bound: it assumes a whole file would have been read each time. Token counts are bytes \u00f7 4.',
),
)
}
function apply(ctx) {
// The chip and the panel are session-scoped, so the framework hands them
// `useProjection` as a standard prop — the same wire view the host
// projection registers. Both render nothing when there is nothing to say.
ctx.slots.inject('conversation.session.header.corner', () =>
ctx.slots.register({ name: 'conversation.session.header.corner' }, (props) => h(RecallChip, props)),
)
ctx.slots.inject('sidebar.right.pane.tab', () =>
ctx.slots.register({ name: 'sidebar.right.pane.tab', key: NS }, (props) => h(RecallPanel, props)),
)
ctx.slots.inject('sidebar.right.pane.tab.title', () =>
ctx.slots.register({ name: 'sidebar.right.pane.tab.title', key: NS }, () => h(RecallTabTitle)),
)
ctx.inject(['settingsScope'], (scoped) => {
const binder = scoped.settingsScope
if (binder === undefined) return
const scope = binder.bind({ namespace: NS })
scoped.slots.inject('settings.plugin.item', () =>
scoped.slots.register(
{
name: 'settings.plugin.item',
key: NS,
inject: () => ({ scope }),
},
(props) => h(Card, props),
),
)
})
}
module.exports = { name: NS, inject: ['slots'], apply }
return module.exports
},
})
+21
View File
@@ -0,0 +1,21 @@
# The layer this bundle contributes to a dsh profile that lists it.
#
# One row: the dsh-recall host plugin, which registers exactly one tool
# (`recall`) on `ctx.tools` and one settings namespace on `ctx.settings`.
#
# There is deliberately NO `config:` block. Every value the plugin needs is
# either discovered at runtime (the workspace, from the calling session) or
# carried by a schema default that is honest for any machine. The embedding
# endpoint is the one field a real deployment usually has to state, because the
# Ollama default of 127.0.0.1 is a convention rather than a fact — so it belongs
# in the profile's own patch layer, where the machine's address is known, and
# not baked into a layer that other machines also apply:
#
# - id: dsh-recall
# name: dsh-recall
# config:
# embeddingApiBase: http://10.50.1.2:11434
- insert:
- id: dsh-recall
name: dsh-recall
+1279
View File
File diff suppressed because it is too large. Load diff
+273
View File
@@ -0,0 +1,273 @@
/**
* Splitting source into retrievable units.
*
* This is where retrieval quality is decided, and the rule that matters is that
* **a chunk must be a thing a person would name**. Splitting on a fixed line
* count produces units that begin mid-function and end mid-conditional; their
* embedding sits between two topics and matches neither, so the search returns
* plausible-looking noise. Splitting at declarations produces units whose
* embedding is about one subject, which is the entire premise of the retrieval.
*
* The anchor set is deliberately shallow — column-0 declarations only. A real
* parser would place boundaries better, but it would also mean shipping a
* tree-sitter grammar per language and keeping ten of them current, for a gain
* that only shows up on languages with heavy nesting. Since a chunk is a
* *search result* and not an edit target, a boundary that is roughly right costs
* a few wasted lines and nothing else.
*
* Fixed-width windows remain as the fallback, because a file whose structure the
* anchor set cannot see — a minified bundle, a template, an unusual dialect —
* must still be searchable rather than silently absent from the index.
*/
/** Lines a chunk aims for when merging neighbouring declarations. */
export const DEFAULT_CHUNK_LINES = 48
/** Lines a chunk may never exceed; longer ones are split with overlap. */
export const DEFAULT_MAX_CHUNK_LINES = 80
/** Lines shared between the halves of a split chunk, so a boundary is never lost. */
export const DEFAULT_OVERLAP_LINES = 8
/** Chunks taken from one file, so one pathological file cannot dominate the index. */
export const DEFAULT_MAX_CHUNKS_PER_FILE = 250
/** Files whose mean line length exceeds this are treated as generated/minified. */
const MINIFIED_MEAN_LINE = 400
/** Anchor patterns by language family, applied to the raw line. */
const FAMILIES = {
javascript: /^(?:export\s+)?(?:default\s+)?(?:async\s+)?(?:function|class|const|let|var|interface|type|enum|namespace|declare)\b/,
python: /^(?:async\s+)?(?:def|class)\s+[A-Za-z_]/,
go: /^(?:func|type|var|const)\b/,
rust: /^(?:pub(?:\([^)]*\))?\s+)?(?:async\s+)?(?:unsafe\s+)?(?:extern\s+)?(?:fn|struct|enum|impl|trait|mod|const|static|type)\b/,
jvm: /^(?:@\w+|public|private|protected|static|final|abstract|sealed|open|class|interface|enum|record|object|fun|val|var|def)\b/,
dotnet: /^(?:\[[^\]]*\]|public|private|protected|internal|static|final|abstract|sealed|partial|class|interface|enum|record|struct|namespace)\b/,
c: /^(?:class|struct|enum|union|namespace|template|typedef|using|extern)\b|^[A-Za-z_][\w \t*&:<>]*\s+[A-Za-z_]\w*\s*\([^;]*\)\s*(?:const\s*)?$/,
ruby: /^(?:def|class|module)\s/,
php: /^(?:public|private|protected|static|final|abstract|function|class|interface|trait|namespace)\b/,
shell: /^(?:function\s+[A-Za-z_]|[A-Za-z_]\w*\s*\(\)\s*\{)/,
sql: /^(?:CREATE|ALTER|DROP|WITH)\b/i,
markup: /^<(?:template|script|style|svelte:)/,
}
/** Extension to family. Anything unlisted falls back to the window splitter. */
const EXTENSION_FAMILY = {
ts: 'javascript', tsx: 'javascript', mts: 'javascript', cts: 'javascript',
js: 'javascript', jsx: 'javascript', mjs: 'javascript', cjs: 'javascript',
py: 'python', rb: 'ruby', go: 'go', rs: 'rust',
java: 'jvm', kt: 'jvm', kts: 'jvm', scala: 'jvm', swift: 'jvm',
c: 'c', h: 'c', cc: 'c', cpp: 'c', cxx: 'c', hpp: 'c', hh: 'c',
cs: 'dotnet',
php: 'php', pl: 'ruby', lua: 'ruby',
sh: 'shell', bash: 'shell', zsh: 'shell',
sql: 'sql', graphql: 'sql', proto: 'c',
vue: 'markup', svelte: 'markup',
}
/** Name of the declaration on an anchor line, when one can be read off it. */
const SYMBOL_PATTERNS = [
/\b(?:function|class|interface|type|enum|namespace|def|func|fn|struct|trait|impl|mod|const|let|var|record|object|fun|val|module|macro_rules!)\s+([A-Za-z_$][\w$]*)/,
/\b([A-Za-z_$][\w$]*)\s*\([^)]*\)\s*(?:const\s*)?(?:\{|:)?\s*$/,
]
/** The language family for a path, or `undefined` when it has none. */
export function languageOf(path) {
const name = path.slice(path.lastIndexOf('/') + 1).toLowerCase()
const dot = name.lastIndexOf('.')
if (dot <= 0) return undefined
return EXTENSION_FAMILY[name.slice(dot + 1)]
}
/** The declaration regex for a family, or `undefined`. */
export function declarationPattern(family) {
return family === undefined ? undefined : FAMILIES[family]
}
/** The name declared on a line, when one reads off it. */
export function symbolOf(line) {
for (const pattern of SYMBOL_PATTERNS) {
const match = pattern.exec(line)
if (match !== null) return match[1]
}
return undefined
}
/** Declaration names for a freshly built segment. */
function symbolList(symbol) {
return symbol === undefined ? [] : [symbol]
}
/**
* The label for a chunk that may have absorbed several declarations.
*
* A small file merges into a single chunk, and that chunk's label is then the
* file's only description in the index — so dropping every name but the first
* would describe a whole module as whatever its first declaration happened to
* be. The list is capped because beyond a handful of names the label stops
* being a description and starts being noise that dilutes the embedding.
*/
export function symbolLabel(symbols) {
const unique = [...new Set(symbols)]
if (unique.length === 0) return undefined
if (unique.length <= 4) return unique.join(', ')
return `${unique.slice(0, 4).join(', ')} +${unique.length - 4}`
}
/**
* Whether a file looks machine-generated.
*
* A minified bundle is one enormous line; embedding windows taken from it
* produces chunks of arbitrary identifiers that match every query weakly and
* none strongly. The mean line length separates the two cheaply and without
* reading the whole file.
*/
export function isLikelyMinified(content) {
const lines = content.split('\n')
if (lines.length === 0) return true
// Only consider files large enough for the statistic to mean anything.
if (lines.length < 4) {
return content.length > 20_000
}
const total = content.length
return total / lines.length > MINIFIED_MEAN_LINE
}
/** Trim trailing blank lines from a segment, leaving at least one line. */
function trimEnd(lines, start, end) {
let last = end
while (last > start && lines[last].trim() === '') last -= 1
return last
}
/** Split one oversized segment into overlapping windows. */
function splitLong(lines, start, end, maxLines, overlap) {
const out = []
const step = Math.max(1, maxLines - overlap)
for (let at = start; at <= end; at += step) {
const stop = Math.min(end, at + maxLines - 1)
out.push({ startLine: at, endLine: stop, symbol: undefined })
if (stop === end) break
}
return out
}
/**
* Split a file into chunks.
*
* @param {object} input
* @param {string} input.path workspace-relative path, used only for language detection
* @param {string} input.content file text
* @param {number} [input.chunkLines] target chunk size
* @param {number} [input.maxChunkLines] hard cap before splitting
* @param {number} [input.overlapLines] lines shared when splitting
* @param {number} [input.maxChunks] cap on chunks returned for one file
* @returns {{chunks: {startLine: number, endLine: number, symbol?: string, text: string}[], skipped?: string}}
*/
export function chunkFile(input) {
const {
path,
content,
chunkLines = DEFAULT_CHUNK_LINES,
maxChunkLines = DEFAULT_MAX_CHUNK_LINES,
overlapLines = DEFAULT_OVERLAP_LINES,
maxChunks = DEFAULT_MAX_CHUNKS_PER_FILE,
} = input
if (isLikelyMinified(content)) return { chunks: [], skipped: 'generated-or-minified' }
if (content.trim() === '') return { chunks: [], skipped: 'empty' }
const lines = content.split('\n')
const family = languageOf(path)
const pattern = declarationPattern(family)
/** Anchors: line indices that begin a declaration. */
const anchors = []
if (pattern !== undefined) {
for (let at = 0; at < lines.length; at += 1) {
const line = lines[at]
if (line === '' || /^\s/.test(line)) continue
if (pattern.test(line)) anchors.push(at)
}
}
/** Segments: [anchor, next anchor) with trailing blanks trimmed. */
const segments = []
// One anchor is still structure: a module holding a single top-level function
// should carry that function's name rather than lose it to the fallback.
if (anchors.length >= 1) {
if (anchors[0] > 0) anchors.unshift(0)
for (let at = 0; at < anchors.length; at += 1) {
const start = anchors[at]
const rawEnd = at + 1 < anchors.length ? anchors[at + 1] - 1 : lines.length - 1
const end = trimEnd(lines, start, rawEnd)
if (end < start) continue
segments.push({ startLine: start, endLine: end, symbols: symbolList(symbolOf(lines[start])) })
}
} else {
// No usable structure: one segment, and the splitter below does the work.
segments.push({ startLine: 0, endLine: trimEnd(lines, 0, lines.length - 1), symbols: [] })
}
/**
* Merge neighbours that fit together.
*
* A file of one-line getters would otherwise produce one chunk per getter,
* each too small to carry meaning. Merging accumulates every declaration name
* it swallowed, because a chunk tagged only with its first name would be
* embedded under a label describing a fraction of its contents.
*/
const merged = []
let current
for (const segment of segments) {
if (current !== undefined) {
const combined = segment.endLine - current.startLine + 1
if (combined <= chunkLines) {
current.endLine = segment.endLine
current.symbols.push(...segment.symbols)
continue
}
}
current = { startLine: segment.startLine, endLine: segment.endLine, symbols: [...segment.symbols] }
merged.push(current)
}
const out = []
for (const segment of merged) {
const label = symbolLabel(segment.symbols)
const length = segment.endLine - segment.startLine + 1
if (length <= maxChunkLines) {
out.push({ startLine: segment.startLine, endLine: segment.endLine, symbol: label })
continue
}
for (const piece of splitLong(lines, segment.startLine, segment.endLine, maxChunkLines, overlapLines)) {
out.push({ ...piece, symbol: piece.startLine === segment.startLine ? label : undefined })
}
}
const capped = out.length > maxChunks
const kept = capped ? out.slice(0, maxChunks) : out
return {
chunks: kept.map((chunk) => ({
// 1-based, so a result can be handed straight to `read` as offset/limit.
startLine: chunk.startLine + 1,
endLine: chunk.endLine + 1,
...(chunk.symbol === undefined ? {} : { symbol: chunk.symbol }),
text: lines.slice(chunk.startLine, chunk.endLine + 1).join('\n'),
})),
...(capped ? { skipped: 'chunk-cap-reached' } : {}),
}
}
/**
* The exact string that gets embedded for a chunk.
*
* The path is included on purpose. A developer asking "where is auth handled"
* means something closer to "under a path called auth" than to any particular
* line of code, and the path is the only signal in the chunk that carries that.
* The symbol follows for the same reason: it is what the question would name.
*/
export function embedText(path, chunk) {
const header = chunk.symbol === undefined ? path : `${path} :: ${chunk.symbol}`
return `${header}\n${chunk.text}`
}
+268
View File
@@ -0,0 +1,268 @@
/**
* The embedding client.
*
* Speaks the Ollama HTTP API, which also covers any OpenAI-compatible gateway
* that implements `/api/embed`, and is deliberately the *only* outbound call
* this plugin makes. There is no vendor SDK and no configuration surface that
* could redirect code anywhere except the endpoint the operator named.
*
* Two properties the rest of the plugin depends on:
*
* **Every vector is unit length.** Cosine similarity is then a plain dot
* product, which is what makes ranking over tens of thousands of chunks a few
* milliseconds of arithmetic rather than a normalisation pass per comparison.
*
* **A dimension mismatch is fatal and loud.** A vector of the wrong width
* cannot be compared with the others, and silently storing one would corrupt
* the index in a way that looks like bad ranking rather than a broken config —
* the hardest possible failure to diagnose from the outside.
*/
/** Failure carrying a stable code, so the tool can explain rather than dump a stack. */
export class EmbeddingError extends Error {
/**
* @param {string} message human-readable cause
* @param {string} code stable identifier
*/
constructor(message, code) {
super(message)
this.name = 'EmbeddingError'
this.code = code
}
}
/** Characters sent per chunk. Comfortably inside an 8k-token code model's window. */
const MAX_INPUT_CHARS = 24_000
/** Attempts per request, including the first. */
const MAX_ATTEMPTS = 3
/** Base backoff between attempts. */
const RETRY_BASE_MS = 250
/** Truncate one input to the character budget. */
export function clampInput(text) {
return text.length <= MAX_INPUT_CHARS ? text : text.slice(0, MAX_INPUT_CHARS)
}
/** Scale a vector to unit length. A zero vector is returned unchanged. */
export function normalize(vector) {
let sum = 0
for (let at = 0; at < vector.length; at += 1) sum += vector[at] * vector[at]
if (sum === 0) return vector
const scale = 1 / Math.sqrt(sum)
for (let at = 0; at < vector.length; at += 1) vector[at] *= scale
return vector
}
/** Dot product, which is cosine similarity for unit vectors. */
export function dot(left, right) {
let sum = 0
const length = Math.min(left.length, right.length)
for (let at = 0; at < length; at += 1) sum += left[at] * right[at]
return sum
}
/** Sleep that rejects promptly when the caller's signal fires. */
function delay(ms, signal) {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
signal?.removeEventListener('abort', onAbort)
resolve()
}, ms)
const onAbort = () => {
clearTimeout(timer)
reject(signal.reason ?? new Error('aborted'))
}
if (signal?.aborted) onAbort()
else signal?.addEventListener('abort', onAbort, { once: true })
})
}
/**
* Build an embedder bound to one endpoint.
*
* @param {object} options
* @param {string} options.apiBase base URL, without a trailing slash
* @param {string} options.model model id as the endpoint knows it
* @param {number} options.dimensions expected vector width
* @param {string} [options.apiKey] bearer token, when the endpoint wants one
* @param {number} [options.batchSize] inputs per request on the batch endpoint
* @param {number} [options.concurrency] requests in flight
* @param {number} [options.timeoutMs] per-request timeout
* @param {typeof fetch} [options.fetchImpl] injection point for tests
*/
export function createEmbedder(options) {
const {
apiBase,
model,
dimensions,
apiKey = '',
batchSize = 16,
concurrency = 2,
timeoutMs = 30_000,
maxAttempts = MAX_ATTEMPTS,
keepAlive = '',
fetchImpl = fetch,
} = options
const base = apiBase.replace(/\/+$/, '')
const headers = { 'content-type': 'application/json' }
if (apiKey !== '') headers.authorization = `Bearer ${apiKey}`
/**
* Add Ollama's `keep_alive` to a request body, when one is configured.
*
* Two reasons. An index run issues hundreds of requests, and without a
* keep-alive the model can be evicted between them and reloaded from disk
* repeatedly. And on a host already holding another model resident, a load
* that has to wait for memory is exactly the situation where a bounded,
* explicit residency beats Ollama's own default.
*/
const withKeepAlive = (body) => (keepAlive === '' ? body : { ...body, keep_alive: keepAlive })
// Discovered on first use: the batch endpoint is preferred, but an older
// Ollama build answers it with a 404 and then every request pays that
// discovery again. Remembering the answer keeps the fallback cheap.
let batchEndpointWorks = true
/** One HTTP request, with retries on transport faults and 5xx. */
async function request(path, body, signal) {
let lastError
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
if (signal?.aborted) throw signal.reason ?? new Error('aborted')
const timeout = AbortSignal.timeout(timeoutMs)
const combined = signal === undefined ? timeout : AbortSignal.any([signal, timeout])
try {
const response = await fetchImpl(`${base}${path}`, {
method: 'POST',
headers,
body: JSON.stringify(body),
signal: combined,
})
if (response.status === 404 || response.status === 405) {
const error = new EmbeddingError(`${path} is not available on ${base}`, 'ENDPOINT_UNSUPPORTED')
error.status = response.status
throw error
}
if (!response.ok) {
const text = await response.text().catch(() => '')
const error = new EmbeddingError(
`${path} responded ${response.status}${text === '' ? '' : `: ${text.slice(0, 300)}`}`,
'HTTP_ERROR',
)
error.status = response.status
throw error
}
return await response.json()
} catch (error) {
// A 4xx that is not a retryable status is a configuration fault, not a
// transient one, and retrying it just delays the explanation.
if (error instanceof EmbeddingError && error.status !== undefined && error.status < 500) throw error
if (signal?.aborted) throw signal.reason ?? error
lastError = error
if (attempt + 1 < MAX_ATTEMPTS) await delay(RETRY_BASE_MS * 2 ** attempt, signal)
}
}
throw new EmbeddingError(
`could not reach ${base}: ${lastError?.message ?? lastError}`,
'UNREACHABLE',
)
}
/** Check a returned vector's width against the configured one. */
function check(vector, index) {
if (!Array.isArray(vector)) {
throw new EmbeddingError(`response item ${index} was not an array`, 'BAD_RESPONSE')
}
if (dimensions > 0 && vector.length !== dimensions) {
throw new EmbeddingError(
`model "${model}" returned ${vector.length}-dimensional vectors but ${dimensions} are configured`,
'DIMENSION_MISMATCH',
)
}
return normalize(Float32Array.from(vector))
}
/** Embed one batch through whichever endpoint answers. */
async function embedBatch(texts, signal) {
const inputs = texts.map(clampInput)
if (inputs.length === 0) return []
if (batchEndpointWorks) {
try {
const payload = await request('/api/embed', withKeepAlive({ model, input: inputs }), signal)
if (!Array.isArray(payload?.embeddings) || payload.embeddings.length !== inputs.length) {
throw new EmbeddingError(
`/api/embed returned ${payload?.embeddings?.length ?? 0} vectors for ${inputs.length} inputs`,
'BAD_RESPONSE',
)
}
return payload.embeddings.map((vector, index) => check(vector, index))
} catch (error) {
// Only a missing endpoint falls back. A dimension mismatch or a bad
// response is a real fault and must not be papered over by a second
// endpoint that would fail the same way more slowly.
if (!(error instanceof EmbeddingError) || error.code !== 'ENDPOINT_UNSUPPORTED') throw error
batchEndpointWorks = false
}
}
const out = []
for (const input of inputs) {
const payload = await request('/api/embeddings', withKeepAlive({ model, prompt: input }), signal)
out.push(check(payload?.embedding, out.length))
}
return out
}
return {
model,
dimensions,
base,
/** Whether the batch endpoint has been ruled out. */
get batchEnabled() {
return batchEndpointWorks
},
/**
* Embed many texts, preserving order.
*
* @param {string[]} texts inputs
* @param {AbortSignal} [signal] caller cancellation
* @param {(done: number, total: number) => void} [onProgress]
* @returns {Promise<Float32Array[]>} unit vectors in input order
*/
async embed(texts, signal, onProgress) {
const out = new Array(texts.length)
const size = Math.max(1, Math.min(batchSize, texts.length))
const batches = []
for (let at = 0; at < texts.length; at += size) {
batches.push({ start: at, items: texts.slice(at, at + size) })
}
let done = 0
let cursor = 0
const workers = new Array(Math.max(1, Math.min(concurrency, batches.length))).fill(0).map(async () => {
while (cursor < batches.length) {
if (signal?.aborted) throw signal.reason ?? new Error('aborted')
const batch = batches[cursor]
cursor += 1
const vectors = await embedBatch(batch.items, signal)
for (let at = 0; at < vectors.length; at += 1) out[batch.start + at] = vectors[at]
done += vectors.length
onProgress?.(done, texts.length)
}
})
await Promise.all(workers)
return out
},
/**
* Embed one query string.
*
* @param {string} text query
* @param {AbortSignal} [signal] caller cancellation
* @returns {Promise<Float32Array>} unit vector
*/
async embedOne(text, signal) {
const [vector] = await embedBatch([text], signal)
return vector
},
}
}
+171
View File
@@ -0,0 +1,171 @@
/**
* Query expansion, on the local model.
*
* A developer's first query is often a description rather than a name — "how
* does a room decide it is occupied" — and that phrasing shares few tokens with
* the code that answers it. Asking a small local model for a handful of
* alternative phrasings, embedding each, and fusing the rankings costs one local
* generation and a few local embeddings, and is free in money.
*
* ## Every failure is a fallback, never an error
*
* This runs on the critical path of a search, against a model on somebody else's
* box. It can be slow, wedged, or absent. None of those may fail the search, so
* the contract is narrow: **the returned list always contains the original
* query**, and anything that goes wrong simply returns it alone. The caller can
* treat expansion as a bonus that is either there or not.
*
* The internal timeout is deliberately separate from the caller's signal: the
* caller cancelling means stop everything, while the model being slow just means
* search with what we have.
*/
/** Attempts per request. Expansion is a bonus; one more try is enough. */
const MAX_ATTEMPTS = 2
/** Characters of query allowed through. Longer is a paste, not a question. */
const MAX_QUERY_CHARS = 500
/** A variant this long is a paragraph, not a search phrase. */
const MAX_VARIANT_CHARS = 200
/** Variants shorter than this match everything and rank nothing. */
const MIN_VARIANT_CHARS = 3
/**
* Openers that mark a line as the model talking rather than a phrasing.
*
* A small model asked for a list sometimes answers with a sentence about the
* request — "I cannot help with that." A line like that is not a weak variant,
* it is not a variant at all: embedding it would search the codebase for the
* model's apology and rank whatever matched.
*
* `i\b` and the refusal check below are the load-bearing ones. A question-shaped
* variant ("how does a room decide it is occupied") is entirely legitimate, so
* the test is first-person and refusal vocabulary, not sentence punctuation.
*/
const COMMENTARY = /^(?:i\b|i'm\b|i am\b|sorry\b|unfortunately\b|note\b|explanation\b|here\b|sure\b|of course\b|certainly\b|these\b|those\b|the following\b|as an?\b|phrasings?\b|query\b)/i
/** Refusal vocabulary, wherever it sits in the line. */
const REFUSAL = /\b(?:cannot help|can't help|don't have|do not have|not able to|unable to|no way to)\b/i
const PROMPT = `You turn a developer's search query into alternative phrasings that would match source code.
Rules:
- One phrasing per line. No numbering, no bullets, no commentary, no blank lines.
- Use the vocabulary the code itself would use: identifiers, domain nouns, verb phrases.
- Vary the angle: a likely function name, a likely filename fragment, a description of the behaviour.
- Do not repeat the original query.
Query: {{QUERY}}
Phrasings:`
/**
* Reduce one model line to a usable phrase, or nothing.
*
* Models are reliably inclined to number a list, wrap it in quotes, or add a
* closing remark. None of that is worth an error path, so it is all stripped.
*/
export function cleanVariant(line) {
let text = String(line ?? '')
.replace(/^\s*(?:[-*•]|\d+[.)])\s*/, '')
.trim()
// A trailing aside ("this should find the handler") is common; drop it.
text = text.split(/\s+[—–-]{1,2}\s+/)[0].trim()
if (text.length >= 2 && ((text.startsWith('"') && text.endsWith('"')) || (text.startsWith("'") && text.endsWith("'")))) {
text = text.slice(1, -1).trim()
}
if (text.length < MIN_VARIANT_CHARS || text.length > MAX_VARIANT_CHARS) return undefined
if (COMMENTARY.test(text) || REFUSAL.test(text)) return undefined
return text
}
/**
* Parse a model response into alternative queries, best effort.
*
* @param {string} body raw model output
* @param {string} query the original, excluded from the results
* @param {number} count most variants to keep
* @returns {string[]} deduplicated variants, in the order the model gave them
*/
export function parseVariants(body, query, count) {
const seen = new Set([String(query).trim().toLowerCase()])
const out = []
for (const line of String(body ?? '').split('\n')) {
if (out.length >= count) break
const variant = cleanVariant(line)
if (variant === undefined) continue
const key = variant.toLowerCase()
if (seen.has(key)) continue
seen.add(key)
out.push(variant)
}
return out
}
/**
* Build an expander bound to one local model.
*
* @param {object} options
* @param {string} options.apiBase endpoint, no trailing slash
* @param {string} options.model model id
* @param {number} [options.timeoutMs] budget for the whole expansion
* @param {number} [options.count] most variants to ask for
* @param {typeof fetch} [options.fetchImpl] injection point for tests
*/
export function createExpander(options) {
const { apiBase, model, timeoutMs = 8_000, count = 4, fetchImpl = fetch } = options
const base = String(apiBase).replace(/\/+$/, '')
/** One request, bounded by its own deadline. */
async function request(query, budget, signal) {
// A non-finite budget would make AbortSignal.timeout throw, and the caller's
// catch would turn that into a silent fallback — a misconfiguration wearing
// the disguise of a slow model. Clamp instead.
const deadline = AbortSignal.timeout(Number.isFinite(budget) && budget > 0 ? budget : 8_000)
const combined = signal === undefined ? deadline : AbortSignal.any([signal, deadline])
const response = await fetchImpl(`${base}/api/generate`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
model,
prompt: PROMPT.replace('{{QUERY}}', query),
stream: false,
// A handful of short lines; a model allowed to ramble will.
options: { num_predict: 120, temperature: 0.4 },
}),
signal: combined,
})
if (!response.ok) throw new Error(`${base} responded ${response.status}`)
const payload = await response.json()
return typeof payload?.response === 'string' ? payload.response : ''
}
return {
model,
/**
* Alternative phrasings for a query.
*
* @param {string} query the original
* @param {AbortSignal} [signal] caller cancellation
* @returns {Promise<string[]>} the original first, then whatever else worked
*/
async expand(query, signal) {
const original = String(query ?? '').trim()
if (original === '' || original.length > MAX_QUERY_CHARS) return [original]
for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt += 1) {
if (signal?.aborted) return [original]
try {
const body = await request(original, timeoutMs, signal)
return [original, ...parseVariants(body, original, count)]
} catch {
// Slow, wedged, or unreachable all mean the same thing here: search
// with the query we were given rather than failing the call.
if (signal?.aborted) return [original]
}
}
return [original]
},
}
}
+215
View File
@@ -0,0 +1,215 @@
/**
* Focus resolution: which slice of the workspace an agent is confined to.
*
* ## Why this exists
*
* A workspace-wide index has to rank across every part of a repository, and in
* a codebase built from many near-identical modules that is exactly where
* embedding similarity fails. In the repository this was written against there
* are eighteen controllers — each a state machine with the same idioms, the same
* naming and the same shape — spread across 228k lines. A query about one of
* them returns plausible fragments of all eighteen.
*
* Narrowing the index to the one being worked on removes the confusability
* rather than trying to rank through it, and it does three things at once:
*
* 1. **precision** — there are no near-duplicates to outrank the right answer;
* 2. **cold start** — ten files embed in seconds where six hundred took minutes;
* 3. **context** — code the agent never sees is code that is never re-sent on
* every later step, which is where the money actually is.
*
* ## Resolution order
*
* Most explicit wins, and the last fallback is the workspace root — which is the
* behaviour that existed before focus did, so adopting this cannot regress a
* deployment that never configures it.
*
* The marker step is the interesting one: a folder that contains its own
* instructions *is* a scope. A controller directory holding a `CONTROLLER.md`
* becomes the world the agent sees, and the document that describes the module
* also defines its boundary. Nothing needed to be invented for that — the
* convention already means "this folder is self-describing".
*/
import { existsSync, statSync } from 'node:fs'
import { readdir } from 'node:fs/promises'
import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'
/**
* Files whose presence marks a directory as a scope root.
*
* Ordered by how specific they are: a `CONTROLLER.md` is a stronger claim about
* a directory than the `AGENTS.md` that sits at every repository root.
*/
export const DEFAULT_FOCUS_MARKERS = Object.freeze(['CONTROLLER.md', 'DEBUG_UI.md', 'AGENTS.md'])
/** True when `path` is `root` itself or sits underneath it. */
export function isInside(root, path) {
const prefix = root.endsWith(sep) ? root : root + sep
return path === root || path.startsWith(prefix)
}
/** True when `path` exists and is a directory. */
function statIsDirectory(path) {
try {
return statSync(path).isDirectory()
} catch {
return false
}
}
/** Every ancestor of `from` up to and including `stop`, nearest first. */
export function ancestorsTo(from, stop) {
const out = []
let current = resolve(from)
const limit = resolve(stop)
for (;;) {
out.push(current)
if (current === limit) break
const parent = dirname(current)
// A path that is not under `stop`, or a filesystem root, ends the walk
// rather than looping.
if (parent === current || !isInside(limit, parent)) break
current = parent
}
return out
}
/**
* Resolve the focus directory for one session.
*
* @param {object} input
* @param {string} input.workspace absolute workspace root
* @param {string} [input.cwd] directory the session is working from
* @param {string} [input.explicit] a configured or caller-supplied focus
* @param {(path: string) => boolean} [input.isDirectory] injection point for tests
* @param {readonly string[]} [input.markers] scope-marker filenames
* @param {(path: string) => boolean} [input.exists] injection point for tests
* @param {{path: string, marker: string}[]} [input.scopes] scopes found by {@link discoverScopes}
* @returns {{root: string, source: 'explicit'|'scope'|'marker'|'workspace', marker?: string, problem?: string}}
*/
export function resolveFocus(input) {
const {
workspace,
cwd = workspace,
explicit = '',
markers = DEFAULT_FOCUS_MARKERS,
scopes = [],
exists = existsSync,
isDirectory = statIsDirectory,
} = input
// 1. An explicit focus wins, but a bad one is reported rather than silently
// ignored: a typo that quietly fell back to the whole workspace would look
// like the feature doing nothing.
const requested = String(explicit ?? '').trim()
if (requested !== '') {
const absolute = isAbsolute(requested) ? resolve(requested) : resolve(workspace, requested)
if (!isInside(workspace, absolute)) {
return {
root: resolve(workspace),
source: 'workspace',
problem: `focus "${requested}" is outside the workspace`,
}
}
if (!isDirectory(absolute)) {
return {
root: resolve(workspace),
source: 'workspace',
problem: `focus "${requested}" is not a directory`,
}
}
return { root: absolute, source: 'explicit' }
}
// 2. Nothing here adopts a discovered scope automatically, and that is
// deliberate. 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 the session legitimately needs. Discovery
// exists so a caller can be shown the scopes and name one; choosing is an
// explicit act, which is what `focus` is for.
// 3. The nearest ancestor carrying a marker file. `cwd` is normally the
// workspace root, at which point the root's own AGENTS.md matches and the
// result is the workspace — the same answer the old code gave.
const start = isInside(workspace, resolve(cwd)) ? resolve(cwd) : resolve(workspace)
for (const directory of ancestorsTo(start, workspace)) {
for (const marker of markers) {
if (marker !== '' && exists(join(directory, marker))) {
return { root: directory, source: 'marker', marker }
}
}
}
// 4. The workspace.
return { root: resolve(workspace), source: 'workspace' }
}
/** A stable key for one focus directory, for index filenames. */
export function focusKey(workspace, root) {
const rel = relative(resolve(workspace), resolve(root)).split(sep).join('/')
return rel === '' ? '.' : rel
}
/**
* Directories skipped while looking for scopes.
*
* A marker inside a dependency or a build directory describes somebody else's
* project, so discovering it would offer the user a scope they do not own.
*/
const DISCOVERY_SKIP_DIRS = new Set([
'node_modules', '.git', 'dist', 'build', 'out', 'coverage', 'target',
'.next', '.nuxt', '.venv', 'venv', '__pycache__', 'vendor', '.cache',
])
/**
* Find every directory that declares itself a scope.
*
* This exists because walking up from the working directory cannot find a
* marker in a subdirectory, and in the repository this was written for that is
* the whole point: the session sits at the workspace root while the eighteen
* controllers each carry their own instructions further down. Inheritance and
* discovery are different questions — "what contains me?" versus "what is
* available?" — and a scope needs both.
*
* Results are workspace-relative and sorted, so a list shown to a user or a
* model is stable between calls.
*
* @param {object} input
* @param {string} input.workspace absolute workspace root
* @param {readonly string[]} [input.markers] marker filenames
* @param {number} [input.maxDepth] deepest directory level to scan
* @returns {Promise<{path: string, marker: string}[]>} discovered scopes
*/
export async function discoverScopes(input) {
const { workspace, markers = DEFAULT_FOCUS_MARKERS, maxDepth = 8 } = input
const root = resolve(workspace)
const found = []
const walk = async (directory, depth) => {
if (depth > maxDepth) return
let entries
try {
entries = await readdir(directory, { withFileTypes: true })
} catch {
return
}
const names = new Set(entries.filter((entry) => entry.isFile()).map((entry) => entry.name))
for (const marker of markers) {
if (names.has(marker)) {
const rel = relative(root, directory).split(sep).join('/')
found.push({ path: rel === '' ? '.' : rel, marker })
break
}
}
for (const entry of entries) {
if (!entry.isDirectory() || entry.isSymbolicLink()) continue
if (DISCOVERY_SKIP_DIRS.has(entry.name)) continue
await walk(join(directory, entry.name), depth + 1)
}
}
await walk(root, 0)
found.sort((left, right) => (left.path < right.path ? -1 : left.path > right.path ? 1 : 0))
return found
}
+72
View File
@@ -0,0 +1,72 @@
/**
* Where this plugin keeps things on disk.
*
* Everything lives under `$DSH_HOME`, never inside the workspace. That is the
* whole point of the plugin's economics: the index holds a vector per code
* chunk, which for a 300k-line repository is tens of megabytes — several times
* the size of the source it describes. A multi-megabyte binary file appearing
* in a repository the user might commit, or that a `git status` might surface
* mid-task, is a worse outcome than the tokens it saves.
*
* The index is also disposable by construction. It is keyed by content hash, so
* deleting it costs one re-embed and nothing else — which is what makes it safe
* to treat as a cache rather than a document.
*/
import { createHash } from 'node:crypto'
import { homedir } from 'node:os'
import { basename, join, resolve } from 'node:path'
/** Plugin name. Also the `$DSH_HOME/<name>` directory. */
export const PLUGIN_NAME = 'dsh-recall'
/** The dsh home directory: `$DSH_HOME`, else `~/.dsh`. */
export function resolveDshHome(env = process.env) {
const configured = typeof env.DSH_HOME === 'string' ? env.DSH_HOME.trim() : ''
return configured === '' ? join(homedir(), '.dsh') : resolve(configured)
}
/**
* A stable, filesystem-safe key for one workspace.
*
* The basename is for a human reading the directory listing; the hash is what
* actually identifies it, so two checkouts both named `app` cannot collide and
* one checkout moved between parents does not silently inherit the other's
* index.
*/
export function workspaceKey(cwd) {
const absolute = resolve(cwd)
const name =
basename(absolute)
.replace(/[^A-Za-z0-9._-]+/g, '-')
.replace(/^[-.]+|-+$/g, '') || 'workspace'
const hash = createHash('sha256').update(absolute).digest('hex').slice(0, 8)
return `${name}-${hash}`
}
/** Root of this plugin's own data, independent of any workspace. */
export function pluginDataDir(dshHome) {
return join(dshHome, PLUGIN_NAME)
}
/** Per-workspace state directory. */
export function workspaceStateDir(dshHome, cwd) {
return join(pluginDataDir(dshHome), 'workspaces', workspaceKey(cwd))
}
/** The SQLite index for one workspace. */
/**
* The SQLite index for one workspace, optionally narrowed to one focus.
*
* The workspace-wide index deliberately keeps its original path. Focus is
* additive: a deployment that never sets one must find the index it already
* built, not silently re-embed the repository into a new file.
*
* @param {string} dshHome harness home
* @param {string} cwd workspace root
* @param {string} [focus] focus key, `.` for the workspace itself
*/
export function indexFile(dshHome, cwd, focus = '.') {
const base = workspaceStateDir(dshHome, cwd)
return focus === '.' ? join(base, 'index.sqlite') : join(base, 'focus', focus, 'index.sqlite')
}
+337
View File
@@ -0,0 +1,337 @@
/**
* Ranking retrieved chunks.
*
* **Pure vector search is not good enough for code, and this is the whole reason
* the module exists.** An embedding of `getUserById` sits close to every other
* lookup function in the repository, so a query naming it returns ten plausible
* lookups and buries the actual definition. A keyword match has the opposite
* failure: it finds the definition instantly and misses the query that describes
* behaviour without naming it.
*
* The two are combined by **reciprocal rank fusion** rather than by adding
* normalised scores. Cosine similarity and a term-frequency score live on
* different scales, and the usual fix — min-max normalising each into [0,1] —
* is actively harmful here: when every candidate is unrelated, the similarities
* are tightly clustered, and stretching that cluster across the full range turns
* noise into a confident-looking ranking. Rank fusion is scale-free, so it
* cannot make that mistake, and a chunk found by only one of the two methods
* still surfaces.
*/
/** RRF damping. 60 is the value from the original TREC work; larger flattens. */
const RRF_K = 60
/** Terms shorter than this carry no signal in code and match everywhere. */
const MIN_TERM_LENGTH = 2
/** Words that appear in almost every source file and therefore rank nothing. */
const STOPWORDS = new Set([
'the', 'and', 'for', 'with', 'that', 'this', 'from', 'into', 'when', 'then',
'const', 'let', 'var', 'function', 'return', 'import', 'export', 'class',
'def', 'self', 'new', 'use', 'are', 'was', 'were', 'not', 'but', 'you', 'how',
'does', 'what', 'where', 'which', 'who', 'why', 'can', 'should', 'would',
])
/** Lowercased haystacks, keyed by the matrix object they describe. */
const haystacks = new WeakMap()
/**
* Split an identifier into its words.
*
* `getUserById` must match the query terms `get`, `user`, `by` and `id`, because
* a caller writing a search phrase naturally writes them separately. Without
* this, the single most useful lexical signal in a codebase — a name spelled in
* camelCase — is invisible.
*
* @param {string} identifier
* @returns {string[]} lowercased words
*/
export function splitIdentifier(identifier) {
return identifier
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
.replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')
.split(/[^A-Za-z0-9]+/)
.filter((word) => word !== '')
}
/**
* The search terms of a query.
*
* Both the raw tokens and the sub-words of each identifier are kept, so a query
* of `getUserById` matches a chunk that spells it `get_user_by_id` as well as
* one that spells it identically.
*
* @param {string} query
* @returns {string[]} de-duplicated terms, longest first
*/
export function queryTerms(query) {
const found = new Set()
for (const raw of String(query).split(/[^A-Za-z0-9_$]+/)) {
if (raw === '') continue
const lower = raw.toLowerCase()
if (lower.length >= MIN_TERM_LENGTH && !STOPWORDS.has(lower)) found.add(lower)
if (raw.length >= MIN_TERM_LENGTH) {
for (const word of splitIdentifier(raw)) {
const part = word.toLowerCase()
if (part.length >= MIN_TERM_LENGTH && !STOPWORDS.has(part)) found.add(part)
}
}
}
return [...found].sort((left, right) => right.length - left.length)
}
/** The lowercased text a chunk is matched against, built once per matrix. */
function haystackFor(matrix, row) {
let list = haystacks.get(matrix)
if (list === undefined) {
list = new Array(matrix.rows.length)
haystacks.set(matrix, list)
}
if (list[row.id] === undefined) {
const symbol = row.symbol === undefined ? '' : ` ${row.symbol}`
list[row.id] = `${row.path}${symbol} ${row.text}`.toLowerCase()
}
return list[row.id]
}
/**
* Score every chunk against the query terms.
*
* Matches in the path or the symbol are weighted above matches in the body,
* because a question about behaviour usually names the thing, and the name is
* what the path and symbol carry.
*
* @param {object} matrix
* @param {string[]} terms
* @returns {{lexical: Float64Array, matched: Uint8Array}} raw scores and a hit flag
*/
export function lexicalScores(matrix, terms) {
const count = matrix.rows.length
const lexical = new Float64Array(count)
const matched = new Uint8Array(count)
if (terms.length === 0) return { lexical, matched }
// Document frequency per term, for the inverse-document-frequency weight.
const frequency = new Map()
for (const term of terms) {
let seen = 0
for (let at = 0; at < count; at += 1) {
if (haystackFor(matrix, matrix.rows[at]).includes(term)) seen += 1
}
frequency.set(term, seen)
}
for (let at = 0; at < count; at += 1) {
const row = matrix.rows[at]
const hay = haystackFor(matrix, row)
const header = `${row.path} ${row.symbol ?? ''}`.toLowerCase()
let score = 0
for (const term of terms) {
const df = frequency.get(term) ?? 0
if (df === 0) continue
// Standard IDF, damped so a term present in every file still contributes.
const idf = Math.log(1 + count / (1 + df))
let occurrences = 0
let from = hay.indexOf(term)
while (from !== -1) {
occurrences += 1
from = hay.indexOf(term, from + term.length)
}
if (occurrences === 0) continue
const weight = header.includes(term) ? 3 : 1
score += idf * (1 + Math.log(occurrences)) * weight
}
if (score > 0) {
lexical[at] = score
matched[at] = 1
}
}
return { lexical, matched }
}
/** Indices ordered by score, descending, ties broken by index for determinism. */
function orderBy(score) {
const indices = Array.from({ length: score.length }, (_, at) => at)
indices.sort((left, right) => score[right] - score[left] || left - right)
return indices
}
/**
* The excerpt a result shows.
*
* Head-truncated rather than tail-truncated: a function's signature, its
* parameters and its first statements are what identify it, while the tail of a
* long function is usually a closing brace and a return.
*
* @param {string} text chunk text
* @param {number} maxLines cap
* @returns {{excerpt: string, truncated: boolean}}
*/
export function excerptOf(text, maxLines) {
const lines = text.split('\n')
if (lines.length <= maxLines) return { excerpt: text, truncated: false }
return { excerpt: lines.slice(0, maxLines).join('\n'), truncated: true }
}
/**
* The fused order of every chunk for one query, before selection.
*
* Split out from ranking so several queries can be ranked against the same rows
* and merged: query expansion produces a handful of phrasings, each gets an
* order, and the orders are fused. Selecting results is a separate step because
* it is the only part that cares about limits and budgets.
*
* @param {object} input
* @param {object} input.matrix vector matrix and rows
* @param {Float32Array} input.queryVector unit query vector
* @param {string} input.query raw query text
* @param {number} [input.vectorWeight] fusion weight for the vector ranking
* @param {number} [input.lexicalWeight] fusion weight for the keyword ranking
* @returns {{order: number[], similarity: Float64Array, vectorBest: number, lexicalBest: number}}
*/
export function rankOrder(input) {
const { matrix, queryVector, query, vectorWeight = 0.7, lexicalWeight = 0.3 } = input
const count = matrix.rows.length
if (count === 0) {
return { order: [], similarity: new Float64Array(0), vectorBest: 0, lexicalBest: 0 }
}
const dimensions = matrix.dimensions
const similarity = new Float64Array(count)
for (let at = 0; at < count; at += 1) {
let sum = 0
const offset = at * dimensions
for (let dim = 0; dim < dimensions; dim += 1) sum += matrix.vectors[offset + dim] * queryVector[dim]
similarity[at] = sum
}
const terms = queryTerms(query)
const { lexical } = lexicalScores(matrix, terms)
const vectorOrder = orderBy(similarity)
const lexicalOrder = orderBy(lexical)
const vectorRank = new Int32Array(count).fill(count)
const lexicalRank = new Int32Array(count).fill(count)
for (let rank = 0; rank < count; rank += 1) vectorRank[vectorOrder[rank]] = rank
for (let rank = 0; rank < count; rank += 1) lexicalRank[lexicalOrder[rank]] = rank
const fused = new Float64Array(count)
for (let at = 0; at < count; at += 1) {
fused[at] = vectorWeight / (RRF_K + vectorRank[at]) + lexicalWeight / (RRF_K + lexicalRank[at])
}
return {
order: orderBy(fused),
similarity,
vectorBest: Number(similarity[vectorOrder[0]]?.toFixed(4) ?? 0),
lexicalBest: Number(lexical[lexicalOrder[0]]?.toFixed(4) ?? 0),
}
}
/**
* Merge several rankings of the same rows into one order, by rank.
*
* Used for query expansion: each alternative phrasing ranks every chunk, and a
* chunk that several phrasings agree on should outrank one that a single
* phrasing happened to like. Rank fusion again rather than adding scores,
* because the rankings come from different query vectors and their similarities
* are not on a comparable scale.
*
* A chunk missing from a ranking is simply absent from that ranking's
* contribution rather than penalised, so a phrasing that finds nothing does not
* drag anything down.
*
* @param {readonly (readonly number[])[]} rankings row indices, each in rank order
* @returns {number[]} row indices, best first
*/
export function fuseRankings(rankings) {
const present = rankings.filter((ranking) => Array.isArray(ranking) && ranking.length > 0)
if (present.length === 0) return []
if (present.length === 1) return [...present[0]]
const scores = new Map()
for (const ranking of present) {
for (let rank = 0; rank < ranking.length; rank += 1) {
const row = ranking[rank]
scores.set(row, (scores.get(row) ?? 0) + 1 / (RRF_K + rank))
}
}
// Ties break by row index, so the same input always gives the same output.
return [...scores.keys()].sort((left, right) => scores.get(right) - scores.get(left) || left - right)
}
/**
* Turn a fused order into the results a caller sees.
*
* @param {number[]} order row indices, best first
* @param {object} input
* @param {object} input.matrix
* @param {Float64Array} input.similarity per-row similarity, for reporting
* @param {number} [input.limit]
* @param {number} [input.perFile]
* @param {number} [input.excerptLines]
* @param {number} [input.totalLines]
* @returns {object[]} results
*/
export function selectFrom(order, input) {
const { matrix, similarity, limit = 8, perFile = 2, excerptLines = 80, totalLines = 400 } = input
const perFileUsed = new Map()
const results = []
// A shared line budget across the whole result set. The per-excerpt cap alone
// does not bound the response — `limit` does — and a chunk is already a
// semantic unit, so cutting one in half is how a search turns into a
// follow-up read that costs more than the lines it saved. Allocation is by
// rank, so the best answer gets the room.
let remaining = totalLines
for (const at of order) {
if (results.length >= limit) break
// Always return at least one result, however small the budget.
if (remaining <= 0 && results.length > 0) break
const row = matrix.rows[at]
if (row === undefined) continue
const used = perFileUsed.get(row.path) ?? 0
if (used >= perFile) continue
perFileUsed.set(row.path, used + 1)
const allowance = Math.max(1, Math.min(excerptLines, remaining))
const { excerpt, truncated } = excerptOf(row.text, allowance)
remaining -= excerpt.split('\n').length
results.push({
path: row.path,
startLine: row.startLine,
endLine: row.endLine,
...(row.symbol === undefined ? {} : { symbol: row.symbol }),
similarity: Number(similarity[at].toFixed(4)),
excerpt,
truncated,
})
}
return results
}
/**
* Rank chunks for a query.
*
* @param {object} input
* @param {object} input.matrix vector matrix and rows
* @param {Float32Array} input.queryVector unit query vector
* @param {string} input.query raw query text
* @param {number} [input.limit] results to return
* @param {number} [input.perFile] most chunks one file may contribute
* @param {number} [input.excerptLines] lines per excerpt
* @param {number} [input.totalLines] total line budget
* @param {number} [input.vectorWeight] fusion weight for the vector ranking
* @param {number} [input.lexicalWeight] fusion weight for the keyword ranking
* @returns {{results: object[], vectorBest: number, lexicalBest: number}}
*/
export function rankChunks(input) {
const ranked = rankOrder(input)
const results = selectFrom(ranked.order, {
matrix: input.matrix,
similarity: ranked.similarity,
limit: input.limit,
perFile: input.perFile,
excerptLines: input.excerptLines,
totalLines: input.totalLines,
})
return { results, vectorBest: ranked.vectorBest, lexicalBest: ranked.lexicalBest }
}
+124
View File
@@ -0,0 +1,124 @@
/**
* Re-ranking candidates on the local model.
*
* Cosine similarity ranks a chunk against a query in isolation. A small model
* reading the query *and* the candidate together does better — it can see that a
* chunk is about the right topic but the wrong side of it, which is exactly the
* mistake embeddings make.
*
* ## `undefined` is not zero
*
* The re-ranker answers with a score per candidate, or with **`undefined`** when
* it has no opinion — slow, wedged, unparseable, or scoring a different number
* of candidates than it was given. The caller must treat those differently:
* replacing a good embedding score with a zero because the model timed out would
* turn a working search into an empty one. No opinion means keep the ranking you
* had.
*
* This is why the module returns a bare array rather than an object with a
* status: there are exactly two outcomes, and one of them is "carry on".
*/
/** Attempts. Re-ranking is an improvement, not a requirement. */
const MAX_ATTEMPTS = 2
/** Characters sent per candidate. The head of a chunk identifies it. */
const MAX_CANDIDATE_CHARS = 700
/** Candidates scored in one call. Longer prompts get slower and vaguer. */
const MAX_CANDIDATES = 12
const PROMPT = `You score how well each code excerpt answers a developer's search query.
Give each excerpt a whole number from 0 to 10:
- 10: this is the code the query is asking about.
- 5: same area, but not what was asked.
- 0: unrelated.
Answer with JSON only, no prose: {"scores": [n, n, ...]} with exactly one score per excerpt, in order.
Query: {{QUERY}}
{{CANDIDATES}}`
/** Pull the first JSON array of numbers out of a model response. */
export function parseScores(body) {
const text = String(body ?? '')
// Prefer the documented shape, then fall back to any bare array: models drift
// between the two and both are unambiguous.
const wrapped = /\{[^{}]*"scores"\s*:\s*\[([^\]]*)\]/i.exec(text)
const bare = wrapped === null ? /\[([^\]]*)\]/.exec(text) : null
const inner = wrapped?.[1] ?? bare?.[1]
if (inner === undefined) return undefined
const numbers = inner
.split(',')
.map((part) => Number.parseFloat(part.trim()))
.filter((value) => Number.isFinite(value))
return numbers.length === 0 ? undefined : numbers
}
/**
* Build a re-ranker bound to one local model.
*
* @param {object} options
* @param {string} options.apiBase endpoint, no trailing slash
* @param {string} options.model model id
* @param {number} [options.timeoutMs] budget for the whole call
* @param {typeof fetch} [options.fetchImpl] injection point for tests
*/
export function createReranker(options) {
const { apiBase, model, timeoutMs = 15_000, fetchImpl = fetch } = options
const base = String(apiBase).replace(/\/+$/, '')
return {
model,
/**
* Score candidates against a query.
*
* @param {string} query the search query
* @param {readonly string[]} candidates excerpt texts, in rank order
* @param {AbortSignal} [signal] caller cancellation
* @returns {Promise<number[] | undefined>} one score in 0..1 per candidate, in
* order, or `undefined` when the model offered no usable opinion
*/
async score(query, candidates, signal) {
const texts = candidates.slice(0, MAX_CANDIDATES)
if (texts.length === 0 || String(query ?? '').trim() === '') return undefined
const rendered = texts
.map((text, at) => `Excerpt ${at + 1}:\n${String(text).slice(0, MAX_CANDIDATE_CHARS)}`)
.join('\n\n')
const prompt = PROMPT.replace('{{QUERY}}', String(query).trim()).replace('{{CANDIDATES}}', rendered)
for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt += 1) {
if (signal?.aborted) return undefined
try {
// See the expander: a non-finite timeout must not become a silent
// "no opinion" that looks exactly like a slow model.
const deadline = AbortSignal.timeout(Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : 15_000)
const combined = signal === undefined ? deadline : AbortSignal.any([signal, deadline])
const response = await fetchImpl(`${base}/api/generate`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
model,
prompt,
stream: false,
options: { num_predict: 80, temperature: 0 },
}),
signal: combined,
})
if (!response.ok) throw new Error(`${base} responded ${response.status}`)
const payload = await response.json()
const scores = parseScores(payload?.response)
// A count mismatch is not a partial answer — it is a model that lost
// track of the list, and applying it would score the wrong candidates.
if (scores === undefined || scores.length !== texts.length) throw new Error('unusable score count')
return scores.map((value) => Math.max(0, Math.min(10, value)) / 10)
} catch {
if (signal?.aborted) return undefined
}
}
return undefined
},
}
}
+285
View File
@@ -0,0 +1,285 @@
/**
* The SQLite index.
*
* `node:sqlite` rather than a dependency: the plugin ships with no build step
* and no install-time code, so every package it imports is one the user has to
* trust and keep current. Node has carried a synchronous SQLite binding since
* 22.5, and a synchronous API is genuinely the right shape here — the work is
* local, short, and on the critical path of a tool call, so an async wrapper
* would only add interleaving to reason about.
*
* **Why vectors are stored and not merely ranked at write time.** A search must
* be able to answer a query it has never seen, so the index has to keep the
* vectors themselves. They are written as unit-length `Float32Array` blobs, so
* scoring is a dot product and nothing else.
*
* **Why the model and width are recorded.** Vectors from two different models,
* or two widths of the same model, are not comparable, and mixing them produces
* plausible-looking but meaningless ranking. A change to either is detected on
* open and the index is discarded rather than silently degraded.
*/
import { mkdirSync } from 'node:fs'
import { dirname } from 'node:path'
import { DatabaseSync } from 'node:sqlite'
/** Schema version. A bump discards existing indexes. */
const SCHEMA_VERSION = '1'
const SCHEMA = `
CREATE TABLE IF NOT EXISTS meta (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS files (
path TEXT PRIMARY KEY,
hash TEXT NOT NULL,
bytes INTEGER NOT NULL,
chunks INTEGER NOT NULL,
indexed_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS chunks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
path TEXT NOT NULL,
start_line INTEGER NOT NULL,
end_line INTEGER NOT NULL,
symbol TEXT,
text TEXT NOT NULL,
dim INTEGER NOT NULL,
vec BLOB NOT NULL
);
CREATE INDEX IF NOT EXISTS chunks_path_idx ON chunks(path);
`
/** One indexed chunk, metadata only. */
/** @typedef {{id: number, path: string, startLine: number, endLine: number, symbol?: string, text: string}} ChunkRow */
export class RecallIndex {
/** @param {string} file absolute path to the index database */
constructor(file) {
this.file = file
/** @type {DatabaseSync | undefined} */
this.db = undefined
/** Cached scoring matrix, dropped on every write. */
this.cached = undefined
}
/**
* Open the database, creating it when absent.
*
* @param {{model: string, dimensions: number, apiBase: string}} identity
* the embedding identity this index is only valid under
* @returns {{discarded?: string}} what was thrown away, when the identity moved
*/
open(identity) {
mkdirSync(dirname(this.file), { recursive: true })
this.db = new DatabaseSync(this.file)
// WAL keeps a search from blocking a concurrent sync; the index is a cache
// and losing the tail of a write on power loss is acceptable.
this.db.exec('PRAGMA journal_mode = WAL')
this.db.exec('PRAGMA synchronous = NORMAL')
this.db.exec(SCHEMA)
return this.recheck(identity)
}
/**
* Re-validate the stored embedding identity against a live settings snapshot.
*
* Called on every tool call, not just on open, because the settings namespace
* is editable at runtime: pointing the plugin at a different model must
* invalidate the existing vectors rather than silently mix two incompatible
* spaces into one ranking.
*
* @param {{model: string, dimensions: number, apiBase: string}} identity
* @returns {{discarded?: string}} what was thrown away, when the identity moved
*/
recheck(identity) {
const previous = {
version: this.#meta('schema_version'),
model: this.#meta('model'),
dimensions: this.#meta('dimensions'),
}
let discarded
if (previous.version !== undefined && previous.version !== SCHEMA_VERSION) {
discarded = `schema version changed from ${previous.version} to ${SCHEMA_VERSION}`
} else if (previous.model !== undefined && previous.model !== identity.model) {
discarded = `embedding model changed from "${previous.model}" to "${identity.model}"`
} else if (previous.dimensions !== undefined && previous.dimensions !== String(identity.dimensions)) {
discarded = `embedding width changed from ${previous.dimensions} to ${identity.dimensions}`
}
if (discarded !== undefined) this.#wipe()
this.#setMeta('schema_version', SCHEMA_VERSION)
this.#setMeta('model', identity.model)
this.#setMeta('dimensions', String(identity.dimensions))
this.#setMeta('api_base', identity.apiBase)
return discarded === undefined ? {} : { discarded }
}
/** Close the database, if open. Safe to call twice. */
close() {
try {
this.db?.close()
} catch {
// Already closed, or the handle was lost with the process. Nothing to do.
}
this.db = undefined
this.cached = undefined
}
/** Drop every row and reset the identity. */
#wipe() {
this.db.exec('DELETE FROM chunks; DELETE FROM files; DELETE FROM meta;')
this.cached = undefined
}
/** One meta value, or `undefined`. */
#meta(key) {
const row = this.db.prepare('SELECT value FROM meta WHERE key = ?').get(key)
return row === undefined ? undefined : row.value
}
/** Write one meta value. */
#setMeta(key, value) {
this.db
.prepare('INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value')
.run(key, value)
}
/** `path -> content hash` for every indexed file. */
fileHashes() {
const out = new Map()
for (const row of this.db.prepare('SELECT path, hash FROM files').all()) out.set(row.path, row.hash)
return out
}
/**
* Byte size of each named file, for the files that are indexed.
*
* This is what makes an honest "returned less than it would have read" figure
* possible: the index already recorded every file's size when it embedded it,
* so the alternative to an excerpt can be stated without reading a single
* source file at query time.
*
* @param {readonly string[]} paths workspace-relative paths
* @returns {Map<string, number>} only the paths that are indexed
*/
bytesFor(paths) {
const out = new Map()
if (paths.length === 0) return out
const statement = this.db.prepare('SELECT bytes FROM files WHERE path = ?')
for (const path of paths) {
const row = statement.get(path)
if (row !== undefined) out.set(path, row.bytes)
}
return out
}
/** Counts for the status surface. */
stats() {
const files = this.db.prepare('SELECT COUNT(*) AS n FROM files').get()?.n ?? 0
const chunks = this.db.prepare('SELECT COUNT(*) AS n FROM chunks').get()?.n ?? 0
const bytes = this.db.prepare('SELECT COALESCE(SUM(bytes), 0) AS n FROM files').get()?.n ?? 0
return { files, chunks, bytes }
}
/**
* Replace one file's chunks atomically.
*
* @param {string} path workspace-relative path
* @param {string} hash content hash
* @param {number} bytes file size
* @param {{startLine: number, endLine: number, symbol?: string, text: string, vec: Float32Array}[]} chunks
*/
replaceFile(path, hash, bytes, chunks) {
this.db.exec('BEGIN IMMEDIATE')
try {
this.db.prepare('DELETE FROM chunks WHERE path = ?').run(path)
const insert = this.db.prepare(
'INSERT INTO chunks (path, start_line, end_line, symbol, text, dim, vec) VALUES (?, ?, ?, ?, ?, ?, ?)',
)
for (const chunk of chunks) {
insert.run(
path,
chunk.startLine,
chunk.endLine,
chunk.symbol ?? null,
chunk.text,
chunk.vec.length,
new Uint8Array(chunk.vec.buffer, chunk.vec.byteOffset, chunk.vec.byteLength),
)
}
this.db
.prepare(
`INSERT INTO files (path, hash, bytes, chunks, indexed_at) VALUES (?, ?, ?, ?, ?)
ON CONFLICT(path) DO UPDATE SET
hash = excluded.hash, bytes = excluded.bytes,
chunks = excluded.chunks, indexed_at = excluded.indexed_at`,
)
.run(path, hash, bytes, chunks.length, Date.now())
this.db.exec('COMMIT')
} catch (error) {
this.db.exec('ROLLBACK')
throw error
}
this.cached = undefined
}
/** Forget files that no longer exist on disk. Returns how many were dropped. */
prune(toRemove) {
if (toRemove.length === 0) return 0
this.db.exec('BEGIN IMMEDIATE')
try {
const dropChunks = this.db.prepare('DELETE FROM chunks WHERE path = ?')
const dropFile = this.db.prepare('DELETE FROM files WHERE path = ?')
for (const path of toRemove) {
dropChunks.run(path)
dropFile.run(path)
}
this.db.exec('COMMIT')
} catch (error) {
this.db.exec('ROLLBACK')
throw error
}
this.cached = undefined
return toRemove.length
}
/**
* Load every chunk for scoring.
*
* One contiguous `Float32Array` and a parallel row list, built once and reused
* until the next write. At 300k lines of source this is a few tens of
* megabytes — the alternative, a blob read per query, would pay SQLite's row
* overhead on every search for no benefit, since a search visits every row
* anyway.
*
* @returns {{vectors: Float32Array, rows: ChunkRow[], dimensions: number}}
*/
matrix() {
if (this.cached !== undefined) return this.cached
const raw = this.db
.prepare('SELECT id, path, start_line, end_line, symbol, text, dim, vec FROM chunks ORDER BY id')
.all()
const dimensions = raw.length === 0 ? 0 : raw[0].dim
const vectors = new Float32Array(raw.length * dimensions)
const rows = new Array(raw.length)
for (let at = 0; at < raw.length; at += 1) {
const row = raw[at]
const view = new Float32Array(row.vec.buffer, row.vec.byteOffset, row.vec.byteLength / 4)
vectors.set(view, at * dimensions)
rows[at] = {
id: row.id,
path: row.path,
startLine: row.start_line,
endLine: row.end_line,
...(row.symbol === null ? {} : { symbol: row.symbol }),
text: row.text,
}
}
this.cached = { vectors, rows, dimensions }
return this.cached
}
}
+160
View File
@@ -0,0 +1,160 @@
/**
* What one conversation got, and what to do with it.
*
* Two jobs, both pure:
*
* 1. **Derive** the small record a tool call leaves in the session log. The
* harness persists `output.presentationMeta` verbatim as the `meta` of the
* `tool/result` event, so this is the one place a result can state facts
* about itself without inventing an event type or a side channel.
* 2. **Fold** those records into a running total per conversation. The fold is
* the projection unit's transition function, which the framework requires to
* be pure and to return the *same reference* for events it does not care
* about — an unchanged reference costs nothing downstream, while a fresh
* object on every tool call would republish on every step.
*
* ## Why `sourceBytes` rather than a headline saving
*
* The honest comparison is between what the caller received and what reading
* those same files would have cost. The index already recorded every file's
* size when it embedded it, so the second figure costs nothing at query time.
*
* It is still an **upper bound**, and the numbers are labelled that way
* everywhere they surface: a careful agent might have read one of those four
* files rather than all four. A figure that flatters the tool is worse than no
* figure, because it stops being believed the first time somebody checks.
*/
/** State for a conversation that has never called the tool. */
export function emptyUsage() {
return {
version: 1,
searches: 0,
indexes: 0,
failed: 0,
excerpts: 0,
returnedBytes: 0,
sourceBytes: 0,
files: 0,
weak: 0,
// Bytes of avoided context multiplied by the steps that have re-read them.
// Accumulated here rather than derived, because deriving it needs a step
// count this state does not own — and the fold sees every step anyway.
rereadBytes: 0,
lastFocus: '',
}
}
/** The context a conversation is carrying that it would not have, in bytes. */
function avoidedBytesOf(state) {
return Math.max(0, state.sourceBytes - state.returnedBytes)
}
/**
* The record one call writes into the session log.
*
* Deliberately tiny: it is appended for every recall call and replayed on every
* projection rebuild, so it carries totals and never content.
*
* @param {{action?: string, query?: string}} args the validated call arguments
* @param {Record<string, any>} value the canonical result
* @returns {{recall: Record<string, any>}} the persisted metadata
*/
export function deriveUsage(args, value) {
const action = value?.action ?? args?.action ?? 'search'
const record = { action }
if (value?.ok === false) record.failed = 1
if (typeof value?.focus?.path === 'string') record.focus = value.focus.path
if (value?.weak === true) record.weak = 1
if (action === 'search' && Array.isArray(value?.results)) {
record.excerpts = value.results.length
record.returnedBytes = value.results.reduce(
(sum, result) => sum + Buffer.byteLength(String(result?.excerpt ?? ''), 'utf8'),
0,
)
}
if (action === 'index' && typeof value?.index?.embedded === 'number') {
record.embedded = value.index.embedded
}
if (typeof value?.measure?.sourceBytes === 'number') {
record.sourceBytes = value.measure.sourceBytes
record.files = value.measure.files
}
return { recall: record }
}
/**
* Fold one session event into the running total.
*
* @param {ReturnType<typeof emptyUsage>} state state covering all prior events
* @param {{type?: string, meta?: any}} event the next committed session event
* @returns the next state, or **the same reference** when not this unit's event
*/
export function foldUsage(state, event) {
// Every step re-reads whatever the conversation is already carrying, so each
// step adds the avoided-so-far to the compounding total. This is what makes
// the figure exact rather than an estimate the caller has to supply.
if (event?.type === 'step/start') {
const carried = avoidedBytesOf(state)
if (carried === 0) return state
return { ...state, rereadBytes: state.rereadBytes + carried }
}
const record = event?.type === 'tool/result' ? event.meta?.recall : undefined
if (record === undefined || record === null || typeof record !== 'object') return state
const next = {
...state,
searches: state.searches + (record.action === 'search' ? 1 : 0),
indexes: state.indexes + (record.action === 'index' ? 1 : 0),
failed: state.failed + (record.failed === 1 ? 1 : 0),
excerpts: state.excerpts + (record.excerpts ?? 0),
returnedBytes: state.returnedBytes + (record.returnedBytes ?? 0),
sourceBytes: state.sourceBytes + (record.sourceBytes ?? 0),
files: state.files + (record.files ?? 0),
weak: state.weak + (record.weak === 1 ? 1 : 0),
lastFocus: typeof record.focus === 'string' ? record.focus : state.lastFocus,
}
return next
}
/**
* The derived figures a surface shows, computed from state.
*
* Kept out of the state itself so the persisted fold stays a plain running
* total: anything derivable is derived on read, which is what lets the formula
* change without invalidating every checkpoint in the cache.
*
* @param {ReturnType<typeof emptyUsage>} state folded totals
* @param {{cacheReadPricePerMillion?: number}} [assumptions]
* @returns {Record<string, number|string|boolean>}
*/
export function usageSummary(state, assumptions = {}) {
const { cacheReadPricePerMillion = 0.007 } = assumptions
// What a read would have cost, less what the excerpt did cost.
const avoidedBytes = avoidedBytesOf(state)
// bytes/4 is an approximation of tokens; labelled as one wherever it is shown.
const avoidedTokens = Math.round(avoidedBytes / 4)
const compression = state.sourceBytes > 0 ? state.sourceBytes / Math.max(1, state.returnedBytes) : 0
// The compounding figure: context kept out, re-read once per step since.
const rereadTokens = Math.round((state.rereadBytes ?? 0) / 4)
const avoidedUsd = ((avoidedTokens + rereadTokens) / 1_000_000) * cacheReadPricePerMillion
return {
searches: state.searches,
indexes: state.indexes,
failed: state.failed,
excerpts: state.excerpts,
files: state.files,
weak: state.weak,
returnedBytes: state.returnedBytes,
sourceBytes: state.sourceBytes,
avoidedBytes,
avoidedTokens,
compression: Number(compression.toFixed(2)),
rereadTokens,
avoidedUsd: Number(avoidedUsd.toFixed(4)),
lastFocus: state.lastFocus,
}
}
+207
View File
@@ -0,0 +1,207 @@
/**
* Workspace file discovery.
*
* Two rules shape everything here.
*
* **The walk never follows a symlink.** A symlink loop turns a bounded walk into
* an unbounded one, and a symlink out of the workspace turns "index this
* project" into "index this machine". Both are worse than missing a file.
*
* **The walk is deterministic.** Candidates come back in sorted path order, so
* a partially-completed cold index covers the same prefix on the next run. A
* filesystem-ordered walk would re-embed a different subset every time and
* never converge on a warm index.
*/
import { readdir } from 'node:fs/promises'
import { join, relative, sep } from 'node:path'
/**
* Directory names skipped at any depth.
*
* These are dependency stores and build output: large, machine-generated, and
* never the answer to a question about this codebase. Indexing them would
* multiply the embedding cost of a cold start by an order of magnitude and
* return vendored copies of code the user does not own.
*/
export const DEFAULT_SKIP_DIRS = Object.freeze([
'node_modules',
'.git',
'.hg',
'.svn',
'dist',
'build',
'out',
'output',
'target',
'coverage',
'.next',
'.nuxt',
'.svelte-kit',
'.output',
'.turbo',
'.cache',
'.parcel-cache',
'.venv',
'venv',
'__pycache__',
'.mypy_cache',
'.pytest_cache',
'.ruff_cache',
'vendor',
'Pods',
'.idea',
'.vscode',
'.gradle',
'.terraform',
'tmp',
'.tmp',
])
/**
* Extensions indexed by default.
*
* Markup and prose are deliberately absent. A question that names a symbol wants
* the code that defines it, and a README that mentions the symbol is a worse
* answer that still costs tokens. Deployments that want documentation indexed
* add the extension through `extensions` in the config.
*/
export const DEFAULT_EXTENSIONS = Object.freeze([
'ts', 'tsx', 'mts', 'cts',
'js', 'jsx', 'mjs', 'cjs',
'py', 'rb', 'go', 'rs', 'java', 'kt', 'kts', 'scala', 'swift',
'c', 'h', 'cc', 'cpp', 'cxx', 'hpp', 'hh', 'cs',
'php', 'pl', 'lua', 'sh', 'bash', 'zsh',
'sql', 'graphql', 'proto', 'vue', 'svelte',
])
/**
* Translate a glob into an anchored regular expression.
*
* Supports `**` (any depth, including none), `*` (within one path segment), `?`
* (one non-separator character) and a trailing `/` (directory and everything
* under it). Written out rather than pulled from a dependency because the plugin
* ships with no build step and no install-time code.
*
* @param {string} pattern glob, using `/` as the separator on every platform
* @returns {RegExp} anchored matcher over a `/`-separated relative path
*/
export function globToRegExp(pattern) {
const normalized = pattern.trim().replace(/\\/g, '/').replace(/^\.\//, '')
let source = ''
for (let at = 0; at < normalized.length; at += 1) {
const char = normalized[at]
if (char === '*') {
const doubled = normalized[at + 1] === '*'
if (doubled) {
// `**/` consumes any number of segments, so `**/foo` also matches `foo`.
if (normalized[at + 2] === '/') {
source += '(?:[^/]*/)*'
at += 2
} else {
source += '.*'
at += 1
}
} else {
source += '[^/]*'
}
} else if (char === '?') {
source += '[^/]'
} else if (char === '/') {
source += '/'
} else {
source += char.replace(/[.+^${}()|[\]\\]/g, '\\$&')
}
}
// A trailing `/` names a directory: match it and everything beneath it.
if (source.endsWith('/')) source += '.*'
return new RegExp(`^(?:${source})$`)
}
/** Whether a `/`-separated relative path is matched by any of `patterns`. */
export function matchesAny(relPath, patterns) {
for (const pattern of patterns) {
if (pattern === '') continue
if (globToRegExp(pattern).test(relPath)) return true
}
return false
}
/** The extension of a path, lowercased, without the dot. */
export function extensionOf(path) {
const name = path.slice(path.lastIndexOf('/') + 1)
const dot = name.lastIndexOf('.')
return dot <= 0 ? '' : name.slice(dot + 1).toLowerCase()
}
/**
* List indexable files beneath `root`.
*
* @param {string} root absolute directory to walk
* @param {object} [options]
* @param {readonly string[]} [options.extensions] allowed extensions, without dots
* @param {readonly string[]} [options.skipDirs] directory names skipped at any depth
* @param {readonly string[]} [options.exclude] glob patterns over the relative path
* @param {number} [options.maxFiles] stop after this many candidates
* @param {number} [options.maxDepth] deepest directory level to enter
* @param {AbortSignal} [options.signal]
* @returns {Promise<{files: {path: string, absolute: string}[], truncated: boolean, skippedFiles: number}>}
*/
export async function listCodeFiles(root, options = {}) {
const {
extensions = DEFAULT_EXTENSIONS,
skipDirs = DEFAULT_SKIP_DIRS,
exclude = [],
maxFiles = 20_000,
maxDepth = 24,
signal,
} = options
const wanted = new Set(extensions.map((value) => String(value).toLowerCase()))
const skip = new Set(skipDirs)
const files = []
let truncated = false
let skippedFiles = 0
/** Depth-first, pushing directories in reverse so the pop order is alphabetical. */
const stack = [{ dir: root, depth: 0 }]
while (stack.length > 0) {
if (signal?.aborted) throw signal.reason ?? new Error('aborted')
const { dir, depth } = stack.pop()
let entries
try {
entries = await readdir(dir, { withFileTypes: true })
} catch {
// An unreadable directory is a gap in the index, not a failure of it.
continue
}
for (const entry of entries) {
// Never traverse a symlink: `dirent.isDirectory()` is false for one, so a
// link to a directory is skipped rather than followed.
if (entry.isSymbolicLink()) {
skippedFiles += 1
continue
}
const absolute = join(dir, entry.name)
const relPath = relative(root, absolute).split(sep).join('/')
if (entry.isDirectory()) {
if (skip.has(entry.name)) continue
if (depth >= maxDepth) continue
if (matchesAny(relPath, exclude)) continue
stack.push({ dir: absolute, depth: depth + 1 })
continue
}
if (!entry.isFile()) continue
if (!wanted.has(extensionOf(entry.name))) continue
if (matchesAny(relPath, exclude)) continue
if (files.length >= maxFiles) {
truncated = true
continue
}
files.push({ path: relPath, absolute })
}
}
files.sort((left, right) => (left.path < right.path ? -1 : left.path > right.path ? 1 : 0))
return { files, truncated, skippedFiles }
}
+55
View File
@@ -0,0 +1,55 @@
{
"name": "dsh-recall",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "Local-embedding code recall for DeepSeek Harness — returns ranked excerpts with line ranges instead of whole files, so the context window stops growing with every read.",
"main": "index.js",
"exports": {
".": {
"default": "./index.js"
},
"./client": {
"default": "./client/client.js"
},
"./cordis.patch.yml": "./cordis.patch.yml",
"./package.json": "./package.json"
},
"files": [
"index.js",
"lib",
"client",
"cordis.patch.yml",
"README.md"
],
"engines": {
"node": ">=22.5"
},
"scripts": {
"test": "node --test test/*.test.js"
},
"dsh": {
"bundle": {
"patch": "./cordis.patch.yml"
},
"client": {
"platform": "web",
"inject": [
"@deepseek-ai/dsh-client-ui-slots",
"@deepseek-ai/dsh-client-ui-settings",
"@deepseek-ai/dsh-client-ui-conversation",
"@deepseek-ai/dsh-client-ui-sidebar-right"
]
}
},
"keywords": [
"dsh-plugin",
"deepseek-harness",
"cordis",
"code-search",
"embeddings",
"context",
"tokens"
],
"license": "MIT"
}
+259
View File
@@ -0,0 +1,259 @@
/**
* The before/after instrument.
*
* Turns "recall should save tokens" into a number that can be re-run. It reads
* two independent stores, because they answer different questions:
*
* - the cost ledger knows **money and per-request volume**: requests made,
* cache-read tokens, and therefore the average context each request carried.
* That average is the figure this whole workstream is measured against.
* - the session projection cache knows **context shape**: the surface each
* session reached, and whether that session used recall at all.
*
* Run it before the plugin sees real use to capture the baseline, then run it
* again after. Sessions that used recall are the "after" group; everything else
* is the baseline. Nothing is inferred — when there is no "after" group it says
* so instead of printing a table of zeroes.
*
* node scripts/baseline.mjs [--ledger <path>] [--cache <path>] [--json]
*/
import { readFile } from 'node:fs/promises'
import { homedir } from 'node:os'
import { join } from 'node:path'
const DSH_HOME = process.env.DSH_HOME ?? join(homedir(), '.dsh')
function argValue(flag, fallback) {
const at = process.argv.indexOf(flag)
return at === -1 ? fallback : process.argv[at + 1]
}
const LEDGER = argValue('--ledger', join(DSH_HOME, 'storages', 'cost-meter', 'ledger.json'))
const CACHE = argValue('--cache', join(DSH_HOME, 'storages', 'session_projcache.json'))
const AS_JSON = process.argv.includes('--json')
/** Format an integer with thousands separators. */
const n = (value) => Math.round(value).toLocaleString('en-GB')
/** A compact human figure. */
function short(value) {
if (value >= 1e9) return (value / 1e9).toFixed(1) + 'B'
if (value >= 1e6) return (value / 1e6).toFixed(1) + 'M'
if (value >= 1e3) return Math.round(value / 1e3) + 'k'
return String(Math.round(value))
}
async function readJson(path, fallback) {
try {
return JSON.parse(await readFile(path, 'utf8'))
} catch {
return fallback
}
}
/**
* Per-session request volume and cost, from the ledger.
*
* The ledger groups by day, and a session that spans midnight appears under both
* — so sessions are merged by id rather than listed.
*/
function sessionsFromLedger(ledger) {
const byId = new Map()
for (const day of Object.values(ledger?.days ?? {})) {
for (const session of day.sessions ?? []) {
const id = session.id
if (typeof id !== 'string') continue
const entry = byId.get(id) ?? { id, calls: 0, cacheRead: 0, input: 0, output: 0, cost: 0 }
entry.calls += session.calls ?? 0
entry.cacheRead += session.cacheRead ?? 0
entry.input += session.input ?? 0
entry.output += session.output ?? 0
entry.cost += session.cost ?? 0
byId.set(id, entry)
}
}
for (const entry of byId.values()) {
// What each request carried: the cached prefix plus the new input.
entry.contextPerRequest = entry.calls > 0 ? (entry.cacheRead + entry.input) / entry.calls : 0
}
return [...byId.values()]
}
/** Context shape and recall usage, from the projection cache. */
function sessionsFromCache(cache) {
const rows = cache?.tables?.sessions ?? {}
const out = []
for (const [id, entry] of Object.entries(rows)) {
const units = entry?.rows ?? {}
const pressure = units.contextPressure?.val
const stats = units.sessionStats?.val
const usage = units.recallUsage?.val
out.push({
id,
cwd: entry?.identity?.cwd ?? '(unknown)',
surfaceTokens: pressure?.surfaceTokens ?? 0,
contextWindow: pressure?.contextWindow ?? 0,
steps: stats?.steps ?? 0,
turns: stats?.turns ?? 0,
recalls: usage?.searches ?? 0,
usedRecall: (usage?.searches ?? 0) > 0,
})
}
return out
}
/** Mean and median of a numeric list. */
function describe(values) {
if (values.length === 0) return { count: 0, mean: 0, median: 0, max: 0 }
const sorted = [...values].sort((a, b) => a - b)
const mean = sorted.reduce((sum, value) => sum + value, 0) / sorted.length
return {
count: sorted.length,
mean,
median: sorted[Math.floor(sorted.length / 2)],
max: sorted[sorted.length - 1],
}
}
async function main() {
const ledger = await readJson(LEDGER, undefined)
const cache = await readJson(CACHE, undefined)
if (ledger === undefined && cache === undefined) {
console.error(`no data. Looked for:\n ${LEDGER}\n ${CACHE}`)
process.exitCode = 1
return
}
const money = sessionsFromLedger(ledger)
const shape = sessionsFromCache(cache)
const shapeById = new Map(shape.map((row) => [row.id, row]))
const totalCalls = money.reduce((sum, row) => sum + row.calls, 0)
const totalCacheRead = money.reduce((sum, row) => sum + row.cacheRead, 0)
const totalInput = money.reduce((sum, row) => sum + row.input, 0)
const totalCost = money.reduce((sum, row) => sum + row.cost, 0)
const averageContext = totalCalls > 0 ? (totalCacheRead + totalInput) / totalCalls : 0
// Workspaces, so a comparison is not an average over unrelated projects.
const byWorkspace = new Map()
for (const row of money) {
const cwd = shapeById.get(row.id)?.cwd ?? '(not in projection cache)'
const entry = byWorkspace.get(cwd) ?? { cwd, sessions: 0, calls: 0, cacheRead: 0, input: 0, cost: 0 }
entry.sessions += 1
entry.calls += row.calls
entry.cacheRead += row.cacheRead
entry.input += row.input
entry.cost += row.cost
byWorkspace.set(cwd, entry)
}
// The comparison the objective names: average context PER REQUEST, which only
// the ledger knows, split by whether that session used recall. Peak surface is
// reported separately because it answers a different question — what a session
// grew to, not what each request carried.
const withRecall = money.filter((row) => shapeById.get(row.id)?.usedRecall === true)
const without = money.filter((row) => shapeById.get(row.id)?.usedRecall !== true)
const pressure = shape.filter((row) => row.surfaceTokens > 0)
const window = pressure[0]?.contextWindow ?? 0
const trigger = Math.round(window * 0.8)
const report = {
baseline: {
sessions: money.length,
requests: totalCalls,
cacheReadTokens: totalCacheRead,
inputTokens: totalInput,
costUsd: Number(totalCost.toFixed(2)),
// Cost-weighted: every request counts once. This is the figure that maps
// to money, and the one the objective names.
pooledAverageContextPerRequest: Math.round(averageContext),
// Typical: every session counts once. Differs from the pooled figure by
// whatever the largest sessions do, which here is a factor of two — so
// quoting only one of them would mislead in a different direction
// depending on which was chosen.
meanSessionContextPerRequest: Math.round(describe(money.map((row) => row.contextPerRequest)).mean),
},
workspaces: [...byWorkspace.values()]
.sort((a, b) => b.calls - a.calls)
.map((entry) => ({
cwd: entry.cwd,
sessions: entry.sessions,
requests: entry.calls,
averageContextPerRequest: entry.calls > 0 ? Math.round((entry.cacheRead + entry.input) / entry.calls) : 0,
costUsd: Number(entry.cost.toFixed(2)),
})),
contextShape: {
sessionsMeasured: pressure.length,
contextWindow: window,
compactionTrigger: trigger,
surface: describe(pressure.map((row) => row.surfaceTokens)),
reachedCompaction: pressure.filter((row) => row.surfaceTokens >= trigger && trigger > 0).length,
},
recall: {
sessionsWithRecallUsage: withRecall.length,
sessionsWithout: without.length,
comparable: withRecall.length > 0 && without.length > 0,
after: withRecall.length > 0 ? describe(withRecall.map((row) => row.contextPerRequest)) : null,
before: without.length > 0 ? describe(without.map((row) => row.contextPerRequest)) : null,
},
sources: { ledger: LEDGER, cache: CACHE },
}
if (AS_JSON) {
console.log(JSON.stringify(report, null, 2))
return
}
const B = report.baseline
console.log('BASELINE — every session on this machine')
console.log(` sessions ${n(B.sessions)}`)
console.log(` requests ${n(B.requests)}`)
console.log(` cache-read tokens ${n(B.cacheReadTokens)}`)
console.log(` avg context/request (pooled) ${n(B.pooledAverageContextPerRequest)}`)
console.log(` avg context/request (typical) ${n(B.meanSessionContextPerRequest)} mean of per-session averages`)
console.log(` cost $${B.costUsd.toFixed(2)}`)
console.log('')
console.log('BY WORKSPACE')
for (const entry of report.workspaces.slice(0, 6)) {
console.log(
` ${String(entry.requests).padStart(7)} req ${String(entry.averageContextPerRequest).padStart(8)} ctx/req ` +
`$${entry.costUsd.toFixed(2).padStart(8)} ${entry.cwd}`,
)
}
if (report.workspaces.length > 6) console.log(` … and ${report.workspaces.length - 6} more`)
console.log('')
const S = report.contextShape
console.log('CONTEXT SHAPE — what sessions reach')
console.log(` sessions with a reading ${n(S.sessionsMeasured)} of ${n(shape.length)}`)
console.log(` peak surface ${n(S.surface.max)}`)
console.log(` median surface ${n(S.surface.median)}`)
console.log(` compaction trigger ${n(S.compactionTrigger)}`)
console.log(` sessions that reached it ${n(S.reachedCompaction)} of ${n(S.sessionsMeasured)}`)
console.log('')
console.log('BEFORE / AFTER — recall usage')
const R = report.recall
console.log(` sessions with recall usage ${n(R.sessionsWithRecallUsage)}`)
console.log(` sessions without ${n(R.sessionsWithout)}`)
console.log(' metric: each session\'s own average context per request (ledger),')
console.log(' not peak surface — the two answer different questions')
if (!R.comparable) {
// The honest output while the plugin has not run. A table here would be a
// comparison of nothing against nothing.
console.log('')
console.log(' Nothing to compare yet: no session has used recall. Run this again')
console.log(' after a week of use and the two groups will report side by side.')
} else {
console.log('')
console.log(` before ${n(R.before.mean)} ctx/req over ${n(R.before.count)} sessions (median ${n(R.before.median)})`)
console.log(` after ${n(R.after.mean)} ctx/req over ${n(R.after.count)} sessions (median ${n(R.after.median)})`)
const delta = R.before.mean > 0 ? ((R.before.mean - R.after.mean) / R.before.mean) * 100 : 0
console.log(` change ${delta >= 0 ? '-' : '+'}${Math.abs(delta).toFixed(1)}%`)
}
}
await main()
+146
View File
@@ -0,0 +1,146 @@
/**
* Real-scale measurement.
*
* Runs the actual plugin — `apply` plus the registered tool — against a real
* workspace and a real embedding endpoint, then reports what the tool costs the
* context window versus what reading the files would have cost.
*
* The comparison is the whole point of the plugin, so it is measured rather than
* asserted: the excerpt characters are counted from the tool's own output, and
* the "read instead" figure is the character count of the files the results came
* from. Token counts are `characters / 4`, which is an approximation and is
* labelled as one everywhere it appears.
*
* node scripts/measure.mjs [workspace] [query] [budgetMs]
*/
import { readFile } from 'node:fs/promises'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { apply } from '../index.js'
const WORKSPACE = process.argv[2] ?? '/root/workspaces/ha-runtime'
const QUERY = process.argv[3] ?? 'how does the occupancy state machine decide a room is occupied'
const BUDGET_MS = Number(process.argv[4] ?? 90_000)
const API_BASE = process.env.RECALL_API_BASE ?? 'http://10.50.1.2:11434'
const MODEL = process.env.RECALL_MODEL ?? 'unclemusclez/jina-embeddings-v2-base-code:latest'
const config = {
embeddingApiBase: API_BASE,
embeddingModel: MODEL,
embeddingDimensions: 768,
embeddingApiKey: '',
embeddingBatchSize: 32,
embeddingConcurrency: 4,
embeddingTimeoutMs: 120_000,
extensions: [],
exclude: [],
maxFiles: 20_000,
maxFileBytes: 1_048_576,
chunkLines: 48,
maxChunkLines: 80,
overlapLines: 8,
defaultLimit: 8,
maxLimit: 25,
perFileLimit: 2,
excerptLines: 40,
minSimilarity: 0.2,
syncBudgetMs: BUDGET_MS,
stateDir: '',
}
/** The smallest context `apply` touches, with no settings provider mounted. */
function context() {
const tools = []
const cleanups = []
return {
tools,
ctx: {
logger: () => ({ warn() {}, info() {}, error() {} }),
effect: (fn) => {
const dispose = fn()
if (typeof dispose === 'function') cleanups.push(dispose)
},
get: () => undefined,
inject: () => {},
tools: { register: (tool) => tools.push(tool) },
},
dispose: () => cleanups.forEach((fn) => fn()),
}
}
const tokens = (text) => Math.round(text.length / 4)
async function main() {
const stateDir = await mkdtemp(join(tmpdir(), 'recall-measure-'))
const { ctx, tools, dispose } = context()
apply(ctx, { ...config, stateDir })
const tool = tools[0]
const exec = { signal: new AbortController().signal, agent: { session: { header: { cwd: WORKSPACE } } } }
console.log(`workspace : ${WORKSPACE}`)
console.log(`endpoint : ${API_BASE} model: ${MODEL}`)
console.log(`query : ${QUERY}`)
console.log('')
// --- cold start, bounded -------------------------------------------------
const coldStart = Date.now()
const first = await tool.execute({ query: QUERY }, exec)
const coldMs = Date.now() - coldStart
console.log('--- FIRST SEARCH (bounded cold start) ---')
console.log(`elapsed : ${(coldMs / 1000).toFixed(1)}s (budget ${BUDGET_MS / 1000}s)`)
console.log(`scanned files : ${first.index.scanned}`)
console.log(`indexed files : ${first.index.files}`)
console.log(`chunks : ${first.index.chunks}`)
console.log(`embedded now : ${first.index.embedded}`)
console.log(`still pending : ${first.index.pending}`)
console.log(`skipped (big) : ${first.index.oversized}`)
console.log(`results : ${first.results.length} best similarity ${first.best}`)
console.log('')
// --- the token comparison ------------------------------------------------
const rendered = tool.output.render({ query: QUERY }, first)[0].text
let wholeFileChars = 0
const seen = new Set()
for (const result of first.results) {
if (seen.has(result.path)) continue
seen.add(result.path)
try {
wholeFileChars += (await readFile(join(WORKSPACE, result.path), 'utf8')).length
} catch {
// A result for a file that has since moved does not change the argument.
}
}
console.log('--- WHAT THE MODEL RECEIVED ---')
console.log(`recall output : ${rendered.length} chars ~${tokens(rendered)} tokens`)
console.log(`whole files : ${wholeFileChars} chars ~${Math.round(wholeFileChars / 4)} tokens across ${seen.size} file(s)`)
if (wholeFileChars > 0) {
console.log(`ratio : ${(wholeFileChars / rendered.length).toFixed(1)}x smaller`)
}
console.log('')
for (const [at, result] of first.results.entries()) {
console.log(`${at + 1}. ${result.path}:${result.startLine}-${result.endLine} sim ${result.similarity} ${result.truncated ? '(truncated)' : ''}`)
}
console.log('')
// --- incremental ---------------------------------------------------------
const secondStart = Date.now()
const second = await tool.execute({ query: 'what happens when the SSE stream reconnects' }, exec)
const secondMs = Date.now() - secondStart
console.log('--- SECOND SEARCH (warm index, different query) ---')
console.log(`elapsed : ${secondMs}ms`)
console.log(`embedded : ${second.index.embedded} file(s)`)
console.log(`pending : ${second.index.pending}`)
console.log(`results : ${second.results.length}`)
console.log('')
console.log('(token figures are characters/4 — an approximation, not a measurement)')
dispose()
await rm(stateDir, { recursive: true, force: true })
}
await main()
+114
View File
@@ -0,0 +1,114 @@
/**
* What focus scoping does to the index, measured without an embedding endpoint.
*
* The semantic half of the claim — that a focused search ranks better — needs a
* live model. This half does not: index size, per-scope chunk counts, and the
* cold-start cost they imply are all arithmetic over the chunker, which is
* deterministic and offline.
*
* The cold-start projection uses the throughput actually measured against the
* real endpoint on this repository: 2,257 chunks embedded in 90s, about 25
* chunks/s. It is a projection from a real figure, not a guess, and it is
* labelled as a projection.
*
* node scripts/scope-measure.mjs [workspace]
*/
import { readFile } from 'node:fs/promises'
import { join } from 'node:path'
import { chunkFile } from '../lib/chunk.js'
import { listCodeFiles } from '../lib/walk.js'
const WORKSPACE = process.argv[2] ?? '/root/workspaces/ha-runtime'
/** Chunks per second, measured on this repository against the live endpoint. */
const MEASURED_CHUNKS_PER_SECOND = 2257 / 90
/** Count files and chunks for one directory, relative to a base for display. */
async function measure(root, label) {
const { files } = await listCodeFiles(root)
let chunks = 0
let lines = 0
let skipped = 0
for (const file of files) {
let content
try {
content = await readFile(file.absolute, 'utf8')
} catch {
continue
}
lines += content.split('\n').length
const split = chunkFile({ path: file.path, content })
if (split.chunks.length === 0) skipped += 1
chunks += split.chunks.length
}
return { label, files: files.length, chunks, lines, skipped }
}
/** Directories under src/controllers that hold code directly. */
async function controllerRoots(root) {
const { files } = await listCodeFiles(root)
const seen = new Map()
for (const file of files) {
if (!file.path.startsWith('src/controllers/')) continue
const dir = file.path.slice(0, file.path.lastIndexOf('/'))
if (dir === '' || dir.endsWith('__tests__')) continue
seen.set(dir, true)
}
return [...seen.keys()].sort()
}
function seconds(chunks) {
return chunks / MEASURED_CHUNKS_PER_SECOND
}
function human(sec) {
if (sec < 60) return `${sec.toFixed(0)}s`
return `${(sec / 60).toFixed(1)}min`
}
async function main() {
console.log(`workspace: ${WORKSPACE}`)
console.log(`throughput: ${MEASURED_CHUNKS_PER_SECOND.toFixed(1)} chunks/s (measured on this repo)`)
console.log('')
const whole = await measure(WORKSPACE, 'whole workspace')
console.log('=== WHOLE WORKSPACE ===')
console.log(` files ${whole.files} chunks ${whole.chunks} lines ${whole.lines.toLocaleString()}`)
console.log(` cold index (projected): ${human(seconds(whole.chunks))}`)
console.log('')
const roots = await controllerRoots(WORKSPACE)
const scopes = []
for (const dir of roots) {
scopes.push(await measure(join(WORKSPACE, dir), dir))
}
scopes.sort((left, right) => right.chunks - left.chunks)
console.log(`=== ${scopes.length} CONTROLLER SCOPES ===`)
console.log(` ${'scope'.padEnd(52)} ${'files'.padStart(5)} ${'chunks'.padStart(7)} ${'share'.padStart(7)} ${'cold'.padStart(7)}`)
for (const scope of scopes.slice(0, 10)) {
const share = ((scope.chunks / whole.chunks) * 100).toFixed(1)
console.log(
` ${scope.label.replace('src/controllers/', '').padEnd(52)} ${String(scope.files).padStart(5)} ` +
`${String(scope.chunks).padStart(7)} ${`${share}%`.padStart(7)} ${human(seconds(scope.chunks)).padStart(7)}`,
)
}
if (scopes.length > 10) console.log(` … and ${scopes.length - 10} more`)
console.log('')
const median = scopes[Math.floor(scopes.length / 2)]
const totalScoped = scopes.reduce((sum, scope) => sum + scope.chunks, 0)
console.log('=== WHAT SCOPING BUYS ===')
console.log(` median scope : ${median.chunks} chunks (${((median.chunks / whole.chunks) * 100).toFixed(1)}% of the workspace)`)
console.log(` largest scope : ${scopes[0].chunks} chunks (${((scopes[0].chunks / whole.chunks) * 100).toFixed(1)}%)`)
console.log(` candidate pool : ${whole.chunks} -> ${median.chunks} chunks, a ${(whole.chunks / median.chunks).toFixed(1)}x smaller haystack`)
console.log(` cold start (projected): ${human(seconds(whole.chunks))} -> ${human(seconds(median.chunks))}`)
console.log(` all scopes together : ${totalScoped} chunks, ${((totalScoped / whole.chunks) * 100).toFixed(1)}% of the workspace`)
console.log('')
console.log('(the candidate-pool figure is the precision argument; whether ranking actually')
console.log(' improves needs the live endpoint, which is currently unable to load the model)')
}
await main()
+148
View File
@@ -0,0 +1,148 @@
/**
* Live smoke test: the real pipeline against the real embedding endpoint.
*
* Not part of `npm test`, because it needs a running Ollama and a model that is
* already pulled. It exists because the unit tests can only prove the code does
* what the test author believed; this proves the retrieval actually returns the
* right file for a question about it, which is the only claim that matters.
*
* node scripts/smoke.mjs [workspace] [query]
*/
import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { chunkFile, embedText } from '../lib/chunk.js'
import { createEmbedder } from '../lib/embed.js'
import { rankChunks } from '../lib/rank.js'
import { RecallIndex } from '../lib/store.js'
import { listCodeFiles } from '../lib/walk.js'
const API_BASE = process.env.RECALL_API_BASE ?? 'http://10.50.1.2:11434'
const MODEL = process.env.RECALL_MODEL ?? 'unclemusclez/jina-embeddings-v2-base-code:latest'
const DIMENSIONS = Number(process.env.RECALL_DIMENSIONS ?? 768)
const FIXTURE = {
'src/services/auth/session.ts': `import { createHash, randomBytes } from 'node:crypto'
const SESSION_TTL_MS = 1000 * 60 * 60 * 12
export interface SessionRecord {
userId: string
issuedAt: number
expiresAt: number
}
export function issueSession(userId: string): SessionRecord {
const now = Date.now()
return { userId, issuedAt: now, expiresAt: now + SESSION_TTL_MS }
}
export function isExpired(record: SessionRecord, now = Date.now()): boolean {
return record.expiresAt <= now
}
export function rotateToken(record: SessionRecord): string {
if (isExpired(record)) throw new Error('cannot rotate an expired session')
return randomBytes(32).toString('hex')
}
export function fingerprint(token: string): string {
return createHash('sha256').update(token).digest('hex').slice(0, 16)
}
`,
'src/services/weather/forecast.ts': `export interface ForecastDay {
date: string
minC: number
maxC: number
rainMm: number
}
export function averageTemperature(days: ForecastDay[]): number {
if (days.length === 0) return 0
const total = days.reduce((sum, day) => sum + (day.minC + day.maxC) / 2, 0)
return total / days.length
}
export function totalRainfall(days: ForecastDay[]): number {
return days.reduce((sum, day) => sum + day.rainMm, 0)
}
`,
'src/store/sqlite.ts': `import { DatabaseSync } from 'node:sqlite'
export function openDatabase(path: string): DatabaseSync {
const db = new DatabaseSync(path)
db.exec('PRAGMA journal_mode = WAL')
return db
}
export function pruneRows(db: DatabaseSync, table: string, olderThan: number): number {
const statement = db.prepare(\`DELETE FROM \${table} WHERE created_at < ?\`)
const result = statement.run(olderThan)
return Number(result.changes)
}
`,
}
/** Write the fixture into a fresh temporary directory. */
async function makeWorkspace() {
const root = await mkdtemp(join(tmpdir(), 'recall-smoke-'))
for (const [path, content] of Object.entries(FIXTURE)) {
const absolute = join(root, path)
await mkdir(join(absolute, '..'), { recursive: true })
await writeFile(absolute, content, 'utf8')
}
return root
}
async function main() {
const query = process.argv[3] ?? 'how long does a user session stay valid before it expires'
const root = process.argv[2] ?? (await makeWorkspace())
const indexDir = await mkdtemp(join(tmpdir(), 'recall-index-'))
const database = join(indexDir, 'index.sqlite')
const embedder = createEmbedder({ apiBase: API_BASE, model: MODEL, dimensions: DIMENSIONS })
const index = new RecallIndex(database)
index.open({ model: MODEL, dimensions: DIMENSIONS, apiBase: API_BASE })
const started = Date.now()
const walk = await listCodeFiles(root)
let chunks = 0
for (const file of walk.files) {
const { readFile } = await import('node:fs/promises')
const content = await readFile(file.absolute, 'utf8')
const split = chunkFile({ path: file.path, content })
if (split.chunks.length === 0) continue
const vectors = await embedder.embed(split.chunks.map((chunk) => embedText(file.path, chunk)))
index.replaceFile(file.path, 'smoke', Buffer.byteLength(content), split.chunks.map((c, at) => ({ ...c, vec: vectors[at] })))
chunks += split.chunks.length
}
const indexMs = Date.now() - started
const matrix = index.matrix()
const queryVector = await embedder.embedOne(query)
const searchStart = Date.now()
const ranked = rankChunks({ matrix, queryVector, query, limit: 5 })
const searchMs = Date.now() - searchStart
console.log(`workspace : ${root}`)
console.log(`files : ${walk.files.length} chunks: ${chunks} indexed in ${indexMs}ms`)
console.log(`query : ${query}`)
console.log(`search : ${searchMs}ms batch endpoint: ${embedder.batchEnabled}`)
console.log('')
for (const [at, result] of ranked.results.entries()) {
console.log(`${at + 1}. ${result.path}:${result.startLine}-${result.endLine} similarity ${result.similarity}`)
console.log(` ${result.excerpt.split('\n')[0]}`)
}
console.log('')
const top = ranked.results[0]
const expected = 'src/services/auth/session.ts'
console.log(top?.path === expected ? `PASS top hit is ${expected}` : `FAIL top hit was ${top?.path}, expected ${expected}`)
index.close()
await rm(indexDir, { recursive: true, force: true })
if (process.argv[2] === undefined) await rm(root, { recursive: true, force: true })
}
await main()
+150
View File
@@ -0,0 +1,150 @@
import { strict as assert } from 'node:assert'
import { test } from 'node:test'
import {
chunkFile,
embedText,
isLikelyMinified,
languageOf,
symbolLabel,
symbolOf,
} from '../lib/chunk.js'
const TS = `import { readFile } from 'node:fs/promises'
const CACHE = new Map()
export function loadConfig(path) {
if (CACHE.has(path)) return CACHE.get(path)
const value = { path, loaded: true }
CACHE.set(path, value)
return value
}
export function clearCache() {
CACHE.clear()
}
export class Store {
constructor() {
this.rows = []
}
add(row) {
this.rows.push(row)
}
}
`
/** A module long enough that its declarations cannot all merge into one chunk. */
function bigModule(count = 6, bodyLines = 14) {
const parts = []
for (let at = 0; at < count; at += 1) {
const body = Array.from({ length: bodyLines }, (_, line) => ` const step_${at}_${line} = compute(${line})`).join('\n')
parts.push(`export function handler_${at}(input) {\n${body}\n return input\n}`)
}
return `${parts.join('\n\n')}\n`
}
test('languageOf maps known extensions and ignores unknown ones', () => {
assert.equal(languageOf('a/b.ts'), 'javascript')
assert.equal(languageOf('a/b.PY'), 'python')
assert.equal(languageOf('a/b.rs'), 'rust')
assert.equal(languageOf('a/b.unknownext'), undefined)
assert.equal(languageOf('Makefile'), undefined)
})
test('symbolOf reads a declaration name', () => {
assert.equal(symbolOf('export function loadConfig(path) {'), 'loadConfig')
assert.equal(symbolOf('export class Store {'), 'Store')
assert.equal(symbolOf('def compute_total(items):'), 'compute_total')
assert.equal(symbolOf('// just a comment'), undefined)
})
test('symbolLabel lists merged declarations and caps a long list', () => {
assert.equal(symbolLabel([]), undefined)
assert.equal(symbolLabel(['a']), 'a')
assert.equal(symbolLabel(['a', 'b']), 'a, b')
assert.equal(symbolLabel(['a', 'a', 'b']), 'a, b')
assert.equal(symbolLabel(['a', 'b', 'c', 'd', 'e', 'f']), 'a, b, c, d +2')
})
test('a small file merges into one chunk labelled with every declaration it holds', () => {
const { chunks } = chunkFile({ path: 'src/config.ts', content: TS })
assert.equal(chunks.length, 1, 'a 25-line file belongs in a single chunk')
// The label must describe the whole chunk, not just its first declaration:
// for a small file this label is the module's only description in the index.
assert.equal(chunks[0].symbol, 'CACHE, loadConfig, clearCache, Store')
assert.equal(chunks[0].startLine, 1)
})
test('a larger file splits at declarations, and each chunk is a real source slice', () => {
const content = bigModule()
const { chunks } = chunkFile({ path: 'src/handlers.ts', content })
assert.ok(chunks.length >= 3, `expected several chunks, got ${chunks.length}`)
const lines = content.split('\n')
for (const chunk of chunks) {
assert.equal(chunk.text, lines.slice(chunk.startLine - 1, chunk.endLine).join('\n'))
}
assert.equal(chunks[0].startLine, 1)
assert.match(chunks[0].symbol, /handler_0/)
})
test('chunkFile merges adjacent small declarations instead of emitting fragments', () => {
const source = ['export function a() { return 1 }', '', 'export function b() { return 2 }', '', 'export function c() { return 3 }', ''].join('\n')
const { chunks } = chunkFile({ path: 'x.ts', content: source, chunkLines: 48 })
assert.equal(chunks.length, 1, 'three one-line functions should merge into one chunk')
assert.equal(chunks[0].symbol, 'a, b, c')
})
test('chunkFile splits an oversized declaration with overlap', () => {
const body = Array.from({ length: 300 }, (_, at) => ` const value_${at} = compute(${at})`).join('\n')
const source = `export function huge() {\n${body}\n}\n`
const { chunks } = chunkFile({ path: 'x.ts', content: source, maxChunkLines: 80, overlapLines: 8 })
assert.ok(chunks.length > 1, 'a 300-line function must be split')
for (const chunk of chunks) {
assert.ok(chunk.endLine - chunk.startLine + 1 <= 80, `chunk was ${chunk.endLine - chunk.startLine + 1} lines`)
}
// Overlap means consecutive chunks share lines, so no boundary is lost.
for (let at = 1; at < chunks.length; at += 1) {
assert.ok(chunks[at].startLine <= chunks[at - 1].endLine, 'consecutive chunks should overlap')
}
// Only the piece holding the signature is labelled with the declaration.
assert.equal(chunks[0].symbol, 'huge')
assert.equal(chunks[1].symbol, undefined)
})
test('chunkFile falls back to windows when the language has no anchor set', () => {
const source = Array.from({ length: 200 }, (_, at) => `line ${at} of an unknown dialect`).join('\n')
const { chunks } = chunkFile({ path: 'thing.weird', content: source, maxChunkLines: 80, overlapLines: 8 })
assert.ok(chunks.length > 1)
assert.equal(chunks[0].startLine, 1)
assert.equal(chunks[0].symbol, undefined)
})
test('chunkFile refuses generated files rather than indexing noise', () => {
const minified = `var a=${'x'.repeat(50_000)};`
assert.equal(isLikelyMinified(minified), true)
const result = chunkFile({ path: 'bundle.js', content: minified })
assert.deepEqual(result.chunks, [])
assert.equal(result.skipped, 'generated-or-minified')
})
test('chunkFile reports an empty file as empty', () => {
const result = chunkFile({ path: 'a.ts', content: ' \n\n' })
assert.deepEqual(result.chunks, [])
assert.equal(result.skipped, 'empty')
})
test('chunkFile caps chunks per file', () => {
const source = Array.from({ length: 400 }, (_, at) => `export function fn_${at}() { return ${at} }`).join('\n')
const { chunks, skipped } = chunkFile({ path: 'many.ts', content: source, chunkLines: 2, maxChunkLines: 2, maxChunks: 10 })
assert.equal(chunks.length, 10)
assert.equal(skipped, 'chunk-cap-reached')
})
test('embedText carries the path, so a question about a name can find it', () => {
const text = embedText('src/auth/session.ts', { symbol: 'issueSession', text: 'export function issueSession() {}' })
assert.match(text, /src\/auth\/session\.ts :: issueSession/)
assert.match(text, /export function issueSession/)
})
+410
View File
@@ -0,0 +1,410 @@
/**
* The browser half, without a browser.
*
* `client/client.js` is a plain script that hands a factory to the platform's
* module loader, so it can be exercised in Node by stubbing `window` and
* `require('react')`. That is worth doing rather than leaving the card to be
* eyeballed in a running app, because the two things most likely to be wrong are
* both structural and both invisible until someone opens Settings:
*
* - the root element must be an `<li>`, because the tab renders every card
* into a `<ul>` and a `<div>` never receives the list styling;
* - the card must start collapsed and disclose on click, because that is the
* framework's card contract and the thing that was wrong the first time.
*/
import { strict as assert } from 'node:assert'
import { test } from 'node:test'
/** A React good enough to run one component: element, state, effect, memo. */
function makeReact() {
const hooks = []
let cursor = 0
return {
createElement(type, props, ...children) {
const merged = { ...(props ?? {}) }
if (children.length > 0) merged.children = children.length === 1 ? children[0] : children
return { type, props: merged }
},
useState(initial) {
const at = cursor
cursor += 1
if (!(at in hooks)) hooks[at] = typeof initial === 'function' ? initial() : initial
return [
hooks[at],
(next) => {
hooks[at] = typeof next === 'function' ? next(hooks[at]) : next
},
]
},
useEffect(fn) {
fn()
},
useMemo(fn) {
return fn()
},
/** Prepare for one more render, keeping hook state. */
__render() {
cursor = 0
},
}
}
/**
* Expand a function element the way React would.
*
* The card renders each control as `h(Field, …)`, and those elements are only
* real DOM once React calls them — so a walk that stops at `type === 'input'`
* finds nothing without this.
*/
function expand(node) {
if (node === null || typeof node !== 'object') return node
return typeof node.type === 'function' ? expand(node.type(node.props)) : node
}
/** Depth-first search over a rendered tree, expanding function components. */
function find(node, predicate, found = []) {
const current = expand(node)
if (current === null || typeof current !== 'object') return found
if (predicate(current)) found.push(current)
const children = current.props?.children
if (Array.isArray(children)) children.forEach((child) => find(child, predicate, found))
else find(children, predicate, found)
return found
}
/** The input carrying a given accessible label. */
function inputFor(tree, label) {
return find(tree, (node) => node.type === 'input' && node.props['aria-label'] === label)[0]
}
/** The save button, whatever its current label. */
function saveButton(tree) {
return find(tree, (node) => node.type === 'button' && String(node.props.children).startsWith('Save'))[0]
}
/** A settings scope that records writes instead of performing them. */
function makeScope(options = {}) {
const { value = {}, user = null, status = 'ready', writable = true } = options
const calls = []
const snapshot = { status, value, user, writable }
return {
calls,
getSnapshot: () => snapshot,
subscribe: () => () => {},
set: async (key, next) => {
calls.push({ op: 'set', key, value: next })
},
unset: async (key) => {
calls.push({ op: 'unset', key })
},
}
}
// Load the browser bundle once, the way the platform loader would.
const loaded = []
globalThis.window = { __ModuleLoader__: { load: (entry) => loaded.push(entry) } }
await import('../client/client.js')
const entry = loaded[0]
/** Instantiate the module with a stubbed `react`. */
function makeModule() {
const react = makeReact()
const factory = entry.factory((name) => {
if (name === 'react') return react
throw new Error(`unexpected import: ${name}`)
})
return { react, module: factory }
}
/** Register every slot the browser half contributes, and render any of them. */
function mount(scope) {
const { react, module } = makeModule()
const registered = []
const slots = {
inject: (name, fn) => fn(),
register: (meta, component) => {
registered.push({ meta, component })
return () => {}
},
}
const ctx = {
slots,
inject: (deps, cb) =>
cb({
settingsScope: {
bind: (options) => {
assert.equal(options.namespace, 'dsh-recall')
return scope
},
},
slots,
}),
}
module.apply(ctx)
const byName = new Map(registered.map((entry) => [entry.meta.name, entry]))
/** Render one registered slot with the props its occupant would receive. */
const renderSlot = (name, props = {}) => {
const entry = byName.get(name)
assert.ok(entry !== undefined, `nothing registered for ${name}`)
react.__render()
const element = entry.component(props)
if (element === null) return null
return element.type(element.props)
}
/** Render the settings card, at its current state. */
const render = () => {
react.__render()
const element = byName.get('settings.plugin.item').component({ scope })
return element.type(element.props)
}
return { react, module, meta: byName.get('settings.plugin.item').meta, registered, render, renderSlot }
}
/** Open the card, and hand back a function returning the CURRENT body. */
function openCard(render) {
render().props.children[0].props.onClick()
// Call this after any edit: React re-renders then, and the Save button the
// user actually clicks is the one created by that later render.
return () => render().props.children[1]
}
test('the bundle registers under the loader with the plugin id', () => {
assert.equal(loaded.length, 1)
assert.equal(entry.id, 'dsh-recall')
assert.equal(typeof entry.factory, 'function')
})
test('the client module claims slots and reports the host namespace', () => {
const { module } = makeModule()
assert.equal(module.name, 'dsh-recall')
assert.deepEqual(module.inject, ['slots'])
})
test('the card is registered under the same key the host half serves', () => {
const { meta } = mount(makeScope())
assert.equal(meta.name, 'settings.plugin.item')
assert.equal(meta.key, 'dsh-recall', 'the host namespace and the card key must match, or nothing renders')
assert.equal(typeof meta.inject, 'function')
})
test('the root is an <li>, because the tab renders cards into a <ul>', () => {
const { render } = mount(makeScope())
assert.equal(render().type, 'li')
})
test('the card starts collapsed and discloses on click', () => {
const { render } = mount(makeScope())
const closed = render()
const [closedHeader, closedBody] = closed.props.children
assert.equal(closedHeader.type, 'button')
assert.equal(closedHeader.props['aria-expanded'], false, 'the card must start collapsed')
assert.equal(closedBody, null, 'a collapsed card renders no body')
// Click the header, then render again — hook state persists across renders.
closedHeader.props.onClick()
const [openHeader, openBodyNode] = render().props.children
assert.equal(openHeader.props['aria-expanded'], true)
assert.notEqual(openBodyNode, null, 'an expanded card renders its controls')
assert.equal(openBodyNode.type, 'div')
})
test('every host setting appears as a control', () => {
const { render } = mount(makeScope())
const body = openCard(render)
const labels = find(body(), (node) => node.type === 'input').map((node) => node.props['aria-label'])
for (const label of [
'Embedding endpoint',
'Embedding model',
'Embedding dimensions',
'Embedding API key',
'Lines per excerpt',
'Index warm-up budget per search (ms)',
]) {
assert.ok(labels.includes(label), `expected a control labelled ${label}, saw ${labels.join(' | ')}`)
}
})
test('an unavailable namespace renders nothing at all', () => {
const { render } = mount(makeScope({ status: 'unavailable' }))
assert.equal(render(), null)
})
test('a read-only namespace says so rather than offering a dead form', () => {
const { render } = mount(makeScope({ writable: false }))
const body = openCard(render)
assert.ok(find(body(), (node) => node.props?.role === 'status').length > 0)
})
test('a save writes only the fields the user changed', async () => {
const scope = makeScope({ value: { embeddingApiBase: 'http://a:11434', excerptLines: 40 } })
const { render } = mount(scope)
const body = openCard(render)
inputFor(body(), 'Lines per excerpt').props.onChange({ target: { value: '25' } })
const button = saveButton(body())
assert.match(String(button.props.children), /Save 1 change/)
await button.props.onClick()
assert.deepEqual(scope.calls, [{ op: 'set', key: 'excerptLines', value: 25 }])
})
test('clearing a non-secret field unsets it rather than writing an empty value', async () => {
const scope = makeScope({ value: { embeddingApiBase: 'http://a:11434' } })
const { render } = mount(scope)
const body = openCard(render)
inputFor(body(), 'Embedding endpoint').props.onChange({ target: { value: '' } })
await saveButton(body()).props.onClick()
assert.deepEqual(scope.calls, [{ op: 'unset', key: 'embeddingApiBase' }])
})
test('a list field round-trips as a comma separated string', async () => {
const scope = makeScope({ value: { exclude: ['dist/', 'vendor/'] } })
const { render } = mount(scope)
const body = openCard(render)
const input = inputFor(body(), 'Extra exclude patterns')
assert.equal(input.props.value, 'dist/, vendor/')
input.props.onChange({ target: { value: 'dist/, vendor/, tmp/' } })
await saveButton(body()).props.onClick()
assert.deepEqual(scope.calls, [{ op: 'set', key: 'exclude', value: ['dist/', 'vendor/', 'tmp/'] }])
})
test('the API key is never sent back to the page, and blank does not clear it', async () => {
// The host marks this field role('secret'), so it arrives absent. A save with
// the field untouched must not write anything for it — otherwise opening the
// card and pressing Save would delete the key.
const scope = makeScope({ value: { embeddingApiBase: 'http://a:11434' }, user: { embeddingApiKey: 'stored' } })
const { render } = mount(scope)
const body = openCard(render)
const keyInput = inputFor(body(), 'Embedding API key')
assert.ok(keyInput !== undefined)
assert.equal(keyInput.props.type, 'password')
assert.equal(keyInput.props.value, '', 'a redacted secret must render blank')
assert.equal(keyInput.props.placeholder, '(unchanged)')
// Nothing is dirty, so there is nothing to save and the button is inert.
const button = saveButton(body())
assert.equal(button.props.disabled, true)
await button.props.onClick()
assert.deepEqual(scope.calls, [], 'an untouched secret must never be written or unset')
})
test('an unsaved edit is marked on the header while collapsed', () => {
const { render } = mount(makeScope({ value: { excerptLines: 40 } }))
const body = openCard(render)
inputFor(body(), 'Lines per excerpt').props.onChange({ target: { value: '25' } })
// Collapse again and look at the header.
render().props.children[0].props.onClick()
const collapsed = render()
const marked = find(collapsed.props.children[0], (node) => node.props?.children === 'unsaved')
assert.equal(marked.length, 1, 'unsaved edits must be visible while the card is folded away')
})
test('the browser half contributes four slots, one of them the header corner', () => {
const { registered } = mount(makeScope())
const names = registered.map((entry) => entry.meta.name).sort()
assert.deepEqual(names, [
'conversation.session.header.corner',
'settings.plugin.item',
'sidebar.right.pane.tab',
'sidebar.right.pane.tab.title',
])
})
/** A projection value shaped like the host unit's wire view. */
function usageView(overrides = {}) {
return {
searches: 3,
indexes: 0,
failed: 0,
excerpts: 9,
files: 5,
weak: 0,
returnedBytes: 12_000,
sourceBytes: 480_000,
avoidedBytes: 468_000,
avoidedTokens: 117_000,
compression: 40,
rereadTokens: 0,
avoidedUsd: 0.0008,
lastFocus: '.',
...overrides,
}
}
test('the header chip renders nothing when the conversation never used it', () => {
const { renderSlot } = mount(makeScope())
// The corner is laid out only while its occupant renders something, so this
// is the state that must cost no space at all.
assert.equal(renderSlot('conversation.session.header.corner', { useProjection: () => undefined }), null)
assert.equal(renderSlot('conversation.session.header.corner', { useProjection: () => usageView({ searches: 0, excerpts: 0 }) }), null)
// And a host without the projection service at all.
assert.equal(renderSlot('conversation.session.header.corner', {}), null)
})
test('the header chip leads with what was kept out, not what it cost', () => {
const { renderSlot } = mount(makeScope())
const chip = renderSlot('conversation.session.header.corner', { useProjection: () => usageView() })
assert.ok(chip !== null)
const text = String(chip.props.children)
assert.match(text, /Recall 3/)
assert.match(text, /40/)
assert.match(text, /kept out/)
// Money is deliberately absent from the chip: a fraction of a penny reads as
// the tool being useless rather than as the arithmetic being honest.
assert.ok(!text.includes('$'), `chip showed a price: ${text}`)
})
test('the chip tooltip carries the figures the chip has no room for', () => {
const { renderSlot } = mount(makeScope())
const chip = renderSlot('conversation.session.header.corner', { useProjection: () => usageView({ rereadTokens: 500_000 }) })
const detail = chip.props.title
assert.match(detail, /3 searches/)
assert.match(detail, /9 excerpts from 5 files/)
assert.match(detail, /would have been read/)
assert.match(detail, /estimate/i, 'the comparison must be labelled as one')
})
test('the sidebar panel explains an unused conversation instead of showing zeroes', () => {
const { renderSlot } = mount(makeScope())
const empty = renderSlot('sidebar.right.pane.tab', { useProjection: () => usageView({ searches: 0, indexes: 0, excerpts: 0 }) })
const text = JSON.stringify(empty)
assert.match(text, /has not used recall yet/)
assert.ok(!/\b0 B\b/.test(text), 'zeroes would read as "this did nothing"')
})
test('the sidebar panel says so when the projection is absent', () => {
const { renderSlot } = mount(makeScope())
const missing = renderSlot('sidebar.right.pane.tab', {})
assert.match(JSON.stringify(missing), /not available for this conversation/)
})
test('the sidebar panel reports the comparison and the compounding', () => {
const { renderSlot } = mount(makeScope())
const panel = renderSlot('sidebar.right.pane.tab', { useProjection: () => usageView({ rereadTokens: 1_200_000 }) })
const text = JSON.stringify(panel)
assert.match(text, /Kept out of this conversation/)
assert.match(text, /Smaller than reading the files/)
assert.match(text, /Re-read since/)
assert.match(text, /1,200,000 tokens/)
assert.match(text, /upper bound/, 'the panel must say what the figure is not')
})
test('the tab title is registered under the same key as the panel', () => {
const { registered } = mount(makeScope())
const panel = registered.find((entry) => entry.meta.name === 'sidebar.right.pane.tab')
const title = registered.find((entry) => entry.meta.name === 'sidebar.right.pane.tab.title')
assert.equal(panel.meta.key, 'dsh-recall')
assert.equal(title.meta.key, panel.meta.key, 'the title must bind to the panel it names')
})
+97
View File
@@ -0,0 +1,97 @@
import { strict as assert } from 'node:assert'
import { test } from 'node:test'
import { Config, SETTING_KEYS, validateSettings } from '../index.js'
/**
* The load-time contract.
*
* `apply` never runs when Cordis rejects a plugin's `Config`, so these are the
* only failures a plugin cannot report from inside itself — the profile simply
* refuses to boot. The rest of the suite calls `apply` directly and would never
* notice, which is exactly why this file exists separately.
*/
test('Config is a Standard Schema, which is what Cordis requires', () => {
assert.ok(Config !== undefined, 'Config must be exported')
assert.ok(Config['~standard'] !== undefined, 'Config must implement the Standard Schema interface')
})
test('Config fills every field from an empty input, so apply can rely on defaults', () => {
const filled = Config({})
for (const key of SETTING_KEYS) {
assert.ok(key in filled, `${key} has no default and would be undefined in apply`)
}
assert.equal(filled.embeddingApiBase, 'http://127.0.0.1:11434')
assert.equal(filled.embeddingDimensions, 768)
assert.equal(filled.syncBudgetMs, 20_000)
assert.deepEqual(filled.exclude, [])
assert.deepEqual(filled.extensions, [])
})
test('Config keeps an explicit value and fills the rest', () => {
const filled = Config({ embeddingApiBase: 'http://10.50.1.2:11434', embeddingConcurrency: 8 })
assert.equal(filled.embeddingApiBase, 'http://10.50.1.2:11434')
assert.equal(filled.embeddingConcurrency, 8)
assert.equal(filled.embeddingBatchSize, 16)
assert.equal(filled.embeddingTimeoutMs, 30_000)
assert.equal(filled.embeddingKeepAlive, '5m')
// The per-excerpt cap equals the chunk cap, so chunks arrive whole.
assert.equal(filled.excerptLines, 80)
assert.equal(filled.totalExcerptLines, 400)
assert.equal(filled.maxChunkLines, 80)
})
test('a wrongly typed value fails loudly at load rather than silently in apply', () => {
assert.throws(() => Config({ embeddingDimensions: 'seven hundred' }))
assert.throws(() => Config({ exclude: 'src/generated/' }))
assert.throws(() => Config({ embeddingApiBase: { host: 'localhost' } }))
})
test('every declared setting is a real schema key, so none is dead config', () => {
const filled = Config({})
for (const key of SETTING_KEYS) {
assert.ok(Object.hasOwn(filled, key), `${key} is listed in SETTING_KEYS but is not in the schema`)
}
// And the reverse: no schema field is missing from the documented list, or
// `configure` would reject a setting the schema happily accepts.
for (const key of Object.keys(filled)) {
assert.ok(SETTING_KEYS.includes(key), `${key} is in the schema but not in SETTING_KEYS`)
}
})
/** The defaults plus an override, as the settings provider would resolve them. */
function settingsWith(patch) {
return { ...Config({}), ...patch }
}
test('validateSettings accepts the defaults', () => {
assert.doesNotThrow(() => validateSettings(Config({})))
})
test('validateSettings rejects an endpoint that is not a URL', () => {
// A bare host:port parses successfully — `localhost:` is read as the scheme —
// so it is the protocol check that actually catches the common mistake.
assert.throws(() => validateSettings(settingsWith({ embeddingApiBase: 'localhost:11434' })), /must be http or https/)
assert.throws(() => validateSettings(settingsWith({ embeddingApiBase: '' })), /not a URL/)
assert.doesNotThrow(() => validateSettings(settingsWith({ embeddingApiBase: 'http://10.50.1.2:11434' })))
})
test('validateSettings rejects a non-http protocol', () => {
assert.throws(() => validateSettings(settingsWith({ embeddingApiBase: 'file:///tmp/socket' })), /must be http or https/)
})
test('validateSettings rejects values that would break the pipeline', () => {
assert.throws(() => validateSettings(settingsWith({ embeddingDimensions: 0 })), /positive number/)
assert.throws(() => validateSettings(settingsWith({ embeddingConcurrency: 0 })), /at least 1/)
assert.throws(() => validateSettings(settingsWith({ excerptLines: 0 })), /at least 1/)
assert.throws(() => validateSettings(settingsWith({ syncBudgetMs: -1 })), /cannot be negative/)
})
test('validateSettings rejects cross-field contradictions the schema cannot see', () => {
assert.throws(() => validateSettings(settingsWith({ defaultLimit: 50, maxLimit: 10 })), /cannot exceed maxLimit/)
assert.throws(() => validateSettings(settingsWith({ chunkLines: 100, maxChunkLines: 80 })), /below chunkLines/)
// An overlap at or above the cap means a split never advances.
assert.throws(() => validateSettings(settingsWith({ chunkLines: 10, maxChunkLines: 10, overlapLines: 10 })), /never advances/)
assert.throws(() => validateSettings(settingsWith({ minSimilarity: 1.5 })), /between 0 and 1/)
})
+173
View File
@@ -0,0 +1,173 @@
import { strict as assert } from 'node:assert'
import { test } from 'node:test'
import { EmbeddingError, clampInput, createEmbedder, dot, normalize } from '../lib/embed.js'
import { makeStubFetch } from './helpers.js'
function embedder(fetchImpl, overrides = {}) {
return createEmbedder({
apiBase: 'http://stub',
model: 'test-model',
dimensions: 768,
fetchImpl,
concurrency: 2,
...overrides,
})
}
test('normalize produces a unit vector and leaves zero alone', () => {
const unit = normalize(Float32Array.from([3, 4]))
assert.equal(Math.round(Math.hypot(unit[0], unit[1]) * 1000) / 1000, 1)
assert.deepEqual(Array.from(normalize(Float32Array.from([0, 0]))), [0, 0])
})
test('dot is the cosine similarity of unit vectors', () => {
assert.equal(dot(Float32Array.from([1, 0]), Float32Array.from([1, 0])), 1)
assert.equal(dot(Float32Array.from([1, 0]), Float32Array.from([0, 1])), 0)
})
test('clampInput truncates to the character budget', () => {
assert.equal(clampInput('abc').length, 3)
assert.equal(clampInput('x'.repeat(50_000)).length, 24_000)
})
test('embed uses the batch endpoint and preserves order', async () => {
const fetchImpl = makeStubFetch()
const result = await embedder(fetchImpl).embed(['alpha alpha', 'beta beta'])
assert.equal(result.length, 2)
assert.equal(fetchImpl.calls.filter((call) => call.url.endsWith('/api/embed')).length, 1)
// The two texts share no tokens, so their vectors must be orthogonal-ish.
assert.ok(dot(result[0], result[1]) < 0.05)
})
test('embed returns unit vectors', async () => {
const [vector] = await embedder(makeStubFetch()).embed(['some real code here'])
let sum = 0
for (const value of vector) sum += value * value
assert.ok(Math.abs(sum - 1) < 1e-5)
})
test('an endpoint without the batch API falls back to single requests', async () => {
const fetchImpl = makeStubFetch({ unsupportedBatch: true })
const subject = embedder(fetchImpl)
const result = await subject.embed(['one two', 'three four'])
assert.equal(result.length, 2)
assert.equal(subject.batchEnabled, false)
assert.equal(fetchImpl.calls.filter((call) => call.url.endsWith('/api/embeddings')).length, 2)
})
test('a dimension mismatch is fatal and explains itself', async () => {
const fetchImpl = makeStubFetch({ width: 128 })
await assert.rejects(
() => embedder(fetchImpl).embed(['x']),
(error) => {
assert.ok(error instanceof EmbeddingError)
assert.equal(error.code, 'DIMENSION_MISMATCH')
assert.match(error.message, /128-dimensional .* 768 are configured/)
return true
},
)
})
test('a persistent server error surfaces as UNREACHABLE after retrying', async () => {
let attempts = 0
const fetchImpl = async () => {
attempts += 1
return { ok: false, status: 500, text: async () => 'boom', json: async () => ({}) }
}
await assert.rejects(
() => embedder(fetchImpl).embed(['x']),
(error) => {
assert.equal(error.code, 'UNREACHABLE')
return true
},
)
assert.equal(attempts, 3, 'the request should be attempted MAX_ATTEMPTS times')
})
test('a transient failure is retried and then succeeds', async () => {
const good = makeStubFetch()
let calls = 0
const fetchImpl = async (url, init) => {
calls += 1
if (calls === 1) throw new Error('socket hang up')
return good(url, init)
}
const [vector] = await embedder(fetchImpl).embed(['retry me'])
assert.equal(vector.length, 768)
assert.equal(calls, 2)
})
test('a client error is not retried', async () => {
let attempts = 0
const fetchImpl = async () => {
attempts += 1
return { ok: false, status: 400, text: async () => 'bad model', json: async () => ({}) }
}
await assert.rejects(
() => embedder(fetchImpl).embed(['x']),
(error) => {
assert.equal(error.code, 'HTTP_ERROR')
return true
},
)
assert.equal(attempts, 1, 'a 400 is a configuration fault, not a transient one')
})
test('embedding nothing makes no request', async () => {
const fetchImpl = makeStubFetch()
const result = await embedder(fetchImpl).embed([])
assert.deepEqual(result, [])
assert.equal(fetchImpl.calls.length, 0)
})
test('cancellation stops the work', async () => {
const fetchImpl = makeStubFetch()
const controller = new AbortController()
controller.abort()
await assert.rejects(() => embedder(fetchImpl).embed(['x'], controller.signal))
})
test('keepAlive and maxAttempts are sent only when configured', async () => {
const withKeepAlive = makeStubFetch()
await embedder(withKeepAlive, { keepAlive: '10m' }).embed(['x'])
assert.equal(withKeepAlive.calls[0].body.keep_alive, '10m')
const without = makeStubFetch()
await embedder(without).embed(['x'])
assert.equal('keep_alive' in without.calls[0].body, false, 'an unset keep-alive must not be sent')
})
test('the keep-alive also reaches the single-text endpoint', async () => {
const fetchImpl = makeStubFetch({ unsupportedBatch: true })
await embedder(fetchImpl, { keepAlive: '2m' }).embed(['x'])
const legacy = fetchImpl.calls.find((call) => call.url.endsWith('/api/embeddings'))
assert.equal(legacy.body.keep_alive, '2m')
})
test('maxAttempts of one gives a probe a single chance', async () => {
let attempts = 0
const fetchImpl = async () => {
attempts += 1
return { ok: false, status: 500, text: async () => 'boom', json: async () => ({}) }
}
await assert.rejects(() => embedder(fetchImpl, { maxAttempts: 1 }).embed(['x']))
assert.equal(attempts, 1)
})
test('a socket that accepts and never answers fails on the timeout, not forever', async () => {
// The real failure mode observed in development: connection opens, no bytes.
const fetchImpl = (url, init) =>
new Promise((_resolve, reject) => {
init.signal.addEventListener('abort', () => reject(init.signal.reason), { once: true })
})
const started = Date.now()
await assert.rejects(
() => embedder(fetchImpl, { timeoutMs: 300, maxAttempts: 1 }).embed(['x']),
(error) => {
assert.equal(error.code, 'UNREACHABLE')
return true
},
)
assert.ok(Date.now() - started < 5_000, 'the timeout must bound the wait')
})
+120
View File
@@ -0,0 +1,120 @@
import { strict as assert } from 'node:assert'
import { test } from 'node:test'
import { cleanVariant, createExpander, parseVariants } from '../lib/expand.js'
/** A stub endpoint that answers with a canned model response. */
function stubFetch(response, options = {}) {
const { status = 200, hang = false } = options
const calls = []
const impl = async (url, init) => {
calls.push({ url, body: JSON.parse(init.body) })
if (hang) {
return new Promise((_resolve, reject) => {
init.signal.addEventListener('abort', () => reject(init.signal.reason), { once: true })
})
}
return { ok: status === 200, status, json: async () => ({ response }) }
}
impl.calls = calls
return impl
}
const MODEL = { apiBase: 'http://stub:11434', model: 'small-model', timeoutMs: 2_000, count: 4 }
test('cleanVariant strips what models add around a list', () => {
assert.equal(cleanVariant('1. occupancy state machine'), 'occupancy state machine')
assert.equal(cleanVariant('- presence detection'), 'presence detection')
assert.equal(cleanVariant('* room occupied handler'), 'room occupied handler')
assert.equal(cleanVariant('"roomOccupied"'), 'roomOccupied')
assert.equal(cleanVariant('occupancy check — this finds the handler'), 'occupancy check')
assert.equal(cleanVariant(' '), undefined)
assert.equal(cleanVariant('x'.repeat(300)), undefined, 'a paragraph is not a search phrase')
assert.equal(cleanVariant('Here are some phrasings:'), undefined)
})
test('model commentary is never mistaken for a phrasing', () => {
// A line like this is not a weak variant, it is not a variant at all:
// embedding it would search the codebase for the model's apology.
for (const line of ['I cannot help with that.', "I can't do that.", 'Sorry, I need more context.', 'Unfortunately I do not have enough information.', 'Here are some phrasings:']) {
assert.equal(cleanVariant(line), undefined, `accepted ${line}`)
}
// But a question-shaped phrasing is entirely legitimate, and must survive.
assert.equal(cleanVariant('how does a room decide it is occupied'), 'how does a room decide it is occupied')
assert.equal(cleanVariant('where is the occupancy handler'), 'where is the occupancy handler')
})
test('parseVariants dedupes, drops the original, and caps the count', () => {
const body = ['occupancy state machine', 'room occupied detection', 'OCCUPANCY STATE MACHINE', 'presence tracking', 'extra one', 'extra two'].join('\n')
const variants = parseVariants(body, 'how does a room decide it is occupied', 3)
assert.deepEqual(variants, ['occupancy state machine', 'room occupied detection', 'presence tracking'])
})
test('expansion returns the original first, then the variants', async () => {
const fetchImpl = stubFetch('occupancy state machine\nroom occupied handler')
const variants = await createExpander({ ...MODEL, fetchImpl }).expand('how does a room decide it is occupied')
assert.equal(variants[0], 'how does a room decide it is occupied')
assert.deepEqual(variants.slice(1), ['occupancy state machine', 'room occupied handler'])
// The query must actually reach the model.
assert.match(fetchImpl.calls[0].body.prompt, /how does a room decide it is occupied/)
assert.equal(fetchImpl.calls[0].body.stream, false)
})
test('an unreachable endpoint degrades to the original query, never an error', async () => {
const fetchImpl = async () => {
throw new Error('ECONNREFUSED')
}
const variants = await createExpander({ ...MODEL, fetchImpl }).expand('some query')
assert.deepEqual(variants, ['some query'])
})
test('a WEDGED endpoint degrades to the original query, and does not hang', async () => {
// The failure actually observed on the box: the socket opens and nothing ever
// comes back. Expansion must give up on its own deadline rather than pass the
// wait on to a search that has nothing to do with the local model.
const fetchImpl = stubFetch('', { hang: true })
const started = Date.now()
const variants = await createExpander({ ...MODEL, fetchImpl, timeoutMs: 120 }).expand('some query')
assert.deepEqual(variants, ['some query'])
assert.ok(Date.now() - started < 3_000, 'the internal deadline must bound the wait')
})
test('a non-200 response degrades rather than throwing', async () => {
const variants = await createExpander({ ...MODEL, fetchImpl: stubFetch('', { status: 500 }) }).expand('q')
assert.deepEqual(variants, ['q'])
})
test('a response with nothing usable leaves just the original', async () => {
const variants = await createExpander({ ...MODEL, fetchImpl: stubFetch('I cannot help with that.') }).expand('q')
assert.deepEqual(variants, ['q'])
})
test('a non-finite timeout is clamped, not turned into a silent fallback', async () => {
// Without the clamp, AbortSignal.timeout(NaN) throws and the catch below
// reports it as "the model had nothing to say" — a misconfiguration wearing
// the disguise of a slow model.
const fetchImpl = stubFetch('occupancy handler')
const variants = await createExpander({ ...MODEL, fetchImpl, timeoutMs: Number.NaN }).expand('q')
assert.deepEqual(variants, ['q', 'occupancy handler'])
})
test('a malformed payload degrades rather than throwing', async () => {
const fetchImpl = async () => ({ ok: true, status: 200, json: async () => ({}) })
assert.deepEqual(await createExpander({ ...MODEL, fetchImpl }).expand('q'), ['q'])
})
test('an empty or oversized query is not sent at all', async () => {
const fetchImpl = stubFetch('variant one')
const expander = createExpander({ ...MODEL, fetchImpl })
assert.deepEqual(await expander.expand(' '), [''])
assert.deepEqual(await expander.expand('x'.repeat(600)), ['x'.repeat(600)])
assert.equal(fetchImpl.calls.length, 0, 'nothing worth asking about')
})
test('caller cancellation stops expansion and returns the original', async () => {
const controller = new AbortController()
controller.abort()
const fetchImpl = stubFetch('variant one')
assert.deepEqual(await createExpander({ ...MODEL, fetchImpl }).expand('q', controller.signal), ['q'])
assert.equal(fetchImpl.calls.length, 0)
})
+222
View File
@@ -0,0 +1,222 @@
import { strict as assert } from 'node:assert'
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { test } from 'node:test'
import {
DEFAULT_FOCUS_MARKERS,
ancestorsTo,
discoverScopes,
focusKey,
isInside,
resolveFocus,
} from '../lib/focus.js'
const WORKSPACE = '/repo'
/**
* The two filesystem injection points, faked.
*
* `exists` and `isDirectory` are separate because the real code distinguishes
* them: a focus path must not merely exist, it must be a directory. A single
* predicate would let a test pass a file where a directory is required.
*
* @param {string[]} files paths that exist
* @param {string[]} [directories] paths that are directories (defaults to `files`)
*/
function fakeFs(files, directories = files) {
const fileSet = new Set(files)
const dirSet = new Set(directories)
return { exists: (path) => fileSet.has(path), isDirectory: (path) => dirSet.has(path) }
}
test('isInside treats a directory as inside itself and rejects siblings', () => {
assert.equal(isInside('/repo', '/repo'), true)
assert.equal(isInside('/repo', '/repo/src'), true)
assert.equal(isInside('/repo', '/repository'), false, 'a shared prefix is not a child')
assert.equal(isInside('/repo', '/other'), false)
})
test('ancestorsTo walks up to the stop directory and no further', () => {
assert.deepEqual(ancestorsTo('/repo/src/a/b', '/repo'), ['/repo/src/a/b', '/repo/src/a', '/repo/src', '/repo'])
assert.deepEqual(ancestorsTo('/repo', '/repo'), ['/repo'])
// A start outside the stop does not run away to the filesystem root.
assert.deepEqual(ancestorsTo('/elsewhere/x', '/repo'), ['/elsewhere/x'])
})
test('no markers anywhere means the workspace, which is the old behaviour', () => {
const result = resolveFocus({ workspace: WORKSPACE, cwd: WORKSPACE, ...fakeFs([]) })
assert.equal(result.root, '/repo')
assert.equal(result.source, 'workspace')
})
test('a marker at the workspace root keeps the scope at the workspace', () => {
// This is the live case today: AGENTS.md sits at the repository root, so
// adopting focus changes nothing until a nested folder carries a marker.
const result = resolveFocus({ workspace: WORKSPACE, cwd: WORKSPACE, ...fakeFs(['/repo/AGENTS.md']) })
assert.equal(result.root, '/repo')
assert.equal(result.source, 'marker')
assert.equal(result.marker, 'AGENTS.md')
})
test('a nested controller marker narrows the scope to that controller', () => {
const controller = '/repo/src/controllers/room/occupancy'
const result = resolveFocus({
workspace: WORKSPACE,
cwd: controller,
...fakeFs(['/repo/AGENTS.md', `${controller}/CONTROLLER.md`]),
})
assert.equal(result.root, controller)
assert.equal(result.source, 'marker')
assert.equal(result.marker, 'CONTROLLER.md')
})
test('the nearest marker wins over a further one', () => {
const result = resolveFocus({
workspace: WORKSPACE,
cwd: '/repo/src/controllers/room/occupancy/state_machine',
...fakeFs(['/repo/AGENTS.md', '/repo/src/controllers/room/occupancy/CONTROLLER.md']),
})
assert.equal(result.root, '/repo/src/controllers/room/occupancy')
})
test('CONTROLLER.md outranks AGENTS.md in the same directory', () => {
const result = resolveFocus({
workspace: WORKSPACE,
cwd: WORKSPACE,
...fakeFs(['/repo/AGENTS.md', '/repo/CONTROLLER.md']),
})
assert.equal(result.marker, 'CONTROLLER.md', 'the more specific marker should be reported')
})
test('an explicit focus wins over any marker', () => {
const lights = '/repo/src/controllers/room/device/lights'
const result = resolveFocus({
workspace: WORKSPACE,
cwd: WORKSPACE,
explicit: 'src/controllers/room/device/lights',
...fakeFs(['/repo/AGENTS.md', lights]),
})
assert.equal(result.root, lights)
assert.equal(result.source, 'explicit')
})
test('an explicit focus may be absolute', () => {
const result = resolveFocus({ workspace: WORKSPACE, explicit: '/repo/src/x', ...fakeFs(['/repo/src/x']) })
assert.equal(result.root, '/repo/src/x')
assert.equal(result.source, 'explicit')
})
test('an explicit focus that is a file, not a directory, is refused', () => {
// The two predicates must genuinely differ, or this passes by accident.
const result = resolveFocus({
workspace: WORKSPACE,
explicit: 'src/AGENTS.md',
...fakeFs(['/repo/src/AGENTS.md'], ['/repo', '/repo/src']),
})
assert.equal(result.root, '/repo')
assert.match(result.problem, /not a directory/)
})
test('a focus outside the workspace is refused and reported, not obeyed', () => {
const result = resolveFocus({
workspace: WORKSPACE,
explicit: '../elsewhere',
...fakeFs(['/elsewhere'], ['/elsewhere']),
})
assert.equal(result.root, '/repo', 'must fall back to the workspace')
assert.match(result.problem, /outside the workspace/)
})
test('a focus that does not exist is refused and reported', () => {
const result = resolveFocus({ workspace: WORKSPACE, explicit: 'src/typo', ...fakeFs([]) })
assert.equal(result.root, '/repo')
assert.match(result.problem, /not a directory/)
})
test('an empty marker list still resolves, to the workspace', () => {
const result = resolveFocus({
workspace: WORKSPACE,
cwd: WORKSPACE,
markers: [],
...fakeFs(['/repo/AGENTS.md']),
})
assert.equal(result.root, '/repo')
assert.equal(result.source, 'workspace')
})
test('a cwd outside the workspace cannot escape it', () => {
const result = resolveFocus({
workspace: WORKSPACE,
cwd: '/somewhere/else',
...fakeFs(['/somewhere/else/AGENTS.md', '/repo/AGENTS.md']),
})
assert.equal(result.root, '/repo', 'a stray cwd must not pull the scope out of the workspace')
})
test('focusKey is a stable relative path, with "." for the workspace itself', () => {
assert.equal(focusKey('/repo', '/repo'), '.')
assert.equal(focusKey('/repo', '/repo/src/controllers/room/occupancy'), 'src/controllers/room/occupancy')
})
test('the default markers include the controller instruction files', () => {
assert.ok(DEFAULT_FOCUS_MARKERS.includes('CONTROLLER.md'))
assert.ok(DEFAULT_FOCUS_MARKERS.includes('DEBUG_UI.md'))
// CONTROLLER.md must be tried before AGENTS.md, since both may be present.
assert.ok(DEFAULT_FOCUS_MARKERS.indexOf('CONTROLLER.md') < DEFAULT_FOCUS_MARKERS.indexOf('AGENTS.md'))
})
test('discoverScopes finds marker directories, sorted and workspace-relative', async () => {
const root = await mkdtemp(join(tmpdir(), 'recall-scopes-'))
try {
await mkdir(join(root, 'src', 'weather'), { recursive: true })
await mkdir(join(root, 'src', 'auth'), { recursive: true })
await mkdir(join(root, 'node_modules', 'dep'), { recursive: true })
await writeFile(join(root, 'src', 'auth', 'CONTROLLER.md'), '# auth\n')
await writeFile(join(root, 'src', 'weather', 'CONTROLLER.md'), '# weather\n')
await writeFile(join(root, 'AGENTS.md'), '# repo\n')
// A marker inside a dependency describes somebody else's project.
await writeFile(join(root, 'node_modules', 'dep', 'CONTROLLER.md'), '# not ours\n')
await writeFile(join(root, 'src', 'code.ts'), 'export const x = 1\n')
const scopes = await discoverScopes({ workspace: root })
assert.deepEqual(
scopes.map((scope) => scope.path),
['.', 'src/auth', 'src/weather'],
'sorted, relative, and node_modules excluded',
)
assert.equal(scopes[0].marker, 'AGENTS.md')
assert.equal(scopes[1].marker, 'CONTROLLER.md')
} finally {
await rm(root, { recursive: true, force: true })
}
})
test('discoverScopes honours a custom marker list and maxDepth', async () => {
const root = await mkdtemp(join(tmpdir(), 'recall-scopes-'))
try {
await mkdir(join(root, 'deep', 'deeper', 'deepest'), { recursive: true })
await writeFile(join(root, 'deep', 'SCOPE.md'), '# scope\n')
await writeFile(join(root, 'deep', 'deeper', 'deepest', 'SCOPE.md'), '# scope\n')
const shallow = await discoverScopes({ workspace: root, markers: ['SCOPE.md'], maxDepth: 1 })
assert.deepEqual(shallow.map((scope) => scope.path), ['deep'])
const deep = await discoverScopes({ workspace: root, markers: ['SCOPE.md'], maxDepth: 4 })
assert.deepEqual(deep.map((scope) => scope.path), ['deep', 'deep/deeper/deepest'])
} finally {
await rm(root, { recursive: true, force: true })
}
})
test('discoverScopes returns nothing for a directory with no markers', async () => {
const root = await mkdtemp(join(tmpdir(), 'recall-scopes-'))
try {
await mkdir(join(root, 'src'), { recursive: true })
await writeFile(join(root, 'src', 'a.ts'), 'a\n')
assert.deepEqual(await discoverScopes({ workspace: root }), [])
} finally {
await rm(root, { recursive: true, force: true })
}
})
+123
View File
@@ -0,0 +1,123 @@
/**
* Shared test scaffolding.
*
* The embedding stub is a bag-of-words hash rather than the real model, for two
* reasons: the suite must run without a network, and a deterministic vector
* makes a ranking assertion a statement about the ranking code rather than a
* statement about a model that may change under it. Whether the real model
* retrieves the right file is a different question, answered by
* `scripts/smoke.mjs` against a live endpoint.
*/
import { createHash } from 'node:crypto'
import { mkdtemp, mkdir, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
/** Deterministic unit vector for a piece of text. */
export function hashEmbed(text, width) {
const vector = new Float32Array(width)
for (const token of String(text).toLowerCase().split(/[^a-z0-9]+/)) {
if (token.length < 3) continue
const digest = createHash('sha1').update(token).digest()
const slot = ((digest[0] << 8) | digest[1]) % width
vector[slot] += 1
}
let sum = 0
for (let at = 0; at < width; at += 1) sum += vector[at] * vector[at]
if (sum === 0) return vector
const scale = 1 / Math.sqrt(sum)
for (let at = 0; at < width; at += 1) vector[at] *= scale
return vector
}
/** A `fetch` that answers the Ollama embedding API deterministically. */
export function makeStubFetch(options = {}) {
const { width = 768, onCall, unsupportedBatch = false, generate = undefined } = options
const calls = []
const impl = async (url, init) => {
const address = String(url)
const body = JSON.parse(init.body)
calls.push({ url: address, body })
onCall?.(address, body)
// The generation endpoint the expander and the re-ranker share. Left
// unanswered unless a test supplies a response, so a caller that expects
// the local model to be absent gets that for free.
if (address.endsWith('/api/generate')) {
if (generate === undefined) return { ok: false, status: 404, text: async () => 'no model', json: async () => ({}) }
const response = typeof generate === 'function' ? generate(body) : generate
return { ok: true, status: 200, json: async () => ({ response }) }
}
if (address.endsWith('/api/embed')) {
if (unsupportedBatch) {
return { ok: false, status: 404, text: async () => 'not found', json: async () => ({}) }
}
const inputs = body.input
return {
ok: true,
status: 200,
json: async () => ({ embeddings: inputs.map((text) => Array.from(hashEmbed(text, width))) }),
}
}
if (address.endsWith('/api/embeddings')) {
return {
ok: true,
status: 200,
json: async () => ({ embedding: Array.from(hashEmbed(body.prompt, width)) }),
}
}
return { ok: false, status: 404, text: async () => 'unknown endpoint', json: async () => ({}) }
}
impl.calls = calls
return impl
}
/**
* The smallest context shape `apply` actually touches.
*
* `services` mounts optional dependencies: `ctx.inject` invokes its callback
* only when every named dependency is present, which is how the plugin decides
* whether it can offer a settings namespace or a projection. Passing none
* exercises the composition-config path every minimal profile takes.
*/
export function makeContext(options = {}) {
const { services = {} } = options
const tools = []
const cleanups = []
const ctx = {
logger: () => ({ warn() {}, info() {}, error() {}, debug() {} }),
effect: (fn) => {
const dispose = fn()
if (typeof dispose === 'function') cleanups.push(dispose)
},
get: (name) => services[name],
inject: (deps, callback) => {
const missing = deps.filter((dep) => services[dep] === undefined)
if (missing.length > 0) return
callback({ ...ctx, ...Object.fromEntries(deps.map((dep) => [dep, services[dep]])) })
},
tools: { register: (tool) => tools.push(tool) },
}
return { ctx, tools, dispose: () => cleanups.forEach((fn) => fn()) }
}
/** An `exec` argument for one tool call. */
export function makeExec(cwd, signal) {
return { signal: signal ?? new AbortController().signal, agent: { session: { header: { cwd } } } }
}
/** Write a map of relative path to content into a fresh temporary directory. */
export async function makeWorkspace(files) {
const root = await mkdtemp(join(tmpdir(), 'recall-test-'))
for (const [path, content] of Object.entries(files)) {
const absolute = join(root, path)
await mkdir(dirname(absolute), { recursive: true })
await writeFile(absolute, content, 'utf8')
}
return root
}
/** A fresh temporary directory, for index state. */
export async function makeStateDir() {
return mkdtemp(join(tmpdir(), 'recall-state-'))
}
+690
View File
@@ -0,0 +1,690 @@
import { strict as assert } from 'node:assert'
import { readFile, rm, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { test } from 'node:test'
import { apply } from '../index.js'
import { makeContext, makeExec, makeStateDir, makeStubFetch, makeWorkspace } from './helpers.js'
/**
* Validate a value against the tool's own declared output schema.
*
* This exists because the schema is `additionalProperties: false` and the host
* enforces it before the model ever sees a result — so an internal field leaking
* into the returned object is not a cosmetic extra, it is a failed call. The
* check is deliberately written against the declared schema rather than a copy
* of it, so the two cannot drift apart.
*/
function schemaErrors(schema, value, at = '$') {
const errors = []
if (schema.oneOf !== undefined) {
if (!schema.oneOf.some((branch) => schemaErrors(branch, value, at).length === 0)) {
errors.push(`${at}: matched no oneOf branch`)
}
return errors
}
const type = schema.type
if (type === 'object') {
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
errors.push(`${at}: expected an object`)
return errors
}
const properties = schema.properties ?? {}
for (const [key, sub] of Object.entries(properties)) {
if (sub.required === true && value[key] === undefined) errors.push(`${at}.${key}: required but missing`)
}
if (schema.additionalProperties === false) {
for (const key of Object.keys(value)) {
if (!(key in properties)) errors.push(`${at}.${key}: not declared, but additionalProperties is false`)
}
}
for (const [key, sub] of Object.entries(properties)) {
if (value[key] !== undefined) errors.push(...schemaErrors(sub, value[key], `${at}.${key}`))
}
return errors
}
if (type === 'array') {
if (!Array.isArray(value)) {
errors.push(`${at}: expected an array`)
return errors
}
value.forEach((item, index) => errors.push(...schemaErrors(schema.items ?? {}, item, `${at}[${index}]`)))
return errors
}
if (type === 'json') return errors
if (type === 'string' && typeof value !== 'string') errors.push(`${at}: expected a string`)
if (type === 'boolean' && typeof value !== 'boolean') errors.push(`${at}: expected a boolean`)
if (type === 'integer' && !Number.isInteger(value)) errors.push(`${at}: expected an integer`)
if (type === 'number' && typeof value !== 'number') errors.push(`${at}: expected a number`)
if (schema.enum !== undefined && !schema.enum.includes(value)) errors.push(`${at}: not one of ${schema.enum.join(', ')}`)
return errors
}
const SESSION = `import { randomBytes } from 'node:crypto'
const SESSION_TTL_MS = 1000 * 60 * 60 * 12
export interface SessionRecord {
userId: string
issuedAt: number
expiresAt: number
}
export function issueSession(userId: string): SessionRecord {
const now = Date.now()
return { userId, issuedAt: now, expiresAt: now + SESSION_TTL_MS }
}
export function isSessionExpired(record: SessionRecord, now = Date.now()): boolean {
return record.expiresAt <= now
}
export function rotateSessionToken(record: SessionRecord): string {
if (isSessionExpired(record)) throw new Error('cannot rotate an expired session')
return randomBytes(32).toString('hex')
}
export function sessionTimeRemaining(record: SessionRecord, now = Date.now()): number {
return Math.max(0, record.expiresAt - now)
}
export function describeSession(record: SessionRecord): string {
const remaining = sessionTimeRemaining(record)
return remaining === 0 ? 'expired' : 'active for ' + remaining + 'ms'
}
`
const FORECAST = `export interface ForecastDay {
date: string
minC: number
maxC: number
rainMm: number
}
export function averageTemperature(days: ForecastDay[]): number {
if (days.length === 0) return 0
return days.reduce((sum, day) => sum + (day.minC + day.maxC) / 2, 0) / days.length
}
export function totalRainfall(days: ForecastDay[]): number {
return days.reduce((sum, day) => sum + day.rainMm, 0)
}
`
const SQLITE = `import { DatabaseSync } from 'node:sqlite'
export function openDatabase(path: string): DatabaseSync {
const db = new DatabaseSync(path)
db.exec('PRAGMA journal_mode = WAL')
return db
}
`
const FILES = {
'src/auth/session.ts': SESSION,
'src/weather/forecast.ts': FORECAST,
'src/store/sqlite.ts': SQLITE,
}
function configFor(stateDir, extra = {}) {
return {
embeddingApiBase: 'http://stub',
embeddingModel: 'test-model',
embeddingDimensions: 768,
embeddingApiKey: '',
embeddingBatchSize: 32,
embeddingConcurrency: 2,
embeddingTimeoutMs: 5_000,
extensions: [],
exclude: [],
maxFiles: 500,
maxFileBytes: 1_048_576,
chunkLines: 48,
maxChunkLines: 80,
overlapLines: 8,
defaultLimit: 8,
maxLimit: 25,
perFileLimit: 2,
excerptLines: 40,
minSimilarity: 0.2,
syncBudgetMs: 20_000,
// These mirror the Config schema exactly. Cordis fills schema defaults
// before `apply`, and this object stands in for that validated config — so
// any key the code reads and this object omits arrives as `undefined`, which
// is how `AbortSignal.timeout(NaN)` came to throw inside the expander.
focus: '',
focusMarkers: [],
expansionEnabled: false,
expansionModel: '',
expansionCount: 4,
expansionTimeoutMs: 8_000,
rerankEnabled: false,
rerankModel: '',
rerankCandidates: 8,
rerankTimeoutMs: 15_000,
stateDir,
...extra,
}
}
/** Apply the plugin to a fresh workspace with a stubbed embedding endpoint. */
async function setup(t, files = FILES, extra = {}, stubOptions = {}) {
const workspace = await makeWorkspace(files)
const stateDir = await makeStateDir()
const stub = makeStubFetch(stubOptions)
const originalFetch = globalThis.fetch
globalThis.fetch = stub
t.after(async () => {
globalThis.fetch = originalFetch
await rm(workspace, { recursive: true, force: true })
await rm(stateDir, { recursive: true, force: true })
})
const { ctx, tools, dispose } = makeContext()
t.after(() => dispose())
apply(ctx, configFor(stateDir, extra))
assert.equal(tools.length, 1, 'exactly one tool should be registered')
return { tool: tools[0], workspace, stub }
}
test('the plugin registers one tool whose declared schema is closed', async (t) => {
const { tool } = await setup(t)
assert.equal(tool.name, 'recall')
assert.equal(tool.output.schema.additionalProperties, false)
// defineTool normalises the parameter DSL into a JSON Schema object.
assert.deepEqual(tool.parameters.properties.action.enum, ['search', 'index', 'status', 'configure'])
assert.equal(tool.parameters.properties.query.type, 'string')
// A long index must not run in parallel with sibling tool calls.
assert.equal(tool.isConcurrencySafe({ action: 'search' }), true)
assert.equal(tool.isConcurrencySafe({ action: 'index' }), false)
})
test('a search returns the right file, as an excerpt rather than a whole file', async (t) => {
const { tool, workspace } = await setup(t)
const value = await tool.execute(
{ query: 'how long until a user session token expires' },
makeExec(workspace),
)
assert.deepEqual(schemaErrors(tool.output.schema, value), [])
assert.equal(value.ok, true)
assert.equal(value.results.length > 0, true)
assert.equal(value.results[0].path, 'src/auth/session.ts')
// The excerpt must be a real slice of the file on disk, not paraphrase.
const onDisk = await readFile(join(workspace, 'src/auth/session.ts'), 'utf8')
assert.ok(onDisk.includes(value.results[0].excerpt), 'the excerpt must come from the file')
assert.ok(
value.results[0].excerpt.split('\n').length <= 40,
'the excerpt must respect the configured line cap',
)
assert.ok(
value.results[0].excerpt.length < onDisk.length,
'the point of the tool is to return less than the whole file',
)
})
test('a second search re-embeds nothing, because the index is incremental', async (t) => {
const { tool, workspace, stub } = await setup(t)
await tool.execute({ query: 'session expiry' }, makeExec(workspace))
const afterFirst = stub.calls.length
assert.ok(afterFirst > 1, 'the first search should have embedded the workspace')
await tool.execute({ query: 'rainfall totals' }, makeExec(workspace))
const second = stub.calls.slice(afterFirst)
// Exactly one request: the query vector. A warm index embeds no files.
assert.equal(second.length, 1, `expected only a query embed, saw ${second.length} requests`)
})
test('an edited file is re-embedded on the next search', async (t) => {
const { tool, workspace, stub } = await setup(t)
await tool.execute({ query: 'session expiry' }, makeExec(workspace))
const afterFirst = stub.calls.length
await writeFile(
join(workspace, 'src/weather/forecast.ts'),
`${FORECAST}\nexport function humidityPercent(days: ForecastDay[]): number {\n return days.length\n}\n`,
'utf8',
)
await tool.execute({ query: 'humidityPercent' }, makeExec(workspace))
const second = stub.calls.slice(afterFirst)
assert.ok(second.length >= 2, 'the changed file plus the query should both be embedded')
assert.deepEqual(second[second.length - 1].body.input, ['humidityPercent'])
})
test('a spent sync budget leaves the rest pending rather than lying about it', async (t) => {
const { tool, workspace } = await setup(t, FILES, { syncBudgetMs: 0 })
// First search indexes one file, then the budget stops it.
const first = await tool.execute({ query: 'session expiry' }, makeExec(workspace))
assert.ok(first.index.pending >= 1, `expected pending work, saw ${first.index.pending}`)
// A full index action catches up.
const full = await tool.execute({ action: 'index', run_in_background: false }, makeExec(workspace))
assert.deepEqual(schemaErrors(tool.output.schema, full), [])
assert.equal(full.index.pending, 0)
const third = await tool.execute({ query: 'session expiry' }, makeExec(workspace))
assert.equal(third.index.pending, 0)
})
test('the index action builds the whole workspace', async (t) => {
const { tool, workspace } = await setup(t)
const value = await tool.execute({ action: 'index', run_in_background: false }, makeExec(workspace))
assert.deepEqual(schemaErrors(tool.output.schema, value), [])
assert.equal(value.ok, true)
assert.equal(value.index.files, 3)
assert.ok(value.index.chunks >= 3)
})
test('status probes the endpoint once and reports it healthy', async (t) => {
const { tool, workspace, stub } = await setup(t)
const value = await tool.execute({ action: 'status' }, makeExec(workspace))
assert.deepEqual(schemaErrors(tool.output.schema, value), [])
assert.equal(value.ok, true)
// Exactly one request: the probe. Status must not index or search.
assert.equal(stub.calls.length, 1, `status should probe once, saw ${stub.calls.length}`)
assert.equal(value.endpoint.ok, true)
assert.match(value.note, /endpoint answered/)
})
test('status reports an endpoint that accepts the connection and never answers', async (t) => {
// The failure mode that a plain reachability check misses, and the one that
// actually happened in development: the socket opens, no bytes ever come back.
const workspace = await makeWorkspace(FILES)
const stateDir = await makeStateDir()
const originalFetch = globalThis.fetch
globalThis.fetch = (url, init) =>
new Promise((_resolve, reject) => {
init.signal.addEventListener('abort', () => reject(init.signal.reason), { once: true })
})
t.after(async () => {
globalThis.fetch = originalFetch
await rm(workspace, { recursive: true, force: true })
await rm(stateDir, { recursive: true, force: true })
})
const { ctx, tools, dispose } = makeContext()
t.after(() => dispose())
// A short timeout keeps the probe honest about how long it will wait.
apply(ctx, configFor(stateDir, { embeddingTimeoutMs: 1_000 }))
const value = await tools[0].execute({ action: 'status' }, makeExec(workspace))
assert.deepEqual(schemaErrors(tools[0].output.schema, value), [])
assert.equal(value.ok, true, 'status itself succeeded; it is reporting a fault, not failing')
assert.equal(value.endpoint.ok, false)
assert.equal(value.endpoint.errorCode, 'UNREACHABLE')
assert.match(value.note, /did NOT answer/)
})
test('a search without a query is refused with advice', async (t) => {
const { tool, workspace } = await setup(t)
const value = await tool.execute({}, makeExec(workspace))
assert.deepEqual(schemaErrors(tool.output.schema, value), [])
assert.equal(value.ok, false)
assert.equal(value.errorCode, 'NO_QUERY')
assert.ok(value.remedy !== undefined)
})
test('a path filter restricts results to that directory', async (t) => {
const { tool, workspace } = await setup(t)
await tool.execute({ action: 'index', run_in_background: false }, makeExec(workspace))
const value = await tool.execute(
{ query: 'function', path: 'src/weather' },
makeExec(workspace),
)
assert.deepEqual(schemaErrors(tool.output.schema, value), [])
assert.ok(value.results.length > 0)
for (const result of value.results) assert.match(result.path, /^src\/weather\//)
})
test('configure reports the missing settings provider instead of pretending to save', async (t) => {
const { tool, workspace } = await setup(t)
const value = await tool.execute(
{ action: 'configure', config: { embeddingApiBase: 'http://elsewhere:11434' } },
makeExec(workspace),
)
assert.deepEqual(schemaErrors(tool.output.schema, value), [])
assert.equal(value.ok, false)
assert.equal(value.errorCode, 'NO_SETTINGS_PROVIDER')
assert.match(value.remedy, /cordis layer/)
})
test('configure rejects an unknown setting by name', async (t) => {
const { tool, workspace } = await setup(t)
const value = await tool.execute(
{ action: 'configure', config: { nonsense: 1 } },
makeExec(workspace),
)
assert.equal(value.errorCode, 'UNKNOWN_SETTING')
assert.match(value.note, /nonsense/)
})
test('an unreachable endpoint fails with a remedy, not a stack trace', async (t) => {
const workspace = await makeWorkspace(FILES)
const stateDir = await makeStateDir()
const originalFetch = globalThis.fetch
// A persistent 5xx, which the client retries and then reports as unreachable.
globalThis.fetch = async () => ({ ok: false, status: 500, text: async () => 'boom', json: async () => ({}) })
t.after(async () => {
globalThis.fetch = originalFetch
await rm(workspace, { recursive: true, force: true })
await rm(stateDir, { recursive: true, force: true })
})
const { ctx, tools, dispose } = makeContext()
t.after(() => dispose())
apply(ctx, configFor(stateDir))
const value = await tools[0].execute({ query: 'anything at all' }, makeExec(workspace))
assert.deepEqual(schemaErrors(tools[0].output.schema, value), [])
assert.equal(value.ok, false)
assert.equal(value.errorCode, 'UNREACHABLE')
assert.match(value.remedy, /embeddingApiBase/)
})
test('a workspace with no indexable code says so plainly', async (t) => {
const { tool, workspace } = await setup(t, { 'README.md': '# prose only\n' })
const value = await tool.execute({ query: 'anything' }, makeExec(workspace))
assert.deepEqual(schemaErrors(tool.output.schema, value), [])
assert.equal(value.ok, true)
assert.deepEqual(value.results, [])
assert.match(value.note, /Nothing is indexed/)
})
test('the rendered result is compact and names the file and line range', async (t) => {
const { tool, workspace } = await setup(t)
const value = await tool.execute({ query: 'session token rotation' }, makeExec(workspace))
const blocks = tool.output.render({ query: 'session token rotation' }, value)
assert.equal(blocks.length, 1)
assert.equal(blocks[0].type, 'text')
assert.match(blocks[0].text, /src\/auth\/session\.ts:\d+-\d+/)
// It must not dump the whole file into the conversation.
assert.ok(blocks[0].text.length < SESSION.length * 3)
})
test('an empty result tells the model to prefer grep', async (t) => {
const { tool, workspace } = await setup(t)
await tool.execute({ action: 'index', run_in_background: false }, makeExec(workspace))
const value = await tool.execute(
{ query: 'zzzz-nothing-matches-this-zzzz', path: 'src/store' },
makeExec(workspace),
)
const blocks = tool.output.render({ query: 'zzzz' }, value)
if (value.results.length === 0) {
assert.match(blocks[0].text, /grep/)
} else {
assert.equal(value.weak, true)
assert.match(blocks[0].text, /No strong match/)
}
})
test('focus narrows the index to one directory, and paths stay workspace-relative', async (t) => {
const { tool, workspace } = await setup(t)
const value = await tool.execute(
{ action: 'index', focus: 'src/auth', run_in_background: false },
makeExec(workspace),
)
assert.deepEqual(schemaErrors(tool.output.schema, value), [])
assert.equal(value.focus.path, 'src/auth')
assert.equal(value.focus.source, 'explicit')
assert.equal(value.index.files, 1, 'only src/auth should be indexed')
const search = await tool.execute({ query: 'session expiry', focus: 'src/auth' }, makeExec(workspace))
assert.ok(search.results.length > 0)
for (const result of search.results) {
// The critical invariant: a result path must be usable with `read`, which
// resolves against the workspace. A focus-relative path here would read the
// wrong file.
assert.match(result.path, /^src\/auth\//, `path was ${result.path}`)
const onDisk = await readFile(join(workspace, result.path), 'utf8')
assert.ok(onDisk.includes(result.excerpt), `${result.path} should resolve from the workspace root`)
}
})
test('each focus keeps its own index', async (t) => {
const { tool, workspace } = await setup(t)
const auth = await tool.execute({ action: 'index', focus: 'src/auth', run_in_background: false }, makeExec(workspace))
const weather = await tool.execute({ action: 'index', focus: 'src/weather', run_in_background: false }, makeExec(workspace))
assert.equal(auth.index.files, 1)
assert.equal(weather.index.files, 1)
assert.notEqual(auth.index.database, weather.index.database, 'focuses must not share an index file')
assert.notEqual(auth.index.database, (await tool.execute({ action: 'status' }, makeExec(workspace))).index.database)
})
test('a search in one focus cannot see another focus', async (t) => {
const { tool, workspace } = await setup(t)
await tool.execute({ action: 'index', focus: 'src/weather', run_in_background: false }, makeExec(workspace))
// A tolerant query that would match the auth module if it were in scope.
const value = await tool.execute({ query: 'issueSession session expiry', focus: 'src/weather' }, makeExec(workspace))
assert.deepEqual(schemaErrors(tool.output.schema, value), [])
for (const result of value.results) assert.match(result.path, /^src\/weather\//)
})
test('an unusable focus is reported and falls back, never silently obeyed', async (t) => {
const { tool, workspace } = await setup(t)
const value = await tool.execute(
{ action: 'index', focus: 'src/does-not-exist', run_in_background: false },
makeExec(workspace),
)
assert.deepEqual(schemaErrors(tool.output.schema, value), [])
assert.equal(value.focus.path, '.', 'must fall back to the workspace')
assert.match(value.focus.problem, /not a directory/)
assert.equal(value.index.files, 3, 'the fallback index covers the whole workspace')
})
test('status reports which focus is in effect, and why', async (t) => {
const { tool, workspace } = await setup(t)
const value = await tool.execute({ action: 'status' }, makeExec(workspace))
assert.deepEqual(schemaErrors(tool.output.schema, value), [])
assert.equal(value.focus.path, '.')
// This fixture has no AGENTS.md, so resolution reaches the final fallback.
assert.equal(value.focus.source, 'workspace')
assert.match(value.note, /scoped to the whole workspace/)
})
test('a session at the workspace root keeps the whole workspace, even with nested scopes', async (t) => {
// Inheritance and discovery are different questions. The root's own AGENTS.md
// is what contains this session; the controller below is merely available.
// Auto-adopting it would hide the other two thirds of the workspace.
const { tool, workspace } = await setup(t, {
'AGENTS.md': '# repo instructions\n',
'src/auth/CONTROLLER.md': '# the auth controller\n',
...FILES,
})
const value = await tool.execute({ action: 'index', run_in_background: false }, makeExec(workspace))
assert.deepEqual(schemaErrors(tool.output.schema, value), [])
assert.equal(value.focus.path, '.')
assert.equal(value.focus.source, 'marker')
assert.equal(value.focus.marker, 'AGENTS.md')
assert.equal(value.index.files, 3)
})
test('discovered scopes are listed, so a caller can name one', async (t) => {
const { tool, workspace } = await setup(t, {
'src/auth/CONTROLLER.md': '# the auth controller\n',
'src/weather/CONTROLLER.md': '# the weather controller\n',
...FILES,
})
const value = await tool.execute({ action: 'status' }, makeExec(workspace))
assert.deepEqual(schemaErrors(tool.output.schema, value), [])
assert.deepEqual(value.scopes, [
{ path: 'src/auth', marker: 'CONTROLLER.md' },
{ path: 'src/weather', marker: 'CONTROLLER.md' },
])
assert.match(value.note, /Available scopes: src\/auth, src\/weather/)
})
test('a workspace with no markers says so rather than listing nothing', async (t) => {
const { tool, workspace } = await setup(t)
const value = await tool.execute({ action: 'status' }, makeExec(workspace))
assert.deepEqual(value.scopes, [])
assert.match(value.note, /No focusable scopes/)
})
test('a scope found by discovery indexes only itself when named', async (t) => {
const { tool, workspace } = await setup(t, {
'src/auth/CONTROLLER.md': '# the auth controller\n',
...FILES,
})
const listed = await tool.execute({ action: 'status' }, makeExec(workspace))
const [scope] = listed.scopes
const value = await tool.execute({ action: 'index', focus: scope.path, run_in_background: false }, makeExec(workspace))
assert.equal(value.focus.source, 'explicit')
assert.equal(value.index.files, 1, 'naming a discovered scope must confine the index to it')
})
test('the rendered search header names the scope when there is one', async (t) => {
const { tool, workspace } = await setup(t)
await tool.execute({ action: 'index', focus: 'src/auth', run_in_background: false }, makeExec(workspace))
const value = await tool.execute({ query: 'session', focus: 'src/auth' }, makeExec(workspace))
const blocks = tool.output.render({ query: 'session' }, value)
assert.match(blocks[0].text, /in src\/auth/)
})
test('the tool records what it saved, for the session log to carry', async (t) => {
const { tool, workspace } = await setup(t)
await tool.execute({ action: 'index', run_in_background: false }, makeExec(workspace))
const value = await tool.execute({ query: 'session expiry' }, makeExec(workspace))
assert.deepEqual(schemaErrors(tool.output.schema, value), [])
// The comparison is against the whole of each file the excerpts came from.
assert.ok(value.measure.sourceBytes > 0)
assert.ok(
value.measure.sourceBytes > value.measure.returnedBytes,
`expected a saving, got ${value.measure.returnedBytes} of ${value.measure.sourceBytes}`,
)
assert.ok(value.measure.files >= 1)
const meta = tool.output.presentationMeta({ query: 'session expiry' }, value)
assert.equal(meta.recall.action, 'search')
assert.equal(meta.recall.excerpts, value.results.length)
assert.equal(meta.recall.sourceBytes, value.measure.sourceBytes)
assert.equal(meta.recall.returnedBytes, value.measure.returnedBytes)
// Pure: replayed on projection rebuilds, so it may not vary between calls.
assert.deepEqual(tool.output.presentationMeta({ query: 'session expiry' }, value), meta)
// And JSON-safe, because this is what actually lands in the log.
assert.deepEqual(JSON.parse(JSON.stringify(meta)), meta)
})
test('an index call records differently from a search', async (t) => {
const { tool, workspace } = await setup(t)
const value = await tool.execute({ action: 'index', run_in_background: false }, makeExec(workspace))
const meta = tool.output.presentationMeta({ action: 'index' }, value)
assert.equal(meta.recall.action, 'index')
assert.equal(meta.recall.excerpts, undefined, 'indexing returns no excerpts to account for')
})
test('a failed search still leaves a record worth folding', async (t) => {
const workspace = await makeWorkspace(FILES)
const stateDir = await makeStateDir()
const originalFetch = globalThis.fetch
globalThis.fetch = async () => ({ ok: false, status: 500, text: async () => 'boom', json: async () => ({}) })
t.after(async () => {
globalThis.fetch = originalFetch
await rm(workspace, { recursive: true, force: true })
await rm(stateDir, { recursive: true, force: true })
})
const { ctx, tools, dispose } = makeContext()
t.after(() => dispose())
apply(ctx, configFor(stateDir))
const value = await tools[0].execute({ query: 'anything' }, makeExec(workspace))
const meta = tools[0].output.presentationMeta({ query: 'anything' }, value)
assert.equal(meta.recall.failed, 1, 'a failure should be counted, not silently dropped')
})
test('the usage projection is registered when the service is mounted', async (t) => {
const registered = []
const { ctx, tools, dispose } = makeContext({
services: { sessionProjections: { register: (unit) => registered.push(unit) } },
})
t.after(() => dispose())
apply(ctx, configFor('/tmp/dsh-recall-projection-test'))
assert.equal(tools.length, 1, 'the tool registers regardless')
assert.equal(registered.length, 1, 'exactly one projection unit')
assert.equal(registered[0].key, 'recallUsage', 'the key the client reads')
assert.equal(typeof registered[0].apply, 'function')
assert.equal(typeof registered[0].wire.view, 'function', 'the unit must be client-visible')
assert.equal(typeof registered[0].wire.viewSchema.parse, 'function')
})
test('a profile without projections still gets a working tool', async (t) => {
// The projection is an optional surface, not a dependency of retrieval: a
// composition that mounts no projection service must lose the reporting and
// nothing else.
const { ctx, tools, dispose } = makeContext()
t.after(() => dispose())
apply(ctx, configFor('/tmp/dsh-recall-no-projection'))
assert.equal(tools.length, 1)
assert.equal(tools[0].name, 'recall')
})
test('a projection that refuses to register is reported, not fatal', async (t) => {
const { ctx, tools, dispose } = makeContext({
services: {
sessionProjections: {
register: () => {
throw new Error('duplicate key')
},
},
},
})
t.after(() => dispose())
// The warning is logged rather than thrown: a conflicting unit must not take
// the whole plugin, and therefore retrieval, down with it.
assert.doesNotThrow(() => apply(ctx, configFor('/tmp/dsh-recall-bad-projection')))
assert.equal(tools.length, 1)
})
test('expansion is off by default and changes nothing', async (t) => {
const { tool, workspace, stub } = await setup(t)
await tool.execute({ action: 'index', run_in_background: false }, makeExec(workspace))
const value = await tool.execute({ query: 'session expiry' }, makeExec(workspace))
assert.ok(value.results.length > 0)
assert.equal(
stub.calls.filter((call) => call.url.endsWith('/api/generate')).length,
0,
'the local model must not be called unless the setting is on',
)
})
test('with expansion on and no model answering, the search still runs', async (t) => {
// The stub only answers /api/generate when a test supplies a response, so this
// is the wedged-endpoint case: expansion must degrade, not fail the search.
const { tool, workspace } = await setup(t, FILES, { expansionEnabled: true, expansionModel: 'local-small' })
await tool.execute({ action: 'index', run_in_background: false }, makeExec(workspace))
const value = await tool.execute({ query: 'session expiry' }, makeExec(workspace))
assert.deepEqual(schemaErrors(tool.output.schema, value), [])
assert.ok(value.results.length > 0, 'a failed expansion must not fail the search')
})
test('expansion asks the model once and still returns results', async (t) => {
const { tool, workspace, stub } = await setup(
t,
FILES,
{ expansionEnabled: true, expansionModel: 'local-small', expansionCount: 3 },
{ generate: 'issueSession\nsession expiry check' },
)
await tool.execute({ action: 'index', run_in_background: false }, makeExec(workspace))
const value = await tool.execute({ query: 'session expiry' }, makeExec(workspace))
assert.deepEqual(schemaErrors(tool.output.schema, value), [])
assert.ok(value.results.length > 0)
const generations = stub.calls.filter((call) => call.url.endsWith('/api/generate'))
assert.equal(generations.length, 1, 'one generation for the query, not one per variant')
assert.match(generations[0].body.prompt, /session expiry/)
assert.equal(generations[0].body.model, 'local-small', 'the configured expansion model is used')
})
test('re-ranking degrades to the fused order when the model has no opinion', async (t) => {
const { tool, workspace } = await setup(t, FILES, { rerankEnabled: true, rerankModel: 'local-small' })
await tool.execute({ action: 'index', run_in_background: false }, makeExec(workspace))
const plain = await tool.execute({ query: 'session expiry' }, makeExec(workspace))
const reranked = await tool.execute({ query: 'session expiry' }, makeExec(workspace))
assert.deepEqual(schemaErrors(tool.output.schema, reranked), [])
// No usable scores means the ranking is untouched rather than emptied.
assert.deepEqual(
reranked.results.map((result) => result.path),
plain.results.map((result) => result.path),
)
assert.ok(reranked.results.length > 0, 'no opinion must not mean no results')
})
+116
View File
@@ -0,0 +1,116 @@
import { strict as assert } from 'node:assert'
import { test } from 'node:test'
import { usageProjection } from '../index.js'
import { deriveUsage, emptyUsage, foldUsage, usageSummary } from '../lib/usage.js'
/**
* The projection contract, tested against the framework's stated requirements
* rather than against the shape of my own code:
*
* - the state schema validates the persisted checkpoint before it seeds a fold,
* so a stale or corrupt row must be *rejected*, not coerced;
* - `apply` must return the same reference for an event it does not own, or the
* unit republishes on every step of every session;
* - the view is compared by `Object.is`, so one state must yield one object.
*/
/** A folded state, as a conversation with a couple of searches would produce. */
function foldedState() {
const value = {
action: 'search',
ok: true,
weak: false,
focus: { path: 'src/auth' },
results: [{ excerpt: 'x'.repeat(400) }, { excerpt: 'y'.repeat(200) }],
measure: { excerpts: 2, returnedBytes: 600, sourceBytes: 24_000, files: 2 },
}
const event = { type: 'tool/result', meta: deriveUsage({ action: 'search' }, value) }
return foldUsage(foldUsage(emptyUsage(), event), event)
}
test('the unit owns the key the client will read', () => {
assert.equal(usageProjection.key, 'recallUsage')
assert.equal(usageProjection.stateVersion, 1)
assert.ok(Number.isInteger(usageProjection.stateVersion) && usageProjection.stateVersion >= 0)
})
test('init produces the empty state, and it survives a round trip', () => {
const state = usageProjection.init()
assert.deepEqual(state, emptyUsage())
assert.deepEqual(usageProjection.stateSchema.parse(state), state)
})
test('the state schema accepts a folded state and preserves it', () => {
const state = foldedState()
assert.equal(state.searches, 2)
// Persisted as JSON and re-parsed on restore, so that round trip must hold.
const restored = usageProjection.stateSchema.parse(JSON.parse(JSON.stringify(state)))
assert.deepEqual(restored, state)
})
test('the state schema REJECTS a corrupt checkpoint rather than coercing it', () => {
// The service catches a parse failure and skips the row; coercing garbage
// would forward-apply nonsense into every later total instead.
for (const bad of [null, undefined, 42, 'state', {}, { searches: 'two' }, { ...emptyUsage(), searches: null }]) {
assert.equal(usageProjection.stateSchema.safeParse(bad).success, false, `accepted ${JSON.stringify(bad)}`)
}
})
test('apply returns the SAME reference for events the unit does not own', () => {
const state = foldedState()
assert.equal(usageProjection.apply(state, { type: 'user/message' }), state)
assert.equal(usageProjection.apply(state, { type: 'tool/result', meta: { other: 1 } }), state)
assert.equal(usageProjection.apply(state, { type: 'assistant/message' }), state)
})
test('apply folds an event the unit does own', () => {
const state = usageProjection.init()
const value = {
action: 'search',
ok: true,
results: [{ excerpt: 'abc' }],
measure: { excerpts: 1, returnedBytes: 3, sourceBytes: 900, files: 1 },
}
const event = { type: 'tool/result', meta: deriveUsage({ action: 'search' }, value) }
const next = usageProjection.apply(state, event)
assert.notEqual(next, state, 'a real event must produce a new state')
assert.equal(next.searches, 1)
assert.equal(next.sourceBytes, 900)
})
test('the view validates against its own schema', () => {
const state = foldedState()
const view = usageProjection.wire.view(state)
assert.deepEqual(usageProjection.wire.viewSchema.parse(view), view)
})
test('one state yields ONE view object, by reference', () => {
// The live drive compares the two latest views with Object.is; returning a
// fresh object per call would republish on every event that touched the state
// even when the numbers a surface shows had not moved.
const state = foldedState()
const first = usageProjection.wire.view(state)
const second = usageProjection.wire.view(state)
assert.equal(first, second)
})
test('a different state yields a different view', () => {
const one = usageProjection.wire.view(foldedState())
const two = usageProjection.wire.view(emptyUsage())
assert.notEqual(one, two)
assert.equal(one.searches, 2)
assert.equal(two.searches, 0)
})
test('the view carries the comparison and the compounding figure', () => {
const view = usageProjection.wire.view(foldedState())
assert.equal(view.searches, 2)
assert.equal(view.sourceBytes, 48_000)
assert.equal(view.returnedBytes, 1200)
assert.equal(view.avoidedBytes, 46_800)
// bytes/4 is an approximation and is labelled as one where it is displayed.
assert.equal(view.avoidedTokens, Math.round(46_800 / 4))
assert.ok(view.compression > 1)
assert.equal(view.avoidedUsd, usageSummary(foldedState()).avoidedUsd)
})
+183
View File
@@ -0,0 +1,183 @@
import { strict as assert } from 'node:assert'
import { test } from 'node:test'
import { excerptOf, queryTerms, rankChunks, splitIdentifier } from '../lib/rank.js'
import { hashEmbed } from './helpers.js'
const DIMENSIONS = 128
/** Build a scoring matrix from a list of chunks. */
function makeMatrix(chunks) {
const vectors = new Float32Array(chunks.length * DIMENSIONS)
chunks.forEach((chunk, at) => {
vectors.set(hashEmbed(`${chunk.path} ${chunk.symbol ?? ''} ${chunk.text}`, DIMENSIONS), at * DIMENSIONS)
})
return {
vectors,
dimensions: DIMENSIONS,
rows: chunks.map((chunk, at) => ({
id: at + 1,
path: chunk.path,
startLine: chunk.startLine,
endLine: chunk.endLine,
...(chunk.symbol === undefined ? {} : { symbol: chunk.symbol }),
text: chunk.text,
})),
}
}
test('splitIdentifier breaks camelCase and snake_case alike', () => {
assert.deepEqual(splitIdentifier('getUserById'), ['get', 'User', 'By', 'Id'])
assert.deepEqual(splitIdentifier('get_user_by_id'), ['get', 'user', 'by', 'id'])
assert.deepEqual(splitIdentifier('HTTPResponseParser'), ['HTTP', 'Response', 'Parser'])
})
test('queryTerms keeps identifier sub-words and drops stopwords', () => {
const terms = queryTerms('where is the getUserById function')
assert.ok(terms.includes('get'))
assert.ok(terms.includes('user'))
assert.ok(!terms.includes('the'), 'stopwords must not rank anything')
})
test('a named identifier wins even against a topically similar decoy', () => {
const matrix = makeMatrix([
{
path: 'src/services/auth/session.ts',
symbol: 'issueSession',
startLine: 1,
endLine: 10,
text: 'export function issueSession(userId) { return { userId, expiresAt: Date.now() } }',
},
{
path: 'src/services/auth/other.ts',
symbol: 'revokeSession',
startLine: 1,
endLine: 10,
text: 'export function revokeSession(record) { record.revoked = true; return record }',
},
{ path: 'src/weather/forecast.ts', symbol: 'average', startLine: 1, endLine: 5, text: 'export function average(days) { return days.length }' },
])
const query = 'issueSession'
const ranked = rankChunks({ matrix, queryVector: hashEmbed(query, DIMENSIONS), query, limit: 3 })
assert.equal(ranked.results[0].path, 'src/services/auth/session.ts')
assert.equal(ranked.results[0].symbol, 'issueSession')
})
test('a behavioural query still finds the right file without naming it', () => {
const matrix = makeMatrix([
{
path: 'src/services/auth/session.ts',
symbol: 'issueSession',
startLine: 1,
endLine: 6,
text: 'export function issueSession(userId) { const expiresAt = Date.now() + ttl; return { userId, expiresAt } }',
},
{ path: 'src/weather/forecast.ts', symbol: 'averageTemperature', startLine: 1, endLine: 6, text: 'export function averageTemperature(days) { return days.reduce(sum, 0) }' },
])
const query = 'session expiry userId expiresAt'
const ranked = rankChunks({ matrix, queryVector: hashEmbed(query, DIMENSIONS), query, limit: 2 })
assert.equal(ranked.results[0].path, 'src/services/auth/session.ts')
})
test('at most perFile chunks come from one file', () => {
const chunks = Array.from({ length: 6 }, (_, at) => ({
path: 'src/big.ts',
symbol: `fn_${at}`,
startLine: at * 10 + 1,
endLine: at * 10 + 9,
text: `export function fn_${at}(handler) { return handler(${at}) }`,
}))
chunks.push({ path: 'src/other.ts', symbol: 'fn_x', startLine: 1, endLine: 5, text: 'export function fn_x(handler) { return handler }' })
const matrix = makeMatrix(chunks)
const query = 'fn handler'
const ranked = rankChunks({ matrix, queryVector: hashEmbed(query, DIMENSIONS), query, limit: 6, perFile: 2 })
const fromBig = ranked.results.filter((result) => result.path === 'src/big.ts')
assert.ok(fromBig.length <= 2, `expected at most 2 from one file, got ${fromBig.length}`)
})
test('excerptOf keeps the head and reports truncation', () => {
const text = Array.from({ length: 100 }, (_, at) => `line ${at}`).join('\n')
const short = excerptOf(text, 200)
assert.equal(short.truncated, false)
assert.equal(short.excerpt, text)
const long = excerptOf(text, 10)
assert.equal(long.truncated, true)
assert.equal(long.excerpt.split('\n').length, 10)
assert.equal(long.excerpt.split('\n')[0], 'line 0')
})
test('rankChunks handles an empty matrix without inventing results', () => {
const ranked = rankChunks({
matrix: { vectors: new Float32Array(0), rows: [], dimensions: 0 },
queryVector: new Float32Array(0),
query: 'anything',
})
assert.deepEqual(ranked.results, [])
assert.equal(ranked.vectorBest, 0)
})
test('ranking is deterministic for equal scores', () => {
const chunks = Array.from({ length: 4 }, (_, at) => ({
path: `src/f${at}.ts`,
startLine: 1,
endLine: 2,
text: 'export const same = 1',
}))
const matrix = makeMatrix(chunks)
const query = 'same'
const first = rankChunks({ matrix, queryVector: hashEmbed(query, DIMENSIONS), query, limit: 4 })
const second = rankChunks({ matrix, queryVector: hashEmbed(query, DIMENSIONS), query, limit: 4 })
assert.deepEqual(
first.results.map((result) => result.path),
second.results.map((result) => result.path),
)
})
test('a chunk that fits is returned whole rather than cut in half', () => {
// The default cap equals the chunk cap, so a chunk — already a semantic unit —
// is not truncated. Cutting below it is what forces a follow-up read.
const body = Array.from({ length: 60 }, (_, at) => ` const line_${at} = compute(${at})`).join('\n')
const text = `export function handler() {\n${body}\n}`
const matrix = makeMatrix([{ path: 'src/a.ts', symbol: 'handler', startLine: 1, endLine: 62, text }])
const ranked = rankChunks({ matrix, queryVector: hashEmbed('handler', DIMENSIONS), query: 'handler', limit: 1 })
assert.equal(ranked.results[0].truncated, false, 'a 62-line chunk should arrive whole')
assert.equal(ranked.results[0].excerpt, text)
})
test('a per-excerpt cap still truncates, and says so', () => {
const text = Array.from({ length: 60 }, (_, at) => `line ${at}`).join('\n')
const matrix = makeMatrix([{ path: 'src/a.ts', startLine: 1, endLine: 60, text }])
const ranked = rankChunks({ matrix, queryVector: hashEmbed('line', DIMENSIONS), query: 'line', limit: 1, excerptLines: 10 })
assert.equal(ranked.results[0].truncated, true)
assert.equal(ranked.results[0].excerpt.split('\n').length, 10)
})
test('the total line budget bounds the whole set, allocating by rank', () => {
const chunks = Array.from({ length: 6 }, (_, at) => ({
path: `src/f${at}.ts`,
symbol: `fn_${at}`,
startLine: 1,
endLine: 40,
text: Array.from({ length: 40 }, (_, line) => `fn_${at} line ${line} handler`).join('\n'),
}))
const matrix = makeMatrix(chunks)
const query = 'fn handler'
const ranked = rankChunks({ matrix, queryVector: hashEmbed(query, DIMENSIONS), query, limit: 6, excerptLines: 40, totalLines: 90 })
const total = ranked.results.reduce((sum, result) => sum + result.excerpt.split('\n').length, 0)
assert.ok(total <= 90 + 40, `budget should bind, total was ${total}`)
assert.ok(ranked.results.length < 6, 'the budget should have stopped the set early')
assert.ok(ranked.results.length >= 1, 'at least one result must survive any budget')
})
test('at least one result is returned even with no budget at all', () => {
const chunks = Array.from({ length: 3 }, (_, at) => ({
path: `src/f${at}.ts`,
startLine: 1,
endLine: 30,
text: Array.from({ length: 30 }, (_, line) => `handler line ${line}`).join('\n'),
}))
const matrix = makeMatrix(chunks)
const ranked = rankChunks({ matrix, queryVector: hashEmbed('handler', DIMENSIONS), query: 'handler', limit: 3, totalLines: 0 })
assert.equal(ranked.results.length, 1, 'an empty budget must still answer with something')
assert.equal(ranked.results[0].excerpt.split('\n').length, 1, 'squeezed to the minimum, not dropped')
})
+118
View File
@@ -0,0 +1,118 @@
import { strict as assert } from 'node:assert'
import { test } from 'node:test'
import { fuseRankings } from '../lib/rank.js'
import { createReranker, parseScores } from '../lib/rerank.js'
/** A stub endpoint answering with a canned model response. */
function stubFetch(response, options = {}) {
const { hang = false, status = 200 } = options
const calls = []
const impl = async (url, init) => {
calls.push({ url, body: JSON.parse(init.body) })
if (hang) {
return new Promise((_resolve, reject) => {
init.signal.addEventListener('abort', () => reject(init.signal.reason), { once: true })
})
}
return { ok: status === 200, status, json: async () => ({ response }) }
}
impl.calls = calls
return impl
}
const MODEL = { apiBase: 'http://stub:11434', model: 'small-model', timeoutMs: 2_000 }
test('parseScores accepts the documented shape and a bare array', () => {
assert.deepEqual(parseScores('{"scores": [10, 4, 0]}'), [10, 4, 0])
assert.deepEqual(parseScores('Sure: [8, 2, 6]'), [8, 2, 6])
assert.deepEqual(parseScores('{"scores":[7]}'), [7])
})
test('parseScores returns undefined rather than guessing', () => {
assert.equal(parseScores('no numbers here'), undefined)
assert.equal(parseScores('{"scores": []}'), undefined)
assert.equal(parseScores(''), undefined)
assert.equal(parseScores(undefined), undefined)
})
test('an unusable response is no opinion, not a zero', async () => {
// The distinction the whole module rests on: returning zeros here would
// replace a good embedding ranking with nothing at all.
for (const fetchImpl of [
async () => {
throw new Error('ECONNREFUSED')
},
stubFetch('I think the first one is best.'),
stubFetch('{"scores": [10, 5]}'), // wrong length for 3 candidates
stubFetch('', { status: 500 }),
]) {
assert.equal(await createReranker({ ...MODEL, fetchImpl }).score('q', ['a', 'b', 'c']), undefined)
}
})
test('a WEDGED endpoint yields no opinion, and does not hang', async () => {
const started = Date.now()
const scores = await createReranker({ ...MODEL, fetchImpl: stubFetch('', { hang: true }), timeoutMs: 120 }).score('q', ['a', 'b'])
assert.equal(scores, undefined)
assert.ok(Date.now() - started < 3_000, 'the deadline must bound the wait')
})
test('scores come back normalised and clamped, in candidate order', async () => {
const fetchImpl = stubFetch('{"scores": [10, 0, 5, 42, -3]}')
const scores = await createReranker({ ...MODEL, fetchImpl }).score('q', ['a', 'b', 'c', 'd', 'e'])
assert.deepEqual(scores, [1, 0, 0.5, 1, 0], 'out-of-range values are clamped, not trusted')
})
test('a non-finite timeout is clamped rather than read as no opinion', async () => {
const fetchImpl = stubFetch('{"scores": [9, 1]}')
const scores = await createReranker({ ...MODEL, fetchImpl, timeoutMs: Number.NaN }).score('q', ['a', 'b'])
assert.deepEqual(scores, [0.9, 0.1])
})
test('the prompt carries the query and every candidate', async () => {
const fetchImpl = stubFetch('{"scores": [1, 1]}')
await createReranker({ ...MODEL, fetchImpl }).score('occupancy detection', ['excerpt one', 'excerpt two'])
const prompt = fetchImpl.calls[0].body.prompt
assert.match(prompt, /occupancy detection/)
assert.match(prompt, /excerpt one/)
assert.match(prompt, /excerpt two/)
})
test('nothing to score is no opinion, and no request', async () => {
const fetchImpl = stubFetch('{"scores": []}')
const reranker = createReranker({ ...MODEL, fetchImpl })
assert.equal(await reranker.score('q', []), undefined)
assert.equal(await reranker.score(' ', ['a']), undefined)
assert.equal(fetchImpl.calls.length, 0)
})
test('caller cancellation yields no opinion without a request', async () => {
const controller = new AbortController()
controller.abort()
const fetchImpl = stubFetch('{"scores": [1]}')
assert.equal(await createReranker({ ...MODEL, fetchImpl }).score('q', ['a'], controller.signal), undefined)
assert.equal(fetchImpl.calls.length, 0)
})
test('fusing one ranking keeps its order', () => {
assert.deepEqual(fuseRankings([[3, 1, 2]]), [3, 1, 2])
})
test('a chunk several queries agree on outranks one that a single query liked', () => {
// Row 7 is second for both queries; row 1 is first for one and absent from the
// other. Agreement should win, which is the whole point of expansion.
const fused = fuseRankings([
[1, 7, 2],
[5, 7, 9],
])
assert.equal(fused[0], 7)
})
test('fusion is empty for no rankings and stable for ties', () => {
assert.deepEqual(fuseRankings([]), [])
assert.deepEqual(fuseRankings([[], []]), [])
const once = fuseRankings([[2, 1, 3], [1, 2, 3]])
const twice = fuseRankings([[2, 1, 3], [1, 2, 3]])
assert.deepEqual(once, twice, 'the same input must give the same order')
})
+113
View File
@@ -0,0 +1,113 @@
import { strict as assert } from 'node:assert'
import { rm } from 'node:fs/promises'
import { join } from 'node:path'
import { test } from 'node:test'
import { RecallIndex } from '../lib/store.js'
import { makeStateDir } from './helpers.js'
const IDENTITY = { model: 'test-model', dimensions: 4, apiBase: 'http://stub' }
/** A chunk with a hand-written vector, so assertions are exact. */
function chunk(startLine, vector, symbol) {
return {
startLine,
endLine: startLine + 2,
...(symbol === undefined ? {} : { symbol }),
text: `line ${startLine}`,
vec: Float32Array.from(vector),
}
}
async function withIndex(t, work) {
const dir = await makeStateDir()
const index = new RecallIndex(join(dir, 'index.sqlite'))
t.after(async () => {
index.close()
await rm(dir, { recursive: true, force: true })
})
index.open(IDENTITY)
return work(index)
}
test('replaceFile round-trips chunks and their vectors', async (t) => {
await withIndex(t, (index) => {
index.replaceFile('a.ts', 'hash-a', 100, [chunk(1, [1, 0, 0, 0], 'alpha'), chunk(10, [0, 1, 0, 0], 'beta')])
const stats = index.stats()
assert.equal(stats.files, 1)
assert.equal(stats.chunks, 2)
const matrix = index.matrix()
assert.equal(matrix.dimensions, 4)
assert.equal(matrix.rows.length, 2)
assert.deepEqual(Array.from(matrix.vectors.slice(0, 4)), [1, 0, 0, 0])
assert.equal(matrix.rows[0].symbol, 'alpha')
assert.equal(matrix.rows[0].path, 'a.ts')
})
})
test('replacing a file drops its previous chunks rather than appending', async (t) => {
await withIndex(t, (index) => {
index.replaceFile('a.ts', 'hash-1', 10, [chunk(1, [1, 0, 0, 0])])
index.replaceFile('a.ts', 'hash-2', 20, [chunk(1, [0, 1, 0, 0]), chunk(5, [0, 0, 1, 0])])
assert.equal(index.stats().chunks, 2)
assert.equal(index.fileHashes().get('a.ts'), 'hash-2')
})
})
test('prune forgets files that disappeared from disk', async (t) => {
await withIndex(t, (index) => {
index.replaceFile('a.ts', 'ha', 1, [chunk(1, [1, 0, 0, 0])])
index.replaceFile('b.ts', 'hb', 1, [chunk(1, [0, 1, 0, 0])])
assert.equal(index.prune(['a.ts']), 1)
assert.equal(index.stats().files, 1)
assert.equal(index.matrix().rows[0].path, 'b.ts')
assert.equal(index.prune([]), 0)
})
})
test('changing the embedding model discards the index instead of mixing spaces', async (t) => {
await withIndex(t, (index) => {
index.replaceFile('a.ts', 'ha', 1, [chunk(1, [1, 0, 0, 0])])
assert.equal(index.stats().chunks, 1)
const outcome = index.recheck({ model: 'other-model', dimensions: 4, apiBase: 'http://stub' })
assert.match(outcome.discarded, /embedding model changed/)
assert.equal(index.stats().chunks, 0)
assert.equal(index.stats().files, 0)
})
})
test('changing the embedding width discards the index', async (t) => {
await withIndex(t, (index) => {
index.replaceFile('a.ts', 'ha', 1, [chunk(1, [1, 0, 0, 0])])
const outcome = index.recheck({ model: 'test-model', dimensions: 8, apiBase: 'http://stub' })
assert.match(outcome.discarded, /embedding width changed/)
assert.equal(index.stats().chunks, 0)
})
})
test('recheck is a no-op when nothing about the identity moved', async (t) => {
await withIndex(t, (index) => {
index.replaceFile('a.ts', 'ha', 1, [chunk(1, [1, 0, 0, 0])])
assert.deepEqual(index.recheck(IDENTITY), {})
assert.equal(index.stats().chunks, 1)
})
})
test('a write invalidates the cached matrix', async (t) => {
await withIndex(t, (index) => {
index.replaceFile('a.ts', 'ha', 1, [chunk(1, [1, 0, 0, 0])])
assert.equal(index.matrix().rows.length, 1)
index.replaceFile('b.ts', 'hb', 1, [chunk(1, [0, 1, 0, 0])])
assert.equal(index.matrix().rows.length, 2, 'the matrix must not serve a stale cache')
})
})
test('an index with no chunks reports a zero-width matrix', async (t) => {
await withIndex(t, (index) => {
const matrix = index.matrix()
assert.equal(matrix.rows.length, 0)
assert.equal(matrix.dimensions, 0)
})
})
+139
View File
@@ -0,0 +1,139 @@
import { strict as assert } from 'node:assert'
import { test } from 'node:test'
import { deriveUsage, emptyUsage, foldUsage, usageSummary } from '../lib/usage.js'
/** A tool result shaped like the ones the tool actually returns. */
function searchValue(overrides = {}) {
return {
action: 'search',
ok: true,
focus: { path: '.', source: 'workspace' },
weak: false,
results: [
{ path: 'src/auth/session.ts', excerpt: 'x'.repeat(1000), similarity: 0.6 },
{ path: 'src/weather/forecast.ts', excerpt: 'y'.repeat(500), similarity: 0.4 },
],
measure: { excerpts: 2, returnedBytes: 1500, sourceBytes: 60_000, files: 2 },
...overrides,
}
}
/** Wrap a record the way the harness wraps the tool's metadata. */
function asEvent(meta) {
return { type: 'tool/result', turn: 1, step: 1, message: {}, meta }
}
test('emptyUsage is a plain zeroed record', () => {
const state = emptyUsage()
assert.equal(state.searches, 0)
assert.equal(state.sourceBytes, 0)
// The projection stores this as plain JSON, so nothing exotic may appear.
assert.deepEqual(JSON.parse(JSON.stringify(state)), state)
})
test('deriveUsage records a search without carrying any content', () => {
const { recall } = deriveUsage({ action: 'search' }, searchValue())
assert.equal(recall.action, 'search')
assert.equal(recall.excerpts, 2)
assert.equal(recall.returnedBytes, 1500)
assert.equal(recall.sourceBytes, 60_000)
assert.equal(recall.files, 2)
assert.equal(recall.focus, '.')
// The whole point of the record is that it is tiny: no excerpts, no query.
const serialised = JSON.stringify(recall)
assert.ok(!serialised.includes('x'.repeat(50)), 'excerpt text must not reach the log')
assert.ok(serialised.length < 300, `record was ${serialised.length} bytes`)
})
test('deriveUsage marks a failure and a weak result', () => {
assert.equal(deriveUsage({ action: 'search' }, { action: 'search', ok: false }).recall.failed, 1)
assert.equal(deriveUsage({ action: 'search' }, searchValue({ weak: true })).recall.weak, 1)
})
test('deriveUsage records an index without pretending it returned excerpts', () => {
const { recall } = deriveUsage({ action: 'index' }, { action: 'index', ok: true, index: { embedded: 4 } })
assert.equal(recall.action, 'index')
assert.equal(recall.embedded, 4)
assert.equal(recall.excerpts, undefined)
})
test('foldUsage returns the SAME reference for events it does not care about', () => {
const state = emptyUsage()
// A fresh object here would republish the projection on every step of every
// session, which is why the framework states this requirement explicitly.
assert.equal(foldUsage(state, { type: 'user/message' }), state)
assert.equal(foldUsage(state, { type: 'tool/result', meta: {} }), state)
assert.equal(foldUsage(state, { type: 'tool/result' }), state)
assert.equal(foldUsage(state, { type: 'tool/result', meta: { other: 1 } }), state)
assert.equal(foldUsage(state, { type: 'tool/result', meta: { recall: null } }), state)
})
test('foldUsage accumulates one conversation', () => {
let state = emptyUsage()
state = foldUsage(state, asEvent(deriveUsage({}, searchValue())))
state = foldUsage(state, asEvent(deriveUsage({}, searchValue())))
state = foldUsage(state, asEvent(deriveUsage({ action: 'index' }, { action: 'index', index: { embedded: 3 } })))
assert.equal(state.searches, 2)
assert.equal(state.indexes, 1)
assert.equal(state.excerpts, 4)
assert.equal(state.returnedBytes, 3000)
assert.equal(state.sourceBytes, 120_000)
assert.equal(state.files, 4)
})
test('a fold and a direct derive agree on the same numbers', () => {
// Guards against the two paths drifting: whatever the tool writes must be
// what the running total adds up to.
const value = searchValue()
const folded = foldUsage(emptyUsage(), asEvent(deriveUsage({}, value)))
const summary = usageSummary(folded)
assert.equal(summary.searches, 1)
assert.equal(summary.sourceBytes, value.measure.sourceBytes)
assert.equal(summary.returnedBytes, value.measure.returnedBytes)
})
test('usageSummary reports the comparison, and calls it an upper bound', () => {
const state = foldUsage(emptyUsage(), asEvent(deriveUsage({}, searchValue())))
const summary = usageSummary(state)
assert.equal(summary.avoidedBytes, 58_500)
assert.equal(summary.avoidedTokens, Math.round(58_500 / 4))
assert.equal(summary.compression, 40)
// The dollars are real rather than rounded to nothing: the direct saving is
// small, which is exactly why the surface must not lead with it.
assert.ok(summary.avoidedUsd > 0)
})
test('each step re-reads the avoided context, so the fold compounds it', () => {
let state = foldUsage(emptyUsage(), asEvent(deriveUsage({}, searchValue())))
const after = usageSummary(state)
assert.equal(after.rereadTokens, 0, 'nothing has been re-read yet')
// Every step after the search re-reads what the conversation is carrying.
for (let step = 0; step < 100; step += 1) state = foldUsage(state, { type: 'step/start' })
const later = usageSummary(state)
assert.equal(later.rereadTokens, after.avoidedTokens * 100)
assert.ok(later.avoidedUsd > after.avoidedUsd)
assert.equal(later.avoidedUsd, Number((((after.avoidedTokens + later.rereadTokens) / 1e6) * 0.007).toFixed(4)))
})
test('a step before anything was avoided adds nothing', () => {
const state = emptyUsage()
assert.equal(foldUsage(state, { type: 'step/start' }), state, 'and keeps the same reference')
})
test('usageSummary survives a conversation with no calls', () => {
const summary = usageSummary(emptyUsage())
assert.equal(summary.searches, 0)
assert.equal(summary.avoidedTokens, 0)
assert.equal(summary.compression, 0, 'no division by zero')
assert.equal(summary.avoidedUsd, 0)
})
test('an excerpt larger than its source cannot produce a negative saving', () => {
const state = foldUsage(emptyUsage(), asEvent({
recall: { action: 'search', excerpts: 1, returnedBytes: 900, sourceBytes: 100 },
}))
assert.equal(usageSummary(state).avoidedBytes, 0)
})
+104
View File
@@ -0,0 +1,104 @@
import { strict as assert } from 'node:assert'
import { mkdtemp, mkdir, symlink, writeFile, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { test } from 'node:test'
import { DEFAULT_EXTENSIONS, extensionOf, globToRegExp, listCodeFiles, matchesAny } from '../lib/walk.js'
test('globToRegExp anchors and handles star and double-star', () => {
assert.equal(globToRegExp('src/*.ts').test('src/a.ts'), true)
assert.equal(globToRegExp('src/*.ts').test('src/nested/a.ts'), false)
assert.equal(globToRegExp('**/*.ts').test('a/b/c.ts'), true)
assert.equal(globToRegExp('**/*.ts').test('c.ts'), true)
assert.equal(globToRegExp('src/**').test('src/a/b/c.ts'), true)
assert.equal(globToRegExp('dist/').test('dist/a/b.js'), true)
assert.equal(globToRegExp('src/a?.ts').test('src/ab.ts'), true)
assert.equal(globToRegExp('src/a?.ts').test('src/abc.ts'), false)
// Dots must be literal, not wildcards.
assert.equal(globToRegExp('a.ts').test('axts'), false)
})
test('matchesAny ignores empty patterns', () => {
assert.equal(matchesAny('a/b.ts', ['']), false)
assert.equal(matchesAny('a/b.ts', ['', 'a/*.ts']), true)
})
test('extensionOf lowercases and rejects dotfiles', () => {
assert.equal(extensionOf('a/B.TS'), 'ts')
assert.equal(extensionOf('.gitignore'), '')
assert.equal(extensionOf('Makefile'), '')
})
test('listCodeFiles returns a deterministic sorted list of code files only', async () => {
const root = await mkdtemp(join(tmpdir(), 'recall-walk-'))
try {
await mkdir(join(root, 'src', 'nested'), { recursive: true })
await mkdir(join(root, 'node_modules', 'dep'), { recursive: true })
await writeFile(join(root, 'src', 'b.ts'), 'export const b = 1\n')
await writeFile(join(root, 'src', 'a.ts'), 'export const a = 1\n')
await writeFile(join(root, 'src', 'nested', 'c.py'), 'c = 1\n')
await writeFile(join(root, 'README.md'), '# not code\n')
await writeFile(join(root, 'node_modules', 'dep', 'index.js'), 'module.exports = 1\n')
const { files } = await listCodeFiles(root)
assert.deepEqual(
files.map((file) => file.path),
['src/a.ts', 'src/b.ts', 'src/nested/c.py'],
)
} finally {
await rm(root, { recursive: true, force: true })
}
})
test('listCodeFiles honours an extension filter and an exclude glob', async () => {
const root = await mkdtemp(join(tmpdir(), 'recall-walk-'))
try {
await mkdir(join(root, 'src', 'generated'), { recursive: true })
await writeFile(join(root, 'src', 'a.ts'), 'a\n')
await writeFile(join(root, 'src', 'generated', 'b.ts'), 'b\n')
await writeFile(join(root, 'src', 'c.py'), 'c\n')
const onlyTs = await listCodeFiles(root, { extensions: ['ts'] })
assert.deepEqual(onlyTs.files.map((file) => file.path), ['src/a.ts', 'src/generated/b.ts'])
const excluded = await listCodeFiles(root, { exclude: ['src/generated/'] })
assert.deepEqual(excluded.files.map((file) => file.path), ['src/a.ts', 'src/c.py'])
} finally {
await rm(root, { recursive: true, force: true })
}
})
test('listCodeFiles reports truncation instead of silently dropping files', async () => {
const root = await mkdtemp(join(tmpdir(), 'recall-walk-'))
try {
for (let at = 0; at < 5; at += 1) await writeFile(join(root, `f${at}.ts`), `${at}\n`)
const { files, truncated } = await listCodeFiles(root, { maxFiles: 2 })
assert.equal(files.length, 2)
assert.equal(truncated, true)
} finally {
await rm(root, { recursive: true, force: true })
}
})
test('listCodeFiles does not follow symlinks', async () => {
const root = await mkdtemp(join(tmpdir(), 'recall-walk-'))
const outside = await mkdtemp(join(tmpdir(), 'recall-outside-'))
try {
await writeFile(join(outside, 'secret.ts'), 'export const secret = 1\n')
await writeFile(join(root, 'real.ts'), 'export const real = 1\n')
await symlink(outside, join(root, 'linked'), 'dir')
const { files } = await listCodeFiles(root)
assert.deepEqual(files.map((file) => file.path), ['real.ts'])
} finally {
await rm(root, { recursive: true, force: true })
await rm(outside, { recursive: true, force: true })
}
})
test('the default extension set is code, not prose', () => {
assert.ok(DEFAULT_EXTENSIONS.includes('ts'))
assert.ok(DEFAULT_EXTENSIONS.includes('py'))
assert.ok(!DEFAULT_EXTENSIONS.includes('md'))
})