graph-indexer 1.1.0 → 1.2.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/PROMPT.md +182 -143
- package/README.md +338 -80
- package/config.mjs +118 -0
- package/core-engine.mjs +631 -188
- package/enrichment.mjs +347 -0
- package/indexer.mjs +101 -71
- package/init.mjs +4 -2
- package/mcp-server.mjs +83 -569
- package/mcp-tools.mjs +545 -0
- package/package.json +16 -7
- package/parser-utils.mjs +319 -10
- package/search-core.mjs +416 -0
- package/sqlite-store.mjs +768 -0
- package/storage.mjs +43 -0
- package/watch-daemon.mjs +156 -67
package/enrichment.mjs
ADDED
|
@@ -0,0 +1,347 @@
|
|
|
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
|
+
// Two-line output format optimised for small (1.5B–3B) models:
|
|
62
|
+
// • SUMMARY — declarative sentence in developer vocabulary; goes into the vector
|
|
63
|
+
// embedding as the leading field, aligned with nomic-embed-text's
|
|
64
|
+
// search_document: training objective.
|
|
65
|
+
// • TAGS — comma-separated domain keywords; joined into chunk.hyde so BM25
|
|
66
|
+
// receives high-IDF concept terms instead of question stopword noise.
|
|
67
|
+
//
|
|
68
|
+
// One-shot example is mandatory for 1.5B models to reliably follow the format.
|
|
69
|
+
export const buildEnrichPrompt = (chunk) => {
|
|
70
|
+
const code = (chunk.code_snippet || '').slice(0, 700);
|
|
71
|
+
const base = path.basename(chunk.file_path);
|
|
72
|
+
const ctx = chunk.class_context ? ` (method of class ${chunk.class_context})` : '';
|
|
73
|
+
const doc = chunk.docstring ? `Doc: ${chunk.docstring.slice(0, 200)}\n` : '';
|
|
74
|
+
return (
|
|
75
|
+
`Output exactly two lines for this ${chunk.node_type} "${chunk.name}"${ctx} in ${base}:\n`
|
|
76
|
+
+ `SUMMARY: one sentence describing what it DOES in plain developer vocabulary (the behavior, not the syntax)\n`
|
|
77
|
+
+ `TAGS: 5-8 comma-separated search keywords a developer would type to find this\n\n`
|
|
78
|
+
+ `Example:\n`
|
|
79
|
+
+ `SUMMARY: validates JWT tokens and refreshes expired authentication sessions\n`
|
|
80
|
+
+ `TAGS: authentication, JWT, token validation, session refresh, middleware\n\n`
|
|
81
|
+
+ doc
|
|
82
|
+
+ `Code:\n${code}\n`
|
|
83
|
+
+ `Output:`
|
|
84
|
+
);
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Default generator: a single non-streaming Ollama /api/generate call. Returns the
|
|
89
|
+
* response text, or null on any failure (enrichment is best-effort and never fatal).
|
|
90
|
+
*/
|
|
91
|
+
export async function ollamaGenerate(prompt, { model, ollamaHost, timeoutMs = 30000, options = null } = {}) {
|
|
92
|
+
try {
|
|
93
|
+
const res = await fetch(`${ollamaHost}/api/generate`, {
|
|
94
|
+
method: 'POST',
|
|
95
|
+
headers: { 'Content-Type': 'application/json' },
|
|
96
|
+
body: JSON.stringify({
|
|
97
|
+
model, prompt, stream: false,
|
|
98
|
+
options: options || { temperature: 0.1, num_predict: 150 },
|
|
99
|
+
}),
|
|
100
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
101
|
+
});
|
|
102
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
103
|
+
const data = await res.json();
|
|
104
|
+
return (data.response || '').trim() || null;
|
|
105
|
+
} catch {
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Parse the SUMMARY + TAGS response into { summary, concepts, hyde }.
|
|
112
|
+
*
|
|
113
|
+
* hyde = concepts.join(' ') — a space-separated keyword string fed to BM25.
|
|
114
|
+
* Concepts are domain terms only (stopwords scrubbed), so every token has
|
|
115
|
+
* meaningful IDF weight. Falls back to extracting non-stopword keywords from
|
|
116
|
+
* the summary when the model omits the TAGS line (graceful degradation).
|
|
117
|
+
*/
|
|
118
|
+
export function parseEnrichResponse(text) {
|
|
119
|
+
if (!text) return null;
|
|
120
|
+
const lines = text.split('\n').map(l => l.trim()).filter(Boolean);
|
|
121
|
+
let summary = '';
|
|
122
|
+
const concepts = [];
|
|
123
|
+
|
|
124
|
+
const _stop = new Set([
|
|
125
|
+
'the', 'a', 'an', 'this', 'that', 'these', 'those', 'is', 'are', 'was', 'were', 'be',
|
|
126
|
+
'been', 'have', 'has', 'had', 'do', 'does', 'did', 'will', 'would', 'could', 'should',
|
|
127
|
+
'to', 'of', 'in', 'for', 'on', 'with', 'at', 'by', 'from', 'as', 'it', 'its', 'and', 'or',
|
|
128
|
+
'not', 'no', 'so', 'if', 'but', 'how', 'what', 'when', 'where', 'which', 'who',
|
|
129
|
+
]);
|
|
130
|
+
|
|
131
|
+
for (const line of lines) {
|
|
132
|
+
const sm = line.match(/^SUMMARY:\s*(.+)$/i);
|
|
133
|
+
if (sm) { summary = sm[1].trim(); continue; }
|
|
134
|
+
const tm = line.match(/^TAGS?:\s*(.+)$/i);
|
|
135
|
+
if (tm) {
|
|
136
|
+
tm[1].split(',').forEach(t => {
|
|
137
|
+
const tok = t.trim().toLowerCase();
|
|
138
|
+
if (tok.length >= 2 && tok.length <= 40 && !_stop.has(tok)) concepts.push(tok);
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (!summary && lines.length) summary = lines[0].replace(/^SUMMARY:\s*/i, '').trim();
|
|
144
|
+
|
|
145
|
+
// Fallback: derive concepts from the summary when TAGS line is absent or empty.
|
|
146
|
+
if (concepts.length === 0 && summary) {
|
|
147
|
+
const words = summary.toLowerCase().match(/\b[a-z][a-z-]{2,}\b/g) || [];
|
|
148
|
+
concepts.push(...words.filter(w => !_stop.has(w)).slice(0, 8));
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (!summary && concepts.length === 0) return null;
|
|
152
|
+
const hyde = concepts.join(' ') || summary;
|
|
153
|
+
return { summary, concepts, hyde };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ─── LLM reranking (query time, opt-in) ─────────────────────────────────────────
|
|
157
|
+
// Fusion resolves most queries, but natural-language queries can end in near-ties
|
|
158
|
+
// between the semantically right chunk and a lexically similar neighbour — a gap
|
|
159
|
+
// no static boost can close. Reranking shows the fused top-M to a local LLM with
|
|
160
|
+
// one line of context per candidate and lets it order them. One generation call
|
|
161
|
+
// per query (~0.5–2 s with a 1.5B model), so it is opt-in and only fires for
|
|
162
|
+
// natural-language queries.
|
|
163
|
+
|
|
164
|
+
/** One-line description of a chunk for the rerank prompt. (A/B-tested against a
|
|
165
|
+
* richer 3-line variant with signature + a wider window: the compact form wins —
|
|
166
|
+
* extra context and extra candidates both ADD noise for a small judge model.) */
|
|
167
|
+
function rerankCandidateLine(i, chunk) {
|
|
168
|
+
const desc = chunk.summary
|
|
169
|
+
|| (chunk.docstring || '').split('\n')[0].slice(0, 110)
|
|
170
|
+
|| (chunk.code_snippet || '').split('\n')[0].slice(0, 110);
|
|
171
|
+
const ctx = chunk.class_context ? `${chunk.class_context}.` : '';
|
|
172
|
+
return `${i + 1}. ${ctx}${chunk.name} [${chunk.node_type}] in ${chunk.file_path} — ${desc}`;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function buildRerankPrompt(queryText, chunks) {
|
|
176
|
+
return (
|
|
177
|
+
`A developer searches a codebase for:\n"${queryText}"\n\n`
|
|
178
|
+
+ `Candidates:\n${chunks.map((c, i) => rerankCandidateLine(i, c)).join('\n')}\n\n`
|
|
179
|
+
+ `Order the candidates from best to worst match for the search. `
|
|
180
|
+
+ `Output ONLY the numbers, comma-separated (e.g. 3,1,2). No other text.\n`
|
|
181
|
+
+ `Answer:`
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Parse "3, 1,2 ..." → unique 0-based indices < n, or null when unusable. */
|
|
186
|
+
export function parseRerankResponse(text, n) {
|
|
187
|
+
if (!text) return null;
|
|
188
|
+
const seen = new Set();
|
|
189
|
+
const order = [];
|
|
190
|
+
for (const m of String(text).matchAll(/\d+/g)) {
|
|
191
|
+
const idx = Number(m[0]) - 1;
|
|
192
|
+
if (idx >= 0 && idx < n && !seen.has(idx)) { seen.add(idx); order.push(idx); }
|
|
193
|
+
if (order.length >= n) break;
|
|
194
|
+
}
|
|
195
|
+
return order.length > 0 ? order : null;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Rerank the fused top-M results with a local LLM. Best-effort: any failure
|
|
200
|
+
* (model unreachable, unparseable output) returns the original order.
|
|
201
|
+
*
|
|
202
|
+
* @param {string} queryText
|
|
203
|
+
* @param {Array<{score:number, chunk:object}>} results Fused results (mutated copy returned).
|
|
204
|
+
* @param {object} opts
|
|
205
|
+
* @param {(prompt:string)=>Promise<string|null>} opts.generate
|
|
206
|
+
* @param {number} [opts.topM] How many leading results to rerank (default 8).
|
|
207
|
+
* @returns {Promise<Array<{score:number, chunk:object}>>}
|
|
208
|
+
*/
|
|
209
|
+
export async function rerankResults(queryText, results, { generate, topM = 8 } = {}) {
|
|
210
|
+
const head = results.slice(0, topM);
|
|
211
|
+
if (head.length < 2) return results;
|
|
212
|
+
const raw = await generate(buildRerankPrompt(queryText, head.map(r => r.chunk)));
|
|
213
|
+
const order = parseRerankResponse(raw, head.length);
|
|
214
|
+
if (!order) return results;
|
|
215
|
+
const picked = order.map(i => head[i]);
|
|
216
|
+
const rest = head.filter((_, i) => !order.includes(i));
|
|
217
|
+
return [...picked, ...rest, ...results.slice(topM)];
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Select the chunks worth enriching.
|
|
222
|
+
*
|
|
223
|
+
* v1 selected the top-PageRank files only — and measured terribly: PageRank
|
|
224
|
+
* rewards the most-IMPORTED files, which are generic leaf utilities (utils.js,
|
|
225
|
+
* bind.js), while the behavioural code agents actually search for conceptually
|
|
226
|
+
* (request dispatchers, interceptor managers, adapters) sits mid-graph and was
|
|
227
|
+
* never enriched. v2 selects ALL substantive chunks in production source —
|
|
228
|
+
* tests, specs and example/docs trees are excluded, since agents search for
|
|
229
|
+
* implementations — and uses PageRank only to ORDER the queue, so the per-run
|
|
230
|
+
* `maxChunks` cap eats the most central files first. Combined with the
|
|
231
|
+
* persistent cache, coverage converges over a few index runs at a bounded
|
|
232
|
+
* per-run cost.
|
|
233
|
+
*
|
|
234
|
+
* `coreRatio` (default 1.0 = all production files) is kept for installs that
|
|
235
|
+
* want to bound enrichment to the most central share of files.
|
|
236
|
+
*/
|
|
237
|
+
export function selectCoreChunks(chunks, graph, { coreRatio = 1.0, maxChunks = 500 } = {}) {
|
|
238
|
+
const pr = computePageRank(graph);
|
|
239
|
+
let coreFiles = null;
|
|
240
|
+
if (coreRatio > 0 && coreRatio < 1) {
|
|
241
|
+
const files = Array.from(pr.keys()).sort((a, b) => (pr.get(b) ?? 0) - (pr.get(a) ?? 0));
|
|
242
|
+
coreFiles = new Set(files.slice(0, Math.max(1, Math.ceil(files.length * coreRatio))));
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const selected = chunks.filter(c =>
|
|
246
|
+
c.name && c.name !== 'anonymous' && c.name !== 'default_export'
|
|
247
|
+
&& (c.end_line - c.start_line) >= 4 // skip trivial stubs (too little code for useful enrichment)
|
|
248
|
+
&& !TEST_FILE_RE.test(c.file_path) // agents search implementations, not test bodies
|
|
249
|
+
&& !EXAMPLE_DIR_RE.test(c.file_path)
|
|
250
|
+
&& (!coreFiles || coreFiles.has(c.file_path))
|
|
251
|
+
);
|
|
252
|
+
selected.sort((a, b) => (pr.get(b.file_path) ?? 0) - (pr.get(a.file_path) ?? 0));
|
|
253
|
+
return selected.slice(0, maxChunks);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Enrich the core chunks in place, attaching `chunk.hyde` and `chunk.summary`.
|
|
258
|
+
*
|
|
259
|
+
* Uses a true sliding-window pool: each worker immediately picks the next task
|
|
260
|
+
* when done, so concurrency is always saturated rather than waiting for the
|
|
261
|
+
* slowest request in a batch before advancing.
|
|
262
|
+
*
|
|
263
|
+
* @param {object[]} chunks All extracted chunks (mutated in place).
|
|
264
|
+
* @param {object} graph { dependencies, importedBy }.
|
|
265
|
+
* @param {object} config Resolved config (uses config.enrichment + config.ollamaHost).
|
|
266
|
+
* @param {object} [deps]
|
|
267
|
+
* @param {(prompt:string)=>Promise<string|null>} [deps.generate] Override generator (tests).
|
|
268
|
+
* @param {number} [deps.concurrency] Defaults to config.enrichment.concurrency.
|
|
269
|
+
* @param {string} [deps.cachePath] Defaults to config.enrichmentCachePath. Pass false to disable.
|
|
270
|
+
* @returns {Promise<{enriched:number, attempted:number, cached:number}>}
|
|
271
|
+
*/
|
|
272
|
+
export async function enrichCoreChunks(chunks, graph, config, { generate, concurrency, cachePath } = {}) {
|
|
273
|
+
const { model, coreRatio, maxChunks } = config.enrichment;
|
|
274
|
+
const ollamaHost = config.ollamaHost;
|
|
275
|
+
const gen = generate || ((prompt) => ollamaGenerate(prompt, { model, ollamaHost, timeoutMs: 20000 }));
|
|
276
|
+
const slots = concurrency ?? config.enrichment.concurrency ?? 12;
|
|
277
|
+
const cacheFile = cachePath === false ? null : (cachePath || config.enrichmentCachePath || null);
|
|
278
|
+
const cache = cacheFile ? loadEnrichmentCache(cacheFile) : new Map();
|
|
279
|
+
|
|
280
|
+
// Cache pass FIRST, over ALL chunks — not just the current selection. The
|
|
281
|
+
// selection rules (test/example filters, coreRatio, caps) evolve between
|
|
282
|
+
// versions and runs; if cached enrichment were only attached to selected
|
|
283
|
+
// chunks, the same code would carry an enriched embedding key in one build
|
|
284
|
+
// and a plain one in the next, silently desynchronising the JSON and SQLite
|
|
285
|
+
// artifacts that share one embeddings bin. Cache membership is the single
|
|
286
|
+
// deterministic source of truth for "is this chunk enriched"; selection only
|
|
287
|
+
// decides which NEW chunks get LLM calls.
|
|
288
|
+
let cached = 0;
|
|
289
|
+
for (const chunk of chunks) {
|
|
290
|
+
if (chunk.content_hash && attachEnrichment(chunk, cache.get(chunk.content_hash))) cached++;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const core = selectCoreChunks(chunks, graph, { coreRatio, maxChunks });
|
|
294
|
+
if (core.length === 0 && cached === 0) {
|
|
295
|
+
console.log('🧠 LLM enrichment: no core chunks selected (empty graph) — skipping.');
|
|
296
|
+
return { enriched: 0, attempted: 0, cached: 0 };
|
|
297
|
+
}
|
|
298
|
+
const pending = core.filter(c => !c.summary && !(c.concepts?.length > 0));
|
|
299
|
+
|
|
300
|
+
if (pending.length === 0) {
|
|
301
|
+
console.log(`🧠 LLM enrichment: all ${cached} core chunks served from cache.`);
|
|
302
|
+
return { enriched: cached, attempted: 0, cached };
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
console.log(`🧠 LLM enrichment: ${pending.length} chunks via ${model} (${cached} from cache, concurrency: ${slots}) …`);
|
|
306
|
+
let enriched = 0, attempted = 0, failures = 0;
|
|
307
|
+
let aborted = false;
|
|
308
|
+
|
|
309
|
+
// Sliding-window pool: `slots` workers each drain the queue independently.
|
|
310
|
+
// No batch boundaries — a worker immediately takes the next item when done.
|
|
311
|
+
let cursor = 0;
|
|
312
|
+
async function runWorker() {
|
|
313
|
+
while (cursor < pending.length && !aborted) {
|
|
314
|
+
const chunk = pending[cursor++];
|
|
315
|
+
attempted++;
|
|
316
|
+
const raw = await gen(buildEnrichPrompt(chunk));
|
|
317
|
+
const parsed = parseEnrichResponse(raw);
|
|
318
|
+
if (parsed) {
|
|
319
|
+
chunk.summary = parsed.summary;
|
|
320
|
+
chunk.concepts = parsed.concepts;
|
|
321
|
+
chunk.hyde = parsed.hyde; // = concepts.join(' ') — domain keywords for BM25
|
|
322
|
+
if (chunk.content_hash) {
|
|
323
|
+
cache.set(chunk.content_hash, { summary: parsed.summary, concepts: parsed.concepts, model });
|
|
324
|
+
}
|
|
325
|
+
enriched++;
|
|
326
|
+
} else {
|
|
327
|
+
failures++;
|
|
328
|
+
}
|
|
329
|
+
process.stdout.write(`\r enriched ${enriched}/${pending.length} (failures: ${failures}) `);
|
|
330
|
+
// Bail early if the model is clearly unreachable.
|
|
331
|
+
if (attempted >= slots * 2 && enriched === 0) {
|
|
332
|
+
aborted = true;
|
|
333
|
+
console.log(`\n⚠️ LLM enrichment: generator unreachable (${failures} failures) — continuing without enrichment.`);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
await Promise.all(Array.from({ length: Math.min(slots, pending.length) }, runWorker));
|
|
339
|
+
|
|
340
|
+
if (cacheFile && enriched > 0) {
|
|
341
|
+
try { saveEnrichmentCache(cacheFile, cache); }
|
|
342
|
+
catch (err) { console.log(`⚠️ enrichment cache save failed: ${err.message}`); }
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
console.log(`\n ✓ enrichment complete: ${enriched} enriched, ${cached} cached, ${failures} failed.`);
|
|
346
|
+
return { enriched: enriched + cached, attempted, cached };
|
|
347
|
+
}
|
package/indexer.mjs
CHANGED
|
@@ -1,32 +1,30 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
3
|
* @file indexer.mjs
|
|
4
|
-
* @description
|
|
4
|
+
* @description Bootstrap indexer. Walks a repository, extracts Tree-sitter AST
|
|
5
|
+
* chunks + cross-file topology, generates local embeddings (Ollama),
|
|
6
|
+
* optionally enriches the most central chunks with an LLM, and writes
|
|
7
|
+
* the index to the configured backend — the default in-memory JSON
|
|
8
|
+
* artifacts, or a disk-backed SQLite database (--use-sqlite).
|
|
5
9
|
* @author MaquinaTech <https://github.com/MaquinaTech>
|
|
6
10
|
* @copyright (c) 2026 MaquinaTech. All rights reserved.
|
|
7
11
|
* @license MIT
|
|
8
|
-
* Copyright (c) 2026 MaquinaTech. All rights reserved.
|
|
9
|
-
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
10
|
-
* of this software and associated documentation files (the "Software"), to deal
|
|
11
|
-
* in the Software without restriction, including without limitation the rights
|
|
12
|
-
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
13
|
-
* copies of the Software, and to permit persons to whom the Software is
|
|
14
|
-
* furnished to do so, subject to the following conditions: The above copyright
|
|
15
|
-
* notice and this permission notice shall be included in all copies or
|
|
16
|
-
* substantial portions of the Software.
|
|
17
12
|
*/
|
|
18
13
|
import fs from 'fs';
|
|
19
14
|
import path from 'path';
|
|
20
15
|
import {
|
|
21
16
|
MAX_FILE_SIZE_BYTES, EXTENSIONS, getParserForFile, buildIgnoreFilter, getLocalEmbeddingsBatch,
|
|
22
|
-
extractImportsFromAST, extractSemanticChunks, resolveLocalImports, buildEmbeddingPayload
|
|
17
|
+
extractImportsFromAST, extractSemanticChunks, resolveLocalImports, buildEmbeddingPayload,
|
|
23
18
|
} from './parser-utils.mjs';
|
|
24
|
-
import {
|
|
19
|
+
import { readEmbeddingBinary, writeEmbeddingBinary } from './core-engine.mjs';
|
|
20
|
+
import { embeddingKeyFor, summaryEmbeddingText, SUMMARY_VEC_SUFFIX } from './search-core.mjs';
|
|
21
|
+
import { resolveConfig } from './config.mjs';
|
|
22
|
+
import { enrichCoreChunks } from './enrichment.mjs';
|
|
25
23
|
|
|
26
|
-
const
|
|
27
|
-
const
|
|
28
|
-
const
|
|
29
|
-
const
|
|
24
|
+
const config = resolveConfig();
|
|
25
|
+
const PROJECT_ROOT = config.projectRoot;
|
|
26
|
+
const INDEX_PATH = config.indexPath;
|
|
27
|
+
const EMBEDDINGS_PATH = config.embeddingPath;
|
|
30
28
|
|
|
31
29
|
function walkRepo(dir, root, ig, files = []) {
|
|
32
30
|
for (const entry of fs.readdirSync(dir)) {
|
|
@@ -44,18 +42,18 @@ function walkRepo(dir, root, ig, files = []) {
|
|
|
44
42
|
}
|
|
45
43
|
|
|
46
44
|
async function main() {
|
|
47
|
-
console.log(`\n🚀 Starting Optimized Indexer\n📂 Directory: ${PROJECT_ROOT}
|
|
45
|
+
console.log(`\n🚀 Starting Optimized Indexer\n📂 Directory: ${PROJECT_ROOT}`);
|
|
46
|
+
console.log(`🗄 Storage: ${config.storage}${config.enrichment.enabled ? ` · 🧠 LLM enrichment: ${config.enrichment.model}` : ''}\n`);
|
|
48
47
|
|
|
49
48
|
const ig = buildIgnoreFilter(PROJECT_ROOT);
|
|
50
49
|
const files = walkRepo(PROJECT_ROOT, PROJECT_ROOT, ig);
|
|
51
50
|
console.log(`Found ${files.length} files to analyse.\n`);
|
|
52
51
|
|
|
53
|
-
const
|
|
54
|
-
db.load();
|
|
55
|
-
const existingCache = db.embeddingCache;
|
|
52
|
+
const existingCache = readEmbeddingBinary(EMBEDDINGS_PATH);
|
|
56
53
|
console.log(`📦 Loaded ${existingCache.size} cached embeddings from previous runs.\n`);
|
|
57
54
|
|
|
58
55
|
const indexData = { chunks: [], graph: { dependencies: {}, importedBy: {} }, embeddingCache: {} };
|
|
56
|
+
const pendingChunks = [];
|
|
59
57
|
let totalCheckedFiles = 0;
|
|
60
58
|
|
|
61
59
|
for (const absolutePath of files) {
|
|
@@ -69,75 +67,97 @@ async function main() {
|
|
|
69
67
|
const stats = await fs.promises.stat(absolutePath);
|
|
70
68
|
if (stats.size > MAX_FILE_SIZE_BYTES) continue;
|
|
71
69
|
|
|
72
|
-
|
|
70
|
+
const content = await fs.promises.readFile(absolutePath, 'utf-8');
|
|
73
71
|
if (!content.trim()) continue;
|
|
74
72
|
|
|
75
73
|
const ext = path.extname(absolutePath);
|
|
76
74
|
const parser = getParserForFile(ext);
|
|
77
75
|
if (!parser) continue;
|
|
78
76
|
|
|
79
|
-
|
|
77
|
+
const tree = parser.parse((offset) => offset < content.length ? content.slice(offset, offset + 4096) : null);
|
|
80
78
|
const rawImports = extractImportsFromAST(tree.rootNode, ext);
|
|
81
79
|
const imports = resolveLocalImports(rawImports, relPath, PROJECT_ROOT);
|
|
82
80
|
indexData.graph.dependencies[relPath] = imports;
|
|
83
81
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
indexData.chunks.push(chunk);
|
|
89
|
-
} else {
|
|
90
|
-
indexData.chunksToEmbed = indexData.chunksToEmbed || [];
|
|
91
|
-
indexData.chunksToEmbed.push(chunk);
|
|
92
|
-
}
|
|
82
|
+
// Chunks are collected first; embedding/enrichment happen in batch below so
|
|
83
|
+
// we can route the high-value subset through the LLM before vectorising.
|
|
84
|
+
for (const chunk of extractSemanticChunks(tree.rootNode, relPath, content, ext)) {
|
|
85
|
+
pendingChunks.push(chunk);
|
|
93
86
|
}
|
|
94
87
|
} catch (err) {
|
|
95
88
|
console.error(`\n💥 Error in ${relPath}: ${err.message}`);
|
|
96
89
|
}
|
|
97
90
|
}
|
|
98
91
|
|
|
99
|
-
|
|
92
|
+
// ── Optional LLM enrichment of the most central chunks (HyDE + summaries) ──────
|
|
93
|
+
// Runs before embedding so the hypothetical questions ride the same vector.
|
|
94
|
+
if (config.enrichment.enabled) {
|
|
95
|
+
await enrichCoreChunks(pendingChunks, indexData.graph, config);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ── Embedding generation (cache-aware) ────────────────────────────────────────
|
|
99
|
+
// Vectors are keyed by embeddingKeyFor(chunk): content_hash for plain chunks,
|
|
100
|
+
// content_hash + enrichment digest for enriched ones. Because the enrichment
|
|
101
|
+
// cache returns the same summary for the same code, enriched chunks now HIT
|
|
102
|
+
// this cache on re-runs — previously every enriched chunk was re-embedded on
|
|
103
|
+
// every single index run.
|
|
104
|
+
const toEmbed = [];
|
|
105
|
+
for (const chunk of pendingChunks) {
|
|
106
|
+
const vecKey = embeddingKeyFor(chunk);
|
|
107
|
+
const sKey = vecKey + SUMMARY_VEC_SUFFIX;
|
|
108
|
+
// An enriched chunk needs BOTH vectors cached (code payload + summary) to
|
|
109
|
+
// skip embedding — e.g. indexes built before dual vectors only have the base.
|
|
110
|
+
const summaryMissing = summaryEmbeddingText(chunk) && !existingCache.has(sKey);
|
|
111
|
+
if (existingCache.has(vecKey) && !summaryMissing) {
|
|
112
|
+
indexData.embeddingCache[vecKey] = Array.from(existingCache.get(vecKey));
|
|
113
|
+
if (existingCache.has(sKey)) indexData.embeddingCache[sKey] = Array.from(existingCache.get(sKey));
|
|
114
|
+
indexData.chunks.push(chunk);
|
|
115
|
+
} else {
|
|
116
|
+
toEmbed.push(chunk);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
100
120
|
console.log(`\n\n🧠 Embedding Generation (Ollama)`);
|
|
101
121
|
console.log(`Chunks reused from cache: ${indexData.chunks.length}`);
|
|
102
|
-
console.log(`New chunks to process: ${
|
|
122
|
+
console.log(`New chunks to process: ${toEmbed.length}`);
|
|
103
123
|
|
|
104
|
-
if (
|
|
124
|
+
if (toEmbed.length > 0) {
|
|
105
125
|
const BATCH_SIZE = 64;
|
|
106
126
|
const CONCURRENCY = 4;
|
|
107
127
|
const batches = [];
|
|
108
|
-
for (let i = 0; i <
|
|
109
|
-
batches.push(pendingChunks.slice(i, i + BATCH_SIZE));
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
let completedChunksCount = 0;
|
|
113
|
-
console.time("Embedding Generation Duration");
|
|
128
|
+
for (let i = 0; i < toEmbed.length; i += BATCH_SIZE) batches.push(toEmbed.slice(i, i + BATCH_SIZE));
|
|
114
129
|
|
|
130
|
+
let completed = 0;
|
|
131
|
+
console.time('Embedding Generation Duration');
|
|
115
132
|
const worker = async (batch) => {
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
)
|
|
119
|
-
|
|
120
|
-
const
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
133
|
+
// Enriched chunks get TWO vectors: the full code payload (base key)
|
|
134
|
+
// and a compact summary-only text (`key|s`) that matches the vocabulary
|
|
135
|
+
// of natural-language queries (see search-core.summaryEmbeddingText).
|
|
136
|
+
const entries = [];
|
|
137
|
+
for (const c of batch) {
|
|
138
|
+
entries.push({ key: embeddingKeyFor(c), text: buildEmbeddingPayload(c, indexData.graph.dependencies[c.file_path] || []) });
|
|
139
|
+
const sText = summaryEmbeddingText(c);
|
|
140
|
+
if (sText) entries.push({ key: embeddingKeyFor(c) + SUMMARY_VEC_SUFFIX, text: sText });
|
|
141
|
+
}
|
|
142
|
+
const matrix = await getLocalEmbeddingsBatch(entries.map(e => e.text), true, {
|
|
143
|
+
ollamaHost: config.ollamaHost, model: config.embedModel,
|
|
144
|
+
});
|
|
145
|
+
if (matrix && matrix.length === entries.length) {
|
|
146
|
+
for (let j = 0; j < entries.length; j++) {
|
|
147
|
+
indexData.embeddingCache[entries[j].key] = matrix[j];
|
|
126
148
|
}
|
|
127
|
-
} else {
|
|
128
|
-
for (const chunk of batch) indexData.chunks.push(chunk);
|
|
129
149
|
}
|
|
130
|
-
|
|
131
|
-
|
|
150
|
+
for (const chunk of batch) indexData.chunks.push(chunk);
|
|
151
|
+
completed += batch.length;
|
|
152
|
+
process.stdout.write(`\r🤖 Embedding Progress: [${completed}/${toEmbed.length}] Chunks processed...`);
|
|
132
153
|
};
|
|
133
|
-
|
|
134
154
|
for (let i = 0; i < batches.length; i += CONCURRENCY) {
|
|
135
|
-
|
|
136
|
-
await Promise.all(batchGroup.map(batch => worker(batch)));
|
|
155
|
+
await Promise.all(batches.slice(i, i + CONCURRENCY).map(worker));
|
|
137
156
|
}
|
|
138
|
-
console.timeEnd(
|
|
157
|
+
console.timeEnd('Embedding Generation Duration');
|
|
139
158
|
}
|
|
140
159
|
|
|
160
|
+
// ── Reverse topology edges ────────────────────────────────────────────────────
|
|
141
161
|
for (const [filePath, imports] of Object.entries(indexData.graph.dependencies)) {
|
|
142
162
|
for (const dep of imports) {
|
|
143
163
|
if (!indexData.graph.importedBy[dep]) indexData.graph.importedBy[dep] = [];
|
|
@@ -145,18 +165,28 @@ async function main() {
|
|
|
145
165
|
}
|
|
146
166
|
}
|
|
147
167
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
168
|
+
// ── Persist to the configured backend ─────────────────────────────────────────
|
|
169
|
+
if (config.storage === 'sqlite') {
|
|
170
|
+
const { SqliteGraphStore } = await import('./sqlite-store.mjs');
|
|
171
|
+
const store = new SqliteGraphStore(config.sqlitePath, { embeddingPath: EMBEDDINGS_PATH });
|
|
172
|
+
const res = store.buildFrom({
|
|
173
|
+
chunks: indexData.chunks, graph: indexData.graph, embeddingCache: indexData.embeddingCache,
|
|
174
|
+
});
|
|
175
|
+
console.log(`\n🎉 SQLite index built: ${res.chunks} chunks · ${res.terms} terms · dim ${res.dim}`);
|
|
176
|
+
console.log(` → ${config.sqlitePath}\n`);
|
|
177
|
+
} else {
|
|
178
|
+
const tmpPath = `${INDEX_PATH}.tmp`;
|
|
179
|
+
const tmpBinPath = `${EMBEDDINGS_PATH}.tmp`;
|
|
180
|
+
await Promise.all([
|
|
181
|
+
fs.promises.writeFile(tmpPath, JSON.stringify({ chunks: indexData.chunks, graph: indexData.graph })),
|
|
182
|
+
fs.promises.writeFile(tmpBinPath, writeEmbeddingBinary(indexData.embeddingCache)),
|
|
183
|
+
]);
|
|
184
|
+
await Promise.all([
|
|
185
|
+
fs.promises.rename(tmpPath, INDEX_PATH),
|
|
186
|
+
fs.promises.rename(tmpBinPath, EMBEDDINGS_PATH),
|
|
187
|
+
]);
|
|
188
|
+
console.log(`\n🎉 Indexing completed blazingly fast. Total fragments: ${indexData.chunks.length}\n`);
|
|
189
|
+
}
|
|
160
190
|
}
|
|
161
191
|
|
|
162
|
-
main().catch(console.error);
|
|
192
|
+
main().catch(console.error);
|
package/init.mjs
CHANGED
|
@@ -263,6 +263,10 @@ function updateGitignore() {
|
|
|
263
263
|
const entries = [
|
|
264
264
|
'code-index.json',
|
|
265
265
|
'code-index.embeddings.bin',
|
|
266
|
+
'code-index.db',
|
|
267
|
+
'code-index.db-wal',
|
|
268
|
+
'code-index.db-shm',
|
|
269
|
+
'code-index.enrichment.json',
|
|
266
270
|
'.idx-daemon.pid',
|
|
267
271
|
'.idx-daemon.log'
|
|
268
272
|
];
|
|
@@ -336,7 +340,6 @@ log(' 1. Run `npm run mcp:index` to index this project');
|
|
|
336
340
|
log(' 2. Restart your IDE to activate the MCP server');
|
|
337
341
|
log(' 3. Run `npm run mcp:start` if your IDE needs the server started manually');
|
|
338
342
|
|
|
339
|
-
// Mensaje final de agradecimiento
|
|
340
343
|
log('\n─────────────────────────────────────────────────────────────────');
|
|
341
344
|
log('✨ Thank you for setting up graph-indexer!');
|
|
342
345
|
log(' Enjoy your blazing-fast, AST-precise codebase search.');
|
|
@@ -348,5 +351,4 @@ log(' copy the system prompt from our repository:');
|
|
|
348
351
|
log(' https://github.com/MaquinaTech/graph-indexer/blob/main/PROMPT.md?plain=1');
|
|
349
352
|
log('─────────────────────────────────────────────────────────────────\n');
|
|
350
353
|
|
|
351
|
-
// Cierre limpio de la ejecución para devolver el control a la terminal
|
|
352
354
|
process.exit(0);
|