@kolisachint/hoocode-agent 0.4.142 → 0.4.144

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/dist/cli/args.d.ts +1 -1
  3. package/dist/cli/args.d.ts.map +1 -1
  4. package/dist/cli/args.js +10 -7
  5. package/dist/cli/args.js.map +1 -1
  6. package/dist/core/agent-session.d.ts.map +1 -1
  7. package/dist/core/agent-session.js +1 -1
  8. package/dist/core/agent-session.js.map +1 -1
  9. package/dist/core/embsearch/embsearch-service.d.ts +18 -0
  10. package/dist/core/embsearch/embsearch-service.d.ts.map +1 -1
  11. package/dist/core/embsearch/embsearch-service.js +32 -2
  12. package/dist/core/embsearch/embsearch-service.js.map +1 -1
  13. package/dist/core/sdk.d.ts +5 -4
  14. package/dist/core/sdk.d.ts.map +1 -1
  15. package/dist/core/sdk.js +1 -1
  16. package/dist/core/sdk.js.map +1 -1
  17. package/dist/core/search/adapter.d.ts +39 -0
  18. package/dist/core/search/adapter.d.ts.map +1 -0
  19. package/dist/core/search/adapter.js +110 -0
  20. package/dist/core/search/adapter.js.map +1 -0
  21. package/dist/core/search/context-assembler.d.ts +22 -0
  22. package/dist/core/search/context-assembler.d.ts.map +1 -0
  23. package/dist/core/search/context-assembler.js +67 -0
  24. package/dist/core/search/context-assembler.js.map +1 -0
  25. package/dist/core/search/eval.d.ts +52 -0
  26. package/dist/core/search/eval.d.ts.map +1 -0
  27. package/dist/core/search/eval.js +75 -0
  28. package/dist/core/search/eval.js.map +1 -0
  29. package/dist/core/search/hybrid-search.d.ts +63 -0
  30. package/dist/core/search/hybrid-search.d.ts.map +1 -0
  31. package/dist/core/search/hybrid-search.js +169 -0
  32. package/dist/core/search/hybrid-search.js.map +1 -0
  33. package/dist/core/search/lexical-retriever.d.ts +37 -0
  34. package/dist/core/search/lexical-retriever.d.ts.map +1 -0
  35. package/dist/core/search/lexical-retriever.js +165 -0
  36. package/dist/core/search/lexical-retriever.js.map +1 -0
  37. package/dist/core/search/mode.d.ts +25 -0
  38. package/dist/core/search/mode.d.ts.map +1 -0
  39. package/dist/core/search/mode.js +37 -0
  40. package/dist/core/search/mode.js.map +1 -0
  41. package/dist/core/search/rerank.d.ts +28 -0
  42. package/dist/core/search/rerank.d.ts.map +1 -0
  43. package/dist/core/search/rerank.js +82 -0
  44. package/dist/core/search/rerank.js.map +1 -0
  45. package/dist/core/search/rrf.d.ts +29 -0
  46. package/dist/core/search/rrf.d.ts.map +1 -0
  47. package/dist/core/search/rrf.js +66 -0
  48. package/dist/core/search/rrf.js.map +1 -0
  49. package/dist/core/search/trace.d.ts +12 -0
  50. package/dist/core/search/trace.d.ts.map +1 -0
  51. package/dist/core/search/trace.js +37 -0
  52. package/dist/core/search/trace.js.map +1 -0
  53. package/dist/core/search/types.d.ts +61 -0
  54. package/dist/core/search/types.d.ts.map +1 -0
  55. package/dist/core/search/types.js +11 -0
  56. package/dist/core/search/types.js.map +1 -0
  57. package/dist/core/settings-types.d.ts.map +1 -1
  58. package/dist/core/settings-types.js.map +1 -1
  59. package/dist/core/tools/grep.d.ts.map +1 -1
  60. package/dist/core/tools/grep.js +1 -1
  61. package/dist/core/tools/grep.js.map +1 -1
  62. package/dist/core/tools/index.d.ts +4 -4
  63. package/dist/core/tools/index.d.ts.map +1 -1
  64. package/dist/core/tools/index.js +3 -3
  65. package/dist/core/tools/index.js.map +1 -1
  66. package/dist/core/tools/search.d.ts +38 -0
  67. package/dist/core/tools/search.d.ts.map +1 -0
  68. package/dist/core/tools/search.js +119 -0
  69. package/dist/core/tools/search.js.map +1 -0
  70. package/dist/main.d.ts.map +1 -1
  71. package/dist/main.js +7 -5
  72. package/dist/main.js.map +1 -1
  73. package/dist/utils/tools-manager.d.ts.map +1 -1
  74. package/dist/utils/tools-manager.js +1 -1
  75. package/dist/utils/tools-manager.js.map +1 -1
  76. package/examples/extensions/custom-provider-anthropic/package.json +1 -1
  77. package/examples/extensions/custom-provider-gitlab-duo/package.json +1 -1
  78. package/examples/extensions/sandbox/package.json +1 -1
  79. package/examples/extensions/with-deps/package.json +1 -1
  80. package/package.json +4 -4
  81. package/dist/core/tools/semantic-search.d.ts +0 -28
  82. package/dist/core/tools/semantic-search.d.ts.map +0 -1
  83. package/dist/core/tools/semantic-search.js +0 -98
  84. package/dist/core/tools/semantic-search.js.map +0 -1
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Grep→chunk adapter (docs/hybrid-retrieval-design.md, Decision 3).
3
+ *
4
+ * Fusion needs a shared identity, and the embedding side already has one:
5
+ * per-build chunk ids with line ranges in the sidecar. This adapter turns raw
6
+ * grep line-hits into a gap-free ranked list of those ids:
7
+ *
8
+ * 1. map each line-hit to its enclosing indexed chunk;
9
+ * 2. coalesce unindexed hits into per-file clusters with synthetic
10
+ * `rel#L<line>` ids, so lexical-only files still enter fusion without
11
+ * one file's adjacent lines flooding the candidate list;
12
+ * 3. collapse multiple hits in the same chunk/cluster to one candidate;
13
+ * 4. rank candidates by evidence — distinct query terms matched, then hit
14
+ * count — and cap candidates per file, before assigning gap-free ranks.
15
+ * RRF's `1/(k + rank)` assumes gap-free ranks, and grep output order
16
+ * carries no relevance, so evidence-based ordering here is what makes
17
+ * the lexical list a real ranking rather than a directory walk.
18
+ */
19
+ import type { CandidateSpan, RankedHit } from "./types.js";
20
+ export interface GrepLineHit {
21
+ /** Repo-relative POSIX path. */
22
+ rel: string;
23
+ /** 1-based line number of the match. */
24
+ line: number;
25
+ /** Lowercased query terms present on this line (retriever-computed). */
26
+ terms?: readonly string[];
27
+ }
28
+ /** Resolves a line to its enclosing indexed chunk, or undefined when the file
29
+ * (or line) is not covered by the embedding index. */
30
+ export type ChunkLookup = (rel: string, line: number) => (CandidateSpan & {
31
+ id: string;
32
+ }) | undefined;
33
+ export interface AdaptedGrepHits {
34
+ hits: RankedHit[];
35
+ /** Span for every emitted id, for post-fusion expansion. */
36
+ spans: Map<string, CandidateSpan>;
37
+ }
38
+ export declare function adaptGrepHits(lineHits: readonly GrepLineHit[], lookupChunk?: ChunkLookup): AdaptedGrepHits;
39
+ //# sourceMappingURL=adapter.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"adapter.d.ts","sourceRoot":"","sources":["../../../src/core/search/adapter.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAEH,OAAO,KAAK,EAAE,aAAa,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAE3D,MAAM,WAAW,WAAW;IAC3B,gCAAgC;IAChC,GAAG,EAAE,MAAM,CAAC;IACZ,wCAAwC;IACxC,IAAI,EAAE,MAAM,CAAC;IACb,wEAAwE;IACxE,KAAK,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;CAC1B;AAED;uDACuD;AACvD,MAAM,MAAM,WAAW,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,KAAK,CAAC,aAAa,GAAG;IAAE,EAAE,EAAE,MAAM,CAAA;CAAE,CAAC,GAAG,SAAS,CAAC;AAatG,MAAM,WAAW,eAAe;IAC/B,IAAI,EAAE,SAAS,EAAE,CAAC;IAClB,4DAA4D;IAC5D,KAAK,EAAE,GAAG,CAAC,MAAM,EAAE,aAAa,CAAC,CAAC;CAClC;AAYD,wBAAgB,aAAa,CAAC,QAAQ,EAAE,SAAS,WAAW,EAAE,EAAE,WAAW,CAAC,EAAE,WAAW,GAAG,eAAe,CAsF1G","sourcesContent":["/**\n * Grep→chunk adapter (docs/hybrid-retrieval-design.md, Decision 3).\n *\n * Fusion needs a shared identity, and the embedding side already has one:\n * per-build chunk ids with line ranges in the sidecar. This adapter turns raw\n * grep line-hits into a gap-free ranked list of those ids:\n *\n * 1. map each line-hit to its enclosing indexed chunk;\n * 2. coalesce unindexed hits into per-file clusters with synthetic\n * `rel#L<line>` ids, so lexical-only files still enter fusion without\n * one file's adjacent lines flooding the candidate list;\n * 3. collapse multiple hits in the same chunk/cluster to one candidate;\n * 4. rank candidates by evidence — distinct query terms matched, then hit\n * count — and cap candidates per file, before assigning gap-free ranks.\n * RRF's `1/(k + rank)` assumes gap-free ranks, and grep output order\n * carries no relevance, so evidence-based ordering here is what makes\n * the lexical list a real ranking rather than a directory walk.\n */\n\nimport type { CandidateSpan, RankedHit } from \"./types.js\";\n\nexport interface GrepLineHit {\n\t/** Repo-relative POSIX path. */\n\trel: string;\n\t/** 1-based line number of the match. */\n\tline: number;\n\t/** Lowercased query terms present on this line (retriever-computed). */\n\tterms?: readonly string[];\n}\n\n/** Resolves a line to its enclosing indexed chunk, or undefined when the file\n * (or line) is not covered by the embedding index. */\nexport type ChunkLookup = (rel: string, line: number) => (CandidateSpan & { id: string }) | undefined;\n\n/** Context padding around a fallback (unindexed) cluster, mirroring chunk-ish\n * size without pretending to know real chunk boundaries. */\nconst FALLBACK_PAD_LINES = 5;\n/** Unindexed hits within this many lines of each other merge into one cluster. */\nconst FALLBACK_MERGE_GAP = 10;\n/** Hard cap on a cluster's own line span, so a file with hits every few lines\n * still splits into readable windows. */\nconst FALLBACK_MAX_CLUSTER_LINES = 40;\n/** Max candidates one file may contribute, to keep the fused list diverse. */\nconst PER_FILE_CANDIDATE_CAP = 8;\n\nexport interface AdaptedGrepHits {\n\thits: RankedHit[];\n\t/** Span for every emitted id, for post-fusion expansion. */\n\tspans: Map<string, CandidateSpan>;\n}\n\ninterface Candidate {\n\tid: string;\n\tspan: CandidateSpan;\n\tterms: Set<string>;\n\thitCount: number;\n\t/** Index of the candidate's earliest contributing hit — the deterministic\n\t * last-resort tie-break. */\n\tfirstSeen: number;\n}\n\nexport function adaptGrepHits(lineHits: readonly GrepLineHit[], lookupChunk?: ChunkLookup): AdaptedGrepHits {\n\tconst candidates = new Map<string, Candidate>();\n\t// Unmapped hits, grouped per file for clustering.\n\tconst unmapped = new Map<string, Array<{ line: number; terms?: readonly string[]; index: number }>>();\n\n\tlineHits.forEach(({ rel, line, terms }, index) => {\n\t\tconst chunk = lookupChunk?.(rel, line);\n\t\tif (!chunk) {\n\t\t\tlet list = unmapped.get(rel);\n\t\t\tif (!list) {\n\t\t\t\tlist = [];\n\t\t\t\tunmapped.set(rel, list);\n\t\t\t}\n\t\t\tlist.push({ line, terms, index });\n\t\t\treturn;\n\t\t}\n\t\tconst existing = candidates.get(chunk.id);\n\t\tif (existing) {\n\t\t\texisting.hitCount++;\n\t\t\tfor (const t of terms ?? []) existing.terms.add(t);\n\t\t} else {\n\t\t\tcandidates.set(chunk.id, {\n\t\t\t\tid: chunk.id,\n\t\t\t\tspan: { path: chunk.path, startLine: chunk.startLine, endLine: chunk.endLine },\n\t\t\t\tterms: new Set(terms),\n\t\t\t\thitCount: 1,\n\t\t\t\tfirstSeen: index,\n\t\t\t});\n\t\t}\n\t});\n\n\t// Cluster unindexed hits: sort by line, merge while the gap stays small\n\t// and the cluster stays readable.\n\tfor (const [rel, hits] of unmapped) {\n\t\thits.sort((a, b) => a.line - b.line || a.index - b.index);\n\t\tlet cluster: typeof hits = [];\n\t\tconst flush = () => {\n\t\t\tif (cluster.length === 0) return;\n\t\t\tconst first = cluster[0].line;\n\t\t\tconst last = cluster[cluster.length - 1].line;\n\t\t\tconst candidate: Candidate = {\n\t\t\t\tid: `${rel}#L${first}`,\n\t\t\t\tspan: { path: rel, startLine: Math.max(1, first - FALLBACK_PAD_LINES), endLine: last + FALLBACK_PAD_LINES },\n\t\t\t\tterms: new Set(cluster.flatMap((h) => [...(h.terms ?? [])])),\n\t\t\t\thitCount: cluster.length,\n\t\t\t\tfirstSeen: Math.min(...cluster.map((h) => h.index)),\n\t\t\t};\n\t\t\tcandidates.set(candidate.id, candidate);\n\t\t\tcluster = [];\n\t\t};\n\t\tfor (const hit of hits) {\n\t\t\tconst clusterStart = cluster[0]?.line;\n\t\t\tconst prevLine = cluster[cluster.length - 1]?.line;\n\t\t\tif (\n\t\t\t\tcluster.length > 0 &&\n\t\t\t\t(hit.line - prevLine > FALLBACK_MERGE_GAP || hit.line - clusterStart > FALLBACK_MAX_CLUSTER_LINES)\n\t\t\t) {\n\t\t\t\tflush();\n\t\t\t}\n\t\t\tcluster.push(hit);\n\t\t}\n\t\tflush();\n\t}\n\n\t// Evidence-based ordering: distinct terms, hit count, earliest appearance,\n\t// then id — fully deterministic.\n\tconst ordered = [...candidates.values()].sort(\n\t\t(a, b) =>\n\t\t\tb.terms.size - a.terms.size ||\n\t\t\tb.hitCount - a.hitCount ||\n\t\t\ta.firstSeen - b.firstSeen ||\n\t\t\ta.id.localeCompare(b.id),\n\t);\n\n\tconst hits: RankedHit[] = [];\n\tconst spans = new Map<string, CandidateSpan>();\n\tconst perFile = new Map<string, number>();\n\tfor (const candidate of ordered) {\n\t\tconst count = perFile.get(candidate.span.path) ?? 0;\n\t\tif (count >= PER_FILE_CANDIDATE_CAP) continue;\n\t\tperFile.set(candidate.span.path, count + 1);\n\t\tspans.set(candidate.id, candidate.span);\n\t\thits.push({ id: candidate.id, rank: hits.length + 1, source: \"grep\" });\n\t}\n\n\treturn { hits, spans };\n}\n"]}
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Grep→chunk adapter (docs/hybrid-retrieval-design.md, Decision 3).
3
+ *
4
+ * Fusion needs a shared identity, and the embedding side already has one:
5
+ * per-build chunk ids with line ranges in the sidecar. This adapter turns raw
6
+ * grep line-hits into a gap-free ranked list of those ids:
7
+ *
8
+ * 1. map each line-hit to its enclosing indexed chunk;
9
+ * 2. coalesce unindexed hits into per-file clusters with synthetic
10
+ * `rel#L<line>` ids, so lexical-only files still enter fusion without
11
+ * one file's adjacent lines flooding the candidate list;
12
+ * 3. collapse multiple hits in the same chunk/cluster to one candidate;
13
+ * 4. rank candidates by evidence — distinct query terms matched, then hit
14
+ * count — and cap candidates per file, before assigning gap-free ranks.
15
+ * RRF's `1/(k + rank)` assumes gap-free ranks, and grep output order
16
+ * carries no relevance, so evidence-based ordering here is what makes
17
+ * the lexical list a real ranking rather than a directory walk.
18
+ */
19
+ /** Context padding around a fallback (unindexed) cluster, mirroring chunk-ish
20
+ * size without pretending to know real chunk boundaries. */
21
+ const FALLBACK_PAD_LINES = 5;
22
+ /** Unindexed hits within this many lines of each other merge into one cluster. */
23
+ const FALLBACK_MERGE_GAP = 10;
24
+ /** Hard cap on a cluster's own line span, so a file with hits every few lines
25
+ * still splits into readable windows. */
26
+ const FALLBACK_MAX_CLUSTER_LINES = 40;
27
+ /** Max candidates one file may contribute, to keep the fused list diverse. */
28
+ const PER_FILE_CANDIDATE_CAP = 8;
29
+ export function adaptGrepHits(lineHits, lookupChunk) {
30
+ const candidates = new Map();
31
+ // Unmapped hits, grouped per file for clustering.
32
+ const unmapped = new Map();
33
+ lineHits.forEach(({ rel, line, terms }, index) => {
34
+ const chunk = lookupChunk?.(rel, line);
35
+ if (!chunk) {
36
+ let list = unmapped.get(rel);
37
+ if (!list) {
38
+ list = [];
39
+ unmapped.set(rel, list);
40
+ }
41
+ list.push({ line, terms, index });
42
+ return;
43
+ }
44
+ const existing = candidates.get(chunk.id);
45
+ if (existing) {
46
+ existing.hitCount++;
47
+ for (const t of terms ?? [])
48
+ existing.terms.add(t);
49
+ }
50
+ else {
51
+ candidates.set(chunk.id, {
52
+ id: chunk.id,
53
+ span: { path: chunk.path, startLine: chunk.startLine, endLine: chunk.endLine },
54
+ terms: new Set(terms),
55
+ hitCount: 1,
56
+ firstSeen: index,
57
+ });
58
+ }
59
+ });
60
+ // Cluster unindexed hits: sort by line, merge while the gap stays small
61
+ // and the cluster stays readable.
62
+ for (const [rel, hits] of unmapped) {
63
+ hits.sort((a, b) => a.line - b.line || a.index - b.index);
64
+ let cluster = [];
65
+ const flush = () => {
66
+ if (cluster.length === 0)
67
+ return;
68
+ const first = cluster[0].line;
69
+ const last = cluster[cluster.length - 1].line;
70
+ const candidate = {
71
+ id: `${rel}#L${first}`,
72
+ span: { path: rel, startLine: Math.max(1, first - FALLBACK_PAD_LINES), endLine: last + FALLBACK_PAD_LINES },
73
+ terms: new Set(cluster.flatMap((h) => [...(h.terms ?? [])])),
74
+ hitCount: cluster.length,
75
+ firstSeen: Math.min(...cluster.map((h) => h.index)),
76
+ };
77
+ candidates.set(candidate.id, candidate);
78
+ cluster = [];
79
+ };
80
+ for (const hit of hits) {
81
+ const clusterStart = cluster[0]?.line;
82
+ const prevLine = cluster[cluster.length - 1]?.line;
83
+ if (cluster.length > 0 &&
84
+ (hit.line - prevLine > FALLBACK_MERGE_GAP || hit.line - clusterStart > FALLBACK_MAX_CLUSTER_LINES)) {
85
+ flush();
86
+ }
87
+ cluster.push(hit);
88
+ }
89
+ flush();
90
+ }
91
+ // Evidence-based ordering: distinct terms, hit count, earliest appearance,
92
+ // then id — fully deterministic.
93
+ const ordered = [...candidates.values()].sort((a, b) => b.terms.size - a.terms.size ||
94
+ b.hitCount - a.hitCount ||
95
+ a.firstSeen - b.firstSeen ||
96
+ a.id.localeCompare(b.id));
97
+ const hits = [];
98
+ const spans = new Map();
99
+ const perFile = new Map();
100
+ for (const candidate of ordered) {
101
+ const count = perFile.get(candidate.span.path) ?? 0;
102
+ if (count >= PER_FILE_CANDIDATE_CAP)
103
+ continue;
104
+ perFile.set(candidate.span.path, count + 1);
105
+ spans.set(candidate.id, candidate.span);
106
+ hits.push({ id: candidate.id, rank: hits.length + 1, source: "grep" });
107
+ }
108
+ return { hits, spans };
109
+ }
110
+ //# sourceMappingURL=adapter.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"adapter.js","sourceRoot":"","sources":["../../../src/core/search/adapter.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Grep→chunk adapter (docs/hybrid-retrieval-design.md, Decision 3).\n *\n * Fusion needs a shared identity, and the embedding side already has one:\n * per-build chunk ids with line ranges in the sidecar. This adapter turns raw\n * grep line-hits into a gap-free ranked list of those ids:\n *\n * 1. map each line-hit to its enclosing indexed chunk;\n * 2. coalesce unindexed hits into per-file clusters with synthetic\n * `rel#L<line>` ids, so lexical-only files still enter fusion without\n * one file's adjacent lines flooding the candidate list;\n * 3. collapse multiple hits in the same chunk/cluster to one candidate;\n * 4. rank candidates by evidence — distinct query terms matched, then hit\n * count — and cap candidates per file, before assigning gap-free ranks.\n * RRF's `1/(k + rank)` assumes gap-free ranks, and grep output order\n * carries no relevance, so evidence-based ordering here is what makes\n * the lexical list a real ranking rather than a directory walk.\n */\n\nimport type { CandidateSpan, RankedHit } from \"./types.js\";\n\nexport interface GrepLineHit {\n\t/** Repo-relative POSIX path. */\n\trel: string;\n\t/** 1-based line number of the match. */\n\tline: number;\n\t/** Lowercased query terms present on this line (retriever-computed). */\n\tterms?: readonly string[];\n}\n\n/** Resolves a line to its enclosing indexed chunk, or undefined when the file\n * (or line) is not covered by the embedding index. */\nexport type ChunkLookup = (rel: string, line: number) => (CandidateSpan & { id: string }) | undefined;\n\n/** Context padding around a fallback (unindexed) cluster, mirroring chunk-ish\n * size without pretending to know real chunk boundaries. */\nconst FALLBACK_PAD_LINES = 5;\n/** Unindexed hits within this many lines of each other merge into one cluster. */\nconst FALLBACK_MERGE_GAP = 10;\n/** Hard cap on a cluster's own line span, so a file with hits every few lines\n * still splits into readable windows. */\nconst FALLBACK_MAX_CLUSTER_LINES = 40;\n/** Max candidates one file may contribute, to keep the fused list diverse. */\nconst PER_FILE_CANDIDATE_CAP = 8;\n\nexport interface AdaptedGrepHits {\n\thits: RankedHit[];\n\t/** Span for every emitted id, for post-fusion expansion. */\n\tspans: Map<string, CandidateSpan>;\n}\n\ninterface Candidate {\n\tid: string;\n\tspan: CandidateSpan;\n\tterms: Set<string>;\n\thitCount: number;\n\t/** Index of the candidate's earliest contributing hit — the deterministic\n\t * last-resort tie-break. */\n\tfirstSeen: number;\n}\n\nexport function adaptGrepHits(lineHits: readonly GrepLineHit[], lookupChunk?: ChunkLookup): AdaptedGrepHits {\n\tconst candidates = new Map<string, Candidate>();\n\t// Unmapped hits, grouped per file for clustering.\n\tconst unmapped = new Map<string, Array<{ line: number; terms?: readonly string[]; index: number }>>();\n\n\tlineHits.forEach(({ rel, line, terms }, index) => {\n\t\tconst chunk = lookupChunk?.(rel, line);\n\t\tif (!chunk) {\n\t\t\tlet list = unmapped.get(rel);\n\t\t\tif (!list) {\n\t\t\t\tlist = [];\n\t\t\t\tunmapped.set(rel, list);\n\t\t\t}\n\t\t\tlist.push({ line, terms, index });\n\t\t\treturn;\n\t\t}\n\t\tconst existing = candidates.get(chunk.id);\n\t\tif (existing) {\n\t\t\texisting.hitCount++;\n\t\t\tfor (const t of terms ?? []) existing.terms.add(t);\n\t\t} else {\n\t\t\tcandidates.set(chunk.id, {\n\t\t\t\tid: chunk.id,\n\t\t\t\tspan: { path: chunk.path, startLine: chunk.startLine, endLine: chunk.endLine },\n\t\t\t\tterms: new Set(terms),\n\t\t\t\thitCount: 1,\n\t\t\t\tfirstSeen: index,\n\t\t\t});\n\t\t}\n\t});\n\n\t// Cluster unindexed hits: sort by line, merge while the gap stays small\n\t// and the cluster stays readable.\n\tfor (const [rel, hits] of unmapped) {\n\t\thits.sort((a, b) => a.line - b.line || a.index - b.index);\n\t\tlet cluster: typeof hits = [];\n\t\tconst flush = () => {\n\t\t\tif (cluster.length === 0) return;\n\t\t\tconst first = cluster[0].line;\n\t\t\tconst last = cluster[cluster.length - 1].line;\n\t\t\tconst candidate: Candidate = {\n\t\t\t\tid: `${rel}#L${first}`,\n\t\t\t\tspan: { path: rel, startLine: Math.max(1, first - FALLBACK_PAD_LINES), endLine: last + FALLBACK_PAD_LINES },\n\t\t\t\tterms: new Set(cluster.flatMap((h) => [...(h.terms ?? [])])),\n\t\t\t\thitCount: cluster.length,\n\t\t\t\tfirstSeen: Math.min(...cluster.map((h) => h.index)),\n\t\t\t};\n\t\t\tcandidates.set(candidate.id, candidate);\n\t\t\tcluster = [];\n\t\t};\n\t\tfor (const hit of hits) {\n\t\t\tconst clusterStart = cluster[0]?.line;\n\t\t\tconst prevLine = cluster[cluster.length - 1]?.line;\n\t\t\tif (\n\t\t\t\tcluster.length > 0 &&\n\t\t\t\t(hit.line - prevLine > FALLBACK_MERGE_GAP || hit.line - clusterStart > FALLBACK_MAX_CLUSTER_LINES)\n\t\t\t) {\n\t\t\t\tflush();\n\t\t\t}\n\t\t\tcluster.push(hit);\n\t\t}\n\t\tflush();\n\t}\n\n\t// Evidence-based ordering: distinct terms, hit count, earliest appearance,\n\t// then id — fully deterministic.\n\tconst ordered = [...candidates.values()].sort(\n\t\t(a, b) =>\n\t\t\tb.terms.size - a.terms.size ||\n\t\t\tb.hitCount - a.hitCount ||\n\t\t\ta.firstSeen - b.firstSeen ||\n\t\t\ta.id.localeCompare(b.id),\n\t);\n\n\tconst hits: RankedHit[] = [];\n\tconst spans = new Map<string, CandidateSpan>();\n\tconst perFile = new Map<string, number>();\n\tfor (const candidate of ordered) {\n\t\tconst count = perFile.get(candidate.span.path) ?? 0;\n\t\tif (count >= PER_FILE_CANDIDATE_CAP) continue;\n\t\tperFile.set(candidate.span.path, count + 1);\n\t\tspans.set(candidate.id, candidate.span);\n\t\thits.push({ id: candidate.id, rank: hits.length + 1, source: \"grep\" });\n\t}\n\n\treturn { hits, spans };\n}\n"]}
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Token-budgeted span expansion (docs/hybrid-retrieval-design.md, step 5 of
3
+ * the shipping order).
4
+ *
5
+ * Retrieval works on chunk ids; only here — after fusion — are line windows
6
+ * read from disk. Every candidate gets a compact `path:start-end [sources]`
7
+ * header; snippets are added top-down until the budget runs out, so the model
8
+ * always sees the full ranked list but never an unbounded dump.
9
+ */
10
+ import type { FusedCandidate } from "./types.js";
11
+ export interface AssembleOptions {
12
+ cwd: string;
13
+ /** Approximate token budget for the whole result text. */
14
+ tokenBudget?: number;
15
+ }
16
+ export interface AssembledContext {
17
+ text: string;
18
+ /** How many candidates got an inline snippet (the rest are bare headers). */
19
+ snippetCount: number;
20
+ }
21
+ export declare function assembleContext(candidates: readonly FusedCandidate[], options: AssembleOptions): AssembledContext;
22
+ //# sourceMappingURL=context-assembler.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"context-assembler.d.ts","sourceRoot":"","sources":["../../../src/core/search/context-assembler.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAIH,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,YAAY,CAAC;AASjD,MAAM,WAAW,eAAe;IAC/B,GAAG,EAAE,MAAM,CAAC;IACZ,0DAA0D;IAC1D,WAAW,CAAC,EAAE,MAAM,CAAC;CACrB;AAED,MAAM,WAAW,gBAAgB;IAChC,IAAI,EAAE,MAAM,CAAC;IACb,6EAA6E;IAC7E,YAAY,EAAE,MAAM,CAAC;CACrB;AAMD,wBAAgB,eAAe,CAAC,UAAU,EAAE,SAAS,cAAc,EAAE,EAAE,OAAO,EAAE,eAAe,GAAG,gBAAgB,CAoDjH","sourcesContent":["/**\n * Token-budgeted span expansion (docs/hybrid-retrieval-design.md, step 5 of\n * the shipping order).\n *\n * Retrieval works on chunk ids; only here — after fusion — are line windows\n * read from disk. Every candidate gets a compact `path:start-end [sources]`\n * header; snippets are added top-down until the budget runs out, so the model\n * always sees the full ranked list but never an unbounded dump.\n */\n\nimport { readFileSync } from \"fs\";\nimport path from \"path\";\nimport type { FusedCandidate } from \"./types.js\";\n\n/** Rough chars-per-token for budgeting (index-time uses the same heuristic). */\nconst CHARS_PER_TOKEN = 4;\nconst DEFAULT_TOKEN_BUDGET = 2000;\n/** Snippet caps keep one giant chunk from eating the whole budget. */\nconst MAX_SNIPPET_LINES = 20;\nconst MAX_SNIPPET_LINE_CHARS = 200;\n\nexport interface AssembleOptions {\n\tcwd: string;\n\t/** Approximate token budget for the whole result text. */\n\ttokenBudget?: number;\n}\n\nexport interface AssembledContext {\n\ttext: string;\n\t/** How many candidates got an inline snippet (the rest are bare headers). */\n\tsnippetCount: number;\n}\n\nfunction sourcesLabel(candidate: FusedCandidate): string {\n\treturn Object.keys(candidate.ranks).sort().join(\"+\");\n}\n\nexport function assembleContext(candidates: readonly FusedCandidate[], options: AssembleOptions): AssembledContext {\n\tconst budgetChars = (options.tokenBudget ?? DEFAULT_TOKEN_BUDGET) * CHARS_PER_TOKEN;\n\tconst fileCache = new Map<string, string[] | undefined>();\n\n\tconst readLines = (rel: string): string[] | undefined => {\n\t\tif (!fileCache.has(rel)) {\n\t\t\ttry {\n\t\t\t\tconst content = readFileSync(path.resolve(options.cwd, rel), \"utf-8\");\n\t\t\t\tfileCache.set(rel, content.replace(/\\r\\n/g, \"\\n\").replace(/\\r/g, \"\\n\").split(\"\\n\"));\n\t\t\t} catch {\n\t\t\t\tfileCache.set(rel, undefined);\n\t\t\t}\n\t\t}\n\t\treturn fileCache.get(rel);\n\t};\n\n\tconst sections: string[] = [];\n\tlet usedChars = 0;\n\tlet snippetCount = 0;\n\tlet snippetsExhausted = false;\n\n\tfor (const candidate of candidates) {\n\t\tconst lines = readLines(candidate.path);\n\t\t// Clamp the span to the file as it exists now (fallback spans may\n\t\t// overshoot; the file may have changed since indexing).\n\t\tconst start = Math.max(1, candidate.startLine);\n\t\tconst end = lines ? Math.min(candidate.endLine, lines.length) : candidate.endLine;\n\t\tconst header = `${candidate.path}:${start}-${end} [${sourcesLabel(candidate)}]`;\n\t\tusedChars += header.length + 1;\n\n\t\tif (!snippetsExhausted && lines && end >= start) {\n\t\t\tconst snippetEnd = Math.min(end, start + MAX_SNIPPET_LINES - 1);\n\t\t\tconst snippetLines = lines\n\t\t\t\t.slice(start - 1, snippetEnd)\n\t\t\t\t.map(\n\t\t\t\t\t(text, i) =>\n\t\t\t\t\t\t` ${start + i}: ${text.length > MAX_SNIPPET_LINE_CHARS ? `${text.slice(0, MAX_SNIPPET_LINE_CHARS)}…` : text}`,\n\t\t\t\t);\n\t\t\tconst snippet = snippetLines.join(\"\\n\");\n\t\t\tif (usedChars + snippet.length <= budgetChars) {\n\t\t\t\tsections.push(`${header}\\n${snippet}`);\n\t\t\t\tusedChars += snippet.length + 1;\n\t\t\t\tsnippetCount++;\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\t// Budget hit: stop expanding, keep listing bare headers.\n\t\t\tsnippetsExhausted = true;\n\t\t}\n\t\tsections.push(header);\n\t}\n\n\treturn { text: sections.join(\"\\n\\n\"), snippetCount };\n}\n"]}
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Token-budgeted span expansion (docs/hybrid-retrieval-design.md, step 5 of
3
+ * the shipping order).
4
+ *
5
+ * Retrieval works on chunk ids; only here — after fusion — are line windows
6
+ * read from disk. Every candidate gets a compact `path:start-end [sources]`
7
+ * header; snippets are added top-down until the budget runs out, so the model
8
+ * always sees the full ranked list but never an unbounded dump.
9
+ */
10
+ import { readFileSync } from "fs";
11
+ import path from "path";
12
+ /** Rough chars-per-token for budgeting (index-time uses the same heuristic). */
13
+ const CHARS_PER_TOKEN = 4;
14
+ const DEFAULT_TOKEN_BUDGET = 2000;
15
+ /** Snippet caps keep one giant chunk from eating the whole budget. */
16
+ const MAX_SNIPPET_LINES = 20;
17
+ const MAX_SNIPPET_LINE_CHARS = 200;
18
+ function sourcesLabel(candidate) {
19
+ return Object.keys(candidate.ranks).sort().join("+");
20
+ }
21
+ export function assembleContext(candidates, options) {
22
+ const budgetChars = (options.tokenBudget ?? DEFAULT_TOKEN_BUDGET) * CHARS_PER_TOKEN;
23
+ const fileCache = new Map();
24
+ const readLines = (rel) => {
25
+ if (!fileCache.has(rel)) {
26
+ try {
27
+ const content = readFileSync(path.resolve(options.cwd, rel), "utf-8");
28
+ fileCache.set(rel, content.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n"));
29
+ }
30
+ catch {
31
+ fileCache.set(rel, undefined);
32
+ }
33
+ }
34
+ return fileCache.get(rel);
35
+ };
36
+ const sections = [];
37
+ let usedChars = 0;
38
+ let snippetCount = 0;
39
+ let snippetsExhausted = false;
40
+ for (const candidate of candidates) {
41
+ const lines = readLines(candidate.path);
42
+ // Clamp the span to the file as it exists now (fallback spans may
43
+ // overshoot; the file may have changed since indexing).
44
+ const start = Math.max(1, candidate.startLine);
45
+ const end = lines ? Math.min(candidate.endLine, lines.length) : candidate.endLine;
46
+ const header = `${candidate.path}:${start}-${end} [${sourcesLabel(candidate)}]`;
47
+ usedChars += header.length + 1;
48
+ if (!snippetsExhausted && lines && end >= start) {
49
+ const snippetEnd = Math.min(end, start + MAX_SNIPPET_LINES - 1);
50
+ const snippetLines = lines
51
+ .slice(start - 1, snippetEnd)
52
+ .map((text, i) => ` ${start + i}: ${text.length > MAX_SNIPPET_LINE_CHARS ? `${text.slice(0, MAX_SNIPPET_LINE_CHARS)}…` : text}`);
53
+ const snippet = snippetLines.join("\n");
54
+ if (usedChars + snippet.length <= budgetChars) {
55
+ sections.push(`${header}\n${snippet}`);
56
+ usedChars += snippet.length + 1;
57
+ snippetCount++;
58
+ continue;
59
+ }
60
+ // Budget hit: stop expanding, keep listing bare headers.
61
+ snippetsExhausted = true;
62
+ }
63
+ sections.push(header);
64
+ }
65
+ return { text: sections.join("\n\n"), snippetCount };
66
+ }
67
+ //# sourceMappingURL=context-assembler.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"context-assembler.js","sourceRoot":"","sources":["../../../src/core/search/context-assembler.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Token-budgeted span expansion (docs/hybrid-retrieval-design.md, step 5 of\n * the shipping order).\n *\n * Retrieval works on chunk ids; only here — after fusion — are line windows\n * read from disk. Every candidate gets a compact `path:start-end [sources]`\n * header; snippets are added top-down until the budget runs out, so the model\n * always sees the full ranked list but never an unbounded dump.\n */\n\nimport { readFileSync } from \"fs\";\nimport path from \"path\";\nimport type { FusedCandidate } from \"./types.js\";\n\n/** Rough chars-per-token for budgeting (index-time uses the same heuristic). */\nconst CHARS_PER_TOKEN = 4;\nconst DEFAULT_TOKEN_BUDGET = 2000;\n/** Snippet caps keep one giant chunk from eating the whole budget. */\nconst MAX_SNIPPET_LINES = 20;\nconst MAX_SNIPPET_LINE_CHARS = 200;\n\nexport interface AssembleOptions {\n\tcwd: string;\n\t/** Approximate token budget for the whole result text. */\n\ttokenBudget?: number;\n}\n\nexport interface AssembledContext {\n\ttext: string;\n\t/** How many candidates got an inline snippet (the rest are bare headers). */\n\tsnippetCount: number;\n}\n\nfunction sourcesLabel(candidate: FusedCandidate): string {\n\treturn Object.keys(candidate.ranks).sort().join(\"+\");\n}\n\nexport function assembleContext(candidates: readonly FusedCandidate[], options: AssembleOptions): AssembledContext {\n\tconst budgetChars = (options.tokenBudget ?? DEFAULT_TOKEN_BUDGET) * CHARS_PER_TOKEN;\n\tconst fileCache = new Map<string, string[] | undefined>();\n\n\tconst readLines = (rel: string): string[] | undefined => {\n\t\tif (!fileCache.has(rel)) {\n\t\t\ttry {\n\t\t\t\tconst content = readFileSync(path.resolve(options.cwd, rel), \"utf-8\");\n\t\t\t\tfileCache.set(rel, content.replace(/\\r\\n/g, \"\\n\").replace(/\\r/g, \"\\n\").split(\"\\n\"));\n\t\t\t} catch {\n\t\t\t\tfileCache.set(rel, undefined);\n\t\t\t}\n\t\t}\n\t\treturn fileCache.get(rel);\n\t};\n\n\tconst sections: string[] = [];\n\tlet usedChars = 0;\n\tlet snippetCount = 0;\n\tlet snippetsExhausted = false;\n\n\tfor (const candidate of candidates) {\n\t\tconst lines = readLines(candidate.path);\n\t\t// Clamp the span to the file as it exists now (fallback spans may\n\t\t// overshoot; the file may have changed since indexing).\n\t\tconst start = Math.max(1, candidate.startLine);\n\t\tconst end = lines ? Math.min(candidate.endLine, lines.length) : candidate.endLine;\n\t\tconst header = `${candidate.path}:${start}-${end} [${sourcesLabel(candidate)}]`;\n\t\tusedChars += header.length + 1;\n\n\t\tif (!snippetsExhausted && lines && end >= start) {\n\t\t\tconst snippetEnd = Math.min(end, start + MAX_SNIPPET_LINES - 1);\n\t\t\tconst snippetLines = lines\n\t\t\t\t.slice(start - 1, snippetEnd)\n\t\t\t\t.map(\n\t\t\t\t\t(text, i) =>\n\t\t\t\t\t\t` ${start + i}: ${text.length > MAX_SNIPPET_LINE_CHARS ? `${text.slice(0, MAX_SNIPPET_LINE_CHARS)}…` : text}`,\n\t\t\t\t);\n\t\t\tconst snippet = snippetLines.join(\"\\n\");\n\t\t\tif (usedChars + snippet.length <= budgetChars) {\n\t\t\t\tsections.push(`${header}\\n${snippet}`);\n\t\t\t\tusedChars += snippet.length + 1;\n\t\t\t\tsnippetCount++;\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\t// Budget hit: stop expanding, keep listing bare headers.\n\t\t\tsnippetsExhausted = true;\n\t\t}\n\t\tsections.push(header);\n\t}\n\n\treturn { text: sections.join(\"\\n\\n\"), snippetCount };\n}\n"]}
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Retrieval evaluation gate (docs/hybrid-retrieval-design.md, step 6 of the
3
+ * shipping order).
4
+ *
5
+ * Measures Recall@K for lexical, semantic, and hybrid retrieval across an
6
+ * RRF `k` sweep, against a gold set keyed by **path + optional line range
7
+ * matched by span overlap** — never by chunkId, which is only stable per
8
+ * index build. Recall@50 doubles as the reranker gate: a gold span that
9
+ * never reaches the fused top-50 cannot be rescued by any reranker.
10
+ *
11
+ * Driven by `scripts/search-eval.mjs`; the scoring logic lives here so it is
12
+ * unit-testable without an embedding index.
13
+ */
14
+ import type { EmbsearchService } from "../embsearch/embsearch-service.js";
15
+ import type { CandidateSpan, ResolvedSearchMode, SearchMode } from "./types.js";
16
+ export interface EvalGoldSpan {
17
+ /** Repo-relative POSIX path. */
18
+ path: string;
19
+ /** 1-based inclusive; omit both to accept any span in the file. */
20
+ startLine?: number;
21
+ endLine?: number;
22
+ }
23
+ export interface EvalQuery {
24
+ id: string;
25
+ /** Query class from the design doc (exact-symbol, path, error-fragment,
26
+ * conceptual, cross-file, boundary). Reporting only. */
27
+ class: string;
28
+ query: string;
29
+ gold: EvalGoldSpan[];
30
+ }
31
+ export interface EvalConfig {
32
+ label: string;
33
+ mode: SearchMode;
34
+ rrfK?: number;
35
+ rerank?: boolean;
36
+ }
37
+ /** The sweep from the design doc — single retrievers, hybrid across k, the
38
+ * routed auto mode — plus reranked (`+rr`) variants for the step 7 gate. */
39
+ export declare const EVAL_CONFIGS: readonly EvalConfig[];
40
+ export declare function spanMatchesGold(span: CandidateSpan, gold: EvalGoldSpan): boolean;
41
+ /** Fraction of gold spans matched by at least one of the top-`k` candidates. */
42
+ export declare function recallAtK(candidates: readonly CandidateSpan[], gold: readonly EvalGoldSpan[], k: number): number;
43
+ export interface EvalQueryResult {
44
+ label: string;
45
+ resolvedMode: ResolvedSearchMode;
46
+ degraded: boolean;
47
+ recallAt5: number;
48
+ recallAt10: number;
49
+ recallAt50: number;
50
+ }
51
+ export declare function evaluateQuery(cwd: string, evalQuery: EvalQuery, configs?: readonly EvalConfig[], service?: EmbsearchService): Promise<EvalQueryResult[]>;
52
+ //# sourceMappingURL=eval.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"eval.d.ts","sourceRoot":"","sources":["../../../src/core/search/eval.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,mCAAmC,CAAC;AAE1E,OAAO,KAAK,EAAE,aAAa,EAAE,kBAAkB,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AAEhF,MAAM,WAAW,YAAY;IAC5B,gCAAgC;IAChC,IAAI,EAAE,MAAM,CAAC;IACb,mEAAmE;IACnE,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,OAAO,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,SAAS;IACzB,EAAE,EAAE,MAAM,CAAC;IACX;6DACyD;IACzD,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,YAAY,EAAE,CAAC;CACrB;AAED,MAAM,WAAW,UAAU;IAC1B,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,UAAU,CAAC;IACjB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,MAAM,CAAC,EAAE,OAAO,CAAC;CACjB;AAED;+EAC6E;AAC7E,eAAO,MAAM,YAAY,EAAE,SAAS,UAAU,EAa7C,CAAC;AAKF,wBAAgB,eAAe,CAAC,IAAI,EAAE,aAAa,EAAE,IAAI,EAAE,YAAY,GAAG,OAAO,CAIhF;AAED,gFAAgF;AAChF,wBAAgB,SAAS,CAAC,UAAU,EAAE,SAAS,aAAa,EAAE,EAAE,IAAI,EAAE,SAAS,YAAY,EAAE,EAAE,CAAC,EAAE,MAAM,GAAG,MAAM,CAQhH;AAED,MAAM,WAAW,eAAe;IAC/B,KAAK,EAAE,MAAM,CAAC;IACd,YAAY,EAAE,kBAAkB,CAAC;IACjC,QAAQ,EAAE,OAAO,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;IAClB,UAAU,EAAE,MAAM,CAAC;IACnB,UAAU,EAAE,MAAM,CAAC;CACnB;AAED,wBAAsB,aAAa,CAClC,GAAG,EAAE,MAAM,EACX,SAAS,EAAE,SAAS,EACpB,OAAO,GAAE,SAAS,UAAU,EAAiB,EAC7C,OAAO,CAAC,EAAE,gBAAgB,GACxB,OAAO,CAAC,eAAe,EAAE,CAAC,CAsB5B","sourcesContent":["/**\n * Retrieval evaluation gate (docs/hybrid-retrieval-design.md, step 6 of the\n * shipping order).\n *\n * Measures Recall@K for lexical, semantic, and hybrid retrieval across an\n * RRF `k` sweep, against a gold set keyed by **path + optional line range\n * matched by span overlap** — never by chunkId, which is only stable per\n * index build. Recall@50 doubles as the reranker gate: a gold span that\n * never reaches the fused top-50 cannot be rescued by any reranker.\n *\n * Driven by `scripts/search-eval.mjs`; the scoring logic lives here so it is\n * unit-testable without an embedding index.\n */\n\nimport type { EmbsearchService } from \"../embsearch/embsearch-service.js\";\nimport { retrieveCandidates } from \"./hybrid-search.js\";\nimport type { CandidateSpan, ResolvedSearchMode, SearchMode } from \"./types.js\";\n\nexport interface EvalGoldSpan {\n\t/** Repo-relative POSIX path. */\n\tpath: string;\n\t/** 1-based inclusive; omit both to accept any span in the file. */\n\tstartLine?: number;\n\tendLine?: number;\n}\n\nexport interface EvalQuery {\n\tid: string;\n\t/** Query class from the design doc (exact-symbol, path, error-fragment,\n\t * conceptual, cross-file, boundary). Reporting only. */\n\tclass: string;\n\tquery: string;\n\tgold: EvalGoldSpan[];\n}\n\nexport interface EvalConfig {\n\tlabel: string;\n\tmode: SearchMode;\n\trrfK?: number;\n\trerank?: boolean;\n}\n\n/** The sweep from the design doc — single retrievers, hybrid across k, the\n * routed auto mode — plus reranked (`+rr`) variants for the step 7 gate. */\nexport const EVAL_CONFIGS: readonly EvalConfig[] = [\n\t{ label: \"lexical\", mode: \"lexical\" },\n\t{ label: \"semantic\", mode: \"semantic\" },\n\t{ label: \"hybrid k=0\", mode: \"hybrid\", rrfK: 0 },\n\t{ label: \"hybrid k=2\", mode: \"hybrid\", rrfK: 2 },\n\t{ label: \"hybrid k=10\", mode: \"hybrid\", rrfK: 10 },\n\t{ label: \"hybrid k=60\", mode: \"hybrid\", rrfK: 60 },\n\t{ label: \"auto\", mode: \"auto\" },\n\t{ label: \"lexical +rr\", mode: \"lexical\", rerank: true },\n\t{ label: \"semantic +rr\", mode: \"semantic\", rerank: true },\n\t{ label: \"hybrid k=2 +rr\", mode: \"hybrid\", rrfK: 2, rerank: true },\n\t{ label: \"hybrid k=60 +rr\", mode: \"hybrid\", rrfK: 60, rerank: true },\n\t{ label: \"auto +rr\", mode: \"auto\", rerank: true },\n];\n\n/** Candidates fetched per eval query — deep enough for the reranker gate. */\nconst EVAL_FETCH_LIMIT = 50;\n\nexport function spanMatchesGold(span: CandidateSpan, gold: EvalGoldSpan): boolean {\n\tif (span.path !== gold.path) return false;\n\tif (gold.startLine === undefined || gold.endLine === undefined) return true;\n\treturn span.startLine <= gold.endLine && span.endLine >= gold.startLine;\n}\n\n/** Fraction of gold spans matched by at least one of the top-`k` candidates. */\nexport function recallAtK(candidates: readonly CandidateSpan[], gold: readonly EvalGoldSpan[], k: number): number {\n\tif (gold.length === 0) return 0;\n\tconst top = candidates.slice(0, k);\n\tlet matched = 0;\n\tfor (const g of gold) {\n\t\tif (top.some((c) => spanMatchesGold(c, g))) matched++;\n\t}\n\treturn matched / gold.length;\n}\n\nexport interface EvalQueryResult {\n\tlabel: string;\n\tresolvedMode: ResolvedSearchMode;\n\tdegraded: boolean;\n\trecallAt5: number;\n\trecallAt10: number;\n\trecallAt50: number;\n}\n\nexport async function evaluateQuery(\n\tcwd: string,\n\tevalQuery: EvalQuery,\n\tconfigs: readonly EvalConfig[] = EVAL_CONFIGS,\n\tservice?: EmbsearchService,\n): Promise<EvalQueryResult[]> {\n\tconst results: EvalQueryResult[] = [];\n\tfor (const config of configs) {\n\t\tconst retrieved = await retrieveCandidates({\n\t\t\tcwd,\n\t\t\tquery: evalQuery.query,\n\t\t\tmode: config.mode,\n\t\t\trrfK: config.rrfK,\n\t\t\trerank: config.rerank ?? false,\n\t\t\tlimit: EVAL_FETCH_LIMIT,\n\t\t\tservice,\n\t\t});\n\t\tresults.push({\n\t\t\tlabel: config.label,\n\t\t\tresolvedMode: retrieved.resolvedMode,\n\t\t\tdegraded: retrieved.degradedReason !== undefined,\n\t\t\trecallAt5: recallAtK(retrieved.candidates, evalQuery.gold, 5),\n\t\t\trecallAt10: recallAtK(retrieved.candidates, evalQuery.gold, 10),\n\t\t\trecallAt50: recallAtK(retrieved.candidates, evalQuery.gold, 50),\n\t\t});\n\t}\n\treturn results;\n}\n"]}
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Retrieval evaluation gate (docs/hybrid-retrieval-design.md, step 6 of the
3
+ * shipping order).
4
+ *
5
+ * Measures Recall@K for lexical, semantic, and hybrid retrieval across an
6
+ * RRF `k` sweep, against a gold set keyed by **path + optional line range
7
+ * matched by span overlap** — never by chunkId, which is only stable per
8
+ * index build. Recall@50 doubles as the reranker gate: a gold span that
9
+ * never reaches the fused top-50 cannot be rescued by any reranker.
10
+ *
11
+ * Driven by `scripts/search-eval.mjs`; the scoring logic lives here so it is
12
+ * unit-testable without an embedding index.
13
+ */
14
+ import { retrieveCandidates } from "./hybrid-search.js";
15
+ /** The sweep from the design doc — single retrievers, hybrid across k, the
16
+ * routed auto mode — plus reranked (`+rr`) variants for the step 7 gate. */
17
+ export const EVAL_CONFIGS = [
18
+ { label: "lexical", mode: "lexical" },
19
+ { label: "semantic", mode: "semantic" },
20
+ { label: "hybrid k=0", mode: "hybrid", rrfK: 0 },
21
+ { label: "hybrid k=2", mode: "hybrid", rrfK: 2 },
22
+ { label: "hybrid k=10", mode: "hybrid", rrfK: 10 },
23
+ { label: "hybrid k=60", mode: "hybrid", rrfK: 60 },
24
+ { label: "auto", mode: "auto" },
25
+ { label: "lexical +rr", mode: "lexical", rerank: true },
26
+ { label: "semantic +rr", mode: "semantic", rerank: true },
27
+ { label: "hybrid k=2 +rr", mode: "hybrid", rrfK: 2, rerank: true },
28
+ { label: "hybrid k=60 +rr", mode: "hybrid", rrfK: 60, rerank: true },
29
+ { label: "auto +rr", mode: "auto", rerank: true },
30
+ ];
31
+ /** Candidates fetched per eval query — deep enough for the reranker gate. */
32
+ const EVAL_FETCH_LIMIT = 50;
33
+ export function spanMatchesGold(span, gold) {
34
+ if (span.path !== gold.path)
35
+ return false;
36
+ if (gold.startLine === undefined || gold.endLine === undefined)
37
+ return true;
38
+ return span.startLine <= gold.endLine && span.endLine >= gold.startLine;
39
+ }
40
+ /** Fraction of gold spans matched by at least one of the top-`k` candidates. */
41
+ export function recallAtK(candidates, gold, k) {
42
+ if (gold.length === 0)
43
+ return 0;
44
+ const top = candidates.slice(0, k);
45
+ let matched = 0;
46
+ for (const g of gold) {
47
+ if (top.some((c) => spanMatchesGold(c, g)))
48
+ matched++;
49
+ }
50
+ return matched / gold.length;
51
+ }
52
+ export async function evaluateQuery(cwd, evalQuery, configs = EVAL_CONFIGS, service) {
53
+ const results = [];
54
+ for (const config of configs) {
55
+ const retrieved = await retrieveCandidates({
56
+ cwd,
57
+ query: evalQuery.query,
58
+ mode: config.mode,
59
+ rrfK: config.rrfK,
60
+ rerank: config.rerank ?? false,
61
+ limit: EVAL_FETCH_LIMIT,
62
+ service,
63
+ });
64
+ results.push({
65
+ label: config.label,
66
+ resolvedMode: retrieved.resolvedMode,
67
+ degraded: retrieved.degradedReason !== undefined,
68
+ recallAt5: recallAtK(retrieved.candidates, evalQuery.gold, 5),
69
+ recallAt10: recallAtK(retrieved.candidates, evalQuery.gold, 10),
70
+ recallAt50: recallAtK(retrieved.candidates, evalQuery.gold, 50),
71
+ });
72
+ }
73
+ return results;
74
+ }
75
+ //# sourceMappingURL=eval.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"eval.js","sourceRoot":"","sources":["../../../src/core/search/eval.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Retrieval evaluation gate (docs/hybrid-retrieval-design.md, step 6 of the\n * shipping order).\n *\n * Measures Recall@K for lexical, semantic, and hybrid retrieval across an\n * RRF `k` sweep, against a gold set keyed by **path + optional line range\n * matched by span overlap** — never by chunkId, which is only stable per\n * index build. Recall@50 doubles as the reranker gate: a gold span that\n * never reaches the fused top-50 cannot be rescued by any reranker.\n *\n * Driven by `scripts/search-eval.mjs`; the scoring logic lives here so it is\n * unit-testable without an embedding index.\n */\n\nimport type { EmbsearchService } from \"../embsearch/embsearch-service.js\";\nimport { retrieveCandidates } from \"./hybrid-search.js\";\nimport type { CandidateSpan, ResolvedSearchMode, SearchMode } from \"./types.js\";\n\nexport interface EvalGoldSpan {\n\t/** Repo-relative POSIX path. */\n\tpath: string;\n\t/** 1-based inclusive; omit both to accept any span in the file. */\n\tstartLine?: number;\n\tendLine?: number;\n}\n\nexport interface EvalQuery {\n\tid: string;\n\t/** Query class from the design doc (exact-symbol, path, error-fragment,\n\t * conceptual, cross-file, boundary). Reporting only. */\n\tclass: string;\n\tquery: string;\n\tgold: EvalGoldSpan[];\n}\n\nexport interface EvalConfig {\n\tlabel: string;\n\tmode: SearchMode;\n\trrfK?: number;\n\trerank?: boolean;\n}\n\n/** The sweep from the design doc — single retrievers, hybrid across k, the\n * routed auto mode — plus reranked (`+rr`) variants for the step 7 gate. */\nexport const EVAL_CONFIGS: readonly EvalConfig[] = [\n\t{ label: \"lexical\", mode: \"lexical\" },\n\t{ label: \"semantic\", mode: \"semantic\" },\n\t{ label: \"hybrid k=0\", mode: \"hybrid\", rrfK: 0 },\n\t{ label: \"hybrid k=2\", mode: \"hybrid\", rrfK: 2 },\n\t{ label: \"hybrid k=10\", mode: \"hybrid\", rrfK: 10 },\n\t{ label: \"hybrid k=60\", mode: \"hybrid\", rrfK: 60 },\n\t{ label: \"auto\", mode: \"auto\" },\n\t{ label: \"lexical +rr\", mode: \"lexical\", rerank: true },\n\t{ label: \"semantic +rr\", mode: \"semantic\", rerank: true },\n\t{ label: \"hybrid k=2 +rr\", mode: \"hybrid\", rrfK: 2, rerank: true },\n\t{ label: \"hybrid k=60 +rr\", mode: \"hybrid\", rrfK: 60, rerank: true },\n\t{ label: \"auto +rr\", mode: \"auto\", rerank: true },\n];\n\n/** Candidates fetched per eval query — deep enough for the reranker gate. */\nconst EVAL_FETCH_LIMIT = 50;\n\nexport function spanMatchesGold(span: CandidateSpan, gold: EvalGoldSpan): boolean {\n\tif (span.path !== gold.path) return false;\n\tif (gold.startLine === undefined || gold.endLine === undefined) return true;\n\treturn span.startLine <= gold.endLine && span.endLine >= gold.startLine;\n}\n\n/** Fraction of gold spans matched by at least one of the top-`k` candidates. */\nexport function recallAtK(candidates: readonly CandidateSpan[], gold: readonly EvalGoldSpan[], k: number): number {\n\tif (gold.length === 0) return 0;\n\tconst top = candidates.slice(0, k);\n\tlet matched = 0;\n\tfor (const g of gold) {\n\t\tif (top.some((c) => spanMatchesGold(c, g))) matched++;\n\t}\n\treturn matched / gold.length;\n}\n\nexport interface EvalQueryResult {\n\tlabel: string;\n\tresolvedMode: ResolvedSearchMode;\n\tdegraded: boolean;\n\trecallAt5: number;\n\trecallAt10: number;\n\trecallAt50: number;\n}\n\nexport async function evaluateQuery(\n\tcwd: string,\n\tevalQuery: EvalQuery,\n\tconfigs: readonly EvalConfig[] = EVAL_CONFIGS,\n\tservice?: EmbsearchService,\n): Promise<EvalQueryResult[]> {\n\tconst results: EvalQueryResult[] = [];\n\tfor (const config of configs) {\n\t\tconst retrieved = await retrieveCandidates({\n\t\t\tcwd,\n\t\t\tquery: evalQuery.query,\n\t\t\tmode: config.mode,\n\t\t\trrfK: config.rrfK,\n\t\t\trerank: config.rerank ?? false,\n\t\t\tlimit: EVAL_FETCH_LIMIT,\n\t\t\tservice,\n\t\t});\n\t\tresults.push({\n\t\t\tlabel: config.label,\n\t\t\tresolvedMode: retrieved.resolvedMode,\n\t\t\tdegraded: retrieved.degradedReason !== undefined,\n\t\t\trecallAt5: recallAtK(retrieved.candidates, evalQuery.gold, 5),\n\t\t\trecallAt10: recallAtK(retrieved.candidates, evalQuery.gold, 10),\n\t\t\trecallAt50: recallAtK(retrieved.candidates, evalQuery.gold, 50),\n\t\t});\n\t}\n\treturn results;\n}\n"]}
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Hybrid search orchestrator: resolve mode, run retrievers in parallel, fuse
3
+ * by rank, expand within budget, trace everything
4
+ * (docs/hybrid-retrieval-design.md).
5
+ *
6
+ * Single-retriever modes flow through the same pipeline — rrfFuse over one
7
+ * list preserves its order — so lexical, semantic, and hybrid all produce the
8
+ * same result shape and the same trace record.
9
+ *
10
+ * `retrieveCandidates` is the candidate-level core (also used by the eval
11
+ * harness, which needs forced modes, a configurable `k`, and no trace
12
+ * pollution); `runSearch` wraps it with span expansion and tracing for the
13
+ * tool.
14
+ */
15
+ import type { EmbsearchService } from "../embsearch/embsearch-service.js";
16
+ import type { FusedCandidate, ResolvedSearchMode, SearchMode, SearchTrace } from "./types.js";
17
+ export interface RetrieveOptions {
18
+ cwd: string;
19
+ query: string;
20
+ mode?: SearchMode;
21
+ /** Optional glob filter applied to file paths. */
22
+ glob?: string;
23
+ /** Maximum fused candidates returned. */
24
+ limit?: number;
25
+ /** RRF constant override (eval harness sweeps this). Default: {@link DEFAULT_RRF_K}. */
26
+ rrfK?: number;
27
+ /** Rerank the fused top-50 before slicing to `limit`. Default: true. */
28
+ rerank?: boolean;
29
+ service?: EmbsearchService;
30
+ signal?: AbortSignal;
31
+ }
32
+ export interface RetrieveResult {
33
+ candidates: FusedCandidate[];
34
+ resolvedMode: ResolvedSearchMode;
35
+ degradedReason?: string;
36
+ indexPhase: SearchTrace["indexPhase"];
37
+ retrievers: SearchTrace["retrievers"];
38
+ /** Set while the embedding index is still building. */
39
+ indexing?: {
40
+ done: number;
41
+ total: number;
42
+ };
43
+ rrfK: number;
44
+ rerank?: SearchTrace["rerank"];
45
+ }
46
+ export interface RunSearchOptions extends RetrieveOptions {
47
+ /** Approximate token budget for the result text. */
48
+ tokenBudget?: number;
49
+ }
50
+ export interface RunSearchResult {
51
+ text: string;
52
+ resolvedMode: ResolvedSearchMode;
53
+ degradedReason?: string;
54
+ resultCount: number;
55
+ /** Set while the embedding index is still building. */
56
+ indexing?: {
57
+ done: number;
58
+ total: number;
59
+ };
60
+ }
61
+ export declare function retrieveCandidates(options: RetrieveOptions): Promise<RetrieveResult>;
62
+ export declare function runSearch(options: RunSearchOptions): Promise<RunSearchResult>;
63
+ //# sourceMappingURL=hybrid-search.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"hybrid-search.d.ts","sourceRoot":"","sources":["../../../src/core/search/hybrid-search.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAEH,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,mCAAmC,CAAC;AAQ1E,OAAO,KAAK,EAAiB,cAAc,EAAa,kBAAkB,EAAE,UAAU,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AAcxH,MAAM,WAAW,eAAe;IAC/B,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,UAAU,CAAC;IAClB,kDAAkD;IAClD,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,yCAAyC;IACzC,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,wFAAwF;IACxF,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,wEAAwE;IACxE,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,OAAO,CAAC,EAAE,gBAAgB,CAAC;IAC3B,MAAM,CAAC,EAAE,WAAW,CAAC;CACrB;AAED,MAAM,WAAW,cAAc;IAC9B,UAAU,EAAE,cAAc,EAAE,CAAC;IAC7B,YAAY,EAAE,kBAAkB,CAAC;IACjC,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,UAAU,EAAE,WAAW,CAAC,YAAY,CAAC,CAAC;IACtC,UAAU,EAAE,WAAW,CAAC,YAAY,CAAC,CAAC;IACtC,uDAAuD;IACvD,QAAQ,CAAC,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC;IAC3C,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,CAAC,EAAE,WAAW,CAAC,QAAQ,CAAC,CAAC;CAC/B;AAED,MAAM,WAAW,gBAAiB,SAAQ,eAAe;IACxD,oDAAoD;IACpD,WAAW,CAAC,EAAE,MAAM,CAAC;CACrB;AAED,MAAM,WAAW,eAAe;IAC/B,IAAI,EAAE,MAAM,CAAC;IACb,YAAY,EAAE,kBAAkB,CAAC;IACjC,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,WAAW,EAAE,MAAM,CAAC;IACpB,uDAAuD;IACvD,QAAQ,CAAC,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC;CAC3C;AAYD,wBAAsB,kBAAkB,CAAC,OAAO,EAAE,eAAe,GAAG,OAAO,CAAC,cAAc,CAAC,CAwG1F;AAED,wBAAsB,SAAS,CAAC,OAAO,EAAE,gBAAgB,GAAG,OAAO,CAAC,eAAe,CAAC,CAyBnF","sourcesContent":["/**\n * Hybrid search orchestrator: resolve mode, run retrievers in parallel, fuse\n * by rank, expand within budget, trace everything\n * (docs/hybrid-retrieval-design.md).\n *\n * Single-retriever modes flow through the same pipeline — rrfFuse over one\n * list preserves its order — so lexical, semantic, and hybrid all produce the\n * same result shape and the same trace record.\n *\n * `retrieveCandidates` is the candidate-level core (also used by the eval\n * harness, which needs forced modes, a configurable `k`, and no trace\n * pollution); `runSearch` wraps it with span expansion and tracing for the\n * tool.\n */\n\nimport type { EmbsearchService } from \"../embsearch/embsearch-service.js\";\nimport { adaptGrepHits, type ChunkLookup } from \"./adapter.js\";\nimport { assembleContext } from \"./context-assembler.js\";\nimport { runLexicalRetriever } from \"./lexical-retriever.js\";\nimport { resolveSearchMode } from \"./mode.js\";\nimport { rerankCandidates } from \"./rerank.js\";\nimport { DEFAULT_RRF_K, rrfFuse } from \"./rrf.js\";\nimport { writeSearchTrace } from \"./trace.js\";\nimport type { CandidateSpan, FusedCandidate, RankedHit, ResolvedSearchMode, SearchMode, SearchTrace } from \"./types.js\";\n\n/** Raw grep line-hits fetched per query (pre-collapse). */\nconst LEXICAL_MATCH_LIMIT = 200;\n/** Adapted lexical candidates entering fusion. The eval gate showed the\n * uncapped lexical tail diluting hybrid below plain semantic: lexical\n * precision is front-loaded by the adapter's term-evidence ranking, while\n * RRF weighs a rank-30 lexical candidate like a rank-30 embedding hit. */\nconst LEXICAL_FUSION_CAP = 20;\n/** Embedding hits fetched per query — deep enough for fusion to matter. */\nconst EMBED_TOP_K = 50;\n/** Fused candidates kept for reranking / final slicing. */\nconst FUSED_WINDOW = 50;\n\nexport interface RetrieveOptions {\n\tcwd: string;\n\tquery: string;\n\tmode?: SearchMode;\n\t/** Optional glob filter applied to file paths. */\n\tglob?: string;\n\t/** Maximum fused candidates returned. */\n\tlimit?: number;\n\t/** RRF constant override (eval harness sweeps this). Default: {@link DEFAULT_RRF_K}. */\n\trrfK?: number;\n\t/** Rerank the fused top-50 before slicing to `limit`. Default: true. */\n\trerank?: boolean;\n\tservice?: EmbsearchService;\n\tsignal?: AbortSignal;\n}\n\nexport interface RetrieveResult {\n\tcandidates: FusedCandidate[];\n\tresolvedMode: ResolvedSearchMode;\n\tdegradedReason?: string;\n\tindexPhase: SearchTrace[\"indexPhase\"];\n\tretrievers: SearchTrace[\"retrievers\"];\n\t/** Set while the embedding index is still building. */\n\tindexing?: { done: number; total: number };\n\trrfK: number;\n\trerank?: SearchTrace[\"rerank\"];\n}\n\nexport interface RunSearchOptions extends RetrieveOptions {\n\t/** Approximate token budget for the result text. */\n\ttokenBudget?: number;\n}\n\nexport interface RunSearchResult {\n\ttext: string;\n\tresolvedMode: ResolvedSearchMode;\n\tdegradedReason?: string;\n\tresultCount: number;\n\t/** Set while the embedding index is still building. */\n\tindexing?: { done: number; total: number };\n}\n\nfunction normalizeSearchGlob(glob: string | undefined): string | undefined {\n\tif (!glob) return undefined;\n\t// Match fd/rg semantics: a slash-containing glob is anchored anywhere in\n\t// the tree, so prepend \"**/\" unless it already starts with a slash or \"**/\".\n\tif (glob.includes(\"/\") && !glob.startsWith(\"/\") && !glob.startsWith(\"**/\")) {\n\t\treturn `**/${glob}`;\n\t}\n\treturn glob;\n}\n\nexport async function retrieveCandidates(options: RetrieveOptions): Promise<RetrieveResult> {\n\tconst { cwd, query, service, signal } = options;\n\tconst glob = normalizeSearchGlob(options.glob);\n\tconst requestedMode = options.mode ?? \"auto\";\n\tconst limit = Math.max(1, options.limit ?? 10);\n\tconst rrfK = options.rrfK ?? DEFAULT_RRF_K;\n\n\tconst state = service?.getState();\n\tconst embedAvailable = service?.isAvailable() ?? false;\n\tconst embedUnavailableReason =\n\t\tstate === undefined\n\t\t\t? \"semantic index is not enabled\"\n\t\t\t: state.phase === \"unavailable\" || state.phase === \"skipped\"\n\t\t\t\t? state.reason\n\t\t\t\t: state.phase === \"idle\"\n\t\t\t\t\t? \"semantic index has not started\"\n\t\t\t\t\t: undefined;\n\n\tconst resolution = resolveSearchMode(query, requestedMode, embedAvailable, embedUnavailableReason);\n\tconst mode = resolution.mode;\n\n\t// Map lexical hits onto indexed chunk ids whenever the sidecar is usable,\n\t// even in lexical-only mode, so identities line up across modes.\n\tconst lookupChunk: ChunkLookup | undefined = embedAvailable\n\t\t? (rel, line) => service!.findEnclosingChunk(rel, line)\n\t\t: undefined;\n\n\tconst spans = new Map<string, CandidateSpan>();\n\tconst lists: RankedHit[][] = [];\n\tconst retrieverStats: SearchTrace[\"retrievers\"] = {};\n\tconst errors: Error[] = [];\n\n\tconst runLexical = async (): Promise<void> => {\n\t\tconst startedMs = Date.now();\n\t\ttry {\n\t\t\tconst lineHits = await runLexicalRetriever({ cwd, query, limit: LEXICAL_MATCH_LIMIT, glob, signal });\n\t\t\tconst adapted = adaptGrepHits(lineHits, lookupChunk);\n\t\t\t// In single-retriever lexical mode the full list is the result; in\n\t\t\t// hybrid, only the front-loaded head is trustworthy enough to vote.\n\t\t\tconst hits = mode === \"hybrid\" ? adapted.hits.slice(0, LEXICAL_FUSION_CAP) : adapted.hits;\n\t\t\tfor (const [id, span] of adapted.spans) if (!spans.has(id)) spans.set(id, span);\n\t\t\tlists.push(hits);\n\t\t\tretrieverStats.grep = { latencyMs: Date.now() - startedMs, hitCount: hits.length };\n\t\t} catch (e) {\n\t\t\terrors.push(e instanceof Error ? e : new Error(String(e)));\n\t\t\tretrieverStats.grep = { latencyMs: Date.now() - startedMs, hitCount: 0 };\n\t\t}\n\t};\n\n\tconst runEmbed = async (): Promise<void> => {\n\t\tconst startedMs = Date.now();\n\t\ttry {\n\t\t\tconst chunkHits = await service!.searchChunks(query, EMBED_TOP_K, glob);\n\t\t\tconst hits: RankedHit[] = chunkHits.map((hit, i) => ({\n\t\t\t\tid: hit.id,\n\t\t\t\trank: i + 1,\n\t\t\t\tscore: hit.score,\n\t\t\t\tsource: \"embed\",\n\t\t\t}));\n\t\t\tfor (const hit of chunkHits) {\n\t\t\t\tspans.set(hit.id, { path: hit.path, startLine: hit.startLine, endLine: hit.endLine });\n\t\t\t}\n\t\t\tlists.push(hits);\n\t\t\tretrieverStats.embed = { latencyMs: Date.now() - startedMs, hitCount: hits.length };\n\t\t} catch (e) {\n\t\t\terrors.push(e instanceof Error ? e : new Error(String(e)));\n\t\t\tretrieverStats.embed = { latencyMs: Date.now() - startedMs, hitCount: 0 };\n\t\t}\n\t};\n\n\tconst runs: Promise<void>[] = [];\n\tif (mode === \"lexical\" || mode === \"hybrid\") runs.push(runLexical());\n\tif (mode === \"semantic\" || mode === \"hybrid\") runs.push(runEmbed());\n\tawait Promise.all(runs);\n\tif (signal?.aborted) throw new Error(\"Operation aborted\");\n\t// A partial failure in hybrid degrades to whichever retriever survived;\n\t// only a total loss is an error.\n\tif (lists.length === 0) throw errors[0] ?? new Error(\"search produced no retriever results\");\n\n\tconst fused = rrfFuse(lists, rrfK).slice(0, FUSED_WINDOW);\n\tlet candidates: FusedCandidate[] = [];\n\tfor (const hit of fused) {\n\t\tconst span = spans.get(hit.id);\n\t\tif (span) candidates.push({ ...hit, ...span });\n\t}\n\n\tlet rerankInfo: SearchTrace[\"rerank\"];\n\tif (options.rerank !== false) {\n\t\tconst reranked = rerankCandidates(query, candidates, cwd);\n\t\trerankInfo = { applied: true, candidateCount: candidates.length, latencyMs: reranked.latencyMs };\n\t\tcandidates = reranked.candidates;\n\t}\n\tcandidates = candidates.slice(0, limit);\n\n\treturn {\n\t\tcandidates,\n\t\tresolvedMode: mode,\n\t\tdegradedReason: resolution.degradedReason,\n\t\tindexPhase: state?.phase === \"ready\" ? \"ready\" : state?.phase === \"indexing\" ? \"indexing\" : \"unavailable\",\n\t\tretrievers: retrieverStats,\n\t\tindexing: state?.phase === \"indexing\" ? { done: state.done, total: state.total } : undefined,\n\t\trrfK,\n\t\trerank: rerankInfo,\n\t};\n}\n\nexport async function runSearch(options: RunSearchOptions): Promise<RunSearchResult> {\n\tconst retrieved = await retrieveCandidates(options);\n\n\tconst assembled = assembleContext(retrieved.candidates, { cwd: options.cwd, tokenBudget: options.tokenBudget });\n\n\twriteSearchTrace(options.cwd, {\n\t\ttimestampMs: Date.now(),\n\t\tquery: options.query,\n\t\trequestedMode: options.mode ?? \"auto\",\n\t\tresolvedMode: retrieved.resolvedMode,\n\t\tdegradedReason: retrieved.degradedReason,\n\t\tindexPhase: retrieved.indexPhase,\n\t\trrfK: retrieved.resolvedMode === \"hybrid\" ? retrieved.rrfK : undefined,\n\t\tretrievers: retrieved.retrievers,\n\t\tfused: retrieved.candidates.map(({ id, rrfScore, ranks, rawScores }) => ({ id, rrfScore, ranks, rawScores })),\n\t\trerank: retrieved.rerank,\n\t});\n\n\treturn {\n\t\ttext: assembled.text,\n\t\tresolvedMode: retrieved.resolvedMode,\n\t\tdegradedReason: retrieved.degradedReason,\n\t\tresultCount: retrieved.candidates.length,\n\t\tindexing: retrieved.indexing,\n\t};\n}\n"]}