@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.
- package/CHANGELOG.md +32 -0
- package/dist/core/canvas/lifecycle.d.ts.map +1 -1
- package/dist/core/canvas/lifecycle.js +1 -1
- package/dist/core/canvas/lifecycle.js.map +1 -1
- package/dist/core/embsearch/chunker.d.ts +17 -1
- package/dist/core/embsearch/chunker.d.ts.map +1 -1
- package/dist/core/embsearch/chunker.js +20 -6
- package/dist/core/embsearch/chunker.js.map +1 -1
- package/dist/core/embsearch/client.d.ts +9 -0
- package/dist/core/embsearch/client.d.ts.map +1 -1
- package/dist/core/embsearch/client.js +2 -0
- package/dist/core/embsearch/client.js.map +1 -1
- package/dist/core/embsearch/embsearch-service.d.ts +37 -0
- package/dist/core/embsearch/embsearch-service.d.ts.map +1 -1
- package/dist/core/embsearch/embsearch-service.js +86 -7
- package/dist/core/embsearch/embsearch-service.js.map +1 -1
- package/dist/core/search/eval-harness.d.ts +86 -5
- package/dist/core/search/eval-harness.d.ts.map +1 -1
- package/dist/core/search/eval-harness.js +143 -3
- package/dist/core/search/eval-harness.js.map +1 -1
- package/dist/modes/interactive/components/session-selector.d.ts.map +1 -1
- package/dist/modes/interactive/components/session-selector.js +1 -1
- package/dist/modes/interactive/components/session-selector.js.map +1 -1
- package/examples/extensions/custom-provider-anthropic/package.json +1 -1
- package/examples/extensions/custom-provider-gitlab-duo/package.json +1 -1
- package/examples/extensions/sandbox/package.json +1 -1
- package/examples/extensions/with-deps/package.json +1 -1
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,OAAO,EAAE,YAAY,EAAE,MAAM,eAAe,CAAC;AAC7C,OAAO,EAAE,YAAY,EAAE,MAAM,EAAE,MAAM,IAAI,CAAC;AAC1C,OAAO,EAAE,SAAS,EAAE,MAAM,WAAW,CAAC;AACtC,OAAO,EAAE,UAAU,EAAE,MAAM,8BAA8B,CAAC;AAC1D,OAAO,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AACzC,OAAO,EAEN,eAAe,GAIf,MAAM,aAAa,CAAC;AACrB,OAAO,EACN,cAAc,EAEd,oBAAoB,EACpB,iBAAiB,EACjB,WAAW,EAEX,aAAa,EACb,aAAa,GACb,MAAM,iBAAiB,CAAC;AACzB,OAAO,EAAqB,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AAE7D;oDACoD;AACpD,MAAM,eAAe,GAAG,EAAE,CAAC;AAC3B,+EAA+E;AAC/E,MAAM,cAAc,GAAG,EAAE,CAAC;AAC1B,0FAAwF;AACxF,MAAM,aAAa,GAAG,cAAc,CAAC;AACrC;;;;;;;GAOG;AACH,MAAM,6BAA6B,GAAG,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC,CAAU,CAAC;AACzD;;;;;;;;;GASG;AACH,MAAM,kBAAkB,GAAG,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC,CAAU,CAAC;AAE9C,0EAA0E;AAC1E,SAAS,kBAAkB,CAAC,MAAc,EAAwB;IACjE,MAAM,KAAK,GAAG,MAAM,CAAC,KAAK,CAAC,qBAAqB,CAAC,CAAC;IAClD,OAAO,KAAK,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,EAAE,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,EAAE,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC;AAAA,CAClF;AAED,SAAS,OAAO,CAAC,OAA0B,EAAE,OAA0B,EAAW;IACjF,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,EAAE,EAAE,CAAC;QACzC,MAAM,IAAI,GAAG,OAAO,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC;QAC7B,IAAI,IAAI,KAAK,OAAO,CAAC,CAAC,CAAC;YAAE,OAAO,IAAI,GAAG,OAAO,CAAC,CAAC,CAAC,CAAC;IACnD,CAAC;IACD,OAAO,IAAI,CAAC;AAAA,CACZ;AA+CD,MAAM,OAAO,gBAAgB;IACX,OAAO,CAA0B;IAC1C,MAAM,CAA8B;IACpC,IAAI,CAAwB;IAC5B,KAAK,GAAmB,EAAE,KAAK,EAAE,MAAM,EAAE,CAAC;IAC1C,QAAQ,GAAG,KAAK,CAAC;IACzB,iEAAiE;IACzD,gBAAgB,GAAG,KAAK,CAAC;IACjC,8DAA8D;IACtD,WAAW,GAAG,KAAK,CAAC;IAC5B,wEAAwE;IAChE,YAAY,GAAG,KAAK,CAAC;IAE7B,YAAY,OAAgC,EAAE;QAC7C,IAAI,CAAC,OAAO,GAAG,OAAO,CAAC;IAAA,CACvB;IAED,QAAQ,GAAmB;QAC1B,OAAO,IAAI,CAAC,KAAK,CAAC;IAAA,CAClB;IAED,oFAAoF;IACpF,WAAW,GAAY;QACtB,OAAO,IAAI,CAAC,KAAK,CAAC,KAAK,KAAK,OAAO,IAAI,IAAI,CAAC,KAAK,CAAC,KAAK,KAAK,UAAU,CAAC;IAAA,CACvE;IAEO,QAAQ,CAAC,KAAqB,EAAQ;QAC7C,IAAI,CAAC,KAAK,GAAG,KAAK,CAAC;QACnB,IAAI,CAAC,OAAO,CAAC,UAAU,EAAE,CAAC,KAAK,CAAC,CAAC;IAAA,CACjC;IAED;;;OAGG;IACH,wBAAwB,GAAY;QACnC,OAAO,IAAI,CAAC,gBAAgB,IAAI,IAAI,CAAC,WAAW,CAAC;IAAA,CACjD;IAED;;;;;;;;;;;;;;;;;;;OAmBG;IACH,UAAU,CAAC,MAAoB,EAAY;QAC1C,IAAI,CAAC,IAAI,CAAC,IAAI;YAAE,OAAO,EAAE,CAAC;QAC1B,MAAM,IAAI,GAAG,IAAI,CAAC,IAAI,CAAC;QACvB,MAAM,KAAK,GAAa,EAAE,CAAC;QAC3B,IAAI,CAAC;YACJ,KAAK,MAAM,IAAI,IAAI,QAAQ,CAAC,IAAI,CAAC,OAAO,CAAC,GAAG,EAAE,MAAM,CAAC,CAAC,KAAK,EAAE,CAAC;gBAC7D,MAAM,KAAK,GAAG,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;gBACnC,IAAI,CAAC,KAAK,IAAI,KAAK,CAAC,OAAO,KAAK,IAAI,CAAC,OAAO,IAAI,KAAK,CAAC,IAAI,KAAK,IAAI,CAAC,IAAI;oBAAE,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;YAChG,CAAC;QACF,CAAC;QAAC,MAAM,CAAC;YACR,kEAAkE;YAClE,uDAAuD;YACvD,OAAO,EAAE,CAAC;QACX,CAAC;QACD,OAAO,KAAK,CAAC;IAAA,CACb;IAED,iEAAiE;IACjE,oBAAoB,GAAY;QAC/B,OAAO,IAAI,CAAC,YAAY,CAAC;IAAA,CACzB;IAED;;;;;;OAMG;IACH,KAAK,CAAC,MAAM,CAAC,KAAa,EAAE,QAAkC,EAAE,CAAS,EAAoC;QAC5G,IAAI,CAAC,IAAI,CAAC,MAAM,IAAI,IAAI,CAAC,MAAM,CAAC,QAAQ,EAAE,CAAC;YAC1C,MAAM,IAAI,KAAK,CAAC,iCAAiC,CAAC,CAAC;QACpD,CAAC;QACD,IAAI,CAAC,IAAI,CAAC,YAAY,EAAE,CAAC;YACxB,MAAM,IAAI,KAAK,CACd,iDAAiD,kBAAkB,CAAC,IAAI,CAAC,GAAG,CAAC,aAAa;gBACzF,6FAA2F;gBAC3F,6BAA6B,CAC9B,CAAC;QACH,CAAC;QACD,OAAO,MAAM,IAAI,CAAC,MAAM,CAAC,MAAM,CAAC,KAAK,EAAE,QAAQ,EAAE,CAAC,CAAC,CAAC;IAAA,CACpD;IAEO,kBAAkB,CAAC,MAAc,EAAwB;QAChE,IAAI,CAAC;YACJ,OAAO,kBAAkB,CAAC,YAAY,CAAC,MAAM,EAAE,CAAC,WAAW,CAAC,EAAE,EAAE,QAAQ,EAAE,OAAO,EAAE,OAAO,EAAE,MAAM,EAAE,CAAC,CAAC,CAAC;QACxG,CAAC;QAAC,MAAM,CAAC;YACR,OAAO,SAAS,CAAC;QAClB,CAAC;IAAA,CACD;IAEO,KAAK,CAAC,aAAa,GAAgC;QAC1D,IAAI,IAAI,CAAC,OAAO,CAAC,UAAU,EAAE,CAAC;YAC7B,OAAO,IAAI,CAAC,OAAO,CAAC,UAAU,CAAC;QAChC,CAAC;QACD,6EAA6E;QAC7E,8EAA8E;QAC9E,gEAAgE;QAChE,OAAO,MAAM,UAAU,CAAC,WAAW,EAAE,IAAI,EAAE,CAAC,aAAa,EAAE,UAAU,EAAE,EAAE,CAAC;YACzE,IAAI,CAAC,QAAQ,CAAC,EAAE,KAAK,EAAE,aAAa,EAAE,aAAa,EAAE,UAAU,EAAE,CAAC,CAAC;QAAA,CACnE,CAAC,CAAC;IAAA,CACH;IAED;;;OAGG;IACH,KAAK,CAAC,KAAK,CAAC,MAAoB,EAAiB;QAChD,IAAI,CAAC;YACJ,MAAM,IAAI,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC;QACxB,CAAC;QAAC,OAAO,CAAC,EAAE,CAAC;YACZ,MAAM,MAAM,GAAG,CAAC,YAAY,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC;YAC1D,IAAI,CAAC,QAAQ,CAAC,EAAE,KAAK,EAAE,aAAa,EAAE,MAAM,EAAE,CAAC,CAAC;YAChD,MAAM,IAAI,CAAC,WAAW,EAAE,CAAC;QAC1B,CAAC;IAAA,CACD;IAEO,KAAK,CAAC,GAAG,CAAC,MAAoB,EAAiB;QACtD,MAAM,MAAM,GAAG,MAAM,IAAI,CAAC,aAAa,EAAE,CAAC;QAC1C,IAAI,CAAC,MAAM,EAAE,CAAC;YACb,IAAI,CAAC,QAAQ,CAAC;gBACb,KAAK,EAAE,aAAa;gBACpB,MAAM,EAAE,kEAAkE;aAC1E,CAAC,CAAC;YACH,OAAO;QACR,CAAC;QAED,MAAM,IAAI,GAAG,QAAQ,CAAC,IAAI,CAAC,OAAO,CAAC,GAAG,EAAE,MAAM,CAAC,CAAC;QAChD,IAAI,IAAI,CAAC,UAAU,GAAG,IAAI,CAAC,OAAO,CAAC,cAAc,EAAE,CAAC;YACnD,IAAI,CAAC,QAAQ,CAAC;gBACb,KAAK,EAAE,SAAS;gBAChB,MAAM,EAAE,yBAAyB,IAAI,CAAC,UAAU,MAAM,IAAI,CAAC,OAAO,CAAC,cAAc,SAAS;aAC1F,CAAC,CAAC;YACH,OAAO;QACR,CAAC;QAED,MAAM,aAAa,GAAG,IAAI,CAAC,kBAAkB,CAAC,MAAM,CAAC,CAAC;QACtD,IAAI,CAAC,gBAAgB,GAAG,aAAa,KAAK,SAAS,IAAI,OAAO,CAAC,aAAa,EAAE,6BAA6B,CAAC,CAAC;QAC7G,yEAAyE;QACzE,uEAAuE;QACvE,IAAI,CAAC,YAAY,GAAG,aAAa,KAAK,SAAS,IAAI,OAAO,CAAC,aAAa,EAAE,kBAAkB,CAAC,CAAC;QAE9F,MAAM,QAAQ,GAAG,IAAI,CAAC,OAAO,CAAC,QAAQ,IAAI,oBAAoB,CAAC,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC;QACjF,yEAAyE;QACzE,0EAA0E;QAC1E,wEAAwE;QACxE,MAAM,UAAU,GAAG,IAAI,CAAC,OAAO,CAAC,WAAW,IAAI,IAAI,CAAC,gBAAgB,CAAC;QAErE,MAAM,UAAU,GAAG,KAAK,IAAkC,EAAE,CAAC;YAC5D,IAAI,CAAC,MAAM,GAAG,IAAI,eAAe,CAAC;gBACjC,UAAU,EAAE,MAAM;gBAClB,SAAS,EAAE,iBAAiB,CAAC,QAAQ,CAAC;gBACtC,MAAM,EAAE,UAAU;aAClB,CAAC,CAAC;YACH,MAAM,IAAI,CAAC,MAAM,CAAC,KAAK,EAAE,CAAC;YAC1B,OAAO,MAAM,IAAI,CAAC,MAAM,CAAC,IAAI,EAAE,CAAC;QAAA,CAChC,CAAC;QAEF,IAAI,IAAI,GAAG,MAAM,UAAU,EAAE,CAAC;QAE9B,yEAAyE;QACzE,yEAAyE;QACzE,yEAAyE;QACzE,yEAAyE;QACzE,wEAAwE;QACxE,6EAA2E;QAC3E,IAAI,UAAU,IAAI,IAAI,CAAC,MAAM,KAAK,KAAK,EAAE,CAAC;YACzC,IAAI,CAAC,QAAQ,CAAC,EAAE,KAAK,EAAE,UAAU,EAAE,IAAI,EAAE,CAAC,EAAE,KAAK,EAAE,CAAC,EAAE,CAAC,CAAC;YACxD,MAAM,IAAI,CAAC,WAAW,EAAE,CAAC;YACzB,MAAM,CAAC,QAAQ,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,KAAK,EAAE,IAAI,EAAE,CAAC,CAAC;YACnD,IAAI,GAAG,MAAM,UAAU,EAAE,CAAC;QAC3B,CAAC;QACD,IAAI,CAAC,WAAW,GAAG,IAAI,CAAC,MAAM,KAAK,IAAI,CAAC;QACxC,2EAAyE;QACzE,uEAAuE;QACvE,IAAI,IAAI,CAAC,MAAM,KAAK,KAAK;YAAE,IAAI,CAAC,YAAY,GAAG,KAAK,CAAC;QACrD,IAAI,IAAI,CAAC,OAAO,KAAK,aAAa,EAAE,CAAC;YACpC,MAAM,IAAI,KAAK,CAAC,+EAA+E,CAAC,CAAC;QAClG,CAAC;QAED,+EAA6E;QAC7E,IAAI,CAAC,IAAI,GAAG,aAAa,CAAC,QAAQ,EAAE,IAAI,CAAC,OAAO,CAAC,IAAI,cAAc,CAAC,IAAI,CAAC,OAAO,CAAC,GAAG,EAAE,IAAI,CAAC,OAAO,CAAC,CAAC;QACpG,IAAI,CAAC,IAAI,CAAC,UAAU,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC;QAElC,MAAM,IAAI,CAAC,iBAAiB,CAAC,IAAI,CAAC,KAAK,EAAE,QAAQ,EAAE,MAAM,CAAC,CAAC;IAAA,CAC3D;IAEO,KAAK,CAAC,iBAAiB,CAAC,KAAqB,EAAE,QAAgB,EAAE,MAAoB,EAAiB;QAC7G,MAAM,IAAI,GAAG,IAAI,CAAC,IAAK,CAAC;QACxB,MAAM,MAAM,GAAG,IAAI,CAAC,MAAO,CAAC;QAE5B,0EAA0E;QAC1E,MAAM,OAAO,GAAiE,EAAE,CAAC;QACjF,MAAM,IAAI,GAAG,IAAI,GAAG,EAAU,CAAC;QAC/B,KAAK,MAAM,IAAI,IAAI,KAAK,EAAE,CAAC;YAC1B,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;YACnB,MAAM,KAAK,GAAG,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;YACnC,IAAI,KAAK,IAAI,KAAK,CAAC,OAAO,KAAK,IAAI,CAAC,OAAO,IAAI,KAAK,CAAC,IAAI,KAAK,IAAI,CAAC,IAAI;gBAAE,SAAS;YAClF,IAAI,OAAe,CAAC;YACpB,IAAI,CAAC;gBACJ,OAAO,GAAG,YAAY,CAAC,IAAI,CAAC,GAAG,EAAE,OAAO,CAAC,CAAC;YAC3C,CAAC;YAAC,MAAM,CAAC;gBACR,SAAS;YACV,CAAC;YACD,MAAM,IAAI,GAAG,WAAW,CAAC,OAAO,CAAC,CAAC;YAClC,IAAI,KAAK,IAAI,KAAK,CAAC,IAAI,KAAK,IAAI,EAAE,CAAC;gBAClC,oDAAkD;gBAClD,KAAK,CAAC,OAAO,GAAG,IAAI,CAAC,OAAO,CAAC;gBAC7B,KAAK,CAAC,IAAI,GAAG,IAAI,CAAC,IAAI,CAAC;gBACvB,SAAS;YACV,CAAC;YACD,OAAO,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC,CAAC;QACvC,CAAC;QACD,MAAM,QAAQ,GAAG,MAAM,CAAC,IAAI,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,MAAM,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC,CAAC;QAEzE,IAAI,OAAO,CAAC,MAAM,KAAK,CAAC,IAAI,QAAQ,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;YACnD,aAAa,CAAC,QAAQ,EAAE,IAAI,CAAC,CAAC;YAC9B,IAAI,CAAC,QAAQ,CAAC,EAAE,KAAK,EAAE,OAAO,EAAE,UAAU,EAAE,IAAI,CAAC,WAAW,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;YACtE,OAAO;QACR,CAAC;QAED,+DAA+D;QAC/D,MAAM,IAAI,GAA4F,EAAE,CAAC;QACzG,IAAI,WAAW,GAAG,CAAC,CAAC;QACpB,KAAK,MAAM,EAAE,IAAI,EAAE,OAAO,EAAE,IAAI,EAAE,IAAI,OAAO,EAAE,CAAC;YAC/C,MAAM,MAAM,GAAG,SAAS,CAAC,IAAI,CAAC,GAAG,EAAE,OAAO,CAAC,CAAC;YAC5C,IAAI,CAAC,IAAI,CAAC;gBACT,GAAG,EAAE,IAAI,CAAC,GAAG;gBACb,QAAQ,EAAE;oBACT,OAAO,EAAE,IAAI,CAAC,OAAO;oBACrB,IAAI,EAAE,IAAI,CAAC,IAAI;oBACf,IAAI;oBACJ,MAAM,EAAE,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,CAAC,SAAS,EAAE,CAAC,CAAC,OAAO,CAAC,CAAC;iBACnD;gBACD,MAAM,EAAE,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,EAAE,EAAE,EAAE,CAAC,CAAC,EAAE,EAAE,IAAI,EAAE,CAAC,CAAC,IAAI,EAAE,CAAC,CAAC;aACvD,CAAC,CAAC;YACH,WAAW,IAAI,MAAM,CAAC,MAAM,CAAC;QAC9B,CAAC;QAED,IAAI,CAAC,QAAQ,CAAC,EAAE,KAAK,EAAE,UAAU,EAAE,IAAI,EAAE,CAAC,EAAE,KAAK,EAAE,WAAW,EAAE,CAAC,CAAC;QAElE,4DAA4D;QAC5D,KAAK,MAAM,GAAG,IAAI,QAAQ,EAAE,CAAC;YAC5B,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,MAAM,CAAC,MAAM,EAAE,CAAC,EAAE;gBAAE,MAAM,MAAM,CAAC,MAAM,CAAC,GAAG,GAAG,IAAI,CAAC,EAAE,CAAC,CAAC;YAC3F,OAAO,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC;QACxB,CAAC;QAED,IAAI,IAAI,GAAG,CAAC,CAAC;QACb,KAAK,MAAM,IAAI,IAAI,IAAI,EAAE,CAAC;YACzB,IAAI,MAAM,EAAE,OAAO,IAAI,IAAI,CAAC,QAAQ;gBAAE,OAAO;YAC7C,MAAM,aAAa,GAAG,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,GAAG,CAAC,EAAE,MAAM,CAAC,MAAM,IAAI,CAAC,CAAC;YAC/D,mEAAmE;YACnE,KAAK,IAAI,CAAC,GAAG,IAAI,CAAC,MAAM,CAAC,MAAM,EAAE,CAAC,GAAG,aAAa,EAAE,CAAC,EAAE;gBAAE,MAAM,MAAM,CAAC,MAAM,CAAC,GAAG,IAAI,CAAC,GAAG,IAAI,CAAC,EAAE,CAAC,CAAC;YAEjG,KAAK,IAAI,MAAM,GAAG,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,IAAI,eAAe,EAAE,CAAC;gBAC7E,IAAI,MAAM,EAAE,OAAO,IAAI,IAAI,CAAC,QAAQ;oBAAE,OAAO;gBAC7C,MAAM,KAAK,GAAG,IAAI,CAAC,MAAM,CAAC,KAAK,CAAC,MAAM,EAAE,MAAM,GAAG,eAAe,CAAC,CAAC;gBAClE,MAAM,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;gBACzB,IAAI,IAAI,KAAK,CAAC,MAAM,CAAC;gBACrB,IAAI,CAAC,QAAQ,CAAC,EAAE,KAAK,EAAE,UAAU,EAAE,IAAI,EAAE,KAAK,EAAE,WAAW,EAAE,CAAC,CAAC;gBAC/D,uDAAuD;gBACvD,MAAM,IAAI,OAAO,CAAC,CAAC,OAAO,EAAE,EAAE,CAAC,UAAU,CAAC,OAAO,EAAE,cAAc,CAAC,CAAC,CAAC;YACrE,CAAC;YACD,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,GAAG,CAAC,GAAG,IAAI,CAAC,QAAQ,CAAC;QACtC,CAAC;QAED,MAAM,MAAM,CAAC,OAAO,EAAE,CAAC;QACvB,MAAM,MAAM,CAAC,IAAI,EAAE,CAAC;QACpB,aAAa,CAAC,QAAQ,EAAE,IAAI,CAAC,CAAC;QAC9B,IAAI,CAAC,QAAQ,CAAC,EAAE,KAAK,EAAE,OAAO,EAAE,UAAU,EAAE,IAAI,CAAC,WAAW,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;IAAA,CACtE;IAEO,WAAW,CAAC,IAAe,EAAU;QAC5C,IAAI,CAAC,GAAG,CAAC,CAAC;QACV,KAAK,MAAM,GAAG,IAAI,MAAM,CAAC,IAAI,CAAC,IAAI,CAAC,KAAK,CAAC;YAAE,CAAC,IAAI,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,MAAM,CAAC,MAAM,CAAC;QAC9E,OAAO,CAAC,CAAC;IAAA,CACT;IAED,4DAA4D;IAC5D,KAAK,CAAC,MAAM,CAAC,KAAa,EAAE,CAAC,GAAG,EAAE,EAA0B;QAC3D,OAAO,MAAM,IAAI,CAAC,YAAY,CAAC,KAAK,EAAE,CAAC,CAAC,CAAC;IAAA,CACzC;IAED,wEAAwE;IACxE,KAAK,CAAC,YAAY,CACjB,KAAa,EACb,CAAC,GAAG,EAAE,EACN,IAAa,EACb,SAAS,GAAoB,OAAO,EACN;QAC9B,IAAI,CAAC,IAAI,CAAC,MAAM,IAAI,IAAI,CAAC,MAAM,CAAC,QAAQ,IAAI,CAAC,IAAI,CAAC,IAAI,EAAE,CAAC;YACxD,MAAM,IAAI,KAAK,CAAC,iCAAiC,CAAC,CAAC;QACpD,CAAC;QACD,IAAI,SAAS,KAAK,SAAS,IAAI,CAAC,IAAI,CAAC,gBAAgB,EAAE,CAAC;YACvD,MAAM,IAAI,KAAK,CACd,0DAA0D,6BAA6B,CAAC,IAAI,CAAC,GAAG,CAAC,KAAK;gBACrG,kEAAkE,CACnE,CAAC;QACH,CAAC;QACD,MAAM,OAAO,GAAG,MAAM,IAAI,CAAC,MAAM,CAAC,KAAK,CAAC,KAAK,EAAE,CAAC,EAAE,SAAS,CAAC,CAAC;QAC7D,MAAM,IAAI,GAAuB,EAAE,CAAC;QACpC,MAAM,SAAS,GAAG,CAAC,GAAW,EAAW,EAAE,CAAC;YAC3C,IAAI,CAAC,IAAI;gBAAE,OAAO,IAAI,CAAC;YACvB,OAAO,SAAS,CAAC,GAAG,EAAE,IAAI,EAAE,EAAE,GAAG,EAAE,IAAI,EAAE,SAAS,EAAE,CAAC,IAAI,CAAC,QAAQ,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC;QAAA,CAC3E,CAAC;QACF,KAAK,MAAM,MAAM,IAAI,OAAO,EAAE,CAAC;YAC9B,MAAM,GAAG,GAAG,MAAM,CAAC,EAAE,CAAC,WAAW,CAAC,GAAG,CAAC,CAAC;YACvC,IAAI,GAAG,KAAK,CAAC,CAAC;gBAAE,SAAS;YACzB,MAAM,GAAG,GAAG,MAAM,CAAC,EAAE,CAAC,KAAK,CAAC,CAAC,EAAE,GAAG,CAAC,CAAC;YACpC,IAAI,CAAC,SAAS,CAAC,GAAG,CAAC;gBAAE,SAAS;YAC9B,MAAM,UAAU,GAAG,MAAM,CAAC,QAAQ,CAAC,MAAM,CAAC,EAAE,CAAC,KAAK,CAAC,GAAG,GAAG,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC;YACjE,MAAM,KAAK,GAAG,IAAI,CAAC,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,EAAE,MAAM,CAAC,UAAU,CAAC,CAAC;YACvD,IAAI,CAAC,KAAK;gBAAE,SAAS;YACrB,IAAI,CAAC,IAAI,CAAC,EAAE,EAAE,EAAE,MAAM,CAAC,EAAE,EAAE,IAAI,EAAE,GAAG,EAAE,SAAS,EAAE,KAAK,CAAC,CAAC,CAAC,EAAE,OAAO,EAAE,KAAK,CAAC,CAAC,CAAC,EAAE,KAAK,EAAE,MAAM,CAAC,KAAK,EAAE,CAAC,CAAC;QACtG,CAAC;QACD,OAAO,IAAI,CAAC;IAAA,CACZ;IAED;;;;;OAKG;IACH,kBAAkB,CACjB,GAAW,EACX,IAAY,EACmE;QAC/E,MAAM,IAAI,GAAG,IAAI,CAAC,IAAI,EAAE,KAAK,CAAC,GAAG,CAAC,CAAC;QACnC,IAAI,CAAC,IAAI;YAAE,OAAO,SAAS,CAAC;QAC5B,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,IAAI,CAAC,MAAM,CAAC,MAAM,EAAE,CAAC,EAAE,EAAE,CAAC;YAC7C,MAAM,CAAC,SAAS,EAAE,OAAO,CAAC,GAAG,IAAI,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC;YAC5C,IAAI,IAAI,IAAI,SAAS,IAAI,IAAI,IAAI,OAAO,EAAE,CAAC;gBAC1C,OAAO,EAAE,EAAE,EAAE,GAAG,GAAG,IAAI,CAAC,EAAE,EAAE,IAAI,EAAE,GAAG,EAAE,SAAS,EAAE,OAAO,EAAE,CAAC;YAC7D,CAAC;QACF,CAAC;QACD,OAAO,SAAS,CAAC;IAAA,CACjB;IAEO,KAAK,CAAC,WAAW,GAAkB;QAC1C,MAAM,MAAM,GAAG,IAAI,CAAC,MAAM,CAAC;QAC3B,IAAI,CAAC,MAAM,GAAG,SAAS,CAAC;QACxB,IAAI,MAAM,IAAI,CAAC,MAAM,CAAC,QAAQ,EAAE,CAAC;YAChC,IAAI,CAAC;gBACJ,MAAM,MAAM,CAAC,KAAK,EAAE,CAAC;YACtB,CAAC;YAAC,MAAM,CAAC;gBACR,eAAe;YAChB,CAAC;QACF,CAAC;IAAA,CACD;IAED,kEAAkE;IAClE,KAAK,CAAC,OAAO,GAAkB;QAC9B,IAAI,IAAI,CAAC,QAAQ;YAAE,OAAO;QAC1B,IAAI,CAAC,QAAQ,GAAG,IAAI,CAAC;QACrB,IAAI,IAAI,CAAC,MAAM,IAAI,CAAC,IAAI,CAAC,MAAM,CAAC,QAAQ,EAAE,CAAC;YAC1C,IAAI,CAAC;gBACJ,MAAM,IAAI,CAAC,MAAM,CAAC,IAAI,EAAE,CAAC;YAC1B,CAAC;YAAC,MAAM,CAAC;gBACR,0CAA0C;YAC3C,CAAC;QACF,CAAC;QACD,MAAM,IAAI,CAAC,WAAW,EAAE,CAAC;IAAA,CACzB;CACD;AAED,mCAAmC;AACnC,EAAE;AACF,uEAAuE;AACvE,8EAA8E;AAC9E,8EAA8E;AAC9E,8DAA8D;AAE9D,MAAM,QAAQ,GAAG,IAAI,GAAG,EAA4B,CAAC;AAErD,MAAM,UAAU,wBAAwB,CAAC,GAAW,EAAE,OAAyB,EAAQ;IACtF,MAAM,GAAG,GAAG,QAAQ,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;IAC9B,IAAI,GAAG,IAAI,GAAG,KAAK,OAAO,EAAE,CAAC;QAC5B,GAAG,CAAC,OAAO,EAAE,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC,EAAC,CAAC,CAAC,CAAC;IAC/B,CAAC;IACD,QAAQ,CAAC,GAAG,CAAC,GAAG,EAAE,OAAO,CAAC,CAAC;AAAA,CAC3B;AAED,MAAM,UAAU,mBAAmB,CAAC,GAAW,EAAgC;IAC9E,OAAO,QAAQ,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;AAAA,CACzB;AAED,MAAM,UAAU,0BAA0B,CAAC,GAAW,EAAQ;IAC7D,QAAQ,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC;AAAA,CACrB","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":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,OAAO,EAAE,YAAY,EAAE,MAAM,eAAe,CAAC;AAC7C,OAAO,EAAE,YAAY,EAAE,MAAM,EAAE,MAAM,IAAI,CAAC;AAC1C,OAAO,EAAE,SAAS,EAAE,MAAM,WAAW,CAAC;AACtC,OAAO,EAAE,UAAU,EAAE,MAAM,8BAA8B,CAAC;AAC1D,OAAO,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AACzC,OAAO,EAEN,eAAe,GAIf,MAAM,aAAa,CAAC;AACrB,OAAO,EACN,cAAc,EAEd,oBAAoB,EACpB,iBAAiB,EACjB,WAAW,EAEX,aAAa,EACb,aAAa,GACb,MAAM,iBAAiB,CAAC;AACzB,OAAO,EAAqB,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AAE7D;oDACoD;AACpD,MAAM,eAAe,GAAG,EAAE,CAAC;AAC3B,+EAA+E;AAC/E,MAAM,cAAc,GAAG,EAAE,CAAC;AAC1B,0FAAwF;AACxF,MAAM,aAAa,GAAG,cAAc,CAAC;AACrC;;;;;;;GAOG;AACH,MAAM,6BAA6B,GAAG,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC,CAAU,CAAC;AACzD;;;;;;;;;GASG;AACH,MAAM,kBAAkB,GAAG,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC,CAAU,CAAC;AAE9C,0EAA0E;AAC1E,SAAS,kBAAkB,CAAC,MAAc,EAAwB;IACjE,MAAM,KAAK,GAAG,MAAM,CAAC,KAAK,CAAC,qBAAqB,CAAC,CAAC;IAClD,OAAO,KAAK,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,EAAE,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,EAAE,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC;AAAA,CAClF;AAED,SAAS,OAAO,CAAC,OAA0B,EAAE,OAA0B,EAAW;IACjF,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,EAAE,EAAE,CAAC;QACzC,MAAM,IAAI,GAAG,OAAO,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC;QAC7B,IAAI,IAAI,KAAK,OAAO,CAAC,CAAC,CAAC;YAAE,OAAO,IAAI,GAAG,OAAO,CAAC,CAAC,CAAC,CAAC;IACnD,CAAC;IACD,OAAO,IAAI,CAAC;AAAA,CACZ;AAkED,MAAM,OAAO,gBAAgB;IACX,OAAO,CAA0B;IAC1C,MAAM,CAA8B;IACpC,IAAI,CAAwB;IAC5B,KAAK,GAAmB,EAAE,KAAK,EAAE,MAAM,EAAE,CAAC;IAC1C,QAAQ,GAAG,KAAK,CAAC;IACzB,iEAAiE;IACzD,gBAAgB,GAAG,KAAK,CAAC;IACjC,8DAA8D;IACtD,WAAW,GAAG,KAAK,CAAC;IAC5B,wEAAwE;IAChE,YAAY,GAAG,KAAK,CAAC;IAE7B,YAAY,OAAgC,EAAE;QAC7C,IAAI,CAAC,OAAO,GAAG,OAAO,CAAC;IAAA,CACvB;IAED,QAAQ,GAAmB;QAC1B,OAAO,IAAI,CAAC,KAAK,CAAC;IAAA,CAClB;IAED,oFAAoF;IACpF;;;;;OAKG;IACH,OAAO,GAAuB;QAC7B,OAAO,IAAI,CAAC,IAAI,EAAE,OAAO,CAAC;IAAA,CAC1B;IAED,WAAW,GAAY;QACtB,OAAO,IAAI,CAAC,KAAK,CAAC,KAAK,KAAK,OAAO,IAAI,IAAI,CAAC,KAAK,CAAC,KAAK,KAAK,UAAU,CAAC;IAAA,CACvE;IAEO,QAAQ,CAAC,KAAqB,EAAQ;QAC7C,IAAI,CAAC,KAAK,GAAG,KAAK,CAAC;QACnB,IAAI,CAAC,OAAO,CAAC,UAAU,EAAE,CAAC,KAAK,CAAC,CAAC;IAAA,CACjC;IAED;;;OAGG;IACH,wBAAwB,GAAY;QACnC,OAAO,IAAI,CAAC,gBAAgB,IAAI,IAAI,CAAC,WAAW,CAAC;IAAA,CACjD;IAED;;;;;;;;;;;;;;;;;;;OAmBG;IACH,UAAU,CAAC,MAAoB,EAAY;QAC1C,IAAI,CAAC,IAAI,CAAC,IAAI;YAAE,OAAO,EAAE,CAAC;QAC1B,MAAM,IAAI,GAAG,IAAI,CAAC,IAAI,CAAC;QACvB,MAAM,KAAK,GAAa,EAAE,CAAC;QAC3B,IAAI,CAAC;YACJ,KAAK,MAAM,IAAI,IAAI,QAAQ,CAAC,IAAI,CAAC,OAAO,CAAC,GAAG,EAAE,MAAM,CAAC,CAAC,KAAK,EAAE,CAAC;gBAC7D,MAAM,KAAK,GAAG,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;gBACnC,IAAI,CAAC,KAAK,IAAI,KAAK,CAAC,OAAO,KAAK,IAAI,CAAC,OAAO,IAAI,KAAK,CAAC,IAAI,KAAK,IAAI,CAAC,IAAI;oBAAE,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;YAChG,CAAC;QACF,CAAC;QAAC,MAAM,CAAC;YACR,kEAAkE;YAClE,uDAAuD;YACvD,OAAO,EAAE,CAAC;QACX,CAAC;QACD,OAAO,KAAK,CAAC;IAAA,CACb;IAED,iEAAiE;IACjE,oBAAoB,GAAY;QAC/B,OAAO,IAAI,CAAC,YAAY,CAAC;IAAA,CACzB;IAED;;;;;;OAMG;IACH,KAAK,CAAC,MAAM,CAAC,KAAa,EAAE,QAAkC,EAAE,CAAS,EAAoC;QAC5G,IAAI,CAAC,IAAI,CAAC,MAAM,IAAI,IAAI,CAAC,MAAM,CAAC,QAAQ,EAAE,CAAC;YAC1C,MAAM,IAAI,KAAK,CAAC,iCAAiC,CAAC,CAAC;QACpD,CAAC;QACD,IAAI,CAAC,IAAI,CAAC,YAAY,EAAE,CAAC;YACxB,MAAM,IAAI,KAAK,CACd,iDAAiD,kBAAkB,CAAC,IAAI,CAAC,GAAG,CAAC,aAAa;gBACzF,6FAA2F;gBAC3F,6BAA6B,CAC9B,CAAC;QACH,CAAC;QACD,OAAO,MAAM,IAAI,CAAC,MAAM,CAAC,MAAM,CAAC,KAAK,EAAE,QAAQ,EAAE,CAAC,CAAC,CAAC;IAAA,CACpD;IAEO,kBAAkB,CAAC,MAAc,EAAwB;QAChE,IAAI,CAAC;YACJ,OAAO,kBAAkB,CAAC,YAAY,CAAC,MAAM,EAAE,CAAC,WAAW,CAAC,EAAE,EAAE,QAAQ,EAAE,OAAO,EAAE,OAAO,EAAE,MAAM,EAAE,CAAC,CAAC,CAAC;QACxG,CAAC;QAAC,MAAM,CAAC;YACR,OAAO,SAAS,CAAC;QAClB,CAAC;IAAA,CACD;IAED;;;;;;;;;OASG;IACK,UAAU,CAAC,MAAc,EAAE,QAAgB,EAAiD;QACnG,IAAI,CAAC;YACJ,MAAM,GAAG,GAAG,YAAY,CAAC,MAAM,EAAE,CAAC,YAAY,EAAE,QAAQ,EAAE,iBAAiB,CAAC,QAAQ,CAAC,EAAE,QAAQ,CAAC,EAAE;gBACjG,QAAQ,EAAE,OAAO;gBACjB,OAAO,EAAE,MAAM;gBACf,KAAK,EAAE,CAAC,QAAQ,EAAE,MAAM,EAAE,QAAQ,CAAC;aACnC,CAAC,CAAC;YACH,MAAM,MAAM,GAAG,IAAI,CAAC,KAAK,CAAC,GAAG,CAAyC,CAAC;YACvE,IAAI,OAAO,MAAM,CAAC,QAAQ,KAAK,QAAQ;gBAAE,OAAO,SAAS,CAAC;YAC1D,OAAO,EAAE,OAAO,EAAE,MAAM,CAAC,QAAQ,EAAE,IAAI,EAAE,MAAM,CAAC,IAAI,IAAI,CAAC,EAAE,CAAC;QAC7D,CAAC;QAAC,MAAM,CAAC;YACR,OAAO,SAAS,CAAC;QAClB,CAAC;IAAA,CACD;IAEO,KAAK,CAAC,aAAa,GAAgC;QAC1D,IAAI,IAAI,CAAC,OAAO,CAAC,UAAU,EAAE,CAAC;YAC7B,OAAO,IAAI,CAAC,OAAO,CAAC,UAAU,CAAC;QAChC,CAAC;QACD,6EAA6E;QAC7E,8EAA8E;QAC9E,gEAAgE;QAChE,OAAO,MAAM,UAAU,CAAC,WAAW,EAAE,IAAI,EAAE,CAAC,aAAa,EAAE,UAAU,EAAE,EAAE,CAAC;YACzE,IAAI,CAAC,QAAQ,CAAC,EAAE,KAAK,EAAE,aAAa,EAAE,aAAa,EAAE,UAAU,EAAE,CAAC,CAAC;QAAA,CACnE,CAAC,CAAC;IAAA,CACH;IAED;;;OAGG;IACH,KAAK,CAAC,KAAK,CAAC,MAAoB,EAAiB;QAChD,IAAI,CAAC;YACJ,MAAM,IAAI,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC;QACxB,CAAC;QAAC,OAAO,CAAC,EAAE,CAAC;YACZ,MAAM,MAAM,GAAG,CAAC,YAAY,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC;YAC1D,IAAI,CAAC,QAAQ,CAAC,EAAE,KAAK,EAAE,aAAa,EAAE,MAAM,EAAE,CAAC,CAAC;YAChD,MAAM,IAAI,CAAC,WAAW,EAAE,CAAC;QAC1B,CAAC;IAAA,CACD;IAEO,KAAK,CAAC,GAAG,CAAC,MAAoB,EAAiB;QACtD,MAAM,MAAM,GAAG,MAAM,IAAI,CAAC,aAAa,EAAE,CAAC;QAC1C,IAAI,CAAC,MAAM,EAAE,CAAC;YACb,IAAI,CAAC,QAAQ,CAAC;gBACb,KAAK,EAAE,aAAa;gBACpB,MAAM,EAAE,kEAAkE;aAC1E,CAAC,CAAC;YACH,OAAO;QACR,CAAC;QAED,MAAM,IAAI,GAAG,QAAQ,CAAC,IAAI,CAAC,OAAO,CAAC,GAAG,EAAE,MAAM,CAAC,CAAC;QAChD,IAAI,IAAI,CAAC,UAAU,GAAG,IAAI,CAAC,OAAO,CAAC,cAAc,EAAE,CAAC;YACnD,IAAI,CAAC,QAAQ,CAAC;gBACb,KAAK,EAAE,SAAS;gBAChB,MAAM,EAAE,yBAAyB,IAAI,CAAC,UAAU,MAAM,IAAI,CAAC,OAAO,CAAC,cAAc,SAAS;aAC1F,CAAC,CAAC;YACH,OAAO;QACR,CAAC;QAED,MAAM,aAAa,GAAG,IAAI,CAAC,kBAAkB,CAAC,MAAM,CAAC,CAAC;QACtD,IAAI,CAAC,gBAAgB,GAAG,aAAa,KAAK,SAAS,IAAI,OAAO,CAAC,aAAa,EAAE,6BAA6B,CAAC,CAAC;QAC7G,yEAAyE;QACzE,uEAAuE;QACvE,IAAI,CAAC,YAAY,GAAG,aAAa,KAAK,SAAS,IAAI,OAAO,CAAC,aAAa,EAAE,kBAAkB,CAAC,CAAC;QAE9F,MAAM,QAAQ,GAAG,IAAI,CAAC,OAAO,CAAC,QAAQ,IAAI,oBAAoB,CAAC,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC;QACjF,yEAAyE;QACzE,0EAA0E;QAC1E,wEAAwE;QACxE,MAAM,UAAU,GAAG,IAAI,CAAC,OAAO,CAAC,WAAW,IAAI,IAAI,CAAC,gBAAgB,CAAC;QAErE,MAAM,UAAU,GAAG,KAAK,IAAkC,EAAE,CAAC;YAC5D,IAAI,CAAC,MAAM,GAAG,IAAI,eAAe,CAAC;gBACjC,UAAU,EAAE,MAAM;gBAClB,SAAS,EAAE,iBAAiB,CAAC,QAAQ,CAAC;gBACtC,MAAM,EAAE,UAAU;gBAClB,QAAQ,EAAE,IAAI,CAAC,OAAO,CAAC,QAAQ;aAC/B,CAAC,CAAC;YACH,MAAM,IAAI,CAAC,MAAM,CAAC,KAAK,EAAE,CAAC;YAC1B,OAAO,MAAM,IAAI,CAAC,MAAM,CAAC,IAAI,EAAE,CAAC;QAAA,CAChC,CAAC;QAEF;;qDAE6C;QAC7C,MAAM,WAAW,GAAG,KAAK,EAAE,GAAW,EAAgC,EAAE,CAAC;YACxE,OAAO,CAAC,KAAK,CAAC,oCAAoC,GAAG,GAAG,CAAC,CAAC;YAC1D,IAAI,CAAC,QAAQ,CAAC,EAAE,KAAK,EAAE,UAAU,EAAE,IAAI,EAAE,CAAC,EAAE,KAAK,EAAE,CAAC,EAAE,CAAC,CAAC;YACxD,MAAM,IAAI,CAAC,WAAW,EAAE,CAAC;YACzB,MAAM,CAAC,QAAQ,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,KAAK,EAAE,IAAI,EAAE,CAAC,CAAC;YACnD,OAAO,MAAM,UAAU,EAAE,CAAC;QAAA,CAC1B,CAAC;QAEF,IAAI,IAAyB,CAAC;QAC9B,IAAI,CAAC;YACJ,IAAI,GAAG,MAAM,UAAU,EAAE,CAAC;QAC3B,CAAC;QAAC,OAAO,GAAG,EAAE,CAAC;YACd,oEAAoE;YACpE,sEAAoE;YACpE,qEAAqE;YACrE,2DAA2D;YAC3D,qEAAqE;YACrE,EAAE;YACF,sEAAsE;YACtE,iEAAiE;YACjE,mEAAmE;YACnE,+BAA+B;YAC/B,MAAM,KAAK,GAAG,IAAI,CAAC,UAAU,CAAC,MAAM,EAAE,QAAQ,CAAC,CAAC;YAChD,IAAI,CAAC,KAAK;gBAAE,MAAM,GAAG,CAAC;YACtB,IAAI,GAAG,MAAM,WAAW,CAAC,mBAAmB,KAAK,CAAC,OAAO,kCAAkC,CAAC,CAAC;QAC9F,CAAC;QAED,yEAAyE;QACzE,yEAAyE;QACzE,yEAAyE;QACzE,yEAAyE;QACzE,wEAAwE;QACxE,6EAA2E;QAC3E,IAAI,UAAU,IAAI,IAAI,CAAC,MAAM,KAAK,KAAK,EAAE,CAAC;YACzC,IAAI,GAAG,MAAM,WAAW,CAAC,0CAA0C,CAAC,CAAC;QACtE,CAAC;QACD,IAAI,CAAC,WAAW,GAAG,IAAI,CAAC,MAAM,KAAK,IAAI,CAAC;QACxC,2EAAyE;QACzE,uEAAuE;QACvE,IAAI,IAAI,CAAC,MAAM,KAAK,KAAK;YAAE,IAAI,CAAC,YAAY,GAAG,KAAK,CAAC;QACrD,IAAI,IAAI,CAAC,OAAO,KAAK,aAAa,EAAE,CAAC;YACpC,MAAM,IAAI,KAAK,CAAC,+EAA+E,CAAC,CAAC;QAClG,CAAC;QAED,+EAA6E;QAC7E,EAAE;QACF,0EAA0E;QAC1E,wEAAwE;QACxE,0EAAwE;QACxE,wEAAwE;QACxE,mEAAmE;QACnE,oEAAkE;QAClE,oEAAoE;QACpE,qEAAqE;QACrE,EAAE;QACF,qEAAqE;QACrE,6BAA6B;QAC7B,IAAI,IAAI,GAAG,aAAa,CAAC,QAAQ,EAAE,IAAI,CAAC,OAAO,CAAC,CAAC;QACjD,IAAI,CAAC,IAAI,IAAI,IAAI,CAAC,KAAK,GAAG,CAAC,EAAE,CAAC;YAC7B,IAAI,GAAG,MAAM,WAAW,CAAC,0EAA0E,CAAC,CAAC;YACrG,IAAI,GAAG,aAAa,CAAC,QAAQ,EAAE,IAAI,CAAC,OAAO,CAAC,CAAC;QAC9C,CAAC;QACD,IAAI,CAAC,IAAI,GAAG,IAAI,IAAI,cAAc,CAAC,IAAI,CAAC,OAAO,CAAC,GAAG,EAAE,IAAI,CAAC,OAAO,CAAC,CAAC;QACnE,IAAI,CAAC,IAAI,CAAC,UAAU,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC;QAElC,MAAM,IAAI,CAAC,iBAAiB,CAAC,IAAI,CAAC,KAAK,EAAE,QAAQ,EAAE,MAAM,CAAC,CAAC;IAAA,CAC3D;IAEO,KAAK,CAAC,iBAAiB,CAAC,KAAqB,EAAE,QAAgB,EAAE,MAAoB,EAAiB;QAC7G,MAAM,IAAI,GAAG,IAAI,CAAC,IAAK,CAAC;QACxB,MAAM,MAAM,GAAG,IAAI,CAAC,MAAO,CAAC;QAE5B,0EAA0E;QAC1E,MAAM,OAAO,GAAiE,EAAE,CAAC;QACjF,MAAM,IAAI,GAAG,IAAI,GAAG,EAAU,CAAC;QAC/B,KAAK,MAAM,IAAI,IAAI,KAAK,EAAE,CAAC;YAC1B,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;YACnB,MAAM,KAAK,GAAG,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;YACnC,IAAI,KAAK,IAAI,KAAK,CAAC,OAAO,KAAK,IAAI,CAAC,OAAO,IAAI,KAAK,CAAC,IAAI,KAAK,IAAI,CAAC,IAAI;gBAAE,SAAS;YAClF,IAAI,OAAe,CAAC;YACpB,IAAI,CAAC;gBACJ,OAAO,GAAG,YAAY,CAAC,IAAI,CAAC,GAAG,EAAE,OAAO,CAAC,CAAC;YAC3C,CAAC;YAAC,MAAM,CAAC;gBACR,SAAS;YACV,CAAC;YACD,MAAM,IAAI,GAAG,WAAW,CAAC,OAAO,CAAC,CAAC;YAClC,IAAI,KAAK,IAAI,KAAK,CAAC,IAAI,KAAK,IAAI,EAAE,CAAC;gBAClC,oDAAkD;gBAClD,KAAK,CAAC,OAAO,GAAG,IAAI,CAAC,OAAO,CAAC;gBAC7B,KAAK,CAAC,IAAI,GAAG,IAAI,CAAC,IAAI,CAAC;gBACvB,SAAS;YACV,CAAC;YACD,OAAO,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC,CAAC;QACvC,CAAC;QACD,MAAM,QAAQ,GAAG,MAAM,CAAC,IAAI,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,MAAM,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC,CAAC;QAEzE,IAAI,OAAO,CAAC,MAAM,KAAK,CAAC,IAAI,QAAQ,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;YACnD,aAAa,CAAC,QAAQ,EAAE,IAAI,CAAC,CAAC;YAC9B,IAAI,CAAC,QAAQ,CAAC,EAAE,KAAK,EAAE,OAAO,EAAE,UAAU,EAAE,IAAI,CAAC,WAAW,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;YACtE,OAAO;QACR,CAAC;QAED,+DAA+D;QAC/D,MAAM,IAAI,GAA4F,EAAE,CAAC;QACzG,IAAI,WAAW,GAAG,CAAC,CAAC;QACpB,KAAK,MAAM,EAAE,IAAI,EAAE,OAAO,EAAE,IAAI,EAAE,IAAI,OAAO,EAAE,CAAC;YAC/C,MAAM,MAAM,GAAG,SAAS,CAAC,IAAI,CAAC,GAAG,EAAE,OAAO,EAAE,IAAI,CAAC,OAAO,CAAC,aAAa,CAAC,CAAC;YACxE,IAAI,CAAC,IAAI,CAAC;gBACT,GAAG,EAAE,IAAI,CAAC,GAAG;gBACb,QAAQ,EAAE;oBACT,OAAO,EAAE,IAAI,CAAC,OAAO;oBACrB,IAAI,EAAE,IAAI,CAAC,IAAI;oBACf,IAAI;oBACJ,MAAM,EAAE,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,CAAC,SAAS,EAAE,CAAC,CAAC,OAAO,CAAC,CAAC;iBACnD;gBACD,MAAM,EAAE,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,EAAE,EAAE,EAAE,CAAC,CAAC,EAAE,EAAE,IAAI,EAAE,CAAC,CAAC,IAAI,EAAE,CAAC,CAAC;aACvD,CAAC,CAAC;YACH,WAAW,IAAI,MAAM,CAAC,MAAM,CAAC;QAC9B,CAAC;QAED,IAAI,CAAC,QAAQ,CAAC,EAAE,KAAK,EAAE,UAAU,EAAE,IAAI,EAAE,CAAC,EAAE,KAAK,EAAE,WAAW,EAAE,CAAC,CAAC;QAElE,4DAA4D;QAC5D,KAAK,MAAM,GAAG,IAAI,QAAQ,EAAE,CAAC;YAC5B,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,MAAM,CAAC,MAAM,EAAE,CAAC,EAAE;gBAAE,MAAM,MAAM,CAAC,MAAM,CAAC,GAAG,GAAG,IAAI,CAAC,EAAE,CAAC,CAAC;YAC3F,OAAO,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC;QACxB,CAAC;QAED,IAAI,IAAI,GAAG,CAAC,CAAC;QACb,KAAK,MAAM,IAAI,IAAI,IAAI,EAAE,CAAC;YACzB,IAAI,MAAM,EAAE,OAAO,IAAI,IAAI,CAAC,QAAQ;gBAAE,OAAO;YAC7C,MAAM,aAAa,GAAG,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,GAAG,CAAC,EAAE,MAAM,CAAC,MAAM,IAAI,CAAC,CAAC;YAC/D,mEAAmE;YACnE,KAAK,IAAI,CAAC,GAAG,IAAI,CAAC,MAAM,CAAC,MAAM,EAAE,CAAC,GAAG,aAAa,EAAE,CAAC,EAAE;gBAAE,MAAM,MAAM,CAAC,MAAM,CAAC,GAAG,IAAI,CAAC,GAAG,IAAI,CAAC,EAAE,CAAC,CAAC;YAEjG,KAAK,IAAI,MAAM,GAAG,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,IAAI,eAAe,EAAE,CAAC;gBAC7E,IAAI,MAAM,EAAE,OAAO,IAAI,IAAI,CAAC,QAAQ;oBAAE,OAAO;gBAC7C,MAAM,KAAK,GAAG,IAAI,CAAC,MAAM,CAAC,KAAK,CAAC,MAAM,EAAE,MAAM,GAAG,eAAe,CAAC,CAAC;gBAClE,MAAM,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;gBACzB,IAAI,IAAI,KAAK,CAAC,MAAM,CAAC;gBACrB,IAAI,CAAC,QAAQ,CAAC,EAAE,KAAK,EAAE,UAAU,EAAE,IAAI,EAAE,KAAK,EAAE,WAAW,EAAE,CAAC,CAAC;gBAC/D,uDAAuD;gBACvD,MAAM,IAAI,OAAO,CAAC,CAAC,OAAO,EAAE,EAAE,CAAC,UAAU,CAAC,OAAO,EAAE,cAAc,CAAC,CAAC,CAAC;YACrE,CAAC;YACD,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,GAAG,CAAC,GAAG,IAAI,CAAC,QAAQ,CAAC;QACtC,CAAC;QAED,MAAM,MAAM,CAAC,OAAO,EAAE,CAAC;QACvB,MAAM,MAAM,CAAC,IAAI,EAAE,CAAC;QACpB,aAAa,CAAC,QAAQ,EAAE,IAAI,CAAC,CAAC;QAC9B,IAAI,CAAC,QAAQ,CAAC,EAAE,KAAK,EAAE,OAAO,EAAE,UAAU,EAAE,IAAI,CAAC,WAAW,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;IAAA,CACtE;IAEO,WAAW,CAAC,IAAe,EAAU;QAC5C,IAAI,CAAC,GAAG,CAAC,CAAC;QACV,KAAK,MAAM,GAAG,IAAI,MAAM,CAAC,IAAI,CAAC,IAAI,CAAC,KAAK,CAAC;YAAE,CAAC,IAAI,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,MAAM,CAAC,MAAM,CAAC;QAC9E,OAAO,CAAC,CAAC;IAAA,CACT;IAED,4DAA4D;IAC5D,KAAK,CAAC,MAAM,CAAC,KAAa,EAAE,CAAC,GAAG,EAAE,EAA0B;QAC3D,OAAO,MAAM,IAAI,CAAC,YAAY,CAAC,KAAK,EAAE,CAAC,CAAC,CAAC;IAAA,CACzC;IAED,wEAAwE;IACxE,KAAK,CAAC,YAAY,CACjB,KAAa,EACb,CAAC,GAAG,EAAE,EACN,IAAa,EACb,SAAS,GAAoB,OAAO,EACN;QAC9B,IAAI,CAAC,IAAI,CAAC,MAAM,IAAI,IAAI,CAAC,MAAM,CAAC,QAAQ,IAAI,CAAC,IAAI,CAAC,IAAI,EAAE,CAAC;YACxD,MAAM,IAAI,KAAK,CAAC,iCAAiC,CAAC,CAAC;QACpD,CAAC;QACD,IAAI,SAAS,KAAK,SAAS,IAAI,CAAC,IAAI,CAAC,gBAAgB,EAAE,CAAC;YACvD,MAAM,IAAI,KAAK,CACd,0DAA0D,6BAA6B,CAAC,IAAI,CAAC,GAAG,CAAC,KAAK;gBACrG,kEAAkE,CACnE,CAAC;QACH,CAAC;QACD,MAAM,OAAO,GAAG,MAAM,IAAI,CAAC,MAAM,CAAC,KAAK,CAAC,KAAK,EAAE,CAAC,EAAE,SAAS,CAAC,CAAC;QAC7D,MAAM,IAAI,GAAuB,EAAE,CAAC;QACpC,MAAM,SAAS,GAAG,CAAC,GAAW,EAAW,EAAE,CAAC;YAC3C,IAAI,CAAC,IAAI;gBAAE,OAAO,IAAI,CAAC;YACvB,OAAO,SAAS,CAAC,GAAG,EAAE,IAAI,EAAE,EAAE,GAAG,EAAE,IAAI,EAAE,SAAS,EAAE,CAAC,IAAI,CAAC,QAAQ,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC;QAAA,CAC3E,CAAC;QACF,KAAK,MAAM,MAAM,IAAI,OAAO,EAAE,CAAC;YAC9B,MAAM,GAAG,GAAG,MAAM,CAAC,EAAE,CAAC,WAAW,CAAC,GAAG,CAAC,CAAC;YACvC,IAAI,GAAG,KAAK,CAAC,CAAC;gBAAE,SAAS;YACzB,MAAM,GAAG,GAAG,MAAM,CAAC,EAAE,CAAC,KAAK,CAAC,CAAC,EAAE,GAAG,CAAC,CAAC;YACpC,IAAI,CAAC,SAAS,CAAC,GAAG,CAAC;gBAAE,SAAS;YAC9B,MAAM,UAAU,GAAG,MAAM,CAAC,QAAQ,CAAC,MAAM,CAAC,EAAE,CAAC,KAAK,CAAC,GAAG,GAAG,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC;YACjE,MAAM,KAAK,GAAG,IAAI,CAAC,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,EAAE,MAAM,CAAC,UAAU,CAAC,CAAC;YACvD,IAAI,CAAC,KAAK;gBAAE,SAAS;YACrB,IAAI,CAAC,IAAI,CAAC,EAAE,EAAE,EAAE,MAAM,CAAC,EAAE,EAAE,IAAI,EAAE,GAAG,EAAE,SAAS,EAAE,KAAK,CAAC,CAAC,CAAC,EAAE,OAAO,EAAE,KAAK,CAAC,CAAC,CAAC,EAAE,KAAK,EAAE,MAAM,CAAC,KAAK,EAAE,CAAC,CAAC;QACtG,CAAC;QACD,OAAO,IAAI,CAAC;IAAA,CACZ;IAED;;;;;OAKG;IACH,kBAAkB,CACjB,GAAW,EACX,IAAY,EACmE;QAC/E,MAAM,IAAI,GAAG,IAAI,CAAC,IAAI,EAAE,KAAK,CAAC,GAAG,CAAC,CAAC;QACnC,IAAI,CAAC,IAAI;YAAE,OAAO,SAAS,CAAC;QAC5B,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,IAAI,CAAC,MAAM,CAAC,MAAM,EAAE,CAAC,EAAE,EAAE,CAAC;YAC7C,MAAM,CAAC,SAAS,EAAE,OAAO,CAAC,GAAG,IAAI,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC;YAC5C,IAAI,IAAI,IAAI,SAAS,IAAI,IAAI,IAAI,OAAO,EAAE,CAAC;gBAC1C,OAAO,EAAE,EAAE,EAAE,GAAG,GAAG,IAAI,CAAC,EAAE,EAAE,IAAI,EAAE,GAAG,EAAE,SAAS,EAAE,OAAO,EAAE,CAAC;YAC7D,CAAC;QACF,CAAC;QACD,OAAO,SAAS,CAAC;IAAA,CACjB;IAEO,KAAK,CAAC,WAAW,GAAkB;QAC1C,MAAM,MAAM,GAAG,IAAI,CAAC,MAAM,CAAC;QAC3B,IAAI,CAAC,MAAM,GAAG,SAAS,CAAC;QACxB,IAAI,MAAM,IAAI,CAAC,MAAM,CAAC,QAAQ,EAAE,CAAC;YAChC,IAAI,CAAC;gBACJ,MAAM,MAAM,CAAC,KAAK,EAAE,CAAC;YACtB,CAAC;YAAC,MAAM,CAAC;gBACR,eAAe;YAChB,CAAC;QACF,CAAC;IAAA,CACD;IAED,kEAAkE;IAClE,KAAK,CAAC,OAAO,GAAkB;QAC9B,IAAI,IAAI,CAAC,QAAQ;YAAE,OAAO;QAC1B,IAAI,CAAC,QAAQ,GAAG,IAAI,CAAC;QACrB,IAAI,IAAI,CAAC,MAAM,IAAI,CAAC,IAAI,CAAC,MAAM,CAAC,QAAQ,EAAE,CAAC;YAC1C,IAAI,CAAC;gBACJ,MAAM,IAAI,CAAC,MAAM,CAAC,IAAI,EAAE,CAAC;YAC1B,CAAC;YAAC,MAAM,CAAC;gBACR,0CAA0C;YAC3C,CAAC;QACF,CAAC;QACD,MAAM,IAAI,CAAC,WAAW,EAAE,CAAC;IAAA,CACzB;CACD;AAED,mCAAmC;AACnC,EAAE;AACF,uEAAuE;AACvE,8EAA8E;AAC9E,8EAA8E;AAC9E,8DAA8D;AAE9D,MAAM,QAAQ,GAAG,IAAI,GAAG,EAA4B,CAAC;AAErD,MAAM,UAAU,wBAAwB,CAAC,GAAW,EAAE,OAAyB,EAAQ;IACtF,MAAM,GAAG,GAAG,QAAQ,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;IAC9B,IAAI,GAAG,IAAI,GAAG,KAAK,OAAO,EAAE,CAAC;QAC5B,GAAG,CAAC,OAAO,EAAE,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC,EAAC,CAAC,CAAC,CAAC;IAC/B,CAAC;IACD,QAAQ,CAAC,GAAG,CAAC,GAAG,EAAE,OAAO,CAAC,CAAC;AAAA,CAC3B;AAED,MAAM,UAAU,mBAAmB,CAAC,GAAW,EAAgC;IAC9E,OAAO,QAAQ,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;AAAA,CACzB;AAED,MAAM,UAAU,0BAA0B,CAAC,GAAW,EAAQ;IAC7D,QAAQ,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC;AAAA,CACrB","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;
|