@kolisachint/hoocode-agent 0.5.59 → 0.5.61

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (28) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/dist/core/canvas/lifecycle.d.ts.map +1 -1
  3. package/dist/core/canvas/lifecycle.js +1 -1
  4. package/dist/core/canvas/lifecycle.js.map +1 -1
  5. package/dist/core/embsearch/chunker.d.ts +17 -1
  6. package/dist/core/embsearch/chunker.d.ts.map +1 -1
  7. package/dist/core/embsearch/chunker.js +20 -6
  8. package/dist/core/embsearch/chunker.js.map +1 -1
  9. package/dist/core/embsearch/client.d.ts +9 -0
  10. package/dist/core/embsearch/client.d.ts.map +1 -1
  11. package/dist/core/embsearch/client.js +2 -0
  12. package/dist/core/embsearch/client.js.map +1 -1
  13. package/dist/core/embsearch/embsearch-service.d.ts +37 -0
  14. package/dist/core/embsearch/embsearch-service.d.ts.map +1 -1
  15. package/dist/core/embsearch/embsearch-service.js +86 -7
  16. package/dist/core/embsearch/embsearch-service.js.map +1 -1
  17. package/dist/core/search/eval-harness.d.ts +86 -5
  18. package/dist/core/search/eval-harness.d.ts.map +1 -1
  19. package/dist/core/search/eval-harness.js +143 -3
  20. package/dist/core/search/eval-harness.js.map +1 -1
  21. package/dist/modes/interactive/components/session-selector.d.ts.map +1 -1
  22. package/dist/modes/interactive/components/session-selector.js +1 -1
  23. package/dist/modes/interactive/components/session-selector.js.map +1 -1
  24. package/examples/extensions/custom-provider-anthropic/package.json +1 -1
  25. package/examples/extensions/custom-provider-gitlab-duo/package.json +1 -1
  26. package/examples/extensions/sandbox/package.json +1 -1
  27. package/examples/extensions/with-deps/package.json +1 -1
  28. package/package.json +4 -4
