@kolisachint/hoocode-agent 0.4.143 → 0.4.144
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 +3 -1
- package/dist/core/embsearch/embsearch-service.d.ts +1 -1
- package/dist/core/embsearch/embsearch-service.d.ts.map +1 -1
- package/dist/core/embsearch/embsearch-service.js +9 -1
- package/dist/core/embsearch/embsearch-service.js.map +1 -1
- package/dist/core/search/hybrid-search.d.ts +3 -1
- package/dist/core/search/hybrid-search.d.ts.map +1 -1
- package/dist/core/search/hybrid-search.js +13 -2
- package/dist/core/search/hybrid-search.js.map +1 -1
- package/dist/core/search/lexical-retriever.d.ts +8 -1
- package/dist/core/search/lexical-retriever.d.ts.map +1 -1
- package/dist/core/search/lexical-retriever.js +21 -15
- package/dist/core/search/lexical-retriever.js.map +1 -1
- package/dist/core/tools/search.d.ts +1 -0
- package/dist/core/tools/search.d.ts.map +1 -1
- package/dist/core/tools/search.js +5 -1
- package/dist/core/tools/search.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
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.4.144] - 2026-07-18
|
|
4
|
+
|
|
3
5
|
## [0.4.143] - 2026-07-18
|
|
4
6
|
|
|
5
7
|
### Added
|
|
@@ -8,7 +10,7 @@
|
|
|
8
10
|
`search` tool replaces `semantic_search`, with `mode: auto | lexical |
|
|
9
11
|
semantic | hybrid`. Hybrid mode runs grep-backed lexical retrieval and the
|
|
10
12
|
local embedding index in parallel and fuses results with Reciprocal Rank
|
|
11
|
-
Fusion (
|
|
13
|
+
Fusion (rank-only, deterministic tie-breaks; `k=2` per the eval gate below). Lexical hits are
|
|
12
14
|
mapped to indexed chunk ids (with `rel#L<line>` fallbacks for uncovered
|
|
13
15
|
files), collapsed, and re-ranked before fusion. Results are expanded into
|
|
14
16
|
line-window snippets under a token budget; full per-call diagnostics go to
|
|
@@ -71,7 +71,7 @@ export declare class EmbsearchService {
|
|
|
71
71
|
/** Top-`k` semantic hits as `path` + line range + score. */
|
|
72
72
|
search(query: string, k?: number): Promise<SemanticHit[]>;
|
|
73
73
|
/** Top-`k` semantic hits including their chunk ids, for rank fusion. */
|
|
74
|
-
searchChunks(query: string, k?: number): Promise<SemanticChunkHit[]>;
|
|
74
|
+
searchChunks(query: string, k?: number, glob?: string): Promise<SemanticChunkHit[]>;
|
|
75
75
|
/**
|
|
76
76
|
* Resolve a repo-relative path + line to its enclosing indexed chunk, or
|
|
77
77
|
* undefined when the file/line is not covered by the index. Chunks overlap
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"embsearch-service.d.ts","sourceRoot":"","sources":["../../../src/core/embsearch/embsearch-service.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AA0BH,MAAM,MAAM,cAAc,GACvB;IAAE,KAAK,EAAE,MAAM,CAAA;CAAE,GACjB;IAAE,KAAK,EAAE,SAAS,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,GACpC;IAAE,KAAK,EAAE,UAAU,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,GAClD;IAAE,KAAK,EAAE,OAAO,CAAC;IAAC,UAAU,EAAE,MAAM,CAAA;CAAE,GACtC;IAAE,KAAK,EAAE,aAAa,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAAC;AAE5C,MAAM,WAAW,uBAAuB;IACvC,GAAG,EAAE,MAAM,CAAC;IACZ,gFAAgF;IAChF,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,wDAAwD;IACxD,cAAc,EAAE,MAAM,CAAC;IACvB,wDAAwD;IACxD,UAAU,CAAC,EAAE,CAAC,KAAK,EAAE,cAAc,KAAK,IAAI,CAAC;CAC7C;AAED,MAAM,WAAW,WAAW;IAC3B,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,EAAE,MAAM,CAAC;IAClB,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;CACd;AAED,MAAM,WAAW,gBAAiB,SAAQ,WAAW;IACpD,sFAAoF;IACpF,EAAE,EAAE,MAAM,CAAC;CACX;AAED,qBAAa,gBAAgB;IAC5B,OAAO,CAAC,QAAQ,CAAC,OAAO,CAA0B;IAClD,OAAO,CAAC,MAAM,CAA8B;IAC5C,OAAO,CAAC,IAAI,CAAwB;IACpC,OAAO,CAAC,KAAK,CAAqC;IAClD,OAAO,CAAC,QAAQ,CAAS;IAEzB,YAAY,OAAO,EAAE,uBAAuB,EAE3C;IAED,QAAQ,IAAI,cAAc,CAEzB;IAED,oFAAoF;IACpF,WAAW,IAAI,OAAO,CAErB;IAED,OAAO,CAAC,QAAQ;YAKF,aAAa;IAO3B;;;OAGG;IACG,KAAK,CAAC,MAAM,CAAC,EAAE,WAAW,GAAG,OAAO,CAAC,IAAI,CAAC,CAQ/C;YAEa,GAAG;YAmCH,iBAAiB;IAqF/B,OAAO,CAAC,WAAW;IAMnB,4DAA4D;IACtD,MAAM,CAAC,KAAK,EAAE,MAAM,EAAE,CAAC,SAAK,GAAG,OAAO,CAAC,WAAW,EAAE,CAAC,CAE1D;IAED,wEAAwE;IAClE,YAAY,CAAC,KAAK,EAAE,MAAM,EAAE,CAAC,SAAK,GAAG,OAAO,CAAC,gBAAgB,EAAE,CAAC,CAgBrE;IAED;;;;;OAKG;IACH,kBAAkB,CACjB,GAAG,EAAE,MAAM,EACX,IAAI,EAAE,MAAM,GACV;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,SAAS,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,GAAG,SAAS,CAU9E;YAEa,WAAW;IAYzB,kEAAkE;IAC5D,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC,CAW7B;CACD;AAWD,wBAAgB,wBAAwB,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,EAAE,gBAAgB,GAAG,IAAI,CAMrF;AAED,wBAAgB,mBAAmB,CAAC,GAAG,EAAE,MAAM,GAAG,gBAAgB,GAAG,SAAS,CAE7E;AAED,wBAAgB,0BAA0B,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,CAE5D","sourcesContent":["/**\n * Orchestrates semantic indexing and search for a repository.\n *\n * Lifecycle (all behind --enable-embsearchtools):\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 { readFileSync } from \"fs\";\nimport { ensureTool } from \"../../utils/tools-manager.js\";\nimport { chunkFile } from \"./chunker.js\";\nimport { EmbSearchClient } 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\nexport type EmbsearchState =\n\t| { phase: \"idle\" }\n\t| { phase: \"skipped\"; reason: string }\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/** 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\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\tprivate async resolveBinary(): Promise<string | undefined> {\n\t\tif (this.options.binaryPath) {\n\t\t\treturn this.options.binaryPath;\n\t\t}\n\t\treturn await ensureTool(\"embsearch\", true);\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 storeDir = getEmbsearchStoreDir(this.options.cwd);\n\t\tthis.client = new EmbSearchClient({ binaryPath: binary, storePath: getVectorStoreDir(storeDir) });\n\t\tawait this.client.ready();\n\n\t\tconst info = await this.client.info();\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(query: string, k = 10): 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\tconst results = await this.client.query(query, k);\n\t\tconst hits: SemanticChunkHit[] = [];\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\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":"AAAA;;;;;;;;;;;;;;GAcG;AA2BH,MAAM,MAAM,cAAc,GACvB;IAAE,KAAK,EAAE,MAAM,CAAA;CAAE,GACjB;IAAE,KAAK,EAAE,SAAS,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,GACpC;IAAE,KAAK,EAAE,UAAU,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,GAClD;IAAE,KAAK,EAAE,OAAO,CAAC;IAAC,UAAU,EAAE,MAAM,CAAA;CAAE,GACtC;IAAE,KAAK,EAAE,aAAa,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAAC;AAE5C,MAAM,WAAW,uBAAuB;IACvC,GAAG,EAAE,MAAM,CAAC;IACZ,gFAAgF;IAChF,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,wDAAwD;IACxD,cAAc,EAAE,MAAM,CAAC;IACvB,wDAAwD;IACxD,UAAU,CAAC,EAAE,CAAC,KAAK,EAAE,cAAc,KAAK,IAAI,CAAC;CAC7C;AAED,MAAM,WAAW,WAAW;IAC3B,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,EAAE,MAAM,CAAC;IAClB,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;CACd;AAED,MAAM,WAAW,gBAAiB,SAAQ,WAAW;IACpD,sFAAoF;IACpF,EAAE,EAAE,MAAM,CAAC;CACX;AAED,qBAAa,gBAAgB;IAC5B,OAAO,CAAC,QAAQ,CAAC,OAAO,CAA0B;IAClD,OAAO,CAAC,MAAM,CAA8B;IAC5C,OAAO,CAAC,IAAI,CAAwB;IACpC,OAAO,CAAC,KAAK,CAAqC;IAClD,OAAO,CAAC,QAAQ,CAAS;IAEzB,YAAY,OAAO,EAAE,uBAAuB,EAE3C;IAED,QAAQ,IAAI,cAAc,CAEzB;IAED,oFAAoF;IACpF,WAAW,IAAI,OAAO,CAErB;IAED,OAAO,CAAC,QAAQ;YAKF,aAAa;IAO3B;;;OAGG;IACG,KAAK,CAAC,MAAM,CAAC,EAAE,WAAW,GAAG,OAAO,CAAC,IAAI,CAAC,CAQ/C;YAEa,GAAG;YAmCH,iBAAiB;IAqF/B,OAAO,CAAC,WAAW;IAMnB,4DAA4D;IACtD,MAAM,CAAC,KAAK,EAAE,MAAM,EAAE,CAAC,SAAK,GAAG,OAAO,CAAC,WAAW,EAAE,CAAC,CAE1D;IAED,wEAAwE;IAClE,YAAY,CAAC,KAAK,EAAE,MAAM,EAAE,CAAC,SAAK,EAAE,IAAI,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,gBAAgB,EAAE,CAAC,CAqBpF;IAED;;;;;OAKG;IACH,kBAAkB,CACjB,GAAG,EAAE,MAAM,EACX,IAAI,EAAE,MAAM,GACV;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,SAAS,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,GAAG,SAAS,CAU9E;YAEa,WAAW;IAYzB,kEAAkE;IAC5D,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC,CAW7B;CACD;AAWD,wBAAgB,wBAAwB,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,EAAE,gBAAgB,GAAG,IAAI,CAMrF;AAED,wBAAgB,mBAAmB,CAAC,GAAG,EAAE,MAAM,GAAG,gBAAgB,GAAG,SAAS,CAE7E;AAED,wBAAgB,0BAA0B,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,CAE5D","sourcesContent":["/**\n * Orchestrates semantic indexing and search for a repository.\n *\n * Lifecycle (all behind --enable-embsearchtools):\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 { readFileSync } from \"fs\";\nimport { minimatch } from \"minimatch\";\nimport { ensureTool } from \"../../utils/tools-manager.js\";\nimport { chunkFile } from \"./chunker.js\";\nimport { EmbSearchClient } 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\nexport type EmbsearchState =\n\t| { phase: \"idle\" }\n\t| { phase: \"skipped\"; reason: string }\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/** 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\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\tprivate async resolveBinary(): Promise<string | undefined> {\n\t\tif (this.options.binaryPath) {\n\t\t\treturn this.options.binaryPath;\n\t\t}\n\t\treturn await ensureTool(\"embsearch\", true);\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 storeDir = getEmbsearchStoreDir(this.options.cwd);\n\t\tthis.client = new EmbSearchClient({ binaryPath: binary, storePath: getVectorStoreDir(storeDir) });\n\t\tawait this.client.ready();\n\n\t\tconst info = await this.client.info();\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(query: string, k = 10, glob?: string): 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\tconst results = await this.client.query(query, k);\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"]}
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
* blocks session startup or affects grep/find.
|
|
15
15
|
*/
|
|
16
16
|
import { readFileSync } from "fs";
|
|
17
|
+
import { minimatch } from "minimatch";
|
|
17
18
|
import { ensureTool } from "../../utils/tools-manager.js";
|
|
18
19
|
import { chunkFile } from "./chunker.js";
|
|
19
20
|
import { EmbSearchClient } from "./client.js";
|
|
@@ -188,17 +189,24 @@ export class EmbsearchService {
|
|
|
188
189
|
return await this.searchChunks(query, k);
|
|
189
190
|
}
|
|
190
191
|
/** Top-`k` semantic hits including their chunk ids, for rank fusion. */
|
|
191
|
-
async searchChunks(query, k = 10) {
|
|
192
|
+
async searchChunks(query, k = 10, glob) {
|
|
192
193
|
if (!this.client || this.client.isClosed || !this.meta) {
|
|
193
194
|
throw new Error("semantic index is not available");
|
|
194
195
|
}
|
|
195
196
|
const results = await this.client.query(query, k);
|
|
196
197
|
const hits = [];
|
|
198
|
+
const matchGlob = (rel) => {
|
|
199
|
+
if (!glob)
|
|
200
|
+
return true;
|
|
201
|
+
return minimatch(rel, glob, { dot: true, matchBase: !glob.includes("/") });
|
|
202
|
+
};
|
|
197
203
|
for (const result of results) {
|
|
198
204
|
const sep = result.id.lastIndexOf("#");
|
|
199
205
|
if (sep === -1)
|
|
200
206
|
continue;
|
|
201
207
|
const rel = result.id.slice(0, sep);
|
|
208
|
+
if (!matchGlob(rel))
|
|
209
|
+
continue;
|
|
202
210
|
const chunkIndex = Number.parseInt(result.id.slice(sep + 1), 10);
|
|
203
211
|
const range = this.meta.files[rel]?.chunks[chunkIndex];
|
|
204
212
|
if (!range)
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"embsearch-service.js","sourceRoot":"","sources":["../../../src/core/embsearch/embsearch-service.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Orchestrates semantic indexing and search for a repository.\n *\n * Lifecycle (all behind --enable-embsearchtools):\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 { readFileSync } from \"fs\";\nimport { ensureTool } from \"../../utils/tools-manager.js\";\nimport { chunkFile } from \"./chunker.js\";\nimport { EmbSearchClient } 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\nexport type EmbsearchState =\n\t| { phase: \"idle\" }\n\t| { phase: \"skipped\"; reason: string }\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/** 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\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\tprivate async resolveBinary(): Promise<string | undefined> {\n\t\tif (this.options.binaryPath) {\n\t\t\treturn this.options.binaryPath;\n\t\t}\n\t\treturn await ensureTool(\"embsearch\", true);\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 storeDir = getEmbsearchStoreDir(this.options.cwd);\n\t\tthis.client = new EmbSearchClient({ binaryPath: binary, storePath: getVectorStoreDir(storeDir) });\n\t\tawait this.client.ready();\n\n\t\tconst info = await this.client.info();\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(query: string, k = 10): 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\tconst results = await this.client.query(query, k);\n\t\tconst hits: SemanticChunkHit[] = [];\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\tconst chunkIndex = Number.parseInt(result.id.slice(sep + 1), 10);\n\t\t\tconst range = this.meta.files[rel]?.chunks[chunkIndex];\n\t\t\tif (!range) continue;\n\t\t\thits.push({ id: result.id, path: rel, startLine: range[0], endLine: range[1], score: result.score });\n\t\t}\n\t\treturn hits;\n\t}\n\n\t/**\n\t * Resolve a repo-relative path + line to its enclosing indexed chunk, or\n\t * undefined when the file/line is not covered by the index. Chunks overlap\n\t * by a few lines; the first (lowest-index) containing chunk wins so the\n\t * mapping is deterministic.\n\t */\n\tfindEnclosingChunk(\n\t\trel: string,\n\t\tline: number,\n\t): { id: string; path: string; startLine: number; endLine: number } | undefined {\n\t\tconst file = this.meta?.files[rel];\n\t\tif (!file) return undefined;\n\t\tfor (let i = 0; i < file.chunks.length; i++) {\n\t\t\tconst [startLine, endLine] = file.chunks[i];\n\t\t\tif (line >= startLine && line <= endLine) {\n\t\t\t\treturn { id: `${rel}#${i}`, path: rel, startLine, endLine };\n\t\t\t}\n\t\t}\n\t\treturn undefined;\n\t}\n\n\tprivate async closeClient(): Promise<void> {\n\t\tconst client = this.client;\n\t\tthis.client = undefined;\n\t\tif (client && !client.isClosed) {\n\t\t\ttry {\n\t\t\t\tawait client.close();\n\t\t\t} catch {\n\t\t\t\t// already dead\n\t\t\t}\n\t\t}\n\t}\n\n\t/** Persist state and shut the daemon down. Safe to call twice. */\n\tasync dispose(): Promise<void> {\n\t\tif (this.disposed) return;\n\t\tthis.disposed = true;\n\t\tif (this.client && !this.client.isClosed) {\n\t\t\ttry {\n\t\t\t\tawait this.client.save();\n\t\t\t} catch {\n\t\t\t\t// daemon may have exited; nothing to save\n\t\t\t}\n\t\t}\n\t\tawait this.closeClient();\n\t}\n}\n\n// --- Per-cwd service registry ---\n//\n// The search tool is constructed by the generic tool factory table and\n// only receives `cwd`; the service is created later during session init (flag\n// gated). This registry connects the two without threading a service instance\n// through every layer between main.ts and the tool factories.\n\nconst services = new Map<string, EmbsearchService>();\n\nexport function registerEmbsearchService(cwd: string, service: EmbsearchService): void {\n\tconst old = services.get(cwd);\n\tif (old && old !== service) {\n\t\told.dispose().catch(() => {});\n\t}\n\tservices.set(cwd, service);\n}\n\nexport function getEmbsearchService(cwd: string): EmbsearchService | undefined {\n\treturn services.get(cwd);\n}\n\nexport function unregisterEmbsearchService(cwd: string): void {\n\tservices.delete(cwd);\n}\n"]}
|
|
1
|
+
{"version":3,"file":"embsearch-service.js","sourceRoot":"","sources":["../../../src/core/embsearch/embsearch-service.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Orchestrates semantic indexing and search for a repository.\n *\n * Lifecycle (all behind --enable-embsearchtools):\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 { readFileSync } from \"fs\";\nimport { minimatch } from \"minimatch\";\nimport { ensureTool } from \"../../utils/tools-manager.js\";\nimport { chunkFile } from \"./chunker.js\";\nimport { EmbSearchClient } 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\nexport type EmbsearchState =\n\t| { phase: \"idle\" }\n\t| { phase: \"skipped\"; reason: string }\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/** 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\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\tprivate async resolveBinary(): Promise<string | undefined> {\n\t\tif (this.options.binaryPath) {\n\t\t\treturn this.options.binaryPath;\n\t\t}\n\t\treturn await ensureTool(\"embsearch\", true);\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 storeDir = getEmbsearchStoreDir(this.options.cwd);\n\t\tthis.client = new EmbSearchClient({ binaryPath: binary, storePath: getVectorStoreDir(storeDir) });\n\t\tawait this.client.ready();\n\n\t\tconst info = await this.client.info();\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(query: string, k = 10, glob?: string): 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\tconst results = await this.client.query(query, k);\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"]}
|
|
@@ -18,9 +18,11 @@ export interface RetrieveOptions {
|
|
|
18
18
|
cwd: string;
|
|
19
19
|
query: string;
|
|
20
20
|
mode?: SearchMode;
|
|
21
|
+
/** Optional glob filter applied to file paths. */
|
|
22
|
+
glob?: string;
|
|
21
23
|
/** Maximum fused candidates returned. */
|
|
22
24
|
limit?: number;
|
|
23
|
-
/** RRF constant override (eval harness sweeps this). Default:
|
|
25
|
+
/** RRF constant override (eval harness sweeps this). Default: {@link DEFAULT_RRF_K}. */
|
|
24
26
|
rrfK?: number;
|
|
25
27
|
/** Rerank the fused top-50 before slicing to `limit`. Default: true. */
|
|
26
28
|
rerank?: boolean;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"hybrid-search.d.ts","sourceRoot":"","sources":["../../../src/core/search/hybrid-search.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAEH,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,mCAAmC,CAAC;AAQ1E,OAAO,KAAK,EAAiB,cAAc,EAAa,kBAAkB,EAAE,UAAU,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AAcxH,MAAM,WAAW,eAAe;IAC/B,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,UAAU,CAAC;IAClB,yCAAyC;IACzC,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,qEAAqE;IACrE,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,wEAAwE;IACxE,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,OAAO,CAAC,EAAE,gBAAgB,CAAC;IAC3B,MAAM,CAAC,EAAE,WAAW,CAAC;CACrB;AAED,MAAM,WAAW,cAAc;IAC9B,UAAU,EAAE,cAAc,EAAE,CAAC;IAC7B,YAAY,EAAE,kBAAkB,CAAC;IACjC,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,UAAU,EAAE,WAAW,CAAC,YAAY,CAAC,CAAC;IACtC,UAAU,EAAE,WAAW,CAAC,YAAY,CAAC,CAAC;IACtC,uDAAuD;IACvD,QAAQ,CAAC,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC;IAC3C,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,CAAC,EAAE,WAAW,CAAC,QAAQ,CAAC,CAAC;CAC/B;AAED,MAAM,WAAW,gBAAiB,SAAQ,eAAe;IACxD,oDAAoD;IACpD,WAAW,CAAC,EAAE,MAAM,CAAC;CACrB;AAED,MAAM,WAAW,eAAe;IAC/B,IAAI,EAAE,MAAM,CAAC;IACb,YAAY,EAAE,kBAAkB,CAAC;IACjC,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,WAAW,EAAE,MAAM,CAAC;IACpB,uDAAuD;IACvD,QAAQ,CAAC,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC;CAC3C;AAED,wBAAsB,kBAAkB,CAAC,OAAO,EAAE,eAAe,GAAG,OAAO,CAAC,cAAc,CAAC,CAuG1F;AAED,wBAAsB,SAAS,CAAC,OAAO,EAAE,gBAAgB,GAAG,OAAO,CAAC,eAAe,CAAC,CAyBnF","sourcesContent":["/**\n * Hybrid search orchestrator: resolve mode, run retrievers in parallel, fuse\n * by rank, expand within budget, trace everything\n * (docs/hybrid-retrieval-design.md).\n *\n * Single-retriever modes flow through the same pipeline — rrfFuse over one\n * list preserves its order — so lexical, semantic, and hybrid all produce the\n * same result shape and the same trace record.\n *\n * `retrieveCandidates` is the candidate-level core (also used by the eval\n * harness, which needs forced modes, a configurable `k`, and no trace\n * pollution); `runSearch` wraps it with span expansion and tracing for the\n * tool.\n */\n\nimport type { EmbsearchService } from \"../embsearch/embsearch-service.js\";\nimport { adaptGrepHits, type ChunkLookup } from \"./adapter.js\";\nimport { assembleContext } from \"./context-assembler.js\";\nimport { runLexicalRetriever } from \"./lexical-retriever.js\";\nimport { resolveSearchMode } from \"./mode.js\";\nimport { rerankCandidates } from \"./rerank.js\";\nimport { DEFAULT_RRF_K, rrfFuse } from \"./rrf.js\";\nimport { writeSearchTrace } from \"./trace.js\";\nimport type { CandidateSpan, FusedCandidate, RankedHit, ResolvedSearchMode, SearchMode, SearchTrace } from \"./types.js\";\n\n/** Raw grep line-hits fetched per query (pre-collapse). */\nconst LEXICAL_MATCH_LIMIT = 200;\n/** Adapted lexical candidates entering fusion. The eval gate showed the\n * uncapped lexical tail diluting hybrid below plain semantic: lexical\n * precision is front-loaded by the adapter's term-evidence ranking, while\n * RRF weighs a rank-30 lexical candidate like a rank-30 embedding hit. */\nconst LEXICAL_FUSION_CAP = 20;\n/** Embedding hits fetched per query — deep enough for fusion to matter. */\nconst EMBED_TOP_K = 50;\n/** Fused candidates kept for reranking / final slicing. */\nconst FUSED_WINDOW = 50;\n\nexport interface RetrieveOptions {\n\tcwd: string;\n\tquery: string;\n\tmode?: SearchMode;\n\t/** Maximum fused candidates returned. */\n\tlimit?: number;\n\t/** RRF constant override (eval harness sweeps this). Default: 60. */\n\trrfK?: number;\n\t/** Rerank the fused top-50 before slicing to `limit`. Default: true. */\n\trerank?: boolean;\n\tservice?: EmbsearchService;\n\tsignal?: AbortSignal;\n}\n\nexport interface RetrieveResult {\n\tcandidates: FusedCandidate[];\n\tresolvedMode: ResolvedSearchMode;\n\tdegradedReason?: string;\n\tindexPhase: SearchTrace[\"indexPhase\"];\n\tretrievers: SearchTrace[\"retrievers\"];\n\t/** Set while the embedding index is still building. */\n\tindexing?: { done: number; total: number };\n\trrfK: number;\n\trerank?: SearchTrace[\"rerank\"];\n}\n\nexport interface RunSearchOptions extends RetrieveOptions {\n\t/** Approximate token budget for the result text. */\n\ttokenBudget?: number;\n}\n\nexport interface RunSearchResult {\n\ttext: string;\n\tresolvedMode: ResolvedSearchMode;\n\tdegradedReason?: string;\n\tresultCount: number;\n\t/** Set while the embedding index is still building. */\n\tindexing?: { done: number; total: number };\n}\n\nexport async function retrieveCandidates(options: RetrieveOptions): Promise<RetrieveResult> {\n\tconst { cwd, query, service, signal } = options;\n\tconst requestedMode = options.mode ?? \"auto\";\n\tconst limit = Math.max(1, options.limit ?? 10);\n\tconst rrfK = options.rrfK ?? DEFAULT_RRF_K;\n\n\tconst state = service?.getState();\n\tconst embedAvailable = service?.isAvailable() ?? false;\n\tconst embedUnavailableReason =\n\t\tstate === undefined\n\t\t\t? \"semantic index is not enabled\"\n\t\t\t: state.phase === \"unavailable\" || state.phase === \"skipped\"\n\t\t\t\t? state.reason\n\t\t\t\t: state.phase === \"idle\"\n\t\t\t\t\t? \"semantic index has not started\"\n\t\t\t\t\t: undefined;\n\n\tconst resolution = resolveSearchMode(query, requestedMode, embedAvailable, embedUnavailableReason);\n\tconst mode = resolution.mode;\n\n\t// Map lexical hits onto indexed chunk ids whenever the sidecar is usable,\n\t// even in lexical-only mode, so identities line up across modes.\n\tconst lookupChunk: ChunkLookup | undefined = embedAvailable\n\t\t? (rel, line) => service!.findEnclosingChunk(rel, line)\n\t\t: undefined;\n\n\tconst spans = new Map<string, CandidateSpan>();\n\tconst lists: RankedHit[][] = [];\n\tconst retrieverStats: SearchTrace[\"retrievers\"] = {};\n\tconst errors: Error[] = [];\n\n\tconst runLexical = async (): Promise<void> => {\n\t\tconst startedMs = Date.now();\n\t\ttry {\n\t\t\tconst lineHits = await runLexicalRetriever(cwd, query, LEXICAL_MATCH_LIMIT, signal);\n\t\t\tconst adapted = adaptGrepHits(lineHits, lookupChunk);\n\t\t\t// In single-retriever lexical mode the full list is the result; in\n\t\t\t// hybrid, only the front-loaded head is trustworthy enough to vote.\n\t\t\tconst hits = mode === \"hybrid\" ? adapted.hits.slice(0, LEXICAL_FUSION_CAP) : adapted.hits;\n\t\t\tfor (const [id, span] of adapted.spans) if (!spans.has(id)) spans.set(id, span);\n\t\t\tlists.push(hits);\n\t\t\tretrieverStats.grep = { latencyMs: Date.now() - startedMs, hitCount: hits.length };\n\t\t} catch (e) {\n\t\t\terrors.push(e instanceof Error ? e : new Error(String(e)));\n\t\t\tretrieverStats.grep = { latencyMs: Date.now() - startedMs, hitCount: 0 };\n\t\t}\n\t};\n\n\tconst runEmbed = async (): Promise<void> => {\n\t\tconst startedMs = Date.now();\n\t\ttry {\n\t\t\tconst chunkHits = await service!.searchChunks(query, EMBED_TOP_K);\n\t\t\tconst hits: RankedHit[] = chunkHits.map((hit, i) => ({\n\t\t\t\tid: hit.id,\n\t\t\t\trank: i + 1,\n\t\t\t\tscore: hit.score,\n\t\t\t\tsource: \"embed\",\n\t\t\t}));\n\t\t\tfor (const hit of chunkHits) {\n\t\t\t\tspans.set(hit.id, { path: hit.path, startLine: hit.startLine, endLine: hit.endLine });\n\t\t\t}\n\t\t\tlists.push(hits);\n\t\t\tretrieverStats.embed = { latencyMs: Date.now() - startedMs, hitCount: hits.length };\n\t\t} catch (e) {\n\t\t\terrors.push(e instanceof Error ? e : new Error(String(e)));\n\t\t\tretrieverStats.embed = { latencyMs: Date.now() - startedMs, hitCount: 0 };\n\t\t}\n\t};\n\n\tconst runs: Promise<void>[] = [];\n\tif (mode === \"lexical\" || mode === \"hybrid\") runs.push(runLexical());\n\tif (mode === \"semantic\" || mode === \"hybrid\") runs.push(runEmbed());\n\tawait Promise.all(runs);\n\tif (signal?.aborted) throw new Error(\"Operation aborted\");\n\t// A partial failure in hybrid degrades to whichever retriever survived;\n\t// only a total loss is an error.\n\tif (lists.length === 0) throw errors[0] ?? new Error(\"search produced no retriever results\");\n\n\tconst fused = rrfFuse(lists, rrfK).slice(0, FUSED_WINDOW);\n\tlet candidates: FusedCandidate[] = [];\n\tfor (const hit of fused) {\n\t\tconst span = spans.get(hit.id);\n\t\tif (span) candidates.push({ ...hit, ...span });\n\t}\n\n\tlet rerankInfo: SearchTrace[\"rerank\"];\n\tif (options.rerank !== false) {\n\t\tconst reranked = rerankCandidates(query, candidates, cwd);\n\t\trerankInfo = { applied: true, candidateCount: candidates.length, latencyMs: reranked.latencyMs };\n\t\tcandidates = reranked.candidates;\n\t}\n\tcandidates = candidates.slice(0, limit);\n\n\treturn {\n\t\tcandidates,\n\t\tresolvedMode: mode,\n\t\tdegradedReason: resolution.degradedReason,\n\t\tindexPhase: state?.phase === \"ready\" ? \"ready\" : state?.phase === \"indexing\" ? \"indexing\" : \"unavailable\",\n\t\tretrievers: retrieverStats,\n\t\tindexing: state?.phase === \"indexing\" ? { done: state.done, total: state.total } : undefined,\n\t\trrfK,\n\t\trerank: rerankInfo,\n\t};\n}\n\nexport async function runSearch(options: RunSearchOptions): Promise<RunSearchResult> {\n\tconst retrieved = await retrieveCandidates(options);\n\n\tconst assembled = assembleContext(retrieved.candidates, { cwd: options.cwd, tokenBudget: options.tokenBudget });\n\n\twriteSearchTrace(options.cwd, {\n\t\ttimestampMs: Date.now(),\n\t\tquery: options.query,\n\t\trequestedMode: options.mode ?? \"auto\",\n\t\tresolvedMode: retrieved.resolvedMode,\n\t\tdegradedReason: retrieved.degradedReason,\n\t\tindexPhase: retrieved.indexPhase,\n\t\trrfK: retrieved.resolvedMode === \"hybrid\" ? retrieved.rrfK : undefined,\n\t\tretrievers: retrieved.retrievers,\n\t\tfused: retrieved.candidates.map(({ id, rrfScore, ranks, rawScores }) => ({ id, rrfScore, ranks, rawScores })),\n\t\trerank: retrieved.rerank,\n\t});\n\n\treturn {\n\t\ttext: assembled.text,\n\t\tresolvedMode: retrieved.resolvedMode,\n\t\tdegradedReason: retrieved.degradedReason,\n\t\tresultCount: retrieved.candidates.length,\n\t\tindexing: retrieved.indexing,\n\t};\n}\n"]}
|
|
1
|
+
{"version":3,"file":"hybrid-search.d.ts","sourceRoot":"","sources":["../../../src/core/search/hybrid-search.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAEH,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,mCAAmC,CAAC;AAQ1E,OAAO,KAAK,EAAiB,cAAc,EAAa,kBAAkB,EAAE,UAAU,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AAcxH,MAAM,WAAW,eAAe;IAC/B,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,UAAU,CAAC;IAClB,kDAAkD;IAClD,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,yCAAyC;IACzC,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,wFAAwF;IACxF,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,wEAAwE;IACxE,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,OAAO,CAAC,EAAE,gBAAgB,CAAC;IAC3B,MAAM,CAAC,EAAE,WAAW,CAAC;CACrB;AAED,MAAM,WAAW,cAAc;IAC9B,UAAU,EAAE,cAAc,EAAE,CAAC;IAC7B,YAAY,EAAE,kBAAkB,CAAC;IACjC,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,UAAU,EAAE,WAAW,CAAC,YAAY,CAAC,CAAC;IACtC,UAAU,EAAE,WAAW,CAAC,YAAY,CAAC,CAAC;IACtC,uDAAuD;IACvD,QAAQ,CAAC,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC;IAC3C,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,CAAC,EAAE,WAAW,CAAC,QAAQ,CAAC,CAAC;CAC/B;AAED,MAAM,WAAW,gBAAiB,SAAQ,eAAe;IACxD,oDAAoD;IACpD,WAAW,CAAC,EAAE,MAAM,CAAC;CACrB;AAED,MAAM,WAAW,eAAe;IAC/B,IAAI,EAAE,MAAM,CAAC;IACb,YAAY,EAAE,kBAAkB,CAAC;IACjC,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,WAAW,EAAE,MAAM,CAAC;IACpB,uDAAuD;IACvD,QAAQ,CAAC,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC;CAC3C;AAYD,wBAAsB,kBAAkB,CAAC,OAAO,EAAE,eAAe,GAAG,OAAO,CAAC,cAAc,CAAC,CAwG1F;AAED,wBAAsB,SAAS,CAAC,OAAO,EAAE,gBAAgB,GAAG,OAAO,CAAC,eAAe,CAAC,CAyBnF","sourcesContent":["/**\n * Hybrid search orchestrator: resolve mode, run retrievers in parallel, fuse\n * by rank, expand within budget, trace everything\n * (docs/hybrid-retrieval-design.md).\n *\n * Single-retriever modes flow through the same pipeline — rrfFuse over one\n * list preserves its order — so lexical, semantic, and hybrid all produce the\n * same result shape and the same trace record.\n *\n * `retrieveCandidates` is the candidate-level core (also used by the eval\n * harness, which needs forced modes, a configurable `k`, and no trace\n * pollution); `runSearch` wraps it with span expansion and tracing for the\n * tool.\n */\n\nimport type { EmbsearchService } from \"../embsearch/embsearch-service.js\";\nimport { adaptGrepHits, type ChunkLookup } from \"./adapter.js\";\nimport { assembleContext } from \"./context-assembler.js\";\nimport { runLexicalRetriever } from \"./lexical-retriever.js\";\nimport { resolveSearchMode } from \"./mode.js\";\nimport { rerankCandidates } from \"./rerank.js\";\nimport { DEFAULT_RRF_K, rrfFuse } from \"./rrf.js\";\nimport { writeSearchTrace } from \"./trace.js\";\nimport type { CandidateSpan, FusedCandidate, RankedHit, ResolvedSearchMode, SearchMode, SearchTrace } from \"./types.js\";\n\n/** Raw grep line-hits fetched per query (pre-collapse). */\nconst LEXICAL_MATCH_LIMIT = 200;\n/** Adapted lexical candidates entering fusion. The eval gate showed the\n * uncapped lexical tail diluting hybrid below plain semantic: lexical\n * precision is front-loaded by the adapter's term-evidence ranking, while\n * RRF weighs a rank-30 lexical candidate like a rank-30 embedding hit. */\nconst LEXICAL_FUSION_CAP = 20;\n/** Embedding hits fetched per query — deep enough for fusion to matter. */\nconst EMBED_TOP_K = 50;\n/** Fused candidates kept for reranking / final slicing. */\nconst FUSED_WINDOW = 50;\n\nexport interface RetrieveOptions {\n\tcwd: string;\n\tquery: string;\n\tmode?: SearchMode;\n\t/** Optional glob filter applied to file paths. */\n\tglob?: string;\n\t/** Maximum fused candidates returned. */\n\tlimit?: number;\n\t/** RRF constant override (eval harness sweeps this). Default: {@link DEFAULT_RRF_K}. */\n\trrfK?: number;\n\t/** Rerank the fused top-50 before slicing to `limit`. Default: true. */\n\trerank?: boolean;\n\tservice?: EmbsearchService;\n\tsignal?: AbortSignal;\n}\n\nexport interface RetrieveResult {\n\tcandidates: FusedCandidate[];\n\tresolvedMode: ResolvedSearchMode;\n\tdegradedReason?: string;\n\tindexPhase: SearchTrace[\"indexPhase\"];\n\tretrievers: SearchTrace[\"retrievers\"];\n\t/** Set while the embedding index is still building. */\n\tindexing?: { done: number; total: number };\n\trrfK: number;\n\trerank?: SearchTrace[\"rerank\"];\n}\n\nexport interface RunSearchOptions extends RetrieveOptions {\n\t/** Approximate token budget for the result text. */\n\ttokenBudget?: number;\n}\n\nexport interface RunSearchResult {\n\ttext: string;\n\tresolvedMode: ResolvedSearchMode;\n\tdegradedReason?: string;\n\tresultCount: number;\n\t/** Set while the embedding index is still building. */\n\tindexing?: { done: number; total: number };\n}\n\nfunction normalizeSearchGlob(glob: string | undefined): string | undefined {\n\tif (!glob) return undefined;\n\t// Match fd/rg semantics: a slash-containing glob is anchored anywhere in\n\t// the tree, so prepend \"**/\" unless it already starts with a slash or \"**/\".\n\tif (glob.includes(\"/\") && !glob.startsWith(\"/\") && !glob.startsWith(\"**/\")) {\n\t\treturn `**/${glob}`;\n\t}\n\treturn glob;\n}\n\nexport async function retrieveCandidates(options: RetrieveOptions): Promise<RetrieveResult> {\n\tconst { cwd, query, service, signal } = options;\n\tconst glob = normalizeSearchGlob(options.glob);\n\tconst requestedMode = options.mode ?? \"auto\";\n\tconst limit = Math.max(1, options.limit ?? 10);\n\tconst rrfK = options.rrfK ?? DEFAULT_RRF_K;\n\n\tconst state = service?.getState();\n\tconst embedAvailable = service?.isAvailable() ?? false;\n\tconst embedUnavailableReason =\n\t\tstate === undefined\n\t\t\t? \"semantic index is not enabled\"\n\t\t\t: state.phase === \"unavailable\" || state.phase === \"skipped\"\n\t\t\t\t? state.reason\n\t\t\t\t: state.phase === \"idle\"\n\t\t\t\t\t? \"semantic index has not started\"\n\t\t\t\t\t: undefined;\n\n\tconst resolution = resolveSearchMode(query, requestedMode, embedAvailable, embedUnavailableReason);\n\tconst mode = resolution.mode;\n\n\t// Map lexical hits onto indexed chunk ids whenever the sidecar is usable,\n\t// even in lexical-only mode, so identities line up across modes.\n\tconst lookupChunk: ChunkLookup | undefined = embedAvailable\n\t\t? (rel, line) => service!.findEnclosingChunk(rel, line)\n\t\t: undefined;\n\n\tconst spans = new Map<string, CandidateSpan>();\n\tconst lists: RankedHit[][] = [];\n\tconst retrieverStats: SearchTrace[\"retrievers\"] = {};\n\tconst errors: Error[] = [];\n\n\tconst runLexical = async (): Promise<void> => {\n\t\tconst startedMs = Date.now();\n\t\ttry {\n\t\t\tconst lineHits = await runLexicalRetriever({ cwd, query, limit: LEXICAL_MATCH_LIMIT, glob, signal });\n\t\t\tconst adapted = adaptGrepHits(lineHits, lookupChunk);\n\t\t\t// In single-retriever lexical mode the full list is the result; in\n\t\t\t// hybrid, only the front-loaded head is trustworthy enough to vote.\n\t\t\tconst hits = mode === \"hybrid\" ? adapted.hits.slice(0, LEXICAL_FUSION_CAP) : adapted.hits;\n\t\t\tfor (const [id, span] of adapted.spans) if (!spans.has(id)) spans.set(id, span);\n\t\t\tlists.push(hits);\n\t\t\tretrieverStats.grep = { latencyMs: Date.now() - startedMs, hitCount: hits.length };\n\t\t} catch (e) {\n\t\t\terrors.push(e instanceof Error ? e : new Error(String(e)));\n\t\t\tretrieverStats.grep = { latencyMs: Date.now() - startedMs, hitCount: 0 };\n\t\t}\n\t};\n\n\tconst runEmbed = async (): Promise<void> => {\n\t\tconst startedMs = Date.now();\n\t\ttry {\n\t\t\tconst chunkHits = await service!.searchChunks(query, EMBED_TOP_K, glob);\n\t\t\tconst hits: RankedHit[] = chunkHits.map((hit, i) => ({\n\t\t\t\tid: hit.id,\n\t\t\t\trank: i + 1,\n\t\t\t\tscore: hit.score,\n\t\t\t\tsource: \"embed\",\n\t\t\t}));\n\t\t\tfor (const hit of chunkHits) {\n\t\t\t\tspans.set(hit.id, { path: hit.path, startLine: hit.startLine, endLine: hit.endLine });\n\t\t\t}\n\t\t\tlists.push(hits);\n\t\t\tretrieverStats.embed = { latencyMs: Date.now() - startedMs, hitCount: hits.length };\n\t\t} catch (e) {\n\t\t\terrors.push(e instanceof Error ? e : new Error(String(e)));\n\t\t\tretrieverStats.embed = { latencyMs: Date.now() - startedMs, hitCount: 0 };\n\t\t}\n\t};\n\n\tconst runs: Promise<void>[] = [];\n\tif (mode === \"lexical\" || mode === \"hybrid\") runs.push(runLexical());\n\tif (mode === \"semantic\" || mode === \"hybrid\") runs.push(runEmbed());\n\tawait Promise.all(runs);\n\tif (signal?.aborted) throw new Error(\"Operation aborted\");\n\t// A partial failure in hybrid degrades to whichever retriever survived;\n\t// only a total loss is an error.\n\tif (lists.length === 0) throw errors[0] ?? new Error(\"search produced no retriever results\");\n\n\tconst fused = rrfFuse(lists, rrfK).slice(0, FUSED_WINDOW);\n\tlet candidates: FusedCandidate[] = [];\n\tfor (const hit of fused) {\n\t\tconst span = spans.get(hit.id);\n\t\tif (span) candidates.push({ ...hit, ...span });\n\t}\n\n\tlet rerankInfo: SearchTrace[\"rerank\"];\n\tif (options.rerank !== false) {\n\t\tconst reranked = rerankCandidates(query, candidates, cwd);\n\t\trerankInfo = { applied: true, candidateCount: candidates.length, latencyMs: reranked.latencyMs };\n\t\tcandidates = reranked.candidates;\n\t}\n\tcandidates = candidates.slice(0, limit);\n\n\treturn {\n\t\tcandidates,\n\t\tresolvedMode: mode,\n\t\tdegradedReason: resolution.degradedReason,\n\t\tindexPhase: state?.phase === \"ready\" ? \"ready\" : state?.phase === \"indexing\" ? \"indexing\" : \"unavailable\",\n\t\tretrievers: retrieverStats,\n\t\tindexing: state?.phase === \"indexing\" ? { done: state.done, total: state.total } : undefined,\n\t\trrfK,\n\t\trerank: rerankInfo,\n\t};\n}\n\nexport async function runSearch(options: RunSearchOptions): Promise<RunSearchResult> {\n\tconst retrieved = await retrieveCandidates(options);\n\n\tconst assembled = assembleContext(retrieved.candidates, { cwd: options.cwd, tokenBudget: options.tokenBudget });\n\n\twriteSearchTrace(options.cwd, {\n\t\ttimestampMs: Date.now(),\n\t\tquery: options.query,\n\t\trequestedMode: options.mode ?? \"auto\",\n\t\tresolvedMode: retrieved.resolvedMode,\n\t\tdegradedReason: retrieved.degradedReason,\n\t\tindexPhase: retrieved.indexPhase,\n\t\trrfK: retrieved.resolvedMode === \"hybrid\" ? retrieved.rrfK : undefined,\n\t\tretrievers: retrieved.retrievers,\n\t\tfused: retrieved.candidates.map(({ id, rrfScore, ranks, rawScores }) => ({ id, rrfScore, ranks, rawScores })),\n\t\trerank: retrieved.rerank,\n\t});\n\n\treturn {\n\t\ttext: assembled.text,\n\t\tresolvedMode: retrieved.resolvedMode,\n\t\tdegradedReason: retrieved.degradedReason,\n\t\tresultCount: retrieved.candidates.length,\n\t\tindexing: retrieved.indexing,\n\t};\n}\n"]}
|
|
@@ -30,8 +30,19 @@ const LEXICAL_FUSION_CAP = 20;
|
|
|
30
30
|
const EMBED_TOP_K = 50;
|
|
31
31
|
/** Fused candidates kept for reranking / final slicing. */
|
|
32
32
|
const FUSED_WINDOW = 50;
|
|
33
|
+
function normalizeSearchGlob(glob) {
|
|
34
|
+
if (!glob)
|
|
35
|
+
return undefined;
|
|
36
|
+
// Match fd/rg semantics: a slash-containing glob is anchored anywhere in
|
|
37
|
+
// the tree, so prepend "**/" unless it already starts with a slash or "**/".
|
|
38
|
+
if (glob.includes("/") && !glob.startsWith("/") && !glob.startsWith("**/")) {
|
|
39
|
+
return `**/${glob}`;
|
|
40
|
+
}
|
|
41
|
+
return glob;
|
|
42
|
+
}
|
|
33
43
|
export async function retrieveCandidates(options) {
|
|
34
44
|
const { cwd, query, service, signal } = options;
|
|
45
|
+
const glob = normalizeSearchGlob(options.glob);
|
|
35
46
|
const requestedMode = options.mode ?? "auto";
|
|
36
47
|
const limit = Math.max(1, options.limit ?? 10);
|
|
37
48
|
const rrfK = options.rrfK ?? DEFAULT_RRF_K;
|
|
@@ -58,7 +69,7 @@ export async function retrieveCandidates(options) {
|
|
|
58
69
|
const runLexical = async () => {
|
|
59
70
|
const startedMs = Date.now();
|
|
60
71
|
try {
|
|
61
|
-
const lineHits = await runLexicalRetriever(cwd, query, LEXICAL_MATCH_LIMIT, signal);
|
|
72
|
+
const lineHits = await runLexicalRetriever({ cwd, query, limit: LEXICAL_MATCH_LIMIT, glob, signal });
|
|
62
73
|
const adapted = adaptGrepHits(lineHits, lookupChunk);
|
|
63
74
|
// In single-retriever lexical mode the full list is the result; in
|
|
64
75
|
// hybrid, only the front-loaded head is trustworthy enough to vote.
|
|
@@ -77,7 +88,7 @@ export async function retrieveCandidates(options) {
|
|
|
77
88
|
const runEmbed = async () => {
|
|
78
89
|
const startedMs = Date.now();
|
|
79
90
|
try {
|
|
80
|
-
const chunkHits = await service.searchChunks(query, EMBED_TOP_K);
|
|
91
|
+
const chunkHits = await service.searchChunks(query, EMBED_TOP_K, glob);
|
|
81
92
|
const hits = chunkHits.map((hit, i) => ({
|
|
82
93
|
id: hit.id,
|
|
83
94
|
rank: i + 1,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"hybrid-search.js","sourceRoot":"","sources":["../../../src/core/search/hybrid-search.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Hybrid search orchestrator: resolve mode, run retrievers in parallel, fuse\n * by rank, expand within budget, trace everything\n * (docs/hybrid-retrieval-design.md).\n *\n * Single-retriever modes flow through the same pipeline — rrfFuse over one\n * list preserves its order — so lexical, semantic, and hybrid all produce the\n * same result shape and the same trace record.\n *\n * `retrieveCandidates` is the candidate-level core (also used by the eval\n * harness, which needs forced modes, a configurable `k`, and no trace\n * pollution); `runSearch` wraps it with span expansion and tracing for the\n * tool.\n */\n\nimport type { EmbsearchService } from \"../embsearch/embsearch-service.js\";\nimport { adaptGrepHits, type ChunkLookup } from \"./adapter.js\";\nimport { assembleContext } from \"./context-assembler.js\";\nimport { runLexicalRetriever } from \"./lexical-retriever.js\";\nimport { resolveSearchMode } from \"./mode.js\";\nimport { rerankCandidates } from \"./rerank.js\";\nimport { DEFAULT_RRF_K, rrfFuse } from \"./rrf.js\";\nimport { writeSearchTrace } from \"./trace.js\";\nimport type { CandidateSpan, FusedCandidate, RankedHit, ResolvedSearchMode, SearchMode, SearchTrace } from \"./types.js\";\n\n/** Raw grep line-hits fetched per query (pre-collapse). */\nconst LEXICAL_MATCH_LIMIT = 200;\n/** Adapted lexical candidates entering fusion. The eval gate showed the\n * uncapped lexical tail diluting hybrid below plain semantic: lexical\n * precision is front-loaded by the adapter's term-evidence ranking, while\n * RRF weighs a rank-30 lexical candidate like a rank-30 embedding hit. */\nconst LEXICAL_FUSION_CAP = 20;\n/** Embedding hits fetched per query — deep enough for fusion to matter. */\nconst EMBED_TOP_K = 50;\n/** Fused candidates kept for reranking / final slicing. */\nconst FUSED_WINDOW = 50;\n\nexport interface RetrieveOptions {\n\tcwd: string;\n\tquery: string;\n\tmode?: SearchMode;\n\t/** Maximum fused candidates returned. */\n\tlimit?: number;\n\t/** RRF constant override (eval harness sweeps this). Default: 60. */\n\trrfK?: number;\n\t/** Rerank the fused top-50 before slicing to `limit`. Default: true. */\n\trerank?: boolean;\n\tservice?: EmbsearchService;\n\tsignal?: AbortSignal;\n}\n\nexport interface RetrieveResult {\n\tcandidates: FusedCandidate[];\n\tresolvedMode: ResolvedSearchMode;\n\tdegradedReason?: string;\n\tindexPhase: SearchTrace[\"indexPhase\"];\n\tretrievers: SearchTrace[\"retrievers\"];\n\t/** Set while the embedding index is still building. */\n\tindexing?: { done: number; total: number };\n\trrfK: number;\n\trerank?: SearchTrace[\"rerank\"];\n}\n\nexport interface RunSearchOptions extends RetrieveOptions {\n\t/** Approximate token budget for the result text. */\n\ttokenBudget?: number;\n}\n\nexport interface RunSearchResult {\n\ttext: string;\n\tresolvedMode: ResolvedSearchMode;\n\tdegradedReason?: string;\n\tresultCount: number;\n\t/** Set while the embedding index is still building. */\n\tindexing?: { done: number; total: number };\n}\n\nexport async function retrieveCandidates(options: RetrieveOptions): Promise<RetrieveResult> {\n\tconst { cwd, query, service, signal } = options;\n\tconst requestedMode = options.mode ?? \"auto\";\n\tconst limit = Math.max(1, options.limit ?? 10);\n\tconst rrfK = options.rrfK ?? DEFAULT_RRF_K;\n\n\tconst state = service?.getState();\n\tconst embedAvailable = service?.isAvailable() ?? false;\n\tconst embedUnavailableReason =\n\t\tstate === undefined\n\t\t\t? \"semantic index is not enabled\"\n\t\t\t: state.phase === \"unavailable\" || state.phase === \"skipped\"\n\t\t\t\t? state.reason\n\t\t\t\t: state.phase === \"idle\"\n\t\t\t\t\t? \"semantic index has not started\"\n\t\t\t\t\t: undefined;\n\n\tconst resolution = resolveSearchMode(query, requestedMode, embedAvailable, embedUnavailableReason);\n\tconst mode = resolution.mode;\n\n\t// Map lexical hits onto indexed chunk ids whenever the sidecar is usable,\n\t// even in lexical-only mode, so identities line up across modes.\n\tconst lookupChunk: ChunkLookup | undefined = embedAvailable\n\t\t? (rel, line) => service!.findEnclosingChunk(rel, line)\n\t\t: undefined;\n\n\tconst spans = new Map<string, CandidateSpan>();\n\tconst lists: RankedHit[][] = [];\n\tconst retrieverStats: SearchTrace[\"retrievers\"] = {};\n\tconst errors: Error[] = [];\n\n\tconst runLexical = async (): Promise<void> => {\n\t\tconst startedMs = Date.now();\n\t\ttry {\n\t\t\tconst lineHits = await runLexicalRetriever(cwd, query, LEXICAL_MATCH_LIMIT, signal);\n\t\t\tconst adapted = adaptGrepHits(lineHits, lookupChunk);\n\t\t\t// In single-retriever lexical mode the full list is the result; in\n\t\t\t// hybrid, only the front-loaded head is trustworthy enough to vote.\n\t\t\tconst hits = mode === \"hybrid\" ? adapted.hits.slice(0, LEXICAL_FUSION_CAP) : adapted.hits;\n\t\t\tfor (const [id, span] of adapted.spans) if (!spans.has(id)) spans.set(id, span);\n\t\t\tlists.push(hits);\n\t\t\tretrieverStats.grep = { latencyMs: Date.now() - startedMs, hitCount: hits.length };\n\t\t} catch (e) {\n\t\t\terrors.push(e instanceof Error ? e : new Error(String(e)));\n\t\t\tretrieverStats.grep = { latencyMs: Date.now() - startedMs, hitCount: 0 };\n\t\t}\n\t};\n\n\tconst runEmbed = async (): Promise<void> => {\n\t\tconst startedMs = Date.now();\n\t\ttry {\n\t\t\tconst chunkHits = await service!.searchChunks(query, EMBED_TOP_K);\n\t\t\tconst hits: RankedHit[] = chunkHits.map((hit, i) => ({\n\t\t\t\tid: hit.id,\n\t\t\t\trank: i + 1,\n\t\t\t\tscore: hit.score,\n\t\t\t\tsource: \"embed\",\n\t\t\t}));\n\t\t\tfor (const hit of chunkHits) {\n\t\t\t\tspans.set(hit.id, { path: hit.path, startLine: hit.startLine, endLine: hit.endLine });\n\t\t\t}\n\t\t\tlists.push(hits);\n\t\t\tretrieverStats.embed = { latencyMs: Date.now() - startedMs, hitCount: hits.length };\n\t\t} catch (e) {\n\t\t\terrors.push(e instanceof Error ? e : new Error(String(e)));\n\t\t\tretrieverStats.embed = { latencyMs: Date.now() - startedMs, hitCount: 0 };\n\t\t}\n\t};\n\n\tconst runs: Promise<void>[] = [];\n\tif (mode === \"lexical\" || mode === \"hybrid\") runs.push(runLexical());\n\tif (mode === \"semantic\" || mode === \"hybrid\") runs.push(runEmbed());\n\tawait Promise.all(runs);\n\tif (signal?.aborted) throw new Error(\"Operation aborted\");\n\t// A partial failure in hybrid degrades to whichever retriever survived;\n\t// only a total loss is an error.\n\tif (lists.length === 0) throw errors[0] ?? new Error(\"search produced no retriever results\");\n\n\tconst fused = rrfFuse(lists, rrfK).slice(0, FUSED_WINDOW);\n\tlet candidates: FusedCandidate[] = [];\n\tfor (const hit of fused) {\n\t\tconst span = spans.get(hit.id);\n\t\tif (span) candidates.push({ ...hit, ...span });\n\t}\n\n\tlet rerankInfo: SearchTrace[\"rerank\"];\n\tif (options.rerank !== false) {\n\t\tconst reranked = rerankCandidates(query, candidates, cwd);\n\t\trerankInfo = { applied: true, candidateCount: candidates.length, latencyMs: reranked.latencyMs };\n\t\tcandidates = reranked.candidates;\n\t}\n\tcandidates = candidates.slice(0, limit);\n\n\treturn {\n\t\tcandidates,\n\t\tresolvedMode: mode,\n\t\tdegradedReason: resolution.degradedReason,\n\t\tindexPhase: state?.phase === \"ready\" ? \"ready\" : state?.phase === \"indexing\" ? \"indexing\" : \"unavailable\",\n\t\tretrievers: retrieverStats,\n\t\tindexing: state?.phase === \"indexing\" ? { done: state.done, total: state.total } : undefined,\n\t\trrfK,\n\t\trerank: rerankInfo,\n\t};\n}\n\nexport async function runSearch(options: RunSearchOptions): Promise<RunSearchResult> {\n\tconst retrieved = await retrieveCandidates(options);\n\n\tconst assembled = assembleContext(retrieved.candidates, { cwd: options.cwd, tokenBudget: options.tokenBudget });\n\n\twriteSearchTrace(options.cwd, {\n\t\ttimestampMs: Date.now(),\n\t\tquery: options.query,\n\t\trequestedMode: options.mode ?? \"auto\",\n\t\tresolvedMode: retrieved.resolvedMode,\n\t\tdegradedReason: retrieved.degradedReason,\n\t\tindexPhase: retrieved.indexPhase,\n\t\trrfK: retrieved.resolvedMode === \"hybrid\" ? retrieved.rrfK : undefined,\n\t\tretrievers: retrieved.retrievers,\n\t\tfused: retrieved.candidates.map(({ id, rrfScore, ranks, rawScores }) => ({ id, rrfScore, ranks, rawScores })),\n\t\trerank: retrieved.rerank,\n\t});\n\n\treturn {\n\t\ttext: assembled.text,\n\t\tresolvedMode: retrieved.resolvedMode,\n\t\tdegradedReason: retrieved.degradedReason,\n\t\tresultCount: retrieved.candidates.length,\n\t\tindexing: retrieved.indexing,\n\t};\n}\n"]}
|
|
1
|
+
{"version":3,"file":"hybrid-search.js","sourceRoot":"","sources":["../../../src/core/search/hybrid-search.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Hybrid search orchestrator: resolve mode, run retrievers in parallel, fuse\n * by rank, expand within budget, trace everything\n * (docs/hybrid-retrieval-design.md).\n *\n * Single-retriever modes flow through the same pipeline — rrfFuse over one\n * list preserves its order — so lexical, semantic, and hybrid all produce the\n * same result shape and the same trace record.\n *\n * `retrieveCandidates` is the candidate-level core (also used by the eval\n * harness, which needs forced modes, a configurable `k`, and no trace\n * pollution); `runSearch` wraps it with span expansion and tracing for the\n * tool.\n */\n\nimport type { EmbsearchService } from \"../embsearch/embsearch-service.js\";\nimport { adaptGrepHits, type ChunkLookup } from \"./adapter.js\";\nimport { assembleContext } from \"./context-assembler.js\";\nimport { runLexicalRetriever } from \"./lexical-retriever.js\";\nimport { resolveSearchMode } from \"./mode.js\";\nimport { rerankCandidates } from \"./rerank.js\";\nimport { DEFAULT_RRF_K, rrfFuse } from \"./rrf.js\";\nimport { writeSearchTrace } from \"./trace.js\";\nimport type { CandidateSpan, FusedCandidate, RankedHit, ResolvedSearchMode, SearchMode, SearchTrace } from \"./types.js\";\n\n/** Raw grep line-hits fetched per query (pre-collapse). */\nconst LEXICAL_MATCH_LIMIT = 200;\n/** Adapted lexical candidates entering fusion. The eval gate showed the\n * uncapped lexical tail diluting hybrid below plain semantic: lexical\n * precision is front-loaded by the adapter's term-evidence ranking, while\n * RRF weighs a rank-30 lexical candidate like a rank-30 embedding hit. */\nconst LEXICAL_FUSION_CAP = 20;\n/** Embedding hits fetched per query — deep enough for fusion to matter. */\nconst EMBED_TOP_K = 50;\n/** Fused candidates kept for reranking / final slicing. */\nconst FUSED_WINDOW = 50;\n\nexport interface RetrieveOptions {\n\tcwd: string;\n\tquery: string;\n\tmode?: SearchMode;\n\t/** Optional glob filter applied to file paths. */\n\tglob?: string;\n\t/** Maximum fused candidates returned. */\n\tlimit?: number;\n\t/** RRF constant override (eval harness sweeps this). Default: {@link DEFAULT_RRF_K}. */\n\trrfK?: number;\n\t/** Rerank the fused top-50 before slicing to `limit`. Default: true. */\n\trerank?: boolean;\n\tservice?: EmbsearchService;\n\tsignal?: AbortSignal;\n}\n\nexport interface RetrieveResult {\n\tcandidates: FusedCandidate[];\n\tresolvedMode: ResolvedSearchMode;\n\tdegradedReason?: string;\n\tindexPhase: SearchTrace[\"indexPhase\"];\n\tretrievers: SearchTrace[\"retrievers\"];\n\t/** Set while the embedding index is still building. */\n\tindexing?: { done: number; total: number };\n\trrfK: number;\n\trerank?: SearchTrace[\"rerank\"];\n}\n\nexport interface RunSearchOptions extends RetrieveOptions {\n\t/** Approximate token budget for the result text. */\n\ttokenBudget?: number;\n}\n\nexport interface RunSearchResult {\n\ttext: string;\n\tresolvedMode: ResolvedSearchMode;\n\tdegradedReason?: string;\n\tresultCount: number;\n\t/** Set while the embedding index is still building. */\n\tindexing?: { done: number; total: number };\n}\n\nfunction normalizeSearchGlob(glob: string | undefined): string | undefined {\n\tif (!glob) return undefined;\n\t// Match fd/rg semantics: a slash-containing glob is anchored anywhere in\n\t// the tree, so prepend \"**/\" unless it already starts with a slash or \"**/\".\n\tif (glob.includes(\"/\") && !glob.startsWith(\"/\") && !glob.startsWith(\"**/\")) {\n\t\treturn `**/${glob}`;\n\t}\n\treturn glob;\n}\n\nexport async function retrieveCandidates(options: RetrieveOptions): Promise<RetrieveResult> {\n\tconst { cwd, query, service, signal } = options;\n\tconst glob = normalizeSearchGlob(options.glob);\n\tconst requestedMode = options.mode ?? \"auto\";\n\tconst limit = Math.max(1, options.limit ?? 10);\n\tconst rrfK = options.rrfK ?? DEFAULT_RRF_K;\n\n\tconst state = service?.getState();\n\tconst embedAvailable = service?.isAvailable() ?? false;\n\tconst embedUnavailableReason =\n\t\tstate === undefined\n\t\t\t? \"semantic index is not enabled\"\n\t\t\t: state.phase === \"unavailable\" || state.phase === \"skipped\"\n\t\t\t\t? state.reason\n\t\t\t\t: state.phase === \"idle\"\n\t\t\t\t\t? \"semantic index has not started\"\n\t\t\t\t\t: undefined;\n\n\tconst resolution = resolveSearchMode(query, requestedMode, embedAvailable, embedUnavailableReason);\n\tconst mode = resolution.mode;\n\n\t// Map lexical hits onto indexed chunk ids whenever the sidecar is usable,\n\t// even in lexical-only mode, so identities line up across modes.\n\tconst lookupChunk: ChunkLookup | undefined = embedAvailable\n\t\t? (rel, line) => service!.findEnclosingChunk(rel, line)\n\t\t: undefined;\n\n\tconst spans = new Map<string, CandidateSpan>();\n\tconst lists: RankedHit[][] = [];\n\tconst retrieverStats: SearchTrace[\"retrievers\"] = {};\n\tconst errors: Error[] = [];\n\n\tconst runLexical = async (): Promise<void> => {\n\t\tconst startedMs = Date.now();\n\t\ttry {\n\t\t\tconst lineHits = await runLexicalRetriever({ cwd, query, limit: LEXICAL_MATCH_LIMIT, glob, signal });\n\t\t\tconst adapted = adaptGrepHits(lineHits, lookupChunk);\n\t\t\t// In single-retriever lexical mode the full list is the result; in\n\t\t\t// hybrid, only the front-loaded head is trustworthy enough to vote.\n\t\t\tconst hits = mode === \"hybrid\" ? adapted.hits.slice(0, LEXICAL_FUSION_CAP) : adapted.hits;\n\t\t\tfor (const [id, span] of adapted.spans) if (!spans.has(id)) spans.set(id, span);\n\t\t\tlists.push(hits);\n\t\t\tretrieverStats.grep = { latencyMs: Date.now() - startedMs, hitCount: hits.length };\n\t\t} catch (e) {\n\t\t\terrors.push(e instanceof Error ? e : new Error(String(e)));\n\t\t\tretrieverStats.grep = { latencyMs: Date.now() - startedMs, hitCount: 0 };\n\t\t}\n\t};\n\n\tconst runEmbed = async (): Promise<void> => {\n\t\tconst startedMs = Date.now();\n\t\ttry {\n\t\t\tconst chunkHits = await service!.searchChunks(query, EMBED_TOP_K, glob);\n\t\t\tconst hits: RankedHit[] = chunkHits.map((hit, i) => ({\n\t\t\t\tid: hit.id,\n\t\t\t\trank: i + 1,\n\t\t\t\tscore: hit.score,\n\t\t\t\tsource: \"embed\",\n\t\t\t}));\n\t\t\tfor (const hit of chunkHits) {\n\t\t\t\tspans.set(hit.id, { path: hit.path, startLine: hit.startLine, endLine: hit.endLine });\n\t\t\t}\n\t\t\tlists.push(hits);\n\t\t\tretrieverStats.embed = { latencyMs: Date.now() - startedMs, hitCount: hits.length };\n\t\t} catch (e) {\n\t\t\terrors.push(e instanceof Error ? e : new Error(String(e)));\n\t\t\tretrieverStats.embed = { latencyMs: Date.now() - startedMs, hitCount: 0 };\n\t\t}\n\t};\n\n\tconst runs: Promise<void>[] = [];\n\tif (mode === \"lexical\" || mode === \"hybrid\") runs.push(runLexical());\n\tif (mode === \"semantic\" || mode === \"hybrid\") runs.push(runEmbed());\n\tawait Promise.all(runs);\n\tif (signal?.aborted) throw new Error(\"Operation aborted\");\n\t// A partial failure in hybrid degrades to whichever retriever survived;\n\t// only a total loss is an error.\n\tif (lists.length === 0) throw errors[0] ?? new Error(\"search produced no retriever results\");\n\n\tconst fused = rrfFuse(lists, rrfK).slice(0, FUSED_WINDOW);\n\tlet candidates: FusedCandidate[] = [];\n\tfor (const hit of fused) {\n\t\tconst span = spans.get(hit.id);\n\t\tif (span) candidates.push({ ...hit, ...span });\n\t}\n\n\tlet rerankInfo: SearchTrace[\"rerank\"];\n\tif (options.rerank !== false) {\n\t\tconst reranked = rerankCandidates(query, candidates, cwd);\n\t\trerankInfo = { applied: true, candidateCount: candidates.length, latencyMs: reranked.latencyMs };\n\t\tcandidates = reranked.candidates;\n\t}\n\tcandidates = candidates.slice(0, limit);\n\n\treturn {\n\t\tcandidates,\n\t\tresolvedMode: mode,\n\t\tdegradedReason: resolution.degradedReason,\n\t\tindexPhase: state?.phase === \"ready\" ? \"ready\" : state?.phase === \"indexing\" ? \"indexing\" : \"unavailable\",\n\t\tretrievers: retrieverStats,\n\t\tindexing: state?.phase === \"indexing\" ? { done: state.done, total: state.total } : undefined,\n\t\trrfK,\n\t\trerank: rerankInfo,\n\t};\n}\n\nexport async function runSearch(options: RunSearchOptions): Promise<RunSearchResult> {\n\tconst retrieved = await retrieveCandidates(options);\n\n\tconst assembled = assembleContext(retrieved.candidates, { cwd: options.cwd, tokenBudget: options.tokenBudget });\n\n\twriteSearchTrace(options.cwd, {\n\t\ttimestampMs: Date.now(),\n\t\tquery: options.query,\n\t\trequestedMode: options.mode ?? \"auto\",\n\t\tresolvedMode: retrieved.resolvedMode,\n\t\tdegradedReason: retrieved.degradedReason,\n\t\tindexPhase: retrieved.indexPhase,\n\t\trrfK: retrieved.resolvedMode === \"hybrid\" ? retrieved.rrfK : undefined,\n\t\tretrievers: retrieved.retrievers,\n\t\tfused: retrieved.candidates.map(({ id, rrfScore, ranks, rawScores }) => ({ id, rrfScore, ranks, rawScores })),\n\t\trerank: retrieved.rerank,\n\t});\n\n\treturn {\n\t\ttext: assembled.text,\n\t\tresolvedMode: retrieved.resolvedMode,\n\t\tdegradedReason: retrieved.degradedReason,\n\t\tresultCount: retrieved.candidates.length,\n\t\tindexing: retrieved.indexing,\n\t};\n}\n"]}
|
|
@@ -26,5 +26,12 @@ export declare function buildLexicalPattern(query: string): string | undefined;
|
|
|
26
26
|
* Run lexical retrieval over `cwd`, returning up to `limit` line-hits in
|
|
27
27
|
* output order with POSIX repo-relative paths.
|
|
28
28
|
*/
|
|
29
|
-
export
|
|
29
|
+
export interface RunLexicalOptions {
|
|
30
|
+
cwd: string;
|
|
31
|
+
query: string;
|
|
32
|
+
limit: number;
|
|
33
|
+
glob?: string;
|
|
34
|
+
signal?: AbortSignal;
|
|
35
|
+
}
|
|
36
|
+
export declare function runLexicalRetriever(options: RunLexicalOptions): Promise<GrepLineHit[]>;
|
|
30
37
|
//# sourceMappingURL=lexical-retriever.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"lexical-retriever.d.ts","sourceRoot":"","sources":["../../../src/core/search/lexical-retriever.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAQH,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,cAAc,CAAC;AAWhD,MAAM,WAAW,gBAAgB;IAChC,8BAA8B;IAC9B,OAAO,EAAE,MAAM,CAAC;IAChB,wEAAwE;IACxE,KAAK,EAAE,MAAM,EAAE,CAAC;CAChB;AAED;;;;GAIG;AACH,wBAAgB,qBAAqB,CAAC,KAAK,EAAE,MAAM,GAAG,gBAAgB,GAAG,SAAS,CAgBjF;AAED,0DAA0D;AAC1D,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAErE;AAUD;;;GAGG;AACH,
|
|
1
|
+
{"version":3,"file":"lexical-retriever.d.ts","sourceRoot":"","sources":["../../../src/core/search/lexical-retriever.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAQH,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,cAAc,CAAC;AAWhD,MAAM,WAAW,gBAAgB;IAChC,8BAA8B;IAC9B,OAAO,EAAE,MAAM,CAAC;IAChB,wEAAwE;IACxE,KAAK,EAAE,MAAM,EAAE,CAAC;CAChB;AAED;;;;GAIG;AACH,wBAAgB,qBAAqB,CAAC,KAAK,EAAE,MAAM,GAAG,gBAAgB,GAAG,SAAS,CAgBjF;AAED,0DAA0D;AAC1D,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAErE;AAUD;;;GAGG;AACH,MAAM,WAAW,iBAAiB;IACjC,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,MAAM,CAAC,EAAE,WAAW,CAAC;CACrB;AAiBD,wBAAsB,mBAAmB,CAAC,OAAO,EAAE,iBAAiB,GAAG,OAAO,CAAC,WAAW,EAAE,CAAC,CA4F5F","sourcesContent":["/**\n * Internal lexical retriever for hybrid search.\n *\n * This is not the grep *tool* — it is the lexical recall backend: it turns a\n * natural query into a ripgrep pattern, streams matches, and returns bare\n * `rel:line` hits for the grep→chunk adapter. rg drives the fast path; the\n * pure-JS nativeGrep fallback keeps restricted environments working, same as\n * the grep tool.\n */\n\nimport { createInterface } from \"node:readline\";\nimport { spawn } from \"child_process\";\nimport { readFileSync } from \"fs\";\nimport path from \"path\";\nimport { ensureTool } from \"../../utils/tools-manager.js\";\nimport { isNativeSearchForced, nativeGrep } from \"../tools/native-search.js\";\nimport type { GrepLineHit } from \"./adapter.js\";\n\n/** Terms considered per query (longest first) when building the pattern. */\nconst MAX_TERMS = 4;\n/** Minimum token length worth matching on. */\nconst MIN_TERM_LENGTH = 3;\n\nfunction escapeRegExp(value: string): string {\n\treturn value.replace(/[.*+?^${}()|[\\]\\\\]/g, \"\\\\$&\");\n}\n\nexport interface LexicalQueryPlan {\n\t/** rg-ready regex pattern. */\n\tpattern: string;\n\t/** Raw (unescaped, lowercased) terms, for per-line term attribution. */\n\tterms: string[];\n}\n\n/**\n * Build the retrieval plan for a query: a quoted segment is searched\n * verbatim; otherwise the longest few identifier-ish tokens are OR-ed\n * together. Returns undefined when the query yields nothing searchable.\n */\nexport function buildLexicalQueryPlan(query: string): LexicalQueryPlan | undefined {\n\tconst quoted = [...query.matchAll(/[\"'`]([^\"'`]+)[\"'`]/g)]\n\t\t.map((m) => m[1].trim())\n\t\t.filter((s) => s.length > 0)\n\t\t.sort((a, b) => b.length - a.length)[0];\n\tif (quoted) return { pattern: escapeRegExp(quoted), terms: [quoted.toLowerCase()] };\n\n\tconst tokens = [...new Set(query.match(/[A-Za-z0-9_$][\\w$.-]*/g) ?? [])]\n\t\t.filter((t) => t.length >= MIN_TERM_LENGTH)\n\t\t.sort((a, b) => b.length - a.length || a.localeCompare(b))\n\t\t.slice(0, MAX_TERMS);\n\tif (tokens.length === 0) {\n\t\tconst trimmed = query.trim();\n\t\treturn trimmed ? { pattern: escapeRegExp(trimmed), terms: [trimmed.toLowerCase()] } : undefined;\n\t}\n\treturn { pattern: tokens.map(escapeRegExp).join(\"|\"), terms: tokens.map((t) => t.toLowerCase()) };\n}\n\n/** Pattern-only view of {@link buildLexicalQueryPlan}. */\nexport function buildLexicalPattern(query: string): string | undefined {\n\treturn buildLexicalQueryPlan(query)?.pattern;\n}\n\n/** Which plan terms appear on a matched line (retrieval is case-insensitive,\n * so attribution is too). */\nfunction termsOnLine(plan: LexicalQueryPlan, lineText: string | undefined): string[] {\n\tif (!lineText) return [];\n\tconst lower = lineText.toLowerCase();\n\treturn plan.terms.filter((t) => lower.includes(t));\n}\n\n/**\n * Run lexical retrieval over `cwd`, returning up to `limit` line-hits in\n * output order with POSIX repo-relative paths.\n */\nexport interface RunLexicalOptions {\n\tcwd: string;\n\tquery: string;\n\tlimit: number;\n\tglob?: string;\n\tsignal?: AbortSignal;\n}\n\n/**\n * Run the lexical retriever for the search tool. Optional glob filter scopes\n * file paths (slashless matches basename anywhere, slash patterns match the\n * full repo-relative path).\n */\nfunction normalizeSearchGlob(glob: string | undefined): string | undefined {\n\tif (!glob) return undefined;\n\t// Match fd/rg semantics: a slash-containing glob is anchored anywhere in\n\t// the tree, so prepend \"**/\" unless it already starts with a slash or \"**/\".\n\tif (glob.includes(\"/\") && !glob.startsWith(\"/\") && !glob.startsWith(\"**/\")) {\n\t\treturn `**/${glob}`;\n\t}\n\treturn glob;\n}\n\nexport async function runLexicalRetriever(options: RunLexicalOptions): Promise<GrepLineHit[]> {\n\tconst { cwd, query, limit, glob: rawGlob, signal } = options;\n\tconst glob = normalizeSearchGlob(rawGlob);\n\tconst plan = buildLexicalQueryPlan(query);\n\tif (!plan) return [];\n\tconst { pattern } = plan;\n\n\tconst toRel = (filePath: string): string => {\n\t\tconst rel = path.relative(cwd, filePath);\n\t\treturn (rel && !rel.startsWith(\"..\") ? rel : filePath).replace(/\\\\/g, \"/\");\n\t};\n\n\tconst rgPath = isNativeSearchForced() ? undefined : await ensureTool(\"rg\", true);\n\tif (!rgPath) {\n\t\tconst result = await nativeGrep(cwd, {\n\t\t\tpattern,\n\t\t\tisDirectory: true,\n\t\t\tignoreCase: true,\n\t\t\tlimit,\n\t\t\tglob,\n\t\t\tsignal,\n\t\t\treadFile: (p) => readFileSync(p, \"utf-8\"),\n\t\t});\n\t\treturn result.matches.map((m) => ({\n\t\t\trel: toRel(m.filePath),\n\t\t\tline: m.lineNumber,\n\t\t\tterms: termsOnLine(plan, m.lineText),\n\t\t}));\n\t}\n\n\treturn new Promise<GrepLineHit[]>((resolve, reject) => {\n\t\t// --sort path forces a deterministic (single-threaded) walk: with the\n\t\t// match cap truncating the stream, a parallel walk would return a\n\t\t// different hit subset per run — \"same query, different context\".\n\t\tconst args = [\"--json\", \"--line-number\", \"--color=never\", \"--hidden\", \"--ignore-case\", \"--sort\", \"path\"];\n\t\tif (glob) args.push(\"--glob\", glob);\n\t\targs.push(\"--\", pattern, cwd);\n\t\tconst child = spawn(rgPath, args, { stdio: [\"ignore\", \"pipe\", \"pipe\"] });\n\t\tconst rl = createInterface({ input: child.stdout });\n\t\tconst hits: GrepLineHit[] = [];\n\t\tlet stderr = \"\";\n\t\tlet killedDueToLimit = false;\n\t\tlet aborted = false;\n\n\t\tconst onAbort = () => {\n\t\t\taborted = true;\n\t\t\tif (!child.killed) child.kill();\n\t\t};\n\t\tsignal?.addEventListener(\"abort\", onAbort, { once: true });\n\t\tchild.stderr?.on(\"data\", (chunk) => {\n\t\t\tstderr += chunk.toString();\n\t\t});\n\n\t\trl.on(\"line\", (line) => {\n\t\t\tif (!line.trim() || hits.length >= limit) return;\n\t\t\tlet event: any;\n\t\t\ttry {\n\t\t\t\tevent = JSON.parse(line);\n\t\t\t} catch {\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tif (event.type !== \"match\") return;\n\t\t\tconst filePath = event.data?.path?.text;\n\t\t\tconst lineNumber = event.data?.line_number;\n\t\t\tif (filePath && typeof lineNumber === \"number\") {\n\t\t\t\thits.push({ rel: toRel(filePath), line: lineNumber, terms: termsOnLine(plan, event.data?.lines?.text) });\n\t\t\t}\n\t\t\tif (hits.length >= limit && !child.killed) {\n\t\t\t\tkilledDueToLimit = true;\n\t\t\t\tchild.kill();\n\t\t\t}\n\t\t});\n\n\t\tchild.on(\"error\", (error) => {\n\t\t\tsignal?.removeEventListener(\"abort\", onAbort);\n\t\t\treject(new Error(`Failed to run ripgrep: ${error.message}`));\n\t\t});\n\t\tchild.on(\"close\", (code) => {\n\t\t\trl.close();\n\t\t\tsignal?.removeEventListener(\"abort\", onAbort);\n\t\t\tif (aborted) {\n\t\t\t\treject(new Error(\"Operation aborted\"));\n\t\t\t\treturn;\n\t\t\t}\n\t\t\t// rg exits 1 on \"no matches\" — that is a valid empty result.\n\t\t\tif (!killedDueToLimit && code !== 0 && code !== 1) {\n\t\t\t\treject(new Error(stderr.trim() || `ripgrep exited with code ${code}`));\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tresolve(hits);\n\t\t});\n\t});\n}\n"]}
|
|
@@ -55,10 +55,23 @@ function termsOnLine(plan, lineText) {
|
|
|
55
55
|
return plan.terms.filter((t) => lower.includes(t));
|
|
56
56
|
}
|
|
57
57
|
/**
|
|
58
|
-
* Run lexical
|
|
59
|
-
*
|
|
58
|
+
* Run the lexical retriever for the search tool. Optional glob filter scopes
|
|
59
|
+
* file paths (slashless matches basename anywhere, slash patterns match the
|
|
60
|
+
* full repo-relative path).
|
|
60
61
|
*/
|
|
61
|
-
|
|
62
|
+
function normalizeSearchGlob(glob) {
|
|
63
|
+
if (!glob)
|
|
64
|
+
return undefined;
|
|
65
|
+
// Match fd/rg semantics: a slash-containing glob is anchored anywhere in
|
|
66
|
+
// the tree, so prepend "**/" unless it already starts with a slash or "**/".
|
|
67
|
+
if (glob.includes("/") && !glob.startsWith("/") && !glob.startsWith("**/")) {
|
|
68
|
+
return `**/${glob}`;
|
|
69
|
+
}
|
|
70
|
+
return glob;
|
|
71
|
+
}
|
|
72
|
+
export async function runLexicalRetriever(options) {
|
|
73
|
+
const { cwd, query, limit, glob: rawGlob, signal } = options;
|
|
74
|
+
const glob = normalizeSearchGlob(rawGlob);
|
|
62
75
|
const plan = buildLexicalQueryPlan(query);
|
|
63
76
|
if (!plan)
|
|
64
77
|
return [];
|
|
@@ -74,6 +87,7 @@ export async function runLexicalRetriever(cwd, query, limit, signal) {
|
|
|
74
87
|
isDirectory: true,
|
|
75
88
|
ignoreCase: true,
|
|
76
89
|
limit,
|
|
90
|
+
glob,
|
|
77
91
|
signal,
|
|
78
92
|
readFile: (p) => readFileSync(p, "utf-8"),
|
|
79
93
|
});
|
|
@@ -87,18 +101,10 @@ export async function runLexicalRetriever(cwd, query, limit, signal) {
|
|
|
87
101
|
// --sort path forces a deterministic (single-threaded) walk: with the
|
|
88
102
|
// match cap truncating the stream, a parallel walk would return a
|
|
89
103
|
// different hit subset per run — "same query, different context".
|
|
90
|
-
const args = [
|
|
91
|
-
|
|
92
|
-
"--
|
|
93
|
-
|
|
94
|
-
"--hidden",
|
|
95
|
-
"--ignore-case",
|
|
96
|
-
"--sort",
|
|
97
|
-
"path",
|
|
98
|
-
"--",
|
|
99
|
-
pattern,
|
|
100
|
-
cwd,
|
|
101
|
-
];
|
|
104
|
+
const args = ["--json", "--line-number", "--color=never", "--hidden", "--ignore-case", "--sort", "path"];
|
|
105
|
+
if (glob)
|
|
106
|
+
args.push("--glob", glob);
|
|
107
|
+
args.push("--", pattern, cwd);
|
|
102
108
|
const child = spawn(rgPath, args, { stdio: ["ignore", "pipe", "pipe"] });
|
|
103
109
|
const rl = createInterface({ input: child.stdout });
|
|
104
110
|
const hits = [];
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"lexical-retriever.js","sourceRoot":"","sources":["../../../src/core/search/lexical-retriever.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Internal lexical retriever for hybrid search.\n *\n * This is not the grep *tool* — it is the lexical recall backend: it turns a\n * natural query into a ripgrep pattern, streams matches, and returns bare\n * `rel:line` hits for the grep→chunk adapter. rg drives the fast path; the\n * pure-JS nativeGrep fallback keeps restricted environments working, same as\n * the grep tool.\n */\n\nimport { createInterface } from \"node:readline\";\nimport { spawn } from \"child_process\";\nimport { readFileSync } from \"fs\";\nimport path from \"path\";\nimport { ensureTool } from \"../../utils/tools-manager.js\";\nimport { isNativeSearchForced, nativeGrep } from \"../tools/native-search.js\";\nimport type { GrepLineHit } from \"./adapter.js\";\n\n/** Terms considered per query (longest first) when building the pattern. */\nconst MAX_TERMS = 4;\n/** Minimum token length worth matching on. */\nconst MIN_TERM_LENGTH = 3;\n\nfunction escapeRegExp(value: string): string {\n\treturn value.replace(/[.*+?^${}()|[\\]\\\\]/g, \"\\\\$&\");\n}\n\nexport interface LexicalQueryPlan {\n\t/** rg-ready regex pattern. */\n\tpattern: string;\n\t/** Raw (unescaped, lowercased) terms, for per-line term attribution. */\n\tterms: string[];\n}\n\n/**\n * Build the retrieval plan for a query: a quoted segment is searched\n * verbatim; otherwise the longest few identifier-ish tokens are OR-ed\n * together. Returns undefined when the query yields nothing searchable.\n */\nexport function buildLexicalQueryPlan(query: string): LexicalQueryPlan | undefined {\n\tconst quoted = [...query.matchAll(/[\"'`]([^\"'`]+)[\"'`]/g)]\n\t\t.map((m) => m[1].trim())\n\t\t.filter((s) => s.length > 0)\n\t\t.sort((a, b) => b.length - a.length)[0];\n\tif (quoted) return { pattern: escapeRegExp(quoted), terms: [quoted.toLowerCase()] };\n\n\tconst tokens = [...new Set(query.match(/[A-Za-z0-9_$][\\w$.-]*/g) ?? [])]\n\t\t.filter((t) => t.length >= MIN_TERM_LENGTH)\n\t\t.sort((a, b) => b.length - a.length || a.localeCompare(b))\n\t\t.slice(0, MAX_TERMS);\n\tif (tokens.length === 0) {\n\t\tconst trimmed = query.trim();\n\t\treturn trimmed ? { pattern: escapeRegExp(trimmed), terms: [trimmed.toLowerCase()] } : undefined;\n\t}\n\treturn { pattern: tokens.map(escapeRegExp).join(\"|\"), terms: tokens.map((t) => t.toLowerCase()) };\n}\n\n/** Pattern-only view of {@link buildLexicalQueryPlan}. */\nexport function buildLexicalPattern(query: string): string | undefined {\n\treturn buildLexicalQueryPlan(query)?.pattern;\n}\n\n/** Which plan terms appear on a matched line (retrieval is case-insensitive,\n * so attribution is too). */\nfunction termsOnLine(plan: LexicalQueryPlan, lineText: string | undefined): string[] {\n\tif (!lineText) return [];\n\tconst lower = lineText.toLowerCase();\n\treturn plan.terms.filter((t) => lower.includes(t));\n}\n\n/**\n * Run lexical retrieval over `cwd`, returning up to `limit` line-hits in\n * output order with POSIX repo-relative paths.\n */\nexport async function runLexicalRetriever(\n\tcwd: string,\n\tquery: string,\n\tlimit: number,\n\tsignal?: AbortSignal,\n): Promise<GrepLineHit[]> {\n\tconst plan = buildLexicalQueryPlan(query);\n\tif (!plan) return [];\n\tconst { pattern } = plan;\n\n\tconst toRel = (filePath: string): string => {\n\t\tconst rel = path.relative(cwd, filePath);\n\t\treturn (rel && !rel.startsWith(\"..\") ? rel : filePath).replace(/\\\\/g, \"/\");\n\t};\n\n\tconst rgPath = isNativeSearchForced() ? undefined : await ensureTool(\"rg\", true);\n\tif (!rgPath) {\n\t\tconst result = await nativeGrep(cwd, {\n\t\t\tpattern,\n\t\t\tisDirectory: true,\n\t\t\tignoreCase: true,\n\t\t\tlimit,\n\t\t\tsignal,\n\t\t\treadFile: (p) => readFileSync(p, \"utf-8\"),\n\t\t});\n\t\treturn result.matches.map((m) => ({\n\t\t\trel: toRel(m.filePath),\n\t\t\tline: m.lineNumber,\n\t\t\tterms: termsOnLine(plan, m.lineText),\n\t\t}));\n\t}\n\n\treturn new Promise<GrepLineHit[]>((resolve, reject) => {\n\t\t// --sort path forces a deterministic (single-threaded) walk: with the\n\t\t// match cap truncating the stream, a parallel walk would return a\n\t\t// different hit subset per run — \"same query, different context\".\n\t\tconst args = [\n\t\t\t\"--json\",\n\t\t\t\"--line-number\",\n\t\t\t\"--color=never\",\n\t\t\t\"--hidden\",\n\t\t\t\"--ignore-case\",\n\t\t\t\"--sort\",\n\t\t\t\"path\",\n\t\t\t\"--\",\n\t\t\tpattern,\n\t\t\tcwd,\n\t\t];\n\t\tconst child = spawn(rgPath, args, { stdio: [\"ignore\", \"pipe\", \"pipe\"] });\n\t\tconst rl = createInterface({ input: child.stdout });\n\t\tconst hits: GrepLineHit[] = [];\n\t\tlet stderr = \"\";\n\t\tlet killedDueToLimit = false;\n\t\tlet aborted = false;\n\n\t\tconst onAbort = () => {\n\t\t\taborted = true;\n\t\t\tif (!child.killed) child.kill();\n\t\t};\n\t\tsignal?.addEventListener(\"abort\", onAbort, { once: true });\n\t\tchild.stderr?.on(\"data\", (chunk) => {\n\t\t\tstderr += chunk.toString();\n\t\t});\n\n\t\trl.on(\"line\", (line) => {\n\t\t\tif (!line.trim() || hits.length >= limit) return;\n\t\t\tlet event: any;\n\t\t\ttry {\n\t\t\t\tevent = JSON.parse(line);\n\t\t\t} catch {\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tif (event.type !== \"match\") return;\n\t\t\tconst filePath = event.data?.path?.text;\n\t\t\tconst lineNumber = event.data?.line_number;\n\t\t\tif (filePath && typeof lineNumber === \"number\") {\n\t\t\t\thits.push({ rel: toRel(filePath), line: lineNumber, terms: termsOnLine(plan, event.data?.lines?.text) });\n\t\t\t}\n\t\t\tif (hits.length >= limit && !child.killed) {\n\t\t\t\tkilledDueToLimit = true;\n\t\t\t\tchild.kill();\n\t\t\t}\n\t\t});\n\n\t\tchild.on(\"error\", (error) => {\n\t\t\tsignal?.removeEventListener(\"abort\", onAbort);\n\t\t\treject(new Error(`Failed to run ripgrep: ${error.message}`));\n\t\t});\n\t\tchild.on(\"close\", (code) => {\n\t\t\trl.close();\n\t\t\tsignal?.removeEventListener(\"abort\", onAbort);\n\t\t\tif (aborted) {\n\t\t\t\treject(new Error(\"Operation aborted\"));\n\t\t\t\treturn;\n\t\t\t}\n\t\t\t// rg exits 1 on \"no matches\" — that is a valid empty result.\n\t\t\tif (!killedDueToLimit && code !== 0 && code !== 1) {\n\t\t\t\treject(new Error(stderr.trim() || `ripgrep exited with code ${code}`));\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tresolve(hits);\n\t\t});\n\t});\n}\n"]}
|
|
1
|
+
{"version":3,"file":"lexical-retriever.js","sourceRoot":"","sources":["../../../src/core/search/lexical-retriever.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Internal lexical retriever for hybrid search.\n *\n * This is not the grep *tool* — it is the lexical recall backend: it turns a\n * natural query into a ripgrep pattern, streams matches, and returns bare\n * `rel:line` hits for the grep→chunk adapter. rg drives the fast path; the\n * pure-JS nativeGrep fallback keeps restricted environments working, same as\n * the grep tool.\n */\n\nimport { createInterface } from \"node:readline\";\nimport { spawn } from \"child_process\";\nimport { readFileSync } from \"fs\";\nimport path from \"path\";\nimport { ensureTool } from \"../../utils/tools-manager.js\";\nimport { isNativeSearchForced, nativeGrep } from \"../tools/native-search.js\";\nimport type { GrepLineHit } from \"./adapter.js\";\n\n/** Terms considered per query (longest first) when building the pattern. */\nconst MAX_TERMS = 4;\n/** Minimum token length worth matching on. */\nconst MIN_TERM_LENGTH = 3;\n\nfunction escapeRegExp(value: string): string {\n\treturn value.replace(/[.*+?^${}()|[\\]\\\\]/g, \"\\\\$&\");\n}\n\nexport interface LexicalQueryPlan {\n\t/** rg-ready regex pattern. */\n\tpattern: string;\n\t/** Raw (unescaped, lowercased) terms, for per-line term attribution. */\n\tterms: string[];\n}\n\n/**\n * Build the retrieval plan for a query: a quoted segment is searched\n * verbatim; otherwise the longest few identifier-ish tokens are OR-ed\n * together. Returns undefined when the query yields nothing searchable.\n */\nexport function buildLexicalQueryPlan(query: string): LexicalQueryPlan | undefined {\n\tconst quoted = [...query.matchAll(/[\"'`]([^\"'`]+)[\"'`]/g)]\n\t\t.map((m) => m[1].trim())\n\t\t.filter((s) => s.length > 0)\n\t\t.sort((a, b) => b.length - a.length)[0];\n\tif (quoted) return { pattern: escapeRegExp(quoted), terms: [quoted.toLowerCase()] };\n\n\tconst tokens = [...new Set(query.match(/[A-Za-z0-9_$][\\w$.-]*/g) ?? [])]\n\t\t.filter((t) => t.length >= MIN_TERM_LENGTH)\n\t\t.sort((a, b) => b.length - a.length || a.localeCompare(b))\n\t\t.slice(0, MAX_TERMS);\n\tif (tokens.length === 0) {\n\t\tconst trimmed = query.trim();\n\t\treturn trimmed ? { pattern: escapeRegExp(trimmed), terms: [trimmed.toLowerCase()] } : undefined;\n\t}\n\treturn { pattern: tokens.map(escapeRegExp).join(\"|\"), terms: tokens.map((t) => t.toLowerCase()) };\n}\n\n/** Pattern-only view of {@link buildLexicalQueryPlan}. */\nexport function buildLexicalPattern(query: string): string | undefined {\n\treturn buildLexicalQueryPlan(query)?.pattern;\n}\n\n/** Which plan terms appear on a matched line (retrieval is case-insensitive,\n * so attribution is too). */\nfunction termsOnLine(plan: LexicalQueryPlan, lineText: string | undefined): string[] {\n\tif (!lineText) return [];\n\tconst lower = lineText.toLowerCase();\n\treturn plan.terms.filter((t) => lower.includes(t));\n}\n\n/**\n * Run lexical retrieval over `cwd`, returning up to `limit` line-hits in\n * output order with POSIX repo-relative paths.\n */\nexport interface RunLexicalOptions {\n\tcwd: string;\n\tquery: string;\n\tlimit: number;\n\tglob?: string;\n\tsignal?: AbortSignal;\n}\n\n/**\n * Run the lexical retriever for the search tool. Optional glob filter scopes\n * file paths (slashless matches basename anywhere, slash patterns match the\n * full repo-relative path).\n */\nfunction normalizeSearchGlob(glob: string | undefined): string | undefined {\n\tif (!glob) return undefined;\n\t// Match fd/rg semantics: a slash-containing glob is anchored anywhere in\n\t// the tree, so prepend \"**/\" unless it already starts with a slash or \"**/\".\n\tif (glob.includes(\"/\") && !glob.startsWith(\"/\") && !glob.startsWith(\"**/\")) {\n\t\treturn `**/${glob}`;\n\t}\n\treturn glob;\n}\n\nexport async function runLexicalRetriever(options: RunLexicalOptions): Promise<GrepLineHit[]> {\n\tconst { cwd, query, limit, glob: rawGlob, signal } = options;\n\tconst glob = normalizeSearchGlob(rawGlob);\n\tconst plan = buildLexicalQueryPlan(query);\n\tif (!plan) return [];\n\tconst { pattern } = plan;\n\n\tconst toRel = (filePath: string): string => {\n\t\tconst rel = path.relative(cwd, filePath);\n\t\treturn (rel && !rel.startsWith(\"..\") ? rel : filePath).replace(/\\\\/g, \"/\");\n\t};\n\n\tconst rgPath = isNativeSearchForced() ? undefined : await ensureTool(\"rg\", true);\n\tif (!rgPath) {\n\t\tconst result = await nativeGrep(cwd, {\n\t\t\tpattern,\n\t\t\tisDirectory: true,\n\t\t\tignoreCase: true,\n\t\t\tlimit,\n\t\t\tglob,\n\t\t\tsignal,\n\t\t\treadFile: (p) => readFileSync(p, \"utf-8\"),\n\t\t});\n\t\treturn result.matches.map((m) => ({\n\t\t\trel: toRel(m.filePath),\n\t\t\tline: m.lineNumber,\n\t\t\tterms: termsOnLine(plan, m.lineText),\n\t\t}));\n\t}\n\n\treturn new Promise<GrepLineHit[]>((resolve, reject) => {\n\t\t// --sort path forces a deterministic (single-threaded) walk: with the\n\t\t// match cap truncating the stream, a parallel walk would return a\n\t\t// different hit subset per run — \"same query, different context\".\n\t\tconst args = [\"--json\", \"--line-number\", \"--color=never\", \"--hidden\", \"--ignore-case\", \"--sort\", \"path\"];\n\t\tif (glob) args.push(\"--glob\", glob);\n\t\targs.push(\"--\", pattern, cwd);\n\t\tconst child = spawn(rgPath, args, { stdio: [\"ignore\", \"pipe\", \"pipe\"] });\n\t\tconst rl = createInterface({ input: child.stdout });\n\t\tconst hits: GrepLineHit[] = [];\n\t\tlet stderr = \"\";\n\t\tlet killedDueToLimit = false;\n\t\tlet aborted = false;\n\n\t\tconst onAbort = () => {\n\t\t\taborted = true;\n\t\t\tif (!child.killed) child.kill();\n\t\t};\n\t\tsignal?.addEventListener(\"abort\", onAbort, { once: true });\n\t\tchild.stderr?.on(\"data\", (chunk) => {\n\t\t\tstderr += chunk.toString();\n\t\t});\n\n\t\trl.on(\"line\", (line) => {\n\t\t\tif (!line.trim() || hits.length >= limit) return;\n\t\t\tlet event: any;\n\t\t\ttry {\n\t\t\t\tevent = JSON.parse(line);\n\t\t\t} catch {\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tif (event.type !== \"match\") return;\n\t\t\tconst filePath = event.data?.path?.text;\n\t\t\tconst lineNumber = event.data?.line_number;\n\t\t\tif (filePath && typeof lineNumber === \"number\") {\n\t\t\t\thits.push({ rel: toRel(filePath), line: lineNumber, terms: termsOnLine(plan, event.data?.lines?.text) });\n\t\t\t}\n\t\t\tif (hits.length >= limit && !child.killed) {\n\t\t\t\tkilledDueToLimit = true;\n\t\t\t\tchild.kill();\n\t\t\t}\n\t\t});\n\n\t\tchild.on(\"error\", (error) => {\n\t\t\tsignal?.removeEventListener(\"abort\", onAbort);\n\t\t\treject(new Error(`Failed to run ripgrep: ${error.message}`));\n\t\t});\n\t\tchild.on(\"close\", (code) => {\n\t\t\trl.close();\n\t\t\tsignal?.removeEventListener(\"abort\", onAbort);\n\t\t\tif (aborted) {\n\t\t\t\treject(new Error(\"Operation aborted\"));\n\t\t\t\treturn;\n\t\t\t}\n\t\t\t// rg exits 1 on \"no matches\" — that is a valid empty result.\n\t\t\tif (!killedDueToLimit && code !== 0 && code !== 1) {\n\t\t\t\treject(new Error(stderr.trim() || `ripgrep exited with code ${code}`));\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tresolve(hits);\n\t\t});\n\t});\n}\n"]}
|
|
@@ -12,6 +12,7 @@ import type { ResolvedSearchMode } from "../search/types.js";
|
|
|
12
12
|
declare const searchSchema: Type.TObject<{
|
|
13
13
|
query: Type.TString;
|
|
14
14
|
mode: Type.TOptional<Type.TUnion<[Type.TLiteral<"auto">, Type.TLiteral<"lexical">, Type.TLiteral<"semantic">, Type.TLiteral<"hybrid">]>>;
|
|
15
|
+
glob: Type.TOptional<Type.TString>;
|
|
15
16
|
limit: Type.TOptional<Type.TNumber>;
|
|
16
17
|
}>;
|
|
17
18
|
export type SearchToolInput = Static<typeof searchSchema>;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"search.d.ts","sourceRoot":"","sources":["../../../src/core/tools/search.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,iCAAiC,CAAC;AAEjE,OAAO,EAAE,KAAK,MAAM,EAAE,IAAI,EAAE,MAAM,SAAS,CAAC;AAE5C,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,mCAAmC,CAAC;AAC1E,OAAO,KAAK,EAAE,cAAc,EAA2B,MAAM,wBAAwB,CAAC;AAEtF,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,oBAAoB,CAAC;AAO7D,QAAA,MAAM,YAAY
|
|
1
|
+
{"version":3,"file":"search.d.ts","sourceRoot":"","sources":["../../../src/core/tools/search.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,iCAAiC,CAAC;AAEjE,OAAO,EAAE,KAAK,MAAM,EAAE,IAAI,EAAE,MAAM,SAAS,CAAC;AAE5C,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,mCAAmC,CAAC;AAC1E,OAAO,KAAK,EAAE,cAAc,EAA2B,MAAM,wBAAwB,CAAC;AAEtF,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,oBAAoB,CAAC;AAO7D,QAAA,MAAM,YAAY;;;;;EAoBhB,CAAC;AAEH,MAAM,MAAM,eAAe,GAAG,MAAM,CAAC,OAAO,YAAY,CAAC,CAAC;AAE1D,MAAM,WAAW,iBAAiB;IACjC,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,YAAY,CAAC,EAAE,kBAAkB,CAAC;IAClC,QAAQ,CAAC,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC;CAC3C;AAED,MAAM,WAAW,iBAAiB;IACjC;;;;OAIG;IACH,UAAU,CAAC,EAAE,MAAM,gBAAgB,GAAG,SAAS,CAAC;CAChD;AAqCD,wBAAgB,0BAA0B,CACzC,GAAG,EAAE,MAAM,EACX,OAAO,CAAC,EAAE,iBAAiB,GACzB,cAAc,CAAC,OAAO,YAAY,EAAE,iBAAiB,GAAG,SAAS,CAAC,CAgEpE;AAED,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,iBAAiB,GAAG,SAAS,CAAC,OAAO,YAAY,CAAC,CAEzG","sourcesContent":["/**\n * The unified `search` tool: ranked code discovery in lexical, semantic, or\n * hybrid (rank-fused) mode. Replaces the old `semantic_search` tool — see\n * docs/hybrid-retrieval-design.md, Decision 1. `grep` stays separate for\n * exact line-level mechanics; this tool answers \"find where X lives\".\n */\n\nimport type { AgentTool } from \"@kolisachint/hoocode-agent-core\";\nimport { Text } from \"@kolisachint/hoocode-tui\";\nimport { type Static, Type } from \"typebox\";\nimport { keyHint } from \"../../modes/interactive/components/keybinding-hints.js\";\nimport type { EmbsearchService } from \"../embsearch/embsearch-service.js\";\nimport type { ToolDefinition, ToolRenderResultOptions } from \"../extensions/types.js\";\nimport { runSearch } from \"../search/hybrid-search.js\";\nimport type { ResolvedSearchMode } from \"../search/types.js\";\nimport { getTextOutput, invalidArgText, str } from \"./render-utils.js\";\nimport { wrapToolDefinition } from \"./tool-definition-wrapper.js\";\n\nconst DEFAULT_RESULTS = 10;\nconst MAX_RESULTS = 30;\n\nconst searchSchema = Type.Object({\n\tquery: Type.String({\n\t\tdescription:\n\t\t\t\"What to find: an identifier, error text, or a natural-language description of the code, e.g. 'where sessions are persisted to disk'\",\n\t}),\n\tmode: Type.Optional(\n\t\tType.Union([Type.Literal(\"auto\"), Type.Literal(\"lexical\"), Type.Literal(\"semantic\"), Type.Literal(\"hybrid\")], {\n\t\t\tdescription:\n\t\t\t\t\"Retrieval mode (default: auto). auto picks hybrid when the semantic index is available; lexical = exact-text only; semantic = embedding index only; hybrid = both, fused by rank.\",\n\t\t}),\n\t),\n\tglob: Type.Optional(\n\t\tType.String({\n\t\t\tdescription:\n\t\t\t\t\"Optional glob filter applied to file paths. Only file paths matching the glob are searched. Supports both slashless patterns (match base name anywhere) and slash patterns (match full path).\",\n\t\t}),\n\t),\n\tlimit: Type.Optional(\n\t\tType.Number({ description: `Maximum number of results (default: ${DEFAULT_RESULTS}, max: ${MAX_RESULTS})` }),\n\t),\n});\n\nexport type SearchToolInput = Static<typeof searchSchema>;\n\nexport interface SearchToolDetails {\n\tresultCount?: number;\n\tresolvedMode?: ResolvedSearchMode;\n\tindexing?: { done: number; total: number };\n}\n\nexport interface SearchToolOptions {\n\t/**\n\t * Provider for the per-session embsearch service. Resolved lazily at call\n\t * time because the service is created (and its index built) after tool\n\t * registration. When absent/unavailable the tool degrades to lexical mode.\n\t */\n\tgetService?: () => EmbsearchService | undefined;\n}\n\nfunction formatSearchCall(\n\targs: { query?: string; mode?: string; limit?: number } | undefined,\n\ttheme: typeof import(\"../../modes/interactive/theme/theme.js\").theme,\n): string {\n\tconst query = str(args?.query);\n\tconst queryDisplay = query === null ? invalidArgText(theme) : `\"${query}\"`;\n\tlet text = theme.fg(\"toolTitle\", theme.bold(\"search \")) + theme.fg(\"accent\", queryDisplay);\n\tconst extras: string[] = [];\n\tif (args?.mode && args.mode !== \"auto\") extras.push(args.mode);\n\tif (args?.limit !== undefined) extras.push(String(args.limit));\n\tif (extras.length > 0) text += theme.fg(\"muted\", ` (${extras.join(\", \")})`);\n\treturn text;\n}\n\nfunction formatSearchResult(\n\tresult: { content: Array<{ type: string; text?: string }>; details?: SearchToolDetails },\n\toptions: ToolRenderResultOptions,\n\ttheme: typeof import(\"../../modes/interactive/theme/theme.js\").theme,\n\tshowImages: boolean,\n): string {\n\tconst output = getTextOutput(result as any, showImages).trim();\n\tlet text = \"\";\n\tif (output) {\n\t\tconst lines = output.split(\"\\n\");\n\t\tconst maxLines = options.expanded ? lines.length : 15;\n\t\tconst displayLines = lines.slice(0, maxLines);\n\t\tconst remaining = lines.length - maxLines;\n\t\ttext += `\\n${displayLines.map((line) => theme.fg(\"toolOutput\", line)).join(\"\\n\")}`;\n\t\tif (remaining > 0) {\n\t\t\ttext += `${theme.fg(\"muted\", `\\n... (${remaining} more lines,`)} ${keyHint(\"app.tools.expand\", \"to expand\")})`;\n\t\t}\n\t}\n\treturn text;\n}\n\nexport function createSearchToolDefinition(\n\tcwd: string,\n\toptions?: SearchToolOptions,\n): ToolDefinition<typeof searchSchema, SearchToolDetails | undefined> {\n\treturn {\n\t\tname: \"search\",\n\t\tlabel: \"search\",\n\t\tdescription:\n\t\t\t\"Find where code lives: ranked file:line-range results from exact-text (grep-backed) and semantic (local embedding index) retrieval, fused by rank when both are available. Use search when you want to locate something — a concept, a behavior, or an identifier you only half-know; use grep when you want exact matching lines (call sites, counts, context). Falls back to exact-text retrieval automatically when the semantic index is unavailable.\",\n\t\tpromptSnippet: \"Ranked code search (exact + semantic, rank-fused)\",\n\t\tpromptGuidelines: [\n\t\t\t\"Use search to locate code by concept or half-known name; use grep for exact matching lines and call sites.\",\n\t\t],\n\t\tparameters: searchSchema,\n\t\tasync execute(_toolCallId, { query, mode, glob, limit }: SearchToolInput, signal?: AbortSignal) {\n\t\t\tif (signal?.aborted) throw new Error(\"Operation aborted\");\n\n\t\t\tconst result = await runSearch({\n\t\t\t\tcwd,\n\t\t\t\tquery,\n\t\t\t\tmode,\n\t\t\t\tglob,\n\t\t\t\tlimit: Math.min(MAX_RESULTS, Math.max(1, limit ?? DEFAULT_RESULTS)),\n\t\t\t\tservice: options?.getService?.(),\n\t\t\t\tsignal,\n\t\t\t});\n\t\t\tif (signal?.aborted) throw new Error(\"Operation aborted\");\n\n\t\t\tconst details: SearchToolDetails = {\n\t\t\t\tresultCount: result.resultCount,\n\t\t\t\tresolvedMode: result.resolvedMode,\n\t\t\t\tindexing: result.indexing,\n\t\t\t};\n\n\t\t\tconst notices: string[] = [];\n\t\t\tif (result.degradedReason) notices.push(result.degradedReason);\n\t\t\tif (result.indexing) {\n\t\t\t\tnotices.push(\n\t\t\t\t\t`index still building: ${result.indexing.done}/${result.indexing.total} chunks embedded — results may be incomplete`,\n\t\t\t\t);\n\t\t\t}\n\t\t\tconst notice = notices.length > 0 ? `\\n\\n[${notices.join(\". \")}]` : \"\";\n\n\t\t\tif (result.resultCount === 0) {\n\t\t\t\treturn {\n\t\t\t\t\tcontent: [\n\t\t\t\t\t\t{ type: \"text\" as const, text: `No results for \"${query}\" (${result.resolvedMode})${notice}` },\n\t\t\t\t\t],\n\t\t\t\t\tdetails,\n\t\t\t\t};\n\t\t\t}\n\t\t\treturn {\n\t\t\t\tcontent: [{ type: \"text\" as const, text: result.text + notice }],\n\t\t\t\tdetails,\n\t\t\t};\n\t\t},\n\t\trenderCall(args, theme, context) {\n\t\t\tconst text = (context.lastComponent as Text | undefined) ?? new Text(\"\", 0, 0);\n\t\t\ttext.setText(formatSearchCall(args, theme));\n\t\t\treturn text;\n\t\t},\n\t\trenderResult(result, options, theme, context) {\n\t\t\tconst text = (context.lastComponent as Text | undefined) ?? new Text(\"\", 0, 0);\n\t\t\ttext.setText(formatSearchResult(result as any, options, theme, context.showImages));\n\t\t\treturn text;\n\t\t},\n\t};\n}\n\nexport function createSearchTool(cwd: string, options?: SearchToolOptions): AgentTool<typeof searchSchema> {\n\treturn wrapToolDefinition(createSearchToolDefinition(cwd, options));\n}\n"]}
|
|
@@ -19,6 +19,9 @@ const searchSchema = Type.Object({
|
|
|
19
19
|
mode: Type.Optional(Type.Union([Type.Literal("auto"), Type.Literal("lexical"), Type.Literal("semantic"), Type.Literal("hybrid")], {
|
|
20
20
|
description: "Retrieval mode (default: auto). auto picks hybrid when the semantic index is available; lexical = exact-text only; semantic = embedding index only; hybrid = both, fused by rank.",
|
|
21
21
|
})),
|
|
22
|
+
glob: Type.Optional(Type.String({
|
|
23
|
+
description: "Optional glob filter applied to file paths. Only file paths matching the glob are searched. Supports both slashless patterns (match base name anywhere) and slash patterns (match full path).",
|
|
24
|
+
})),
|
|
22
25
|
limit: Type.Optional(Type.Number({ description: `Maximum number of results (default: ${DEFAULT_RESULTS}, max: ${MAX_RESULTS})` })),
|
|
23
26
|
});
|
|
24
27
|
function formatSearchCall(args, theme) {
|
|
@@ -59,13 +62,14 @@ export function createSearchToolDefinition(cwd, options) {
|
|
|
59
62
|
"Use search to locate code by concept or half-known name; use grep for exact matching lines and call sites.",
|
|
60
63
|
],
|
|
61
64
|
parameters: searchSchema,
|
|
62
|
-
async execute(_toolCallId, { query, mode, limit }, signal) {
|
|
65
|
+
async execute(_toolCallId, { query, mode, glob, limit }, signal) {
|
|
63
66
|
if (signal?.aborted)
|
|
64
67
|
throw new Error("Operation aborted");
|
|
65
68
|
const result = await runSearch({
|
|
66
69
|
cwd,
|
|
67
70
|
query,
|
|
68
71
|
mode,
|
|
72
|
+
glob,
|
|
69
73
|
limit: Math.min(MAX_RESULTS, Math.max(1, limit ?? DEFAULT_RESULTS)),
|
|
70
74
|
service: options?.getService?.(),
|
|
71
75
|
signal,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"search.js","sourceRoot":"","sources":["../../../src/core/tools/search.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * The unified `search` tool: ranked code discovery in lexical, semantic, or\n * hybrid (rank-fused) mode. Replaces the old `semantic_search` tool — see\n * docs/hybrid-retrieval-design.md, Decision 1. `grep` stays separate for\n * exact line-level mechanics; this tool answers \"find where X lives\".\n */\n\nimport type { AgentTool } from \"@kolisachint/hoocode-agent-core\";\nimport { Text } from \"@kolisachint/hoocode-tui\";\nimport { type Static, Type } from \"typebox\";\nimport { keyHint } from \"../../modes/interactive/components/keybinding-hints.js\";\nimport type { EmbsearchService } from \"../embsearch/embsearch-service.js\";\nimport type { ToolDefinition, ToolRenderResultOptions } from \"../extensions/types.js\";\nimport { runSearch } from \"../search/hybrid-search.js\";\nimport type { ResolvedSearchMode } from \"../search/types.js\";\nimport { getTextOutput, invalidArgText, str } from \"./render-utils.js\";\nimport { wrapToolDefinition } from \"./tool-definition-wrapper.js\";\n\nconst DEFAULT_RESULTS = 10;\nconst MAX_RESULTS = 30;\n\nconst searchSchema = Type.Object({\n\tquery: Type.String({\n\t\tdescription:\n\t\t\t\"What to find: an identifier, error text, or a natural-language description of the code, e.g. 'where sessions are persisted to disk'\",\n\t}),\n\tmode: Type.Optional(\n\t\tType.Union([Type.Literal(\"auto\"), Type.Literal(\"lexical\"), Type.Literal(\"semantic\"), Type.Literal(\"hybrid\")], {\n\t\t\tdescription:\n\t\t\t\t\"Retrieval mode (default: auto). auto picks hybrid when the semantic index is available; lexical = exact-text only; semantic = embedding index only; hybrid = both, fused by rank.\",\n\t\t}),\n\t),\n\tlimit: Type.Optional(\n\t\tType.Number({ description: `Maximum number of results (default: ${DEFAULT_RESULTS}, max: ${MAX_RESULTS})` }),\n\t),\n});\n\nexport type SearchToolInput = Static<typeof searchSchema>;\n\nexport interface SearchToolDetails {\n\tresultCount?: number;\n\tresolvedMode?: ResolvedSearchMode;\n\tindexing?: { done: number; total: number };\n}\n\nexport interface SearchToolOptions {\n\t/**\n\t * Provider for the per-session embsearch service. Resolved lazily at call\n\t * time because the service is created (and its index built) after tool\n\t * registration. When absent/unavailable the tool degrades to lexical mode.\n\t */\n\tgetService?: () => EmbsearchService | undefined;\n}\n\nfunction formatSearchCall(\n\targs: { query?: string; mode?: string; limit?: number } | undefined,\n\ttheme: typeof import(\"../../modes/interactive/theme/theme.js\").theme,\n): string {\n\tconst query = str(args?.query);\n\tconst queryDisplay = query === null ? invalidArgText(theme) : `\"${query}\"`;\n\tlet text = theme.fg(\"toolTitle\", theme.bold(\"search \")) + theme.fg(\"accent\", queryDisplay);\n\tconst extras: string[] = [];\n\tif (args?.mode && args.mode !== \"auto\") extras.push(args.mode);\n\tif (args?.limit !== undefined) extras.push(String(args.limit));\n\tif (extras.length > 0) text += theme.fg(\"muted\", ` (${extras.join(\", \")})`);\n\treturn text;\n}\n\nfunction formatSearchResult(\n\tresult: { content: Array<{ type: string; text?: string }>; details?: SearchToolDetails },\n\toptions: ToolRenderResultOptions,\n\ttheme: typeof import(\"../../modes/interactive/theme/theme.js\").theme,\n\tshowImages: boolean,\n): string {\n\tconst output = getTextOutput(result as any, showImages).trim();\n\tlet text = \"\";\n\tif (output) {\n\t\tconst lines = output.split(\"\\n\");\n\t\tconst maxLines = options.expanded ? lines.length : 15;\n\t\tconst displayLines = lines.slice(0, maxLines);\n\t\tconst remaining = lines.length - maxLines;\n\t\ttext += `\\n${displayLines.map((line) => theme.fg(\"toolOutput\", line)).join(\"\\n\")}`;\n\t\tif (remaining > 0) {\n\t\t\ttext += `${theme.fg(\"muted\", `\\n... (${remaining} more lines,`)} ${keyHint(\"app.tools.expand\", \"to expand\")})`;\n\t\t}\n\t}\n\treturn text;\n}\n\nexport function createSearchToolDefinition(\n\tcwd: string,\n\toptions?: SearchToolOptions,\n): ToolDefinition<typeof searchSchema, SearchToolDetails | undefined> {\n\treturn {\n\t\tname: \"search\",\n\t\tlabel: \"search\",\n\t\tdescription:\n\t\t\t\"Find where code lives: ranked file:line-range results from exact-text (grep-backed) and semantic (local embedding index) retrieval, fused by rank when both are available. Use search when you want to locate something — a concept, a behavior, or an identifier you only half-know; use grep when you want exact matching lines (call sites, counts, context). Falls back to exact-text retrieval automatically when the semantic index is unavailable.\",\n\t\tpromptSnippet: \"Ranked code search (exact + semantic, rank-fused)\",\n\t\tpromptGuidelines: [\n\t\t\t\"Use search to locate code by concept or half-known name; use grep for exact matching lines and call sites.\",\n\t\t],\n\t\tparameters: searchSchema,\n\t\tasync execute(_toolCallId, { query, mode, limit }: SearchToolInput, signal?: AbortSignal) {\n\t\t\tif (signal?.aborted) throw new Error(\"Operation aborted\");\n\n\t\t\tconst result = await runSearch({\n\t\t\t\tcwd,\n\t\t\t\tquery,\n\t\t\t\tmode,\n\t\t\t\tlimit: Math.min(MAX_RESULTS, Math.max(1, limit ?? DEFAULT_RESULTS)),\n\t\t\t\tservice: options?.getService?.(),\n\t\t\t\tsignal,\n\t\t\t});\n\t\t\tif (signal?.aborted) throw new Error(\"Operation aborted\");\n\n\t\t\tconst details: SearchToolDetails = {\n\t\t\t\tresultCount: result.resultCount,\n\t\t\t\tresolvedMode: result.resolvedMode,\n\t\t\t\tindexing: result.indexing,\n\t\t\t};\n\n\t\t\tconst notices: string[] = [];\n\t\t\tif (result.degradedReason) notices.push(result.degradedReason);\n\t\t\tif (result.indexing) {\n\t\t\t\tnotices.push(\n\t\t\t\t\t`index still building: ${result.indexing.done}/${result.indexing.total} chunks embedded — results may be incomplete`,\n\t\t\t\t);\n\t\t\t}\n\t\t\tconst notice = notices.length > 0 ? `\\n\\n[${notices.join(\". \")}]` : \"\";\n\n\t\t\tif (result.resultCount === 0) {\n\t\t\t\treturn {\n\t\t\t\t\tcontent: [\n\t\t\t\t\t\t{ type: \"text\" as const, text: `No results for \"${query}\" (${result.resolvedMode})${notice}` },\n\t\t\t\t\t],\n\t\t\t\t\tdetails,\n\t\t\t\t};\n\t\t\t}\n\t\t\treturn {\n\t\t\t\tcontent: [{ type: \"text\" as const, text: result.text + notice }],\n\t\t\t\tdetails,\n\t\t\t};\n\t\t},\n\t\trenderCall(args, theme, context) {\n\t\t\tconst text = (context.lastComponent as Text | undefined) ?? new Text(\"\", 0, 0);\n\t\t\ttext.setText(formatSearchCall(args, theme));\n\t\t\treturn text;\n\t\t},\n\t\trenderResult(result, options, theme, context) {\n\t\t\tconst text = (context.lastComponent as Text | undefined) ?? new Text(\"\", 0, 0);\n\t\t\ttext.setText(formatSearchResult(result as any, options, theme, context.showImages));\n\t\t\treturn text;\n\t\t},\n\t};\n}\n\nexport function createSearchTool(cwd: string, options?: SearchToolOptions): AgentTool<typeof searchSchema> {\n\treturn wrapToolDefinition(createSearchToolDefinition(cwd, options));\n}\n"]}
|
|
1
|
+
{"version":3,"file":"search.js","sourceRoot":"","sources":["../../../src/core/tools/search.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * The unified `search` tool: ranked code discovery in lexical, semantic, or\n * hybrid (rank-fused) mode. Replaces the old `semantic_search` tool — see\n * docs/hybrid-retrieval-design.md, Decision 1. `grep` stays separate for\n * exact line-level mechanics; this tool answers \"find where X lives\".\n */\n\nimport type { AgentTool } from \"@kolisachint/hoocode-agent-core\";\nimport { Text } from \"@kolisachint/hoocode-tui\";\nimport { type Static, Type } from \"typebox\";\nimport { keyHint } from \"../../modes/interactive/components/keybinding-hints.js\";\nimport type { EmbsearchService } from \"../embsearch/embsearch-service.js\";\nimport type { ToolDefinition, ToolRenderResultOptions } from \"../extensions/types.js\";\nimport { runSearch } from \"../search/hybrid-search.js\";\nimport type { ResolvedSearchMode } from \"../search/types.js\";\nimport { getTextOutput, invalidArgText, str } from \"./render-utils.js\";\nimport { wrapToolDefinition } from \"./tool-definition-wrapper.js\";\n\nconst DEFAULT_RESULTS = 10;\nconst MAX_RESULTS = 30;\n\nconst searchSchema = Type.Object({\n\tquery: Type.String({\n\t\tdescription:\n\t\t\t\"What to find: an identifier, error text, or a natural-language description of the code, e.g. 'where sessions are persisted to disk'\",\n\t}),\n\tmode: Type.Optional(\n\t\tType.Union([Type.Literal(\"auto\"), Type.Literal(\"lexical\"), Type.Literal(\"semantic\"), Type.Literal(\"hybrid\")], {\n\t\t\tdescription:\n\t\t\t\t\"Retrieval mode (default: auto). auto picks hybrid when the semantic index is available; lexical = exact-text only; semantic = embedding index only; hybrid = both, fused by rank.\",\n\t\t}),\n\t),\n\tglob: Type.Optional(\n\t\tType.String({\n\t\t\tdescription:\n\t\t\t\t\"Optional glob filter applied to file paths. Only file paths matching the glob are searched. Supports both slashless patterns (match base name anywhere) and slash patterns (match full path).\",\n\t\t}),\n\t),\n\tlimit: Type.Optional(\n\t\tType.Number({ description: `Maximum number of results (default: ${DEFAULT_RESULTS}, max: ${MAX_RESULTS})` }),\n\t),\n});\n\nexport type SearchToolInput = Static<typeof searchSchema>;\n\nexport interface SearchToolDetails {\n\tresultCount?: number;\n\tresolvedMode?: ResolvedSearchMode;\n\tindexing?: { done: number; total: number };\n}\n\nexport interface SearchToolOptions {\n\t/**\n\t * Provider for the per-session embsearch service. Resolved lazily at call\n\t * time because the service is created (and its index built) after tool\n\t * registration. When absent/unavailable the tool degrades to lexical mode.\n\t */\n\tgetService?: () => EmbsearchService | undefined;\n}\n\nfunction formatSearchCall(\n\targs: { query?: string; mode?: string; limit?: number } | undefined,\n\ttheme: typeof import(\"../../modes/interactive/theme/theme.js\").theme,\n): string {\n\tconst query = str(args?.query);\n\tconst queryDisplay = query === null ? invalidArgText(theme) : `\"${query}\"`;\n\tlet text = theme.fg(\"toolTitle\", theme.bold(\"search \")) + theme.fg(\"accent\", queryDisplay);\n\tconst extras: string[] = [];\n\tif (args?.mode && args.mode !== \"auto\") extras.push(args.mode);\n\tif (args?.limit !== undefined) extras.push(String(args.limit));\n\tif (extras.length > 0) text += theme.fg(\"muted\", ` (${extras.join(\", \")})`);\n\treturn text;\n}\n\nfunction formatSearchResult(\n\tresult: { content: Array<{ type: string; text?: string }>; details?: SearchToolDetails },\n\toptions: ToolRenderResultOptions,\n\ttheme: typeof import(\"../../modes/interactive/theme/theme.js\").theme,\n\tshowImages: boolean,\n): string {\n\tconst output = getTextOutput(result as any, showImages).trim();\n\tlet text = \"\";\n\tif (output) {\n\t\tconst lines = output.split(\"\\n\");\n\t\tconst maxLines = options.expanded ? lines.length : 15;\n\t\tconst displayLines = lines.slice(0, maxLines);\n\t\tconst remaining = lines.length - maxLines;\n\t\ttext += `\\n${displayLines.map((line) => theme.fg(\"toolOutput\", line)).join(\"\\n\")}`;\n\t\tif (remaining > 0) {\n\t\t\ttext += `${theme.fg(\"muted\", `\\n... (${remaining} more lines,`)} ${keyHint(\"app.tools.expand\", \"to expand\")})`;\n\t\t}\n\t}\n\treturn text;\n}\n\nexport function createSearchToolDefinition(\n\tcwd: string,\n\toptions?: SearchToolOptions,\n): ToolDefinition<typeof searchSchema, SearchToolDetails | undefined> {\n\treturn {\n\t\tname: \"search\",\n\t\tlabel: \"search\",\n\t\tdescription:\n\t\t\t\"Find where code lives: ranked file:line-range results from exact-text (grep-backed) and semantic (local embedding index) retrieval, fused by rank when both are available. Use search when you want to locate something — a concept, a behavior, or an identifier you only half-know; use grep when you want exact matching lines (call sites, counts, context). Falls back to exact-text retrieval automatically when the semantic index is unavailable.\",\n\t\tpromptSnippet: \"Ranked code search (exact + semantic, rank-fused)\",\n\t\tpromptGuidelines: [\n\t\t\t\"Use search to locate code by concept or half-known name; use grep for exact matching lines and call sites.\",\n\t\t],\n\t\tparameters: searchSchema,\n\t\tasync execute(_toolCallId, { query, mode, glob, limit }: SearchToolInput, signal?: AbortSignal) {\n\t\t\tif (signal?.aborted) throw new Error(\"Operation aborted\");\n\n\t\t\tconst result = await runSearch({\n\t\t\t\tcwd,\n\t\t\t\tquery,\n\t\t\t\tmode,\n\t\t\t\tglob,\n\t\t\t\tlimit: Math.min(MAX_RESULTS, Math.max(1, limit ?? DEFAULT_RESULTS)),\n\t\t\t\tservice: options?.getService?.(),\n\t\t\t\tsignal,\n\t\t\t});\n\t\t\tif (signal?.aborted) throw new Error(\"Operation aborted\");\n\n\t\t\tconst details: SearchToolDetails = {\n\t\t\t\tresultCount: result.resultCount,\n\t\t\t\tresolvedMode: result.resolvedMode,\n\t\t\t\tindexing: result.indexing,\n\t\t\t};\n\n\t\t\tconst notices: string[] = [];\n\t\t\tif (result.degradedReason) notices.push(result.degradedReason);\n\t\t\tif (result.indexing) {\n\t\t\t\tnotices.push(\n\t\t\t\t\t`index still building: ${result.indexing.done}/${result.indexing.total} chunks embedded — results may be incomplete`,\n\t\t\t\t);\n\t\t\t}\n\t\t\tconst notice = notices.length > 0 ? `\\n\\n[${notices.join(\". \")}]` : \"\";\n\n\t\t\tif (result.resultCount === 0) {\n\t\t\t\treturn {\n\t\t\t\t\tcontent: [\n\t\t\t\t\t\t{ type: \"text\" as const, text: `No results for \"${query}\" (${result.resolvedMode})${notice}` },\n\t\t\t\t\t],\n\t\t\t\t\tdetails,\n\t\t\t\t};\n\t\t\t}\n\t\t\treturn {\n\t\t\t\tcontent: [{ type: \"text\" as const, text: result.text + notice }],\n\t\t\t\tdetails,\n\t\t\t};\n\t\t},\n\t\trenderCall(args, theme, context) {\n\t\t\tconst text = (context.lastComponent as Text | undefined) ?? new Text(\"\", 0, 0);\n\t\t\ttext.setText(formatSearchCall(args, theme));\n\t\t\treturn text;\n\t\t},\n\t\trenderResult(result, options, theme, context) {\n\t\t\tconst text = (context.lastComponent as Text | undefined) ?? new Text(\"\", 0, 0);\n\t\t\ttext.setText(formatSearchResult(result as any, options, theme, context.showImages));\n\t\t\treturn text;\n\t\t},\n\t};\n}\n\nexport function createSearchTool(cwd: string, options?: SearchToolOptions): AgentTool<typeof searchSchema> {\n\treturn wrapToolDefinition(createSearchToolDefinition(cwd, options));\n}\n"]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kolisachint/hoocode-agent",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.144",
|
|
4
4
|
"description": "Coding agent CLI with read, bash, edit, write tools and session management",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"hoocodeConfig": {
|
|
@@ -45,9 +45,9 @@
|
|
|
45
45
|
"prepublishOnly": "npm run clean && npm run build"
|
|
46
46
|
},
|
|
47
47
|
"dependencies": {
|
|
48
|
-
"@kolisachint/hoocode-agent-core": "^0.4.
|
|
49
|
-
"@kolisachint/hoocode-ai": "^0.4.
|
|
50
|
-
"@kolisachint/hoocode-tui": "^0.4.
|
|
48
|
+
"@kolisachint/hoocode-agent-core": "^0.4.144",
|
|
49
|
+
"@kolisachint/hoocode-ai": "^0.4.144",
|
|
50
|
+
"@kolisachint/hoocode-tui": "^0.4.144",
|
|
51
51
|
"@silvia-odwyer/photon-node": "^0.3.4",
|
|
52
52
|
"chalk": "^5.5.0",
|
|
53
53
|
"cli-highlight": "^2.1.11",
|