@kolisachint/hoocode-agent 0.4.141 → 0.4.143

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 (88) 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 +31 -8
  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 +61 -0
  30. package/dist/core/search/hybrid-search.d.ts.map +1 -0
  31. package/dist/core/search/hybrid-search.js +158 -0
  32. package/dist/core/search/hybrid-search.js.map +1 -0
  33. package/dist/core/search/lexical-retriever.d.ts +30 -0
  34. package/dist/core/search/lexical-retriever.d.ts.map +1 -0
  35. package/dist/core/search/lexical-retriever.js +159 -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 +37 -0
  67. package/dist/core/tools/search.d.ts.map +1 -0
  68. package/dist/core/tools/search.js +115 -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/modes/interactive/interactive-mode.d.ts.map +1 -1
  74. package/dist/modes/interactive/interactive-mode.js +5 -1
  75. package/dist/modes/interactive/interactive-mode.js.map +1 -1
  76. package/dist/utils/tools-manager.d.ts +1 -1
  77. package/dist/utils/tools-manager.d.ts.map +1 -1
  78. package/dist/utils/tools-manager.js +24 -0
  79. package/dist/utils/tools-manager.js.map +1 -1
  80. package/examples/extensions/custom-provider-anthropic/package.json +1 -1
  81. package/examples/extensions/custom-provider-gitlab-duo/package.json +1 -1
  82. package/examples/extensions/sandbox/package.json +1 -1
  83. package/examples/extensions/with-deps/package.json +1 -1
  84. package/package.json +4 -4
  85. package/dist/core/tools/semantic-search.d.ts +0 -28
  86. package/dist/core/tools/semantic-search.d.ts.map +0 -1
  87. package/dist/core/tools/semantic-search.js +0 -98
  88. package/dist/core/tools/semantic-search.js.map +0 -1