@@ -1 +1 @@
1
- {"version":3,"file":"embsearch-service.d.ts","sourceRoot":"","sources":["../../../src/core/embsearch/embsearch-service.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Orchestrates semantic indexing and search for a repository.\n *\n * Lifecycle (all behind --enable-semantic-index):\n * 1. `start()` — resolve the embsearch binary, scan the repo (ignore-aware),\n * apply the byte threshold. Under threshold → dormant. Over → spawn the\n * daemon, verify the backend is not the mock embedder, then index changed\n * files in the background in small batches, reporting progress.\n * 2. `search()` — top-k semantic query, mapping chunk ids back to\n * `path:start-end` via the sidecar metadata.\n * 3. `dispose()` — save + close the daemon.\n *\n * Every failure degrades to `unavailable` with a reason; nothing here ever\n * blocks session startup or affects grep/find.\n */\n\nimport { execFileSync } from \"child_process\";\nimport { readFileSync, rmSync } from \"fs\";\nimport { minimatch } from \"minimatch\";\nimport { ensureTool } from \"../../utils/tools-manager.js\";\nimport { chunkFile } from \"./chunker.js\";\nimport {\n\ttype DaemonRetriever,\n\tEmbSearchClient,\n\ttype EmbSearchDaemonInfo,\n\ttype EmbSearchRerankPassage,\n\ttype EmbSearchRerankResult,\n} from \"./client.js\";\nimport {\n\temptyIndexMeta,\n\ttype FileMeta,\n\tgetEmbsearchStoreDir,\n\tgetVectorStoreDir,\n\thashContent,\n\ttype IndexMeta,\n\tloadIndexMeta,\n\tsaveIndexMeta,\n} from \"./index-meta.js\";\nimport { type RepoScanFile, scanRepo } from \"./repo-scan.js\";\n\n/** Chunks per bulk request. Small enough that a concurrent query is never\n * stuck long behind one padded batch inference. */\nconst BULK_BATCH_SIZE = 48;\n/** Yield between batches so background indexing doesn't starve the session. */\nconst BATCH_YIELD_MS = 15;\n/** The Rust mock backend's model id — semantically meaningless, never index with it. */\nconst MOCK_MODEL_ID = \"mock-hash-v1\";\n/**\n * First embsearch release serving `retriever: \"lexical\"`.\n *\n * The guard matters because the daemon does not reject unknown request fields:\n * an older binary silently ignores `retriever` and answers with dense results.\n * Fusing that list a second time as a \"bm25\" leg would double-count it and\n * corrupt the ranking with no error anywhere — so refuse instead of degrading.\n */\nconst MIN_LEXICAL_RETRIEVER_VERSION = [0, 2, 0] as const;\n/**\n * First embsearch release serving the `rerank` op.\n *\n * Necessary but no longer sufficient. Releases from 0.3.1 carry the op without\n * the ~23 MB cross-encoder weights — they measured worse than the\n * deterministic reranker on five of six query classes, so they are no longer\n * bundled — and such a daemon answers `rerank` with an error. The version is\n * therefore only the floor for *asking*; `info.rerank` is the answer, and\n * {@link EmbsearchService.supportsCrossEncoder} needs both.\n */\nconst MIN_RERANK_VERSION = [0, 3, 0] as const;\n\n/** `embsearch 0.2.0` -> [0, 2, 0]; undefined when it cannot be parsed. */\nfunction parseBinaryVersion(output: string): number[] | undefined {\n\tconst match = output.match(/(\\d+)\\.(\\d+)\\.(\\d+)/);\n\treturn match ? [Number(match[1]), Number(match[2]), Number(match[3])] : undefined;\n}\n\nfunction atLeast(version: readonly number[], minimum: readonly number[]): boolean {\n\tfor (let i = 0; i < minimum.length; i++) {\n\t\tconst part = version[i] ?? 0;\n\t\tif (part !== minimum[i]) return part > minimum[i];\n\t}\n\treturn true;\n}\n\nexport type EmbsearchState =\n\t| { phase: \"idle\" }\n\t| { phase: \"skipped\"; reason: string }\n\t| { phase: \"downloading\"; receivedBytes: number; totalBytes: number | null }\n\t| { phase: \"indexing\"; done: number; total: number }\n\t| { phase: \"ready\"; chunkCount: number }\n\t| { phase: \"unavailable\"; reason: string };\n\nexport interface EmbsearchServiceOptions {\n\tcwd: string;\n\t/** Explicit binary path (settings override). Default: \"embsearch\" from PATH. */\n\tbinaryPath?: string;\n\t/** Minimum indexable bytes before indexing kicks in. */\n\tthresholdBytes: number;\n\t/**\n\t * Override the store location. Only the eval harness sets this, so a\n\t * second index (e.g. a BM25-hybrid store) can exist for the same repo\n\t * without colliding with the primary one.\n\t */\n\tstoreDir?: string;\n\t/**\n\t * Create the store with the daemon's BM25 lexical index.\n\t *\n\t * Defaults to whatever the daemon can serve. Fixed at store creation, so an\n\t * existing store that disagrees is rebuilt once; when overriding this to\n\t * hold two different stores for one repo, pair it with a distinct\n\t * `storeDir` so they do not fight over the same directory.\n\t */\n\thybridStore?: boolean;\n\t/** Progress callback for UI (footer / stderr lines). */\n\tonProgress?: (state: EmbsearchState) => void;\n}\n\nexport interface SemanticHit {\n\tpath: string;\n\tstartLine: number;\n\tendLine: number;\n\tscore: number;\n}\n\nexport interface SemanticChunkHit extends SemanticHit {\n\t/** Per-build chunk id (`relpath#index`) — the fusion identity for hybrid search. */\n\tid: string;\n}\n\nexport class EmbsearchService {\n\tprivate readonly options: EmbsearchServiceOptions;\n\tprivate client: EmbSearchClient | undefined;\n\tprivate meta: IndexMeta | undefined;\n\tprivate state: EmbsearchState = { phase: \"idle\" };\n\tprivate disposed = false;\n\t/** Whether the resolved binary serves `retriever: \"lexical\"`. */\n\tprivate lexicalRetriever = false;\n\t/** Whether the store actually opened carries a BM25 index. */\n\tprivate hybridStore = false;\n\t/** Whether the resolved binary serves the cross-encoder `rerank` op. */\n\tprivate crossEncoder = false;\n\n\tconstructor(options: EmbsearchServiceOptions) {\n\t\tthis.options = options;\n\t}\n\n\tgetState(): EmbsearchState {\n\t\treturn this.state;\n\t}\n\n\t/** Semantic search is usable (index ready, or still building with partial data). */\n\tisAvailable(): boolean {\n\t\treturn this.state.phase === \"ready\" || this.state.phase === \"indexing\";\n\t}\n\n\tprivate setState(state: EmbsearchState): void {\n\t\tthis.state = state;\n\t\tthis.options.onProgress?.(state);\n\t}\n\n\t/**\n\t * Whether a BM25-only query will work: the daemon has to understand the\n\t * `lexical` retriever *and* the open store has to carry a BM25 index.\n\t */\n\tsupportsLexicalRetriever(): boolean {\n\t\treturn this.lexicalRetriever && this.hybridStore;\n\t}\n\n\t/**\n\t * Repo files whose on-disk content the index does not have — unknown to it,\n\t * or changed since it last read them.\n\t *\n\t * This is the set BM25 is structurally blind to, and the only place the\n\t * grep leg still earns its keep once BM25 is available. An agent that edits\n\t * a file and immediately searches for what it wrote is asking about exactly\n\t * these files; the index cannot answer until the next pass.\n\t *\n\t * Compares mtime and size only, never hashing: the check runs per query, and\n\t * a false positive merely lets grep cover a file BM25 already covers, while\n\t * a false negative would lose the edit.\n\t *\n\t * Deliberately uncached. A cache here caches the *absence* of an edit, which\n\t * is the one thing this must never do — an agent writes a file and searches\n\t * for it in the same breath. A 1s TTL was tried and cost the live-edit set\n\t * 75% to 100% of its score depending on how the timing fell, which is worse\n\t * than wrong: it was non-deterministic. One scan is ~25ms over ~1k files and\n\t * happens once per search, against retrieval that already costs more.\n\t */\n\tstaleFiles(signal?: AbortSignal): string[] {\n\t\tif (!this.meta) return [];\n\t\tconst meta = this.meta;\n\t\tconst files: string[] = [];\n\t\ttry {\n\t\t\tfor (const file of scanRepo(this.options.cwd, signal).files) {\n\t\t\t\tconst known = meta.files[file.rel];\n\t\t\t\tif (!known || known.mtimeMs !== file.mtimeMs || known.size !== file.size) files.push(file.rel);\n\t\t\t}\n\t\t} catch {\n\t\t\t// A failed scan must not silently narrow the grep leg to nothing;\n\t\t\t// report no staleness and let the indexed legs answer.\n\t\t\treturn [];\n\t\t}\n\t\treturn files;\n\t}\n\n\t/** Whether the running daemon can score with a cross-encoder. */\n\tsupportsCrossEncoder(): boolean {\n\t\treturn this.crossEncoder;\n\t}\n\n\t/**\n\t * Cross-encoder rerank of caller-supplied passages.\n\t *\n\t * Unlike the retrievers this does not consult the index at all — it scores\n\t * exactly the text passed in, which is why the caller sends its expanded\n\t * windows rather than chunk ids.\n\t */\n\tasync rerank(query: string, passages: EmbSearchRerankPassage[], k: number): Promise<EmbSearchRerankResult[]> {\n\t\tif (!this.client || this.client.isClosed) {\n\t\t\tthrow new Error(\"semantic index is not available\");\n\t\t}\n\t\tif (!this.crossEncoder) {\n\t\t\tthrow new Error(\n\t\t\t\t`this embsearch daemon cannot rerank (needs >= ${MIN_RERANK_VERSION.join(\".\")} reporting ` +\n\t\t\t\t\t\"`rerank: true`); released binaries ship without cross-encoder weights — start the daemon \" +\n\t\t\t\t\t\"with --reranker-model <dir>\",\n\t\t\t);\n\t\t}\n\t\treturn await this.client.rerank(query, passages, k);\n\t}\n\n\tprivate probeBinaryVersion(binary: string): number[] | undefined {\n\t\ttry {\n\t\t\treturn parseBinaryVersion(execFileSync(binary, [\"--version\"], { encoding: \"utf-8\", timeout: 10_000 }));\n\t\t} catch {\n\t\t\treturn undefined;\n\t\t}\n\t}\n\n\tprivate async resolveBinary(): Promise<string | undefined> {\n\t\tif (this.options.binaryPath) {\n\t\t\treturn this.options.binaryPath;\n\t\t}\n\t\t// Surface the on-demand binary download through the same progress channel as\n\t\t// indexing, so the first-run fetch renders a progress bar instead of a stall.\n\t\t// A cached binary resolves without ever invoking this callback.\n\t\treturn await ensureTool(\"embsearch\", true, (receivedBytes, totalBytes) => {\n\t\t\tthis.setState({ phase: \"downloading\", receivedBytes, totalBytes });\n\t\t});\n\t}\n\n\t/**\n\t * Scan, threshold-check, and (when needed) index in the background.\n\t * Resolves when indexing completes or the feature settles dormant.\n\t */\n\tasync start(signal?: AbortSignal): Promise<void> {\n\t\ttry {\n\t\t\tawait this.run(signal);\n\t\t} catch (e) {\n\t\t\tconst reason = e instanceof Error ? e.message : String(e);\n\t\t\tthis.setState({ phase: \"unavailable\", reason });\n\t\t\tawait this.closeClient();\n\t\t}\n\t}\n\n\tprivate async run(signal?: AbortSignal): Promise<void> {\n\t\tconst binary = await this.resolveBinary();\n\t\tif (!binary) {\n\t\t\tthis.setState({\n\t\t\t\tphase: \"unavailable\",\n\t\t\t\treason: \"embsearch binary not found (PATH or embsearchBinaryPath setting)\",\n\t\t\t});\n\t\t\treturn;\n\t\t}\n\n\t\tconst scan = scanRepo(this.options.cwd, signal);\n\t\tif (scan.totalBytes < this.options.thresholdBytes) {\n\t\t\tthis.setState({\n\t\t\t\tphase: \"skipped\",\n\t\t\t\treason: `repo under threshold (${scan.totalBytes} < ${this.options.thresholdBytes} bytes)`,\n\t\t\t});\n\t\t\treturn;\n\t\t}\n\n\t\tconst binaryVersion = this.probeBinaryVersion(binary);\n\t\tthis.lexicalRetriever = binaryVersion !== undefined && atLeast(binaryVersion, MIN_LEXICAL_RETRIEVER_VERSION);\n\t\t// Provisional: the version says the daemon understands `rerank`. Whether\n\t\t// it can serve one is answered by `info` below, once the client is up.\n\t\tthis.crossEncoder = binaryVersion !== undefined && atLeast(binaryVersion, MIN_RERANK_VERSION);\n\n\t\tconst storeDir = this.options.storeDir ?? getEmbsearchStoreDir(this.options.cwd);\n\t\t// A hybrid store carries a BM25 index next to its vectors, which is what\n\t\t// lets search use BM25 as its lexical leg instead of ripgrep. Callers may\n\t\t// force it either way; by default it follows what the daemon can serve.\n\t\tconst wantHybrid = this.options.hybridStore ?? this.lexicalRetriever;\n\n\t\tconst openClient = async (): Promise<EmbSearchDaemonInfo> => {\n\t\t\tthis.client = new EmbSearchClient({\n\t\t\t\tbinaryPath: binary,\n\t\t\t\tstorePath: getVectorStoreDir(storeDir),\n\t\t\t\thybrid: wantHybrid,\n\t\t\t});\n\t\t\tawait this.client.ready();\n\t\t\treturn await this.client.info();\n\t\t};\n\n\t\tlet info = await openClient();\n\n\t\t// Hybrid-ness is fixed when a store is created and `--hybrid` against an\n\t\t// existing plain store only warns, so an index built before this was the\n\t\t// default would silently stay dense-only and every BM25 query against it\n\t\t// would fail. Ask the store itself rather than trusting the sidecar, and\n\t\t// rebuild once when it disagrees. `info.hybrid` is undefined on daemons\n\t\t// too old to report it — those cannot serve BM25 anyway, so leave them be.\n\t\tif (wantHybrid && info.hybrid === false) {\n\t\t\tthis.setState({ phase: \"indexing\", done: 0, total: 0 });\n\t\t\tawait this.closeClient();\n\t\t\trmSync(storeDir, { recursive: true, force: true });\n\t\t\tinfo = await openClient();\n\t\t}\n\t\tthis.hybridStore = info.hybrid === true;\n\t\t// A daemon old enough to omit the field is left on the version verdict —\n\t\t// back then the weights were bundled, so version did imply capability.\n\t\tif (info.rerank === false) this.crossEncoder = false;\n\t\tif (info.modelId === MOCK_MODEL_ID) {\n\t\t\tthrow new Error(\"embsearch binary uses the mock embedder (not semantic); install an onnx build\");\n\t\t}\n\n\t\t// Missing/stale sidecar (format, chunker, or model changed) → clean rebuild.\n\t\tthis.meta = loadIndexMeta(storeDir, info.modelId) ?? emptyIndexMeta(this.options.cwd, info.modelId);\n\t\tthis.meta.lastUsedMs = Date.now();\n\n\t\tawait this.indexChangedFiles(scan.files, storeDir, signal);\n\t}\n\n\tprivate async indexChangedFiles(files: RepoScanFile[], storeDir: string, signal?: AbortSignal): Promise<void> {\n\t\tconst meta = this.meta!;\n\t\tconst client = this.client!;\n\n\t\t// Diff scan vs sidecar: cheap mtime+size check first, hash only on delta.\n\t\tconst toIndex: Array<{ file: RepoScanFile; content: string; hash: string }> = [];\n\t\tconst seen = new Set<string>();\n\t\tfor (const file of files) {\n\t\t\tseen.add(file.rel);\n\t\t\tconst known = meta.files[file.rel];\n\t\t\tif (known && known.mtimeMs === file.mtimeMs && known.size === file.size) continue;\n\t\t\tlet content: string;\n\t\t\ttry {\n\t\t\t\tcontent = readFileSync(file.abs, \"utf-8\");\n\t\t\t} catch {\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\tconst hash = hashContent(content);\n\t\t\tif (known && known.hash === hash) {\n\t\t\t\t// Touched but unchanged — refresh stat info only.\n\t\t\t\tknown.mtimeMs = file.mtimeMs;\n\t\t\t\tknown.size = file.size;\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\ttoIndex.push({ file, content, hash });\n\t\t}\n\t\tconst toRemove = Object.keys(meta.files).filter((rel) => !seen.has(rel));\n\n\t\tif (toIndex.length === 0 && toRemove.length === 0) {\n\t\t\tsaveIndexMeta(storeDir, meta);\n\t\t\tthis.setState({ phase: \"ready\", chunkCount: this.countChunks(meta) });\n\t\t\treturn;\n\t\t}\n\n\t\t// Chunk changed files; count total upserts for exact progress.\n\t\tconst work: Array<{ rel: string; fileMeta: FileMeta; chunks: Array<{ id: string; text: string }> }> = [];\n\t\tlet totalChunks = 0;\n\t\tfor (const { file, content, hash } of toIndex) {\n\t\t\tconst chunks = chunkFile(file.rel, content);\n\t\t\twork.push({\n\t\t\t\trel: file.rel,\n\t\t\t\tfileMeta: {\n\t\t\t\t\tmtimeMs: file.mtimeMs,\n\t\t\t\t\tsize: file.size,\n\t\t\t\t\thash,\n\t\t\t\t\tchunks: chunks.map((c) => [c.startLine, c.endLine]),\n\t\t\t\t},\n\t\t\t\tchunks: chunks.map((c) => ({ id: c.id, text: c.text })),\n\t\t\t});\n\t\t\ttotalChunks += chunks.length;\n\t\t}\n\n\t\tthis.setState({ phase: \"indexing\", done: 0, total: totalChunks });\n\n\t\t// Drop vectors of deleted files and superseded chunk tails.\n\t\tfor (const rel of toRemove) {\n\t\t\tfor (let i = 0; i < meta.files[rel].chunks.length; i++) await client.remove(`${rel}#${i}`);\n\t\t\tdelete meta.files[rel];\n\t\t}\n\n\t\tlet done = 0;\n\t\tfor (const item of work) {\n\t\t\tif (signal?.aborted || this.disposed) return;\n\t\t\tconst oldChunkCount = meta.files[item.rel]?.chunks.length ?? 0;\n\t\t\t// Remove old chunks beyond the new count (upsert covers the rest).\n\t\t\tfor (let i = item.chunks.length; i < oldChunkCount; i++) await client.remove(`${item.rel}#${i}`);\n\n\t\t\tfor (let offset = 0; offset < item.chunks.length; offset += BULK_BATCH_SIZE) {\n\t\t\t\tif (signal?.aborted || this.disposed) return;\n\t\t\t\tconst batch = item.chunks.slice(offset, offset + BULK_BATCH_SIZE);\n\t\t\t\tawait client.bulk(batch);\n\t\t\t\tdone += batch.length;\n\t\t\t\tthis.setState({ phase: \"indexing\", done, total: totalChunks });\n\t\t\t\t// Yield so queries and the event loop stay responsive.\n\t\t\t\tawait new Promise((resolve) => setTimeout(resolve, BATCH_YIELD_MS));\n\t\t\t}\n\t\t\tmeta.files[item.rel] = item.fileMeta;\n\t\t}\n\n\t\tawait client.compact();\n\t\tawait client.save();\n\t\tsaveIndexMeta(storeDir, meta);\n\t\tthis.setState({ phase: \"ready\", chunkCount: this.countChunks(meta) });\n\t}\n\n\tprivate countChunks(meta: IndexMeta): number {\n\t\tlet n = 0;\n\t\tfor (const rel of Object.keys(meta.files)) n += meta.files[rel].chunks.length;\n\t\treturn n;\n\t}\n\n\t/** Top-`k` semantic hits as `path` + line range + score. */\n\tasync search(query: string, k = 10): Promise<SemanticHit[]> {\n\t\treturn await this.searchChunks(query, k);\n\t}\n\n\t/** Top-`k` semantic hits including their chunk ids, for rank fusion. */\n\tasync searchChunks(\n\t\tquery: string,\n\t\tk = 10,\n\t\tglob?: string,\n\t\tretriever: DaemonRetriever = \"dense\",\n\t): Promise<SemanticChunkHit[]> {\n\t\tif (!this.client || this.client.isClosed || !this.meta) {\n\t\t\tthrow new Error(\"semantic index is not available\");\n\t\t}\n\t\tif (retriever === \"lexical\" && !this.lexicalRetriever) {\n\t\t\tthrow new Error(\n\t\t\t\t`embsearch is too old for retriever \"lexical\" (needs >= ${MIN_LEXICAL_RETRIEVER_VERSION.join(\".\")}); ` +\n\t\t\t\t\t\"an older daemon ignores the field and answers with dense results\",\n\t\t\t);\n\t\t}\n\t\tconst results = await this.client.query(query, k, retriever);\n\t\tconst hits: SemanticChunkHit[] = [];\n\t\tconst matchGlob = (rel: string): boolean => {\n\t\t\tif (!glob) return true;\n\t\t\treturn minimatch(rel, glob, { dot: true, matchBase: !glob.includes(\"/\") });\n\t\t};\n\t\tfor (const result of results) {\n\t\t\tconst sep = result.id.lastIndexOf(\"#\");\n\t\t\tif (sep === -1) continue;\n\t\t\tconst rel = result.id.slice(0, sep);\n\t\t\tif (!matchGlob(rel)) continue;\n\t\t\tconst chunkIndex = Number.parseInt(result.id.slice(sep + 1), 10);\n\t\t\tconst range = this.meta.files[rel]?.chunks[chunkIndex];\n\t\t\tif (!range) continue;\n\t\t\thits.push({ id: result.id, path: rel, startLine: range[0], endLine: range[1], score: result.score });\n\t\t}\n\t\treturn hits;\n\t}\n\n\t/**\n\t * Resolve a repo-relative path + line to its enclosing indexed chunk, or\n\t * undefined when the file/line is not covered by the index. Chunks overlap\n\t * by a few lines; the first (lowest-index) containing chunk wins so the\n\t * mapping is deterministic.\n\t */\n\tfindEnclosingChunk(\n\t\trel: string,\n\t\tline: number,\n\t): { id: string; path: string; startLine: number; endLine: number } | undefined {\n\t\tconst file = this.meta?.files[rel];\n\t\tif (!file) return undefined;\n\t\tfor (let i = 0; i < file.chunks.length; i++) {\n\t\t\tconst [startLine, endLine] = file.chunks[i];\n\t\t\tif (line >= startLine && line <= endLine) {\n\t\t\t\treturn { id: `${rel}#${i}`, path: rel, startLine, endLine };\n\t\t\t}\n\t\t}\n\t\treturn undefined;\n\t}\n\n\tprivate async closeClient(): Promise<void> {\n\t\tconst client = this.client;\n\t\tthis.client = undefined;\n\t\tif (client && !client.isClosed) {\n\t\t\ttry {\n\t\t\t\tawait client.close();\n\t\t\t} catch {\n\t\t\t\t// already dead\n\t\t\t}\n\t\t}\n\t}\n\n\t/** Persist state and shut the daemon down. Safe to call twice. */\n\tasync dispose(): Promise<void> {\n\t\tif (this.disposed) return;\n\t\tthis.disposed = true;\n\t\tif (this.client && !this.client.isClosed) {\n\t\t\ttry {\n\t\t\t\tawait this.client.save();\n\t\t\t} catch {\n\t\t\t\t// daemon may have exited; nothing to save\n\t\t\t}\n\t\t}\n\t\tawait this.closeClient();\n\t}\n}\n\n// --- Per-cwd service registry ---\n//\n// The search tool is constructed by the generic tool factory table and\n// only receives `cwd`; the service is created later during session init (flag\n// gated). This registry connects the two without threading a service instance\n// through every layer between main.ts and the tool factories.\n\nconst services = new Map<string, EmbsearchService>();\n\nexport function registerEmbsearchService(cwd: string, service: EmbsearchService): void {\n\tconst old = services.get(cwd);\n\tif (old && old !== service) {\n\t\told.dispose().catch(() => {});\n\t}\n\tservices.set(cwd, service);\n}\n\nexport function getEmbsearchService(cwd: string): EmbsearchService | undefined {\n\treturn services.get(cwd);\n}\n\nexport function unregisterEmbsearchService(cwd: string): void {\n\tservices.delete(cwd);\n}\n"]}
1
+ {"version":3,"file":"embsearch-service.d.ts","sourceRoot":"","sources":["../../../src/core/embsearch/embsearch-service.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Orchestrates semantic indexing and search for a repository.\n *\n * Lifecycle (all behind --enable-semantic-index):\n * 1. `start()` — resolve the embsearch binary, scan the repo (ignore-aware),\n * apply the byte threshold. Under threshold → dormant. Over → spawn the\n * daemon, verify the backend is not the mock embedder, then index changed\n * files in the background in small batches, reporting progress.\n * 2. `search()` — top-k semantic query, mapping chunk ids back to\n * `path:start-end` via the sidecar metadata.\n * 3. `dispose()` — save + close the daemon.\n *\n * Every failure degrades to `unavailable` with a reason; nothing here ever\n * blocks session startup or affects grep/find.\n */\n\nimport { execFileSync } from \"child_process\";\nimport { readFileSync, rmSync } from \"fs\";\nimport { minimatch } from \"minimatch\";\nimport { ensureTool } from \"../../utils/tools-manager.js\";\nimport { chunkFile } from \"./chunker.js\";\nimport {\n\ttype DaemonRetriever,\n\tEmbSearchClient,\n\ttype EmbSearchDaemonInfo,\n\ttype EmbSearchRerankPassage,\n\ttype EmbSearchRerankResult,\n} from \"./client.js\";\nimport {\n\temptyIndexMeta,\n\ttype FileMeta,\n\tgetEmbsearchStoreDir,\n\tgetVectorStoreDir,\n\thashContent,\n\ttype IndexMeta,\n\tloadIndexMeta,\n\tsaveIndexMeta,\n} from \"./index-meta.js\";\nimport { type RepoScanFile, scanRepo } from \"./repo-scan.js\";\n\n/** Chunks per bulk request. Small enough that a concurrent query is never\n * stuck long behind one padded batch inference. */\nconst BULK_BATCH_SIZE = 48;\n/** Yield between batches so background indexing doesn't starve the session. */\nconst BATCH_YIELD_MS = 15;\n/** The Rust mock backend's model id — semantically meaningless, never index with it. */\nconst MOCK_MODEL_ID = \"mock-hash-v1\";\n/**\n * First embsearch release serving `retriever: \"lexical\"`.\n *\n * The guard matters because the daemon does not reject unknown request fields:\n * an older binary silently ignores `retriever` and answers with dense results.\n * Fusing that list a second time as a \"bm25\" leg would double-count it and\n * corrupt the ranking with no error anywhere — so refuse instead of degrading.\n */\nconst MIN_LEXICAL_RETRIEVER_VERSION = [0, 2, 0] as const;\n/**\n * First embsearch release serving the `rerank` op.\n *\n * Necessary but no longer sufficient. Releases from 0.3.1 carry the op without\n * the ~23 MB cross-encoder weights — they measured worse than the\n * deterministic reranker on five of six query classes, so they are no longer\n * bundled — and such a daemon answers `rerank` with an error. The version is\n * therefore only the floor for *asking*; `info.rerank` is the answer, and\n * {@link EmbsearchService.supportsCrossEncoder} needs both.\n */\nconst MIN_RERANK_VERSION = [0, 3, 0] as const;\n\n/** `embsearch 0.2.0` -> [0, 2, 0]; undefined when it cannot be parsed. */\nfunction parseBinaryVersion(output: string): number[] | undefined {\n\tconst match = output.match(/(\\d+)\\.(\\d+)\\.(\\d+)/);\n\treturn match ? [Number(match[1]), Number(match[2]), Number(match[3])] : undefined;\n}\n\nfunction atLeast(version: readonly number[], minimum: readonly number[]): boolean {\n\tfor (let i = 0; i < minimum.length; i++) {\n\t\tconst part = version[i] ?? 0;\n\t\tif (part !== minimum[i]) return part > minimum[i];\n\t}\n\treturn true;\n}\n\nexport type EmbsearchState =\n\t| { phase: \"idle\" }\n\t| { phase: \"skipped\"; reason: string }\n\t| { phase: \"downloading\"; receivedBytes: number; totalBytes: number | null }\n\t| { phase: \"indexing\"; done: number; total: number }\n\t| { phase: \"ready\"; chunkCount: number }\n\t| { phase: \"unavailable\"; reason: string };\n\nexport interface EmbsearchServiceOptions {\n\tcwd: string;\n\t/** Explicit binary path (settings override). Default: \"embsearch\" from PATH. */\n\tbinaryPath?: string;\n\t/**\n\t * Model directory handed to the daemon as `--model`, overriding the model\n\t * bundled in the binary.\n\t *\n\t * Only the eval harness sets this, to score two embedding models from one\n\t * binary. Pair it with a distinct `storeDir`: vectors from different models\n\t * are incompatible, and the daemon refuses to open a store built by another\n\t * model rather than mixing them.\n\t */\n\tmodelDir?: string;\n\t/**\n\t * Override the chunker's character cap.\n\t *\n\t * Only the eval harness sets this, to sweep the chunk window. It changes\n\t * what every vector in the store *is*, and nothing stored records it, so it\n\t * must be paired with a distinct `storeDir` exactly as `modelDir` is —\n\t * otherwise a run silently scores an index built at another cap.\n\t */\n\tchunkMaxChars?: number;\n\t/** Minimum indexable bytes before indexing kicks in. */\n\tthresholdBytes: number;\n\t/**\n\t * Override the store location. Only the eval harness sets this, so a\n\t * second index (e.g. a BM25-hybrid store) can exist for the same repo\n\t * without colliding with the primary one.\n\t */\n\tstoreDir?: string;\n\t/**\n\t * Create the store with the daemon's BM25 lexical index.\n\t *\n\t * Defaults to whatever the daemon can serve. Fixed at store creation, so an\n\t * existing store that disagrees is rebuilt once; when overriding this to\n\t * hold two different stores for one repo, pair it with a distinct\n\t * `storeDir` so they do not fight over the same directory.\n\t */\n\thybridStore?: boolean;\n\t/** Progress callback for UI (footer / stderr lines). */\n\tonProgress?: (state: EmbsearchState) => void;\n}\n\nexport interface SemanticHit {\n\tpath: string;\n\tstartLine: number;\n\tendLine: number;\n\tscore: number;\n}\n\nexport interface SemanticChunkHit extends SemanticHit {\n\t/** Per-build chunk id (`relpath#index`) — the fusion identity for hybrid search. */\n\tid: string;\n}\n\nexport class EmbsearchService {\n\tprivate readonly options: EmbsearchServiceOptions;\n\tprivate client: EmbSearchClient | undefined;\n\tprivate meta: IndexMeta | undefined;\n\tprivate state: EmbsearchState = { phase: \"idle\" };\n\tprivate disposed = false;\n\t/** Whether the resolved binary serves `retriever: \"lexical\"`. */\n\tprivate lexicalRetriever = false;\n\t/** Whether the store actually opened carries a BM25 index. */\n\tprivate hybridStore = false;\n\t/** Whether the resolved binary serves the cross-encoder `rerank` op. */\n\tprivate crossEncoder = false;\n\n\tconstructor(options: EmbsearchServiceOptions) {\n\t\tthis.options = options;\n\t}\n\n\tgetState(): EmbsearchState {\n\t\treturn this.state;\n\t}\n\n\t/** Semantic search is usable (index ready, or still building with partial data). */\n\t/**\n\t * Model id reported by the running daemon, once it is up.\n\t *\n\t * This — not the binary's version — identifies which model produced the\n\t * vectors in the store, because `--model` decouples the two.\n\t */\n\tmodelId(): string | undefined {\n\t\treturn this.meta?.modelId;\n\t}\n\n\tisAvailable(): boolean {\n\t\treturn this.state.phase === \"ready\" || this.state.phase === \"indexing\";\n\t}\n\n\tprivate setState(state: EmbsearchState): void {\n\t\tthis.state = state;\n\t\tthis.options.onProgress?.(state);\n\t}\n\n\t/**\n\t * Whether a BM25-only query will work: the daemon has to understand the\n\t * `lexical` retriever *and* the open store has to carry a BM25 index.\n\t */\n\tsupportsLexicalRetriever(): boolean {\n\t\treturn this.lexicalRetriever && this.hybridStore;\n\t}\n\n\t/**\n\t * Repo files whose on-disk content the index does not have — unknown to it,\n\t * or changed since it last read them.\n\t *\n\t * This is the set BM25 is structurally blind to, and the only place the\n\t * grep leg still earns its keep once BM25 is available. An agent that edits\n\t * a file and immediately searches for what it wrote is asking about exactly\n\t * these files; the index cannot answer until the next pass.\n\t *\n\t * Compares mtime and size only, never hashing: the check runs per query, and\n\t * a false positive merely lets grep cover a file BM25 already covers, while\n\t * a false negative would lose the edit.\n\t *\n\t * Deliberately uncached. A cache here caches the *absence* of an edit, which\n\t * is the one thing this must never do — an agent writes a file and searches\n\t * for it in the same breath. A 1s TTL was tried and cost the live-edit set\n\t * 75% to 100% of its score depending on how the timing fell, which is worse\n\t * than wrong: it was non-deterministic. One scan is ~25ms over ~1k files and\n\t * happens once per search, against retrieval that already costs more.\n\t */\n\tstaleFiles(signal?: AbortSignal): string[] {\n\t\tif (!this.meta) return [];\n\t\tconst meta = this.meta;\n\t\tconst files: string[] = [];\n\t\ttry {\n\t\t\tfor (const file of scanRepo(this.options.cwd, signal).files) {\n\t\t\t\tconst known = meta.files[file.rel];\n\t\t\t\tif (!known || known.mtimeMs !== file.mtimeMs || known.size !== file.size) files.push(file.rel);\n\t\t\t}\n\t\t} catch {\n\t\t\t// A failed scan must not silently narrow the grep leg to nothing;\n\t\t\t// report no staleness and let the indexed legs answer.\n\t\t\treturn [];\n\t\t}\n\t\treturn files;\n\t}\n\n\t/** Whether the running daemon can score with a cross-encoder. */\n\tsupportsCrossEncoder(): boolean {\n\t\treturn this.crossEncoder;\n\t}\n\n\t/**\n\t * Cross-encoder rerank of caller-supplied passages.\n\t *\n\t * Unlike the retrievers this does not consult the index at all — it scores\n\t * exactly the text passed in, which is why the caller sends its expanded\n\t * windows rather than chunk ids.\n\t */\n\tasync rerank(query: string, passages: EmbSearchRerankPassage[], k: number): Promise<EmbSearchRerankResult[]> {\n\t\tif (!this.client || this.client.isClosed) {\n\t\t\tthrow new Error(\"semantic index is not available\");\n\t\t}\n\t\tif (!this.crossEncoder) {\n\t\t\tthrow new Error(\n\t\t\t\t`this embsearch daemon cannot rerank (needs >= ${MIN_RERANK_VERSION.join(\".\")} reporting ` +\n\t\t\t\t\t\"`rerank: true`); released binaries ship without cross-encoder weights — start the daemon \" +\n\t\t\t\t\t\"with --reranker-model <dir>\",\n\t\t\t);\n\t\t}\n\t\treturn await this.client.rerank(query, passages, k);\n\t}\n\n\tprivate probeBinaryVersion(binary: string): number[] | undefined {\n\t\ttry {\n\t\t\treturn parseBinaryVersion(execFileSync(binary, [\"--version\"], { encoding: \"utf-8\", timeout: 10_000 }));\n\t\t} catch {\n\t\t\treturn undefined;\n\t\t}\n\t}\n\n\t/**\n\t * What the store on disk says about itself, read straight from its manifest.\n\t *\n\t * `store-info` exists precisely for the case where the daemon will not open\n\t * the store: a `serve` pairs a store with an embedder and refuses the pair\n\t * when their models disagree, so at that moment nothing else can tell us\n\t * what built it. Returns undefined when there is no readable store — which\n\t * includes a binary too old to have the subcommand, and so degrades to the\n\t * previous behaviour rather than guessing.\n\t */\n\tprivate probeStore(binary: string, storeDir: string): { modelId: string; live: number } | undefined {\n\t\ttry {\n\t\t\tconst out = execFileSync(binary, [\"store-info\", \"--path\", getVectorStoreDir(storeDir), \"--json\"], {\n\t\t\t\tencoding: \"utf-8\",\n\t\t\t\ttimeout: 10_000,\n\t\t\t\tstdio: [\"ignore\", \"pipe\", \"ignore\"],\n\t\t\t});\n\t\t\tconst parsed = JSON.parse(out) as { model_id?: string; live?: number };\n\t\t\tif (typeof parsed.model_id !== \"string\") return undefined;\n\t\t\treturn { modelId: parsed.model_id, live: parsed.live ?? 0 };\n\t\t} catch {\n\t\t\treturn undefined;\n\t\t}\n\t}\n\n\tprivate async resolveBinary(): Promise<string | undefined> {\n\t\tif (this.options.binaryPath) {\n\t\t\treturn this.options.binaryPath;\n\t\t}\n\t\t// Surface the on-demand binary download through the same progress channel as\n\t\t// indexing, so the first-run fetch renders a progress bar instead of a stall.\n\t\t// A cached binary resolves without ever invoking this callback.\n\t\treturn await ensureTool(\"embsearch\", true, (receivedBytes, totalBytes) => {\n\t\t\tthis.setState({ phase: \"downloading\", receivedBytes, totalBytes });\n\t\t});\n\t}\n\n\t/**\n\t * Scan, threshold-check, and (when needed) index in the background.\n\t * Resolves when indexing completes or the feature settles dormant.\n\t */\n\tasync start(signal?: AbortSignal): Promise<void> {\n\t\ttry {\n\t\t\tawait this.run(signal);\n\t\t} catch (e) {\n\t\t\tconst reason = e instanceof Error ? e.message : String(e);\n\t\t\tthis.setState({ phase: \"unavailable\", reason });\n\t\t\tawait this.closeClient();\n\t\t}\n\t}\n\n\tprivate async run(signal?: AbortSignal): Promise<void> {\n\t\tconst binary = await this.resolveBinary();\n\t\tif (!binary) {\n\t\t\tthis.setState({\n\t\t\t\tphase: \"unavailable\",\n\t\t\t\treason: \"embsearch binary not found (PATH or embsearchBinaryPath setting)\",\n\t\t\t});\n\t\t\treturn;\n\t\t}\n\n\t\tconst scan = scanRepo(this.options.cwd, signal);\n\t\tif (scan.totalBytes < this.options.thresholdBytes) {\n\t\t\tthis.setState({\n\t\t\t\tphase: \"skipped\",\n\t\t\t\treason: `repo under threshold (${scan.totalBytes} < ${this.options.thresholdBytes} bytes)`,\n\t\t\t});\n\t\t\treturn;\n\t\t}\n\n\t\tconst binaryVersion = this.probeBinaryVersion(binary);\n\t\tthis.lexicalRetriever = binaryVersion !== undefined && atLeast(binaryVersion, MIN_LEXICAL_RETRIEVER_VERSION);\n\t\t// Provisional: the version says the daemon understands `rerank`. Whether\n\t\t// it can serve one is answered by `info` below, once the client is up.\n\t\tthis.crossEncoder = binaryVersion !== undefined && atLeast(binaryVersion, MIN_RERANK_VERSION);\n\n\t\tconst storeDir = this.options.storeDir ?? getEmbsearchStoreDir(this.options.cwd);\n\t\t// A hybrid store carries a BM25 index next to its vectors, which is what\n\t\t// lets search use BM25 as its lexical leg instead of ripgrep. Callers may\n\t\t// force it either way; by default it follows what the daemon can serve.\n\t\tconst wantHybrid = this.options.hybridStore ?? this.lexicalRetriever;\n\n\t\tconst openClient = async (): Promise<EmbSearchDaemonInfo> => {\n\t\t\tthis.client = new EmbSearchClient({\n\t\t\t\tbinaryPath: binary,\n\t\t\t\tstorePath: getVectorStoreDir(storeDir),\n\t\t\t\thybrid: wantHybrid,\n\t\t\t\tmodelDir: this.options.modelDir,\n\t\t\t});\n\t\t\tawait this.client.ready();\n\t\t\treturn await this.client.info();\n\t\t};\n\n\t\t/** Discard the store and start clean. The only recovery from a store the\n\t\t * current binary cannot use — and the only way to be rid of vectors that\n\t\t * outlived the metadata describing them. */\n\t\tconst rebuildFrom = async (why: string): Promise<EmbSearchDaemonInfo> => {\n\t\t\tconsole.error(`embsearch: rebuilding the index (${why})`);\n\t\t\tthis.setState({ phase: \"indexing\", done: 0, total: 0 });\n\t\t\tawait this.closeClient();\n\t\t\trmSync(storeDir, { recursive: true, force: true });\n\t\t\treturn await openClient();\n\t\t};\n\n\t\tlet info: EmbSearchDaemonInfo;\n\t\ttry {\n\t\t\tinfo = await openClient();\n\t\t} catch (err) {\n\t\t\t// The daemon refuses to open a store whose recorded model disagrees\n\t\t\t// with its own — correctly, since vectors from different models are\n\t\t\t// not comparable. But refusing is where it stopped: the store stayed\n\t\t\t// on disk, the daemon never came up, and this service went\n\t\t\t// permanently unavailable with a rebuild one directory-removal away.\n\t\t\t//\n\t\t\t// Only a store that is present and *readable* is treated this way. If\n\t\t\t// `store-info` cannot read it either, the problem is not a model\n\t\t\t// mismatch and destroying an index would be the wrong response, so\n\t\t\t// the original failure stands.\n\t\t\tconst store = this.probeStore(binary, storeDir);\n\t\t\tif (!store) throw err;\n\t\t\tinfo = await rebuildFrom(`built by model '${store.modelId}', which this binary cannot read`);\n\t\t}\n\n\t\t// Hybrid-ness is fixed when a store is created and `--hybrid` against an\n\t\t// existing plain store only warns, so an index built before this was the\n\t\t// default would silently stay dense-only and every BM25 query against it\n\t\t// would fail. Ask the store itself rather than trusting the sidecar, and\n\t\t// rebuild once when it disagrees. `info.hybrid` is undefined on daemons\n\t\t// too old to report it — those cannot serve BM25 anyway, so leave them be.\n\t\tif (wantHybrid && info.hybrid === false) {\n\t\t\tinfo = await rebuildFrom(\"the existing store carries no BM25 index\");\n\t\t}\n\t\tthis.hybridStore = info.hybrid === true;\n\t\t// A daemon old enough to omit the field is left on the version verdict —\n\t\t// back then the weights were bundled, so version did imply capability.\n\t\tif (info.rerank === false) this.crossEncoder = false;\n\t\tif (info.modelId === MOCK_MODEL_ID) {\n\t\t\tthrow new Error(\"embsearch binary uses the mock embedder (not semantic); install an onnx build\");\n\t\t}\n\n\t\t// Missing/stale sidecar (format, chunker, or model changed) → clean rebuild.\n\t\t//\n\t\t// Resetting the sidecar alone is not enough, and used to be all this did.\n\t\t// The sidecar is the only record of how many chunks each file produced,\n\t\t// so an empty one reports zero for every file — and `indexChangedFiles`\n\t\t// removes stale chunks by counting down from that number. Re-chunking a\n\t\t// file into *fewer* pieces then leaves its tail vectors (`path#N`,\n\t\t// `path#N+1`, …) in the store, holding text that no longer exists\n\t\t// anywhere, retrievable forever. Upserts hide it: chunk counts look\n\t\t// right, the sidecar looks right, and only search results are wrong.\n\t\t//\n\t\t// So when the sidecar cannot be trusted and the store is not already\n\t\t// empty, the store goes too.\n\t\tlet meta = loadIndexMeta(storeDir, info.modelId);\n\t\tif (!meta && info.count > 0) {\n\t\t\tinfo = await rebuildFrom(\"index metadata is missing or was written by a different chunker or model\");\n\t\t\tmeta = loadIndexMeta(storeDir, info.modelId);\n\t\t}\n\t\tthis.meta = meta ?? emptyIndexMeta(this.options.cwd, info.modelId);\n\t\tthis.meta.lastUsedMs = Date.now();\n\n\t\tawait this.indexChangedFiles(scan.files, storeDir, signal);\n\t}\n\n\tprivate async indexChangedFiles(files: RepoScanFile[], storeDir: string, signal?: AbortSignal): Promise<void> {\n\t\tconst meta = this.meta!;\n\t\tconst client = this.client!;\n\n\t\t// Diff scan vs sidecar: cheap mtime+size check first, hash only on delta.\n\t\tconst toIndex: Array<{ file: RepoScanFile; content: string; hash: string }> = [];\n\t\tconst seen = new Set<string>();\n\t\tfor (const file of files) {\n\t\t\tseen.add(file.rel);\n\t\t\tconst known = meta.files[file.rel];\n\t\t\tif (known && known.mtimeMs === file.mtimeMs && known.size === file.size) continue;\n\t\t\tlet content: string;\n\t\t\ttry {\n\t\t\t\tcontent = readFileSync(file.abs, \"utf-8\");\n\t\t\t} catch {\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\tconst hash = hashContent(content);\n\t\t\tif (known && known.hash === hash) {\n\t\t\t\t// Touched but unchanged — refresh stat info only.\n\t\t\t\tknown.mtimeMs = file.mtimeMs;\n\t\t\t\tknown.size = file.size;\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\ttoIndex.push({ file, content, hash });\n\t\t}\n\t\tconst toRemove = Object.keys(meta.files).filter((rel) => !seen.has(rel));\n\n\t\tif (toIndex.length === 0 && toRemove.length === 0) {\n\t\t\tsaveIndexMeta(storeDir, meta);\n\t\t\tthis.setState({ phase: \"ready\", chunkCount: this.countChunks(meta) });\n\t\t\treturn;\n\t\t}\n\n\t\t// Chunk changed files; count total upserts for exact progress.\n\t\tconst work: Array<{ rel: string; fileMeta: FileMeta; chunks: Array<{ id: string; text: string }> }> = [];\n\t\tlet totalChunks = 0;\n\t\tfor (const { file, content, hash } of toIndex) {\n\t\t\tconst chunks = chunkFile(file.rel, content, this.options.chunkMaxChars);\n\t\t\twork.push({\n\t\t\t\trel: file.rel,\n\t\t\t\tfileMeta: {\n\t\t\t\t\tmtimeMs: file.mtimeMs,\n\t\t\t\t\tsize: file.size,\n\t\t\t\t\thash,\n\t\t\t\t\tchunks: chunks.map((c) => [c.startLine, c.endLine]),\n\t\t\t\t},\n\t\t\t\tchunks: chunks.map((c) => ({ id: c.id, text: c.text })),\n\t\t\t});\n\t\t\ttotalChunks += chunks.length;\n\t\t}\n\n\t\tthis.setState({ phase: \"indexing\", done: 0, total: totalChunks });\n\n\t\t// Drop vectors of deleted files and superseded chunk tails.\n\t\tfor (const rel of toRemove) {\n\t\t\tfor (let i = 0; i < meta.files[rel].chunks.length; i++) await client.remove(`${rel}#${i}`);\n\t\t\tdelete meta.files[rel];\n\t\t}\n\n\t\tlet done = 0;\n\t\tfor (const item of work) {\n\t\t\tif (signal?.aborted || this.disposed) return;\n\t\t\tconst oldChunkCount = meta.files[item.rel]?.chunks.length ?? 0;\n\t\t\t// Remove old chunks beyond the new count (upsert covers the rest).\n\t\t\tfor (let i = item.chunks.length; i < oldChunkCount; i++) await client.remove(`${item.rel}#${i}`);\n\n\t\t\tfor (let offset = 0; offset < item.chunks.length; offset += BULK_BATCH_SIZE) {\n\t\t\t\tif (signal?.aborted || this.disposed) return;\n\t\t\t\tconst batch = item.chunks.slice(offset, offset + BULK_BATCH_SIZE);\n\t\t\t\tawait client.bulk(batch);\n\t\t\t\tdone += batch.length;\n\t\t\t\tthis.setState({ phase: \"indexing\", done, total: totalChunks });\n\t\t\t\t// Yield so queries and the event loop stay responsive.\n\t\t\t\tawait new Promise((resolve) => setTimeout(resolve, BATCH_YIELD_MS));\n\t\t\t}\n\t\t\tmeta.files[item.rel] = item.fileMeta;\n\t\t}\n\n\t\tawait client.compact();\n\t\tawait client.save();\n\t\tsaveIndexMeta(storeDir, meta);\n\t\tthis.setState({ phase: \"ready\", chunkCount: this.countChunks(meta) });\n\t}\n\n\tprivate countChunks(meta: IndexMeta): number {\n\t\tlet n = 0;\n\t\tfor (const rel of Object.keys(meta.files)) n += meta.files[rel].chunks.length;\n\t\treturn n;\n\t}\n\n\t/** Top-`k` semantic hits as `path` + line range + score. */\n\tasync search(query: string, k = 10): Promise<SemanticHit[]> {\n\t\treturn await this.searchChunks(query, k);\n\t}\n\n\t/** Top-`k` semantic hits including their chunk ids, for rank fusion. */\n\tasync searchChunks(\n\t\tquery: string,\n\t\tk = 10,\n\t\tglob?: string,\n\t\tretriever: DaemonRetriever = \"dense\",\n\t): Promise<SemanticChunkHit[]> {\n\t\tif (!this.client || this.client.isClosed || !this.meta) {\n\t\t\tthrow new Error(\"semantic index is not available\");\n\t\t}\n\t\tif (retriever === \"lexical\" && !this.lexicalRetriever) {\n\t\t\tthrow new Error(\n\t\t\t\t`embsearch is too old for retriever \"lexical\" (needs >= ${MIN_LEXICAL_RETRIEVER_VERSION.join(\".\")}); ` +\n\t\t\t\t\t\"an older daemon ignores the field and answers with dense results\",\n\t\t\t);\n\t\t}\n\t\tconst results = await this.client.query(query, k, retriever);\n\t\tconst hits: SemanticChunkHit[] = [];\n\t\tconst matchGlob = (rel: string): boolean => {\n\t\t\tif (!glob) return true;\n\t\t\treturn minimatch(rel, glob, { dot: true, matchBase: !glob.includes(\"/\") });\n\t\t};\n\t\tfor (const result of results) {\n\t\t\tconst sep = result.id.lastIndexOf(\"#\");\n\t\t\tif (sep === -1) continue;\n\t\t\tconst rel = result.id.slice(0, sep);\n\t\t\tif (!matchGlob(rel)) continue;\n\t\t\tconst chunkIndex = Number.parseInt(result.id.slice(sep + 1), 10);\n\t\t\tconst range = this.meta.files[rel]?.chunks[chunkIndex];\n\t\t\tif (!range) continue;\n\t\t\thits.push({ id: result.id, path: rel, startLine: range[0], endLine: range[1], score: result.score });\n\t\t}\n\t\treturn hits;\n\t}\n\n\t/**\n\t * Resolve a repo-relative path + line to its enclosing indexed chunk, or\n\t * undefined when the file/line is not covered by the index. Chunks overlap\n\t * by a few lines; the first (lowest-index) containing chunk wins so the\n\t * mapping is deterministic.\n\t */\n\tfindEnclosingChunk(\n\t\trel: string,\n\t\tline: number,\n\t): { id: string; path: string; startLine: number; endLine: number } | undefined {\n\t\tconst file = this.meta?.files[rel];\n\t\tif (!file) return undefined;\n\t\tfor (let i = 0; i < file.chunks.length; i++) {\n\t\t\tconst [startLine, endLine] = file.chunks[i];\n\t\t\tif (line >= startLine && line <= endLine) {\n\t\t\t\treturn { id: `${rel}#${i}`, path: rel, startLine, endLine };\n\t\t\t}\n\t\t}\n\t\treturn undefined;\n\t}\n\n\tprivate async closeClient(): Promise<void> {\n\t\tconst client = this.client;\n\t\tthis.client = undefined;\n\t\tif (client && !client.isClosed) {\n\t\t\ttry {\n\t\t\t\tawait client.close();\n\t\t\t} catch {\n\t\t\t\t// already dead\n\t\t\t}\n\t\t}\n\t}\n\n\t/** Persist state and shut the daemon down. Safe to call twice. */\n\tasync dispose(): Promise<void> {\n\t\tif (this.disposed) return;\n\t\tthis.disposed = true;\n\t\tif (this.client && !this.client.isClosed) {\n\t\t\ttry {\n\t\t\t\tawait this.client.save();\n\t\t\t} catch {\n\t\t\t\t// daemon may have exited; nothing to save\n\t\t\t}\n\t\t}\n\t\tawait this.closeClient();\n\t}\n}\n\n// --- Per-cwd service registry ---\n//\n// The search tool is constructed by the generic tool factory table and\n// only receives `cwd`; the service is created later during session init (flag\n// gated). This registry connects the two without threading a service instance\n// through every layer between main.ts and the tool factories.\n\nconst services = new Map<string, EmbsearchService>();\n\nexport function registerEmbsearchService(cwd: string, service: EmbsearchService): void {\n\tconst old = services.get(cwd);\n\tif (old && old !== service) {\n\t\told.dispose().catch(() => {});\n\t}\n\tservices.set(cwd, service);\n}\n\nexport function getEmbsearchService(cwd: string): EmbsearchService | undefined {\n\treturn services.get(cwd);\n}\n\nexport function unregisterEmbsearchService(cwd: string): void {\n\tservices.delete(cwd);\n}\n"]}
@@ -80,6 +80,15 @@ export class EmbsearchService {
80
80
  return this.state;
81
81
  }
