init
This commit is contained in:
commit
0964819cdc
34 files changed
+7912
No files matched your search
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules/
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite-wal
|
||||||
|
*.sqlite-shm
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -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
|
||||||
+273
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 }
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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/)
|
||||||
|
})
|
||||||
@@ -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')
|
||||||
|
})
|
||||||
@@ -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/)
|
||||||
|
})
|
||||||
@@ -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')
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
@@ -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
@@ -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-'))
|
||||||
|
}
|
||||||
@@ -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')
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
@@ -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')
|
||||||
|
})
|
||||||
@@ -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')
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
@@ -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'))
|
||||||
|
})
|
||||||
Reference in new issue
Block a user