{"version":3,"file":"search.d.ts","sourceRoot":"","sources":["../../../src/core/capabilities/search.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAMH,OAAO,EAAE,KAAK,aAAa,EAAE,KAAK,cAAc,EAAsC,MAAM,eAAe,CAAC;AAE5G,MAAM,WAAW,aAAa;IAC7B,GAAG,EAAE,aAAa,CAAC;IACnB,0DAA0D;IAC1D,KAAK,EAAE,MAAM,CAAC;IACd,2EAAyE;IACzE,SAAS,EAAE,KAAK,CAAC,SAAS,GAAG,OAAO,CAAC,CAAC;CACtC;AAED,MAAM,WAAW,uBAAuB;IACvC,oDAAoD;IACpD,KAAK,CAAC,EAAE,SAAS,cAAc,EAAE,CAAC;IAClC,yBAAyB;IACzB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,2EAA2E;IAC3E,YAAY,CAAC,EAAE,OAAO,CAAC;CACvB;AAED,MAAM,WAAW,sBAAsB;IACtC,IAAI,EAAE,aAAa,EAAE,CAAC;IACtB,gFAAgF;IAChF,IAAI,EAAE,KAAK,CAAC,SAAS,GAAG,OAAO,CAAC,CAAC;CACjC;AAWD,+FAA+F;AAC/F,wBAAgB,qBAAqB,IAAI,IAAI,CAE5C;AAED;;;;;;;GAOG;AACH,wBAAsB,kBAAkB,CACvC,KAAK,EAAE,MAAM,EACb,OAAO,GAAE,uBAA4B,GACnC,OAAO,CAAC,sBAAsB,CAAC,CAuCjC","sourcesContent":["/**\n * Capability retrieval: BM25 and dense, fused by RRF.\n *\n * Hybrid rather than dense-only because capability descriptions are short.\n * Pure dense retrieval over eight-word strings is weak, and — more decisively —\n * an exact name lookup has to stay exact: `create_pull_request` must find\n * `mcp_github_create_pull_request` every time, which is a lexical guarantee, not\n * a nearest-neighbour one. Fusion gets both: the lexical leg answers \"the tool\n * called roughly this\", the dense leg \"something that does this\".\n *\n * Reuses the repo search's {@link rrfFuse} rather than reimplementing fusion.\n * The tie-breaking and duplicate handling there are already worked out, and a\n * second, subtly different fusion is how two retrieval paths drift apart.\n */\n\nimport { rrfFuse } from \"../search/rrf.js\";\nimport type { RankedHit } from \"../search/types.js\";\nimport { denseSearch, denseState } from \"./dense.js\";\nimport { LexicalIndex } from \"./lexical.js\";\nimport { type CapabilityDoc, type CapabilityKind, capabilitySetHash, getCapabilities } from \"./registry.js\";\n\nexport interface CapabilityHit {\n\tdoc: CapabilityDoc;\n\t/** Fused score. Comparable within one result set only. */\n\tscore: number;\n\t/** Which legs found it — useful when explaining a surprising ranking. */\n\tmatchedBy: Array<\"lexical\" | \"dense\">;\n}\n\nexport interface CapabilitySearchOptions {\n\t/** Restrict to these kinds. Omit for everything. */\n\tkinds?: readonly CapabilityKind[];\n\t/** Results to return. */\n\tlimit?: number;\n\t/** Restrict to capabilities whose expensive part is currently withheld. */\n\tdeferredOnly?: boolean;\n}\n\nexport interface CapabilitySearchResult {\n\thits: CapabilityHit[];\n\t/** How the answer was produced, so a caller can say \"lexical only\" honestly. */\n\tlegs: Array<\"lexical\" | \"dense\">;\n}\n\n/** Cached index, rebuilt when the capability set changes. */\nlet cached: { hash: string; index: LexicalIndex; docs: CapabilityDoc[] } | undefined;\n\nfunction lexicalIndexFor(docs: CapabilityDoc[]): LexicalIndex {\n\tconst hash = capabilitySetHash(docs);\n\tif (cached?.hash !== hash) cached = { hash, index: new LexicalIndex(docs), docs };\n\treturn cached.index;\n}\n\n/** Drop the cached lexical index. Tests, and anything that rewrites the registry wholesale. */\nexport function resetCapabilitySearch(): void {\n\tcached = undefined;\n}\n\n/**\n * Find capabilities matching `query`.\n *\n * Over-fetches from each leg before fusing: RRF works on rank, so a document\n * ranked 8th by one retriever and absent from the other still deserves to be\n * considered — truncating each list to the final `limit` first would throw that\n * away before fusion could use it.\n */\nexport async function searchCapabilities(\n\tquery: string,\n\toptions: CapabilitySearchOptions = {},\n): Promise<CapabilitySearchResult> {\n\tconst limit = options.limit ?? 10;\n\tlet docs = getCapabilities(options.kinds);\n\tif (options.deferredOnly) docs = docs.filter((d) => d.deferred);\n\tif (docs.length === 0 || !query.trim()) return { hits: [], legs: [] };\n\n\tconst byId = new Map(docs.map((d) => [d.id, d]));\n\tconst fetchK = Math.max(limit * 3, 20);\n\n\tconst lexical = lexicalIndexFor(docs).search(query, fetchK);\n\tconst dense = denseState().status === \"ready\" ? await denseSearch(query, fetchK) : [];\n\n\tconst legs: Array<\"lexical\" | \"dense\"> = [];\n\tconst lists: RankedHit[][] = [];\n\tif (lexical.length > 0) {\n\t\tlegs.push(\"lexical\");\n\t\tlists.push(lexical.map((h, i) => ({ id: h.id, rank: i + 1, score: h.score, source: \"bm25\" as const })));\n\t}\n\t// The dense store spans every registered capability, so its hits can fall\n\t// outside a kind- or deferred-filtered set; drop those before ranking rather\n\t// than leaving gaps in the rank sequence RRF expects.\n\tconst denseInScope = dense.filter((h) => byId.has(h.id));\n\tif (denseInScope.length > 0) {\n\t\tlegs.push(\"dense\");\n\t\tlists.push(denseInScope.map((h, i) => ({ id: h.id, rank: i + 1, score: h.score, source: \"embed\" as const })));\n\t}\n\tif (lists.length === 0) return { hits: [], legs: [] };\n\n\tconst hits: CapabilityHit[] = [];\n\tfor (const fused of rrfFuse(lists)) {\n\t\tconst doc = byId.get(fused.id);\n\t\tif (!doc) continue;\n\t\tconst matchedBy: Array<\"lexical\" | \"dense\"> = [];\n\t\tif (fused.ranks.bm25 !== undefined) matchedBy.push(\"lexical\");\n\t\tif (fused.ranks.embed !== undefined) matchedBy.push(\"dense\");\n\t\thits.push({ doc, score: fused.rrfScore, matchedBy });\n\t\tif (hits.length >= limit) break;\n\t}\n\treturn { hits, legs };\n}\n"]}