82
82
  /** Semantic search is usable (index ready, or still building with partial data). */
83
+ /**
84
+ * Model id reported by the running daemon, once it is up.
85
+ *
86
+ * This — not the binary's version — identifies which model produced the
87
+ * vectors in the store, because `--model` decouples the two.
88
+ */
89
+ modelId() {
90
+ return this.meta?.modelId;
91
+ }
83
92
  isAvailable() {
84
93
  return this.state.phase === "ready" || this.state.phase === "indexing";
85
94
  }
@@ -163,6 +172,32 @@ export class EmbsearchService {
163
172
  return undefined;
164
173
  }
165
174
  }
175
+ /**
176
+ * What the store on disk says about itself, read straight from its manifest.
177
+ *
178
+ * `store-info` exists precisely for the case where the daemon will not open
179
+ * the store: a `serve` pairs a store with an embedder and refuses the pair
180
+ * when their models disagree, so at that moment nothing else can tell us
181
+ * what built it. Returns undefined when there is no readable store — which
182
+ * includes a binary too old to have the subcommand, and so degrades to the
183
+ * previous behaviour rather than guessing.
184
+ */
185
+ probeStore(binary, storeDir) {
186
+ try {
187
+ const out = execFileSync(binary, ["store-info", "--path", getVectorStoreDir(storeDir), "--json"], {
188
+ encoding: "utf-8",
189
+ timeout: 10_000,
190
+ stdio: ["ignore", "pipe", "ignore"],
191
+ });
192
+ const parsed = JSON.parse(out);
193
+ if (typeof parsed.model_id !== "string")
194
+ return undefined;
195
+ return { modelId: parsed.model_id, live: parsed.live ?? 0 };
196
+ }
197
+ catch {
198
+ return undefined;
199
+ }
200
+ }
166
201
  async resolveBinary() {
167
202
  if (this.options.binaryPath) {
168
203
  return this.options.binaryPath;
@@ -220,11 +255,41 @@ export class EmbsearchService {
220
255
  binaryPath: binary,
221
256
  storePath: getVectorStoreDir(storeDir),
222
257
  hybrid: wantHybrid,
258
+ modelDir: this.options.modelDir,
223
259
  });
224
260
  await this.client.ready();
225
261
  return await this.client.info();
226
262
  };
