@kolisachint/hoocode-agent 0.4.167 → 0.4.168

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.
@@ -50,6 +50,13 @@ export const EVAL_CONFIGS = [
50
50
  // Three-way fusion: dense + BM25 + grep, all fused here so `k` is ours and
51
51
  // the per-leg ranks survive into the trace. `bm25+dense` isolates what the
52
52
  // grep leg still contributes once BM25 is present.
53
+ //
54
+ // `3-way` used to lose badly to `bm25+dense` (MRR 0.608 -> 0.552, 18 of 62
55
+ // queries worse) because two lexical views of the same corpus dilute each
56
+ // other in fusion. Its grep leg is now scoped to files the index has not
57
+ // read, so the two rows are identical on this corpus by construction —
58
+ // the difference only appears under `--live-edits`, where `3-way` scores
59
+ // 1.000 and `bm25+dense` scores 0.000.
53
60
  { label: "bm25+dense +rr", mode: "semantic", rerank: true, bm25Leg: true },
54
61
  { label: "3-way +rr", mode: "hybrid", rerank: true, bm25Leg: true },
55
62
  // Cross-encoder reranking, against the same retrieval each deterministic
@@ -1 +1 @@
1
- {"version":3,"file":"eval.js","sourceRoot":"","sources":["../../../src/core/search/eval.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Retrieval evaluation gate (docs/hybrid-retrieval-design.md, step 6 of the\n * shipping order).\n *\n * Measures Recall@K and MRR for lexical, semantic, and hybrid retrieval across\n * an RRF `k` sweep, against a gold set keyed by **path + line range matched by\n * span overlap** — never by chunkId, which is only stable per index build.\n * Recall@50 doubles as the reranker gate: a gold span that never reaches the\n * fused top-50 cannot be rescued by any reranker.\n *\n * Rank-sensitive metrics are the point of Recall@1 and MRR: an agent reads the\n * top result or two, so \"the gold span is somewhere in the top 5\" understates\n * how much ordering matters. Recall@5/10/50 are kept for continuity with the\n * numbers already published in the design note.\n *\n * Driven by `scripts/search-eval.ts` via `eval-harness.ts`; the scoring logic\n * lives here so it is unit-testable without an embedding index.\n */\n\nimport type { EmbsearchService } from \"../embsearch/embsearch-service.js\";\nimport { retrieveCandidates } from \"./hybrid-search.js\";\nimport type { CandidateSpan, ResolvedSearchMode, SearchMode } from \"./types.js\";\n\nexport interface EvalGoldSpan {\n\t/** Repo-relative POSIX path. */\n\tpath: string;\n\t/** 1-based inclusive; omit both to accept any span in the file. */\n\tstartLine?: number;\n\tendLine?: number;\n\t/**\n\t * Literal source text that must occur inside `[startLine, endLine]`. Not\n\t * used for scoring — it is how the gold set survives the corpus moving\n\t * underneath it: `scripts/search-eval-gold.ts` re-resolves the line range\n\t * from this anchor, and the fixture test fails when an anchor no longer\n\t * sits inside its recorded range. Omitted for `scope: \"file\"` entries.\n\t */\n\tanchor?: string;\n\t/**\n\t * `\"span\"` (default) scores by overlap with the recorded line range;\n\t * `\"file\"` accepts any span in the file. File scope is deliberate for\n\t * path-class queries, where the whole file *is* the answer — it is\n\t * recorded explicitly so a file-level score is never mistaken for a\n\t * span-level one.\n\t */\n\tscope?: \"span\" | \"file\";\n}\n\nexport interface EvalQuery {\n\tid: string;\n\t/** Query class from the design doc (exact-symbol, path, error-fragment,\n\t * conceptual, cross-file, boundary). Reporting only. */\n\tclass: string;\n\tquery: string;\n\tgold: EvalGoldSpan[];\n}\n\nexport interface EvalConfig {\n\tlabel: string;\n\tmode: SearchMode;\n\trrfK?: number;\n\trerank?: boolean;\n\t/**\n\t * Score this config against a daemon-side hybrid store (BM25 fused with\n\t * vectors inside the Rust daemon) instead of the dense-only index. Skipped\n\t * when the harness has no hybrid service, so records without one simply\n\t * omit the row rather than silently scoring it as plain semantic.\n\t */\n\tdaemonHybrid?: boolean;\n\t/**\n\t * Fetch the daemon's BM25 index as a separate leg and fuse it here with\n\t * dense and grep. Like `daemonHybrid` it needs a hybrid store, so it is\n\t * skipped when the harness has no hybrid service.\n\t */\n\tbm25Leg?: boolean;\n\t/** Rerank with the daemon's cross-encoder rather than the deterministic\n\t * scorer. Needs a service, so it is skipped without one. */\n\tcrossEncoder?: boolean;\n}\n\n/**\n * The sweep from the design doc — single retrievers, hybrid across k, the\n * routed auto mode — plus reranked (`+rr`) variants for the step 7 gate.\n *\n * Exported because `scripts/search-eval.ts` and `test/search-eval.test.ts`\n * both consume it. It previously lost its `export` to a knip-driven\n * dead-export sweep (02efaab): the only importer was a `.mjs` script reading\n * from `dist/`, which static analysis cannot see, and the eval gate silently\n * stopped running. The TypeScript importers are the fix — do not \"clean up\"\n * this export without checking them.\n */\nexport const EVAL_CONFIGS: readonly EvalConfig[] = [\n\t{ label: \"lexical\", mode: \"lexical\" },\n\t{ label: \"semantic\", mode: \"semantic\" },\n\t{ label: \"hybrid k=0\", mode: \"hybrid\", rrfK: 0 },\n\t{ label: \"hybrid k=2\", mode: \"hybrid\", rrfK: 2 },\n\t{ label: \"hybrid k=10\", mode: \"hybrid\", rrfK: 10 },\n\t{ label: \"hybrid k=60\", mode: \"hybrid\", rrfK: 60 },\n\t{ label: \"auto\", mode: \"auto\" },\n\t{ label: \"lexical +rr\", mode: \"lexical\", rerank: true },\n\t{ label: \"semantic +rr\", mode: \"semantic\", rerank: true },\n\t{ label: \"hybrid k=2 +rr\", mode: \"hybrid\", rrfK: 2, rerank: true },\n\t{ label: \"hybrid k=60 +rr\", mode: \"hybrid\", rrfK: 60, rerank: true },\n\t{ label: \"auto +rr\", mode: \"auto\", rerank: true },\n\t// Is BM25 a better lexical leg than ripgrep? These two run the daemon's own\n\t// vector+BM25 fusion and no ripgrep at all, so comparing them against\n\t// \"semantic\" isolates what BM25 adds, and against \"hybrid k=2\" compares the\n\t// two lexical legs at the system level.\n\t{ label: \"daemon-hybrid\", mode: \"semantic\", daemonHybrid: true },\n\t{ label: \"daemon-hybrid +rr\", mode: \"semantic\", rerank: true, daemonHybrid: true },\n\t// Three-way fusion: dense + BM25 + grep, all fused here so `k` is ours and\n\t// the per-leg ranks survive into the trace. `bm25+dense` isolates what the\n\t// grep leg still contributes once BM25 is present.\n\t{ label: \"bm25+dense +rr\", mode: \"semantic\", rerank: true, bm25Leg: true },\n\t{ label: \"3-way +rr\", mode: \"hybrid\", rerank: true, bm25Leg: true },\n\t// Cross-encoder reranking, against the same retrieval each deterministic\n\t// `+rr` row uses — so the delta is the reranker and nothing else.\n\t{ label: \"semantic +ce\", mode: \"semantic\", crossEncoder: true },\n\t{ label: \"auto +ce\", mode: \"auto\", crossEncoder: true },\n\t{ label: \"bm25+dense +ce\", mode: \"semantic\", bm25Leg: true, crossEncoder: true },\n];\n\n/** Candidates fetched per eval query — deep enough for the reranker gate. */\nconst EVAL_FETCH_LIMIT = 50;\n\nexport function spanMatchesGold(span: CandidateSpan, gold: EvalGoldSpan): boolean {\n\tif (span.path !== gold.path) return false;\n\tif (gold.scope === \"file\") return true;\n\tif (gold.startLine === undefined || gold.endLine === undefined) return true;\n\treturn span.startLine <= gold.endLine && span.endLine >= gold.startLine;\n}\n\n/** Fraction of gold spans matched by at least one of the top-`k` candidates. */\nexport function recallAtK(candidates: readonly CandidateSpan[], gold: readonly EvalGoldSpan[], k: number): number {\n\tif (gold.length === 0) return 0;\n\tconst top = candidates.slice(0, k);\n\tlet matched = 0;\n\tfor (const g of gold) {\n\t\tif (top.some((c) => spanMatchesGold(c, g))) matched++;\n\t}\n\treturn matched / gold.length;\n}\n\n/**\n * Mean reciprocal rank, averaged over gold spans: for each gold span, `1/rank`\n * of the first candidate matching it (0 when it never appears).\n *\n * Averaging per gold span rather than taking the single first hit keeps\n * multi-span queries (the cross-file class) honest — finding one of two\n * required sites should not score like finding both. For single-span queries\n * this reduces to textbook MRR.\n */\nexport function mrr(candidates: readonly CandidateSpan[], gold: readonly EvalGoldSpan[]): number {\n\tif (gold.length === 0) return 0;\n\tlet total = 0;\n\tfor (const g of gold) {\n\t\tconst index = candidates.findIndex((c) => spanMatchesGold(c, g));\n\t\tif (index >= 0) total += 1 / (index + 1);\n\t}\n\treturn total / gold.length;\n}\n\nexport interface EvalQueryResult {\n\tlabel: string;\n\tresolvedMode: ResolvedSearchMode;\n\tdegraded: boolean;\n\trecallAt1: number;\n\trecallAt5: number;\n\trecallAt10: number;\n\trecallAt50: number;\n\tmrr: number;\n}\n\nexport async function evaluateQuery(\n\tcwd: string,\n\tevalQuery: EvalQuery,\n\tconfigs: readonly EvalConfig[] = EVAL_CONFIGS,\n\tservice?: EmbsearchService,\n\thybridService?: EmbsearchService,\n): Promise<EvalQueryResult[]> {\n\tconst results: EvalQueryResult[] = [];\n\tfor (const config of configs) {\n\t\t// A daemon-hybrid config against the plain store would error in the\n\t\t// daemon (`query_hybrid requires a hybrid store`); omit the row instead.\n\t\tif ((config.daemonHybrid || config.bm25Leg) && !hybridService) continue;\n\t\t// Cross-encoder rows need a live daemon; omit rather than silently\n\t\t// falling back to the deterministic reranker under a \"+ce\" label.\n\t\tif (config.crossEncoder && !(config.bm25Leg ? hybridService : service)?.supportsCrossEncoder()) continue;\n\t\tconst retrieved = await retrieveCandidates({\n\t\t\tcwd,\n\t\t\tquery: evalQuery.query,\n\t\t\tmode: config.mode,\n\t\t\trrfK: config.rrfK,\n\t\t\trerank: config.rerank ?? false,\n\t\t\tlimit: EVAL_FETCH_LIMIT,\n\t\t\tservice: config.daemonHybrid || config.bm25Leg ? hybridService : service,\n\t\t\tdaemonHybrid: config.daemonHybrid,\n\t\t\tbm25Leg: config.bm25Leg,\n\t\t\tcrossEncoder: config.crossEncoder,\n\t\t});\n\t\tresults.push({\n\t\t\tlabel: config.label,\n\t\t\tresolvedMode: retrieved.resolvedMode,\n\t\t\tdegraded: retrieved.degradedReason !== undefined,\n\t\t\trecallAt1: recallAtK(retrieved.candidates, evalQuery.gold, 1),\n\t\t\trecallAt5: recallAtK(retrieved.candidates, evalQuery.gold, 5),\n\t\t\trecallAt10: recallAtK(retrieved.candidates, evalQuery.gold, 10),\n\t\t\trecallAt50: recallAtK(retrieved.candidates, evalQuery.gold, 50),\n\t\t\tmrr: mrr(retrieved.candidates, evalQuery.gold),\n\t\t});\n\t}\n\treturn results;\n}\n"]}
1
+ {"version":3,"file":"eval.js","sourceRoot":"","sources":["../../../src/core/search/eval.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Retrieval evaluation gate (docs/hybrid-retrieval-design.md, step 6 of the\n * shipping order).\n *\n * Measures Recall@K and MRR for lexical, semantic, and hybrid retrieval across\n * an RRF `k` sweep, against a gold set keyed by **path + line range matched by\n * span overlap** — never by chunkId, which is only stable per index build.\n * Recall@50 doubles as the reranker gate: a gold span that never reaches the\n * fused top-50 cannot be rescued by any reranker.\n *\n * Rank-sensitive metrics are the point of Recall@1 and MRR: an agent reads the\n * top result or two, so \"the gold span is somewhere in the top 5\" understates\n * how much ordering matters. Recall@5/10/50 are kept for continuity with the\n * numbers already published in the design note.\n *\n * Driven by `scripts/search-eval.ts` via `eval-harness.ts`; the scoring logic\n * lives here so it is unit-testable without an embedding index.\n */\n\nimport type { EmbsearchService } from \"../embsearch/embsearch-service.js\";\nimport { retrieveCandidates } from \"./hybrid-search.js\";\nimport type { CandidateSpan, ResolvedSearchMode, SearchMode } from \"./types.js\";\n\nexport interface EvalGoldSpan {\n\t/** Repo-relative POSIX path. */\n\tpath: string;\n\t/** 1-based inclusive; omit both to accept any span in the file. */\n\tstartLine?: number;\n\tendLine?: number;\n\t/**\n\t * Literal source text that must occur inside `[startLine, endLine]`. Not\n\t * used for scoring — it is how the gold set survives the corpus moving\n\t * underneath it: `scripts/search-eval-gold.ts` re-resolves the line range\n\t * from this anchor, and the fixture test fails when an anchor no longer\n\t * sits inside its recorded range. Omitted for `scope: \"file\"` entries.\n\t */\n\tanchor?: string;\n\t/**\n\t * `\"span\"` (default) scores by overlap with the recorded line range;\n\t * `\"file\"` accepts any span in the file. File scope is deliberate for\n\t * path-class queries, where the whole file *is* the answer — it is\n\t * recorded explicitly so a file-level score is never mistaken for a\n\t * span-level one.\n\t */\n\tscope?: \"span\" | \"file\";\n}\n\nexport interface EvalQuery {\n\tid: string;\n\t/** Query class from the design doc (exact-symbol, path, error-fragment,\n\t * conceptual, cross-file, boundary). Reporting only. */\n\tclass: string;\n\tquery: string;\n\tgold: EvalGoldSpan[];\n}\n\nexport interface EvalConfig {\n\tlabel: string;\n\tmode: SearchMode;\n\trrfK?: number;\n\trerank?: boolean;\n\t/**\n\t * Score this config against a daemon-side hybrid store (BM25 fused with\n\t * vectors inside the Rust daemon) instead of the dense-only index. Skipped\n\t * when the harness has no hybrid service, so records without one simply\n\t * omit the row rather than silently scoring it as plain semantic.\n\t */\n\tdaemonHybrid?: boolean;\n\t/**\n\t * Fetch the daemon's BM25 index as a separate leg and fuse it here with\n\t * dense and grep. Like `daemonHybrid` it needs a hybrid store, so it is\n\t * skipped when the harness has no hybrid service.\n\t */\n\tbm25Leg?: boolean;\n\t/** Rerank with the daemon's cross-encoder rather than the deterministic\n\t * scorer. Needs a service, so it is skipped without one. */\n\tcrossEncoder?: boolean;\n}\n\n/**\n * The sweep from the design doc — single retrievers, hybrid across k, the\n * routed auto mode — plus reranked (`+rr`) variants for the step 7 gate.\n *\n * Exported because `scripts/search-eval.ts` and `test/search-eval.test.ts`\n * both consume it. It previously lost its `export` to a knip-driven\n * dead-export sweep (02efaab): the only importer was a `.mjs` script reading\n * from `dist/`, which static analysis cannot see, and the eval gate silently\n * stopped running. The TypeScript importers are the fix — do not \"clean up\"\n * this export without checking them.\n */\nexport const EVAL_CONFIGS: readonly EvalConfig[] = [\n\t{ label: \"lexical\", mode: \"lexical\" },\n\t{ label: \"semantic\", mode: \"semantic\" },\n\t{ label: \"hybrid k=0\", mode: \"hybrid\", rrfK: 0 },\n\t{ label: \"hybrid k=2\", mode: \"hybrid\", rrfK: 2 },\n\t{ label: \"hybrid k=10\", mode: \"hybrid\", rrfK: 10 },\n\t{ label: \"hybrid k=60\", mode: \"hybrid\", rrfK: 60 },\n\t{ label: \"auto\", mode: \"auto\" },\n\t{ label: \"lexical +rr\", mode: \"lexical\", rerank: true },\n\t{ label: \"semantic +rr\", mode: \"semantic\", rerank: true },\n\t{ label: \"hybrid k=2 +rr\", mode: \"hybrid\", rrfK: 2, rerank: true },\n\t{ label: \"hybrid k=60 +rr\", mode: \"hybrid\", rrfK: 60, rerank: true },\n\t{ label: \"auto +rr\", mode: \"auto\", rerank: true },\n\t// Is BM25 a better lexical leg than ripgrep? These two run the daemon's own\n\t// vector+BM25 fusion and no ripgrep at all, so comparing them against\n\t// \"semantic\" isolates what BM25 adds, and against \"hybrid k=2\" compares the\n\t// two lexical legs at the system level.\n\t{ label: \"daemon-hybrid\", mode: \"semantic\", daemonHybrid: true },\n\t{ label: \"daemon-hybrid +rr\", mode: \"semantic\", rerank: true, daemonHybrid: true },\n\t// Three-way fusion: dense + BM25 + grep, all fused here so `k` is ours and\n\t// the per-leg ranks survive into the trace. `bm25+dense` isolates what the\n\t// grep leg still contributes once BM25 is present.\n\t//\n\t// `3-way` used to lose badly to `bm25+dense` (MRR 0.608 -> 0.552, 18 of 62\n\t// queries worse) because two lexical views of the same corpus dilute each\n\t// other in fusion. Its grep leg is now scoped to files the index has not\n\t// read, so the two rows are identical on this corpus by construction —\n\t// the difference only appears under `--live-edits`, where `3-way` scores\n\t// 1.000 and `bm25+dense` scores 0.000.\n\t{ label: \"bm25+dense +rr\", mode: \"semantic\", rerank: true, bm25Leg: true },\n\t{ label: \"3-way +rr\", mode: \"hybrid\", rerank: true, bm25Leg: true },\n\t// Cross-encoder reranking, against the same retrieval each deterministic\n\t// `+rr` row uses — so the delta is the reranker and nothing else.\n\t{ label: \"semantic +ce\", mode: \"semantic\", crossEncoder: true },\n\t{ label: \"auto +ce\", mode: \"auto\", crossEncoder: true },\n\t{ label: \"bm25+dense +ce\", mode: \"semantic\", bm25Leg: true, crossEncoder: true },\n];\n\n/** Candidates fetched per eval query — deep enough for the reranker gate. */\nconst EVAL_FETCH_LIMIT = 50;\n\nexport function spanMatchesGold(span: CandidateSpan, gold: EvalGoldSpan): boolean {\n\tif (span.path !== gold.path) return false;\n\tif (gold.scope === \"file\") return true;\n\tif (gold.startLine === undefined || gold.endLine === undefined) return true;\n\treturn span.startLine <= gold.endLine && span.endLine >= gold.startLine;\n}\n\n/** Fraction of gold spans matched by at least one of the top-`k` candidates. */\nexport function recallAtK(candidates: readonly CandidateSpan[], gold: readonly EvalGoldSpan[], k: number): number {\n\tif (gold.length === 0) return 0;\n\tconst top = candidates.slice(0, k);\n\tlet matched = 0;\n\tfor (const g of gold) {\n\t\tif (top.some((c) => spanMatchesGold(c, g))) matched++;\n\t}\n\treturn matched / gold.length;\n}\n\n/**\n * Mean reciprocal rank, averaged over gold spans: for each gold span, `1/rank`\n * of the first candidate matching it (0 when it never appears).\n *\n * Averaging per gold span rather than taking the single first hit keeps\n * multi-span queries (the cross-file class) honest — finding one of two\n * required sites should not score like finding both. For single-span queries\n * this reduces to textbook MRR.\n */\nexport function mrr(candidates: readonly CandidateSpan[], gold: readonly EvalGoldSpan[]): number {\n\tif (gold.length === 0) return 0;\n\tlet total = 0;\n\tfor (const g of gold) {\n\t\tconst index = candidates.findIndex((c) => spanMatchesGold(c, g));\n\t\tif (index >= 0) total += 1 / (index + 1);\n\t}\n\treturn total / gold.length;\n}\n\nexport interface EvalQueryResult {\n\tlabel: string;\n\tresolvedMode: ResolvedSearchMode;\n\tdegraded: boolean;\n\trecallAt1: number;\n\trecallAt5: number;\n\trecallAt10: number;\n\trecallAt50: number;\n\tmrr: number;\n}\n\nexport async function evaluateQuery(\n\tcwd: string,\n\tevalQuery: EvalQuery,\n\tconfigs: readonly EvalConfig[] = EVAL_CONFIGS,\n\tservice?: EmbsearchService,\n\thybridService?: EmbsearchService,\n): Promise<EvalQueryResult[]> {\n\tconst results: EvalQueryResult[] = [];\n\tfor (const config of configs) {\n\t\t// A daemon-hybrid config against the plain store would error in the\n\t\t// daemon (`query_hybrid requires a hybrid store`); omit the row instead.\n\t\tif ((config.daemonHybrid || config.bm25Leg) && !hybridService) continue;\n\t\t// Cross-encoder rows need a live daemon; omit rather than silently\n\t\t// falling back to the deterministic reranker under a \"+ce\" label.\n\t\tif (config.crossEncoder && !(config.bm25Leg ? hybridService : service)?.supportsCrossEncoder()) continue;\n\t\tconst retrieved = await retrieveCandidates({\n\t\t\tcwd,\n\t\t\tquery: evalQuery.query,\n\t\t\tmode: config.mode,\n\t\t\trrfK: config.rrfK,\n\t\t\trerank: config.rerank ?? false,\n\t\t\tlimit: EVAL_FETCH_LIMIT,\n\t\t\tservice: config.daemonHybrid || config.bm25Leg ? hybridService : service,\n\t\t\tdaemonHybrid: config.daemonHybrid,\n\t\t\tbm25Leg: config.bm25Leg,\n\t\t\tcrossEncoder: config.crossEncoder,\n\t\t});\n\t\tresults.push({\n\t\t\tlabel: config.label,\n\t\t\tresolvedMode: retrieved.resolvedMode,\n\t\t\tdegraded: retrieved.degradedReason !== undefined,\n\t\t\trecallAt1: recallAtK(retrieved.candidates, evalQuery.gold, 1),\n\t\t\trecallAt5: recallAtK(retrieved.candidates, evalQuery.gold, 5),\n\t\t\trecallAt10: recallAtK(retrieved.candidates, evalQuery.gold, 10),\n\t\t\trecallAt50: recallAtK(retrieved.candidates, evalQuery.gold, 50),\n\t\t\tmrr: mrr(retrieved.candidates, evalQuery.gold),\n\t\t});\n\t}\n\treturn results;\n}\n"]}
@@ -80,6 +80,23 @@ export interface RunSearchResult {
80
80
  total: number;
81
81
  };
82
82
  }
