@kolisachint/hoocode-agent 0.5.0 → 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,9 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.5.2] - 2026-08-08
4
+
5
+ ## [0.5.1] - 2026-08-08
6
+
3
7
  ## [0.5.0] - 2026-08-08
4
8
 
5
9
  ## [0.4.168] - 2026-08-08
@@ -104,6 +104,27 @@ export interface RunSearchResult {
104
104
  * grep. Past the cap the remainder keeps its fused position.
105
105
  */
106
106
  export declare function hoistStaleCandidates(candidates: readonly FusedCandidate[], staleFiles: ReadonlySet<string>): FusedCandidate[];
107
+ /**
108
+ * Fold overlapping or adjacent spans of the same file into their best-ranked
109
+ * occurrence.
110
+ *
111
+ * Chunks overlap by design (see the chunker's `CHUNK_OVERLAP_LINES`) and each
112
+ * one is a separate id, so neighbouring chunks of one region survive fusion as
113
+ * separate candidates and take separate result slots — showing the model code
114
+ * it already has. Measured on the 62-query set at the tool's default
115
+ * `limit=5`, 43 queries had such a pair in their top 5 and 58 of 310 slots
116
+ * went to repeated code.
117
+ *
118
+ * Merging is free recall: the union of two overlapping spans matches exactly
119
+ * what either matched, so nothing is gained by widening — the gain is entirely
120
+ * the slot handed back to the ranked tail. Recall@5 0.597 -> 0.677, Recall@1
121
+ * unchanged (merging cannot alter the top result).
122
+ *
123
+ * Adjacency counts as overlap (`endLine + 1`): two chunks that abut describe
124
+ * one continuous region, and rendering them as separate results implies a gap
125
+ * that is not there.
126
+ */
127
+ export declare function mergeOverlappingSpans(candidates: readonly FusedCandidate[]): FusedCandidate[];
107
128
  export declare function retrieveCandidates(options: RetrieveOptions): Promise<RetrieveResult>;
108
129
  export declare function runSearch(options: RunSearchOptions): Promise<RunSearchResult>;
109
130
  //# 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;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;;;;;;;;;;;OAWG;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,CAuK1F;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.\n\t *\n\t * Defaults to on wherever the daemon can serve it, because BM25 is the\n\t * better lexical leg on the indexed corpus: Recall@50 0.790 -> 0.879, 6 of\n\t * 62 queries better and 0 worse (p <= 0.05). When it is on, the grep leg\n\t * narrows to files the index has not read — see `staleFiles`.\n\t *\n\t * Set `false` to force ripgrep as the only lexical leg; the eval harness\n\t * does this to keep measuring what the old rows measured.\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 =\n\t\t(options.bm25Leg ?? service?.supportsLexicalRetriever() ?? false) && 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"]}
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;;;;;;;;;;;OAWG;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;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,qBAAqB,CAAC,UAAU,EAAE,SAAS,cAAc,EAAE,GAAG,cAAc,EAAE,CA+B7F;AAED,wBAAsB,kBAAkB,CAAC,OAAO,EAAE,eAAe,GAAG,OAAO,CAAC,cAAc,CAAC,CAyK1F;AAED,wBAAsB,SAAS,CAAC,OAAO,EAAE,gBAAgB,GAAG,OAAO,CAAC,eAAe,CAAC,CA+BnF","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.\n\t *\n\t * Defaults to on wherever the daemon can serve it, because BM25 is the\n\t * better lexical leg on the indexed corpus: Recall@50 0.790 -> 0.879, 6 of\n\t * 62 queries better and 0 worse (p <= 0.05). When it is on, the grep leg\n\t * narrows to files the index has not read — see `staleFiles`.\n\t *\n\t * Set `false` to force ripgrep as the only lexical leg; the eval harness\n\t * does this to keep measuring what the old rows measured.\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\n/**\n * Fold overlapping or adjacent spans of the same file into their best-ranked\n * occurrence.\n *\n * Chunks overlap by design (see the chunker's `CHUNK_OVERLAP_LINES`) and each\n * one is a separate id, so neighbouring chunks of one region survive fusion as\n * separate candidates and take separate result slots — showing the model code\n * it already has. Measured on the 62-query set at the tool's default\n * `limit=5`, 43 queries had such a pair in their top 5 and 58 of 310 slots\n * went to repeated code.\n *\n * Merging is free recall: the union of two overlapping spans matches exactly\n * what either matched, so nothing is gained by widening — the gain is entirely\n * the slot handed back to the ranked tail. Recall@5 0.597 -> 0.677, Recall@1\n * unchanged (merging cannot alter the top result).\n *\n * Adjacency counts as overlap (`endLine + 1`): two chunks that abut describe\n * one continuous region, and rendering them as separate results implies a gap\n * that is not there.\n */\nexport function mergeOverlappingSpans(candidates: readonly FusedCandidate[]): FusedCandidate[] {\n\tconst merged: FusedCandidate[] = [];\n\tfor (const candidate of candidates) {\n\t\tconst into = merged.find(\n\t\t\t(m) =>\n\t\t\t\tm.path === candidate.path && candidate.startLine <= m.endLine + 1 && m.startLine <= candidate.endLine + 1,\n\t\t);\n\t\tif (!into) {\n\t\t\tmerged.push({ ...candidate, ranks: { ...candidate.ranks }, rawScores: { ...candidate.rawScores } });\n\t\t\tcontinue;\n\t\t}\n\t\tinto.startLine = Math.min(into.startLine, candidate.startLine);\n\t\tinto.endLine = Math.max(into.endLine, candidate.endLine);\n\t\t// Keep the folded id: the trace is the only record that retrieval found\n\t\t// it, and dropping it would make a merged result look like a miss.\n\t\tif (!into.mergedFrom) into.mergedFrom = [];\n\t\tinto.mergedFrom.push(candidate.id);\n\t\t// The merged region really was reached by every retriever that found any\n\t\t// part of it, so the source label and the trace should say so. Best rank\n\t\t// per source wins; the fused score stays the survivor's, which is what\n\t\t// ordered it here.\n\t\tfor (const [source, rank] of Object.entries(candidate.ranks)) {\n\t\t\tconst held = into.ranks[source as keyof typeof into.ranks];\n\t\t\tif (held === undefined || rank < held) into.ranks[source as keyof typeof into.ranks] = rank;\n\t\t}\n\t\tfor (const [source, score] of Object.entries(candidate.rawScores)) {\n\t\t\tconst held = into.rawScores[source as keyof typeof into.rawScores];\n\t\t\tif (held === undefined || score > held) into.rawScores[source as keyof typeof into.rawScores] = score;\n\t\t}\n\t}\n\treturn merged;\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 =\n\t\t(options.bm25Leg ?? service?.supportsLexicalRetriever() ?? false) && 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\t// Merge before slicing, so a slot freed by a duplicate is refilled from the\n\t// ranked tail rather than left empty.\n\tcandidates = mergeOverlappingSpans(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, mergedFrom }) => ({\n\t\t\tid,\n\t\t\trrfScore,\n\t\t\tranks,\n\t\t\trawScores,\n\t\t\t...(mergedFrom ? { mergedFrom } : {}),\n\t\t})),\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"]}
@@ -92,6 +92,58 @@ export function hoistStaleCandidates(candidates, staleFiles) {
92
92
  }
93
93
  return [...hoisted, ...rest];
94
94
  }
95
+ /**
96
+ * Fold overlapping or adjacent spans of the same file into their best-ranked
97
+ * occurrence.
98
+ *
99
+ * Chunks overlap by design (see the chunker's `CHUNK_OVERLAP_LINES`) and each
100
+ * one is a separate id, so neighbouring chunks of one region survive fusion as
101
+ * separate candidates and take separate result slots — showing the model code
102
+ * it already has. Measured on the 62-query set at the tool's default
103
+ * `limit=5`, 43 queries had such a pair in their top 5 and 58 of 310 slots
104
+ * went to repeated code.
105
+ *
106
+ * Merging is free recall: the union of two overlapping spans matches exactly
107
+ * what either matched, so nothing is gained by widening — the gain is entirely
108
+ * the slot handed back to the ranked tail. Recall@5 0.597 -> 0.677, Recall@1
109
+ * unchanged (merging cannot alter the top result).
110
+ *
111
+ * Adjacency counts as overlap (`endLine + 1`): two chunks that abut describe
112
+ * one continuous region, and rendering them as separate results implies a gap
113
+ * that is not there.
114
+ */
115
+ export function mergeOverlappingSpans(candidates) {
116
+ const merged = [];
117
+ for (const candidate of candidates) {
118
+ const into = merged.find((m) => m.path === candidate.path && candidate.startLine <= m.endLine + 1 && m.startLine <= candidate.endLine + 1);
119
+ if (!into) {
120
+ merged.push({ ...candidate, ranks: { ...candidate.ranks }, rawScores: { ...candidate.rawScores } });
121
+ continue;
122
+ }
123
+ into.startLine = Math.min(into.startLine, candidate.startLine);
124
+ into.endLine = Math.max(into.endLine, candidate.endLine);
125
+ // Keep the folded id: the trace is the only record that retrieval found
126
+ // it, and dropping it would make a merged result look like a miss.
127
+ if (!into.mergedFrom)
128
+ into.mergedFrom = [];
129
+ into.mergedFrom.push(candidate.id);
130
+ // The merged region really was reached by every retriever that found any
131
+ // part of it, so the source label and the trace should say so. Best rank
132
+ // per source wins; the fused score stays the survivor's, which is what
133
+ // ordered it here.
134
+ for (const [source, rank] of Object.entries(candidate.ranks)) {
135
+ const held = into.ranks[source];
136
+ if (held === undefined || rank < held)
137
+ into.ranks[source] = rank;
138
+ }
139
+ for (const [source, score] of Object.entries(candidate.rawScores)) {
140
+ const held = into.rawScores[source];
141
+ if (held === undefined || score > held)
142
+ into.rawScores[source] = score;
143
+ }
144
+ }
145
+ return merged;
146
+ }
95
147
  export async function retrieveCandidates(options) {
96
148
  const { cwd, query, service, signal } = options;
97
149
  const glob = normalizeSearchGlob(options.glob);
@@ -246,7 +298,9 @@ export async function retrieveCandidates(options) {
246
298
  // lone grep hit from an unindexed file has the lowest prior there is, so
247
299
  // hoisting first would simply be undone. The reranker still orders the
248
300
  // hoisted set against itself.
249
- candidates = hoistStaleCandidates(candidates, staleSet).slice(0, limit);
301
+ // Merge before slicing, so a slot freed by a duplicate is refilled from the
302
+ // ranked tail rather than left empty.
303
+ candidates = mergeOverlappingSpans(hoistStaleCandidates(candidates, staleSet)).slice(0, limit);
250
304
  return {
251
305
  candidates,
252
306
  resolvedMode: mode,
@@ -270,7 +324,13 @@ export async function runSearch(options) {
270
324
  indexPhase: retrieved.indexPhase,
271
325
  rrfK: retrieved.resolvedMode === "hybrid" ? retrieved.rrfK : undefined,
272
326
  retrievers: retrieved.retrievers,
273
- fused: retrieved.candidates.map(({ id, rrfScore, ranks, rawScores }) => ({ id, rrfScore, ranks, rawScores })),
327
+ fused: retrieved.candidates.map(({ id, rrfScore, ranks, rawScores, mergedFrom }) => ({
328
+ id,
329
+ rrfScore,
330
+ ranks,
331
+ rawScores,
332
+ ...(mergedFrom ? { mergedFrom } : {}),
333
+ })),
274
334
  rerank: retrieved.rerank,
275
335
  });
276
336
  return {
@@ -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/** 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.\n\t *\n\t * Defaults to on wherever the daemon can serve it, because BM25 is the\n\t * better lexical leg on the indexed corpus: Recall@50 0.790 -> 0.879, 6 of\n\t * 62 queries better and 0 worse (p <= 0.05). When it is on, the grep leg\n\t * narrows to files the index has not read — see `staleFiles`.\n\t *\n\t * Set `false` to force ripgrep as the only lexical leg; the eval harness\n\t * does this to keep measuring what the old rows measured.\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 =\n\t\t(options.bm25Leg ?? service?.supportsLexicalRetriever() ?? false) && 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"]}
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.\n\t *\n\t * Defaults to on wherever the daemon can serve it, because BM25 is the\n\t * better lexical leg on the indexed corpus: Recall@50 0.790 -> 0.879, 6 of\n\t * 62 queries better and 0 worse (p <= 0.05). When it is on, the grep leg\n\t * narrows to files the index has not read — see `staleFiles`.\n\t *\n\t * Set `false` to force ripgrep as the only lexical leg; the eval harness\n\t * does this to keep measuring what the old rows measured.\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\n/**\n * Fold overlapping or adjacent spans of the same file into their best-ranked\n * occurrence.\n *\n * Chunks overlap by design (see the chunker's `CHUNK_OVERLAP_LINES`) and each\n * one is a separate id, so neighbouring chunks of one region survive fusion as\n * separate candidates and take separate result slots — showing the model code\n * it already has. Measured on the 62-query set at the tool's default\n * `limit=5`, 43 queries had such a pair in their top 5 and 58 of 310 slots\n * went to repeated code.\n *\n * Merging is free recall: the union of two overlapping spans matches exactly\n * what either matched, so nothing is gained by widening — the gain is entirely\n * the slot handed back to the ranked tail. Recall@5 0.597 -> 0.677, Recall@1\n * unchanged (merging cannot alter the top result).\n *\n * Adjacency counts as overlap (`endLine + 1`): two chunks that abut describe\n * one continuous region, and rendering them as separate results implies a gap\n * that is not there.\n */\nexport function mergeOverlappingSpans(candidates: readonly FusedCandidate[]): FusedCandidate[] {\n\tconst merged: FusedCandidate[] = [];\n\tfor (const candidate of candidates) {\n\t\tconst into = merged.find(\n\t\t\t(m) =>\n\t\t\t\tm.path === candidate.path && candidate.startLine <= m.endLine + 1 && m.startLine <= candidate.endLine + 1,\n\t\t);\n\t\tif (!into) {\n\t\t\tmerged.push({ ...candidate, ranks: { ...candidate.ranks }, rawScores: { ...candidate.rawScores } });\n\t\t\tcontinue;\n\t\t}\n\t\tinto.startLine = Math.min(into.startLine, candidate.startLine);\n\t\tinto.endLine = Math.max(into.endLine, candidate.endLine);\n\t\t// Keep the folded id: the trace is the only record that retrieval found\n\t\t// it, and dropping it would make a merged result look like a miss.\n\t\tif (!into.mergedFrom) into.mergedFrom = [];\n\t\tinto.mergedFrom.push(candidate.id);\n\t\t// The merged region really was reached by every retriever that found any\n\t\t// part of it, so the source label and the trace should say so. Best rank\n\t\t// per source wins; the fused score stays the survivor's, which is what\n\t\t// ordered it here.\n\t\tfor (const [source, rank] of Object.entries(candidate.ranks)) {\n\t\t\tconst held = into.ranks[source as keyof typeof into.ranks];\n\t\t\tif (held === undefined || rank < held) into.ranks[source as keyof typeof into.ranks] = rank;\n\t\t}\n\t\tfor (const [source, score] of Object.entries(candidate.rawScores)) {\n\t\t\tconst held = into.rawScores[source as keyof typeof into.rawScores];\n\t\t\tif (held === undefined || score > held) into.rawScores[source as keyof typeof into.rawScores] = score;\n\t\t}\n\t}\n\treturn merged;\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 =\n\t\t(options.bm25Leg ?? service?.supportsLexicalRetriever() ?? false) && 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\t// Merge before slicing, so a slot freed by a duplicate is refilled from the\n\t// ranked tail rather than left empty.\n\tcandidates = mergeOverlappingSpans(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, mergedFrom }) => ({\n\t\t\tid,\n\t\t\trrfScore,\n\t\t\tranks,\n\t\t\trawScores,\n\t\t\t...(mergedFrom ? { mergedFrom } : {}),\n\t\t})),\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"]}
@@ -35,6 +35,15 @@ export interface FusedHit {
35
35
  ranks: Partial<Record<RetrieverSource, number>>;
36
36
  /** Raw score at the best rank per retriever. Diagnostics only. */
37
37
  rawScores: Partial<Record<RetrieverSource, number>>;
38
+ /**
39
+ * Ids folded into this one by span merging, if any.
40
+ *
41
+ * The model sees one result per continuous region, but a trace that only
42
+ * listed survivors would hide that a chunk was retrieved at all — which is
43
+ * exactly what you need when working out why something ranked where it did.
44
+ * Diagnostics only.
45
+ */
46
+ mergedFrom?: string[];
38
47
  }
39
48
  /** Line-span identity a candidate id resolves to, for post-fusion expansion. */
40
49
  export interface CandidateSpan {
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../../src/core/search/types.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH;;;;;;;;;GASG;AACH,KAAK,eAAe,GAAG,MAAM,GAAG,OAAO,GAAG,MAAM,CAAC;AAEjD,MAAM,MAAM,UAAU,GAAG,MAAM,GAAG,SAAS,GAAG,UAAU,GAAG,QAAQ,CAAC;AACpE,MAAM,MAAM,kBAAkB,GAAG,OAAO,CAAC,UAAU,EAAE,MAAM,CAAC,CAAC;AAE7D,MAAM,WAAW,SAAS;IACzB,EAAE,EAAE,MAAM,CAAC;IACX,4DAA4D;IAC5D,IAAI,EAAE,MAAM,CAAC;IACb,uFAAmF;IACnF,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,eAAe,CAAC;CACxB;AAED,MAAM,WAAW,QAAQ;IACxB,EAAE,EAAE,MAAM,CAAC;IACX,QAAQ,EAAE,MAAM,CAAC;IACjB,4CAA4C;IAC5C,KAAK,EAAE,OAAO,CAAC,MAAM,CAAC,eAAe,EAAE,MAAM,CAAC,CAAC,CAAC;IAChD,kEAAkE;IAClE,SAAS,EAAE,OAAO,CAAC,MAAM,CAAC,eAAe,EAAE,MAAM,CAAC,CAAC,CAAC;CACpD;AAED,gFAAgF;AAChF,MAAM,WAAW,aAAa;IAC7B,IAAI,EAAE,MAAM,CAAC;IACb,yBAAyB;IACzB,SAAS,EAAE,MAAM,CAAC;IAClB,sEAAsE;IACtE,OAAO,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,cAAe,SAAQ,QAAQ,EAAE,aAAa;CAAG;AAElE;0BAC0B;AAC1B,MAAM,WAAW,WAAW;IAC3B,WAAW,EAAE,MAAM,CAAC;IACpB,KAAK,EAAE,MAAM,CAAC;IACd,aAAa,EAAE,UAAU,CAAC;IAC1B,YAAY,EAAE,kBAAkB,CAAC;IACjC,wEAAwE;IACxE,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,UAAU,EAAE,OAAO,GAAG,UAAU,GAAG,aAAa,CAAC;IACjD,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,UAAU,EAAE,OAAO,CAAC,MAAM,CAAC,eAAe,EAAE;QAAE,SAAS,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC,CAAC;IACtF,KAAK,EAAE,QAAQ,EAAE,CAAC;IAClB,MAAM,CAAC,EAAE;QAAE,OAAO,EAAE,OAAO,CAAC;QAAC,cAAc,EAAE,MAAM,CAAC;QAAC,SAAS,EAAE,MAAM,CAAA;KAAE,CAAC;CACzE","sourcesContent":["/**\n * Shared types for hybrid code retrieval (see docs/hybrid-retrieval-design.md).\n *\n * Identity note: candidate ids are per-index-build chunk ids (`relpath#index`)\n * from the embedding sidecar, or synthetic `relpath#L<line>` fallback ids for\n * grep hits in files the index does not cover. They are stable within one\n * query (fusion and span expansion consult the same sidecar snapshot) but not\n * across edits or rebuilds — never persist them as durable references.\n */\n\n/**\n * A retriever whose ranked list can enter fusion.\n *\n * `bm25` is the daemon's Okapi lexical index, fetched as its own leg via the\n * `retriever: \"lexical\"` op rather than pre-fused by `query_hybrid`. Keeping\n * it separate from `grep` matters: they are both \"lexical\" but they fail\n * differently — BM25 has IDF and misses identifiers written in another naming\n * convention, ripgrep has neither IDF nor an index but sees the working tree,\n * including edits made this session.\n */\ntype RetrieverSource = \"grep\" | \"embed\" | \"bm25\";\n\nexport type SearchMode = \"auto\" | \"lexical\" | \"semantic\" | \"hybrid\";\nexport type ResolvedSearchMode = Exclude<SearchMode, \"auto\">;\n\nexport interface RankedHit {\n\tid: string;\n\t/** 1-indexed, gap-free rank within its retriever's list. */\n\trank: number;\n\t/** Retriever-local score (BM25-ish, cosine, …). Diagnostics only — never fused. */\n\tscore?: number;\n\tsource: RetrieverSource;\n}\n\nexport interface FusedHit {\n\tid: string;\n\trrfScore: number;\n\t/** Best rank per contributing retriever. */\n\tranks: Partial<Record<RetrieverSource, number>>;\n\t/** Raw score at the best rank per retriever. Diagnostics only. */\n\trawScores: Partial<Record<RetrieverSource, number>>;\n}\n\n/** Line-span identity a candidate id resolves to, for post-fusion expansion. */\nexport interface CandidateSpan {\n\tpath: string;\n\t/** 1-based inclusive. */\n\tstartLine: number;\n\t/** 1-based inclusive. May exceed the file's length; readers clamp. */\n\tendLine: number;\n}\n\nexport interface FusedCandidate extends FusedHit, CandidateSpan {}\n\n/** Per-call diagnostic record, written to the store-dir trace jsonl — never\n * into model context. */\nexport interface SearchTrace {\n\ttimestampMs: number;\n\tquery: string;\n\trequestedMode: SearchMode;\n\tresolvedMode: ResolvedSearchMode;\n\t/** Set when the resolved mode is a degradation of the requested one. */\n\tdegradedReason?: string;\n\tindexPhase: \"ready\" | \"indexing\" | \"unavailable\";\n\trrfK?: number;\n\tretrievers: Partial<Record<RetrieverSource, { latencyMs: number; hitCount: number }>>;\n\tfused: FusedHit[];\n\trerank?: { applied: boolean; candidateCount: number; latencyMs: number };\n}\n"]}
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../../src/core/search/types.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH;;;;;;;;;GASG;AACH,KAAK,eAAe,GAAG,MAAM,GAAG,OAAO,GAAG,MAAM,CAAC;AAEjD,MAAM,MAAM,UAAU,GAAG,MAAM,GAAG,SAAS,GAAG,UAAU,GAAG,QAAQ,CAAC;AACpE,MAAM,MAAM,kBAAkB,GAAG,OAAO,CAAC,UAAU,EAAE,MAAM,CAAC,CAAC;AAE7D,MAAM,WAAW,SAAS;IACzB,EAAE,EAAE,MAAM,CAAC;IACX,4DAA4D;IAC5D,IAAI,EAAE,MAAM,CAAC;IACb,uFAAmF;IACnF,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,eAAe,CAAC;CACxB;AAED,MAAM,WAAW,QAAQ;IACxB,EAAE,EAAE,MAAM,CAAC;IACX,QAAQ,EAAE,MAAM,CAAC;IACjB,4CAA4C;IAC5C,KAAK,EAAE,OAAO,CAAC,MAAM,CAAC,eAAe,EAAE,MAAM,CAAC,CAAC,CAAC;IAChD,kEAAkE;IAClE,SAAS,EAAE,OAAO,CAAC,MAAM,CAAC,eAAe,EAAE,MAAM,CAAC,CAAC,CAAC;IACpD;;;;;;;OAOG;IACH,UAAU,CAAC,EAAE,MAAM,EAAE,CAAC;CACtB;AAED,gFAAgF;AAChF,MAAM,WAAW,aAAa;IAC7B,IAAI,EAAE,MAAM,CAAC;IACb,yBAAyB;IACzB,SAAS,EAAE,MAAM,CAAC;IAClB,sEAAsE;IACtE,OAAO,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,cAAe,SAAQ,QAAQ,EAAE,aAAa;CAAG;AAElE;0BAC0B;AAC1B,MAAM,WAAW,WAAW;IAC3B,WAAW,EAAE,MAAM,CAAC;IACpB,KAAK,EAAE,MAAM,CAAC;IACd,aAAa,EAAE,UAAU,CAAC;IAC1B,YAAY,EAAE,kBAAkB,CAAC;IACjC,wEAAwE;IACxE,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,UAAU,EAAE,OAAO,GAAG,UAAU,GAAG,aAAa,CAAC;IACjD,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,UAAU,EAAE,OAAO,CAAC,MAAM,CAAC,eAAe,EAAE;QAAE,SAAS,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC,CAAC;IACtF,KAAK,EAAE,QAAQ,EAAE,CAAC;IAClB,MAAM,CAAC,EAAE;QAAE,OAAO,EAAE,OAAO,CAAC;QAAC,cAAc,EAAE,MAAM,CAAC;QAAC,SAAS,EAAE,MAAM,CAAA;KAAE,CAAC;CACzE","sourcesContent":["/**\n * Shared types for hybrid code retrieval (see docs/hybrid-retrieval-design.md).\n *\n * Identity note: candidate ids are per-index-build chunk ids (`relpath#index`)\n * from the embedding sidecar, or synthetic `relpath#L<line>` fallback ids for\n * grep hits in files the index does not cover. They are stable within one\n * query (fusion and span expansion consult the same sidecar snapshot) but not\n * across edits or rebuilds — never persist them as durable references.\n */\n\n/**\n * A retriever whose ranked list can enter fusion.\n *\n * `bm25` is the daemon's Okapi lexical index, fetched as its own leg via the\n * `retriever: \"lexical\"` op rather than pre-fused by `query_hybrid`. Keeping\n * it separate from `grep` matters: they are both \"lexical\" but they fail\n * differently — BM25 has IDF and misses identifiers written in another naming\n * convention, ripgrep has neither IDF nor an index but sees the working tree,\n * including edits made this session.\n */\ntype RetrieverSource = \"grep\" | \"embed\" | \"bm25\";\n\nexport type SearchMode = \"auto\" | \"lexical\" | \"semantic\" | \"hybrid\";\nexport type ResolvedSearchMode = Exclude<SearchMode, \"auto\">;\n\nexport interface RankedHit {\n\tid: string;\n\t/** 1-indexed, gap-free rank within its retriever's list. */\n\trank: number;\n\t/** Retriever-local score (BM25-ish, cosine, …). Diagnostics only — never fused. */\n\tscore?: number;\n\tsource: RetrieverSource;\n}\n\nexport interface FusedHit {\n\tid: string;\n\trrfScore: number;\n\t/** Best rank per contributing retriever. */\n\tranks: Partial<Record<RetrieverSource, number>>;\n\t/** Raw score at the best rank per retriever. Diagnostics only. */\n\trawScores: Partial<Record<RetrieverSource, number>>;\n\t/**\n\t * Ids folded into this one by span merging, if any.\n\t *\n\t * The model sees one result per continuous region, but a trace that only\n\t * listed survivors would hide that a chunk was retrieved at all — which is\n\t * exactly what you need when working out why something ranked where it did.\n\t * Diagnostics only.\n\t */\n\tmergedFrom?: string[];\n}\n\n/** Line-span identity a candidate id resolves to, for post-fusion expansion. */\nexport interface CandidateSpan {\n\tpath: string;\n\t/** 1-based inclusive. */\n\tstartLine: number;\n\t/** 1-based inclusive. May exceed the file's length; readers clamp. */\n\tendLine: number;\n}\n\nexport interface FusedCandidate extends FusedHit, CandidateSpan {}\n\n/** Per-call diagnostic record, written to the store-dir trace jsonl — never\n * into model context. */\nexport interface SearchTrace {\n\ttimestampMs: number;\n\tquery: string;\n\trequestedMode: SearchMode;\n\tresolvedMode: ResolvedSearchMode;\n\t/** Set when the resolved mode is a degradation of the requested one. */\n\tdegradedReason?: string;\n\tindexPhase: \"ready\" | \"indexing\" | \"unavailable\";\n\trrfK?: number;\n\tretrievers: Partial<Record<RetrieverSource, { latencyMs: number; hitCount: number }>>;\n\tfused: FusedHit[];\n\trerank?: { applied: boolean; candidateCount: number; latencyMs: number };\n}\n"]}
@@ -1 +1 @@
1
- {"version":3,"file":"types.js","sourceRoot":"","sources":["../../../src/core/search/types.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG","sourcesContent":["/**\n * Shared types for hybrid code retrieval (see docs/hybrid-retrieval-design.md).\n *\n * Identity note: candidate ids are per-index-build chunk ids (`relpath#index`)\n * from the embedding sidecar, or synthetic `relpath#L<line>` fallback ids for\n * grep hits in files the index does not cover. They are stable within one\n * query (fusion and span expansion consult the same sidecar snapshot) but not\n * across edits or rebuilds — never persist them as durable references.\n */\n\n/**\n * A retriever whose ranked list can enter fusion.\n *\n * `bm25` is the daemon's Okapi lexical index, fetched as its own leg via the\n * `retriever: \"lexical\"` op rather than pre-fused by `query_hybrid`. Keeping\n * it separate from `grep` matters: they are both \"lexical\" but they fail\n * differently — BM25 has IDF and misses identifiers written in another naming\n * convention, ripgrep has neither IDF nor an index but sees the working tree,\n * including edits made this session.\n */\ntype RetrieverSource = \"grep\" | \"embed\" | \"bm25\";\n\nexport type SearchMode = \"auto\" | \"lexical\" | \"semantic\" | \"hybrid\";\nexport type ResolvedSearchMode = Exclude<SearchMode, \"auto\">;\n\nexport interface RankedHit {\n\tid: string;\n\t/** 1-indexed, gap-free rank within its retriever's list. */\n\trank: number;\n\t/** Retriever-local score (BM25-ish, cosine, …). Diagnostics only — never fused. */\n\tscore?: number;\n\tsource: RetrieverSource;\n}\n\nexport interface FusedHit {\n\tid: string;\n\trrfScore: number;\n\t/** Best rank per contributing retriever. */\n\tranks: Partial<Record<RetrieverSource, number>>;\n\t/** Raw score at the best rank per retriever. Diagnostics only. */\n\trawScores: Partial<Record<RetrieverSource, number>>;\n}\n\n/** Line-span identity a candidate id resolves to, for post-fusion expansion. */\nexport interface CandidateSpan {\n\tpath: string;\n\t/** 1-based inclusive. */\n\tstartLine: number;\n\t/** 1-based inclusive. May exceed the file's length; readers clamp. */\n\tendLine: number;\n}\n\nexport interface FusedCandidate extends FusedHit, CandidateSpan {}\n\n/** Per-call diagnostic record, written to the store-dir trace jsonl — never\n * into model context. */\nexport interface SearchTrace {\n\ttimestampMs: number;\n\tquery: string;\n\trequestedMode: SearchMode;\n\tresolvedMode: ResolvedSearchMode;\n\t/** Set when the resolved mode is a degradation of the requested one. */\n\tdegradedReason?: string;\n\tindexPhase: \"ready\" | \"indexing\" | \"unavailable\";\n\trrfK?: number;\n\tretrievers: Partial<Record<RetrieverSource, { latencyMs: number; hitCount: number }>>;\n\tfused: FusedHit[];\n\trerank?: { applied: boolean; candidateCount: number; latencyMs: number };\n}\n"]}
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../../../src/core/search/types.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG","sourcesContent":["/**\n * Shared types for hybrid code retrieval (see docs/hybrid-retrieval-design.md).\n *\n * Identity note: candidate ids are per-index-build chunk ids (`relpath#index`)\n * from the embedding sidecar, or synthetic `relpath#L<line>` fallback ids for\n * grep hits in files the index does not cover. They are stable within one\n * query (fusion and span expansion consult the same sidecar snapshot) but not\n * across edits or rebuilds — never persist them as durable references.\n */\n\n/**\n * A retriever whose ranked list can enter fusion.\n *\n * `bm25` is the daemon's Okapi lexical index, fetched as its own leg via the\n * `retriever: \"lexical\"` op rather than pre-fused by `query_hybrid`. Keeping\n * it separate from `grep` matters: they are both \"lexical\" but they fail\n * differently — BM25 has IDF and misses identifiers written in another naming\n * convention, ripgrep has neither IDF nor an index but sees the working tree,\n * including edits made this session.\n */\ntype RetrieverSource = \"grep\" | \"embed\" | \"bm25\";\n\nexport type SearchMode = \"auto\" | \"lexical\" | \"semantic\" | \"hybrid\";\nexport type ResolvedSearchMode = Exclude<SearchMode, \"auto\">;\n\nexport interface RankedHit {\n\tid: string;\n\t/** 1-indexed, gap-free rank within its retriever's list. */\n\trank: number;\n\t/** Retriever-local score (BM25-ish, cosine, …). Diagnostics only — never fused. */\n\tscore?: number;\n\tsource: RetrieverSource;\n}\n\nexport interface FusedHit {\n\tid: string;\n\trrfScore: number;\n\t/** Best rank per contributing retriever. */\n\tranks: Partial<Record<RetrieverSource, number>>;\n\t/** Raw score at the best rank per retriever. Diagnostics only. */\n\trawScores: Partial<Record<RetrieverSource, number>>;\n\t/**\n\t * Ids folded into this one by span merging, if any.\n\t *\n\t * The model sees one result per continuous region, but a trace that only\n\t * listed survivors would hide that a chunk was retrieved at all — which is\n\t * exactly what you need when working out why something ranked where it did.\n\t * Diagnostics only.\n\t */\n\tmergedFrom?: string[];\n}\n\n/** Line-span identity a candidate id resolves to, for post-fusion expansion. */\nexport interface CandidateSpan {\n\tpath: string;\n\t/** 1-based inclusive. */\n\tstartLine: number;\n\t/** 1-based inclusive. May exceed the file's length; readers clamp. */\n\tendLine: number;\n}\n\nexport interface FusedCandidate extends FusedHit, CandidateSpan {}\n\n/** Per-call diagnostic record, written to the store-dir trace jsonl — never\n * into model context. */\nexport interface SearchTrace {\n\ttimestampMs: number;\n\tquery: string;\n\trequestedMode: SearchMode;\n\tresolvedMode: ResolvedSearchMode;\n\t/** Set when the resolved mode is a degradation of the requested one. */\n\tdegradedReason?: string;\n\tindexPhase: \"ready\" | \"indexing\" | \"unavailable\";\n\trrfK?: number;\n\tretrievers: Partial<Record<RetrieverSource, { latencyMs: number; hitCount: number }>>;\n\tfused: FusedHit[];\n\trerank?: { applied: boolean; candidateCount: number; latencyMs: number };\n}\n"]}
@@ -1 +1 @@
1
- {"version":3,"file":"search.d.ts","sourceRoot":"","sources":["../../../src/core/tools/search.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAGH,OAAO,EAAe,IAAI,EAAE,MAAM,SAAS,CAAC;AAE5C,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,mCAAmC,CAAC;AAC1E,OAAO,KAAK,EAAE,cAAc,EAA2B,MAAM,wBAAwB,CAAC;AAEtF,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,oBAAoB,CAAC;AAQ7D,QAAA,MAAM,YAAY;;;;;EAoBhB,CAAC;AAIH,MAAM,WAAW,iBAAiB;IACjC,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,YAAY,CAAC,EAAE,kBAAkB,CAAC;IAClC,QAAQ,CAAC,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC;CAC3C;AAED,MAAM,WAAW,iBAAiB;IACjC;;;;OAIG;IACH,UAAU,CAAC,EAAE,MAAM,gBAAgB,GAAG,SAAS,CAAC;CAChD;AAqCD,wBAAgB,0BAA0B,CACzC,GAAG,EAAE,MAAM,EACX,OAAO,CAAC,EAAE,iBAAiB,GACzB,cAAc,CAAC,OAAO,YAAY,EAAE,iBAAiB,GAAG,SAAS,CAAC,CAoEpE","sourcesContent":["/**\n * The unified `search` tool: ranked code discovery in lexical, semantic, or\n * hybrid (rank-fused) mode. Replaces the old `semantic_search` tool — see\n * docs/hybrid-retrieval-design.md, Decision 1. `grep` stays separate for\n * exact line-level mechanics; this tool answers \"find where X lives\".\n */\n\nimport { Text } from \"@kolisachint/hoocode-tui\";\nimport { type Static, Type } from \"typebox\";\nimport { keyHint } from \"../../modes/interactive/components/keybinding-hints.js\";\nimport type { EmbsearchService } from \"../embsearch/embsearch-service.js\";\nimport type { ToolDefinition, ToolRenderResultOptions } from \"../extensions/types.js\";\nimport { runSearch } from \"../search/hybrid-search.js\";\nimport type { ResolvedSearchMode } from \"../search/types.js\";\nimport { getTextOutput, invalidArgText, str } from \"./render-utils.js\";\n\n// Default of 5 balances recall against context cost: eval showed limit=5\n// preserving the top-rank hits of limit=10 at roughly half the tokens.\nconst DEFAULT_RESULTS = 5;\nconst MAX_RESULTS = 30;\n\nconst searchSchema = Type.Object({\n\tquery: Type.String({\n\t\tdescription:\n\t\t\t\"What to find: an identifier, error text, or a natural-language description of the code, e.g. 'where sessions are persisted to disk'\",\n\t}),\n\tmode: Type.Optional(\n\t\tType.Union([Type.Literal(\"auto\"), Type.Literal(\"lexical\"), Type.Literal(\"semantic\"), Type.Literal(\"hybrid\")], {\n\t\t\tdescription:\n\t\t\t\t\"Retrieval mode (default: auto). auto picks hybrid when the semantic index is available; lexical = exact-text only; semantic = embedding index only; hybrid = both, fused by rank.\",\n\t\t}),\n\t),\n\tglob: Type.Optional(\n\t\tType.String({\n\t\t\tdescription:\n\t\t\t\t\"Optional glob filter applied to file paths. Only file paths matching the glob are searched. Supports both slashless patterns (match base name anywhere) and slash patterns (match full path).\",\n\t\t}),\n\t),\n\tlimit: Type.Optional(\n\t\tType.Number({ description: `Maximum number of results (default: ${DEFAULT_RESULTS}, max: ${MAX_RESULTS})` }),\n\t),\n});\n\ntype SearchToolInput = Static<typeof searchSchema>;\n\nexport interface SearchToolDetails {\n\tresultCount?: number;\n\tresolvedMode?: ResolvedSearchMode;\n\tindexing?: { done: number; total: number };\n}\n\nexport interface SearchToolOptions {\n\t/**\n\t * Provider for the per-session embsearch service. Resolved lazily at call\n\t * time because the service is created (and its index built) after tool\n\t * registration. When absent/unavailable the tool degrades to lexical mode.\n\t */\n\tgetService?: () => EmbsearchService | undefined;\n}\n\nfunction formatSearchCall(\n\targs: { query?: string; mode?: string; limit?: number } | undefined,\n\ttheme: typeof import(\"../../modes/interactive/theme/theme.js\").theme,\n): string {\n\tconst query = str(args?.query);\n\tconst queryDisplay = query === null ? invalidArgText(theme) : `\"${query}\"`;\n\tlet text = theme.fg(\"toolTitle\", theme.bold(\"search \")) + theme.fg(\"accent\", queryDisplay);\n\tconst extras: string[] = [];\n\tif (args?.mode && args.mode !== \"auto\") extras.push(args.mode);\n\tif (args?.limit !== undefined) extras.push(String(args.limit));\n\tif (extras.length > 0) text += theme.fg(\"muted\", ` (${extras.join(\", \")})`);\n\treturn text;\n}\n\nfunction formatSearchResult(\n\tresult: { content: Array<{ type: string; text?: string }>; details?: SearchToolDetails },\n\toptions: ToolRenderResultOptions,\n\ttheme: typeof import(\"../../modes/interactive/theme/theme.js\").theme,\n\tshowImages: boolean,\n): string {\n\tconst output = getTextOutput(result as any, showImages).trim();\n\tlet text = \"\";\n\tif (output) {\n\t\tconst lines = output.split(\"\\n\");\n\t\tconst maxLines = options.expanded ? lines.length : 15;\n\t\tconst displayLines = lines.slice(0, maxLines);\n\t\tconst remaining = lines.length - maxLines;\n\t\ttext += `\\n${displayLines.map((line) => theme.fg(\"toolOutput\", line)).join(\"\\n\")}`;\n\t\tif (remaining > 0) {\n\t\t\ttext += `${theme.fg(\"muted\", `\\n... (${remaining} more lines,`)} ${keyHint(\"app.tools.expand\", \"to expand\")})`;\n\t\t}\n\t}\n\treturn text;\n}\n\nexport function createSearchToolDefinition(\n\tcwd: string,\n\toptions?: SearchToolOptions,\n): ToolDefinition<typeof searchSchema, SearchToolDetails | undefined> {\n\treturn {\n\t\tname: \"search\",\n\t\tlabel: \"search\",\n\t\t// The search-vs-grep split is stated once, by buildSystemPrompt, whenever both\n\t\t// tools are registered — so it is deliberately absent here and from grep's\n\t\t// description. What stays is what only this tool knows: that the query is not\n\t\t// a regex, and that it degrades to exact-text when the index is missing.\n\t\tdescription:\n\t\t\t\"Find where code lives: ranked file:line-range results from exact-text and semantic (local embedding index) retrieval, fused by rank when both are available. The query is plain text, not a regex — regex metacharacters are matched literally. Falls back to exact-text retrieval automatically when the semantic index is unavailable.\",\n\t\tpromptSnippet: \"Ranked code search (exact + semantic, rank-fused)\",\n\t\tpromptGuidelines: [\n\t\t\t\"search defaults to mode=auto, which is almost always right. Use limit=3 for targeted lookups, 10–20 when exploring a broad topic.\",\n\t\t],\n\t\tparameters: searchSchema,\n\t\tasync execute(_toolCallId, { query, mode, glob, limit }: SearchToolInput, signal?: AbortSignal) {\n\t\t\tif (signal?.aborted) throw new Error(\"Operation aborted\");\n\n\t\t\tconst result = await runSearch({\n\t\t\t\tcwd,\n\t\t\t\tquery,\n\t\t\t\tmode,\n\t\t\t\tglob,\n\t\t\t\tlimit: Math.min(MAX_RESULTS, Math.max(1, limit ?? DEFAULT_RESULTS)),\n\t\t\t\tservice: options?.getService?.(),\n\t\t\t\tsignal,\n\t\t\t});\n\t\t\tif (signal?.aborted) throw new Error(\"Operation aborted\");\n\n\t\t\tconst details: SearchToolDetails = {\n\t\t\t\tresultCount: result.resultCount,\n\t\t\t\tresolvedMode: result.resolvedMode,\n\t\t\t\tindexing: result.indexing,\n\t\t\t};\n\n\t\t\tconst notices: string[] = [];\n\t\t\tif (result.degradedReason) notices.push(result.degradedReason);\n\t\t\tif (result.indexing) {\n\t\t\t\tnotices.push(\n\t\t\t\t\t`index still building: ${result.indexing.done}/${result.indexing.total} chunks embedded — results may be incomplete`,\n\t\t\t\t);\n\t\t\t}\n\t\t\tconst notice = notices.length > 0 ? `\\n\\n[${notices.join(\". \")}]` : \"\";\n\n\t\t\tif (result.resultCount === 0) {\n\t\t\t\treturn {\n\t\t\t\t\tcontent: [\n\t\t\t\t\t\t{ type: \"text\" as const, text: `No results for \"${query}\" (${result.resolvedMode})${notice}` },\n\t\t\t\t\t],\n\t\t\t\t\tdetails,\n\t\t\t\t};\n\t\t\t}\n\t\t\treturn {\n\t\t\t\tcontent: [{ type: \"text\" as const, text: result.text + notice }],\n\t\t\t\tdetails,\n\t\t\t};\n\t\t},\n\t\trenderCall(args, theme, context) {\n\t\t\tconst text = (context.lastComponent as Text | undefined) ?? new Text(\"\", 0, 0);\n\t\t\ttext.setText(formatSearchCall(args, theme));\n\t\t\treturn text;\n\t\t},\n\t\trenderResult(result, options, theme, context) {\n\t\t\tconst text = (context.lastComponent as Text | undefined) ?? new Text(\"\", 0, 0);\n\t\t\ttext.setText(formatSearchResult(result as any, options, theme, context.showImages));\n\t\t\treturn text;\n\t\t},\n\t};\n}\n"]}
1
+ {"version":3,"file":"search.d.ts","sourceRoot":"","sources":["../../../src/core/tools/search.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAGH,OAAO,EAAe,IAAI,EAAE,MAAM,SAAS,CAAC;AAE5C,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,mCAAmC,CAAC;AAC1E,OAAO,KAAK,EAAE,cAAc,EAA2B,MAAM,wBAAwB,CAAC;AAEtF,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,oBAAoB,CAAC;AAU7D,QAAA,MAAM,YAAY;;;;;EAoBhB,CAAC;AAIH,MAAM,WAAW,iBAAiB;IACjC,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,YAAY,CAAC,EAAE,kBAAkB,CAAC;IAClC,QAAQ,CAAC,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC;CAC3C;AAED,MAAM,WAAW,iBAAiB;IACjC;;;;OAIG;IACH,UAAU,CAAC,EAAE,MAAM,gBAAgB,GAAG,SAAS,CAAC;CAChD;AAqCD,wBAAgB,0BAA0B,CACzC,GAAG,EAAE,MAAM,EACX,OAAO,CAAC,EAAE,iBAAiB,GACzB,cAAc,CAAC,OAAO,YAAY,EAAE,iBAAiB,GAAG,SAAS,CAAC,CAoEpE","sourcesContent":["/**\n * The unified `search` tool: ranked code discovery in lexical, semantic, or\n * hybrid (rank-fused) mode. Replaces the old `semantic_search` tool — see\n * docs/hybrid-retrieval-design.md, Decision 1. `grep` stays separate for\n * exact line-level mechanics; this tool answers \"find where X lives\".\n */\n\nimport { Text } from \"@kolisachint/hoocode-tui\";\nimport { type Static, Type } from \"typebox\";\nimport { keyHint } from \"../../modes/interactive/components/keybinding-hints.js\";\nimport type { EmbsearchService } from \"../embsearch/embsearch-service.js\";\nimport type { ToolDefinition, ToolRenderResultOptions } from \"../extensions/types.js\";\nimport { runSearch } from \"../search/hybrid-search.js\";\nimport type { ResolvedSearchMode } from \"../search/types.js\";\nimport { getTextOutput, invalidArgText, str } from \"./render-utils.js\";\n\n// Default of 5 balances recall against context cost. Measured on the 62-query\n// eval: limit=5 costs ~1000 tokens and puts a gold span in the results 68% of\n// the time; limit=10 costs ~1600 tokens for 74%. The extra 6 points are real\n// but not worth 60% more context on every call, and the model can ask for more.\nconst DEFAULT_RESULTS = 5;\nconst MAX_RESULTS = 30;\n\nconst searchSchema = Type.Object({\n\tquery: Type.String({\n\t\tdescription:\n\t\t\t\"What to find: an identifier, error text, or a natural-language description of the code, e.g. 'where sessions are persisted to disk'\",\n\t}),\n\tmode: Type.Optional(\n\t\tType.Union([Type.Literal(\"auto\"), Type.Literal(\"lexical\"), Type.Literal(\"semantic\"), Type.Literal(\"hybrid\")], {\n\t\t\tdescription:\n\t\t\t\t\"Retrieval mode (default: auto, which is almost always right). auto = hybrid when the index is available, else lexical; hybrid = keyword and meaning over the index plus exact text for anything indexed later; semantic = index only, skipping exact text; lexical = exact text only, the one mode that works with no index.\",\n\t\t}),\n\t),\n\tglob: Type.Optional(\n\t\tType.String({\n\t\t\tdescription:\n\t\t\t\t\"Optional glob filter applied to file paths. Only file paths matching the glob are searched. Supports both slashless patterns (match base name anywhere) and slash patterns (match full path).\",\n\t\t}),\n\t),\n\tlimit: Type.Optional(\n\t\tType.Number({ description: `Maximum number of results (default: ${DEFAULT_RESULTS}, max: ${MAX_RESULTS})` }),\n\t),\n});\n\ntype SearchToolInput = Static<typeof searchSchema>;\n\nexport interface SearchToolDetails {\n\tresultCount?: number;\n\tresolvedMode?: ResolvedSearchMode;\n\tindexing?: { done: number; total: number };\n}\n\nexport interface SearchToolOptions {\n\t/**\n\t * Provider for the per-session embsearch service. Resolved lazily at call\n\t * time because the service is created (and its index built) after tool\n\t * registration. When absent/unavailable the tool degrades to lexical mode.\n\t */\n\tgetService?: () => EmbsearchService | undefined;\n}\n\nfunction formatSearchCall(\n\targs: { query?: string; mode?: string; limit?: number } | undefined,\n\ttheme: typeof import(\"../../modes/interactive/theme/theme.js\").theme,\n): string {\n\tconst query = str(args?.query);\n\tconst queryDisplay = query === null ? invalidArgText(theme) : `\"${query}\"`;\n\tlet text = theme.fg(\"toolTitle\", theme.bold(\"search \")) + theme.fg(\"accent\", queryDisplay);\n\tconst extras: string[] = [];\n\tif (args?.mode && args.mode !== \"auto\") extras.push(args.mode);\n\tif (args?.limit !== undefined) extras.push(String(args.limit));\n\tif (extras.length > 0) text += theme.fg(\"muted\", ` (${extras.join(\", \")})`);\n\treturn text;\n}\n\nfunction formatSearchResult(\n\tresult: { content: Array<{ type: string; text?: string }>; details?: SearchToolDetails },\n\toptions: ToolRenderResultOptions,\n\ttheme: typeof import(\"../../modes/interactive/theme/theme.js\").theme,\n\tshowImages: boolean,\n): string {\n\tconst output = getTextOutput(result as any, showImages).trim();\n\tlet text = \"\";\n\tif (output) {\n\t\tconst lines = output.split(\"\\n\");\n\t\tconst maxLines = options.expanded ? lines.length : 15;\n\t\tconst displayLines = lines.slice(0, maxLines);\n\t\tconst remaining = lines.length - maxLines;\n\t\ttext += `\\n${displayLines.map((line) => theme.fg(\"toolOutput\", line)).join(\"\\n\")}`;\n\t\tif (remaining > 0) {\n\t\t\ttext += `${theme.fg(\"muted\", `\\n... (${remaining} more lines,`)} ${keyHint(\"app.tools.expand\", \"to expand\")})`;\n\t\t}\n\t}\n\treturn text;\n}\n\nexport function createSearchToolDefinition(\n\tcwd: string,\n\toptions?: SearchToolOptions,\n): ToolDefinition<typeof searchSchema, SearchToolDetails | undefined> {\n\treturn {\n\t\tname: \"search\",\n\t\tlabel: \"search\",\n\t\t// The search-vs-grep split is stated once, by buildSystemPrompt, whenever both\n\t\t// tools are registered — so it is deliberately absent here and from grep's\n\t\t// description. What stays is what only this tool knows: that the query is not\n\t\t// a regex, and that it degrades to exact-text when the index is missing.\n\t\tdescription:\n\t\t\t\"Find where code lives: ranked file:line-range results, fusing keyword and semantic retrieval over a local index with exact-text search of files the index has not read yet. The query is plain text, not a regex — regex metacharacters are matched literally. Falls back to exact-text retrieval automatically when the index is unavailable, and still finds code written moments ago that no index has seen.\",\n\t\tpromptSnippet: \"Ranked code search (keyword + semantic, rank-fused)\",\n\t\tpromptGuidelines: [\n\t\t\t\"search defaults to mode=auto, which is almost always right. Use limit=3 for targeted lookups, 10–20 when exploring a broad topic — past ~15 results the deeper ones arrive as ranked file:line-range headers without a snippet, which is still enough to choose what to read.\",\n\t\t],\n\t\tparameters: searchSchema,\n\t\tasync execute(_toolCallId, { query, mode, glob, limit }: SearchToolInput, signal?: AbortSignal) {\n\t\t\tif (signal?.aborted) throw new Error(\"Operation aborted\");\n\n\t\t\tconst result = await runSearch({\n\t\t\t\tcwd,\n\t\t\t\tquery,\n\t\t\t\tmode,\n\t\t\t\tglob,\n\t\t\t\tlimit: Math.min(MAX_RESULTS, Math.max(1, limit ?? DEFAULT_RESULTS)),\n\t\t\t\tservice: options?.getService?.(),\n\t\t\t\tsignal,\n\t\t\t});\n\t\t\tif (signal?.aborted) throw new Error(\"Operation aborted\");\n\n\t\t\tconst details: SearchToolDetails = {\n\t\t\t\tresultCount: result.resultCount,\n\t\t\t\tresolvedMode: result.resolvedMode,\n\t\t\t\tindexing: result.indexing,\n\t\t\t};\n\n\t\t\tconst notices: string[] = [];\n\t\t\tif (result.degradedReason) notices.push(result.degradedReason);\n\t\t\tif (result.indexing) {\n\t\t\t\tnotices.push(\n\t\t\t\t\t`index still building: ${result.indexing.done}/${result.indexing.total} chunks embedded — results may be incomplete`,\n\t\t\t\t);\n\t\t\t}\n\t\t\tconst notice = notices.length > 0 ? `\\n\\n[${notices.join(\". \")}]` : \"\";\n\n\t\t\tif (result.resultCount === 0) {\n\t\t\t\treturn {\n\t\t\t\t\tcontent: [\n\t\t\t\t\t\t{ type: \"text\" as const, text: `No results for \"${query}\" (${result.resolvedMode})${notice}` },\n\t\t\t\t\t],\n\t\t\t\t\tdetails,\n\t\t\t\t};\n\t\t\t}\n\t\t\treturn {\n\t\t\t\tcontent: [{ type: \"text\" as const, text: result.text + notice }],\n\t\t\t\tdetails,\n\t\t\t};\n\t\t},\n\t\trenderCall(args, theme, context) {\n\t\t\tconst text = (context.lastComponent as Text | undefined) ?? new Text(\"\", 0, 0);\n\t\t\ttext.setText(formatSearchCall(args, theme));\n\t\t\treturn text;\n\t\t},\n\t\trenderResult(result, options, theme, context) {\n\t\t\tconst text = (context.lastComponent as Text | undefined) ?? new Text(\"\", 0, 0);\n\t\t\ttext.setText(formatSearchResult(result as any, options, theme, context.showImages));\n\t\t\treturn text;\n\t\t},\n\t};\n}\n"]}
@@ -9,8 +9,10 @@ import { Type } from "typebox";
9
9
  import { keyHint } from "../../modes/interactive/components/keybinding-hints.js";
10
10
  import { runSearch } from "../search/hybrid-search.js";
11
11
  import { getTextOutput, invalidArgText, str } from "./render-utils.js";
12
- // Default of 5 balances recall against context cost: eval showed limit=5
13
- // preserving the top-rank hits of limit=10 at roughly half the tokens.
12
+ // Default of 5 balances recall against context cost. Measured on the 62-query
13
+ // eval: limit=5 costs ~1000 tokens and puts a gold span in the results 68% of
14
+ // the time; limit=10 costs ~1600 tokens for 74%. The extra 6 points are real
15
+ // but not worth 60% more context on every call, and the model can ask for more.
14
16
  const DEFAULT_RESULTS = 5;
15
17
  const MAX_RESULTS = 30;
16
18
  const searchSchema = Type.Object({
@@ -18,7 +20,7 @@ const searchSchema = Type.Object({
18
20
  description: "What to find: an identifier, error text, or a natural-language description of the code, e.g. 'where sessions are persisted to disk'",
19
21
  }),
20
22
  mode: Type.Optional(Type.Union([Type.Literal("auto"), Type.Literal("lexical"), Type.Literal("semantic"), Type.Literal("hybrid")], {
21
- description: "Retrieval mode (default: auto). auto picks hybrid when the semantic index is available; lexical = exact-text only; semantic = embedding index only; hybrid = both, fused by rank.",
23
+ description: "Retrieval mode (default: auto, which is almost always right). auto = hybrid when the index is available, else lexical; hybrid = keyword and meaning over the index plus exact text for anything indexed later; semantic = index only, skipping exact text; lexical = exact text only, the one mode that works with no index.",
22
24
  })),
23
25
  glob: Type.Optional(Type.String({
24
26
  description: "Optional glob filter applied to file paths. Only file paths matching the glob are searched. Supports both slashless patterns (match base name anywhere) and slash patterns (match full path).",
@@ -61,10 +63,10 @@ export function createSearchToolDefinition(cwd, options) {
61
63
  // tools are registered — so it is deliberately absent here and from grep's
62
64
  // description. What stays is what only this tool knows: that the query is not
63
65
  // a regex, and that it degrades to exact-text when the index is missing.
64
- description: "Find where code lives: ranked file:line-range results from exact-text and semantic (local embedding index) retrieval, fused by rank when both are available. The query is plain text, not a regex — regex metacharacters are matched literally. Falls back to exact-text retrieval automatically when the semantic index is unavailable.",
65
- promptSnippet: "Ranked code search (exact + semantic, rank-fused)",
66
+ description: "Find where code lives: ranked file:line-range results, fusing keyword and semantic retrieval over a local index with exact-text search of files the index has not read yet. The query is plain text, not a regex — regex metacharacters are matched literally. Falls back to exact-text retrieval automatically when the index is unavailable, and still finds code written moments ago that no index has seen.",
67
+ promptSnippet: "Ranked code search (keyword + semantic, rank-fused)",
66
68
  promptGuidelines: [
67
- "search defaults to mode=auto, which is almost always right. Use limit=3 for targeted lookups, 10–20 when exploring a broad topic.",
69
+ "search defaults to mode=auto, which is almost always right. Use limit=3 for targeted lookups, 10–20 when exploring a broad topic — past ~15 results the deeper ones arrive as ranked file:line-range headers without a snippet, which is still enough to choose what to read.",
68
70
  ],
69
71
  parameters: searchSchema,
70
72
  async execute(_toolCallId, { query, mode, glob, limit }, signal) {
@@ -1 +1 @@
1
- {"version":3,"file":"search.js","sourceRoot":"","sources":["../../../src/core/tools/search.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * The unified `search` tool: ranked code discovery in lexical, semantic, or\n * hybrid (rank-fused) mode. Replaces the old `semantic_search` tool — see\n * docs/hybrid-retrieval-design.md, Decision 1. `grep` stays separate for\n * exact line-level mechanics; this tool answers \"find where X lives\".\n */\n\nimport { Text } from \"@kolisachint/hoocode-tui\";\nimport { type Static, Type } from \"typebox\";\nimport { keyHint } from \"../../modes/interactive/components/keybinding-hints.js\";\nimport type { EmbsearchService } from \"../embsearch/embsearch-service.js\";\nimport type { ToolDefinition, ToolRenderResultOptions } from \"../extensions/types.js\";\nimport { runSearch } from \"../search/hybrid-search.js\";\nimport type { ResolvedSearchMode } from \"../search/types.js\";\nimport { getTextOutput, invalidArgText, str } from \"./render-utils.js\";\n\n// Default of 5 balances recall against context cost: eval showed limit=5\n// preserving the top-rank hits of limit=10 at roughly half the tokens.\nconst DEFAULT_RESULTS = 5;\nconst MAX_RESULTS = 30;\n\nconst searchSchema = Type.Object({\n\tquery: Type.String({\n\t\tdescription:\n\t\t\t\"What to find: an identifier, error text, or a natural-language description of the code, e.g. 'where sessions are persisted to disk'\",\n\t}),\n\tmode: Type.Optional(\n\t\tType.Union([Type.Literal(\"auto\"), Type.Literal(\"lexical\"), Type.Literal(\"semantic\"), Type.Literal(\"hybrid\")], {\n\t\t\tdescription:\n\t\t\t\t\"Retrieval mode (default: auto). auto picks hybrid when the semantic index is available; lexical = exact-text only; semantic = embedding index only; hybrid = both, fused by rank.\",\n\t\t}),\n\t),\n\tglob: Type.Optional(\n\t\tType.String({\n\t\t\tdescription:\n\t\t\t\t\"Optional glob filter applied to file paths. Only file paths matching the glob are searched. Supports both slashless patterns (match base name anywhere) and slash patterns (match full path).\",\n\t\t}),\n\t),\n\tlimit: Type.Optional(\n\t\tType.Number({ description: `Maximum number of results (default: ${DEFAULT_RESULTS}, max: ${MAX_RESULTS})` }),\n\t),\n});\n\ntype SearchToolInput = Static<typeof searchSchema>;\n\nexport interface SearchToolDetails {\n\tresultCount?: number;\n\tresolvedMode?: ResolvedSearchMode;\n\tindexing?: { done: number; total: number };\n}\n\nexport interface SearchToolOptions {\n\t/**\n\t * Provider for the per-session embsearch service. Resolved lazily at call\n\t * time because the service is created (and its index built) after tool\n\t * registration. When absent/unavailable the tool degrades to lexical mode.\n\t */\n\tgetService?: () => EmbsearchService | undefined;\n}\n\nfunction formatSearchCall(\n\targs: { query?: string; mode?: string; limit?: number } | undefined,\n\ttheme: typeof import(\"../../modes/interactive/theme/theme.js\").theme,\n): string {\n\tconst query = str(args?.query);\n\tconst queryDisplay = query === null ? invalidArgText(theme) : `\"${query}\"`;\n\tlet text = theme.fg(\"toolTitle\", theme.bold(\"search \")) + theme.fg(\"accent\", queryDisplay);\n\tconst extras: string[] = [];\n\tif (args?.mode && args.mode !== \"auto\") extras.push(args.mode);\n\tif (args?.limit !== undefined) extras.push(String(args.limit));\n\tif (extras.length > 0) text += theme.fg(\"muted\", ` (${extras.join(\", \")})`);\n\treturn text;\n}\n\nfunction formatSearchResult(\n\tresult: { content: Array<{ type: string; text?: string }>; details?: SearchToolDetails },\n\toptions: ToolRenderResultOptions,\n\ttheme: typeof import(\"../../modes/interactive/theme/theme.js\").theme,\n\tshowImages: boolean,\n): string {\n\tconst output = getTextOutput(result as any, showImages).trim();\n\tlet text = \"\";\n\tif (output) {\n\t\tconst lines = output.split(\"\\n\");\n\t\tconst maxLines = options.expanded ? lines.length : 15;\n\t\tconst displayLines = lines.slice(0, maxLines);\n\t\tconst remaining = lines.length - maxLines;\n\t\ttext += `\\n${displayLines.map((line) => theme.fg(\"toolOutput\", line)).join(\"\\n\")}`;\n\t\tif (remaining > 0) {\n\t\t\ttext += `${theme.fg(\"muted\", `\\n... (${remaining} more lines,`)} ${keyHint(\"app.tools.expand\", \"to expand\")})`;\n\t\t}\n\t}\n\treturn text;\n}\n\nexport function createSearchToolDefinition(\n\tcwd: string,\n\toptions?: SearchToolOptions,\n): ToolDefinition<typeof searchSchema, SearchToolDetails | undefined> {\n\treturn {\n\t\tname: \"search\",\n\t\tlabel: \"search\",\n\t\t// The search-vs-grep split is stated once, by buildSystemPrompt, whenever both\n\t\t// tools are registered — so it is deliberately absent here and from grep's\n\t\t// description. What stays is what only this tool knows: that the query is not\n\t\t// a regex, and that it degrades to exact-text when the index is missing.\n\t\tdescription:\n\t\t\t\"Find where code lives: ranked file:line-range results from exact-text and semantic (local embedding index) retrieval, fused by rank when both are available. The query is plain text, not a regex — regex metacharacters are matched literally. Falls back to exact-text retrieval automatically when the semantic index is unavailable.\",\n\t\tpromptSnippet: \"Ranked code search (exact + semantic, rank-fused)\",\n\t\tpromptGuidelines: [\n\t\t\t\"search defaults to mode=auto, which is almost always right. Use limit=3 for targeted lookups, 10–20 when exploring a broad topic.\",\n\t\t],\n\t\tparameters: searchSchema,\n\t\tasync execute(_toolCallId, { query, mode, glob, limit }: SearchToolInput, signal?: AbortSignal) {\n\t\t\tif (signal?.aborted) throw new Error(\"Operation aborted\");\n\n\t\t\tconst result = await runSearch({\n\t\t\t\tcwd,\n\t\t\t\tquery,\n\t\t\t\tmode,\n\t\t\t\tglob,\n\t\t\t\tlimit: Math.min(MAX_RESULTS, Math.max(1, limit ?? DEFAULT_RESULTS)),\n\t\t\t\tservice: options?.getService?.(),\n\t\t\t\tsignal,\n\t\t\t});\n\t\t\tif (signal?.aborted) throw new Error(\"Operation aborted\");\n\n\t\t\tconst details: SearchToolDetails = {\n\t\t\t\tresultCount: result.resultCount,\n\t\t\t\tresolvedMode: result.resolvedMode,\n\t\t\t\tindexing: result.indexing,\n\t\t\t};\n\n\t\t\tconst notices: string[] = [];\n\t\t\tif (result.degradedReason) notices.push(result.degradedReason);\n\t\t\tif (result.indexing) {\n\t\t\t\tnotices.push(\n\t\t\t\t\t`index still building: ${result.indexing.done}/${result.indexing.total} chunks embedded — results may be incomplete`,\n\t\t\t\t);\n\t\t\t}\n\t\t\tconst notice = notices.length > 0 ? `\\n\\n[${notices.join(\". \")}]` : \"\";\n\n\t\t\tif (result.resultCount === 0) {\n\t\t\t\treturn {\n\t\t\t\t\tcontent: [\n\t\t\t\t\t\t{ type: \"text\" as const, text: `No results for \"${query}\" (${result.resolvedMode})${notice}` },\n\t\t\t\t\t],\n\t\t\t\t\tdetails,\n\t\t\t\t};\n\t\t\t}\n\t\t\treturn {\n\t\t\t\tcontent: [{ type: \"text\" as const, text: result.text + notice }],\n\t\t\t\tdetails,\n\t\t\t};\n\t\t},\n\t\trenderCall(args, theme, context) {\n\t\t\tconst text = (context.lastComponent as Text | undefined) ?? new Text(\"\", 0, 0);\n\t\t\ttext.setText(formatSearchCall(args, theme));\n\t\t\treturn text;\n\t\t},\n\t\trenderResult(result, options, theme, context) {\n\t\t\tconst text = (context.lastComponent as Text | undefined) ?? new Text(\"\", 0, 0);\n\t\t\ttext.setText(formatSearchResult(result as any, options, theme, context.showImages));\n\t\t\treturn text;\n\t\t},\n\t};\n}\n"]}
1
+ {"version":3,"file":"search.js","sourceRoot":"","sources":["../../../src/core/tools/search.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * The unified `search` tool: ranked code discovery in lexical, semantic, or\n * hybrid (rank-fused) mode. Replaces the old `semantic_search` tool — see\n * docs/hybrid-retrieval-design.md, Decision 1. `grep` stays separate for\n * exact line-level mechanics; this tool answers \"find where X lives\".\n */\n\nimport { Text } from \"@kolisachint/hoocode-tui\";\nimport { type Static, Type } from \"typebox\";\nimport { keyHint } from \"../../modes/interactive/components/keybinding-hints.js\";\nimport type { EmbsearchService } from \"../embsearch/embsearch-service.js\";\nimport type { ToolDefinition, ToolRenderResultOptions } from \"../extensions/types.js\";\nimport { runSearch } from \"../search/hybrid-search.js\";\nimport type { ResolvedSearchMode } from \"../search/types.js\";\nimport { getTextOutput, invalidArgText, str } from \"./render-utils.js\";\n\n// Default of 5 balances recall against context cost. Measured on the 62-query\n// eval: limit=5 costs ~1000 tokens and puts a gold span in the results 68% of\n// the time; limit=10 costs ~1600 tokens for 74%. The extra 6 points are real\n// but not worth 60% more context on every call, and the model can ask for more.\nconst DEFAULT_RESULTS = 5;\nconst MAX_RESULTS = 30;\n\nconst searchSchema = Type.Object({\n\tquery: Type.String({\n\t\tdescription:\n\t\t\t\"What to find: an identifier, error text, or a natural-language description of the code, e.g. 'where sessions are persisted to disk'\",\n\t}),\n\tmode: Type.Optional(\n\t\tType.Union([Type.Literal(\"auto\"), Type.Literal(\"lexical\"), Type.Literal(\"semantic\"), Type.Literal(\"hybrid\")], {\n\t\t\tdescription:\n\t\t\t\t\"Retrieval mode (default: auto, which is almost always right). auto = hybrid when the index is available, else lexical; hybrid = keyword and meaning over the index plus exact text for anything indexed later; semantic = index only, skipping exact text; lexical = exact text only, the one mode that works with no index.\",\n\t\t}),\n\t),\n\tglob: Type.Optional(\n\t\tType.String({\n\t\t\tdescription:\n\t\t\t\t\"Optional glob filter applied to file paths. Only file paths matching the glob are searched. Supports both slashless patterns (match base name anywhere) and slash patterns (match full path).\",\n\t\t}),\n\t),\n\tlimit: Type.Optional(\n\t\tType.Number({ description: `Maximum number of results (default: ${DEFAULT_RESULTS}, max: ${MAX_RESULTS})` }),\n\t),\n});\n\ntype SearchToolInput = Static<typeof searchSchema>;\n\nexport interface SearchToolDetails {\n\tresultCount?: number;\n\tresolvedMode?: ResolvedSearchMode;\n\tindexing?: { done: number; total: number };\n}\n\nexport interface SearchToolOptions {\n\t/**\n\t * Provider for the per-session embsearch service. Resolved lazily at call\n\t * time because the service is created (and its index built) after tool\n\t * registration. When absent/unavailable the tool degrades to lexical mode.\n\t */\n\tgetService?: () => EmbsearchService | undefined;\n}\n\nfunction formatSearchCall(\n\targs: { query?: string; mode?: string; limit?: number } | undefined,\n\ttheme: typeof import(\"../../modes/interactive/theme/theme.js\").theme,\n): string {\n\tconst query = str(args?.query);\n\tconst queryDisplay = query === null ? invalidArgText(theme) : `\"${query}\"`;\n\tlet text = theme.fg(\"toolTitle\", theme.bold(\"search \")) + theme.fg(\"accent\", queryDisplay);\n\tconst extras: string[] = [];\n\tif (args?.mode && args.mode !== \"auto\") extras.push(args.mode);\n\tif (args?.limit !== undefined) extras.push(String(args.limit));\n\tif (extras.length > 0) text += theme.fg(\"muted\", ` (${extras.join(\", \")})`);\n\treturn text;\n}\n\nfunction formatSearchResult(\n\tresult: { content: Array<{ type: string; text?: string }>; details?: SearchToolDetails },\n\toptions: ToolRenderResultOptions,\n\ttheme: typeof import(\"../../modes/interactive/theme/theme.js\").theme,\n\tshowImages: boolean,\n): string {\n\tconst output = getTextOutput(result as any, showImages).trim();\n\tlet text = \"\";\n\tif (output) {\n\t\tconst lines = output.split(\"\\n\");\n\t\tconst maxLines = options.expanded ? lines.length : 15;\n\t\tconst displayLines = lines.slice(0, maxLines);\n\t\tconst remaining = lines.length - maxLines;\n\t\ttext += `\\n${displayLines.map((line) => theme.fg(\"toolOutput\", line)).join(\"\\n\")}`;\n\t\tif (remaining > 0) {\n\t\t\ttext += `${theme.fg(\"muted\", `\\n... (${remaining} more lines,`)} ${keyHint(\"app.tools.expand\", \"to expand\")})`;\n\t\t}\n\t}\n\treturn text;\n}\n\nexport function createSearchToolDefinition(\n\tcwd: string,\n\toptions?: SearchToolOptions,\n): ToolDefinition<typeof searchSchema, SearchToolDetails | undefined> {\n\treturn {\n\t\tname: \"search\",\n\t\tlabel: \"search\",\n\t\t// The search-vs-grep split is stated once, by buildSystemPrompt, whenever both\n\t\t// tools are registered — so it is deliberately absent here and from grep's\n\t\t// description. What stays is what only this tool knows: that the query is not\n\t\t// a regex, and that it degrades to exact-text when the index is missing.\n\t\tdescription:\n\t\t\t\"Find where code lives: ranked file:line-range results, fusing keyword and semantic retrieval over a local index with exact-text search of files the index has not read yet. The query is plain text, not a regex — regex metacharacters are matched literally. Falls back to exact-text retrieval automatically when the index is unavailable, and still finds code written moments ago that no index has seen.\",\n\t\tpromptSnippet: \"Ranked code search (keyword + semantic, rank-fused)\",\n\t\tpromptGuidelines: [\n\t\t\t\"search defaults to mode=auto, which is almost always right. Use limit=3 for targeted lookups, 10–20 when exploring a broad topic — past ~15 results the deeper ones arrive as ranked file:line-range headers without a snippet, which is still enough to choose what to read.\",\n\t\t],\n\t\tparameters: searchSchema,\n\t\tasync execute(_toolCallId, { query, mode, glob, limit }: SearchToolInput, signal?: AbortSignal) {\n\t\t\tif (signal?.aborted) throw new Error(\"Operation aborted\");\n\n\t\t\tconst result = await runSearch({\n\t\t\t\tcwd,\n\t\t\t\tquery,\n\t\t\t\tmode,\n\t\t\t\tglob,\n\t\t\t\tlimit: Math.min(MAX_RESULTS, Math.max(1, limit ?? DEFAULT_RESULTS)),\n\t\t\t\tservice: options?.getService?.(),\n\t\t\t\tsignal,\n\t\t\t});\n\t\t\tif (signal?.aborted) throw new Error(\"Operation aborted\");\n\n\t\t\tconst details: SearchToolDetails = {\n\t\t\t\tresultCount: result.resultCount,\n\t\t\t\tresolvedMode: result.resolvedMode,\n\t\t\t\tindexing: result.indexing,\n\t\t\t};\n\n\t\t\tconst notices: string[] = [];\n\t\t\tif (result.degradedReason) notices.push(result.degradedReason);\n\t\t\tif (result.indexing) {\n\t\t\t\tnotices.push(\n\t\t\t\t\t`index still building: ${result.indexing.done}/${result.indexing.total} chunks embedded — results may be incomplete`,\n\t\t\t\t);\n\t\t\t}\n\t\t\tconst notice = notices.length > 0 ? `\\n\\n[${notices.join(\". \")}]` : \"\";\n\n\t\t\tif (result.resultCount === 0) {\n\t\t\t\treturn {\n\t\t\t\t\tcontent: [\n\t\t\t\t\t\t{ type: \"text\" as const, text: `No results for \"${query}\" (${result.resolvedMode})${notice}` },\n\t\t\t\t\t],\n\t\t\t\t\tdetails,\n\t\t\t\t};\n\t\t\t}\n\t\t\treturn {\n\t\t\t\tcontent: [{ type: \"text\" as const, text: result.text + notice }],\n\t\t\t\tdetails,\n\t\t\t};\n\t\t},\n\t\trenderCall(args, theme, context) {\n\t\t\tconst text = (context.lastComponent as Text | undefined) ?? new Text(\"\", 0, 0);\n\t\t\ttext.setText(formatSearchCall(args, theme));\n\t\t\treturn text;\n\t\t},\n\t\trenderResult(result, options, theme, context) {\n\t\t\tconst text = (context.lastComponent as Text | undefined) ?? new Text(\"\", 0, 0);\n\t\t\ttext.setText(formatSearchResult(result as any, options, theme, context.showImages));\n\t\t\treturn text;\n\t\t},\n\t};\n}\n"]}
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@kolisachint/hoocode-extension-custom-provider-anthropic",
3
3
  "private": true,
4
- "version": "0.3.0",
4
+ "version": "0.3.2",
5
5
  "type": "module",
6
6
  "engines": {
7
7
  "bun": ">=1.0.0"
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@kolisachint/hoocode-extension-custom-provider-gitlab-duo",
3
3
  "private": true,
4
- "version": "0.3.0",
4
+ "version": "0.3.2",
5
5
  "type": "module",
6
6
  "engines": {
7
7
  "bun": ">=1.0.0"
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@kolisachint/hoocode-extension-sandbox",
3
3
  "private": true,
4
- "version": "0.3.0",
4
+ "version": "0.3.2",
5
5
  "type": "module",
6
6
  "engines": {
7
7
  "bun": ">=1.0.0"
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@kolisachint/hoocode-extension-with-deps",
3
3
  "private": true,
4
- "version": "0.3.0",
4
+ "version": "0.3.2",
5
5
  "type": "module",
6
6
  "engines": {
7
7
  "bun": ">=1.0.0"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kolisachint/hoocode-agent",
3
- "version": "0.5.0",
3
+ "version": "0.5.2",
4
4
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
5
5
  "type": "module",
6
6
  "hoocodeConfig": {
@@ -48,9 +48,9 @@
48
48
  "prepublishOnly": "npm run clean && npm run build"
49
49
  },
50
50
  "dependencies": {
51
- "@kolisachint/hoocode-agent-core": "^0.5.0",
52
- "@kolisachint/hoocode-ai": "^0.5.0",
53
- "@kolisachint/hoocode-tui": "^0.5.0",
51
+ "@kolisachint/hoocode-agent-core": "^0.5.2",
52
+ "@kolisachint/hoocode-ai": "^0.5.2",
53
+ "@kolisachint/hoocode-tui": "^0.5.2",
54
54
  "@silvia-odwyer/photon-node": "^0.3.4",
55
55
  "chalk": "^5.5.0",
56
56
  "cli-highlight": "^2.1.11",