graph-indexer 2.1.1 → 3.0.1

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 (131) hide show
  1. package/CHANGELOG.md +312 -1
  2. package/README.md +236 -312
  3. package/SECURITY.md +53 -58
  4. package/bin/graph-indexer.mjs +251 -0
  5. package/integrations/claude-code/.claude-plugin/plugin.json +9 -0
  6. package/integrations/claude-code/.mcp.json +8 -0
  7. package/integrations/claude-code/agents/code-structure.md +22 -0
  8. package/integrations/claude-code/hooks/gi-hook.mjs +103 -0
  9. package/integrations/claude-code/hooks/hooks.json +16 -0
  10. package/integrations/opencode/graph-indexer.js +129 -0
  11. package/package.json +32 -109
  12. package/src/cli/helper.mjs +67 -0
  13. package/src/cli/hook.mjs +366 -0
  14. package/src/cli/init.mjs +272 -0
  15. package/src/cli/resident.mjs +185 -0
  16. package/src/index/discover.mjs +128 -0
  17. package/src/index/fingerprint.mjs +30 -0
  18. package/src/index/graph.mjs +61 -0
  19. package/src/index/indexer.mjs +476 -0
  20. package/src/index/modules.mjs +332 -0
  21. package/src/index/resolver.mjs +961 -0
  22. package/src/mcp/render.mjs +60 -0
  23. package/src/mcp/server.mjs +147 -0
  24. package/src/mcp/tools.mjs +974 -0
  25. package/src/parse/arity.mjs +153 -0
  26. package/src/parse/extract.mjs +1038 -0
  27. package/src/parse/lang/csharp.mjs +106 -0
  28. package/src/parse/lang/ecma.mjs +438 -0
  29. package/src/parse/lang/go.mjs +194 -0
  30. package/src/parse/lang/jvm.mjs +118 -0
  31. package/src/parse/lang/kotlin.mjs +97 -0
  32. package/src/parse/lang/misc.mjs +115 -0
  33. package/src/parse/lang/native.mjs +126 -0
  34. package/src/parse/lang/python.mjs +153 -0
  35. package/src/parse/lang/rust.mjs +157 -0
  36. package/src/parse/lang/scripting.mjs +219 -0
  37. package/src/parse/languages.mjs +48 -0
  38. package/src/parse/runtime.mjs +56 -0
  39. package/src/query/check.mjs +345 -0
  40. package/src/query/facts.mjs +323 -0
  41. package/src/query/intel.mjs +683 -0
  42. package/src/query/read.mjs +81 -0
  43. package/src/query/testcmd.mjs +130 -0
  44. package/src/query/textsearch.mjs +178 -0
  45. package/src/search/search.mjs +210 -0
  46. package/src/search/tokenize.mjs +188 -0
  47. package/src/store/db.mjs +169 -0
  48. package/src/util/git.mjs +30 -0
  49. package/src/util/paths.mjs +63 -0
  50. package/src/util/regex.mjs +2 -0
  51. package/vendor/grammars/bash.wasm.gz +0 -0
  52. package/vendor/grammars/c.wasm.gz +0 -0
  53. package/vendor/grammars/c_sharp.wasm.gz +0 -0
  54. package/vendor/grammars/cpp.wasm.gz +0 -0
  55. package/vendor/grammars/css.wasm.gz +0 -0
  56. package/vendor/grammars/go.wasm.gz +0 -0
  57. package/vendor/grammars/java.wasm.gz +0 -0
  58. package/vendor/grammars/javascript.wasm.gz +0 -0
  59. package/vendor/grammars/kotlin.wasm.gz +0 -0
  60. package/vendor/grammars/manifest.json +148 -0
  61. package/vendor/grammars/php.wasm.gz +0 -0
  62. package/vendor/grammars/python.wasm.gz +0 -0
  63. package/vendor/grammars/ruby.wasm.gz +0 -0
  64. package/vendor/grammars/rust.wasm.gz +0 -0
  65. package/vendor/grammars/scala.wasm.gz +0 -0
  66. package/vendor/grammars/tsx.wasm.gz +0 -0
  67. package/vendor/grammars/typescript.wasm.gz +0 -0
  68. package/vendor/web-tree-sitter/web-tree-sitter.js +3980 -0
  69. package/vendor/web-tree-sitter/web-tree-sitter.wasm +0 -0
  70. package/cli-ui.mjs +0 -78
  71. package/colbert.mjs +0 -168
  72. package/config.mjs +0 -494
  73. package/daemon-ctl.mjs +0 -175
  74. package/daemon-lock.mjs +0 -69
  75. package/embedders/python/mlx_embed_server.py +0 -61
  76. package/embedders/python/requirements-mlx.txt +0 -5
  77. package/embedders/setup-mlx.mjs +0 -110
  78. package/embeddings.mjs +0 -571
  79. package/engine/binary.mjs +0 -437
  80. package/engine/memory.mjs +0 -1003
  81. package/engine/sqlite.mjs +0 -1136
  82. package/enrichment.mjs +0 -521
  83. package/git-signals.mjs +0 -248
  84. package/indexer.mjs +0 -546
  85. package/init.mjs +0 -1809
  86. package/layout.mjs +0 -123
  87. package/mcp/centrality.mjs +0 -117
  88. package/mcp/format.mjs +0 -61
  89. package/mcp/resolver.mjs +0 -116
  90. package/mcp/symbolgraph.mjs +0 -137
  91. package/mcp/taint.mjs +0 -225
  92. package/mcp/tools.mjs +0 -1582
  93. package/mcp/topology.mjs +0 -676
  94. package/mcp-server.mjs +0 -197
  95. package/parse/extractor.mjs +0 -628
  96. package/parse/imports.mjs +0 -365
  97. package/parse/interprocedural.mjs +0 -134
  98. package/parse/languages.mjs +0 -253
  99. package/parse/metadata.mjs +0 -701
  100. package/parse/routes.mjs +0 -327
  101. package/parse/scip.mjs +0 -391
  102. package/parse/taint-patterns.mjs +0 -119
  103. package/prompts/CORE.md +0 -91
  104. package/prompts/DOMAIN_TEMPLATE.md +0 -46
  105. package/prompts/INTEGRATION.md +0 -123
  106. package/prompts/frameworks/ANDROID.md +0 -17
  107. package/prompts/frameworks/ASPNET_CORE.md +0 -19
  108. package/prompts/frameworks/FASTAPI_DJANGO.md +0 -20
  109. package/prompts/frameworks/LARAVEL_SYMFONY.md +0 -22
  110. package/prompts/frameworks/NODE_EXPRESS_NESTJS.md +0 -19
  111. package/prompts/frameworks/RAILS.md +0 -20
  112. package/prompts/frameworks/REACT.md +0 -20
  113. package/prompts/frameworks/SPRING_BOOT.md +0 -20
  114. package/prompts/languages/BASH.md +0 -22
  115. package/prompts/languages/C.md +0 -21
  116. package/prompts/languages/CSHARP.md +0 -24
  117. package/prompts/languages/CSS_SCSS.md +0 -22
  118. package/prompts/languages/GO.md +0 -24
  119. package/prompts/languages/JAVA.md +0 -23
  120. package/prompts/languages/JAVASCRIPT_TYPESCRIPT.md +0 -24
  121. package/prompts/languages/KOTLIN.md +0 -24
  122. package/prompts/languages/PHP.md +0 -23
  123. package/prompts/languages/PYTHON.md +0 -23
  124. package/prompts/languages/RUBY.md +0 -23
  125. package/prompts/languages/RUST.md +0 -21
  126. package/prompts/languages/SWIFT.md +0 -21
  127. package/seal.mjs +0 -400
  128. package/search-core.mjs +0 -777
  129. package/search-sparse.mjs +0 -183
  130. package/storage.mjs +0 -73
  131. package/watch-daemon.mjs +0 -289