83
+ /**
84
+ * Move candidates from files the index has not read to the front.
85
+ *
86
+ * They are there because grep found them and nothing else could: the index is
87
+ * ranking a stale copy of the file, or has never seen it. Left to fuse, they
88
+ * lose — RRF rewards agreement, and one leg reporting a single document is
89
+ * outvoted by two legs agreeing on hundreds. Measured: scoping grep to stale
90
+ * files without this hoist scored 25% on the live-edit set where unscoped grep
91
+ * scored 100%, because the fused window filled with consensus hits about the
92
+ * indexed copy.
93
+ *
94
+ * Capped, because "stale" scales with how far behind the index is. A few
95
+ * edited files is the case this exists for; a fresh checkout makes everything
96
+ * stale, and hoisting all of it would quietly turn hybrid search back into
97
+ * grep. Past the cap the remainder keeps its fused position.
98
+ */
99
+ export declare function hoistStaleCandidates(candidates: readonly FusedCandidate[], staleFiles: ReadonlySet<string>): FusedCandidate[];
83
100
  export declare function retrieveCandidates(options: RetrieveOptions): Promise<RetrieveResult>;
84
101
  export declare function runSearch(options: RunSearchOptions): Promise<RunSearchResult>;
85
102
  //# sourceMappingURL=hybrid-search.d.ts.map