227
- let info = await openClient();
263
+ /** Discard the store and start clean. The only recovery from a store the
264
+ * current binary cannot use — and the only way to be rid of vectors that
265
+ * outlived the metadata describing them. */
266
+ const rebuildFrom = async (why) => {
267
+ console.error(`embsearch: rebuilding the index (${why})`);
268
+ this.setState({ phase: "indexing", done: 0, total: 0 });
269
+ await this.closeClient();
270
+ rmSync(storeDir, { recursive: true, force: true });
271
+ return await openClient();
272
+ };
273
+ let info;
274
+ try {
275
+ info = await openClient();
276
+ }
277
+ catch (err) {
278
+ // The daemon refuses to open a store whose recorded model disagrees
279
+ // with its own — correctly, since vectors from different models are
280
+ // not comparable. But refusing is where it stopped: the store stayed
281
+ // on disk, the daemon never came up, and this service went
282
+ // permanently unavailable with a rebuild one directory-removal away.
283
+ //
284
+ // Only a store that is present and *readable* is treated this way. If
285
+ // `store-info` cannot read it either, the problem is not a model
286
+ // mismatch and destroying an index would be the wrong response, so
287
+ // the original failure stands.
288
+ const store = this.probeStore(binary, storeDir);
289
+ if (!store)
290
+ throw err;
291
+ info = await rebuildFrom(`built by model '${store.modelId}', which this binary cannot read`);
292
+ }
228
293
  // Hybrid-ness is fixed when a store is created and `--hybrid` against an
229
294
  // existing plain store only warns, so an index built before this was the
230
295
  // default would silently stay dense-only and every BM25 query against it
@@ -232,10 +297,7 @@ export class EmbsearchService {
232
297
  // rebuild once when it disagrees. `info.hybrid` is undefined on daemons
233
298
  // too old to report it — those cannot serve BM25 anyway, so leave them be.
234
299
  if (wantHybrid && info.hybrid === false) {
235
- this.setState({ phase: "indexing", done: 0, total: 0 });
236
- await this.closeClient();
237
- rmSync(storeDir, { recursive: true, force: true });
238
- info = await openClient();
300
+ info = await rebuildFrom("the existing store carries no BM25 index");
239
301
  }
240
302
  this.hybridStore = info.hybrid === true;
241
303
  // A daemon old enough to omit the field is left on the version verdict —
@@ -246,7 +308,24 @@ export class EmbsearchService {
246
308
  throw new Error("embsearch binary uses the mock embedder (not semantic); install an onnx build");
247
309
  }
248
310
  // Missing/stale sidecar (format, chunker, or model changed) → clean rebuild.
249
- this.meta = loadIndexMeta(storeDir, info.modelId) ?? emptyIndexMeta(this.options.cwd, info.modelId);
311
+ //
312
+ // Resetting the sidecar alone is not enough, and used to be all this did.
313
+ // The sidecar is the only record of how many chunks each file produced,
314
+ // so an empty one reports zero for every file — and `indexChangedFiles`
315
+ // removes stale chunks by counting down from that number. Re-chunking a
316
+ // file into *fewer* pieces then leaves its tail vectors (`path#N`,
317
+ // `path#N+1`, …) in the store, holding text that no longer exists
318
+ // anywhere, retrievable forever. Upserts hide it: chunk counts look
319
+ // right, the sidecar looks right, and only search results are wrong.
320
+ //
321
+ // So when the sidecar cannot be trusted and the store is not already
322
+ // empty, the store goes too.
323
+ let meta = loadIndexMeta(storeDir, info.modelId);
324
+ if (!meta && info.count > 0) {
325
+ info = await rebuildFrom("index metadata is missing or was written by a different chunker or model");
326
+ meta = loadIndexMeta(storeDir, info.modelId);
327
+ }
328
+ this.meta = meta ?? emptyIndexMeta(this.options.cwd, info.modelId);
250
329
  this.meta.lastUsedMs = Date.now();
251
330
  await this.indexChangedFiles(scan.files, storeDir, signal);
252
331
  }