@@ -0,0 +1,158 @@
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 { adaptGrepHits } from "./adapter.js";
16
+ import { assembleContext } from "./context-assembler.js";
17
+ import { runLexicalRetriever } from "./lexical-retriever.js";
18
+ import { resolveSearchMode } from "./mode.js";
19
+ import { rerankCandidates } from "./rerank.js";
20
+ import { DEFAULT_RRF_K, rrfFuse } from "./rrf.js";
21
+ import { writeSearchTrace } from "./trace.js";
22
+ /** Raw grep line-hits fetched per query (pre-collapse). */
23
+ const LEXICAL_MATCH_LIMIT = 200;
24
+ /** Adapted lexical candidates entering fusion. The eval gate showed the
25
+ * uncapped lexical tail diluting hybrid below plain semantic: lexical
26
+ * precision is front-loaded by the adapter's term-evidence ranking, while
27
+ * RRF weighs a rank-30 lexical candidate like a rank-30 embedding hit. */
28
+ const LEXICAL_FUSION_CAP = 20;
29
+ /** Embedding hits fetched per query — deep enough for fusion to matter. */
30
+ const EMBED_TOP_K = 50;
31
+ /** Fused candidates kept for reranking / final slicing. */
32
+ const FUSED_WINDOW = 50;
33
+ export async function retrieveCandidates(options) {
34
+ const { cwd, query, service, signal } = options;
35
+ const requestedMode = options.mode ?? "auto";
36
+ const limit = Math.max(1, options.limit ?? 10);
37
+ const rrfK = options.rrfK ?? DEFAULT_RRF_K;
38
+ const state = service?.getState();
39
+ const embedAvailable = service?.isAvailable() ?? false;
40
+ const embedUnavailableReason = state === undefined
41
+ ? "semantic index is not enabled"
42
+ : state.phase === "unavailable" || state.phase === "skipped"
43
+ ? state.reason
44
+ : state.phase === "idle"
45
+ ? "semantic index has not started"
46
+ : undefined;
47
+ const resolution = resolveSearchMode(query, requestedMode, embedAvailable, embedUnavailableReason);
48
+ const mode = resolution.mode;
49
+ // Map lexical hits onto indexed chunk ids whenever the sidecar is usable,
50
+ // even in lexical-only mode, so identities line up across modes.
51
+ const lookupChunk = embedAvailable
52
+ ? (rel, line) => service.findEnclosingChunk(rel, line)
53
+ : undefined;
54
+ const spans = new Map();
55
+ const lists = [];
56
+ const retrieverStats = {};
57
+ const errors = [];
58
+ const runLexical = async () => {
59
+ const startedMs = Date.now();
60
+ try {
61
+ const lineHits = await runLexicalRetriever(cwd, query, LEXICAL_MATCH_LIMIT, signal);
62
+ const adapted = adaptGrepHits(lineHits, lookupChunk);
63
+ // In single-retriever lexical mode the full list is the result; in
64
+ // hybrid, only the front-loaded head is trustworthy enough to vote.
65
+ const hits = mode === "hybrid" ? adapted.hits.slice(0, LEXICAL_FUSION_CAP) : adapted.hits;
66
+ for (const [id, span] of adapted.spans)
67
+ if (!spans.has(id))
68
+ spans.set(id, span);
69
+ lists.push(hits);
70
+ retrieverStats.grep = { latencyMs: Date.now() - startedMs, hitCount: hits.length };
71
+ }
72
+ catch (e) {
73
+ errors.push(e instanceof Error ? e : new Error(String(e)));
74
+ retrieverStats.grep = { latencyMs: Date.now() - startedMs, hitCount: 0 };
75
+ }
76
+ };
77
+ const runEmbed = async () => {
78
+ const startedMs = Date.now();
79
+ try {
80
+ const chunkHits = await service.searchChunks(query, EMBED_TOP_K);
81
+ const hits = chunkHits.map((hit, i) => ({
82
+ id: hit.id,
83
+ rank: i + 1,
84
+ score: hit.score,
85
+ source: "embed",
86
+ }));
87
+ for (const hit of chunkHits) {
88
+ spans.set(hit.id, { path: hit.path, startLine: hit.startLine, endLine: hit.endLine });
89
+ }
90
+ lists.push(hits);
91
+ retrieverStats.embed = { latencyMs: Date.now() - startedMs, hitCount: hits.length };
92
+ }
93
+ catch (e) {
94
+ errors.push(e instanceof Error ? e : new Error(String(e)));
95
+ retrieverStats.embed = { latencyMs: Date.now() - startedMs, hitCount: 0 };
96
+ }
97
+ };
98
+ const runs = [];
99
+ if (mode === "lexical" || mode === "hybrid")
100
+ runs.push(runLexical());
101
+ if (mode === "semantic" || mode === "hybrid")
102
+ runs.push(runEmbed());
103
+ await Promise.all(runs);
104
+ if (signal?.aborted)
105
+ throw new Error("Operation aborted");
106
+ // A partial failure in hybrid degrades to whichever retriever survived;
107
+ // only a total loss is an error.
108
+ if (lists.length === 0)
109
+ throw errors[0] ?? new Error("search produced no retriever results");
110
+ const fused = rrfFuse(lists, rrfK).slice(0, FUSED_WINDOW);
111
+ let candidates = [];
112
+ for (const hit of fused) {
113
+ const span = spans.get(hit.id);
114
+ if (span)
115
+ candidates.push({ ...hit, ...span });
116
+ }
117
+ let rerankInfo;
118
+ if (options.rerank !== false) {
119
+ const reranked = rerankCandidates(query, candidates, cwd);
120
+ rerankInfo = { applied: true, candidateCount: candidates.length, latencyMs: reranked.latencyMs };
121
+ candidates = reranked.candidates;
122
+ }
123
+ candidates = candidates.slice(0, limit);
124
+ return {
125
+ candidates,
126
+ resolvedMode: mode,
127
+ degradedReason: resolution.degradedReason,
128
+ indexPhase: state?.phase === "ready" ? "ready" : state?.phase === "indexing" ? "indexing" : "unavailable",
129
+ retrievers: retrieverStats,
130
+ indexing: state?.phase === "indexing" ? { done: state.done, total: state.total } : undefined,
131
+ rrfK,
132
+ rerank: rerankInfo,
133
+ };
134
+ }
135
+ export async function runSearch(options) {
136
+ const retrieved = await retrieveCandidates(options);
137
+ const assembled = assembleContext(retrieved.candidates, { cwd: options.cwd, tokenBudget: options.tokenBudget });
138
+ writeSearchTrace(options.cwd, {
139
+ timestampMs: Date.now(),
140
+ query: options.query,
141
+ requestedMode: options.mode ?? "auto",
142
+ resolvedMode: retrieved.resolvedMode,
143
+ degradedReason: retrieved.degradedReason,
144
+ indexPhase: retrieved.indexPhase,
145
+ rrfK: retrieved.resolvedMode === "hybrid" ? retrieved.rrfK : undefined,
146
+ retrievers: retrieved.retrievers,
147
+ fused: retrieved.candidates.map(({ id, rrfScore, ranks, rawScores }) => ({ id, rrfScore, ranks, rawScores })),
148
+ rerank: retrieved.rerank,
149
+ });
150
+ return {
151
+ text: assembled.text,
152
+ resolvedMode: retrieved.resolvedMode,
153
+ degradedReason: retrieved.degradedReason,
154
+ resultCount: retrieved.candidates.length,
155
+ indexing: retrieved.indexing,
156
+ };
157
+ }
158
+ //# sourceMappingURL=hybrid-search.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"hybrid-search.js","sourceRoot":"","sources":["../../../src/core/search/hybrid-search.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Hybrid search orchestrator: resolve mode, run retrievers in parallel, fuse\n * by rank, expand within budget, trace everything\n * (docs/hybrid-retrieval-design.md).\n *\n * Single-retriever modes flow through the same pipeline — rrfFuse over one\n * list preserves its order — so lexical, semantic, and hybrid all produce the\n * same result shape and the same trace record.\n *\n * `retrieveCandidates` is the candidate-level core (also used by the eval\n * harness, which needs forced modes, a configurable `k`, and no trace\n * pollution); `runSearch` wraps it with span expansion and tracing for the\n * tool.\n */\n\nimport type { EmbsearchService } from \"../embsearch/embsearch-service.js\";\nimport { adaptGrepHits, type ChunkLookup } from \"./adapter.js\";\nimport { assembleContext } from \"./context-assembler.js\";\nimport { runLexicalRetriever } from \"./lexical-retriever.js\";\nimport { resolveSearchMode } from \"./mode.js\";\nimport { rerankCandidates } from \"./rerank.js\";\nimport { DEFAULT_RRF_K, rrfFuse } from \"./rrf.js\";\nimport { writeSearchTrace } from \"./trace.js\";\nimport type { CandidateSpan, FusedCandidate, RankedHit, ResolvedSearchMode, SearchMode, SearchTrace } from \"./types.js\";\n\n/** Raw grep line-hits fetched per query (pre-collapse). */\nconst LEXICAL_MATCH_LIMIT = 200;\n/** Adapted lexical candidates entering fusion. The eval gate showed the\n * uncapped lexical tail diluting hybrid below plain semantic: lexical\n * precision is front-loaded by the adapter's term-evidence ranking, while\n * RRF weighs a rank-30 lexical candidate like a rank-30 embedding hit. */\nconst LEXICAL_FUSION_CAP = 20;\n/** Embedding hits fetched per query — deep enough for fusion to matter. */\nconst EMBED_TOP_K = 50;\n/** Fused candidates kept for reranking / final slicing. */\nconst FUSED_WINDOW = 50;\n\nexport interface RetrieveOptions {\n\tcwd: string;\n\tquery: string;\n\tmode?: SearchMode;\n\t/** Maximum fused candidates returned. */\n\tlimit?: number;\n\t/** RRF constant override (eval harness sweeps this). Default: 60. */\n\trrfK?: number;\n\t/** Rerank the fused top-50 before slicing to `limit`. Default: true. */\n\trerank?: boolean;\n\tservice?: EmbsearchService;\n\tsignal?: AbortSignal;\n}\n\nexport interface RetrieveResult {\n\tcandidates: FusedCandidate[];\n\tresolvedMode: ResolvedSearchMode;\n\tdegradedReason?: string;\n\tindexPhase: SearchTrace[\"indexPhase\"];\n\tretrievers: SearchTrace[\"retrievers\"];\n\t/** Set while the embedding index is still building. */\n\tindexing?: { done: number; total: number };\n\trrfK: number;\n\trerank?: SearchTrace[\"rerank\"];\n}\n\nexport interface RunSearchOptions extends RetrieveOptions {\n\t/** Approximate token budget for the result text. */\n\ttokenBudget?: number;\n}\n\nexport interface RunSearchResult {\n\ttext: string;\n\tresolvedMode: ResolvedSearchMode;\n\tdegradedReason?: string;\n\tresultCount: number;\n\t/** Set while the embedding index is still building. */\n\tindexing?: { done: number; total: number };\n}\n\nexport async function retrieveCandidates(options: RetrieveOptions): Promise<RetrieveResult> {\n\tconst { cwd, query, service, signal } = options;\n\tconst requestedMode = options.mode ?? \"auto\";\n\tconst limit = Math.max(1, options.limit ?? 10);\n\tconst rrfK = options.rrfK ?? DEFAULT_RRF_K;\n\n\tconst state = service?.getState();\n\tconst embedAvailable = service?.isAvailable() ?? false;\n\tconst embedUnavailableReason =\n\t\tstate === undefined\n\t\t\t? \"semantic index is not enabled\"\n\t\t\t: state.phase === \"unavailable\" || state.phase === \"skipped\"\n\t\t\t\t? state.reason\n\t\t\t\t: state.phase === \"idle\"\n\t\t\t\t\t? \"semantic index has not started\"\n\t\t\t\t\t: undefined;\n\n\tconst resolution = resolveSearchMode(query, requestedMode, embedAvailable, embedUnavailableReason);\n\tconst mode = resolution.mode;\n\n\t// Map lexical hits onto indexed chunk ids whenever the sidecar is usable,\n\t// even in lexical-only mode, so identities line up across modes.\n\tconst lookupChunk: ChunkLookup | undefined = embedAvailable\n\t\t? (rel, line) => service!.findEnclosingChunk(rel, line)\n\t\t: undefined;\n\n\tconst spans = new Map<string, CandidateSpan>();\n\tconst lists: RankedHit[][] = [];\n\tconst retrieverStats: SearchTrace[\"retrievers\"] = {};\n\tconst errors: Error[] = [];\n\n\tconst runLexical = async (): Promise<void> => {\n\t\tconst startedMs = Date.now();\n\t\ttry {\n\t\t\tconst lineHits = await runLexicalRetriever(cwd, query, LEXICAL_MATCH_LIMIT, signal);\n\t\t\tconst adapted = adaptGrepHits(lineHits, lookupChunk);\n\t\t\t// In single-retriever lexical mode the full list is the result; in\n\t\t\t// hybrid, only the front-loaded head is trustworthy enough to vote.\n\t\t\tconst hits = mode === \"hybrid\" ? adapted.hits.slice(0, LEXICAL_FUSION_CAP) : adapted.hits;\n\t\t\tfor (const [id, span] of adapted.spans) if (!spans.has(id)) spans.set(id, span);\n\t\t\tlists.push(hits);\n\t\t\tretrieverStats.grep = { latencyMs: Date.now() - startedMs, hitCount: hits.length };\n\t\t} catch (e) {\n\t\t\terrors.push(e instanceof Error ? e : new Error(String(e)));\n\t\t\tretrieverStats.grep = { latencyMs: Date.now() - startedMs, hitCount: 0 };\n\t\t}\n\t};\n\n\tconst runEmbed = async (): Promise<void> => {\n\t\tconst startedMs = Date.now();\n\t\ttry {\n\t\t\tconst chunkHits = await service!.searchChunks(query, EMBED_TOP_K);\n\t\t\tconst hits: RankedHit[] = chunkHits.map((hit, i) => ({\n\t\t\t\tid: hit.id,\n\t\t\t\trank: i + 1,\n\t\t\t\tscore: hit.score,\n\t\t\t\tsource: \"embed\",\n\t\t\t}));\n\t\t\tfor (const hit of chunkHits) {\n\t\t\t\tspans.set(hit.id, { path: hit.path, startLine: hit.startLine, endLine: hit.endLine });\n\t\t\t}\n\t\t\tlists.push(hits);\n\t\t\tretrieverStats.embed = { latencyMs: Date.now() - startedMs, hitCount: hits.length };\n\t\t} catch (e) {\n\t\t\terrors.push(e instanceof Error ? e : new Error(String(e)));\n\t\t\tretrieverStats.embed = { latencyMs: Date.now() - startedMs, hitCount: 0 };\n\t\t}\n\t};\n\n\tconst runs: Promise<void>[] = [];\n\tif (mode === \"lexical\" || mode === \"hybrid\") runs.push(runLexical());\n\tif (mode === \"semantic\" || mode === \"hybrid\") runs.push(runEmbed());\n\tawait Promise.all(runs);\n\tif (signal?.aborted) throw new Error(\"Operation aborted\");\n\t// A partial failure in hybrid degrades to whichever retriever survived;\n\t// only a total loss is an error.\n\tif (lists.length === 0) throw errors[0] ?? new Error(\"search produced no retriever results\");\n\n\tconst fused = rrfFuse(lists, rrfK).slice(0, FUSED_WINDOW);\n\tlet candidates: FusedCandidate[] = [];\n\tfor (const hit of fused) {\n\t\tconst span = spans.get(hit.id);\n\t\tif (span) candidates.push({ ...hit, ...span });\n\t}\n\n\tlet rerankInfo: SearchTrace[\"rerank\"];\n\tif (options.rerank !== false) {\n\t\tconst reranked = rerankCandidates(query, candidates, cwd);\n\t\trerankInfo = { applied: true, candidateCount: candidates.length, latencyMs: reranked.latencyMs };\n\t\tcandidates = reranked.candidates;\n\t}\n\tcandidates = candidates.slice(0, limit);\n\n\treturn {\n\t\tcandidates,\n\t\tresolvedMode: mode,\n\t\tdegradedReason: resolution.degradedReason,\n\t\tindexPhase: state?.phase === \"ready\" ? \"ready\" : state?.phase === \"indexing\" ? \"indexing\" : \"unavailable\",\n\t\tretrievers: retrieverStats,\n\t\tindexing: state?.phase === \"indexing\" ? { done: state.done, total: state.total } : undefined,\n\t\trrfK,\n\t\trerank: rerankInfo,\n\t};\n}\n\nexport async function runSearch(options: RunSearchOptions): Promise<RunSearchResult> {\n\tconst retrieved = await retrieveCandidates(options);\n\n\tconst assembled = assembleContext(retrieved.candidates, { cwd: options.cwd, tokenBudget: options.tokenBudget });\n\n\twriteSearchTrace(options.cwd, {\n\t\ttimestampMs: Date.now(),\n\t\tquery: options.query,\n\t\trequestedMode: options.mode ?? \"auto\",\n\t\tresolvedMode: retrieved.resolvedMode,\n\t\tdegradedReason: retrieved.degradedReason,\n\t\tindexPhase: retrieved.indexPhase,\n\t\trrfK: retrieved.resolvedMode === \"hybrid\" ? retrieved.rrfK : undefined,\n\t\tretrievers: retrieved.retrievers,\n\t\tfused: retrieved.candidates.map(({ id, rrfScore, ranks, rawScores }) => ({ id, rrfScore, ranks, rawScores })),\n\t\trerank: retrieved.rerank,\n\t});\n\n\treturn {\n\t\ttext: assembled.text,\n\t\tresolvedMode: retrieved.resolvedMode,\n\t\tdegradedReason: retrieved.degradedReason,\n\t\tresultCount: retrieved.candidates.length,\n\t\tindexing: retrieved.indexing,\n\t};\n}\n"]}
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Internal lexical retriever for hybrid search.
3
+ *
4
+ * This is not the grep *tool* — it is the lexical recall backend: it turns a
5
+ * natural query into a ripgrep pattern, streams matches, and returns bare
6
+ * `rel:line` hits for the grep→chunk adapter. rg drives the fast path; the
7
+ * pure-JS nativeGrep fallback keeps restricted environments working, same as
8
+ * the grep tool.
9
+ */
10
+ import type { GrepLineHit } from "./adapter.js";
11
+ export interface LexicalQueryPlan {
12
+ /** rg-ready regex pattern. */
13
+ pattern: string;
14
+ /** Raw (unescaped, lowercased) terms, for per-line term attribution. */
15
+ terms: string[];
16
+ }
17
+ /**
18
+ * Build the retrieval plan for a query: a quoted segment is searched
19
+ * verbatim; otherwise the longest few identifier-ish tokens are OR-ed
20
+ * together. Returns undefined when the query yields nothing searchable.
21
+ */
22
+ export declare function buildLexicalQueryPlan(query: string): LexicalQueryPlan | undefined;
23
+ /** Pattern-only view of {@link buildLexicalQueryPlan}. */
24
+ export declare function buildLexicalPattern(query: string): string | undefined;
25
+ /**
26
+ * Run lexical retrieval over `cwd`, returning up to `limit` line-hits in
27
+ * output order with POSIX repo-relative paths.
28
+ */
29
+ export declare function runLexicalRetriever(cwd: string, query: string, limit: number, signal?: AbortSignal): Promise<GrepLineHit[]>;
30
+ //# sourceMappingURL=lexical-retriever.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"lexical-retriever.d.ts","sourceRoot":"","sources":["../../../src/core/search/lexical-retriever.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAQH,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,cAAc,CAAC;AAWhD,MAAM,WAAW,gBAAgB;IAChC,8BAA8B;IAC9B,OAAO,EAAE,MAAM,CAAC;IAChB,wEAAwE;IACxE,KAAK,EAAE,MAAM,EAAE,CAAC;CAChB;AAED;;;;GAIG;AACH,wBAAgB,qBAAqB,CAAC,KAAK,EAAE,MAAM,GAAG,gBAAgB,GAAG,SAAS,CAgBjF;AAED,0DAA0D;AAC1D,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAErE;AAUD;;;GAGG;AACH,wBAAsB,mBAAmB,CACxC,GAAG,EAAE,MAAM,EACX,KAAK,EAAE,MAAM,EACb,KAAK,EAAE,MAAM,EACb,MAAM,CAAC,EAAE,WAAW,GAClB,OAAO,CAAC,WAAW,EAAE,CAAC,CAkGxB","sourcesContent":["/**\n * Internal lexical retriever for hybrid search.\n *\n * This is not the grep *tool* — it is the lexical recall backend: it turns a\n * natural query into a ripgrep pattern, streams matches, and returns bare\n * `rel:line` hits for the grep→chunk adapter. rg drives the fast path; the\n * pure-JS nativeGrep fallback keeps restricted environments working, same as\n * the grep tool.\n */\n\nimport { createInterface } from \"node:readline\";\nimport { spawn } from \"child_process\";\nimport { readFileSync } from \"fs\";\nimport path from \"path\";\nimport { ensureTool } from \"../../utils/tools-manager.js\";\nimport { isNativeSearchForced, nativeGrep } from \"../tools/native-search.js\";\nimport type { GrepLineHit } from \"./adapter.js\";\n\n/** Terms considered per query (longest first) when building the pattern. */\nconst MAX_TERMS = 4;\n/** Minimum token length worth matching on. */\nconst MIN_TERM_LENGTH = 3;\n\nfunction escapeRegExp(value: string): string {\n\treturn value.replace(/[.*+?^${}()|[\\]\\\\]/g, \"\\\\$&\");\n}\n\nexport interface LexicalQueryPlan {\n\t/** rg-ready regex pattern. */\n\tpattern: string;\n\t/** Raw (unescaped, lowercased) terms, for per-line term attribution. */\n\tterms: string[];\n}\n\n/**\n * Build the retrieval plan for a query: a quoted segment is searched\n * verbatim; otherwise the longest few identifier-ish tokens are OR-ed\n * together. Returns undefined when the query yields nothing searchable.\n */\nexport function buildLexicalQueryPlan(query: string): LexicalQueryPlan | undefined {\n\tconst quoted = [...query.matchAll(/[\"'`]([^\"'`]+)[\"'`]/g)]\n\t\t.map((m) => m[1].trim())\n\t\t.filter((s) => s.length > 0)\n\t\t.sort((a, b) => b.length - a.length)[0];\n\tif (quoted) return { pattern: escapeRegExp(quoted), terms: [quoted.toLowerCase()] };\n\n\tconst tokens = [...new Set(query.match(/[A-Za-z0-9_$][\\w$.-]*/g) ?? [])]\n\t\t.filter((t) => t.length >= MIN_TERM_LENGTH)\n\t\t.sort((a, b) => b.length - a.length || a.localeCompare(b))\n\t\t.slice(0, MAX_TERMS);\n\tif (tokens.length === 0) {\n\t\tconst trimmed = query.trim();\n\t\treturn trimmed ? { pattern: escapeRegExp(trimmed), terms: [trimmed.toLowerCase()] } : undefined;\n\t}\n\treturn { pattern: tokens.map(escapeRegExp).join(\"|\"), terms: tokens.map((t) => t.toLowerCase()) };\n}\n\n/** Pattern-only view of {@link buildLexicalQueryPlan}. */\nexport function buildLexicalPattern(query: string): string | undefined {\n\treturn buildLexicalQueryPlan(query)?.pattern;\n}\n\n/** Which plan terms appear on a matched line (retrieval is case-insensitive,\n * so attribution is too). */\nfunction termsOnLine(plan: LexicalQueryPlan, lineText: string | undefined): string[] {\n\tif (!lineText) return [];\n\tconst lower = lineText.toLowerCase();\n\treturn plan.terms.filter((t) => lower.includes(t));\n}\n\n/**\n * Run lexical retrieval over `cwd`, returning up to `limit` line-hits in\n * output order with POSIX repo-relative paths.\n */\nexport async function runLexicalRetriever(\n\tcwd: string,\n\tquery: string,\n\tlimit: number,\n\tsignal?: AbortSignal,\n): Promise<GrepLineHit[]> {\n\tconst plan = buildLexicalQueryPlan(query);\n\tif (!plan) return [];\n\tconst { pattern } = plan;\n\n\tconst toRel = (filePath: string): string => {\n\t\tconst rel = path.relative(cwd, filePath);\n\t\treturn (rel && !rel.startsWith(\"..\") ? rel : filePath).replace(/\\\\/g, \"/\");\n\t};\n\n\tconst rgPath = isNativeSearchForced() ? undefined : await ensureTool(\"rg\", true);\n\tif (!rgPath) {\n\t\tconst result = await nativeGrep(cwd, {\n\t\t\tpattern,\n\t\t\tisDirectory: true,\n\t\t\tignoreCase: true,\n\t\t\tlimit,\n\t\t\tsignal,\n\t\t\treadFile: (p) => readFileSync(p, \"utf-8\"),\n\t\t});\n\t\treturn result.matches.map((m) => ({\n\t\t\trel: toRel(m.filePath),\n\t\t\tline: m.lineNumber,\n\t\t\tterms: termsOnLine(plan, m.lineText),\n\t\t}));\n\t}\n\n\treturn new Promise<GrepLineHit[]>((resolve, reject) => {\n\t\t// --sort path forces a deterministic (single-threaded) walk: with the\n\t\t// match cap truncating the stream, a parallel walk would return a\n\t\t// different hit subset per run — \"same query, different context\".\n\t\tconst args = [\n\t\t\t\"--json\",\n\t\t\t\"--line-number\",\n\t\t\t\"--color=never\",\n\t\t\t\"--hidden\",\n\t\t\t\"--ignore-case\",\n\t\t\t\"--sort\",\n\t\t\t\"path\",\n\t\t\t\"--\",\n\t\t\tpattern,\n\t\t\tcwd,\n\t\t];\n\t\tconst child = spawn(rgPath, args, { stdio: [\"ignore\", \"pipe\", \"pipe\"] });\n\t\tconst rl = createInterface({ input: child.stdout });\n\t\tconst hits: GrepLineHit[] = [];\n\t\tlet stderr = \"\";\n\t\tlet killedDueToLimit = false;\n\t\tlet aborted = false;\n\n\t\tconst onAbort = () => {\n\t\t\taborted = true;\n\t\t\tif (!child.killed) child.kill();\n\t\t};\n\t\tsignal?.addEventListener(\"abort\", onAbort, { once: true });\n\t\tchild.stderr?.on(\"data\", (chunk) => {\n\t\t\tstderr += chunk.toString();\n\t\t});\n\n\t\trl.on(\"line\", (line) => {\n\t\t\tif (!line.trim() || hits.length >= limit) return;\n\t\t\tlet event: any;\n\t\t\ttry {\n\t\t\t\tevent = JSON.parse(line);\n\t\t\t} catch {\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tif (event.type !== \"match\") return;\n\t\t\tconst filePath = event.data?.path?.text;\n\t\t\tconst lineNumber = event.data?.line_number;\n\t\t\tif (filePath && typeof lineNumber === \"number\") {\n\t\t\t\thits.push({ rel: toRel(filePath), line: lineNumber, terms: termsOnLine(plan, event.data?.lines?.text) });\n\t\t\t}\n\t\t\tif (hits.length >= limit && !child.killed) {\n\t\t\t\tkilledDueToLimit = true;\n\t\t\t\tchild.kill();\n\t\t\t}\n\t\t});\n\n\t\tchild.on(\"error\", (error) => {\n\t\t\tsignal?.removeEventListener(\"abort\", onAbort);\n\t\t\treject(new Error(`Failed to run ripgrep: ${error.message}`));\n\t\t});\n\t\tchild.on(\"close\", (code) => {\n\t\t\trl.close();\n\t\t\tsignal?.removeEventListener(\"abort\", onAbort);\n\t\t\tif (aborted) {\n\t\t\t\treject(new Error(\"Operation aborted\"));\n\t\t\t\treturn;\n\t\t\t}\n\t\t\t// rg exits 1 on \"no matches\" — that is a valid empty result.\n\t\t\tif (!killedDueToLimit && code !== 0 && code !== 1) {\n\t\t\t\treject(new Error(stderr.trim() || `ripgrep exited with code ${code}`));\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tresolve(hits);\n\t\t});\n\t});\n}\n"]}
@@ -0,0 +1,159 @@
1
+ /**
2
+ * Internal lexical retriever for hybrid search.
3
+ *
4
+ * This is not the grep *tool* — it is the lexical recall backend: it turns a
5
+ * natural query into a ripgrep pattern, streams matches, and returns bare
6
+ * `rel:line` hits for the grep→chunk adapter. rg drives the fast path; the
7
+ * pure-JS nativeGrep fallback keeps restricted environments working, same as
8
+ * the grep tool.
9
+ */
10
+ import { createInterface } from "node:readline";
11
+ import { spawn } from "child_process";
12
+ import { readFileSync } from "fs";
13
+ import path from "path";
14
+ import { ensureTool } from "../../utils/tools-manager.js";
15
+ import { isNativeSearchForced, nativeGrep } from "../tools/native-search.js";
16
+ /** Terms considered per query (longest first) when building the pattern. */
17
+ const MAX_TERMS = 4;
18
+ /** Minimum token length worth matching on. */
19
+ const MIN_TERM_LENGTH = 3;
20
+ function escapeRegExp(value) {
21
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
22
+ }
23
+ /**
24
+ * Build the retrieval plan for a query: a quoted segment is searched
25
+ * verbatim; otherwise the longest few identifier-ish tokens are OR-ed
26
+ * together. Returns undefined when the query yields nothing searchable.
27
+ */
28
+ export function buildLexicalQueryPlan(query) {
29
+ const quoted = [...query.matchAll(/["'`]([^"'`]+)["'`]/g)]
30
+ .map((m) => m[1].trim())
31
+ .filter((s) => s.length > 0)
32
+ .sort((a, b) => b.length - a.length)[0];
33
+ if (quoted)
34
+ return { pattern: escapeRegExp(quoted), terms: [quoted.toLowerCase()] };
35
+ const tokens = [...new Set(query.match(/[A-Za-z0-9_$][\w$.-]*/g) ?? [])]
36
+ .filter((t) => t.length >= MIN_TERM_LENGTH)
37
+ .sort((a, b) => b.length - a.length || a.localeCompare(b))
38
+ .slice(0, MAX_TERMS);
39
+ if (tokens.length === 0) {
40
+ const trimmed = query.trim();
41
+ return trimmed ? { pattern: escapeRegExp(trimmed), terms: [trimmed.toLowerCase()] } : undefined;
42
+ }
43
+ return { pattern: tokens.map(escapeRegExp).join("|"), terms: tokens.map((t) => t.toLowerCase()) };
44
+ }
45
+ /** Pattern-only view of {@link buildLexicalQueryPlan}. */
46
+ export function buildLexicalPattern(query) {
47
+ return buildLexicalQueryPlan(query)?.pattern;
48
+ }
49
+ /** Which plan terms appear on a matched line (retrieval is case-insensitive,
50
+ * so attribution is too). */
51
+ function termsOnLine(plan, lineText) {
52
+ if (!lineText)
53
+ return [];
54
+ const lower = lineText.toLowerCase();
55
+ return plan.terms.filter((t) => lower.includes(t));
56
+ }
57
+ /**
58
+ * Run lexical retrieval over `cwd`, returning up to `limit` line-hits in
59
+ * output order with POSIX repo-relative paths.
60
+ */
61
+ export async function runLexicalRetriever(cwd, query, limit, signal) {
62
+ const plan = buildLexicalQueryPlan(query);
63
+ if (!plan)
64
+ return [];
65
+ const { pattern } = plan;
66
+ const toRel = (filePath) => {
67
+ const rel = path.relative(cwd, filePath);
68
+ return (rel && !rel.startsWith("..") ? rel : filePath).replace(/\\/g, "/");
69
+ };
70
+ const rgPath = isNativeSearchForced() ? undefined : await ensureTool("rg", true);
71
+ if (!rgPath) {
72
+ const result = await nativeGrep(cwd, {
73
+ pattern,
74
+ isDirectory: true,
75
+ ignoreCase: true,
76
+ limit,
77
+ signal,
78
+ readFile: (p) => readFileSync(p, "utf-8"),
79
+ });
80
+ return result.matches.map((m) => ({
81
+ rel: toRel(m.filePath),
82
+ line: m.lineNumber,
83
+ terms: termsOnLine(plan, m.lineText),
84
+ }));
85
+ }
86
+ return new Promise((resolve, reject) => {
87
+ // --sort path forces a deterministic (single-threaded) walk: with the
88
+ // match cap truncating the stream, a parallel walk would return a
89
+ // different hit subset per run — "same query, different context".
90
+ const args = [
91
+ "--json",
92
+ "--line-number",
93
+ "--color=never",
94
+ "--hidden",
95
+ "--ignore-case",
96
+ "--sort",
97
+ "path",
98
+ "--",
99
+ pattern,
100
+ cwd,
101
+ ];
102
+ const child = spawn(rgPath, args, { stdio: ["ignore", "pipe", "pipe"] });
103
+ const rl = createInterface({ input: child.stdout });
104
+ const hits = [];
105
+ let stderr = "";
106
+ let killedDueToLimit = false;
107
+ let aborted = false;
108
+ const onAbort = () => {
109
+ aborted = true;
110
+ if (!child.killed)
111
+ child.kill();
112
+ };
113
+ signal?.addEventListener("abort", onAbort, { once: true });
114
+ child.stderr?.on("data", (chunk) => {
115
+ stderr += chunk.toString();
116
+ });
117
+ rl.on("line", (line) => {
118
+ if (!line.trim() || hits.length >= limit)
119
+ return;
120
+ let event;
121
+ try {
122
+ event = JSON.parse(line);
123
+ }
124
+ catch {
125
+ return;
126
+ }
127
+ if (event.type !== "match")
128
+ return;
129
+ const filePath = event.data?.path?.text;
130
+ const lineNumber = event.data?.line_number;
131
+ if (filePath && typeof lineNumber === "number") {
132
+ hits.push({ rel: toRel(filePath), line: lineNumber, terms: termsOnLine(plan, event.data?.lines?.text) });
133
+ }
134
+ if (hits.length >= limit && !child.killed) {
135
+ killedDueToLimit = true;
136
+ child.kill();
137
+ }
138
+ });
139
+ child.on("error", (error) => {
140
+ signal?.removeEventListener("abort", onAbort);
141
+ reject(new Error(`Failed to run ripgrep: ${error.message}`));
142
+ });
143
+ child.on("close", (code) => {
144
+ rl.close();
145
+ signal?.removeEventListener("abort", onAbort);
146
+ if (aborted) {
147
+ reject(new Error("Operation aborted"));
148
+ return;
149
+ }
150
+ // rg exits 1 on "no matches" — that is a valid empty result.
151
+ if (!killedDueToLimit && code !== 0 && code !== 1) {
152
+ reject(new Error(stderr.trim() || `ripgrep exited with code ${code}`));
153
+ return;
154
+ }
155
+ resolve(hits);
156
+ });
157
+ });
158
+ }
159
+ //# sourceMappingURL=lexical-retriever.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"lexical-retriever.js","sourceRoot":"","sources":["../../../src/core/search/lexical-retriever.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Internal lexical retriever for hybrid search.\n *\n * This is not the grep *tool* — it is the lexical recall backend: it turns a\n * natural query into a ripgrep pattern, streams matches, and returns bare\n * `rel:line` hits for the grep→chunk adapter. rg drives the fast path; the\n * pure-JS nativeGrep fallback keeps restricted environments working, same as\n * the grep tool.\n */\n\nimport { createInterface } from \"node:readline\";\nimport { spawn } from \"child_process\";\nimport { readFileSync } from \"fs\";\nimport path from \"path\";\nimport { ensureTool } from \"../../utils/tools-manager.js\";\nimport { isNativeSearchForced, nativeGrep } from \"../tools/native-search.js\";\nimport type { GrepLineHit } from \"./adapter.js\";\n\n/** Terms considered per query (longest first) when building the pattern. */\nconst MAX_TERMS = 4;\n/** Minimum token length worth matching on. */\nconst MIN_TERM_LENGTH = 3;\n\nfunction escapeRegExp(value: string): string {\n\treturn value.replace(/[.*+?^${}()|[\\]\\\\]/g, \"\\\\$&\");\n}\n\nexport interface LexicalQueryPlan {\n\t/** rg-ready regex pattern. */\n\tpattern: string;\n\t/** Raw (unescaped, lowercased) terms, for per-line term attribution. */\n\tterms: string[];\n}\n\n/**\n * Build the retrieval plan for a query: a quoted segment is searched\n * verbatim; otherwise the longest few identifier-ish tokens are OR-ed\n * together. Returns undefined when the query yields nothing searchable.\n */\nexport function buildLexicalQueryPlan(query: string): LexicalQueryPlan | undefined {\n\tconst quoted = [...query.matchAll(/[\"'`]([^\"'`]+)[\"'`]/g)]\n\t\t.map((m) => m[1].trim())\n\t\t.filter((s) => s.length > 0)\n\t\t.sort((a, b) => b.length - a.length)[0];\n\tif (quoted) return { pattern: escapeRegExp(quoted), terms: [quoted.toLowerCase()] };\n\n\tconst tokens = [...new Set(query.match(/[A-Za-z0-9_$][\\w$.-]*/g) ?? [])]\n\t\t.filter((t) => t.length >= MIN_TERM_LENGTH)\n\t\t.sort((a, b) => b.length - a.length || a.localeCompare(b))\n\t\t.slice(0, MAX_TERMS);\n\tif (tokens.length === 0) {\n\t\tconst trimmed = query.trim();\n\t\treturn trimmed ? { pattern: escapeRegExp(trimmed), terms: [trimmed.toLowerCase()] } : undefined;\n\t}\n\treturn { pattern: tokens.map(escapeRegExp).join(\"|\"), terms: tokens.map((t) => t.toLowerCase()) };\n}\n\n/** Pattern-only view of {@link buildLexicalQueryPlan}. */\nexport function buildLexicalPattern(query: string): string | undefined {\n\treturn buildLexicalQueryPlan(query)?.pattern;\n}\n\n/** Which plan terms appear on a matched line (retrieval is case-insensitive,\n * so attribution is too). */\nfunction termsOnLine(plan: LexicalQueryPlan, lineText: string | undefined): string[] {\n\tif (!lineText) return [];\n\tconst lower = lineText.toLowerCase();\n\treturn plan.terms.filter((t) => lower.includes(t));\n}\n\n/**\n * Run lexical retrieval over `cwd`, returning up to `limit` line-hits in\n * output order with POSIX repo-relative paths.\n */\nexport async function runLexicalRetriever(\n\tcwd: string,\n\tquery: string,\n\tlimit: number,\n\tsignal?: AbortSignal,\n): Promise<GrepLineHit[]> {\n\tconst plan = buildLexicalQueryPlan(query);\n\tif (!plan) return [];\n\tconst { pattern } = plan;\n\n\tconst toRel = (filePath: string): string => {\n\t\tconst rel = path.relative(cwd, filePath);\n\t\treturn (rel && !rel.startsWith(\"..\") ? rel : filePath).replace(/\\\\/g, \"/\");\n\t};\n\n\tconst rgPath = isNativeSearchForced() ? undefined : await ensureTool(\"rg\", true);\n\tif (!rgPath) {\n\t\tconst result = await nativeGrep(cwd, {\n\t\t\tpattern,\n\t\t\tisDirectory: true,\n\t\t\tignoreCase: true,\n\t\t\tlimit,\n\t\t\tsignal,\n\t\t\treadFile: (p) => readFileSync(p, \"utf-8\"),\n\t\t});\n\t\treturn result.matches.map((m) => ({\n\t\t\trel: toRel(m.filePath),\n\t\t\tline: m.lineNumber,\n\t\t\tterms: termsOnLine(plan, m.lineText),\n\t\t}));\n\t}\n\n\treturn new Promise<GrepLineHit[]>((resolve, reject) => {\n\t\t// --sort path forces a deterministic (single-threaded) walk: with the\n\t\t// match cap truncating the stream, a parallel walk would return a\n\t\t// different hit subset per run — \"same query, different context\".\n\t\tconst args = [\n\t\t\t\"--json\",\n\t\t\t\"--line-number\",\n\t\t\t\"--color=never\",\n\t\t\t\"--hidden\",\n\t\t\t\"--ignore-case\",\n\t\t\t\"--sort\",\n\t\t\t\"path\",\n\t\t\t\"--\",\n\t\t\tpattern,\n\t\t\tcwd,\n\t\t];\n\t\tconst child = spawn(rgPath, args, { stdio: [\"ignore\", \"pipe\", \"pipe\"] });\n\t\tconst rl = createInterface({ input: child.stdout });\n\t\tconst hits: GrepLineHit[] = [];\n\t\tlet stderr = \"\";\n\t\tlet killedDueToLimit = false;\n\t\tlet aborted = false;\n\n\t\tconst onAbort = () => {\n\t\t\taborted = true;\n\t\t\tif (!child.killed) child.kill();\n\t\t};\n\t\tsignal?.addEventListener(\"abort\", onAbort, { once: true });\n\t\tchild.stderr?.on(\"data\", (chunk) => {\n\t\t\tstderr += chunk.toString();\n\t\t});\n\n\t\trl.on(\"line\", (line) => {\n\t\t\tif (!line.trim() || hits.length >= limit) return;\n\t\t\tlet event: any;\n\t\t\ttry {\n\t\t\t\tevent = JSON.parse(line);\n\t\t\t} catch {\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tif (event.type !== \"match\") return;\n\t\t\tconst filePath = event.data?.path?.text;\n\t\t\tconst lineNumber = event.data?.line_number;\n\t\t\tif (filePath && typeof lineNumber === \"number\") {\n\t\t\t\thits.push({ rel: toRel(filePath), line: lineNumber, terms: termsOnLine(plan, event.data?.lines?.text) });\n\t\t\t}\n\t\t\tif (hits.length >= limit && !child.killed) {\n\t\t\t\tkilledDueToLimit = true;\n\t\t\t\tchild.kill();\n\t\t\t}\n\t\t});\n\n\t\tchild.on(\"error\", (error) => {\n\t\t\tsignal?.removeEventListener(\"abort\", onAbort);\n\t\t\treject(new Error(`Failed to run ripgrep: ${error.message}`));\n\t\t});\n\t\tchild.on(\"close\", (code) => {\n\t\t\trl.close();\n\t\t\tsignal?.removeEventListener(\"abort\", onAbort);\n\t\t\tif (aborted) {\n\t\t\t\treject(new Error(\"Operation aborted\"));\n\t\t\t\treturn;\n\t\t\t}\n\t\t\t// rg exits 1 on \"no matches\" — that is a valid empty result.\n\t\t\tif (!killedDueToLimit && code !== 0 && code !== 1) {\n\t\t\t\treject(new Error(stderr.trim() || `ripgrep exited with code ${code}`));\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tresolve(hits);\n\t\t});\n\t});\n}\n"]}
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Availability-first search mode resolution
3
+ * (docs/hybrid-retrieval-design.md, Decision 4).
4
+ *
5
+ * No clever query router: with a hot local daemon, running both retrievers
6
+ * costs one extra embedding query, while misrouting costs recall. `auto`
7
+ * therefore means hybrid whenever the index is available, dropping to lexical
8
+ * only on strong lexical signals. Requested semantic/hybrid degrade to
9
+ * lexical (with a recorded reason) when the index is unavailable — never an
10
+ * error, unlike the old semantic_search tool.
11
+ */
12
+ import type { ResolvedSearchMode, SearchMode } from "./types.js";
13
+ export interface ModeResolution {
14
+ mode: ResolvedSearchMode;
15
+ /** Set when the resolved mode is a forced degradation of the request. */
16
+ degradedReason?: string;
17
+ }
18
+ /** Regex metacharacters or quoted strings — queries where exact matching is
19
+ * clearly what the caller wants. Path-like queries deliberately do NOT
20
+ * count: the eval gate showed them scoring 0% lexically (content grep
21
+ * cannot find a file by its own name) and 100% in hybrid, where the
22
+ * embedding side and the reranker's path-affinity signal carry them. */
23
+ export declare function hasStrongLexicalSignals(query: string): boolean;
24
+ export declare function resolveSearchMode(query: string, requested: SearchMode, embedAvailable: boolean, embedUnavailableReason?: string): ModeResolution;
25
+ //# sourceMappingURL=mode.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"mode.d.ts","sourceRoot":"","sources":["../../../src/core/search/mode.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,OAAO,KAAK,EAAE,kBAAkB,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AAEjE,MAAM,WAAW,cAAc;IAC9B,IAAI,EAAE,kBAAkB,CAAC;IACzB,yEAAyE;IACzE,cAAc,CAAC,EAAE,MAAM,CAAC;CACxB;AAED;;;;yEAIyE;AACzE,wBAAgB,uBAAuB,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAI9D;AAED,wBAAgB,iBAAiB,CAChC,KAAK,EAAE,MAAM,EACb,SAAS,EAAE,UAAU,EACrB,cAAc,EAAE,OAAO,EACvB,sBAAsB,CAAC,EAAE,MAAM,GAC7B,cAAc,CAahB","sourcesContent":["/**\n * Availability-first search mode resolution\n * (docs/hybrid-retrieval-design.md, Decision 4).\n *\n * No clever query router: with a hot local daemon, running both retrievers\n * costs one extra embedding query, while misrouting costs recall. `auto`\n * therefore means hybrid whenever the index is available, dropping to lexical\n * only on strong lexical signals. Requested semantic/hybrid degrade to\n * lexical (with a recorded reason) when the index is unavailable — never an\n * error, unlike the old semantic_search tool.\n */\n\nimport type { ResolvedSearchMode, SearchMode } from \"./types.js\";\n\nexport interface ModeResolution {\n\tmode: ResolvedSearchMode;\n\t/** Set when the resolved mode is a forced degradation of the request. */\n\tdegradedReason?: string;\n}\n\n/** Regex metacharacters or quoted strings — queries where exact matching is\n * clearly what the caller wants. Path-like queries deliberately do NOT\n * count: the eval gate showed them scoring 0% lexically (content grep\n * cannot find a file by its own name) and 100% in hybrid, where the\n * embedding side and the reranker's path-affinity signal carry them. */\nexport function hasStrongLexicalSignals(query: string): boolean {\n\tif (/[\"'`]/.test(query)) return true;\n\tif (/[\\\\^$|()[\\]{}*+?]/.test(query)) return true;\n\treturn false;\n}\n\nexport function resolveSearchMode(\n\tquery: string,\n\trequested: SearchMode,\n\tembedAvailable: boolean,\n\tembedUnavailableReason?: string,\n): ModeResolution {\n\tif (requested === \"lexical\") return { mode: \"lexical\" };\n\n\tif (!embedAvailable) {\n\t\tconst reason = embedUnavailableReason ?? \"semantic index unavailable\";\n\t\treturn requested === \"auto\"\n\t\t\t? { mode: \"lexical\" }\n\t\t\t: { mode: \"lexical\", degradedReason: `${requested} requested but ${reason}` };\n\t}\n\n\tif (requested === \"semantic\" || requested === \"hybrid\") return { mode: requested };\n\n\treturn hasStrongLexicalSignals(query) ? { mode: \"lexical\" } : { mode: \"hybrid\" };\n}\n"]}
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Availability-first search mode resolution
3
+ * (docs/hybrid-retrieval-design.md, Decision 4).
4
+ *
5
+ * No clever query router: with a hot local daemon, running both retrievers
6
+ * costs one extra embedding query, while misrouting costs recall. `auto`
7
+ * therefore means hybrid whenever the index is available, dropping to lexical
8
+ * only on strong lexical signals. Requested semantic/hybrid degrade to
9
+ * lexical (with a recorded reason) when the index is unavailable — never an
10
+ * error, unlike the old semantic_search tool.
11
+ */
12
+ /** Regex metacharacters or quoted strings — queries where exact matching is
13
+ * clearly what the caller wants. Path-like queries deliberately do NOT
14
+ * count: the eval gate showed them scoring 0% lexically (content grep
15
+ * cannot find a file by its own name) and 100% in hybrid, where the
16
+ * embedding side and the reranker's path-affinity signal carry them. */
17
+ export function hasStrongLexicalSignals(query) {
18
+ if (/["'`]/.test(query))
19
+ return true;
20
+ if (/[\\^$|()[\]{}*+?]/.test(query))
21
+ return true;
22
+ return false;
23
+ }
24
+ export function resolveSearchMode(query, requested, embedAvailable, embedUnavailableReason) {
25
+ if (requested === "lexical")
26
+ return { mode: "lexical" };
27
+ if (!embedAvailable) {
28
+ const reason = embedUnavailableReason ?? "semantic index unavailable";
29
+ return requested === "auto"
30
+ ? { mode: "lexical" }
31
+ : { mode: "lexical", degradedReason: `${requested} requested but ${reason}` };
32
+ }
33
+ if (requested === "semantic" || requested === "hybrid")
34
+ return { mode: requested };
35
+ return hasStrongLexicalSignals(query) ? { mode: "lexical" } : { mode: "hybrid" };
36
+ }
37
+ //# sourceMappingURL=mode.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"mode.js","sourceRoot":"","sources":["../../../src/core/search/mode.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAUH;;;;yEAIyE;AACzE,MAAM,UAAU,uBAAuB,CAAC,KAAa,EAAW;IAC/D,IAAI,OAAO,CAAC,IAAI,CAAC,KAAK,CAAC;QAAE,OAAO,IAAI,CAAC;IACrC,IAAI,mBAAmB,CAAC,IAAI,CAAC,KAAK,CAAC;QAAE,OAAO,IAAI,CAAC;IACjD,OAAO,KAAK,CAAC;AAAA,CACb;AAED,MAAM,UAAU,iBAAiB,CAChC,KAAa,EACb,SAAqB,EACrB,cAAuB,EACvB,sBAA+B,EACd;IACjB,IAAI,SAAS,KAAK,SAAS;QAAE,OAAO,EAAE,IAAI,EAAE,SAAS,EAAE,CAAC;IAExD,IAAI,CAAC,cAAc,EAAE,CAAC;QACrB,MAAM,MAAM,GAAG,sBAAsB,IAAI,4BAA4B,CAAC;QACtE,OAAO,SAAS,KAAK,MAAM;YAC1B,CAAC,CAAC,EAAE,IAAI,EAAE,SAAS,EAAE;YACrB,CAAC,CAAC,EAAE,IAAI,EAAE,SAAS,EAAE,cAAc,EAAE,GAAG,SAAS,kBAAkB,MAAM,EAAE,EAAE,CAAC;IAChF,CAAC;IAED,IAAI,SAAS,KAAK,UAAU,IAAI,SAAS,KAAK,QAAQ;QAAE,OAAO,EAAE,IAAI,EAAE,SAAS,EAAE,CAAC;IAEnF,OAAO,uBAAuB,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,SAAS,EAAE,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,QAAQ,EAAE,CAAC;AAAA,CACjF","sourcesContent":["/**\n * Availability-first search mode resolution\n * (docs/hybrid-retrieval-design.md, Decision 4).\n *\n * No clever query router: with a hot local daemon, running both retrievers\n * costs one extra embedding query, while misrouting costs recall. `auto`\n * therefore means hybrid whenever the index is available, dropping to lexical\n * only on strong lexical signals. Requested semantic/hybrid degrade to\n * lexical (with a recorded reason) when the index is unavailable — never an\n * error, unlike the old semantic_search tool.\n */\n\nimport type { ResolvedSearchMode, SearchMode } from \"./types.js\";\n\nexport interface ModeResolution {\n\tmode: ResolvedSearchMode;\n\t/** Set when the resolved mode is a forced degradation of the request. */\n\tdegradedReason?: string;\n}\n\n/** Regex metacharacters or quoted strings — queries where exact matching is\n * clearly what the caller wants. Path-like queries deliberately do NOT\n * count: the eval gate showed them scoring 0% lexically (content grep\n * cannot find a file by its own name) and 100% in hybrid, where the\n * embedding side and the reranker's path-affinity signal carry them. */\nexport function hasStrongLexicalSignals(query: string): boolean {\n\tif (/[\"'`]/.test(query)) return true;\n\tif (/[\\\\^$|()[\\]{}*+?]/.test(query)) return true;\n\treturn false;\n}\n\nexport function resolveSearchMode(\n\tquery: string,\n\trequested: SearchMode,\n\tembedAvailable: boolean,\n\tembedUnavailableReason?: string,\n): ModeResolution {\n\tif (requested === \"lexical\") return { mode: \"lexical\" };\n\n\tif (!embedAvailable) {\n\t\tconst reason = embedUnavailableReason ?? \"semantic index unavailable\";\n\t\treturn requested === \"auto\"\n\t\t\t? { mode: \"lexical\" }\n\t\t\t: { mode: \"lexical\", degradedReason: `${requested} requested but ${reason}` };\n\t}\n\n\tif (requested === \"semantic\" || requested === \"hybrid\") return { mode: requested };\n\n\treturn hasStrongLexicalSignals(query) ? { mode: \"lexical\" } : { mode: \"hybrid\" };\n}\n"]}
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Deterministic reranker over the fused top-50
3
+ * (docs/hybrid-retrieval-design.md, step 7 of the shipping order).
4
+ *
5
+ * The eval gate showed fused Recall@50 well above Recall@5/10 — the right
6
+ * candidates survive fusion but sit too deep. This reranker re-orders them
7
+ * using evidence that is only cheap to compute *after* fusion, when there
8
+ * are ≤50 candidates instead of thousands of lines:
9
+ *
10
+ * - term coverage: how many distinct query terms appear in the candidate's
11
+ * actual expanded window (read from disk);
12
+ * - path affinity: query terms appearing in the candidate's file path —
13
+ * this is what lets a query like `core/search/hybrid-search.ts` rank the
14
+ * file itself first, which content grep alone cannot do;
15
+ * - fused prior: the RRF ordering, so retriever consensus still counts.
16
+ *
17
+ * Purely lexical-statistical and deterministic — no model, no I/O beyond
18
+ * reading candidate windows. A cross-encoder can later replace the scoring
19
+ * function behind the same signature; that model work belongs to
20
+ * `kolisachint/embeddingsearchtools`, not here.
21
+ */
22
+ import type { FusedCandidate } from "./types.js";
23
+ export interface RerankResult {
24
+ candidates: FusedCandidate[];
25
+ latencyMs: number;
26
+ }
27
+ export declare function rerankCandidates(query: string, candidates: readonly FusedCandidate[], cwd: string): RerankResult;
28
+ //# sourceMappingURL=rerank.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"rerank.d.ts","sourceRoot":"","sources":["../../../src/core/search/rerank.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAKH,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,YAAY,CAAC;AAYjD,MAAM,WAAW,YAAY;IAC5B,UAAU,EAAE,cAAc,EAAE,CAAC;IAC7B,SAAS,EAAE,MAAM,CAAC;CAClB;AAED,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,MAAM,EAAE,UAAU,EAAE,SAAS,cAAc,EAAE,EAAE,GAAG,EAAE,MAAM,GAAG,YAAY,CAwDhH","sourcesContent":["/**\n * Deterministic reranker over the fused top-50\n * (docs/hybrid-retrieval-design.md, step 7 of the shipping order).\n *\n * The eval gate showed fused Recall@50 well above Recall@5/10 — the right\n * candidates survive fusion but sit too deep. This reranker re-orders them\n * using evidence that is only cheap to compute *after* fusion, when there\n * are ≤50 candidates instead of thousands of lines:\n *\n * - term coverage: how many distinct query terms appear in the candidate's\n * actual expanded window (read from disk);\n * - path affinity: query terms appearing in the candidate's file path —\n * this is what lets a query like `core/search/hybrid-search.ts` rank the\n * file itself first, which content grep alone cannot do;\n * - fused prior: the RRF ordering, so retriever consensus still counts.\n *\n * Purely lexical-statistical and deterministic — no model, no I/O beyond\n * reading candidate windows. A cross-encoder can later replace the scoring\n * function behind the same signature; that model work belongs to\n * `kolisachint/embeddingsearchtools`, not here.\n */\n\nimport { readFileSync } from \"fs\";\nimport path from \"path\";\nimport { buildLexicalQueryPlan } from \"./lexical-retriever.js\";\nimport type { FusedCandidate } from \"./types.js\";\n\n/** Weights of the scoring blend. The eval harness (scripts/search-eval.mjs)\n * is the instrument for changing them — don't tune blind. */\nconst WEIGHT_FUSED_PRIOR = 0.4;\nconst WEIGHT_TERM_COVERAGE = 0.35;\nconst WEIGHT_PATH_AFFINITY = 0.25;\n/** Additive bonus when the query *is* the candidate's path (or its suffix):\n * the caller named the file, so no amount of content evidence elsewhere\n * should outrank it. */\nconst EXACT_PATH_BONUS = 0.5;\n\nexport interface RerankResult {\n\tcandidates: FusedCandidate[];\n\tlatencyMs: number;\n}\n\nexport function rerankCandidates(query: string, candidates: readonly FusedCandidate[], cwd: string): RerankResult {\n\tconst startedMs = Date.now();\n\tconst plan = buildLexicalQueryPlan(query);\n\tif (!plan || candidates.length < 2) {\n\t\treturn { candidates: [...candidates], latencyMs: Date.now() - startedMs };\n\t}\n\tconst terms = plan.terms;\n\tconst queryPath = query.trim().toLowerCase();\n\n\tconst fileCache = new Map<string, string[] | undefined>();\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(cwd, rel), \"utf-8\");\n\t\t\t\tfileCache.set(rel, content.toLowerCase().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 scored = candidates.map((candidate, index) => {\n\t\t// Fused prior: normalized RRF ordering, 1 for the top candidate.\n\t\tconst fusedPrior = 1 - index / candidates.length;\n\n\t\tconst lines = readLines(candidate.path);\n\t\tlet termCoverage = 0;\n\t\tif (lines && terms.length > 0) {\n\t\t\tconst window = lines\n\t\t\t\t.slice(Math.max(0, candidate.startLine - 1), Math.min(lines.length, candidate.endLine))\n\t\t\t\t.join(\"\\n\");\n\t\t\ttermCoverage = terms.filter((t) => window.includes(t)).length / terms.length;\n\t\t}\n\n\t\tconst lowerPath = candidate.path.toLowerCase();\n\t\t// A quoted phrase rarely names a file; split it into path-ish tokens so\n\t\t// `\"token budget exceeded\"` still gets partial path credit.\n\t\tconst pathTerms = terms.length === 1 ? terms[0].split(/[^a-z0-9_$]+/).filter((t) => t.length >= 3) : terms;\n\t\tconst pathAffinity =\n\t\t\tpathTerms.length > 0 ? pathTerms.filter((t) => lowerPath.includes(t)).length / pathTerms.length : 0;\n\n\t\tconst exactPath =\n\t\t\tqueryPath.length >= 3 && (lowerPath === queryPath || lowerPath.endsWith(`/${queryPath}`)) ? 1 : 0;\n\n\t\tconst score =\n\t\t\tWEIGHT_FUSED_PRIOR * fusedPrior +\n\t\t\tWEIGHT_TERM_COVERAGE * termCoverage +\n\t\t\tWEIGHT_PATH_AFFINITY * pathAffinity +\n\t\t\tEXACT_PATH_BONUS * exactPath;\n\t\treturn { candidate, index, score };\n\t});\n\n\t// Stable, deterministic: score desc, fused order as tie-break.\n\tscored.sort((a, b) => b.score - a.score || a.index - b.index);\n\treturn { candidates: scored.map((s) => s.candidate), latencyMs: Date.now() - startedMs };\n}\n"]}
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Deterministic reranker over the fused top-50
3
+ * (docs/hybrid-retrieval-design.md, step 7 of the shipping order).
4
+ *
5
+ * The eval gate showed fused Recall@50 well above Recall@5/10 — the right
6
+ * candidates survive fusion but sit too deep. This reranker re-orders them
7
+ * using evidence that is only cheap to compute *after* fusion, when there
8
+ * are ≤50 candidates instead of thousands of lines:
9
+ *
10
+ * - term coverage: how many distinct query terms appear in the candidate's
11
+ * actual expanded window (read from disk);
12
+ * - path affinity: query terms appearing in the candidate's file path —
13
+ * this is what lets a query like `core/search/hybrid-search.ts` rank the
14
+ * file itself first, which content grep alone cannot do;
15
+ * - fused prior: the RRF ordering, so retriever consensus still counts.
16
+ *
17
+ * Purely lexical-statistical and deterministic — no model, no I/O beyond
18
+ * reading candidate windows. A cross-encoder can later replace the scoring
19
+ * function behind the same signature; that model work belongs to
20
+ * `kolisachint/embeddingsearchtools`, not here.
21
+ */
22
+ import { readFileSync } from "fs";
23
+ import path from "path";
24
+ import { buildLexicalQueryPlan } from "./lexical-retriever.js";
25
+ /** Weights of the scoring blend. The eval harness (scripts/search-eval.mjs)
26
+ * is the instrument for changing them — don't tune blind. */
27
+ const WEIGHT_FUSED_PRIOR = 0.4;
28
+ const WEIGHT_TERM_COVERAGE = 0.35;
29
+ const WEIGHT_PATH_AFFINITY = 0.25;
30
+ /** Additive bonus when the query *is* the candidate's path (or its suffix):
31
+ * the caller named the file, so no amount of content evidence elsewhere
32
+ * should outrank it. */
33
+ const EXACT_PATH_BONUS = 0.5;
34
+ export function rerankCandidates(query, candidates, cwd) {
35
+ const startedMs = Date.now();
36
+ const plan = buildLexicalQueryPlan(query);
37
+ if (!plan || candidates.length < 2) {
38
+ return { candidates: [...candidates], latencyMs: Date.now() - startedMs };
39
+ }
40
+ const terms = plan.terms;
41
+ const queryPath = query.trim().toLowerCase();
42
+ const fileCache = new Map();
43
+ const readLines = (rel) => {
44
+ if (!fileCache.has(rel)) {
45
+ try {
46
+ const content = readFileSync(path.resolve(cwd, rel), "utf-8");
47
+ fileCache.set(rel, content.toLowerCase().split("\n"));
48
+ }
49
+ catch {
50
+ fileCache.set(rel, undefined);
51
+ }
52
+ }
53
+ return fileCache.get(rel);
54
+ };
55
+ const scored = candidates.map((candidate, index) => {
56
+ // Fused prior: normalized RRF ordering, 1 for the top candidate.
57
+ const fusedPrior = 1 - index / candidates.length;
58
+ const lines = readLines(candidate.path);
59
+ let termCoverage = 0;
60
+ if (lines && terms.length > 0) {
61
+ const window = lines
62
+ .slice(Math.max(0, candidate.startLine - 1), Math.min(lines.length, candidate.endLine))
63
+ .join("\n");
64
+ termCoverage = terms.filter((t) => window.includes(t)).length / terms.length;
65
+ }
66
+ const lowerPath = candidate.path.toLowerCase();
67
+ // A quoted phrase rarely names a file; split it into path-ish tokens so
68
+ // `"token budget exceeded"` still gets partial path credit.
69
+ const pathTerms = terms.length === 1 ? terms[0].split(/[^a-z0-9_$]+/).filter((t) => t.length >= 3) : terms;
70
+ const pathAffinity = pathTerms.length > 0 ? pathTerms.filter((t) => lowerPath.includes(t)).length / pathTerms.length : 0;
71
+ const exactPath = queryPath.length >= 3 && (lowerPath === queryPath || lowerPath.endsWith(`/${queryPath}`)) ? 1 : 0;
72
+ const score = WEIGHT_FUSED_PRIOR * fusedPrior +
73
+ WEIGHT_TERM_COVERAGE * termCoverage +
74
+ WEIGHT_PATH_AFFINITY * pathAffinity +
75
+ EXACT_PATH_BONUS * exactPath;
76
+ return { candidate, index, score };
77
+ });
78
+ // Stable, deterministic: score desc, fused order as tie-break.
79
+ scored.sort((a, b) => b.score - a.score || a.index - b.index);
80
+ return { candidates: scored.map((s) => s.candidate), latencyMs: Date.now() - startedMs };
81
+ }
82
+ //# sourceMappingURL=rerank.js.map