@@ -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;AAS1E,OAAO,KAAK,EAAiB,cAAc,EAAa,kBAAkB,EAAE,UAAU,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AAmCxH,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;;;;;;;;OAQG;IACH,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB;;;;OAIG;IACH,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB;;;;OAIG;IACH,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,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,CA+I1F;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 { crossEncoderRerank } from \"./cross-rerank.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/**\n * Embedding hits fetched per query when fusing with the grep leg.\n *\n * Deliberately shallow. The grep list is capped at {@link LEXICAL_FUSION_CAP}\n * because its tail is unranked noise, so a deep dense pool here simply\n * outnumbers it: raising this to 200 alongside a 20-candidate grep list cost\n * `auto +rr` 0.472 -> 0.450 MRR.\n */\nconst EMBED_TOP_K = 50;\n/**\n * Per-leg depth when both retrievers are ranked ones (dense + BM25).\n *\n * Four times {@link FUSED_WINDOW}, matching what the daemon's `query_hybrid`\n * does internally (`pool = 4·k`). Fusing at the same depth as the window loses\n * any candidate ranked well by one retriever but just outside the other's\n * top-50 — worth 12pp R@10 and 8pp R@50 for `bm25+dense`, which is exactly\n * what closed the gap to daemon-side fusion.\n */\nconst FUSION_POOL_TOP_K = 200;\n/** BM25 depth, matching {@link FUSION_POOL_TOP_K} so neither ranked leg is\n * structurally advantaged by pool size. The daemon returns only documents\n * sharing a query term, so this is an upper bound, not a fill. */\nconst BM25_TOP_K = FUSION_POOL_TOP_K;\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\t/**\n\t * Ask the daemon to fuse its own BM25 index with the vectors and return one\n\t * already-fused ranking, instead of taking a dense-only list. Needs a store\n\t * built with `--hybrid`. The fused list arrives as a single \"embed\" leg,\n\t * because a pre-fused ranking has no per-retriever structure left to record.\n\t *\n\t * Prefer {@link bm25Leg}: fusing here keeps the legs separable in the trace\n\t * and lets the grep leg participate.\n\t */\n\tdaemonHybrid?: boolean;\n\t/**\n\t * Fetch the daemon's BM25 index as its own ranked list and fuse it here,\n\t * alongside dense and grep. Needs a store built with `--hybrid` and an\n\t * embsearch new enough to serve `retriever: \"lexical\"`.\n\t */\n\tbm25Leg?: boolean;\n\t/**\n\t * Reorder the fused shortlist with the daemon's cross-encoder instead of\n\t * the deterministic reranker. Needs embsearch >= 0.3.0; costs one model\n\t * pass per scored candidate.\n\t */\n\tcrossEncoder?: 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\t// The flat index pads top-k with whatever exists; with the cosine\n\t\t\t// metric the store uses, score <= 0 means \"no relation at all\", so\n\t\t\t// those padding hits would cast RRF votes on pure noise.\n\t\t\t// A BM25 leg is itself ranked, so the pair can afford — and needs — the\n\t\t\t// deeper pool; the grep leg cannot (see EMBED_TOP_K).\n\t\t\tconst topK = options.bm25Leg ? FUSION_POOL_TOP_K : EMBED_TOP_K;\n\t\t\tconst chunkHits = (\n\t\t\t\tawait service!.searchChunks(query, topK, glob, options.daemonHybrid ? \"hybrid\" : \"dense\")\n\t\t\t).filter((hit) => hit.score > 0);\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 runBm25 = async (): Promise<void> => {\n\t\tconst startedMs = Date.now();\n\t\ttry {\n\t\t\t// Raw BM25 sums; only the ordering enters fusion, the score is a\n\t\t\t// diagnostic. Unlike the dense leg there is no zero-score padding to\n\t\t\t// filter — the daemon omits documents sharing no query term.\n\t\t\tconst chunkHits = await service!.searchChunks(query, BM25_TOP_K, glob, \"lexical\");\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: \"bm25\",\n\t\t\t}));\n\t\t\tfor (const hit of chunkHits) {\n\t\t\t\tif (!spans.has(hit.id)) {\n\t\t\t\t\tspans.set(hit.id, { path: hit.path, startLine: hit.startLine, endLine: hit.endLine });\n\t\t\t\t}\n\t\t\t}\n\t\t\tlists.push(hits);\n\t\t\tretrieverStats.bm25 = { 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.bm25 = { 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\tif (options.bm25Leg && embedAvailable && mode !== \"lexical\") runs.push(runBm25());\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.crossEncoder && service?.supportsCrossEncoder()) {\n\t\tconst reranked = await crossEncoderRerank(query, candidates, cwd, service);\n\t\trerankInfo = { applied: true, candidateCount: reranked.scored, latencyMs: reranked.latencyMs };\n\t\tcandidates = reranked.candidates;\n\t} else if (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;AAS1E,OAAO,KAAK,EAAiB,cAAc,EAAa,kBAAkB,EAAE,UAAU,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AAuCxH,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;;;;;;;;OAQG;IACH,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB;;;;OAIG;IACH,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB;;;;OAIG;IACH,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,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;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,oBAAoB,CACnC,UAAU,EAAE,SAAS,cAAc,EAAE,EACrC,UAAU,EAAE,WAAW,CAAC,MAAM,CAAC,GAC7B,cAAc,EAAE,CAQlB;AAED,wBAAsB,kBAAkB,CAAC,OAAO,EAAE,eAAe,GAAG,OAAO,CAAC,cAAc,CAAC,CAsK1F;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 { crossEncoderRerank } from \"./cross-rerank.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/**\n * Embedding hits fetched per query when fusing with the grep leg.\n *\n * Deliberately shallow. The grep list is capped at {@link LEXICAL_FUSION_CAP}\n * because its tail is unranked noise, so a deep dense pool here simply\n * outnumbers it: raising this to 200 alongside a 20-candidate grep list cost\n * `auto +rr` 0.472 -> 0.450 MRR.\n */\nconst EMBED_TOP_K = 50;\n/**\n * Per-leg depth when both retrievers are ranked ones (dense + BM25).\n *\n * Four times {@link FUSED_WINDOW}, matching what the daemon's `query_hybrid`\n * does internally (`pool = 4·k`). Fusing at the same depth as the window loses\n * any candidate ranked well by one retriever but just outside the other's\n * top-50 — worth 12pp R@10 and 8pp R@50 for `bm25+dense`, which is exactly\n * what closed the gap to daemon-side fusion.\n */\nconst FUSION_POOL_TOP_K = 200;\n/** BM25 depth, matching {@link FUSION_POOL_TOP_K} so neither ranked leg is\n * structurally advantaged by pool size. The daemon returns only documents\n * sharing a query term, so this is an upper bound, not a fill. */\nconst BM25_TOP_K = FUSION_POOL_TOP_K;\n/** Candidates from unindexed/stale files moved ahead of the fused window.\n * Small on purpose: this answers \"what did I just write\", not \"search the\n * working tree\". */\nconst STALE_HOIST_CAP = 5;\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\t/**\n\t * Ask the daemon to fuse its own BM25 index with the vectors and return one\n\t * already-fused ranking, instead of taking a dense-only list. Needs a store\n\t * built with `--hybrid`. The fused list arrives as a single \"embed\" leg,\n\t * because a pre-fused ranking has no per-retriever structure left to record.\n\t *\n\t * Prefer {@link bm25Leg}: fusing here keeps the legs separable in the trace\n\t * and lets the grep leg participate.\n\t */\n\tdaemonHybrid?: boolean;\n\t/**\n\t * Fetch the daemon's BM25 index as its own ranked list and fuse it here,\n\t * alongside dense and grep. Needs a store built with `--hybrid` and an\n\t * embsearch new enough to serve `retriever: \"lexical\"`.\n\t */\n\tbm25Leg?: boolean;\n\t/**\n\t * Reorder the fused shortlist with the daemon's cross-encoder instead of\n\t * the deterministic reranker. Needs embsearch >= 0.3.0; costs one model\n\t * pass per scored candidate.\n\t */\n\tcrossEncoder?: 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\n/**\n * Move candidates from files the index has not read to the front.\n *\n * They are there because grep found them and nothing else could: the index is\n * ranking a stale copy of the file, or has never seen it. Left to fuse, they\n * lose — RRF rewards agreement, and one leg reporting a single document is\n * outvoted by two legs agreeing on hundreds. Measured: scoping grep to stale\n * files without this hoist scored 25% on the live-edit set where unscoped grep\n * scored 100%, because the fused window filled with consensus hits about the\n * indexed copy.\n *\n * Capped, because \"stale\" scales with how far behind the index is. A few\n * edited files is the case this exists for; a fresh checkout makes everything\n * stale, and hoisting all of it would quietly turn hybrid search back into\n * grep. Past the cap the remainder keeps its fused position.\n */\nexport function hoistStaleCandidates(\n\tcandidates: readonly FusedCandidate[],\n\tstaleFiles: ReadonlySet<string>,\n): FusedCandidate[] {\n\tif (staleFiles.size === 0) return [...candidates];\n\tconst hoisted: FusedCandidate[] = [];\n\tconst rest: FusedCandidate[] = [];\n\tfor (const candidate of candidates) {\n\t\t(staleFiles.has(candidate.path) && hoisted.length < STALE_HOIST_CAP ? hoisted : rest).push(candidate);\n\t}\n\treturn [...hoisted, ...rest];\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\t/** Files the index does not have current content for, when grep is scoped\n\t * to them. Read again after fusion — see the hoist below. */\n\tconst staleSet = new Set<string>();\n\tconst lists: RankedHit[][] = [];\n\tconst retrieverStats: SearchTrace[\"retrievers\"] = {};\n\tconst errors: Error[] = [];\n\n\tconst runLexical = async (scopeToStale = false): Promise<void> => {\n\t\tconst startedMs = Date.now();\n\t\ttry {\n\t\t\t// Scoped run: grep covers only what the index has not read yet, so it\n\t\t\t// adds the one thing BM25 cannot see without re-voting on documents\n\t\t\t// BM25 already ranked. Fusing two lexical views of the same corpus is\n\t\t\t// what made the three-leg configuration lose (18 of 62 queries worse).\n\t\t\tconst paths = scopeToStale ? service?.staleFiles(signal) : undefined;\n\t\t\tif (paths) for (const rel of paths) staleSet.add(rel);\n\t\t\tconst lineHits = await runLexicalRetriever({ cwd, query, limit: LEXICAL_MATCH_LIMIT, glob, signal, paths });\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\t// The flat index pads top-k with whatever exists; with the cosine\n\t\t\t// metric the store uses, score <= 0 means \"no relation at all\", so\n\t\t\t// those padding hits would cast RRF votes on pure noise.\n\t\t\t// A BM25 leg is itself ranked, so the pair can afford — and needs — the\n\t\t\t// deeper pool; the grep leg cannot (see EMBED_TOP_K).\n\t\t\tconst topK = options.bm25Leg ? FUSION_POOL_TOP_K : EMBED_TOP_K;\n\t\t\tconst chunkHits = (\n\t\t\t\tawait service!.searchChunks(query, topK, glob, options.daemonHybrid ? \"hybrid\" : \"dense\")\n\t\t\t).filter((hit) => hit.score > 0);\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 runBm25 = async (): Promise<void> => {\n\t\tconst startedMs = Date.now();\n\t\ttry {\n\t\t\t// Raw BM25 sums; only the ordering enters fusion, the score is a\n\t\t\t// diagnostic. Unlike the dense leg there is no zero-score padding to\n\t\t\t// filter — the daemon omits documents sharing no query term.\n\t\t\tconst chunkHits = await service!.searchChunks(query, BM25_TOP_K, glob, \"lexical\");\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: \"bm25\",\n\t\t\t}));\n\t\t\tfor (const hit of chunkHits) {\n\t\t\t\tif (!spans.has(hit.id)) {\n\t\t\t\t\tspans.set(hit.id, { path: hit.path, startLine: hit.startLine, endLine: hit.endLine });\n\t\t\t\t}\n\t\t\t}\n\t\t\tlists.push(hits);\n\t\t\tretrieverStats.bm25 = { 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.bm25 = { latencyMs: Date.now() - startedMs, hitCount: 0 };\n\t\t}\n\t};\n\n\t// BM25 is the better lexical leg where the index is current: it beats grep\n\t// on Recall@50 (0.790 -> 0.879, 6 queries better and 0 worse, p <= 0.05)\n\t// because it ranks the whole corpus rather than truncating a match stream.\n\t// It is also blind to anything indexed later than it was written, which is\n\t// precisely where grep still wins — so grep runs scoped to that set instead\n\t// of being dropped or left to duplicate BM25 over the whole tree.\n\tconst bm25AsLexicalLeg = options.bm25Leg && embedAvailable && mode !== \"lexical\";\n\n\tconst runs: Promise<void>[] = [];\n\tif (mode === \"lexical\" || mode === \"hybrid\") runs.push(runLexical(bm25AsLexicalLeg));\n\tif (mode === \"semantic\" || mode === \"hybrid\") runs.push(runEmbed());\n\t// `semantic` gets the BM25 leg too: the caller asked for the index, and\n\t// BM25 is part of it. Only an explicit `lexical` request excludes it.\n\tif (bm25AsLexicalLeg) runs.push(runBm25());\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.crossEncoder && service?.supportsCrossEncoder()) {\n\t\tconst reranked = await crossEncoderRerank(query, candidates, cwd, service);\n\t\trerankInfo = { applied: true, candidateCount: reranked.scored, latencyMs: reranked.latencyMs };\n\t\tcandidates = reranked.candidates;\n\t} else if (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\t// After reranking, not before: both rerankers weight the fused prior, and a\n\t// lone grep hit from an unindexed file has the lowest prior there is, so\n\t// hoisting first would simply be undone. The reranker still orders the\n\t// hoisted set against itself.\n\tcandidates = hoistStaleCandidates(candidates, staleSet).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"]}
@@ -50,6 +50,10 @@ const FUSION_POOL_TOP_K = 200;
50
50
  * structurally advantaged by pool size. The daemon returns only documents