package/enrichment.mjs DELETED
@@ -1,521 +0,0 @@
1
- /**
2
- * @file enrichment.mjs
3
- * @description Optional LLM-assisted semantic enrichment. Standard embeddings
4
- * match text proximity, not intent — a query like "payment webhook
5
- * bottleneck" misses code that never uses those words. This module
6
- * routes the codebase's most central chunks (top PageRank files)
7
- * through a local LLM to produce two enrichment fields per chunk:
8
- *
9
- * • chunk.summary — one declarative sentence in developer vocabulary,
10
- * added as the leading field of the embedding payload so the vector
11
- * is anchored toward query vocabulary (not just code syntax).
12
- *
13
- * • chunk.concepts — domain keyword array (e.g. ["authentication",
14
- * "JWT", "middleware"]), joined into chunk.hyde for BM25 indexing.
15
- *
16
- * Only the top-PageRank subset of chunks is enriched (hard-capped).
17
- * Generation is injectable for deterministic testing without a live model.
18
- * @author MaquinaTech <https://github.com/MaquinaTech>
19
- * @copyright (c) 2026 MaquinaTech. All rights reserved.
20
- * @license MIT
21
- */
22
- import fs from 'fs';
23
- import path from 'path';
24
- import { computePageRank, truncateForEmbedding, TEST_FILE_RE, EXAMPLE_DIR_RE } from './search-core.mjs';
25
-
26
- // ─── Persistent enrichment cache ───────────────────────────────────────────────
27
- // Keyed by content_hash: the same code always yields the same (cached) summary
28
- // and concepts, which makes enrichment INCREMENTAL — a re-index only sends new
29
- // or changed chunks to the LLM, and the embedding cache (keyed by content_hash +
30
- // enrichment digest, see search-core.embeddingKeyFor) keeps hitting too. Without
31
- // this cache every index run re-enriched and re-embedded the full core set.
32
-
33
- /** Load `code-index.enrichment.json` → Map<content_hash, {summary, concepts, model}>. */
34
- export function loadEnrichmentCache(cachePath) {
35
- try {
36
- if (fs.existsSync(cachePath)) {
37
- const data = JSON.parse(fs.readFileSync(cachePath, 'utf-8'));
38
- return new Map(Object.entries(data.entries || data));
39
- }
40
- } catch { /* corrupt cache → start fresh */ }
41
- return new Map();
42
- }
43
-
44
- /** Persist the enrichment cache (atomic write via tmp + rename). */
45
- export function saveEnrichmentCache(cachePath, cache) {
46
- const payload = JSON.stringify({ version: 1, entries: Object.fromEntries(cache) });
47
- const tmp = `${cachePath}.tmp`;
48
- fs.writeFileSync(tmp, payload);
49
- fs.renameSync(tmp, cachePath);
50
- }
51
-
52
- /** Attach a cached enrichment entry to a chunk (summary, concepts, hyde). */
53
- export function attachEnrichment(chunk, entry) {
54
- if (!entry) return false;
55
- chunk.summary = entry.summary || '';
56
- chunk.concepts = Array.isArray(entry.concepts) ? entry.concepts : [];
57
- chunk.hyde = chunk.concepts.join(' ') || chunk.summary;
58
- return Boolean(chunk.summary || chunk.concepts.length);
59
- }
60
-
61
- // Single-line SUMMARY keeps parseEnrichResponse trivial regardless of model capability.
62
- // One-shot example is mandatory for small (1.5B) models to follow the format.
63
- // TAGS → chunk.hyde gives BM25 high-IDF domain terms instead of question stopword noise.
64
- export const buildEnrichPrompt = (chunk) => {
65
- const code = (chunk.code_snippet || '').slice(0, 1000);
66
- const base = path.basename(chunk.file_path);
67
- const ctx = chunk.class_context ? ` (member of ${chunk.class_context})` : '';
68
- const doc = chunk.docstring ? `Doc: ${chunk.docstring.slice(0, 240)}\n` : '';
69
- return (
70
- `You are indexing source code for semantic search. A developer will search by INTENT\n`
71
- + `("where is the code that …", "how does it …") in plain words — NOT by the symbol name.\n\n`
72
- + `Describe this ${chunk.node_type} "${chunk.name}"${ctx} in ${base} as exactly two lines:\n`
73
- + `SUMMARY: one or two sentences on a SINGLE line, in the vocabulary a developer would\n`
74
- + ` search with, covering: WHAT IT DOES and the DOMAIN it acts on; the PROBLEM it solves;\n`
75
- + ` its key INPUTS and OUTPUTS; and any notable SIDE EFFECTS (I/O, state mutation, network\n`
76
- + ` calls, errors thrown). Use concrete domain nouns and action verbs. Do NOT restate the\n`
77
- + ` symbol name and do NOT use filler words like "function", "method", "class", "handles",\n`
78
- + ` "manages", or "responsible for".\n`
79
- + `TAGS: 5-8 comma-separated domain keywords a developer would type to find this code.\n\n`
80
- + `Example:\n`
81
- + `SUMMARY: validates JWT bearer tokens against the signing key and refreshes expired sessions, taking the incoming request's Authorization header and returning the decoded principal, rejecting tampered or expired tokens with a 401 so downstream handlers only ever see authenticated requests\n`
82
- + `TAGS: authentication, JWT, token validation, session refresh, middleware, authorization\n\n`
83
- + doc
84
- + `Code:\n${code}\n`
85
- + `Output:`
86
- );
87
- };
88
-
89
- // Preference ladder for `enrichment.model: "auto"` — pick the strongest code-aware
90
- // model the local Ollama actually has pulled, falling back to the fast 1.5B floor.
91
- // Enrichment is offline and cached, so a heavier model here costs nothing at query
92
- // time; "auto" means "use the best you have" without the user naming it.
93
- const ENRICH_MODEL_FLOOR = 'qwen2.5-coder:1.5b';
94
- const ENRICH_MODEL_PREFERENCE = [
95
- 'qwen2.5-coder:7b', 'qwen2.5-coder:3b', 'deepseek-coder-v2', 'qwen2.5-coder:1.5b',
96
- 'codellama:13b', 'codellama:7b',
97
- ];
98
-
99
- /**
100
- * Resolve the enrichment model. A concrete name is returned as-is; "auto" probes
101
- * the Ollama model list and returns the strongest available code model (then any
102
- * coder/code model, then the 1.5B floor). Best-effort and non-throwing — an
103
- * unreachable Ollama yields the floor (enrichment then no-ops gracefully anyway).
104
- *
105
- * @param {string} model Configured model, or "auto".
106
- * @param {string} ollamaHost
107
- * @param {{timeoutMs?:number, fetchImpl?:typeof fetch}} [opts] fetchImpl injectable for tests.
108
- */
109
- export async function resolveEnrichModel(model, ollamaHost, { timeoutMs = 2000, fetchImpl = fetch } = {}) {
110
- if (model && model !== 'auto') return model;
111
- try {
112
- const res = await fetchImpl(`${ollamaHost}/api/tags`, { signal: AbortSignal.timeout(timeoutMs) });
113
- if (!res.ok) return ENRICH_MODEL_FLOOR;
114
- const names = ((await res.json()).models || []).map(m => m.name);
115
- const has = (p) => names.some(n => n === p || n === `${p}:latest` || n.startsWith(`${p}:`) || n.split(':')[0] === p);
116
- for (const pref of ENRICH_MODEL_PREFERENCE) if (has(pref)) return pref;
117
- const coder = names.find(n => /coder|code/i.test(n));
118
- return coder || ENRICH_MODEL_FLOOR;
119
- } catch { return ENRICH_MODEL_FLOOR; }
120
- }
121
-
122
- /**
123
- * Default generator: a single non-streaming Ollama /api/generate call. Returns the
124
- * response text, or null on any failure (enrichment is best-effort and never fatal).
125
- */
126
- export async function ollamaGenerate(prompt, { model, ollamaHost, timeoutMs = 30000, options = null } = {}) {
127
- try {
128
- const res = await fetch(`${ollamaHost}/api/generate`, {
129
- method: 'POST',
130
- headers: { 'Content-Type': 'application/json' },
131
- body: JSON.stringify({
132
- model, prompt, stream: false,
133
- options: options || { temperature: 0.1, num_predict: 150 },
134
- }),
135
- signal: AbortSignal.timeout(timeoutMs),
136
- });
137
- if (!res.ok) throw new Error(`HTTP ${res.status}`);
138
- const data = await res.json();
139
- return (data.response || '').trim() || null;
140
- } catch {
141
- return null;
142
- }
143
- }
144
-
145
- /**
146
- * MLX-LM generator: calls a local mlx_lm.server (OpenAI-compatible /v1/completions).
147
- * Start the server with: mlx_lm.server --model <model-name> [--port 8080]
148
- * Returns the completion text, or null on any failure.
149
- */
150
- export async function mlxLmGenerate(prompt, { mlxLmHost = 'http://localhost:8080', timeoutMs = 60000 } = {}) {
151
- try {
152
- const res = await fetch(`${mlxLmHost}/v1/completions`, {
153
- method: 'POST',
154
- headers: { 'Content-Type': 'application/json' },
155
- body: JSON.stringify({ prompt, max_tokens: 150, temperature: 0.1 }),
156
- signal: AbortSignal.timeout(timeoutMs),
157
- });
158
- if (!res.ok) throw new Error(`HTTP ${res.status}`);
159
- const data = await res.json();
160
- return (data.choices?.[0]?.text || '').trim() || null;
161
- } catch {
162
- return null;
163
- }
164
- }
165
-
166
- /**
167
- * Parse the SUMMARY + TAGS response into { summary, concepts, hyde }.
168
- *
169
- * Concepts are domain terms only (stopwords scrubbed) so every token has
170
- * meaningful IDF weight. Falls back to extracting non-stopword keywords from
171
- * the summary when the model omits the TAGS line (graceful degradation).
172
- */
173
- export function parseEnrichResponse(text) {
174
- if (!text) return null;
175
- const lines = text.split('\n').map(l => l.trim()).filter(Boolean);
176
- let summary = '';
177
- const concepts = [];
178
-
179
- const _stop = new Set([
180
- 'the', 'a', 'an', 'this', 'that', 'these', 'those', 'is', 'are', 'was', 'were', 'be',
181
- 'been', 'have', 'has', 'had', 'do', 'does', 'did', 'will', 'would', 'could', 'should',
182
- 'to', 'of', 'in', 'for', 'on', 'with', 'at', 'by', 'from', 'as', 'it', 'its', 'and', 'or',
183
- 'not', 'no', 'so', 'if', 'but', 'how', 'what', 'when', 'where', 'which', 'who',
184
- ]);
185
-
186
- for (const line of lines) {
187
- const sm = line.match(/^SUMMARY:\s*(.+)$/i);
188
- if (sm) { summary = sm[1].trim(); continue; }
189
- const tm = line.match(/^TAGS?:\s*(.+)$/i);
190
- if (tm) {
191
- tm[1].split(',').forEach(t => {
192
- const tok = t.trim().toLowerCase();
193
- if (tok.length >= 2 && tok.length <= 40 && !_stop.has(tok)) concepts.push(tok);
194
- });
195
- }
196
- }
197
-
198
- if (!summary && lines.length) summary = lines[0].replace(/^SUMMARY:\s*/i, '').trim();
199
-
200
-
201
- if (concepts.length === 0 && summary) {
202
- const words = summary.toLowerCase().match(/\b[a-z][a-z-]{2,}\b/g) || [];
203
- concepts.push(...words.filter(w => !_stop.has(w)).slice(0, 8));
204
- }
205
-
206
- if (!summary && concepts.length === 0) return null;
207
- const hyde = concepts.join(' ') || summary;
208
- return { summary, concepts, hyde };
209
- }
210
-
211
- // ─── LLM reranking (query time, opt-in) ─────────────────────────────────────────
212
- // Fusion resolves most queries, but natural-language queries can end in near-ties
213
- // between the semantically right chunk and a lexically similar neighbour — a gap
214
- // no static boost can close. Reranking shows the fused top-M to a local LLM with
215
- // one line of context per candidate and lets it order them. One generation call
216
- // per query (~0.5–2 s with a 1.5B model), so it is opt-in and only fires for
217
- // natural-language queries.
218
-
219
- /** One-line description of a chunk for the rerank prompt. (A/B-tested against a
220
- * richer 3-line variant with signature + a wider window: the compact form wins —
221
- * extra context and extra candidates both ADD noise for a small judge model.) */
222
- function rerankCandidateLine(i, chunk) {
223
- const desc = chunk.summary
224
- || (chunk.docstring || '').split('\n')[0].slice(0, 110)
225
- || (chunk.code_snippet || '').split('\n')[0].slice(0, 110);
226
- const ctx = chunk.class_context ? `${chunk.class_context}.` : '';
227
- return `${i + 1}. ${ctx}${chunk.name} [${chunk.node_type}] in ${chunk.file_path} — ${desc}`;
228
- }
229
-
230
- export function buildRerankPrompt(queryText, chunks) {
231
- return (
232
- `A developer searches a codebase for:\n"${queryText}"\n\n`
233
- + `Candidates:\n${chunks.map((c, i) => rerankCandidateLine(i, c)).join('\n')}\n\n`
234
- + `Order the candidates from best to worst match for the search. `
235
- + `Output ONLY the numbers, comma-separated (e.g. 3,1,2). No other text.\n`
236
- + `Answer:`
237
- );
238
- }
239
-
240
- /** Parse "3, 1,2 ..." → unique 0-based indices < n, or null when unusable. */
241
- export function parseRerankResponse(text, n) {
242
- if (!text) return null;
243
- const seen = new Set();
244
- const order = [];
245
- for (const m of String(text).matchAll(/\d+/g)) {
246
- const idx = Number(m[0]) - 1;
247
- if (idx >= 0 && idx < n && !seen.has(idx)) { seen.add(idx); order.push(idx); }
248
- if (order.length >= n) break;
249
- }
250
- return order.length > 0 ? order : null;
251
- }
252
-
253
- /**
254
- * Rerank the fused top-M results with a local LLM: the judge's order wins outright
255
- * for the head, the unjudged tail is preserved. Best-effort — any failure (model
256
- * unreachable, unparseable output) returns the original order untouched.
257
- *
258
- * A reciprocal-rank BLEND of judge-order with retrieval-order was A/B-tested (to
259
- * anchor strongly-retrieved hits against judge mistakes) and measured WORSE on
260
- * both rank-1 and MRR while not recovering recall@k — so the straight reorder is
261
- * what ships. Recall@k is protected upstream instead, by over-fetching a deep pool
262
- * before reranking (see mcp/tools.mjs search_code) so the judge reorders real
263
- * candidates rather than a thin top_k.
264
- *
265
- * @param {string} queryText
266
- * @param {Array<{score:number, chunk:object}>} results Fused results (copy returned).
267
- * @param {object} opts
268
- * @param {(prompt:string)=>Promise<string|null>} opts.generate
269
- * @param {number} [opts.topM] How many leading results to rerank (default 8).
270
- * @returns {Promise<Array<{score:number, chunk:object}>>}
271
- */
272
- export async function rerankResults(queryText, results, { generate, topM = 8 } = {}) {
273
- const head = results.slice(0, topM);
274
- if (head.length < 2) return results;
275
- const raw = await generate(buildRerankPrompt(queryText, head.map(r => r.chunk)));
276
- const order = parseRerankResponse(raw, head.length);
277
- if (!order) return results;
278
- const picked = order.map(i => head[i]);
279
- const rest = head.filter((_, i) => !order.includes(i));
280
- return [...picked, ...rest, ...results.slice(topM)];
281
- }
282
-
283
- // ─── Cross-encoder reranker (local, air-gapped, deterministic) ───────────────
284
- // A second, opt-in reranker provider (rerank.provider === 'cross-encoder'). Unlike the
285
- // generative judge — which prompts an LLM for a permutation — a cross-encoder SCORES each
286
- // (query, candidate) PAIR with a small sequence-classification model and sorts by score.
287
- // It is local (runs through the optional @huggingface/transformers package, the same dep as
288
- // the in-process 'local' embedder), needs no Ollama/daemon, is deterministic, and is fast
289
- // (~tens of ms for a top-12 pool on CPU). It sees only a candidate's signature/summary line,
290
- // not full code — so it sharpens lexical-vs-semantic near-ties rather than reasoning about
291
- // behaviour the way a 7B judge can. The generative judge stays as the higher-reasoning tier.
292
-
293
- /**
294
- * One-line textual representation of a chunk to pair against the query. Mirrors the
295
- * generative judge's candidate line (name + class + summary→docstring→snippet) so both
296
- * rerankers judge comparable evidence.
297
- */
298
- export function crossEncoderCandidateText(chunk) {
299
- const ctx = chunk.class_context ? `${chunk.class_context}.` : '';
300
- const desc = chunk.summary
301
- || (chunk.docstring || '').split('\n')[0]
302
- || (chunk.code_snippet || '').split('\n').slice(0, 3).join(' ');
303
- return `${ctx}${chunk.name || ''} — ${(desc || '').slice(0, 300)}`;
304
- }
305
-
306
- // Lazy, module-level singleton model+tokenizer (mirrors embeddings.mjs:_localPipeline). The
307
- // import is inside the function so the default path never loads @huggingface/transformers.
308
- let _xenc = null, _xencModel = null;
309
- async function _crossEncoderPipeline(model) {
310
- if (_xenc && _xencModel === model) return _xenc;
311
- let mod;
312
- try { mod = await import('@huggingface/transformers'); }
313
- catch {
314
- throw new Error(
315
- "Cross-encoder rerank needs the optional '@huggingface/transformers' package. "
316
- + "Install it (`npm run embed:setup:local`, or `npm i @huggingface/transformers`) "
317
- + "or set rerank.provider to 'generative'."
318
- );
319
- }
320
- const m = await mod.AutoModelForSequenceClassification.from_pretrained(model, { quantized: true });
321
- const t = await mod.AutoTokenizer.from_pretrained(model);
322
- _xenc = { model: m, tokenizer: t };
323
- _xencModel = model;
324
- return _xenc;
325
- }
326
-
327
- /** Reset the cached cross-encoder pipeline (tests). */
328
- export function _resetCrossEncoderPipeline() { _xenc = null; _xencModel = null; }
329
-
330
- /**
331
- * Score each candidate text against the query with the cross-encoder. Returns one
332
- * relevance float per text, in input order (higher = more relevant).
333
- *
334
- * @param {string} query
335
- * @param {string[]} texts
336
- * @param {object} [opts]
337
- * @param {string} [opts.model] HF model id (default a MS-MARCO MiniLM cross-encoder).
338
- * @returns {Promise<number[]>}
339
- */
340
- export async function crossEncoderScore(query, texts, { model = 'Xenova/ms-marco-MiniLM-L-6-v2' } = {}) {
341
- if (!texts || texts.length === 0) return [];
342
- const { model: m, tokenizer: t } = await _crossEncoderPipeline(model);
343
- const inputs = t(Array(texts.length).fill(query), { text_pair: texts, padding: true, truncation: true });
344
- const { logits } = await m(inputs);
345
- return logits.tolist().map(row => row[0]);
346
- }
347
-
348
- /**
349
- * Cross-encoder rerank: score the leading topM candidates as (query, text) pairs, sort
350
- * DESC by score with ties broken on chunk id (so the reorder is deterministic and both
351
- * backends rank identically), and preserve the unscored tail. Best-effort — any scorer
352
- * failure or malformed output returns the original order untouched. Never mutates the
353
- * fused `score` on a result (the git-boost branch downstream relies on that).
354
- *
355
- * @param {string} queryText
356
- * @param {Array<{score:number, chunk:object}>} results Fused results (copy returned).
357
- * @param {object} opts
358
- * @param {(query:string, texts:string[])=>Promise<number[]>} opts.scorer Injectable (tests).
359
- * @param {number} [opts.topM] How many leading results to rerank (default 12).
360
- * @returns {Promise<Array<{score:number, chunk:object}>>}
361
- */
362
- export async function rerankCrossEncoder(queryText, results, { scorer, topM = 12 } = {}) {
363
- const head = results.slice(0, topM);
364
- if (head.length < 2) return results;
365
- let scores;
366
- try { scores = await scorer(queryText, head.map(r => crossEncoderCandidateText(r.chunk))); }
367
- catch { return results; }
368
- if (!Array.isArray(scores) || scores.length !== head.length || scores.some(s => !Number.isFinite(s))) {
369
- return results;
370
- }
371
- const order = head
372
- .map((r, i) => ({ i, s: scores[i], id: r.chunk.id }))
373
- .sort((a, b) => (b.s - a.s) || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
374
- .map(x => x.i);
375
- return [...order.map(i => head[i]), ...results.slice(topM)];
376
- }
377
-
378
- /**
379
- * Select the chunks worth enriching.
380
- *
381
- * v1 selected the top-PageRank files only — and measured terribly: PageRank
382
- * rewards the most-IMPORTED files, which are generic leaf utilities (utils.js,
383
- * bind.js), while the behavioural code agents actually search for conceptually
384
- * (request dispatchers, interceptor managers, adapters) sits mid-graph and was
385
- * never enriched. v2 selects ALL substantive chunks in production source —
386
- * tests, specs and example/docs trees are excluded, since agents search for
387
- * implementations — and uses PageRank only to ORDER the queue, so the per-run
388
- * `maxChunks` cap eats the most central files first. Combined with the
389
- * persistent cache, coverage converges over a few index runs at a bounded
390
- * per-run cost.
391
- *
392
- * `coreRatio` (default 1.0 = all production files) is kept for installs that
393
- * want to bound enrichment to the most central share of files.
394
- */
395
- export function selectCoreChunks(chunks, graph, { coreRatio = 1.0, maxChunks = 500 } = {}) {
396
- const pr = computePageRank(graph);
397
- let coreFiles = null;
398
- if (coreRatio > 0 && coreRatio < 1) {
399
- const files = Array.from(pr.keys()).sort((a, b) => (pr.get(b) ?? 0) - (pr.get(a) ?? 0));
400
- coreFiles = new Set(files.slice(0, Math.max(1, Math.ceil(files.length * coreRatio))));
401
- }
402
-
403
- const selected = chunks.filter(c =>
404
- c.name && c.name !== 'anonymous' && c.name !== 'default_export'
405
- && (c.end_line - c.start_line) >= 4 // skip trivial stubs (too little code for useful enrichment)
406
- && !TEST_FILE_RE.test(c.file_path) // agents search implementations, not test bodies
407
- && !EXAMPLE_DIR_RE.test(c.file_path)
408
- && (!coreFiles || coreFiles.has(c.file_path))
409
- );
410
- selected.sort((a, b) => (pr.get(b.file_path) ?? 0) - (pr.get(a.file_path) ?? 0));
411
- return selected.slice(0, maxChunks);
412
- }
413
-
414
- /**
415
- * Enrich the core chunks in place, attaching `chunk.hyde` and `chunk.summary`.
416
- *
417
- * Uses a true sliding-window pool: each worker immediately picks the next task
418
- * when done, so concurrency is always saturated rather than waiting for the
419
- * slowest request in a batch before advancing.
420
- *
421
- * @param {object[]} chunks All extracted chunks (mutated in place).
422
- * @param {object} graph { dependencies, importedBy }.
423
- * @param {object} config Resolved config (uses config.enrichment + config.ollamaHost).
424
- * @param {object} [deps]
425
- * @param {(prompt:string)=>Promise<string|null>} [deps.generate] Override generator (tests).
426
- * @param {number} [deps.concurrency] Defaults to config.enrichment.concurrency.
427
- * @param {string} [deps.cachePath] Defaults to config.enrichmentCachePath. Pass false to disable.
428
- * @returns {Promise<{enriched:number, attempted:number, cached:number}>}
429
- */
430
- export async function enrichCoreChunks(chunks, graph, config, { generate, concurrency, cachePath } = {}) {
431
- const { coreRatio, maxChunks } = config.enrichment;
432
- const ollamaHost = config.ollamaHost;
433
- const mlxLmHost = config.mlxLmHost;
434
- const useMlx = config.llmProvider === 'mlx';
435
- // Resolve "auto" → the strongest code model the local Ollama has (1.5B floor).
436
- // A pinned generate (tests) skips the network probe — model is only used to
437
- // build the default generator and to stamp cache provenance.
438
- const model = generate ? config.enrichment.model
439
- : (useMlx ? config.enrichment.model : await resolveEnrichModel(config.enrichment.model, ollamaHost));
440
- // 60 s timeout: a larger judge model on a busy/CPU-bound local Ollama can take
441
- // 10–25 s per generation; the old 20 s cap made every call fail there and aborted
442
- // enrichment entirely. Concurrency defaults low for the same reason — parallel
443
- // requests to one local model thrash and slow each other (set higher only when
444
- // OLLAMA_NUM_PARALLEL and the hardware support it).
445
- const gen = generate
446
- || (useMlx
447
- ? (prompt) => mlxLmGenerate(prompt, { mlxLmHost, timeoutMs: 60000 })
448
- : (prompt) => ollamaGenerate(prompt, { model, ollamaHost, timeoutMs: 60000 }));
449
- const slots = concurrency ?? config.enrichment.concurrency ?? 12;
450
- const cacheFile = cachePath === false ? null : (cachePath || config.enrichmentCachePath || null);
451
- const cache = cacheFile ? loadEnrichmentCache(cacheFile) : new Map();
452
-
453
- // Cache pass FIRST, over ALL chunks — not just the current selection. The
454
- // selection rules (test/example filters, coreRatio, caps) evolve between
455
- // versions and runs; if cached enrichment were only attached to selected
456
- // chunks, the same code would carry an enriched embedding key in one build
457
- // and a plain one in the next, silently desynchronising the JSON and SQLite
458
- // artifacts that share one embeddings bin. Cache membership is the single
459
- // deterministic source of truth for "is this chunk enriched"; selection only
460
- // decides which NEW chunks get LLM calls.
461
- let cached = 0;
462
- for (const chunk of chunks) {
463
- if (chunk.content_hash && attachEnrichment(chunk, cache.get(chunk.content_hash))) cached++;
464
- }
465
-
466
- const core = selectCoreChunks(chunks, graph, { coreRatio, maxChunks });
467
- if (core.length === 0 && cached === 0) {
468
- console.log('\n 🧠 LLM enrichment: no core chunks selected (empty graph) — skipping.');
469
- return { enriched: 0, attempted: 0, cached: 0 };
470
- }
471
- const pending = core.filter(c => !c.summary && !(c.concepts?.length > 0));
472
-
473
- if (pending.length === 0) {
474
- console.log(`\n 🧠 LLM enrichment: all ${cached} core chunks served from cache.`);
475
- return { enriched: cached, attempted: 0, cached };
476
- }
477
-
478
- console.log(`\n 🧠 LLM enrichment: ${pending.length} chunks via ${model} (${cached} from cache, concurrency: ${slots}) …`);
479
- let enriched = 0, attempted = 0, failures = 0;
480
- let aborted = false;
481
-
482
-
483
- let cursor = 0;
484
- async function runWorker() {
485
- while (cursor < pending.length && !aborted) {
486
- const chunk = pending[cursor++];
487
- attempted++;
488
- const raw = await gen(buildEnrichPrompt(chunk));
489
- const parsed = parseEnrichResponse(raw);
490
- if (parsed) {
491
- chunk.summary = parsed.summary;
492
- chunk.concepts = parsed.concepts;
493
- chunk.hyde = parsed.hyde;
494
- if (chunk.content_hash) {
495
- cache.set(chunk.content_hash, { summary: parsed.summary, concepts: parsed.concepts, model });
496
- }
497
- enriched++;
498
- } else {
499
- failures++;
500
- }
501
- process.stdout.write(`\r enriched ${enriched}/${pending.length} (failures: ${failures}) `);
502
- // Bail early if the model is clearly unreachable. Require a real minimum
503
- // of attempts (not just slots×2) so a slow first generation at low
504
- // concurrency doesn't abort a perfectly healthy run.
505
- if (attempted >= Math.max(8, slots * 2) && enriched === 0) {
506
- aborted = true;
507
- console.log(`\n⚠️ LLM enrichment: generator unreachable (${failures} failures) — continuing without enrichment.`);
508
- }
509
- }
510
- }
511
-
512
- await Promise.all(Array.from({ length: Math.min(slots, pending.length) }, runWorker));
513
-
514
- if (cacheFile && enriched > 0) {
515
- try { saveEnrichmentCache(cacheFile, cache); }
516
- catch (err) { console.log(`⚠️ enrichment cache save failed: ${err.message}`); }
517
- }
518
-
519
- console.log(`\n ✓ enrichment complete: ${enriched} enriched, ${cached} cached, ${failures} failed.`);
520
- return { enriched: enriched + cached, attempted, cached };
521
- }