graph-indexer 2.1.1 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +260 -1
- package/README.md +232 -312
- package/SECURITY.md +37 -59
- package/bin/graph-indexer.mjs +251 -0
- package/integrations/claude-code/.claude-plugin/plugin.json +8 -0
- package/integrations/claude-code/.mcp.json +8 -0
- package/integrations/claude-code/agents/code-structure.md +22 -0
- package/integrations/claude-code/hooks/gi-hook.mjs +99 -0
- package/integrations/claude-code/hooks/hooks.json +16 -0
- package/integrations/opencode/graph-indexer.js +124 -0
- package/package.json +32 -109
- package/src/cli/helper.mjs +67 -0
- package/src/cli/hook.mjs +356 -0
- package/src/cli/init.mjs +255 -0
- package/src/cli/resident.mjs +175 -0
- package/src/index/discover.mjs +130 -0
- package/src/index/fingerprint.mjs +30 -0
- package/src/index/graph.mjs +61 -0
- package/src/index/indexer.mjs +476 -0
- package/src/index/modules.mjs +331 -0
- package/src/index/resolver.mjs +954 -0
- package/src/mcp/render.mjs +60 -0
- package/src/mcp/server.mjs +147 -0
- package/src/mcp/tools.mjs +973 -0
- package/src/parse/arity.mjs +152 -0
- package/src/parse/extract.mjs +1031 -0
- package/src/parse/lang/csharp.mjs +106 -0
- package/src/parse/lang/ecma.mjs +438 -0
- package/src/parse/lang/go.mjs +194 -0
- package/src/parse/lang/jvm.mjs +118 -0
- package/src/parse/lang/kotlin.mjs +97 -0
- package/src/parse/lang/misc.mjs +115 -0
- package/src/parse/lang/native.mjs +126 -0
- package/src/parse/lang/python.mjs +153 -0
- package/src/parse/lang/rust.mjs +157 -0
- package/src/parse/lang/scripting.mjs +219 -0
- package/src/parse/languages.mjs +48 -0
- package/src/parse/runtime.mjs +56 -0
- package/src/query/check.mjs +343 -0
- package/src/query/facts.mjs +323 -0
- package/src/query/intel.mjs +680 -0
- package/src/query/read.mjs +81 -0
- package/src/query/testcmd.mjs +119 -0
- package/src/query/textsearch.mjs +160 -0
- package/src/search/search.mjs +210 -0
- package/src/search/tokenize.mjs +188 -0
- package/src/store/db.mjs +169 -0
- package/src/util/paths.mjs +24 -0
- package/vendor/grammars/bash.wasm.gz +0 -0
- package/vendor/grammars/c.wasm.gz +0 -0
- package/vendor/grammars/c_sharp.wasm.gz +0 -0
- package/vendor/grammars/cpp.wasm.gz +0 -0
- package/vendor/grammars/css.wasm.gz +0 -0
- package/vendor/grammars/go.wasm.gz +0 -0
- package/vendor/grammars/java.wasm.gz +0 -0
- package/vendor/grammars/javascript.wasm.gz +0 -0
- package/vendor/grammars/kotlin.wasm.gz +0 -0
- package/vendor/grammars/manifest.json +148 -0
- package/vendor/grammars/php.wasm.gz +0 -0
- package/vendor/grammars/python.wasm.gz +0 -0
- package/vendor/grammars/ruby.wasm.gz +0 -0
- package/vendor/grammars/rust.wasm.gz +0 -0
- package/vendor/grammars/scala.wasm.gz +0 -0
- package/vendor/grammars/tsx.wasm.gz +0 -0
- package/vendor/grammars/typescript.wasm.gz +0 -0
- package/vendor/web-tree-sitter/web-tree-sitter.js +3980 -0
- package/vendor/web-tree-sitter/web-tree-sitter.wasm +0 -0
- package/cli-ui.mjs +0 -78
- package/colbert.mjs +0 -168
- package/config.mjs +0 -494
- package/daemon-ctl.mjs +0 -175
- package/daemon-lock.mjs +0 -69
- package/embedders/python/mlx_embed_server.py +0 -61
- package/embedders/python/requirements-mlx.txt +0 -5
- package/embedders/setup-mlx.mjs +0 -110
- package/embeddings.mjs +0 -571
- package/engine/binary.mjs +0 -437
- package/engine/memory.mjs +0 -1003
- package/engine/sqlite.mjs +0 -1136
- package/enrichment.mjs +0 -521
- package/git-signals.mjs +0 -248
- package/indexer.mjs +0 -546
- package/init.mjs +0 -1809
- package/layout.mjs +0 -123
- package/mcp/centrality.mjs +0 -117
- package/mcp/format.mjs +0 -61
- package/mcp/resolver.mjs +0 -116
- package/mcp/symbolgraph.mjs +0 -137
- package/mcp/taint.mjs +0 -225
- package/mcp/tools.mjs +0 -1582
- package/mcp/topology.mjs +0 -676
- package/mcp-server.mjs +0 -197
- package/parse/extractor.mjs +0 -628
- package/parse/imports.mjs +0 -365
- package/parse/interprocedural.mjs +0 -134
- package/parse/languages.mjs +0 -253
- package/parse/metadata.mjs +0 -701
- package/parse/routes.mjs +0 -327
- package/parse/scip.mjs +0 -391
- package/parse/taint-patterns.mjs +0 -119
- package/prompts/CORE.md +0 -91
- package/prompts/DOMAIN_TEMPLATE.md +0 -46
- package/prompts/INTEGRATION.md +0 -123
- package/prompts/frameworks/ANDROID.md +0 -17
- package/prompts/frameworks/ASPNET_CORE.md +0 -19
- package/prompts/frameworks/FASTAPI_DJANGO.md +0 -20
- package/prompts/frameworks/LARAVEL_SYMFONY.md +0 -22
- package/prompts/frameworks/NODE_EXPRESS_NESTJS.md +0 -19
- package/prompts/frameworks/RAILS.md +0 -20
- package/prompts/frameworks/REACT.md +0 -20
- package/prompts/frameworks/SPRING_BOOT.md +0 -20
- package/prompts/languages/BASH.md +0 -22
- package/prompts/languages/C.md +0 -21
- package/prompts/languages/CSHARP.md +0 -24
- package/prompts/languages/CSS_SCSS.md +0 -22
- package/prompts/languages/GO.md +0 -24
- package/prompts/languages/JAVA.md +0 -23
- package/prompts/languages/JAVASCRIPT_TYPESCRIPT.md +0 -24
- package/prompts/languages/KOTLIN.md +0 -24
- package/prompts/languages/PHP.md +0 -23
- package/prompts/languages/PYTHON.md +0 -23
- package/prompts/languages/RUBY.md +0 -23
- package/prompts/languages/RUST.md +0 -21
- package/prompts/languages/SWIFT.md +0 -21
- package/seal.mjs +0 -400
- package/search-core.mjs +0 -777
- package/search-sparse.mjs +0 -183
- package/storage.mjs +0 -73
- 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
|
-
}
|