51
51
  * sharing a query term, so this is an upper bound, not a fill. */
52
52
  const BM25_TOP_K = FUSION_POOL_TOP_K;
53
+ /** Candidates from unindexed/stale files moved ahead of the fused window.
54
+ * Small on purpose: this answers "what did I just write", not "search the
55
+ * working tree". */
56
+ const STALE_HOIST_CAP = 5;
53
57
  /** Fused candidates kept for reranking / final slicing. */
54
58
  const FUSED_WINDOW = 50;
55
59
  function normalizeSearchGlob(glob) {
@@ -62,6 +66,32 @@ function normalizeSearchGlob(glob) {
62
66
  }
63
67
  return glob;
64
68
  }
69
+ /**
70
+ * Move candidates from files the index has not read to the front.
71
+ *
72
+ * They are there because grep found them and nothing else could: the index is
73
+ * ranking a stale copy of the file, or has never seen it. Left to fuse, they
74
+ * lose — RRF rewards agreement, and one leg reporting a single document is
75
+ * outvoted by two legs agreeing on hundreds. Measured: scoping grep to stale
76
+ * files without this hoist scored 25% on the live-edit set where unscoped grep
77
+ * scored 100%, because the fused window filled with consensus hits about the
78
+ * indexed copy.
79
+ *
80
+ * Capped, because "stale" scales with how far behind the index is. A few
81
+ * edited files is the case this exists for; a fresh checkout makes everything
82
+ * stale, and hoisting all of it would quietly turn hybrid search back into
83
+ * grep. Past the cap the remainder keeps its fused position.
84
+ */
85
+ export function hoistStaleCandidates(candidates, staleFiles) {
86
+ if (staleFiles.size === 0)
87
+ return [...candidates];
88
+ const hoisted = [];
89
+ const rest = [];
90
+ for (const candidate of candidates) {
91
+ (staleFiles.has(candidate.path) && hoisted.length < STALE_HOIST_CAP ? hoisted : rest).push(candidate);
92
+ }
93
+ return [...hoisted, ...rest];
94
+ }
65
95
  export async function retrieveCandidates(options) {
66
96
  const { cwd, query, service, signal } = options;
67
97
  const glob = normalizeSearchGlob(options.glob);
@@ -85,13 +115,24 @@ export async function retrieveCandidates(options) {
85
115
  ? (rel, line) => service.findEnclosingChunk(rel, line)
86
116
  : undefined;
87
117
  const spans = new Map();
118
+ /** Files the index does not have current content for, when grep is scoped
119
+ * to them. Read again after fusion — see the hoist below. */
120
+ const staleSet = new Set();
88
121
  const lists = [];
89
122
  const retrieverStats = {};
90
123
  const errors = [];
91
- const runLexical = async () => {
124
+ const runLexical = async (scopeToStale = false) => {
92
125
  const startedMs = Date.now();
93
126
  try {
94
- const lineHits = await runLexicalRetriever({ cwd, query, limit: LEXICAL_MATCH_LIMIT, glob, signal });
127
+ // Scoped run: grep covers only what the index has not read yet, so it
128
+ // adds the one thing BM25 cannot see without re-voting on documents
129
+ // BM25 already ranked. Fusing two lexical views of the same corpus is
130
+ // what made the three-leg configuration lose (18 of 62 queries worse).
131
+ const paths = scopeToStale ? service?.staleFiles(signal) : undefined;
132
+ if (paths)
133
+ for (const rel of paths)
134
+ staleSet.add(rel);
135
+ const lineHits = await runLexicalRetriever({ cwd, query, limit: LEXICAL_MATCH_LIMIT, glob, signal, paths });
95
136
  const adapted = adaptGrepHits(lineHits, lookupChunk);
96
137
  // In single-retriever lexical mode the full list is the result; in
97
138
  // hybrid, only the front-loaded head is trustworthy enough to vote.
@@ -160,12 +201,21 @@ export async function retrieveCandidates(options) {
160
201
  retrieverStats.bm25 = { latencyMs: Date.now() - startedMs, hitCount: 0 };
161
202
  }
162
203
  };
204
+ // BM25 is the better lexical leg where the index is current: it beats grep
205
+ // on Recall@50 (0.790 -> 0.879, 6 queries better and 0 worse, p <= 0.05)
206
+ // because it ranks the whole corpus rather than truncating a match stream.
207
+ // It is also blind to anything indexed later than it was written, which is
208
+ // precisely where grep still wins — so grep runs scoped to that set instead
209
+ // of being dropped or left to duplicate BM25 over the whole tree.
210
+ const bm25AsLexicalLeg = options.bm25Leg && embedAvailable && mode !== "lexical";
163
211
  const runs = [];
164
212
  if (mode === "lexical" || mode === "hybrid")
165
- runs.push(runLexical());
213
+ runs.push(runLexical(bm25AsLexicalLeg));
166
214
  if (mode === "semantic" || mode === "hybrid")
167
215
  runs.push(runEmbed());
168
- if (options.bm25Leg && embedAvailable && mode !== "lexical")
216
+ // `semantic` gets the BM25 leg too: the caller asked for the index, and
217
+ // BM25 is part of it. Only an explicit `lexical` request excludes it.
218
+ if (bm25AsLexicalLeg)
169
219
  runs.push(runBm25());
170
220
  await Promise.all(runs);
171
221
  if (signal?.aborted)
@@ -192,7 +242,11 @@ export async function retrieveCandidates(options) {
192
242
  rerankInfo = { applied: true, candidateCount: candidates.length, latencyMs: reranked.latencyMs };
193
243
  candidates = reranked.candidates;
194
244
  }
195
- candidates = candidates.slice(0, limit);
245
+ // After reranking, not before: both rerankers weight the fused prior, and a
246
+ // lone grep hit from an unindexed file has the lowest prior there is, so
247
+ // hoisting first would simply be undone. The reranker still orders the
248
+ // hoisted set against itself.
249
+ candidates = hoistStaleCandidates(candidates, staleSet).slice(0, limit);
196
250
  return {
197
251
  candidates,
198
252
  resolvedMode: mode,
@@ -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 { crossEncoderRerank } from \"./cross-rerank.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/**\n * Embedding hits fetched per query when fusing with the grep leg.\n *\n * Deliberately shallow. The grep list is capped at {@link LEXICAL_FUSION_CAP}\n * because its tail is unranked noise, so a deep dense pool here simply\n * outnumbers it: raising this to 200 alongside a 20-candidate grep list cost\n * `auto +rr` 0.472 -> 0.450 MRR.\n */\nconst EMBED_TOP_K = 50;\n/**\n * Per-leg depth when both retrievers are ranked ones (dense + BM25).\n *\n * Four times {@link FUSED_WINDOW}, matching what the daemon's `query_hybrid`\n * does internally (`pool = 4·k`). Fusing at the same depth as the window loses\n * any candidate ranked well by one retriever but just outside the other's\n * top-50 — worth 12pp R@10 and 8pp R@50 for `bm25+dense`, which is exactly\n * what closed the gap to daemon-side fusion.\n */\nconst FUSION_POOL_TOP_K = 200;\n/** BM25 depth, matching {@link FUSION_POOL_TOP_K} so neither ranked leg is\n * structurally advantaged by pool size. The daemon returns only documents\n * sharing a query term, so this is an upper bound, not a fill. */\nconst BM25_TOP_K = FUSION_POOL_TOP_K;\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\t/**\n\t * Ask the daemon to fuse its own BM25 index with the vectors and return one\n\t * already-fused ranking, instead of taking a dense-only list. Needs a store\n\t * built with `--hybrid`. The fused list arrives as a single \"embed\" leg,\n\t * because a pre-fused ranking has no per-retriever structure left to record.\n\t *\n\t * Prefer {@link bm25Leg}: fusing here keeps the legs separable in the trace\n\t * and lets the grep leg participate.\n\t */\n\tdaemonHybrid?: boolean;\n\t/**\n\t * Fetch the daemon's BM25 index as its own ranked list and fuse it here,\n\t * alongside dense and grep. Needs a store built with `--hybrid` and an\n\t * embsearch new enough to serve `retriever: \"lexical\"`.\n\t */\n\tbm25Leg?: boolean;\n\t/**\n\t * Reorder the fused shortlist with the daemon's cross-encoder instead of\n\t * the deterministic reranker. Needs embsearch >= 0.3.0; costs one model\n\t * pass per scored candidate.\n\t */\n\tcrossEncoder?: 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\t// The flat index pads top-k with whatever exists; with the cosine\n\t\t\t// metric the store uses, score <= 0 means \"no relation at all\", so\n\t\t\t// those padding hits would cast RRF votes on pure noise.\n\t\t\t// A BM25 leg is itself ranked, so the pair can afford — and needs — the\n\t\t\t// deeper pool; the grep leg cannot (see EMBED_TOP_K).\n\t\t\tconst topK = options.bm25Leg ? FUSION_POOL_TOP_K : EMBED_TOP_K;\n\t\t\tconst chunkHits = (\n\t\t\t\tawait service!.searchChunks(query, topK, glob, options.daemonHybrid ? \"hybrid\" : \"dense\")\n\t\t\t).filter((hit) => hit.score > 0);\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 runBm25 = async (): Promise<void> => {\n\t\tconst startedMs = Date.now();\n\t\ttry {\n\t\t\t// Raw BM25 sums; only the ordering enters fusion, the score is a\n\t\t\t// diagnostic. Unlike the dense leg there is no zero-score padding to\n\t\t\t// filter — the daemon omits documents sharing no query term.\n\t\t\tconst chunkHits = await service!.searchChunks(query, BM25_TOP_K, glob, \"lexical\");\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: \"bm25\",\n\t\t\t}));\n\t\t\tfor (const hit of chunkHits) {\n\t\t\t\tif (!spans.has(hit.id)) {\n\t\t\t\t\tspans.set(hit.id, { path: hit.path, startLine: hit.startLine, endLine: hit.endLine });\n\t\t\t\t}\n\t\t\t}\n\t\t\tlists.push(hits);\n\t\t\tretrieverStats.bm25 = { 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.bm25 = { 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\tif (options.bm25Leg && embedAvailable && mode !== \"lexical\") runs.push(runBm25());\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.crossEncoder && service?.supportsCrossEncoder()) {\n\t\tconst reranked = await crossEncoderRerank(query, candidates, cwd, service);\n\t\trerankInfo = { applied: true, candidateCount: reranked.scored, latencyMs: reranked.latencyMs };\n\t\tcandidates = reranked.candidates;\n\t} else if (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 { crossEncoderRerank } from \"./cross-rerank.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/**\n * Embedding hits fetched per query when fusing with the grep leg.\n *\n * Deliberately shallow. The grep list is capped at {@link LEXICAL_FUSION_CAP}\n * because its tail is unranked noise, so a deep dense pool here simply\n * outnumbers it: raising this to 200 alongside a 20-candidate grep list cost\n * `auto +rr` 0.472 -> 0.450 MRR.\n */\nconst EMBED_TOP_K = 50;\n/**\n * Per-leg depth when both retrievers are ranked ones (dense + BM25).\n *\n * Four times {@link FUSED_WINDOW}, matching what the daemon's `query_hybrid`\n * does internally (`pool = 4·k`). Fusing at the same depth as the window loses\n * any candidate ranked well by one retriever but just outside the other's\n * top-50 — worth 12pp R@10 and 8pp R@50 for `bm25+dense`, which is exactly\n * what closed the gap to daemon-side fusion.\n */\nconst FUSION_POOL_TOP_K = 200;\n/** BM25 depth, matching {@link FUSION_POOL_TOP_K} so neither ranked leg is\n * structurally advantaged by pool size. The daemon returns only documents\n * sharing a query term, so this is an upper bound, not a fill. */\nconst BM25_TOP_K = FUSION_POOL_TOP_K;\n/** Candidates from unindexed/stale files moved ahead of the fused window.\n * Small on purpose: this answers \"what did I just write\", not \"search the\n * working tree\". */\nconst STALE_HOIST_CAP = 5;\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\t/**\n\t * Ask the daemon to fuse its own BM25 index with the vectors and return one\n\t * already-fused ranking, instead of taking a dense-only list. Needs a store\n\t * built with `--hybrid`. The fused list arrives as a single \"embed\" leg,\n\t * because a pre-fused ranking has no per-retriever structure left to record.\n\t *\n\t * Prefer {@link bm25Leg}: fusing here keeps the legs separable in the trace\n\t * and lets the grep leg participate.\n\t */\n\tdaemonHybrid?: boolean;\n\t/**\n\t * Fetch the daemon's BM25 index as its own ranked list and fuse it here,\n\t * alongside dense and grep. Needs a store built with `--hybrid` and an\n\t * embsearch new enough to serve `retriever: \"lexical\"`.\n\t */\n\tbm25Leg?: boolean;\n\t/**\n\t * Reorder the fused shortlist with the daemon's cross-encoder instead of\n\t * the deterministic reranker. Needs embsearch >= 0.3.0; costs one model\n\t * pass per scored candidate.\n\t */\n\tcrossEncoder?: 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\n/**\n * Move candidates from files the index has not read to the front.\n *\n * They are there because grep found them and nothing else could: the index is\n * ranking a stale copy of the file, or has never seen it. Left to fuse, they\n * lose — RRF rewards agreement, and one leg reporting a single document is\n * outvoted by two legs agreeing on hundreds. Measured: scoping grep to stale\n * files without this hoist scored 25% on the live-edit set where unscoped grep\n * scored 100%, because the fused window filled with consensus hits about the\n * indexed copy.\n *\n * Capped, because \"stale\" scales with how far behind the index is. A few\n * edited files is the case this exists for; a fresh checkout makes everything\n * stale, and hoisting all of it would quietly turn hybrid search back into\n * grep. Past the cap the remainder keeps its fused position.\n */\nexport function hoistStaleCandidates(\n\tcandidates: readonly FusedCandidate[],\n\tstaleFiles: ReadonlySet<string>,\n): FusedCandidate[] {\n\tif (staleFiles.size === 0) return [...candidates];\n\tconst hoisted: FusedCandidate[] = [];\n\tconst rest: FusedCandidate[] = [];\n\tfor (const candidate of candidates) {\n\t\t(staleFiles.has(candidate.path) && hoisted.length < STALE_HOIST_CAP ? hoisted : rest).push(candidate);\n\t}\n\treturn [...hoisted, ...rest];\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\t/** Files the index does not have current content for, when grep is scoped\n\t * to them. Read again after fusion — see the hoist below. */\n\tconst staleSet = new Set<string>();\n\tconst lists: RankedHit[][] = [];\n\tconst retrieverStats: SearchTrace[\"retrievers\"] = {};\n\tconst errors: Error[] = [];\n\n\tconst runLexical = async (scopeToStale = false): Promise<void> => {\n\t\tconst startedMs = Date.now();\n\t\ttry {\n\t\t\t// Scoped run: grep covers only what the index has not read yet, so it\n\t\t\t// adds the one thing BM25 cannot see without re-voting on documents\n\t\t\t// BM25 already ranked. Fusing two lexical views of the same corpus is\n\t\t\t// what made the three-leg configuration lose (18 of 62 queries worse).\n\t\t\tconst paths = scopeToStale ? service?.staleFiles(signal) : undefined;\n\t\t\tif (paths) for (const rel of paths) staleSet.add(rel);\n\t\t\tconst lineHits = await runLexicalRetriever({ cwd, query, limit: LEXICAL_MATCH_LIMIT, glob, signal, paths });\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\t// The flat index pads top-k with whatever exists; with the cosine\n\t\t\t// metric the store uses, score <= 0 means \"no relation at all\", so\n\t\t\t// those padding hits would cast RRF votes on pure noise.\n\t\t\t// A BM25 leg is itself ranked, so the pair can afford — and needs — the\n\t\t\t// deeper pool; the grep leg cannot (see EMBED_TOP_K).\n\t\t\tconst topK = options.bm25Leg ? FUSION_POOL_TOP_K : EMBED_TOP_K;\n\t\t\tconst chunkHits = (\n\t\t\t\tawait service!.searchChunks(query, topK, glob, options.daemonHybrid ? \"hybrid\" : \"dense\")\n\t\t\t).filter((hit) => hit.score > 0);\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 runBm25 = async (): Promise<void> => {\n\t\tconst startedMs = Date.now();\n\t\ttry {\n\t\t\t// Raw BM25 sums; only the ordering enters fusion, the score is a\n\t\t\t// diagnostic. Unlike the dense leg there is no zero-score padding to\n\t\t\t// filter — the daemon omits documents sharing no query term.\n\t\t\tconst chunkHits = await service!.searchChunks(query, BM25_TOP_K, glob, \"lexical\");\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: \"bm25\",\n\t\t\t}));\n\t\t\tfor (const hit of chunkHits) {\n\t\t\t\tif (!spans.has(hit.id)) {\n\t\t\t\t\tspans.set(hit.id, { path: hit.path, startLine: hit.startLine, endLine: hit.endLine });\n\t\t\t\t}\n\t\t\t}\n\t\t\tlists.push(hits);\n\t\t\tretrieverStats.bm25 = { 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.bm25 = { latencyMs: Date.now() - startedMs, hitCount: 0 };\n\t\t}\n\t};\n\n\t// BM25 is the better lexical leg where the index is current: it beats grep\n\t// on Recall@50 (0.790 -> 0.879, 6 queries better and 0 worse, p <= 0.05)\n\t// because it ranks the whole corpus rather than truncating a match stream.\n\t// It is also blind to anything indexed later than it was written, which is\n\t// precisely where grep still wins — so grep runs scoped to that set instead\n\t// of being dropped or left to duplicate BM25 over the whole tree.\n\tconst bm25AsLexicalLeg = options.bm25Leg && embedAvailable && mode !== \"lexical\";\n\n\tconst runs: Promise<void>[] = [];\n\tif (mode === \"lexical\" || mode === \"hybrid\") runs.push(runLexical(bm25AsLexicalLeg));\n\tif (mode === \"semantic\" || mode === \"hybrid\") runs.push(runEmbed());\n\t// `semantic` gets the BM25 leg too: the caller asked for the index, and\n\t// BM25 is part of it. Only an explicit `lexical` request excludes it.\n\tif (bm25AsLexicalLeg) runs.push(runBm25());\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.crossEncoder && service?.supportsCrossEncoder()) {\n\t\tconst reranked = await crossEncoderRerank(query, candidates, cwd, service);\n\t\trerankInfo = { applied: true, candidateCount: reranked.scored, latencyMs: reranked.latencyMs };\n\t\tcandidates = reranked.candidates;\n\t} else if (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\t// After reranking, not before: both rerankers weight the fused prior, and a\n\t// lone grep hit from an unindexed file has the lowest prior there is, so\n\t// hoisting first would simply be undone. The reranker still orders the\n\t// hoisted set against itself.\n\tcandidates = hoistStaleCandidates(candidates, staleSet).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"]}
@@ -32,6 +32,14 @@ export interface RunLexicalOptions {
32
32
  limit: number;
33
33
  glob?: string;
34
34
  signal?: AbortSignal;
35
+ /**
36
+ * Restrict the search to these repo-relative files instead of walking `cwd`.
37
+ *
38
+ * Used to scope the grep leg to files the embedding index does not have
39
+ * current content for. An empty array means "no files to search", which is
40
+ * a real answer — not "search everything".
41
+ */
42
+ paths?: readonly string[];
35
43
  }
36
44
  export declare function runLexicalRetriever(options: RunLexicalOptions): Promise<GrepLineHit[]>;
37
45
  //# 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,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,CAyG5F","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\t// `!.git` because --hidden would otherwise search .git contents;\n\t\t// --no-require-git so .gitignore is honored outside git repos too.\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\"--no-require-git\",\n\t\t\t\"--ignore-case\",\n\t\t\t\"--sort\",\n\t\t\t\"path\",\n\t\t\t\"--glob\",\n\t\t\t\"!**/.git/**\",\n\t\t];\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"]}
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;IACrB;;;;;;OAMG;IACH,KAAK,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;CAC1B;AAiBD,wBAAsB,mBAAmB,CAAC,OAAO,EAAE,iBAAiB,GAAG,OAAO,CAAC,WAAW,EAAE,CAAC,CAmH5F","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\t/**\n\t * Restrict the search to these repo-relative files instead of walking `cwd`.\n\t *\n\t * Used to scope the grep leg to files the embedding index does not have\n\t * current content for. An empty array means \"no files to search\", which is\n\t * a real answer — not \"search everything\".\n\t */\n\tpaths?: readonly string[];\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, paths } = options;\n\tconst glob = normalizeSearchGlob(rawGlob);\n\tconst plan = buildLexicalQueryPlan(query);\n\tif (!plan) return [];\n\tconst { pattern } = plan;\n\t// Scoped to an empty set: nothing to search, and handing rg no paths would\n\t// make it read stdin and hang.\n\tif (paths?.length === 0) return [];\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\t// nativeGrep takes one root, so a scoped run visits each file directly.\n\t\t// The scoped set is small by construction — it is what changed since the\n\t\t// last index pass — so this stays bounded.\n\t\tconst roots = paths ? paths.map((rel) => path.resolve(cwd, rel)) : [cwd];\n\t\tconst hits: GrepLineHit[] = [];\n\t\tfor (const root of roots) {\n\t\t\tif (hits.length >= limit) break;\n\t\t\tconst result = await nativeGrep(root, {\n\t\t\t\tpattern,\n\t\t\t\tisDirectory: paths === undefined,\n\t\t\t\tignoreCase: true,\n\t\t\t\tlimit: limit - hits.length,\n\t\t\t\tglob,\n\t\t\t\tsignal,\n\t\t\t\treadFile: (p) => readFileSync(p, \"utf-8\"),\n\t\t\t});\n\t\t\tfor (const m of result.matches) {\n\t\t\t\thits.push({ rel: toRel(m.filePath), line: m.lineNumber, terms: termsOnLine(plan, m.lineText) });\n\t\t\t}\n\t\t}\n\t\treturn hits;\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\t// `!.git` because --hidden would otherwise search .git contents;\n\t\t// --no-require-git so .gitignore is honored outside git repos too.\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\"--no-require-git\",\n\t\t\t\"--ignore-case\",\n\t\t\t\"--sort\",\n\t\t\t\"path\",\n\t\t\t\"--glob\",\n\t\t\t\"!**/.git/**\",\n\t\t];\n\t\tif (glob) args.push(\"--glob\", glob);\n\t\targs.push(\"--\", pattern, ...(paths ? paths.map((rel) => path.resolve(cwd, rel)) : [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"]}
@@ -70,32 +70,44 @@ function normalizeSearchGlob(glob) {
70
70
  return glob;
71
71
  }
72
72
  export async function runLexicalRetriever(options) {
73
- const { cwd, query, limit, glob: rawGlob, signal } = options;
73
+ const { cwd, query, limit, glob: rawGlob, signal, paths } = options;
74
74
  const glob = normalizeSearchGlob(rawGlob);
75
75
  const plan = buildLexicalQueryPlan(query);
76
76
  if (!plan)
77
77
  return [];
78
78
  const { pattern } = plan;
79
+ // Scoped to an empty set: nothing to search, and handing rg no paths would
80
+ // make it read stdin and hang.
81
+ if (paths?.length === 0)
82
+ return [];
79
83
  const toRel = (filePath) => {
80
84
  const rel = path.relative(cwd, filePath);
81
85
  return (rel && !rel.startsWith("..") ? rel : filePath).replace(/\\/g, "/");
82
86
  };
83
87
  const rgPath = isNativeSearchForced() ? undefined : await ensureTool("rg", true);
84
88
  if (!rgPath) {
85
- const result = await nativeGrep(cwd, {
86
- pattern,
87
- isDirectory: true,
88
- ignoreCase: true,
89
- limit,
90
- glob,
91
- signal,
92
- readFile: (p) => readFileSync(p, "utf-8"),
93
- });
94
- return result.matches.map((m) => ({
95
- rel: toRel(m.filePath),
96
- line: m.lineNumber,
97
- terms: termsOnLine(plan, m.lineText),
98
- }));
89
+ // nativeGrep takes one root, so a scoped run visits each file directly.
90
+ // The scoped set is small by construction — it is what changed since the
91
+ // last index pass — so this stays bounded.
92
+ const roots = paths ? paths.map((rel) => path.resolve(cwd, rel)) : [cwd];
93
+ const hits = [];
94
+ for (const root of roots) {
95
+ if (hits.length >= limit)
96
+ break;
97
+ const result = await nativeGrep(root, {
98
+ pattern,
99
+ isDirectory: paths === undefined,
100
+ ignoreCase: true,
101
+ limit: limit - hits.length,
102
+ glob,
103
+ signal,
104
+ readFile: (p) => readFileSync(p, "utf-8"),
105
+ });
106
+ for (const m of result.matches) {
107
+ hits.push({ rel: toRel(m.filePath), line: m.lineNumber, terms: termsOnLine(plan, m.lineText) });
108
+ }
109
+ }
110
+ return hits;
99
111
  }
100
112
  return new Promise((resolve, reject) => {
101
113
  // --sort path forces a deterministic (single-threaded) walk: with the
@@ -117,7 +129,7 @@ export async function runLexicalRetriever(options) {
117
129
  ];
118
130
  if (glob)
119
131
  args.push("--glob", glob);
120
- args.push("--", pattern, cwd);
132
+ args.push("--", pattern, ...(paths ? paths.map((rel) => path.resolve(cwd, rel)) : [cwd]));
121
133
  const child = spawn(rgPath, args, { stdio: ["ignore", "pipe", "pipe"] });
122
134
  const rl = createInterface({ input: child.stdout });
123
135
  const hits = [];