@@ -287,7 +366,7 @@ export class EmbsearchService {
287
366
  const work = [];
288
367
  let totalChunks = 0;
289
368
  for (const { file, content, hash } of toIndex) {
290
- const chunks = chunkFile(file.rel, content);
369
+ const chunks = chunkFile(file.rel, content, this.options.chunkMaxChars);
291
370
  work.push({
292
371
  rel: file.rel,
293
372
  fileMeta: {
@@ -1 +1 @@
1
- {"version":3,"file":"embsearch-service.js","sourceRoot":"","sources":["../../../src/core/embsearch/embsearch-service.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Orchestrates semantic indexing and search for a repository.\n *\n * Lifecycle (all behind --enable-semantic-index):\n * 1. `start()` — resolve the embsearch binary, scan the repo (ignore-aware),\n * apply the byte threshold. Under threshold → dormant. Over → spawn the\n * daemon, verify the backend is not the mock embedder, then index changed\n * files in the background in small batches, reporting progress.\n * 2. `search()` — top-k semantic query, mapping chunk ids back to\n * `path:start-end` via the sidecar metadata.\n * 3. `dispose()` — save + close the daemon.\n *\n * Every failure degrades to `unavailable` with a reason; nothing here ever\n * blocks session startup or affects grep/find.\n */\n\nimport { execFileSync } from \"child_process\";\nimport { readFileSync, rmSync } from \"fs\";\nimport { minimatch } from \"minimatch\";\nimport { ensureTool } from \"../../utils/tools-manager.js\";\nimport { chunkFile } from \"./chunker.js\";\nimport {\n\ttype DaemonRetriever,\n\tEmbSearchClient,\n\ttype EmbSearchDaemonInfo,\n\ttype EmbSearchRerankPassage,\n\ttype EmbSearchRerankResult,\n} from \"./client.js\";\nimport {\n\temptyIndexMeta,\n\ttype FileMeta,\n\tgetEmbsearchStoreDir,\n\tgetVectorStoreDir,\n\thashContent,\n\ttype IndexMeta,\n\tloadIndexMeta,\n\tsaveIndexMeta,\n} from \"./index-meta.js\";\nimport { type RepoScanFile, scanRepo } from \"./repo-scan.js\";\n\n/** Chunks per bulk request. Small enough that a concurrent query is never\n * stuck long behind one padded batch inference. */\nconst BULK_BATCH_SIZE = 48;\n/** Yield between batches so background indexing doesn't starve the session. */\nconst BATCH_YIELD_MS = 15;\n/** The Rust mock backend's model id — semantically meaningless, never index with it. */\nconst MOCK_MODEL_ID = \"mock-hash-v1\";\n/**\n * First embsearch release serving `retriever: \"lexical\"`.\n *\n * The guard matters because the daemon does not reject unknown request fields:\n * an older binary silently ignores `retriever` and answers with dense results.\n * Fusing that list a second time as a \"bm25\" leg would double-count it and\n * corrupt the ranking with no error anywhere — so refuse instead of degrading.\n */\nconst MIN_LEXICAL_RETRIEVER_VERSION = [0, 2, 0] as const;\n/**\n * First embsearch release serving the `rerank` op.\n *\n * Necessary but no longer sufficient. Releases from 0.3.1 carry the op without\n * the ~23 MB cross-encoder weights — they measured worse than the\n * deterministic reranker on five of six query classes, so they are no longer\n * bundled — and such a daemon answers `rerank` with an error. The version is\n * therefore only the floor for *asking*; `info.rerank` is the answer, and\n * {@link EmbsearchService.supportsCrossEncoder} needs both.\n */\nconst MIN_RERANK_VERSION = [0, 3, 0] as const;\n\n/** `embsearch 0.2.0` -> [0, 2, 0]; undefined when it cannot be parsed. */\nfunction parseBinaryVersion(output: string): number[] | undefined {\n\tconst match = output.match(/(\\d+)\\.(\\d+)\\.(\\d+)/);\n\treturn match ? [Number(match[1]), Number(match[2]), Number(match[3])] : undefined;\n}\n\nfunction atLeast(version: readonly number[], minimum: readonly number[]): boolean {\n\tfor (let i = 0; i < minimum.length; i++) {\n\t\tconst part = version[i] ?? 0;\n\t\tif (part !== minimum[i]) return part > minimum[i];\n\t}\n\treturn true;\n}\n\nexport type EmbsearchState =\n\t| { phase: \"idle\" }\n\t| { phase: \"skipped\"; reason: string }\n\t| { phase: \"downloading\"; receivedBytes: number; totalBytes: number | null }\n\t| { phase: \"indexing\"; done: number; total: number }\n\t| { phase: \"ready\"; chunkCount: number }\n\t| { phase: \"unavailable\"; reason: string };\n\nexport interface EmbsearchServiceOptions {\n\tcwd: string;\n\t/** Explicit binary path (settings override). Default: \"embsearch\" from PATH. */\n\tbinaryPath?: string;\n\t/** Minimum indexable bytes before indexing kicks in. */\n\tthresholdBytes: number;\n\t/**\n\t * Override the store location. Only the eval harness sets this, so a\n\t * second index (e.g. a BM25-hybrid store) can exist for the same repo\n\t * without colliding with the primary one.\n\t */\n\tstoreDir?: string;\n\t/**\n\t * Create the store with the daemon's BM25 lexical index.\n\t *\n\t * Defaults to whatever the daemon can serve. Fixed at store creation, so an\n\t * existing store that disagrees is rebuilt once; when overriding this to\n\t * hold two different stores for one repo, pair it with a distinct\n\t * `storeDir` so they do not fight over the same directory.\n\t */\n\thybridStore?: boolean;\n\t/** Progress callback for UI (footer / stderr lines). */\n\tonProgress?: (state: EmbsearchState) => void;\n}\n\nexport interface SemanticHit {\n\tpath: string;\n\tstartLine: number;\n\tendLine: number;\n\tscore: number;\n}\n\nexport interface SemanticChunkHit extends SemanticHit {\n\t/** Per-build chunk id (`relpath#index`) — the fusion identity for hybrid search. */\n\tid: string;\n}\n\nexport class EmbsearchService {\n\tprivate readonly options: EmbsearchServiceOptions;\n\tprivate client: EmbSearchClient | undefined;\n\tprivate meta: IndexMeta | undefined;\n\tprivate state: EmbsearchState = { phase: \"idle\" };\n\tprivate disposed = false;\n\t/** Whether the resolved binary serves `retriever: \"lexical\"`. */\n\tprivate lexicalRetriever = false;\n\t/** Whether the store actually opened carries a BM25 index. */\n\tprivate hybridStore = false;\n\t/** Whether the resolved binary serves the cross-encoder `rerank` op. */\n\tprivate crossEncoder = false;\n\n\tconstructor(options: EmbsearchServiceOptions) {\n\t\tthis.options = options;\n\t}\n\n\tgetState(): EmbsearchState {\n\t\treturn this.state;\n\t}\n\n\t/** Semantic search is usable (index ready, or still building with partial data). */\n\tisAvailable(): boolean {\n\t\treturn this.state.phase === \"ready\" || this.state.phase === \"indexing\";\n\t}\n\n\tprivate setState(state: EmbsearchState): void {\n\t\tthis.state = state;\n\t\tthis.options.onProgress?.(state);\n\t}\n\n\t/**\n\t * Whether a BM25-only query will work: the daemon has to understand the\n\t * `lexical` retriever *and* the open store has to carry a BM25 index.\n\t */\n\tsupportsLexicalRetriever(): boolean {\n\t\treturn this.lexicalRetriever && this.hybridStore;\n\t}\n\n\t/**\n\t * Repo files whose on-disk content the index does not have — unknown to it,\n\t * or changed since it last read them.\n\t *\n\t * This is the set BM25 is structurally blind to, and the only place the\n\t * grep leg still earns its keep once BM25 is available. An agent that edits\n\t * a file and immediately searches for what it wrote is asking about exactly\n\t * these files; the index cannot answer until the next pass.\n\t *\n\t * Compares mtime and size only, never hashing: the check runs per query, and\n\t * a false positive merely lets grep cover a file BM25 already covers, while\n\t * a false negative would lose the edit.\n\t *\n\t * Deliberately uncached. A cache here caches the *absence* of an edit, which\n\t * is the one thing this must never do — an agent writes a file and searches\n\t * for it in the same breath. A 1s TTL was tried and cost the live-edit set\n\t * 75% to 100% of its score depending on how the timing fell, which is worse\n\t * than wrong: it was non-deterministic. One scan is ~25ms over ~1k files and\n\t * happens once per search, against retrieval that already costs more.\n\t */\n\tstaleFiles(signal?: AbortSignal): string[] {\n\t\tif (!this.meta) return [];\n\t\tconst meta = this.meta;\n\t\tconst files: string[] = [];\n\t\ttry {\n\t\t\tfor (const file of scanRepo(this.options.cwd, signal).files) {\n\t\t\t\tconst known = meta.files[file.rel];\n\t\t\t\tif (!known || known.mtimeMs !== file.mtimeMs || known.size !== file.size) files.push(file.rel);\n\t\t\t}\n\t\t} catch {\n\t\t\t// A failed scan must not silently narrow the grep leg to nothing;\n\t\t\t// report no staleness and let the indexed legs answer.\n\t\t\treturn [];\n\t\t}\n\t\treturn files;\n\t}\n\n\t/** Whether the running daemon can score with a cross-encoder. */\n\tsupportsCrossEncoder(): boolean {\n\t\treturn this.crossEncoder;\n\t}\n\n\t/**\n\t * Cross-encoder rerank of caller-supplied passages.\n\t *\n\t * Unlike the retrievers this does not consult the index at all — it scores\n\t * exactly the text passed in, which is why the caller sends its expanded\n\t * windows rather than chunk ids.\n\t */\n\tasync rerank(query: string, passages: EmbSearchRerankPassage[], k: number): Promise<EmbSearchRerankResult[]> {\n\t\tif (!this.client || this.client.isClosed) {\n\t\t\tthrow new Error(\"semantic index is not available\");\n\t\t}\n\t\tif (!this.crossEncoder) {\n\t\t\tthrow new Error(\n\t\t\t\t`this embsearch daemon cannot rerank (needs >= ${MIN_RERANK_VERSION.join(\".\")} reporting ` +\n\t\t\t\t\t\"`rerank: true`); released binaries ship without cross-encoder weights — start the daemon \" +\n\t\t\t\t\t\"with --reranker-model <dir>\",\n\t\t\t);\n\t\t}\n\t\treturn await this.client.rerank(query, passages, k);\n\t}\n\n\tprivate probeBinaryVersion(binary: string): number[] | undefined {\n\t\ttry {\n\t\t\treturn parseBinaryVersion(execFileSync(binary, [\"--version\"], { encoding: \"utf-8\", timeout: 10_000 }));\n\t\t} catch {\n\t\t\treturn undefined;\n\t\t}\n\t}\n\n\tprivate async resolveBinary(): Promise<string | undefined> {\n\t\tif (this.options.binaryPath) {\n\t\t\treturn this.options.binaryPath;\n\t\t}\n\t\t// Surface the on-demand binary download through the same progress channel as\n\t\t// indexing, so the first-run fetch renders a progress bar instead of a stall.\n\t\t// A cached binary resolves without ever invoking this callback.\n\t\treturn await ensureTool(\"embsearch\", true, (receivedBytes, totalBytes) => {\n\t\t\tthis.setState({ phase: \"downloading\", receivedBytes, totalBytes });\n\t\t});\n\t}\n\n\t/**\n\t * Scan, threshold-check, and (when needed) index in the background.\n\t * Resolves when indexing completes or the feature settles dormant.\n\t */\n\tasync start(signal?: AbortSignal): Promise<void> {\n\t\ttry {\n\t\t\tawait this.run(signal);\n\t\t} catch (e) {\n\t\t\tconst reason = e instanceof Error ? e.message : String(e);\n\t\t\tthis.setState({ phase: \"unavailable\", reason });\n\t\t\tawait this.closeClient();\n\t\t}\n\t}\n\n\tprivate async run(signal?: AbortSignal): Promise<void> {\n\t\tconst binary = await this.resolveBinary();\n\t\tif (!binary) {\n\t\t\tthis.setState({\n\t\t\t\tphase: \"unavailable\",\n\t\t\t\treason: \"embsearch binary not found (PATH or embsearchBinaryPath setting)\",\n\t\t\t});\n\t\t\treturn;\n\t\t}\n\n\t\tconst scan = scanRepo(this.options.cwd, signal);\n\t\tif (scan.totalBytes < this.options.thresholdBytes) {\n\t\t\tthis.setState({\n\t\t\t\tphase: \"skipped\",\n\t\t\t\treason: `repo under threshold (${scan.totalBytes} < ${this.options.thresholdBytes} bytes)`,\n\t\t\t});\n\t\t\treturn;\n\t\t}\n\n\t\tconst binaryVersion = this.probeBinaryVersion(binary);\n\t\tthis.lexicalRetriever = binaryVersion !== undefined && atLeast(binaryVersion, MIN_LEXICAL_RETRIEVER_VERSION);\n\t\t// Provisional: the version says the daemon understands `rerank`. Whether\n\t\t// it can serve one is answered by `info` below, once the client is up.\n\t\tthis.crossEncoder = binaryVersion !== undefined && atLeast(binaryVersion, MIN_RERANK_VERSION);\n\n\t\tconst storeDir = this.options.storeDir ?? getEmbsearchStoreDir(this.options.cwd);\n\t\t// A hybrid store carries a BM25 index next to its vectors, which is what\n\t\t// lets search use BM25 as its lexical leg instead of ripgrep. Callers may\n\t\t// force it either way; by default it follows what the daemon can serve.\n\t\tconst wantHybrid = this.options.hybridStore ?? this.lexicalRetriever;\n\n\t\tconst openClient = async (): Promise<EmbSearchDaemonInfo> => {\n\t\t\tthis.client = new EmbSearchClient({\n\t\t\t\tbinaryPath: binary,\n\t\t\t\tstorePath: getVectorStoreDir(storeDir),\n\t\t\t\thybrid: wantHybrid,\n\t\t\t});\n\t\t\tawait this.client.ready();\n\t\t\treturn await this.client.info();\n\t\t};\n\n\t\tlet info = await openClient();\n\n\t\t// Hybrid-ness is fixed when a store is created and `--hybrid` against an\n\t\t// existing plain store only warns, so an index built before this was the\n\t\t// default would silently stay dense-only and every BM25 query against it\n\t\t// would fail. Ask the store itself rather than trusting the sidecar, and\n\t\t// rebuild once when it disagrees. `info.hybrid` is undefined on daemons\n\t\t// too old to report it — those cannot serve BM25 anyway, so leave them be.\n\t\tif (wantHybrid && info.hybrid === false) {\n\t\t\tthis.setState({ phase: \"indexing\", done: 0, total: 0 });\n\t\t\tawait this.closeClient();\n\t\t\trmSync(storeDir, { recursive: true, force: true });\n\t\t\tinfo = await openClient();\n\t\t}\n\t\tthis.hybridStore = info.hybrid === true;\n\t\t// A daemon old enough to omit the field is left on the version verdict —\n\t\t// back then the weights were bundled, so version did imply capability.\n\t\tif (info.rerank === false) this.crossEncoder = false;\n\t\tif (info.modelId === MOCK_MODEL_ID) {\n\t\t\tthrow new Error(\"embsearch binary uses the mock embedder (not semantic); install an onnx build\");\n\t\t}\n\n\t\t// Missing/stale sidecar (format, chunker, or model changed) → clean rebuild.\n\t\tthis.meta = loadIndexMeta(storeDir, info.modelId) ?? emptyIndexMeta(this.options.cwd, info.modelId);\n\t\tthis.meta.lastUsedMs = Date.now();\n\n\t\tawait this.indexChangedFiles(scan.files, storeDir, signal);\n\t}\n\n\tprivate async indexChangedFiles(files: RepoScanFile[], storeDir: string, signal?: AbortSignal): Promise<void> {\n\t\tconst meta = this.meta!;\n\t\tconst client = this.client!;\n\n\t\t// Diff scan vs sidecar: cheap mtime+size check first, hash only on delta.\n\t\tconst toIndex: Array<{ file: RepoScanFile; content: string; hash: string }> = [];\n\t\tconst seen = new Set<string>();\n\t\tfor (const file of files) {\n\t\t\tseen.add(file.rel);\n\t\t\tconst known = meta.files[file.rel];\n\t\t\tif (known && known.mtimeMs === file.mtimeMs && known.size === file.size) continue;\n\t\t\tlet content: string;\n\t\t\ttry {\n\t\t\t\tcontent = readFileSync(file.abs, \"utf-8\");\n\t\t\t} catch {\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\tconst hash = hashContent(content);\n\t\t\tif (known && known.hash === hash) {\n\t\t\t\t// Touched but unchanged — refresh stat info only.\n\t\t\t\tknown.mtimeMs = file.mtimeMs;\n\t\t\t\tknown.size = file.size;\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\ttoIndex.push({ file, content, hash });\n\t\t}\n\t\tconst toRemove = Object.keys(meta.files).filter((rel) => !seen.has(rel));\n\n\t\tif (toIndex.length === 0 && toRemove.length === 0) {\n\t\t\tsaveIndexMeta(storeDir, meta);\n\t\t\tthis.setState({ phase: \"ready\", chunkCount: this.countChunks(meta) });\n\t\t\treturn;\n\t\t}\n\n\t\t// Chunk changed files; count total upserts for exact progress.\n\t\tconst work: Array<{ rel: string; fileMeta: FileMeta; chunks: Array<{ id: string; text: string }> }> = [];\n\t\tlet totalChunks = 0;\n\t\tfor (const { file, content, hash } of toIndex) {\n\t\t\tconst chunks = chunkFile(file.rel, content);\n\t\t\twork.push({\n\t\t\t\trel: file.rel,\n\t\t\t\tfileMeta: {\n\t\t\t\t\tmtimeMs: file.mtimeMs,\n\t\t\t\t\tsize: file.size,\n\t\t\t\t\thash,\n\t\t\t\t\tchunks: chunks.map((c) => [c.startLine, c.endLine]),\n\t\t\t\t},\n\t\t\t\tchunks: chunks.map((c) => ({ id: c.id, text: c.text })),\n\t\t\t});\n\t\t\ttotalChunks += chunks.length;\n\t\t}\n\n\t\tthis.setState({ phase: \"indexing\", done: 0, total: totalChunks });\n\n\t\t// Drop vectors of deleted files and superseded chunk tails.\n\t\tfor (const rel of toRemove) {\n\t\t\tfor (let i = 0; i < meta.files[rel].chunks.length; i++) await client.remove(`${rel}#${i}`);\n\t\t\tdelete meta.files[rel];\n\t\t}\n\n\t\tlet done = 0;\n\t\tfor (const item of work) {\n\t\t\tif (signal?.aborted || this.disposed) return;\n\t\t\tconst oldChunkCount = meta.files[item.rel]?.chunks.length ?? 0;\n\t\t\t// Remove old chunks beyond the new count (upsert covers the rest).\n\t\t\tfor (let i = item.chunks.length; i < oldChunkCount; i++) await client.remove(`${item.rel}#${i}`);\n\n\t\t\tfor (let offset = 0; offset < item.chunks.length; offset += BULK_BATCH_SIZE) {\n\t\t\t\tif (signal?.aborted || this.disposed) return;\n\t\t\t\tconst batch = item.chunks.slice(offset, offset + BULK_BATCH_SIZE);\n\t\t\t\tawait client.bulk(batch);\n\t\t\t\tdone += batch.length;\n\t\t\t\tthis.setState({ phase: \"indexing\", done, total: totalChunks });\n\t\t\t\t// Yield so queries and the event loop stay responsive.\n\t\t\t\tawait new Promise((resolve) => setTimeout(resolve, BATCH_YIELD_MS));\n\t\t\t}\n\t\t\tmeta.files[item.rel] = item.fileMeta;\n\t\t}\n\n\t\tawait client.compact();\n\t\tawait client.save();\n\t\tsaveIndexMeta(storeDir, meta);\n\t\tthis.setState({ phase: \"ready\", chunkCount: this.countChunks(meta) });\n\t}\n\n\tprivate countChunks(meta: IndexMeta): number {\n\t\tlet n = 0;\n\t\tfor (const rel of Object.keys(meta.files)) n += meta.files[rel].chunks.length;\n\t\treturn n;\n\t}\n\n\t/** Top-`k` semantic hits as `path` + line range + score. */\n\tasync search(query: string, k = 10): Promise<SemanticHit[]> {\n\t\treturn await this.searchChunks(query, k);\n\t}\n\n\t/** Top-`k` semantic hits including their chunk ids, for rank fusion. */\n\tasync searchChunks(\n\t\tquery: string,\n\t\tk = 10,\n\t\tglob?: string,\n\t\tretriever: DaemonRetriever = \"dense\",\n\t): Promise<SemanticChunkHit[]> {\n\t\tif (!this.client || this.client.isClosed || !this.meta) {\n\t\t\tthrow new Error(\"semantic index is not available\");\n\t\t}\n\t\tif (retriever === \"lexical\" && !this.lexicalRetriever) {\n\t\t\tthrow new Error(\n\t\t\t\t`embsearch is too old for retriever \"lexical\" (needs >= ${MIN_LEXICAL_RETRIEVER_VERSION.join(\".\")}); ` +\n\t\t\t\t\t\"an older daemon ignores the field and answers with dense results\",\n\t\t\t);\n\t\t}\n\t\tconst results = await this.client.query(query, k, retriever);\n\t\tconst hits: SemanticChunkHit[] = [];\n\t\tconst matchGlob = (rel: string): boolean => {\n\t\t\tif (!glob) return true;\n\t\t\treturn minimatch(rel, glob, { dot: true, matchBase: !glob.includes(\"/\") });\n\t\t};\n\t\tfor (const result of results) {\n\t\t\tconst sep = result.id.lastIndexOf(\"#\");\n\t\t\tif (sep === -1) continue;\n\t\t\tconst rel = result.id.slice(0, sep);\n\t\t\tif (!matchGlob(rel)) continue;\n\t\t\tconst chunkIndex = Number.parseInt(result.id.slice(sep + 1), 10);\n\t\t\tconst range = this.meta.files[rel]?.chunks[chunkIndex];\n\t\t\tif (!range) continue;\n\t\t\thits.push({ id: result.id, path: rel, startLine: range[0], endLine: range[1], score: result.score });\n\t\t}\n\t\treturn hits;\n\t}\n\n\t/**\n\t * Resolve a repo-relative path + line to its enclosing indexed chunk, or\n\t * undefined when the file/line is not covered by the index. Chunks overlap\n\t * by a few lines; the first (lowest-index) containing chunk wins so the\n\t * mapping is deterministic.\n\t */\n\tfindEnclosingChunk(\n\t\trel: string,\n\t\tline: number,\n\t): { id: string; path: string; startLine: number; endLine: number } | undefined {\n\t\tconst file = this.meta?.files[rel];\n\t\tif (!file) return undefined;\n\t\tfor (let i = 0; i < file.chunks.length; i++) {\n\t\t\tconst [startLine, endLine] = file.chunks[i];\n\t\t\tif (line >= startLine && line <= endLine) {\n\t\t\t\treturn { id: `${rel}#${i}`, path: rel, startLine, endLine };\n\t\t\t}\n\t\t}\n\t\treturn undefined;\n\t}\n\n\tprivate async closeClient(): Promise<void> {\n\t\tconst client = this.client;\n\t\tthis.client = undefined;\n\t\tif (client && !client.isClosed) {\n\t\t\ttry {\n\t\t\t\tawait client.close();\n\t\t\t} catch {\n\t\t\t\t// already dead\n\t\t\t}\n\t\t}\n\t}\n\n\t/** Persist state and shut the daemon down. Safe to call twice. */\n\tasync dispose(): Promise<void> {\n\t\tif (this.disposed) return;\n\t\tthis.disposed = true;\n\t\tif (this.client && !this.client.isClosed) {\n\t\t\ttry {\n\t\t\t\tawait this.client.save();\n\t\t\t} catch {\n\t\t\t\t// daemon may have exited; nothing to save\n\t\t\t}\n\t\t}\n\t\tawait this.closeClient();\n\t}\n}\n\n// --- Per-cwd service registry ---\n//\n// The search tool is constructed by the generic tool factory table and\n// only receives `cwd`; the service is created later during session init (flag\n// gated). This registry connects the two without threading a service instance\n// through every layer between main.ts and the tool factories.\n\nconst services = new Map<string, EmbsearchService>();\n\nexport function registerEmbsearchService(cwd: string, service: EmbsearchService): void {\n\tconst old = services.get(cwd);\n\tif (old && old !== service) {\n\t\told.dispose().catch(() => {});\n\t}\n\tservices.set(cwd, service);\n}\n\nexport function getEmbsearchService(cwd: string): EmbsearchService | undefined {\n\treturn services.get(cwd);\n}\n\nexport function unregisterEmbsearchService(cwd: string): void {\n\tservices.delete(cwd);\n}\n"]}
1
+ {"version":3,"file":"embsearch-service.js","sourceRoot":"","sources":["../../../src/core/embsearch/embsearch-service.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Orchestrates semantic indexing and search for a repository.\n *\n * Lifecycle (all behind --enable-semantic-index):\n * 1. `start()` — resolve the embsearch binary, scan the repo (ignore-aware),\n * apply the byte threshold. Under threshold → dormant. Over → spawn the\n * daemon, verify the backend is not the mock embedder, then index changed\n * files in the background in small batches, reporting progress.\n * 2. `search()` — top-k semantic query, mapping chunk ids back to\n * `path:start-end` via the sidecar metadata.\n * 3. `dispose()` — save + close the daemon.\n *\n * Every failure degrades to `unavailable` with a reason; nothing here ever\n * blocks session startup or affects grep/find.\n */\n\nimport { execFileSync } from \"child_process\";\nimport { readFileSync, rmSync } from \"fs\";\nimport { minimatch } from \"minimatch\";\nimport { ensureTool } from \"../../utils/tools-manager.js\";\nimport { chunkFile } from \"./chunker.js\";\nimport {\n\ttype DaemonRetriever,\n\tEmbSearchClient,\n\ttype EmbSearchDaemonInfo,\n\ttype EmbSearchRerankPassage,\n\ttype EmbSearchRerankResult,\n} from \"./client.js\";\nimport {\n\temptyIndexMeta,\n\ttype FileMeta,\n\tgetEmbsearchStoreDir,\n\tgetVectorStoreDir,\n\thashContent,\n\ttype IndexMeta,\n\tloadIndexMeta,\n\tsaveIndexMeta,\n} from \"./index-meta.js\";\nimport { type RepoScanFile, scanRepo } from \"./repo-scan.js\";\n\n/** Chunks per bulk request. Small enough that a concurrent query is never\n * stuck long behind one padded batch inference. */\nconst BULK_BATCH_SIZE = 48;\n/** Yield between batches so background indexing doesn't starve the session. */\nconst BATCH_YIELD_MS = 15;\n/** The Rust mock backend's model id — semantically meaningless, never index with it. */\nconst MOCK_MODEL_ID = \"mock-hash-v1\";\n/**\n * First embsearch release serving `retriever: \"lexical\"`.\n *\n * The guard matters because the daemon does not reject unknown request fields:\n * an older binary silently ignores `retriever` and answers with dense results.\n * Fusing that list a second time as a \"bm25\" leg would double-count it and\n * corrupt the ranking with no error anywhere — so refuse instead of degrading.\n */\nconst MIN_LEXICAL_RETRIEVER_VERSION = [0, 2, 0] as const;\n/**\n * First embsearch release serving the `rerank` op.\n *\n * Necessary but no longer sufficient. Releases from 0.3.1 carry the op without\n * the ~23 MB cross-encoder weights — they measured worse than the\n * deterministic reranker on five of six query classes, so they are no longer\n * bundled — and such a daemon answers `rerank` with an error. The version is\n * therefore only the floor for *asking*; `info.rerank` is the answer, and\n * {@link EmbsearchService.supportsCrossEncoder} needs both.\n */\nconst MIN_RERANK_VERSION = [0, 3, 0] as const;\n\n/** `embsearch 0.2.0` -> [0, 2, 0]; undefined when it cannot be parsed. */\nfunction parseBinaryVersion(output: string): number[] | undefined {\n\tconst match = output.match(/(\\d+)\\.(\\d+)\\.(\\d+)/);\n\treturn match ? [Number(match[1]), Number(match[2]), Number(match[3])] : undefined;\n}\n\nfunction atLeast(version: readonly number[], minimum: readonly number[]): boolean {\n\tfor (let i = 0; i < minimum.length; i++) {\n\t\tconst part = version[i] ?? 0;\n\t\tif (part !== minimum[i]) return part > minimum[i];\n\t}\n\treturn true;\n}\n\nexport type EmbsearchState =\n\t| { phase: \"idle\" }\n\t| { phase: \"skipped\"; reason: string }\n\t| { phase: \"downloading\"; receivedBytes: number; totalBytes: number | null }\n\t| { phase: \"indexing\"; done: number; total: number }\n\t| { phase: \"ready\"; chunkCount: number }\n\t| { phase: \"unavailable\"; reason: string };\n\nexport interface EmbsearchServiceOptions {\n\tcwd: string;\n\t/** Explicit binary path (settings override). Default: \"embsearch\" from PATH. */\n\tbinaryPath?: string;\n\t/**\n\t * Model directory handed to the daemon as `--model`, overriding the model\n\t * bundled in the binary.\n\t *\n\t * Only the eval harness sets this, to score two embedding models from one\n\t * binary. Pair it with a distinct `storeDir`: vectors from different models\n\t * are incompatible, and the daemon refuses to open a store built by another\n\t * model rather than mixing them.\n\t */\n\tmodelDir?: string;\n\t/**\n\t * Override the chunker's character cap.\n\t *\n\t * Only the eval harness sets this, to sweep the chunk window. It changes\n\t * what every vector in the store *is*, and nothing stored records it, so it\n\t * must be paired with a distinct `storeDir` exactly as `modelDir` is —\n\t * otherwise a run silently scores an index built at another cap.\n\t */\n\tchunkMaxChars?: number;\n\t/** Minimum indexable bytes before indexing kicks in. */\n\tthresholdBytes: number;\n\t/**\n\t * Override the store location. Only the eval harness sets this, so a\n\t * second index (e.g. a BM25-hybrid store) can exist for the same repo\n\t * without colliding with the primary one.\n\t */\n\tstoreDir?: string;\n\t/**\n\t * Create the store with the daemon's BM25 lexical index.\n\t *\n\t * Defaults to whatever the daemon can serve. Fixed at store creation, so an\n\t * existing store that disagrees is rebuilt once; when overriding this to\n\t * hold two different stores for one repo, pair it with a distinct\n\t * `storeDir` so they do not fight over the same directory.\n\t */\n\thybridStore?: boolean;\n\t/** Progress callback for UI (footer / stderr lines). */\n\tonProgress?: (state: EmbsearchState) => void;\n}\n\nexport interface SemanticHit {\n\tpath: string;\n\tstartLine: number;\n\tendLine: number;\n\tscore: number;\n}\n\nexport interface SemanticChunkHit extends SemanticHit {\n\t/** Per-build chunk id (`relpath#index`) — the fusion identity for hybrid search. */\n\tid: string;\n}\n\nexport class EmbsearchService {\n\tprivate readonly options: EmbsearchServiceOptions;\n\tprivate client: EmbSearchClient | undefined;\n\tprivate meta: IndexMeta | undefined;\n\tprivate state: EmbsearchState = { phase: \"idle\" };\n\tprivate disposed = false;\n\t/** Whether the resolved binary serves `retriever: \"lexical\"`. */\n\tprivate lexicalRetriever = false;\n\t/** Whether the store actually opened carries a BM25 index. */\n\tprivate hybridStore = false;\n\t/** Whether the resolved binary serves the cross-encoder `rerank` op. */\n\tprivate crossEncoder = false;\n\n\tconstructor(options: EmbsearchServiceOptions) {\n\t\tthis.options = options;\n\t}\n\n\tgetState(): EmbsearchState {\n\t\treturn this.state;\n\t}\n\n\t/** Semantic search is usable (index ready, or still building with partial data). */\n\t/**\n\t * Model id reported by the running daemon, once it is up.\n\t *\n\t * This — not the binary's version — identifies which model produced the\n\t * vectors in the store, because `--model` decouples the two.\n\t */\n\tmodelId(): string | undefined {\n\t\treturn this.meta?.modelId;\n\t}\n\n\tisAvailable(): boolean {\n\t\treturn this.state.phase === \"ready\" || this.state.phase === \"indexing\";\n\t}\n\n\tprivate setState(state: EmbsearchState): void {\n\t\tthis.state = state;\n\t\tthis.options.onProgress?.(state);\n\t}\n\n\t/**\n\t * Whether a BM25-only query will work: the daemon has to understand the\n\t * `lexical` retriever *and* the open store has to carry a BM25 index.\n\t */\n\tsupportsLexicalRetriever(): boolean {\n\t\treturn this.lexicalRetriever && this.hybridStore;\n\t}\n\n\t/**\n\t * Repo files whose on-disk content the index does not have — unknown to it,\n\t * or changed since it last read them.\n\t *\n\t * This is the set BM25 is structurally blind to, and the only place the\n\t * grep leg still earns its keep once BM25 is available. An agent that edits\n\t * a file and immediately searches for what it wrote is asking about exactly\n\t * these files; the index cannot answer until the next pass.\n\t *\n\t * Compares mtime and size only, never hashing: the check runs per query, and\n\t * a false positive merely lets grep cover a file BM25 already covers, while\n\t * a false negative would lose the edit.\n\t *\n\t * Deliberately uncached. A cache here caches the *absence* of an edit, which\n\t * is the one thing this must never do — an agent writes a file and searches\n\t * for it in the same breath. A 1s TTL was tried and cost the live-edit set\n\t * 75% to 100% of its score depending on how the timing fell, which is worse\n\t * than wrong: it was non-deterministic. One scan is ~25ms over ~1k files and\n\t * happens once per search, against retrieval that already costs more.\n\t */\n\tstaleFiles(signal?: AbortSignal): string[] {\n\t\tif (!this.meta) return [];\n\t\tconst meta = this.meta;\n\t\tconst files: string[] = [];\n\t\ttry {\n\t\t\tfor (const file of scanRepo(this.options.cwd, signal).files) {\n\t\t\t\tconst known = meta.files[file.rel];\n\t\t\t\tif (!known || known.mtimeMs !== file.mtimeMs || known.size !== file.size) files.push(file.rel);\n\t\t\t}\n\t\t} catch {\n\t\t\t// A failed scan must not silently narrow the grep leg to nothing;\n\t\t\t// report no staleness and let the indexed legs answer.\n\t\t\treturn [];\n\t\t}\n\t\treturn files;\n\t}\n\n\t/** Whether the running daemon can score with a cross-encoder. */\n\tsupportsCrossEncoder(): boolean {\n\t\treturn this.crossEncoder;\n\t}\n\n\t/**\n\t * Cross-encoder rerank of caller-supplied passages.\n\t *\n\t * Unlike the retrievers this does not consult the index at all — it scores\n\t * exactly the text passed in, which is why the caller sends its expanded\n\t * windows rather than chunk ids.\n\t */\n\tasync rerank(query: string, passages: EmbSearchRerankPassage[], k: number): Promise<EmbSearchRerankResult[]> {\n\t\tif (!this.client || this.client.isClosed) {\n\t\t\tthrow new Error(\"semantic index is not available\");\n\t\t}\n\t\tif (!this.crossEncoder) {\n\t\t\tthrow new Error(\n\t\t\t\t`this embsearch daemon cannot rerank (needs >= ${MIN_RERANK_VERSION.join(\".\")} reporting ` +\n\t\t\t\t\t\"`rerank: true`); released binaries ship without cross-encoder weights — start the daemon \" +\n\t\t\t\t\t\"with --reranker-model <dir>\",\n\t\t\t);\n\t\t}\n\t\treturn await this.client.rerank(query, passages, k);\n\t}\n\n\tprivate probeBinaryVersion(binary: string): number[] | undefined {\n\t\ttry {\n\t\t\treturn parseBinaryVersion(execFileSync(binary, [\"--version\"], { encoding: \"utf-8\", timeout: 10_000 }));\n\t\t} catch {\n\t\t\treturn undefined;\n\t\t}\n\t}\n\n\t/**\n\t * What the store on disk says about itself, read straight from its manifest.\n\t *\n\t * `store-info` exists precisely for the case where the daemon will not open\n\t * the store: a `serve` pairs a store with an embedder and refuses the pair\n\t * when their models disagree, so at that moment nothing else can tell us\n\t * what built it. Returns undefined when there is no readable store — which\n\t * includes a binary too old to have the subcommand, and so degrades to the\n\t * previous behaviour rather than guessing.\n\t */\n\tprivate probeStore(binary: string, storeDir: string): { modelId: string; live: number } | undefined {\n\t\ttry {\n\t\t\tconst out = execFileSync(binary, [\"store-info\", \"--path\", getVectorStoreDir(storeDir), \"--json\"], {\n\t\t\t\tencoding: \"utf-8\",\n\t\t\t\ttimeout: 10_000,\n\t\t\t\tstdio: [\"ignore\", \"pipe\", \"ignore\"],\n\t\t\t});\n\t\t\tconst parsed = JSON.parse(out) as { model_id?: string; live?: number };\n\t\t\tif (typeof parsed.model_id !== \"string\") return undefined;\n\t\t\treturn { modelId: parsed.model_id, live: parsed.live ?? 0 };\n\t\t} catch {\n\t\t\treturn undefined;\n\t\t}\n\t}\n\n\tprivate async resolveBinary(): Promise<string | undefined> {\n\t\tif (this.options.binaryPath) {\n\t\t\treturn this.options.binaryPath;\n\t\t}\n\t\t// Surface the on-demand binary download through the same progress channel as\n\t\t// indexing, so the first-run fetch renders a progress bar instead of a stall.\n\t\t// A cached binary resolves without ever invoking this callback.\n\t\treturn await ensureTool(\"embsearch\", true, (receivedBytes, totalBytes) => {\n\t\t\tthis.setState({ phase: \"downloading\", receivedBytes, totalBytes });\n\t\t});\n\t}\n\n\t/**\n\t * Scan, threshold-check, and (when needed) index in the background.\n\t * Resolves when indexing completes or the feature settles dormant.\n\t */\n\tasync start(signal?: AbortSignal): Promise<void> {\n\t\ttry {\n\t\t\tawait this.run(signal);\n\t\t} catch (e) {\n\t\t\tconst reason = e instanceof Error ? e.message : String(e);\n\t\t\tthis.setState({ phase: \"unavailable\", reason });\n\t\t\tawait this.closeClient();\n\t\t}\n\t}\n\n\tprivate async run(signal?: AbortSignal): Promise<void> {\n\t\tconst binary = await this.resolveBinary();\n\t\tif (!binary) {\n\t\t\tthis.setState({\n\t\t\t\tphase: \"unavailable\",\n\t\t\t\treason: \"embsearch binary not found (PATH or embsearchBinaryPath setting)\",\n\t\t\t});\n\t\t\treturn;\n\t\t}\n\n\t\tconst scan = scanRepo(this.options.cwd, signal);\n\t\tif (scan.totalBytes < this.options.thresholdBytes) {\n\t\t\tthis.setState({\n\t\t\t\tphase: \"skipped\",\n\t\t\t\treason: `repo under threshold (${scan.totalBytes} < ${this.options.thresholdBytes} bytes)`,\n\t\t\t});\n\t\t\treturn;\n\t\t}\n\n\t\tconst binaryVersion = this.probeBinaryVersion(binary);\n\t\tthis.lexicalRetriever = binaryVersion !== undefined && atLeast(binaryVersion, MIN_LEXICAL_RETRIEVER_VERSION);\n\t\t// Provisional: the version says the daemon understands `rerank`. Whether\n\t\t// it can serve one is answered by `info` below, once the client is up.\n\t\tthis.crossEncoder = binaryVersion !== undefined && atLeast(binaryVersion, MIN_RERANK_VERSION);\n\n\t\tconst storeDir = this.options.storeDir ?? getEmbsearchStoreDir(this.options.cwd);\n\t\t// A hybrid store carries a BM25 index next to its vectors, which is what\n\t\t// lets search use BM25 as its lexical leg instead of ripgrep. Callers may\n\t\t// force it either way; by default it follows what the daemon can serve.\n\t\tconst wantHybrid = this.options.hybridStore ?? this.lexicalRetriever;\n\n\t\tconst openClient = async (): Promise<EmbSearchDaemonInfo> => {\n\t\t\tthis.client = new EmbSearchClient({\n\t\t\t\tbinaryPath: binary,\n\t\t\t\tstorePath: getVectorStoreDir(storeDir),\n\t\t\t\thybrid: wantHybrid,\n\t\t\t\tmodelDir: this.options.modelDir,\n\t\t\t});\n\t\t\tawait this.client.ready();\n\t\t\treturn await this.client.info();\n\t\t};\n\n\t\t/** Discard the store and start clean. The only recovery from a store the\n\t\t * current binary cannot use — and the only way to be rid of vectors that\n\t\t * outlived the metadata describing them. */\n\t\tconst rebuildFrom = async (why: string): Promise<EmbSearchDaemonInfo> => {\n\t\t\tconsole.error(`embsearch: rebuilding the index (${why})`);\n\t\t\tthis.setState({ phase: \"indexing\", done: 0, total: 0 });\n\t\t\tawait this.closeClient();\n\t\t\trmSync(storeDir, { recursive: true, force: true });\n\t\t\treturn await openClient();\n\t\t};\n\n\t\tlet info: EmbSearchDaemonInfo;\n\t\ttry {\n\t\t\tinfo = await openClient();\n\t\t} catch (err) {\n\t\t\t// The daemon refuses to open a store whose recorded model disagrees\n\t\t\t// with its own — correctly, since vectors from different models are\n\t\t\t// not comparable. But refusing is where it stopped: the store stayed\n\t\t\t// on disk, the daemon never came up, and this service went\n\t\t\t// permanently unavailable with a rebuild one directory-removal away.\n\t\t\t//\n\t\t\t// Only a store that is present and *readable* is treated this way. If\n\t\t\t// `store-info` cannot read it either, the problem is not a model\n\t\t\t// mismatch and destroying an index would be the wrong response, so\n\t\t\t// the original failure stands.\n\t\t\tconst store = this.probeStore(binary, storeDir);\n\t\t\tif (!store) throw err;\n\t\t\tinfo = await rebuildFrom(`built by model '${store.modelId}', which this binary cannot read`);\n\t\t}\n\n\t\t// Hybrid-ness is fixed when a store is created and `--hybrid` against an\n\t\t// existing plain store only warns, so an index built before this was the\n\t\t// default would silently stay dense-only and every BM25 query against it\n\t\t// would fail. Ask the store itself rather than trusting the sidecar, and\n\t\t// rebuild once when it disagrees. `info.hybrid` is undefined on daemons\n\t\t// too old to report it — those cannot serve BM25 anyway, so leave them be.\n\t\tif (wantHybrid && info.hybrid === false) {\n\t\t\tinfo = await rebuildFrom(\"the existing store carries no BM25 index\");\n\t\t}\n\t\tthis.hybridStore = info.hybrid === true;\n\t\t// A daemon old enough to omit the field is left on the version verdict —\n\t\t// back then the weights were bundled, so version did imply capability.\n\t\tif (info.rerank === false) this.crossEncoder = false;\n\t\tif (info.modelId === MOCK_MODEL_ID) {\n\t\t\tthrow new Error(\"embsearch binary uses the mock embedder (not semantic); install an onnx build\");\n\t\t}\n\n\t\t// Missing/stale sidecar (format, chunker, or model changed) → clean rebuild.\n\t\t//\n\t\t// Resetting the sidecar alone is not enough, and used to be all this did.\n\t\t// The sidecar is the only record of how many chunks each file produced,\n\t\t// so an empty one reports zero for every file — and `indexChangedFiles`\n\t\t// removes stale chunks by counting down from that number. Re-chunking a\n\t\t// file into *fewer* pieces then leaves its tail vectors (`path#N`,\n\t\t// `path#N+1`, …) in the store, holding text that no longer exists\n\t\t// anywhere, retrievable forever. Upserts hide it: chunk counts look\n\t\t// right, the sidecar looks right, and only search results are wrong.\n\t\t//\n\t\t// So when the sidecar cannot be trusted and the store is not already\n\t\t// empty, the store goes too.\n\t\tlet meta = loadIndexMeta(storeDir, info.modelId);\n\t\tif (!meta && info.count > 0) {\n\t\t\tinfo = await rebuildFrom(\"index metadata is missing or was written by a different chunker or model\");\n\t\t\tmeta = loadIndexMeta(storeDir, info.modelId);\n\t\t}\n\t\tthis.meta = meta ?? emptyIndexMeta(this.options.cwd, info.modelId);\n\t\tthis.meta.lastUsedMs = Date.now();\n\n\t\tawait this.indexChangedFiles(scan.files, storeDir, signal);\n\t}\n\n\tprivate async indexChangedFiles(files: RepoScanFile[], storeDir: string, signal?: AbortSignal): Promise<void> {\n\t\tconst meta = this.meta!;\n\t\tconst client = this.client!;\n\n\t\t// Diff scan vs sidecar: cheap mtime+size check first, hash only on delta.\n\t\tconst toIndex: Array<{ file: RepoScanFile; content: string; hash: string }> = [];\n\t\tconst seen = new Set<string>();\n\t\tfor (const file of files) {\n\t\t\tseen.add(file.rel);\n\t\t\tconst known = meta.files[file.rel];\n\t\t\tif (known && known.mtimeMs === file.mtimeMs && known.size === file.size) continue;\n\t\t\tlet content: string;\n\t\t\ttry {\n\t\t\t\tcontent = readFileSync(file.abs, \"utf-8\");\n\t\t\t} catch {\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\tconst hash = hashContent(content);\n\t\t\tif (known && known.hash === hash) {\n\t\t\t\t// Touched but unchanged — refresh stat info only.\n\t\t\t\tknown.mtimeMs = file.mtimeMs;\n\t\t\t\tknown.size = file.size;\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\ttoIndex.push({ file, content, hash });\n\t\t}\n\t\tconst toRemove = Object.keys(meta.files).filter((rel) => !seen.has(rel));\n\n\t\tif (toIndex.length === 0 && toRemove.length === 0) {\n\t\t\tsaveIndexMeta(storeDir, meta);\n\t\t\tthis.setState({ phase: \"ready\", chunkCount: this.countChunks(meta) });\n\t\t\treturn;\n\t\t}\n\n\t\t// Chunk changed files; count total upserts for exact progress.\n\t\tconst work: Array<{ rel: string; fileMeta: FileMeta; chunks: Array<{ id: string; text: string }> }> = [];\n\t\tlet totalChunks = 0;\n\t\tfor (const { file, content, hash } of toIndex) {\n\t\t\tconst chunks = chunkFile(file.rel, content, this.options.chunkMaxChars);\n\t\t\twork.push({\n\t\t\t\trel: file.rel,\n\t\t\t\tfileMeta: {\n\t\t\t\t\tmtimeMs: file.mtimeMs,\n\t\t\t\t\tsize: file.size,\n\t\t\t\t\thash,\n\t\t\t\t\tchunks: chunks.map((c) => [c.startLine, c.endLine]),\n\t\t\t\t},\n\t\t\t\tchunks: chunks.map((c) => ({ id: c.id, text: c.text })),\n\t\t\t});\n\t\t\ttotalChunks += chunks.length;\n\t\t}\n\n\t\tthis.setState({ phase: \"indexing\", done: 0, total: totalChunks });\n\n\t\t// Drop vectors of deleted files and superseded chunk tails.\n\t\tfor (const rel of toRemove) {\n\t\t\tfor (let i = 0; i < meta.files[rel].chunks.length; i++) await client.remove(`${rel}#${i}`);\n\t\t\tdelete meta.files[rel];\n\t\t}\n\n\t\tlet done = 0;\n\t\tfor (const item of work) {\n\t\t\tif (signal?.aborted || this.disposed) return;\n\t\t\tconst oldChunkCount = meta.files[item.rel]?.chunks.length ?? 0;\n\t\t\t// Remove old chunks beyond the new count (upsert covers the rest).\n\t\t\tfor (let i = item.chunks.length; i < oldChunkCount; i++) await client.remove(`${item.rel}#${i}`);\n\n\t\t\tfor (let offset = 0; offset < item.chunks.length; offset += BULK_BATCH_SIZE) {\n\t\t\t\tif (signal?.aborted || this.disposed) return;\n\t\t\t\tconst batch = item.chunks.slice(offset, offset + BULK_BATCH_SIZE);\n\t\t\t\tawait client.bulk(batch);\n\t\t\t\tdone += batch.length;\n\t\t\t\tthis.setState({ phase: \"indexing\", done, total: totalChunks });\n\t\t\t\t// Yield so queries and the event loop stay responsive.\n\t\t\t\tawait new Promise((resolve) => setTimeout(resolve, BATCH_YIELD_MS));\n\t\t\t}\n\t\t\tmeta.files[item.rel] = item.fileMeta;\n\t\t}\n\n\t\tawait client.compact();\n\t\tawait client.save();\n\t\tsaveIndexMeta(storeDir, meta);\n\t\tthis.setState({ phase: \"ready\", chunkCount: this.countChunks(meta) });\n\t}\n\n\tprivate countChunks(meta: IndexMeta): number {\n\t\tlet n = 0;\n\t\tfor (const rel of Object.keys(meta.files)) n += meta.files[rel].chunks.length;\n\t\treturn n;\n\t}\n\n\t/** Top-`k` semantic hits as `path` + line range + score. */\n\tasync search(query: string, k = 10): Promise<SemanticHit[]> {\n\t\treturn await this.searchChunks(query, k);\n\t}\n\n\t/** Top-`k` semantic hits including their chunk ids, for rank fusion. */\n\tasync searchChunks(\n\t\tquery: string,\n\t\tk = 10,\n\t\tglob?: string,\n\t\tretriever: DaemonRetriever = \"dense\",\n\t): Promise<SemanticChunkHit[]> {\n\t\tif (!this.client || this.client.isClosed || !this.meta) {\n\t\t\tthrow new Error(\"semantic index is not available\");\n\t\t}\n\t\tif (retriever === \"lexical\" && !this.lexicalRetriever) {\n\t\t\tthrow new Error(\n\t\t\t\t`embsearch is too old for retriever \"lexical\" (needs >= ${MIN_LEXICAL_RETRIEVER_VERSION.join(\".\")}); ` +\n\t\t\t\t\t\"an older daemon ignores the field and answers with dense results\",\n\t\t\t);\n\t\t}\n\t\tconst results = await this.client.query(query, k, retriever);\n\t\tconst hits: SemanticChunkHit[] = [];\n\t\tconst matchGlob = (rel: string): boolean => {\n\t\t\tif (!glob) return true;\n\t\t\treturn minimatch(rel, glob, { dot: true, matchBase: !glob.includes(\"/\") });\n\t\t};\n\t\tfor (const result of results) {\n\t\t\tconst sep = result.id.lastIndexOf(\"#\");\n\t\t\tif (sep === -1) continue;\n\t\t\tconst rel = result.id.slice(0, sep);\n\t\t\tif (!matchGlob(rel)) continue;\n\t\t\tconst chunkIndex = Number.parseInt(result.id.slice(sep + 1), 10);\n\t\t\tconst range = this.meta.files[rel]?.chunks[chunkIndex];\n\t\t\tif (!range) continue;\n\t\t\thits.push({ id: result.id, path: rel, startLine: range[0], endLine: range[1], score: result.score });\n\t\t}\n\t\treturn hits;\n\t}\n\n\t/**\n\t * Resolve a repo-relative path + line to its enclosing indexed chunk, or\n\t * undefined when the file/line is not covered by the index. Chunks overlap\n\t * by a few lines; the first (lowest-index) containing chunk wins so the\n\t * mapping is deterministic.\n\t */\n\tfindEnclosingChunk(\n\t\trel: string,\n\t\tline: number,\n\t): { id: string; path: string; startLine: number; endLine: number } | undefined {\n\t\tconst file = this.meta?.files[rel];\n\t\tif (!file) return undefined;\n\t\tfor (let i = 0; i < file.chunks.length; i++) {\n\t\t\tconst [startLine, endLine] = file.chunks[i];\n\t\t\tif (line >= startLine && line <= endLine) {\n\t\t\t\treturn { id: `${rel}#${i}`, path: rel, startLine, endLine };\n\t\t\t}\n\t\t}\n\t\treturn undefined;\n\t}\n\n\tprivate async closeClient(): Promise<void> {\n\t\tconst client = this.client;\n\t\tthis.client = undefined;\n\t\tif (client && !client.isClosed) {\n\t\t\ttry {\n\t\t\t\tawait client.close();\n\t\t\t} catch {\n\t\t\t\t// already dead\n\t\t\t}\n\t\t}\n\t}\n\n\t/** Persist state and shut the daemon down. Safe to call twice. */\n\tasync dispose(): Promise<void> {\n\t\tif (this.disposed) return;\n\t\tthis.disposed = true;\n\t\tif (this.client && !this.client.isClosed) {\n\t\t\ttry {\n\t\t\t\tawait this.client.save();\n\t\t\t} catch {\n\t\t\t\t// daemon may have exited; nothing to save\n\t\t\t}\n\t\t}\n\t\tawait this.closeClient();\n\t}\n}\n\n// --- Per-cwd service registry ---\n//\n// The search tool is constructed by the generic tool factory table and\n// only receives `cwd`; the service is created later during session init (flag\n// gated). This registry connects the two without threading a service instance\n// through every layer between main.ts and the tool factories.\n\nconst services = new Map<string, EmbsearchService>();\n\nexport function registerEmbsearchService(cwd: string, service: EmbsearchService): void {\n\tconst old = services.get(cwd);\n\tif (old && old !== service) {\n\t\told.dispose().catch(() => {});\n\t}\n\tservices.set(cwd, service);\n}\n\nexport function getEmbsearchService(cwd: string): EmbsearchService | undefined {\n\treturn services.get(cwd);\n}\n\nexport function unregisterEmbsearchService(cwd: string): void {\n\tservices.delete(cwd);\n}\n"]}
@@ -52,6 +52,15 @@ export interface EvalProvenance {
52
52
  * {@link CORPUS_EXCLUSIONS}. A score against a different exclusion list is
53
53
  * a score against a different corpus, so it is recorded, not assumed. */
54
54
  corpusExcluded: string[];
55
+ /**
56
+ * Set only when the run scored a deliberately shrunk corpus.
57
+ *
58
+ * A smaller distractor pool makes every query easier, so these metrics are
59
+ * higher than a full-corpus run's and are **not** comparable to one. They
60
+ * are comparable to another subsampled run with the same target and seed,
61
+ * which is what makes this useful for screening model arms.
62
+ */
63
+ corpusSubsample?: CorpusSubsampleInfo;
55
64
  /** SHA of the tree whose retrieval code ran. Usually equals `corpusSha`,
56
65
  * but differs when pinning an old corpus with today's code. */
57
66
  harnessSha: string;
@@ -71,11 +80,25 @@ export interface EvalProvenance {
71
80
  /** Indexed chunk count when the index reached `ready`. */
72
81
  chunkCount?: number;
73
82
  phase: string;
74
- /** Binary that served the embeddings, and its self-reported version.
75
- * The embedding model is baked into the binary at build time, so this
76
- * is the only thing that identifies which model produced a score. */
83
+ /** Binary that served the embeddings, and its self-reported version. */
77
84
  binaryPath?: string;
78
85
  binaryVersion?: string;
86
+ /**
87
+ * Model id the daemon reported — the thing that actually identifies
88
+ * which model produced these scores.
89
+ *
90
+ * This used to be inferred from `binaryVersion`, on the reasoning that
91
+ * the model was baked into the binary at build time. `--model <dir>`
92
+ * ends that: one binary now serves any number of models, so two arms of
93
+ * a model comparison would have carried identical provenance and been
94
+ * indistinguishable in the record. The id is a hash over the model's
95
+ * whole spec (pooling, token limit, prefixes), so a change to any of
96
+ * them shows up here.
97
+ */
98
+ modelId?: string;
99
+ /** Model directory passed as `--model`, when the run overrode the
100
+ * bundled model. Absent means the binary's own model was used. */
101
+ modelDir?: string;
79
102
  };
80
103
  /** Daemon-side BM25 hybrid store, when the run included one. Absent means
81
104
  * the record has no `daemon-hybrid` rows. */
@@ -83,6 +106,34 @@ export interface EvalProvenance {
83
106
  available: boolean;
84
107
  phase: string;
85
108
  };
109
+ /**
110
+ * Wall time, split at the seam between building the index and scoring the
111
+ * gold set.
112
+ *
113
+ * Recorded because the cost side of a model comparison is almost entirely
114
+ * indexing, and a single total cannot show it: the first such comparison
115
+ * could only report "17 -> 60 min" for whole runs and had to note that the
116
+ * figure was "not isolated from query work", which left the headline cost
117
+ * of the change unmeasured. These two numbers are machine- and
118
+ * load-dependent and say nothing about retrieval quality; they are a budget,
119
+ * not a metric.
120
+ */
121
+ /**
122
+ * Chunker character cap, when an arm overrode it. Absent means the shipped
123
+ * `CHUNK_MAX_CHARS`. Records differing here are not comparable: the chunks
124
+ * are different text, so every id, span and vector differs.
125
+ */
126
+ chunkMaxChars?: number;
127
+ timing?: {
128
+ /** Seconds spent bringing the index(es) to `ready`, model load included. */
129
+ indexSeconds: number;
130
+ /** Seconds spent running every config over every gold query. */
131
+ querySeconds: number;
132
+ /** True when one hybrid store served both the dense and BM25 roles
133
+ * rather than the corpus being embedded twice. Runs with this false
134
+ * paid roughly double the indexing time. */
135
+ sharedStore: boolean;
136
+ };
86
137
  runtime: {
87
138
  node: string;
88
139
  platform: string;
@@ -116,9 +167,32 @@ export interface PinnedCorpus {
116
167
  /** Files removed from the corpus before indexing. Empty when the corpus is
117
168
  * the live working tree, which is never mutated. */
118
169
  excluded: string[];
170
+ /** Present only on a subsampled run. Its presence is what marks a record as
171
+ * incomparable to a full-corpus one. */
172
+ subsample?: CorpusSubsampleInfo;
119
173
  /** Removes the worktree, if one was created. */
120
174
  dispose: () => void;
121
175
  }
176
+ /** Request to shrink the corpus to a chunk budget. See {@link pinCorpus}. */
177
+ export interface CorpusSubsampleRequest {
178
+ /** Approximate chunk budget. Gold-bearing files are kept past it. */
179
+ targetChunks: number;
180
+ /** Files that must survive regardless of budget — the gold-bearing ones. */
181
+ keepRelPaths: readonly string[];
182
+ /** Seed for the distractor draw, so a budget reproduces exactly. */
183
+ seed: number;
184
+ }
185
+ /** What a subsampled run did, recorded so it can never be read as a full one. */
186
+ export interface CorpusSubsampleInfo {
187
+ targetChunks: number;
188
+ /** Chunks actually kept. Exceeds the target when gold files alone do. */
189
+ chunkCount: number;
190
+ filesKept: number;
191
+ filesDropped: number;
192
+ /** Gold-bearing files, all of which are kept unconditionally. */
193
+ goldFilesKept: number;
194
+ seed: number;
195
+ }
122
196
  /**
123
197
  * Files that describe this eval rather than being searched by it.
124
198
  *
@@ -145,9 +219,16 @@ export declare const CORPUS_EXCLUSIONS: readonly string[];
145
219
  * With a `ref`, checks out a detached worktree at that commit so the corpus is
146
220
  * byte-identical on every rerun. Without one, falls back to the live working
147
221
  * tree and reports `dirty` so the record shows the run was not reproducible.
222
+ *
223
+ * `subsample` shrinks the corpus to a chunk budget, for screening runs where a
224
+ * full arm costs too much to iterate on. It keeps every gold-bearing file and
225
+ * draws distractors deterministically. This is a real change to what is being
226
+ * measured — a smaller distractor pool makes retrieval easier and inflates
227
+ * every metric — so it is recorded in the record and folded into the worktree
228
+ * path, and it needs a `ref`.
148
229
  */
149
- export declare function pinCorpus(repoRoot: string, ref: string | undefined): PinnedCorpus;
150
- export declare function collectProvenance(repoRoot: string, corpus: PinnedCorpus, corpusRef: string, service: EmbsearchService | undefined, embsearchBinary?: string, hybridService?: EmbsearchService): EvalProvenance;
230
+ export declare function pinCorpus(repoRoot: string, ref: string | undefined, subsample?: CorpusSubsampleRequest): PinnedCorpus;
231
+ export declare function collectProvenance(repoRoot: string, corpus: PinnedCorpus, corpusRef: string, service: EmbsearchService | undefined, embsearchBinary?: string, hybridService?: EmbsearchService, modelDir?: string, timing?: EvalProvenance["timing"], chunkMaxChars?: number): EvalProvenance;
151
232
  export declare function summarizeGoldSet(dataset: readonly EvalQuery[]): EvalRunRecord["goldSet"];
152
233
  export interface RunEvalSuiteOptions {
153
234
  cwd: string;