Files
NotExpectedYet 0dc79f8e0b
Release / release (push) Successful in 6s
fix: hand the jobs registry a session id, not the agent handle
JobSpec.owner is a SessionId, and the registry resolves it through
agents.get(owner) - so passing exec.agent made the DEFAULT index call fail
with 'session "[object Object]" has no live agent'. Since index defaults to
run_in_background, the action that exists to pre-index a workspace was the
one action that could not run. A handle without an id registers the job
unowned rather than throwing.

The test mounts a jobs service and pins the owner contract; no test had ever
supplied one, which is why this reached a real deployment.
2026-09-27 07:16:31 +01:00

1604 lines
67 KiB
JavaScript

/**
* dsh-recall — local-embedding code recall for DeepSeek Harness.
*
* One tool (`recall`) over a per-workspace vector index built by a local
* embedding model, plus a `Config` schema whose every field is live-editable —
* the endpoint is configuration, not a constant.
*
* **How the settings surface works (harness 0.1.7).** A plugin no longer
* registers a settings section. It exports a `Config` schema; the Harness keys
* a form for the profile entry by that row's `id`, and a field is offered to
* the UI only when its schema node is declared `volatile()` — the marker that
* means "editable without remounting the plugin". So this file's schema carries
* two jobs at once: validation, and being the whole of the settings page's
* description. `apply` therefore receives volatile fields as live references
* and reads them through {@link settingsSnapshot} per call, which is what makes
* a saved edit take effect without a restart.
*
* **The economics this exists for.** In a long agent session the dominant cost
* is not the model's reasoning; it is re-reading the accumulated transcript on
* every step. Measured on this harness over 32 days, 65% of spend was re-reading
* cached context and 96% of that context was message history — mostly file
* contents the agent had already read. A tool that answers "where is this
* handled" with a ranked 40-line excerpt instead of four whole files keeps that
* history small, and the saving compounds on every subsequent step because the
* context it did not add is never re-sent again.
*
* **Why the index is warmed lazily rather than at load.** `apply` must not block
* on embedding a repository; a plugin that took minutes to activate would make
* every profile start slow, including the ones that never call the tool. Instead
* a search does a *bounded* sync — read and hash everything, embed only what
* fits in a time budget — and reports honestly what is still pending. A full
* warm-up is available as an explicit background action.
*
* **Why the endpoint is configuration and not a constant.** The Ollama default
* of `127.0.0.1:11434` is a convention, not a fact — a deployment often runs
* Ollama on another host. Baking one machine's address into a bundle layer that
* other machines apply is exactly the mistake the composition docs warn about,
* so the endpoint is a schema default the profile can override, at rest or
* live.
*/
import { readFile, stat } from 'node:fs/promises'
import { createHash } from 'node:crypto'
import { basename, join, resolve, sep } from 'node:path'
import { defineTool } from '@deepseek-ai/dsh-tools'
// A real dependency rather than a duck-typed `{ parse }`: the projection service
// calls these schemas, and `dsh-session-projection` declares zod itself, so it is
// present wherever projections are.
import { z } from 'zod'
import Schema from '@deepseek-ai/schemastery'
import { chunkFile, embedText } from './lib/chunk.js'
import { EmbeddingError, createEmbedder } from './lib/embed.js'
import { DEFAULT_FOCUS_MARKERS, discoverScopes, focusKey, resolveFocus } from './lib/focus.js'
import { indexFile, resolveDshHome, workspaceStateDir } from './lib/paths.js'
import { fuseRankings, rankOrder, selectFrom } from './lib/rank.js'
import { listRootFiles, resolveSourceRoots } from './lib/roots.js'
import { RecallIndex } from './lib/store.js'
import { createExpander } from './lib/expand.js'
import { createReranker } from './lib/rerank.js'
import { deriveUsage, emptyUsage, foldUsage, usageSummary } from './lib/usage.js'
import { DEFAULT_EXTENSIONS, DEFAULT_SKIP_DIRS, listCodeFiles } from './lib/walk.js'
/**
* This plugin's settings namespace, which in 0.1.7 IS its profile entry id.
*
* `ctx.settings.describe()` keys one form per composed entry by the `id` the
* patch layer gives that row — `dsh-recall` in cordis.patch.yml — and writes go
* back to the same key. It is not a name the plugin registers; the schema
* exported as `Config` is the registration, and this constant is the id those
* settings surfaces address it by. Renaming the row without renaming this
* would leave the `configure` action writing to a row that does not exist.
*/
const SETTINGS_NAMESPACE = 'dsh-recall'
/** Every configuration field this plugin exposes. */
export const SETTING_KEYS = [
'embeddingApiBase',
'embeddingModel',
'embeddingDimensions',
'embeddingApiKey',
'embeddingBatchSize',
'embeddingConcurrency',
'embeddingTimeoutMs',
'embeddingKeepAlive',
'extensions',
'exclude',
'maxFiles',
'maxFileBytes',
'chunkLines',
'maxChunkLines',
'overlapLines',
'defaultLimit',
'maxLimit',
'perFileLimit',
'excerptLines',
'totalExcerptLines',
'minSimilarity',
'syncBudgetMs',
'stateDir',
'focus',
'focusMarkers',
'sourceRoots',
'expansionEnabled',
'expansionModel',
'expansionCount',
'expansionTimeoutMs',
'rerankEnabled',
'rerankModel',
'rerankCandidates',
'rerankTimeoutMs',
]
/**
* Every field is `.volatile()`.
*
* A volatile field is one the configuration document may change without
* remounting the plugin: the Loader re-parses the raw config, writes the new
* value into the running instance's existing reference, and emits
* `loader/volatile-update`. Two consequences decide the shape below:
*
* - `ctx.settings.describe` SKIPS an entry with no volatile field, so a schema
* that marks none is invisible to every settings surface — there is no page
* to draw rather than an empty one. This is why the plugin cannot expose a
* settings page at all without these markers.
* - a volatile field arrives in `apply`'s config as a reference (`{ get() }`)
* rather than a value, which is what {@link settingsSnapshot} unwraps.
*
* Every field qualifies: all of them are read afresh per tool call, so a live
* edit takes effect on the next call with nothing to re-register.
*
* The `.min`/`.max` bounds duplicate what {@link validateSettings} enforces.
* That is deliberate rather than redundant: those are the constraints a schema
* can express, and expressing them here is what makes the generated form refuse
* the invalid draft in front of the person typing it. `validateSettings` keeps
* the cross-field rules no single field's schema can state.
*/
export const Config = Schema.object({
/** Embedding endpoint, without a trailing slash. */
embeddingApiBase: Schema.string().default('http://127.0.0.1:11434').volatile(),
/** Model id as the endpoint knows it. */
embeddingModel: Schema.string().default('unclemusclez/jina-embeddings-v2-base-code:latest').volatile(),
/** Vector width. Must match the model; a mismatch is refused, not truncated. */
embeddingDimensions: Schema.number().min(1).default(768).volatile(),
/** Bearer token for a gateway that wants one. */
// `role('secret')` keeps this out of every settings response: the wire
// surfaces strip schema-declared secret positions, so a page can report that a
// key is set without ever receiving it.
embeddingApiKey: Schema.string().default('').role('secret').volatile(),
/** Inputs per request on the batch endpoint. */
embeddingBatchSize: Schema.number().min(1).default(16).volatile(),
/** Requests in flight. */
embeddingConcurrency: Schema.number().min(1).default(2).volatile(),
/**
* Per-request timeout.
*
* Deliberately short. A local endpoint that has wedged does not refuse the
* connection — it accepts it and never answers, so a long timeout turns a
* broken backend into a tool call that appears to hang. Thirty seconds is far
* more than a batch of a few thousand characters needs on any CPU, and it
* fails in bounded time instead of leaving the caller guessing.
*/
embeddingTimeoutMs: Schema.number().min(1).default(30_000).volatile(),
/** Ollama `keep_alive`. Keeps the model resident across an index run. */
embeddingKeepAlive: Schema.string().default('5m').volatile(),
/** Extensions to index. Empty means the built-in code set. */
extensions: Schema.array(String).default([]).volatile(),
/** Extra glob patterns to skip, on top of the built-in directory list. */
exclude: Schema.array(String).default([]).volatile(),
/** Most files a workspace walk will consider. */
maxFiles: Schema.number().min(1).default(20_000).volatile(),
/** Files larger than this are skipped as generated. */
maxFileBytes: Schema.number().min(1).default(1_048_576).volatile(),
/** Target lines per chunk. */
chunkLines: Schema.number().min(1).default(48).volatile(),
/** Hard cap before a chunk is split. */
maxChunkLines: Schema.number().min(1).default(80).volatile(),
/** Lines shared between the halves of a split chunk. */
overlapLines: Schema.number().min(0).default(8).volatile(),
/** Results returned when the caller does not say. */
defaultLimit: Schema.number().min(1).default(8).volatile(),
/** Ceiling on the caller's `limit`. */
maxLimit: Schema.number().min(1).default(25).volatile(),
/** Most chunks one file may contribute to one result set. */
perFileLimit: Schema.number().min(1).default(2).volatile(),
/**
* Lines shown per excerpt.
*
* Defaults to the chunk cap, so a chunk — already a bounded semantic unit —
* is returned whole. Cutting one in half is how a search turns into a
* follow-up read that costs more than the lines it saved.
*/
excerptLines: Schema.number().min(1).default(80).volatile(),
/** Total lines across one result set, allocated by rank. */
totalExcerptLines: Schema.number().min(1).default(400).volatile(),
/** Below this best-similarity, the result set is reported as weak. */
minSimilarity: Schema.number().min(0).max(1).default(0.2).volatile(),
/** Embedding time one `search` call may spend warming the index. */
syncBudgetMs: Schema.number().min(0).default(20_000).volatile(),
/** Index directory. Empty means `$DSH_HOME/dsh-recall/workspaces/<key>`. */
stateDir: Schema.string().default('').volatile(),
/**
* Narrows the index and every search to one directory.
*
* Empty resolves by marker: the nearest ancestor of the session's directory
* carrying a `focusMarkers` file becomes the scope, and the workspace root is
* the fallback — which is the behaviour that existed before focus did, so
* adopting this cannot regress a deployment that never configures it.
*/
focus: Schema.string().default('').volatile(),
/** Filenames that mark a directory as its own scope. Empty means the built-ins. */
focusMarkers: Schema.array(String).default([]).volatile(),
/**
* Extra trees to index beside the workspace, for code that is not in it.
*
* The workspace index cannot answer questions about the code you are writing
* *against* — an installed framework, a platform SDK, a sibling checkout. In
* plugin development that is where most of the reading goes: the harness's own
* packages are the only authoritative copy of the API being implemented, and
* they live outside the workspace.
*
* Each entry is a path, optionally prefixed with `name=`:
*
* - an **absolute** path is taken as given — what a deployment's installed
* tree needs;
* - a **relative** path resolves against the workspace, which is what makes a
* checked-in configuration portable;
* - the **name** defaults to the directory's basename and becomes the first
* segment of every result path from that root (`@deepseek-ai/dsh-agent/…`),
* so a result says which tree it came from. Use `name=` when the basename is
* long or ambiguous.
*
* The root's *contents* are indexed, never its dependencies: `node_modules`,
* build output and VCS directories are skipped below the root while the root
* itself is always entered — which is what lets a root be the scoped-packages
* directory of a package manager. An unusable entry is reported in the result
* rather than being fatal, so one typo cannot cost the other roots.
*
* A result from a root carries an absolute `sourcePath` for the follow-up read,
* because the labelled path is relative to the root rather than the workspace.
* Reading it may still need a scope grant from the caller's own policy; this
* plugin cannot grant one.
*/
sourceRoots: Schema.array(String).default([]).volatile(),
/**
* Ask the local model for alternative phrasings and fuse the rankings.
*
* Off by default. It costs one local generation plus an embedding per variant,
* and its benefit has not been measured against a real model — a default that
* spends latency on an unquantified gain is not a default worth having.
*/
expansionEnabled: Schema.boolean().default(false).volatile(),
/** Model that writes the phrasings. Empty uses `embeddingModel`'s endpoint, not its model. */
expansionModel: Schema.string().default('').volatile(),
/** Most variants to ask for. */
expansionCount: Schema.number().min(1).default(4).volatile(),
/** Budget for the whole expansion, in ms. Expiry just means search without it. */
expansionTimeoutMs: Schema.number().min(1).default(8_000).volatile(),
/**
* Have the local model score the top candidates against the query.
*
* Off by default for the same reason, and more so: it is one more model call
* on the critical path of every search.
*/
rerankEnabled: Schema.boolean().default(false).volatile(),
/** Model that scores candidates. */
rerankModel: Schema.string().default('').volatile(),
/** Candidates scored per search. The reranker caps this at 12. */
rerankCandidates: Schema.number().min(1).default(8).volatile(),
/** Budget for the scoring call, in ms. */
rerankTimeoutMs: Schema.number().min(1).default(15_000).volatile(),
})
/**
* Read one configuration field.
*
* A volatile field is a reference in the running config, not a plain value; a
* non-volatile one (or a config object a test handed straight to `apply`) is
* the value itself. Both are accepted so the same path serves a real profile
* and a direct call.
*
* @param {unknown} value a field of the resolved config
* @returns {unknown} the current value
*/
export function configValue(value) {
return value !== null && typeof value === 'object' && typeof value.get === 'function' ? value.get() : value
}
/**
* A plain snapshot of the whole settings section.
*
* Taken per tool call rather than cached, because that is exactly what a
* volatile field buys: the running instance's references are updated in place
* when the document changes, so the next read sees the new value without the
* plugin being remounted.
*
* @param {Record<string, any>} config the resolved composition config
* @returns {Record<string, any>} every declared field, unwrapped
*/
export function settingsSnapshot(config) {
const snapshot = {}
for (const key of Object.keys(config ?? {})) snapshot[key] = configValue(config[key])
return snapshot
}
/** Resolve the harness home, preferring the harness's own resolver. */
function dshHomeOf(ctx) {
try {
const homePath = ctx.get('dshHomePath')
if (typeof homePath === 'function') {
const resolved = homePath()
if (typeof resolved === 'string' && resolved !== '') return resolved
}
} catch {
// Declared optional; absent outside dsh-app-boot, which is expected.
}
return resolveDshHome()
}
/**
* Constraints a single field's schema cannot express.
*
* Called on the section a call is about to act on, and on the section a
* `configure` patch would leave behind. The Host validates every field against
* `Config` — including the `.min`/`.max` bounds — but the rules here relate two
* fields to each other (`defaultLimit` against `maxLimit`, `overlapLines`
* against `maxChunkLines`), and 0.1.7 has no write-time validator hook for
* those. So the check moves to the two moments that can still refuse: before a
* write this plugin performs, and before a configured value is put to work.
*
* @param {Record<string, any>} value a schema-valid settings section
* @throws {Error} when the section cannot be acted on
*/
export function validateSettings(value) {
const base = String(value.embeddingApiBase ?? '').trim()
let url
try {
url = new URL(base)
} catch {
throw new Error(`embeddingApiBase is not a URL: ${JSON.stringify(base)}`)
}
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
throw new Error(`embeddingApiBase must be http or https, not ${url.protocol}`)
}
if (!(Number(value.embeddingDimensions) > 0)) {
throw new Error('embeddingDimensions must be a positive number')
}
for (const key of [
'embeddingBatchSize',
'embeddingConcurrency',
'embeddingTimeoutMs',
'maxLimit',
'defaultLimit',
'perFileLimit',
'excerptLines',
'totalExcerptLines',
'chunkLines',
'maxChunkLines',
'maxFiles',
'maxFileBytes',
'expansionCount',
'expansionTimeoutMs',
'rerankCandidates',
'rerankTimeoutMs',
]) {
if (!(Number(value[key]) >= 1)) throw new Error(`${key} must be at least 1`)
}
if (Number(value.syncBudgetMs) < 0) throw new Error('syncBudgetMs cannot be negative')
if (Number(value.overlapLines) < 0) throw new Error('overlapLines cannot be negative')
if (Number(value.maxChunkLines) < Number(value.chunkLines)) {
throw new Error('maxChunkLines cannot be below chunkLines')
}
if (Number(value.overlapLines) >= Number(value.maxChunkLines)) {
throw new Error('overlapLines must be smaller than maxChunkLines, or a split never advances')
}
if (Number(value.defaultLimit) > Number(value.maxLimit)) {
throw new Error(`defaultLimit (${value.defaultLimit}) cannot exceed maxLimit (${value.maxLimit})`)
}
if (Number(value.minSimilarity) < 0 || Number(value.minSimilarity) > 1) {
throw new Error('minSimilarity is a cosine similarity and must be between 0 and 1')
}
}
/** SHA-256 of file content, used as the change key. */
function hashOf(content) {
return createHash('sha256').update(content).digest('hex')
}
/**
* Serialise work per key.
*
* Two tool calls can arrive while the first is still warming the index, and two
* concurrent syncs of the same workspace would interleave writes to one SQLite
* handle. Chaining on a per-workspace promise makes the second wait rather than
* race, which is what lets `search` stay marked concurrency-safe.
*/
function createLock() {
const chains = new Map()
return async function withLock(key, work) {
const previous = chains.get(key) ?? Promise.resolve()
let release
const gate = new Promise((resolveGate) => {
release = resolveGate
})
chains.set(
key,
previous.then(() => gate),
)
await previous
try {
return await work()
} finally {
release()
if (chains.get(key) === gate) chains.delete(key)
}
}
}
/**
* Schema of the persisted fold.
*
* The projection stores state as plain JSON and validates it with `.parse()`
* before seeding a fold from a checkpoint, so this must accept exactly what
* `emptyUsage()` produces and what `foldUsage` returns.
*/
const usageStateSchema = z.object({
version: z.number(),
searches: z.number(),
indexes: z.number(),
failed: z.number(),
excerpts: z.number(),
returnedBytes: z.number(),
sourceBytes: z.number(),
files: z.number(),
weak: z.number(),
rereadBytes: z.number(),
lastFocus: z.string(),
})
/** Schema of the wire view — what the browser receives. */
const usageViewSchema = z.object({
searches: z.number(),
indexes: z.number(),
failed: z.number(),
excerpts: z.number(),
files: z.number(),
weak: z.number(),
returnedBytes: z.number(),
sourceBytes: z.number(),
avoidedBytes: z.number(),
avoidedTokens: z.number(),
compression: z.number(),
rereadTokens: z.number(),
avoidedUsd: z.number(),
lastFocus: z.string(),
})
/**
* Per-conversation recall usage, folded from the tool metadata in the log.
*
* A projection rather than a client-side fold over conversation nodes: nodes
* carry only the loaded window, so a chip built on them would undercount the
* moment a session outgrew what the browser had scrolled through. This folds
* the whole log and is replayed from it.
*/
const USAGE_KEY = 'recallUsage'
/** Views are compared by reference, so one state must yield one view object. */
const usageViews = new WeakMap()
function usageViewFor(state) {
let view = usageViews.get(state)
if (view === undefined) {
view = usageSummary(state)
usageViews.set(state, view)
}
return view
}
export const usageProjection = {
key: USAGE_KEY,
stateSchema: usageStateSchema,
init: () => emptyUsage(),
apply: (state, event) => foldUsage(state, event),
stateVersion: 1,
wire: { viewSchema: usageViewSchema, view: usageViewFor },
}
export const name = 'dsh-recall'
export const inject = ['tools']
/**
* Register the tool and the settings namespace.
*
* @param {import('@deepseek-ai/cordis').Context} ctx
* @param {Record<string, any>} config validated composition config
*/
export function apply(ctx, config) {
const logger = ctx.logger('dsh-recall')
const dshHome = dshHomeOf(ctx)
const withLock = createLock()
// Discovered scopes per workspace, cached for a short window. Discovery is a
// directory walk on the hot path of every tool call, and re-walking a large
// repository to learn something that changes only when a marker file is added
// is work with no benefit.
const SCOPE_CACHE_MS = 30_000
const scopeCache = new Map()
// Open indexes are filesystem handles, so they are an effect: Cordis closes
// them on unload and on a config change, and a reload never leaks a handle.
const openIndexes = new Map()
ctx.effect(() => () => {
for (const index of openIndexes.values()) index.close()
openIndexes.clear()
})
// The authoritative settings source: a plain snapshot of the running config.
//
// No settings provider is involved in READING. The `Config` schema above
// declares every field volatile, so a document edit updates this instance's
// config references in place and the very next snapshot sees it. That is the
// 0.1.7 model — there is no `installSection` to attach to, and nothing to
// subscribe to for a value that is already live.
const readSettings = () => settingsSnapshot(config)
let writeSettings
// Per-conversation usage, folded from the tool metadata the log already
// carries. Optional: a profile without projections still gets the tool, it
// just does not get a surface that reports what it saved.
ctx.inject(['sessionProjections'], (projectionCtx) => {
try {
projectionCtx.sessionProjections.register(usageProjection)
} catch (error) {
logger.warn('dsh-recall: usage projection unavailable: %s', error?.message ?? error)
}
})
// Writing is still the settings provider's job, because the value has to
// reach the profile's own patch layer rather than live only in this process.
//
// `ns` is the profile ENTRY id — the `id` of this plugin's row in the composed
// tree, which is `dsh-recall` in cordis.patch.yml — not a namespace this
// plugin invents. The revision is read back from `describe()` so a
// configuration dialog that saved in the meantime refuses this write instead
// of being silently overwritten.
ctx.inject(['settings'], (settingsCtx) => {
writeSettings = async (patch) => {
const revision = settingsCtx.settings
.describe()
.find((descriptor) => descriptor.ns === SETTINGS_NAMESPACE)?.revision
await settingsCtx.settings.update(SETTINGS_NAMESPACE, patch, revision)
}
})
/** The embedder for a settings snapshot. */
const embedderFor = (settings) =>
createEmbedder({
apiBase: String(settings.embeddingApiBase),
model: String(settings.embeddingModel),
dimensions: Number(settings.embeddingDimensions),
apiKey: String(settings.embeddingApiKey ?? ''),
batchSize: Number(settings.embeddingBatchSize),
concurrency: Number(settings.embeddingConcurrency),
timeoutMs: Number(settings.embeddingTimeoutMs),
keepAlive: String(settings.embeddingKeepAlive ?? ''),
})
/**
* A one-shot embedder for probing the endpoint.
*
* No retries and a short timeout, because a probe exists to answer a question
* quickly: `status` should report a wedged backend, not wait for one. Three
* retries against a socket that accepts and never replies would take longer
* than the tool call that provoked it.
*/
const proberFor = (settings) =>
createEmbedder({
apiBase: String(settings.embeddingApiBase),
model: String(settings.embeddingModel),
dimensions: Number(settings.embeddingDimensions),
apiKey: String(settings.embeddingApiKey ?? ''),
batchSize: 1,
concurrency: 1,
timeoutMs: Math.min(Number(settings.embeddingTimeoutMs), 10_000),
maxAttempts: 1,
keepAlive: String(settings.embeddingKeepAlive ?? ''),
})
/**
* The index fields the tool's output schema declares.
*
* Built by picking rather than by spreading the sync result, because that
* result also carries internal fields (`stale`, `chunksWritten`) which are not
* part of the model-facing contract — and the schema is
* `additionalProperties: false`, so spreading one in is a validation failure
* rather than a harmless extra.
*/
function indexView(current, extra = {}) {
const out = { ...current }
const fields = [
'files',
'chunks',
'pending',
'scanned',
'embedded',
'removed',
'oversized',
'unreadable',
'truncated',
'sourceRoots',
]
for (const key of fields) if (extra[key] !== undefined) out[key] = extra[key]
return out
}
/**
* Discovered scopes for a workspace, cached briefly.
*
* Discovery is a directory walk, not a file read, so it is cheap — but it runs
* on every tool call and a large repository has a lot of directories. A short
* cache keeps the cost off the hot path while still noticing a marker file
* added a moment ago.
*
* @param {string} workspace absolute workspace root
* @param {readonly string[]} markers marker filenames
*/
async function scopesFor(workspace, markers) {
const key = `${workspace}\u0000${markers.join(',')}`
const hit = scopeCache.get(key)
if (hit !== undefined && Date.now() - hit.at < SCOPE_CACHE_MS) return hit.scopes
let scopes = []
try {
scopes = await discoverScopes({ workspace, markers })
} catch {
// A workspace that cannot be walked simply has no discoverable scopes;
// resolution still falls back to markers and then the workspace.
scopes = []
}
cacheScopes(key, scopes)
return scopes
}
/** Replace the scope cache, dropping entries for workspaces that are gone. */
function cacheScopes(key, scopes) {
scopeCache.set(key, { at: Date.now(), scopes })
if (scopeCache.size > 32) {
for (const [existing, entry] of scopeCache) {
if (Date.now() - entry.at > SCOPE_CACHE_MS * 4) scopeCache.delete(existing)
}
}
}
/**
* Open (or reuse) the index for one workspace and focus.
*
* @param {Record<string, any>} settings resolved settings
* @param {string} cwd workspace root
* @param {string} focus focus key, `.` for the whole workspace
*/
function indexFor(settings, cwd, focus = '.') {
const absolute = (path) => join(resolve(path), focus === '.' ? 'index.sqlite' : join('focus', focus, 'index.sqlite'))
const file =
typeof settings.stateDir === 'string' && settings.stateDir.trim() !== ''
? absolute(settings.stateDir.trim())
: indexFile(dshHome, cwd, focus)
let index = openIndexes.get(file)
if (index === undefined) {
index = new RecallIndex(file)
openIndexes.set(file, index)
}
const identity = {
model: String(settings.embeddingModel),
dimensions: String(settings.embeddingDimensions),
apiBase: String(settings.embeddingApiBase),
}
// `open` is idempotent for an already-open handle; the identity check is the
// part that must run every time, because the settings can change under it.
const outcome = index.db === undefined ? index.open(identity) : index.recheck(identity)
return { index, file, discarded: outcome.discarded }
}
/**
* Bring one workspace's index up to date.
*
* Phase one reads and hashes every candidate — fast, bounded by disk. Phase
* two embeds what changed, and is the only part given a deadline. Splitting
* them is what makes the reported state honest: after phase one the plugin
* knows exactly what is stale, so a partial index is described rather than
* guessed at.
*/
/**
* The candidate file list for one index: the workspace (or focus) tree plus
* every extra root.
*
* Split out from {@link syncWorkspace} because a search reports the absolute
* path of a result that came from an extra root, and the read phase is where
* that mapping is known. Both callers therefore see one list and one set of
* problems.
*/
async function walkIndex(input) {
const { root, prefix = '', settings, signal, sourceRoots = [] } = input
const walk = await listCodeFiles(root, {
...(settings.extensions?.length ? { extensions: settings.extensions } : {}),
skipDirs: DEFAULT_SKIP_DIRS,
exclude: settings.exclude ?? [],
maxFiles: Number(settings.maxFiles),
signal,
})
// `listCodeFiles` reports paths relative to the walk root, which under a
// focus is the focus directory. They are rewritten to be relative to the
// WORKSPACE, because that is the path the model will hand to `read` — a
// focus-relative result would silently read the wrong file.
if (prefix !== '') {
for (const file of walk.files) file.path = `${prefix}/${file.path}`
}
// Extra roots are walked as their own trees and merged into the same index,
// so one search ranks workspace code and library code together. Each root
// reports its own truncation: a root too large to index must say so rather
// than answer from its first few files.
const files = [...walk.files]
const problems = [...(input.sourceRootProblems ?? [])]
const summary = []
let truncated = walk.truncated
for (const sourceRoot of sourceRoots) {
const walkOfRoot = await listRootFiles({
name: sourceRoot.name,
path: sourceRoot.path,
...(settings.extensions?.length ? { extensions: settings.extensions } : {}),
exclude: settings.exclude ?? [],
maxFiles: Number(settings.maxFiles),
signal,
})
// A root labelled with the focus path itself would index two different
// files under one key — the second write silently replacing the first.
if (prefix !== '' && (prefix === sourceRoot.name || prefix.startsWith(`${sourceRoot.name}/`))) {
problems.push(
`the source root "${sourceRoot.name}" collides with the focus path "${prefix}"; ` +
'give it a different name= or search without that focus',
)
continue
}
files.push(...walkOfRoot.files)
summary.push({ name: sourceRoot.name, path: sourceRoot.path, files: walkOfRoot.files.length })
truncated = truncated || walkOfRoot.truncated
}
return { files, truncated, problems, summary }
}
async function syncWorkspace(input) {
const { index, settings, budgetMs, signal, onProgress } = input
const { files, truncated, problems: rootProblems, summary: rootSummary } = await walkIndex(input)
const known = index.fileHashes()
const present = new Set(files.map((file) => file.path))
const removed = index.prune([...known.keys()].filter((path) => !present.has(path)))
const maxBytes = Number(settings.maxFileBytes)
const pending = []
let oversized = 0
let unreadable = 0
for (const file of files) {
if (signal?.aborted) throw signal.reason ?? new Error('aborted')
let content
try {
const info = await stat(file.absolute)
if (info.size > maxBytes) {
oversized += 1
continue
}
content = await readFile(file.absolute, 'utf8')
} catch {
unreadable += 1
continue
}
const hash = hashOf(content)
if (known.get(file.path) === hash) continue
pending.push({ ...file, content, hash, bytes: Buffer.byteLength(content) })
}
// Anything still holding an old hash is stale until it is re-embedded.
const stale = new Set(pending.map((file) => file.path))
const deadline = Date.now() + budgetMs
let embedded = 0
let chunksWritten = 0
for (const file of pending) {
if (signal?.aborted) throw signal.reason ?? new Error('aborted')
// Always embed at least one file, or a zero budget would make the feature
// indistinguishable from a broken index.
if (embedded > 0 && Date.now() >= deadline) break
const { chunks } = chunkFile({
path: file.path,
content: file.content,
chunkLines: Number(settings.chunkLines),
maxChunkLines: Number(settings.maxChunkLines),
overlapLines: Number(settings.overlapLines),
})
if (chunks.length === 0) {
// An empty or generated file still gets a row, so it is not re-read and
// re-chunked on every subsequent search.
index.replaceFile(file.path, file.hash, file.bytes, [])
embedded += 1
stale.delete(file.path)
onProgress?.(embedded, pending.length)
continue
}
const vectors = await input.embedder.embed(
chunks.map((chunk) => embedText(file.path, chunk)),
signal,
)
index.replaceFile(
file.path,
file.hash,
file.bytes,
chunks.map((chunk, at) => ({ ...chunk, vec: vectors[at] })),
)
chunksWritten += chunks.length
embedded += 1
stale.delete(file.path)
onProgress?.(embedded, pending.length)
}
const stats = index.stats()
return {
...stats,
scanned: files.length,
truncated,
removed,
embedded,
chunksWritten,
pending: pending.length - embedded,
stale: [...stale],
oversized,
unreadable,
sourceRoots: rootSummary,
sourceRootProblems: rootProblems,
// Not part of the model-facing contract — the schema is closed — but a
// search needs it: a result from an extra root is labelled relative to
// that root, so the absolute path is the only thing `read` can use, and
// only the walk knows it.
fileAbsolutes: new Map(files.map((file) => [file.path, file.absolute])),
}
}
const TOOL_OUTPUT = {
type: 'object',
additionalProperties: false,
properties: {
action: { type: 'string', required: true },
ok: { type: 'boolean', required: true },
query: { type: 'string' },
workspace: { type: 'string' },
focus: {
type: 'object',
additionalProperties: false,
properties: {
path: { type: 'string', required: true },
source: { type: 'string', required: true },
marker: { type: 'string' },
problem: { type: 'string' },
},
},
results: {
type: 'array',
items: {
type: 'object',
additionalProperties: false,
properties: {
path: { type: 'string', required: true },
startLine: { type: 'integer', required: true },
endLine: { type: 'integer', required: true },
symbol: { type: 'string' },
similarity: { type: 'number', required: true },
excerpt: { type: 'string', required: true },
truncated: { type: 'boolean', required: true },
stale: { type: 'boolean', required: true },
/**
* Absolute path, present only for a result that came from an extra
* index root. The labelled `path` of such a result is relative to
* its root rather than the workspace, so it is not what `read`
* resolves — this is.
*/
sourcePath: { type: 'string' },
},
},
},
index: {
type: 'object',
additionalProperties: false,
properties: {
files: { type: 'integer' },
chunks: { type: 'integer' },
pending: { type: 'integer' },
scanned: { type: 'integer' },
embedded: { type: 'integer' },
removed: { type: 'integer' },
oversized: { type: 'integer' },
unreadable: { type: 'integer' },
truncated: { type: 'boolean' },
database: { type: 'string' },
stateDir: { type: 'string' },
/** The extra roots this index holds, and how many files each contributed. */
sourceRoots: {
type: 'array',
items: {
type: 'object',
additionalProperties: false,
properties: {
name: { type: 'string', required: true },
path: { type: 'string', required: true },
files: { type: 'integer', required: true },
},
},
},
},
},
/** Entries of `sourceRoots` that could not be used, one message each. */
sourceRootProblems: { type: 'array', items: { type: 'string' } },
best: { type: 'number' },
endpoint: {
type: 'object',
additionalProperties: false,
properties: {
ok: { type: 'boolean', required: true },
url: { type: 'string', required: true },
model: { type: 'string', required: true },
errorCode: { type: 'string' },
message: { type: 'string' },
},
},
weak: { type: 'boolean' },
/**
* What this call returned, against what reading those files would have
* cost. `sourceBytes` is an upper bound and is labelled as one wherever
* it is shown — a careful reader might have opened one file, not four.
*/
measure: {
type: 'object',
additionalProperties: false,
properties: {
excerpts: { type: 'integer', required: true },
returnedBytes: { type: 'integer', required: true },
sourceBytes: { type: 'integer', required: true },
files: { type: 'integer', required: true },
},
},
jobId: { type: 'string' },
/** Every scope discovered in this workspace, so a caller can pick one. */
scopes: {
type: 'array',
items: {
type: 'object',
additionalProperties: false,
properties: {
path: { type: 'string', required: true },
marker: { type: 'string', required: true },
},
},
},
note: { type: 'string' },
errorCode: { type: 'string' },
remedy: { type: 'string' },
},
}
/** Render one search result set for the model. */
function renderResults(value) {
if (value.jobId !== undefined) {
return [
{
type: 'text',
text:
`Indexing ${value.workspace} as background job ${value.jobId}. You will be notified when it settles; ` +
`collect it with job_output for "${value.jobId}" (add wait: true if you are blocked on it). ` +
'Searches work meanwhile against whatever is already indexed.',
},
]
}
if (value.ok !== true) {
const parts = [value.note ?? `recall ${value.action} failed.`]
if (value.remedy !== undefined) parts.push(`Next: ${value.remedy}`)
return [{ type: 'text', text: parts.join('\n\n') }]
}
if (value.action === 'configure') {
return [{ type: 'text', text: value.note ?? 'Configuration updated.' }]
}
if (value.action !== 'search') {
const { files = 0, chunks = 0, pending = 0, scanned = 0, embedded = 0 } = value.index ?? {}
const refused = (value.sourceRootProblems ?? []).length === 0
? ''
: `\n\nRefused source roots: ${value.sourceRootProblems.join('; ')}.`
return [
{
type: 'text',
text:
`Index for ${value.workspace}: ${files} files, ${chunks} chunks. ` +
`Scanned ${scanned}, embedded ${embedded} this call, ${pending} still pending.${refused}`,
},
]
}
const results = value.results ?? []
if (results.length === 0) {
const suffix = (value.index?.pending ?? 0) > 0 ? ` ${value.index.pending} file(s) are still unindexed.` : ''
const refused =
(value.sourceRootProblems ?? []).length === 0
? ''
: ` Refused source roots: ${value.sourceRootProblems.join('; ')}.`
return [
{
type: 'text',
text:
`No match for ${JSON.stringify(value.query)}.${suffix}${refused} ` +
'If you know the exact identifier, grep is the better tool for this query.',
},
]
}
const scope = value.focus?.path !== undefined && value.focus.path !== '.' ? ` in ${value.focus.path}` : ''
const header =
`${results.length} excerpt(s) for ${JSON.stringify(value.query)}${scope} — ` +
`from ${value.index?.chunks ?? 0} indexed chunks` +
((value.index?.pending ?? 0) > 0 ? `, ${value.index.pending} file(s) still unindexed` : '')
const body = results.map((result, at) => {
const symbol = result.symbol === undefined ? '' : ` (${result.symbol})`
const stale = result.stale ? ' [stale — file changed since indexing]' : ''
const cut = result.truncated ? '\n… excerpt truncated; read the file for the rest' : ''
// A result from an extra root is labelled relative to that root, so the
// workspace-relative `path` is not something `read` can resolve. The
// absolute path is stated when it is the one that differs — a workspace
// result already has a usable path and does not need the noise.
const source = result.sourcePath === undefined ? '' : `\n at ${result.sourcePath}`
return (
`--- ${at + 1}. ${result.path}:${result.startLine}-${result.endLine}${symbol} ` +
`similarity ${result.similarity}${stale}${source}\n${result.excerpt}${cut}`
)
})
// A scope is a window, not a filter on relevance. When it holds nothing
// strong, the most likely reason is that the answer is shared code the scope
// excludes — 41.6% of the workspace this was measured on — so say that
// rather than leaving the caller to conclude the code does not exist.
const outside =
value.focus?.path !== undefined && value.focus.path !== '.'
? ` Only ${value.focus.path} is indexed here; shared code outside it is not. Retry without focus to search the whole workspace.`
: ''
const advice = value.weak
? `\n\nNo strong match. Prefer grep for a known identifier, or rephrase with the name of the thing.${outside}`
: ''
return [{ type: 'text', text: `${header}\n\n${body.join('\n\n')}${advice}` }]
}
ctx.tools.register(
defineTool({
name: 'recall',
description:
'Search this workspace semantically with a local embedding model and return ranked code excerpts ' +
'with file paths and line ranges — without reading whole files into the conversation. Use it when ' +
'you do not know the exact identifier: "where is X handled", "which module does Y", "how does Z ' +
'recover from failure". ' +
// The move this exists to replace: reading a module's files one by one to
// find out what is in it. That is a search, and this is cheaper than the
// reads it avoids — including on the very first call, because a search
// warms a bounded slice of the index as it answers.
'Reach for it before reading files to find out what a module contains or ' +
'which of several alike modules does the job: one search returns the relevant lines from wherever ' +
'they are, where reading to find them costs the whole file for each wrong guess and every one of ' +
'those lines is re-sent on every later step. ' +
'Prefer grep when you already know the exact symbol, and read when you need the whole file. ' +
'Because excerpts are returned inline, do not follow up with a read of the same ' +
'range. A result labelled with a leading name (for example "@dsh/…") came from an extra source ' +
'root rather than the workspace; such a result also carries an absolute `sourcePath`, which is the ' +
'one `read` can resolve. Actions: "search" (default) answers a query and warms a bounded slice of ' +
'the index; "index" builds or refreshes the whole index, as a background job by default, which is ' +
'what makes the first search of a session cheap; "status" reports the ' +
'index and endpoint state. The index is incremental and lives outside your repository.',
parameters: {
query: {
type: 'string',
description: 'What to look for, in words. A description of behaviour works as well as a name.',
},
action: {
type: 'string',
enum: ['search', 'index', 'status', 'configure'],
description: 'What to do. Defaults to "search".',
},
limit: {
type: 'integer',
description: 'Most excerpts to return. Defaults to the configured limit (8).',
},
path: {
type: 'string',
description:
'Restrict the search to files under this workspace-relative directory, for example "src/services".',
},
focus: {
type: 'string',
description:
'Scope this whole call — index and search — to one workspace-relative directory, for example ' +
'"src/services/billing". Use it when the session is working on one module: the index ' +
'then holds only that module, so its near-identical neighbours cannot outrank it and the first ' +
'search does not wait on the rest of the repository. Empty uses the configured focus, then the ' +
'nearest enclosing directory that carries a CONTROLLER.md, DEBUG_UI.md or AGENTS.md.',
},
run_in_background: {
type: 'boolean',
description:
'For action "index" only: return a job id instead of the result. Defaults to true, because a ' +
'cold index of a large repository takes minutes.',
},
config: {
type: 'json',
description:
'Settings patch for action "configure", using this plugin\'s own camelCase field names, for ' +
'example {"embeddingApiBase":"http://127.0.0.1:11434"}.',
},
},
output: {
schema: TOOL_OUTPUT,
render: (_args, value) => renderResults(value),
// The harness persists this verbatim as the `meta` of the `tool/result`
// event, which is what puts per-call usage in the session log — and
// therefore in reach of a projection the browser can read. Pure, and it
// carries totals rather than content: it is appended on every call and
// replayed on every projection rebuild.
presentationMeta: (args, value) => deriveUsage(args, value),
},
isConcurrencySafe: (args) => (args.action ?? 'search') !== 'index',
async execute(args, exec) {
const settings = readSettings()
const cwd = exec.agent?.session.header.cwd ?? process.cwd()
const action = args.action ?? 'search'
const workspace = basename(cwd) || cwd
// Validate the live section before using it, every call.
//
// The Host parses a write against the schema, so anything a single field
// can express is refused at the document; the cross-field rules — a cap
// below its target, an overlap that never advances a split — are not
// expressible that way, and 0.1.7 has no write-time validator hook to
// hang them on. Checking here is what keeps a bad combination from
// surfacing later as an unexplained empty search.
try {
validateSettings(settings)
} catch (error) {
return {
action,
ok: false,
workspace,
errorCode: 'INVALID_SETTINGS',
note: `The dsh-recall configuration is not usable as it stands: ${error?.message ?? error}`,
remedy:
'Fix the conflicting values in Settings → Plugins → dsh-recall, or in the dsh-recall ' +
'row of your cordis layer.',
}
}
const embedder = embedderFor(settings)
// Resolve the scope before anything else, because it decides which index
// is opened. A caller's `focus` outranks the configured one, so a session
// can name a controller without changing the deployment's default.
const markers = settings.focusMarkers?.length ? settings.focusMarkers : DEFAULT_FOCUS_MARKERS
const scopes = await scopesFor(cwd, markers)
const focus = resolveFocus({
workspace: cwd,
cwd,
explicit: args.focus ?? settings.focus ?? '',
markers,
scopes,
})
const focusPath = focusKey(cwd, focus.root)
// Extra roots are resolved once per call against the live workspace, so
// an entry is judged where it is used rather than at load: a path that
// has not been installed yet is reported on the call that wanted it. A
// bad entry never aborts the call — it is reported beside the result, or
// the caller would lose the workspace index to one typo.
const resolvedRoots = resolveSourceRoots(settings.sourceRoots ?? [], { workspace: cwd })
const sourceRoots = resolvedRoots.roots
const sourceRootProblems = resolvedRoots.problems
let opened
try {
opened = indexFor(settings, cwd, focusPath)
} catch (error) {
return {
action,
ok: false,
workspace,
errorCode: 'INDEX_UNAVAILABLE',
note: `Could not open the index: ${error?.message ?? error}`,
remedy: `Check that ${dshHome} is writable.`,
}
}
const { index, file, discarded } = opened
const stateDir = workspaceStateDir(dshHome, cwd)
const focusView = {
path: focusPath,
source: focus.source,
...(focus.marker === undefined ? {} : { marker: focus.marker }),
...(focus.problem === undefined ? {} : { problem: focus.problem }),
}
const base = { action, workspace, focus: focusView, index: { database: file, stateDir } }
if (action === 'status') {
const stats = index.stats()
// Probe the endpoint rather than merely naming it. A wedged backend
// accepts the connection and never answers, so "configured" and
// "working" are different claims and status should make the second
// one. One attempt, short timeout — a probe that waits is not a probe.
let endpoint
try {
await proberFor(settings).embedOne('dsh-recall endpoint probe', exec.signal)
endpoint = {
ok: true,
url: String(settings.embeddingApiBase),
model: String(settings.embeddingModel),
}
} catch (error) {
endpoint = {
ok: false,
url: String(settings.embeddingApiBase),
model: String(settings.embeddingModel),
errorCode: error instanceof EmbeddingError ? error.code : 'PROBE_FAILED',
message: error?.message ?? String(error),
}
}
return {
...base,
ok: true,
index: indexView(base.index, stats),
endpoint,
scopes: scopes.map((entry) => ({ path: entry.path, marker: entry.marker })),
...(sourceRootProblems.length === 0 ? {} : { sourceRootProblems }),
note:
`Index ${file} holds ${stats.files} files and ${stats.chunks} chunks, scoped to ` +
`${focusPath === '.' ? 'the whole workspace' : focusPath} (${focus.source}` +
`${focus.marker === undefined ? '' : `: ${focus.marker}`}). ` +
(focus.problem === undefined ? '' : `Requested focus was ignored — ${focus.problem}. `) +
// A workspace with discoverable scopes can be scoped by naming one,
// so listing them is the difference between a feature that exists
// and one a caller can actually use.
(scopes.length === 0
? 'No focusable scopes were found; pass focus to scope a call explicitly. '
: `Available scopes: ${scopes
.slice(0, 12)
.map((entry) => entry.path)
.join(', ')}${scopes.length > 12 ? ` (+${scopes.length - 12} more)` : ''}. `) +
// Extra roots are named even when the index has not been rebuilt
// since they were configured, so their file counts are deliberately
// absent here — `stats` describes the index, not the configuration.
(sourceRoots.length === 0
? ''
: `Extra roots indexed beside the workspace: ${sourceRoots
.map((entry) => `${entry.name} (${entry.path})`)
.join(', ')}. `) +
(sourceRootProblems.length === 0
? ''
: `Source roots refused: ${sourceRootProblems.join('; ')}. `) +
`Embeddings come from "${settings.embeddingModel}" (${settings.embeddingDimensions} dims) at ` +
`${settings.embeddingApiBase} — ` +
(endpoint.ok === true
? 'endpoint answered.'
: `endpoint did NOT answer (${endpoint.errorCode}). Run \`ollama ps\` on that host; a ` +
'model can be evicted, and an endpoint holding another model resident forever can ' +
'leave no room to load this one.') +
(discarded === undefined ? '' : ` The index was rebuilt because ${discarded}.`),
}
}
if (action === 'configure') {
const patch = args.config
if (patch === undefined || patch === null || typeof patch !== 'object' || Array.isArray(patch)) {
return {
...base,
ok: false,
errorCode: 'BAD_PATCH',
note: 'The `config` parameter must be an object.',
}
}
const unknown = Object.keys(patch).filter((key) => !SETTING_KEYS.includes(key))
if (unknown.length > 0) {
return {
...base,
ok: false,
errorCode: 'UNKNOWN_SETTING',
note: `Unknown setting(s): ${unknown.join(', ')}.`,
remedy: `Known settings: ${SETTING_KEYS.join(', ')}.`,
}
}
// Judge the SECTION the write would leave behind, not the patch on its
// own: `maxLimit` alone is always fine, and `defaultLimit` alone is
// always fine, but raising one past the other is the mistake worth
// catching — and it is invisible to any single value's schema.
try {
validateSettings({ ...settings, ...patch })
} catch (error) {
return {
...base,
ok: false,
errorCode: 'INVALID_SETTING',
note: `Refused: ${error?.message ?? error}`,
remedy: 'Nothing was written. Send a patch whose resulting section is consistent.',
}
}
if (writeSettings === undefined) {
return {
...base,
ok: false,
errorCode: 'NO_SETTINGS_PROVIDER',
note: 'No settings provider is mounted, so configuration cannot be persisted at runtime.',
remedy: `Set these values in the dsh-recall row of your cordis layer instead: ${JSON.stringify(patch)}`,
}
}
try {
await writeSettings(patch)
} catch (error) {
return {
...base,
ok: false,
errorCode: 'SETTINGS_WRITE_FAILED',
note: `The settings document refused the write: ${error?.message ?? error}`,
remedy: 'Re-read the current values and retry; a concurrent edit is the usual cause.',
}
}
return { ...base, ok: true, note: `Updated ${Object.keys(patch).join(', ')}.` }
}
// Full warm-up. Deliberately a background job by default: on a large
// repository this is minutes of work, and blocking a turn on it is the
// behaviour that makes a useful tool feel broken.
if (action === 'index') {
const jobs = ctx.get('jobs')
const wantsBackground = args.run_in_background ?? true
const runIndex = (signal) =>
withLock(file, () =>
syncWorkspace({
root: focus.root,
prefix: focusPath === '.' ? '' : focusPath,
index,
embedder,
settings,
budgetMs: Number.POSITIVE_INFINITY,
signal,
sourceRoots,
sourceRootProblems,
}),
)
if (wantsBackground && jobs !== undefined) {
// `owner` is a SESSION ID, not the Agent handle.
//
// The jobs registry resolves an owner through the agent registry —
// `agents.get(session)` — and throws `session "[object Object]" has
// no live agent` for anything else, so passing `exec.agent` here made
// the default `index` call fail outright: it is the one path this
// plugin takes that a caller cannot see fail until the job service is
// present. The agent's own `id` IS the session id (the registry keys
// by it), and a handle without one is registered unowned rather than
// refused.
const owner = exec.agent?.id
const jobId = jobs.start({
kind: 'recall-index',
label: `recall index ${workspace}`,
...(owner === undefined || owner === null || owner === '' ? {} : { owner }),
run: () => {
const controller = new AbortController()
const done = (async () => {
const result = await runIndex(controller.signal)
return {
status: 'completed',
detail: `${result.files} files, ${result.chunks} chunks`,
output:
`Indexed ${result.embedded} file(s) into ${result.chunks} chunks (${result.files} files ` +
`total, ${result.removed} removed). Database: ${file}`,
}
})()
return { cancel: () => controller.abort(), done }
},
})
return { ...base, ok: true, jobId }
}
const result = await runIndex(exec.signal)
return {
...base,
ok: true,
index: indexView(base.index, result),
...(result.sourceRootProblems.length === 0
? {}
: { sourceRootProblems: result.sourceRootProblems }),
}
}
// search
const query = typeof args.query === 'string' ? args.query.trim() : ''
if (query === '') {
return {
...base,
ok: false,
errorCode: 'NO_QUERY',
note: 'A search needs a query.',
remedy: 'Call recall with a `query` describing what you are looking for.',
}
}
const limit = Math.max(
1,
Math.min(Number(args.limit ?? settings.defaultLimit), Number(settings.maxLimit)),
)
try {
const sync = await withLock(file, () =>
syncWorkspace({
root: focus.root,
prefix: focusPath === '.' ? '' : focusPath,
index,
embedder,
settings,
budgetMs: Math.max(0, Number(settings.syncBudgetMs)),
signal: exec.signal,
sourceRoots,
sourceRootProblems,
}),
)
const matrix = index.matrix()
if (matrix.rows.length === 0) {
return {
...base,
ok: true,
query,
results: [],
weak: true,
index: indexView(base.index, sync),
note:
'Nothing is indexed yet, so there is nothing to search. ' +
(sync.pending > 0
? `${sync.pending} file(s) were found but the per-call embedding budget was reached.`
: 'No indexable code was found in this workspace.'),
...(sync.pending > 0
? {
remedy:
'Run recall with action "index" to build the whole index in the background.',
}
: {}),
}
}
// Query expansion: rank every phrasing and fuse the orders. The
// expander never throws — on a slow or absent local model it returns
// the original query alone, and this path is unchanged.
const expansionModel = String(settings.expansionModel ?? '').trim()
const queries = settings.expansionEnabled === true
? await createExpander({
apiBase: String(settings.embeddingApiBase),
model: expansionModel === '' ? String(settings.embeddingModel) : expansionModel,
timeoutMs: Number(settings.expansionTimeoutMs),
count: Number(settings.expansionCount),
}).expand(query, exec.signal)
: [query]
const orders = []
let similarity
let vectorBest = 0
for (const variant of queries) {
const vector = await embedder.embedOne(variant, exec.signal)
const ranked = rankOrder({ matrix, queryVector: vector, query: variant })
orders.push(ranked.order)
// The first variant is the caller's own query, so its similarities
// are the honest ones to report against each result.
if (similarity === undefined) {
similarity = ranked.similarity
vectorBest = ranked.vectorBest
}
}
let results = selectFrom(fuseRankings(orders), {
matrix,
similarity,
limit,
perFile: Number(settings.perFileLimit),
excerptLines: Number(settings.excerptLines),
totalLines: Number(settings.totalExcerptLines),
})
// Local re-ranking: one call scores the head of the list. `undefined`
// means the model had no opinion, which is not the same as "all
// irrelevant" — the fused order stands unchanged.
const rerankModel = String(settings.rerankModel ?? '').trim()
const rerankCount = Math.min(results.length, Number(settings.rerankCandidates), 12)
if (settings.rerankEnabled === true && rerankCount > 1) {
const head = results.slice(0, rerankCount)
const localScores = await createReranker({
apiBase: String(settings.embeddingApiBase),
model: rerankModel === '' ? String(settings.embeddingModel) : rerankModel,
timeoutMs: Number(settings.rerankTimeoutMs),
}).score(query, head.map((result) => result.excerpt), exec.signal)
if (localScores !== undefined && localScores.length === head.length) {
const blended = head
.map((result, at) => ({ result, score: 0.4 * result.similarity + 0.6 * localScores[at] }))
.sort((left, right) => right.score - left.score)
.map((entry) => entry.result)
results = [...blended, ...results.slice(rerankCount)]
}
}
if (typeof args.path === 'string' && args.path.trim() !== '') {
const prefix = args.path.trim().replace(/^\.\//, '').replace(/\/+$/, '')
results = results.filter(
(result) => result.path === prefix || result.path.startsWith(`${prefix}/`),
)
}
const stale = new Set(sync.stale ?? [])
const best = results.length === 0 ? 0 : Math.max(...results.map((result) => result.similarity))
// Where the result actually is, for the cases where the label is not a
// workspace-relative path. A workspace result keeps its plain path: the
// caller resolves it against the workspace and has no use for an
// absolute one. A result from an extra root cannot be resolved that
// way, so it carries the absolute path beside its label.
const absolutes = sync.fileAbsolutes ?? new Map()
const workspacePrefix = resolve(cwd).endsWith(sep) ? resolve(cwd) : `${resolve(cwd)}${sep}`
const sourcePathOf = (result) => {
const absolute = absolutes.get(result.path)
if (absolute === undefined) return undefined
return absolute.startsWith(workspacePrefix) ? undefined : absolute
}
// The alternative to these excerpts: the whole of each file they came
// from. Sizes come from the index, which recorded them at embed time,
// so this costs nothing here — and it is an upper bound, because a
// careful reader might have opened one of the four rather than all.
const sourcePaths = [...new Set(results.map((result) => result.path))]
const sourceSizes = index.bytesFor(sourcePaths)
const sourceBytes = [...sourceSizes.values()].reduce((sum, size) => sum + size, 0)
const returnedBytes = results.reduce(
(sum, result) => sum + Buffer.byteLength(result.excerpt, 'utf8'),
0,
)
const measure = {
excerpts: results.length,
returnedBytes,
sourceBytes,
files: sourceSizes.size,
}
return {
...base,
ok: true,
query,
// Built field by field rather than spread: the ranker also returns
// a fused RRF `score`, which is an internal ordering artefact and not
// part of the declared result shape.
results: results.map((result) => ({
path: result.path,
startLine: result.startLine,
endLine: result.endLine,
...(result.symbol === undefined ? {} : { symbol: result.symbol }),
similarity: result.similarity,
excerpt: result.excerpt,
truncated: result.truncated,
stale: stale.has(result.path),
...(sourcePathOf(result) === undefined ? {} : { sourcePath: sourcePathOf(result) }),
})),
index: indexView(base.index, sync),
best,
weak: results.length === 0 || best < Number(settings.minSimilarity),
measure,
...(sync.sourceRootProblems.length === 0
? {}
: { sourceRootProblems: sync.sourceRootProblems }),
}
} catch (error) {
const code = error instanceof EmbeddingError ? error.code : 'RECALL_FAILED'
const remedy =
code === 'UNREACHABLE' || error?.cause?.code === 'ECONNREFUSED'
? `Is the embedding endpoint at ${settings.embeddingApiBase} running and reachable? Set ` +
'`embeddingApiBase` through the plugin settings if it moved.'
: code === 'DIMENSION_MISMATCH'
? `Set \`embeddingDimensions\` to match "${settings.embeddingModel}".`
: undefined
logger.warn('recall search failed: %s', error?.message ?? error)
return {
...base,
ok: false,
query,
errorCode: code,
note: error?.message ?? String(error),
...(remedy === undefined ? {} : { remedy }),
}
}
},
}),
)
}