graph-indexer 1.2.0 → 2.1.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 +854 -0
- package/README.md +258 -595
- package/SECURITY.md +85 -0
- package/TRADEMARK.md +31 -0
- package/cli-ui.mjs +78 -0
- package/colbert.mjs +168 -0
- package/config.mjs +408 -32
- package/daemon-ctl.mjs +175 -0
- package/daemon-lock.mjs +69 -0
- package/embedders/python/mlx_embed_server.py +61 -0
- package/embedders/python/requirements-mlx.txt +5 -0
- package/embedders/setup-mlx.mjs +110 -0
- package/embeddings.mjs +571 -0
- package/engine/binary.mjs +437 -0
- package/{core-engine.mjs → engine/memory.mjs} +321 -441
- package/{sqlite-store.mjs → engine/sqlite.mjs} +413 -45
- package/enrichment.mjs +206 -32
- package/git-signals.mjs +248 -0
- package/indexer.mjs +399 -45
- package/init.mjs +1626 -171
- package/layout.mjs +123 -0
- package/mcp/centrality.mjs +117 -0
- package/mcp/format.mjs +61 -0
- package/mcp/resolver.mjs +116 -0
- package/mcp/symbolgraph.mjs +137 -0
- package/mcp/taint.mjs +225 -0
- package/mcp/tools.mjs +1582 -0
- package/mcp/topology.mjs +676 -0
- package/mcp-server.mjs +80 -25
- package/package.json +66 -27
- package/parse/extractor.mjs +628 -0
- package/parse/imports.mjs +365 -0
- package/parse/interprocedural.mjs +134 -0
- package/parse/languages.mjs +253 -0
- package/parse/metadata.mjs +701 -0
- package/parse/routes.mjs +327 -0
- package/parse/scip.mjs +391 -0
- package/parse/taint-patterns.mjs +119 -0
- package/prompts/CORE.md +91 -0
- package/prompts/DOMAIN_TEMPLATE.md +46 -0
- package/prompts/INTEGRATION.md +123 -0
- package/prompts/frameworks/ANDROID.md +17 -0
- package/prompts/frameworks/ASPNET_CORE.md +19 -0
- package/prompts/frameworks/FASTAPI_DJANGO.md +20 -0
- package/prompts/frameworks/LARAVEL_SYMFONY.md +22 -0
- package/prompts/frameworks/NODE_EXPRESS_NESTJS.md +19 -0
- package/prompts/frameworks/RAILS.md +20 -0
- package/prompts/frameworks/REACT.md +20 -0
- package/prompts/frameworks/SPRING_BOOT.md +20 -0
- package/prompts/languages/BASH.md +22 -0
- package/prompts/languages/C.md +21 -0
- package/prompts/languages/CSHARP.md +24 -0
- package/prompts/languages/CSS_SCSS.md +22 -0
- package/prompts/languages/GO.md +24 -0
- package/prompts/languages/JAVA.md +23 -0
- package/prompts/languages/JAVASCRIPT_TYPESCRIPT.md +24 -0
- package/prompts/languages/KOTLIN.md +24 -0
- package/prompts/languages/PHP.md +23 -0
- package/prompts/languages/PYTHON.md +23 -0
- package/prompts/languages/RUBY.md +23 -0
- package/prompts/languages/RUST.md +21 -0
- package/prompts/languages/SWIFT.md +21 -0
- package/seal.mjs +400 -0
- package/search-core.mjs +405 -44
- package/search-sparse.mjs +183 -0
- package/storage.mjs +33 -3
- package/watch-daemon.mjs +75 -24
- package/PROMPT.md +0 -182
- package/mcp-tools.mjs +0 -545
- package/parser-utils.mjs +0 -1170
package/enrichment.mjs
CHANGED
|
@@ -58,32 +58,67 @@ export function attachEnrichment(chunk, entry) {
|
|
|
58
58
|
return Boolean(chunk.summary || chunk.concepts.length);
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
//
|
|
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.
|
|
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.
|
|
69
64
|
export const buildEnrichPrompt = (chunk) => {
|
|
70
|
-
const code = (chunk.code_snippet || '').slice(0,
|
|
65
|
+
const code = (chunk.code_snippet || '').slice(0, 1000);
|
|
71
66
|
const base = path.basename(chunk.file_path);
|
|
72
|
-
const ctx = chunk.class_context ? ` (
|
|
73
|
-
const doc = chunk.docstring ? `Doc: ${chunk.docstring.slice(0,
|
|
67
|
+
const ctx = chunk.class_context ? ` (member of ${chunk.class_context})` : '';
|
|
68
|
+
const doc = chunk.docstring ? `Doc: ${chunk.docstring.slice(0, 240)}\n` : '';
|
|
74
69
|
return (
|
|
75
|
-
`
|
|
76
|
-
+ `
|
|
77
|
-
+ `
|
|
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`
|
|
78
80
|
+ `Example:\n`
|
|
79
|
-
+ `SUMMARY: validates JWT tokens and refreshes expired
|
|
80
|
-
+ `TAGS: authentication, JWT, token validation, session refresh, middleware\n\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`
|
|
81
83
|
+ doc
|
|
82
84
|
+ `Code:\n${code}\n`
|
|
83
85
|
+ `Output:`
|
|
84
86
|
);
|
|
85
87
|
};
|
|
86
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
|
+
|
|
87
122
|
/**
|
|
88
123
|
* Default generator: a single non-streaming Ollama /api/generate call. Returns the
|
|
89
124
|
* response text, or null on any failure (enrichment is best-effort and never fatal).
|
|
@@ -107,11 +142,31 @@ export async function ollamaGenerate(prompt, { model, ollamaHost, timeoutMs = 30
|
|
|
107
142
|
}
|
|
108
143
|
}
|
|
109
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
|
+
|
|
110
166
|
/**
|
|
111
167
|
* Parse the SUMMARY + TAGS response into { summary, concepts, hyde }.
|
|
112
168
|
*
|
|
113
|
-
*
|
|
114
|
-
* Concepts are domain terms only (stopwords scrubbed), so every token has
|
|
169
|
+
* Concepts are domain terms only (stopwords scrubbed) so every token has
|
|
115
170
|
* meaningful IDF weight. Falls back to extracting non-stopword keywords from
|
|
116
171
|
* the summary when the model omits the TAGS line (graceful degradation).
|
|
117
172
|
*/
|
|
@@ -142,7 +197,7 @@ export function parseEnrichResponse(text) {
|
|
|
142
197
|
|
|
143
198
|
if (!summary && lines.length) summary = lines[0].replace(/^SUMMARY:\s*/i, '').trim();
|
|
144
199
|
|
|
145
|
-
|
|
200
|
+
|
|
146
201
|
if (concepts.length === 0 && summary) {
|
|
147
202
|
const words = summary.toLowerCase().match(/\b[a-z][a-z-]{2,}\b/g) || [];
|
|
148
203
|
concepts.push(...words.filter(w => !_stop.has(w)).slice(0, 8));
|
|
@@ -196,11 +251,19 @@ export function parseRerankResponse(text, n) {
|
|
|
196
251
|
}
|
|
197
252
|
|
|
198
253
|
/**
|
|
199
|
-
* Rerank the fused top-M results with a local LLM
|
|
200
|
-
*
|
|
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.
|
|
201
264
|
*
|
|
202
265
|
* @param {string} queryText
|
|
203
|
-
* @param {Array<{score:number, chunk:object}>} results Fused results (
|
|
266
|
+
* @param {Array<{score:number, chunk:object}>} results Fused results (copy returned).
|
|
204
267
|
* @param {object} opts
|
|
205
268
|
* @param {(prompt:string)=>Promise<string|null>} opts.generate
|
|
206
269
|
* @param {number} [opts.topM] How many leading results to rerank (default 8).
|
|
@@ -217,6 +280,101 @@ export async function rerankResults(queryText, results, { generate, topM = 8 } =
|
|
|
217
280
|
return [...picked, ...rest, ...results.slice(topM)];
|
|
218
281
|
}
|
|
219
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
|
+
|
|
220
378
|
/**
|
|
221
379
|
* Select the chunks worth enriching.
|
|
222
380
|
*
|
|
@@ -270,9 +428,24 @@ export function selectCoreChunks(chunks, graph, { coreRatio = 1.0, maxChunks = 5
|
|
|
270
428
|
* @returns {Promise<{enriched:number, attempted:number, cached:number}>}
|
|
271
429
|
*/
|
|
272
430
|
export async function enrichCoreChunks(chunks, graph, config, { generate, concurrency, cachePath } = {}) {
|
|
273
|
-
const {
|
|
431
|
+
const { coreRatio, maxChunks } = config.enrichment;
|
|
274
432
|
const ollamaHost = config.ollamaHost;
|
|
275
|
-
const
|
|
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 }));
|
|
276
449
|
const slots = concurrency ?? config.enrichment.concurrency ?? 12;
|
|
277
450
|
const cacheFile = cachePath === false ? null : (cachePath || config.enrichmentCachePath || null);
|
|
278
451
|
const cache = cacheFile ? loadEnrichmentCache(cacheFile) : new Map();
|
|
@@ -292,22 +465,21 @@ export async function enrichCoreChunks(chunks, graph, config, { generate, concur
|
|
|
292
465
|
|
|
293
466
|
const core = selectCoreChunks(chunks, graph, { coreRatio, maxChunks });
|
|
294
467
|
if (core.length === 0 && cached === 0) {
|
|
295
|
-
console.log('🧠 LLM enrichment: no core chunks selected (empty graph) — skipping.');
|
|
468
|
+
console.log('\n 🧠 LLM enrichment: no core chunks selected (empty graph) — skipping.');
|
|
296
469
|
return { enriched: 0, attempted: 0, cached: 0 };
|
|
297
470
|
}
|
|
298
471
|
const pending = core.filter(c => !c.summary && !(c.concepts?.length > 0));
|
|
299
472
|
|
|
300
473
|
if (pending.length === 0) {
|
|
301
|
-
console.log(
|
|
474
|
+
console.log(`\n 🧠 LLM enrichment: all ${cached} core chunks served from cache.`);
|
|
302
475
|
return { enriched: cached, attempted: 0, cached };
|
|
303
476
|
}
|
|
304
477
|
|
|
305
|
-
console.log(
|
|
478
|
+
console.log(`\n 🧠 LLM enrichment: ${pending.length} chunks via ${model} (${cached} from cache, concurrency: ${slots}) …`);
|
|
306
479
|
let enriched = 0, attempted = 0, failures = 0;
|
|
307
480
|
let aborted = false;
|
|
308
481
|
|
|
309
|
-
|
|
310
|
-
// No batch boundaries — a worker immediately takes the next item when done.
|
|
482
|
+
|
|
311
483
|
let cursor = 0;
|
|
312
484
|
async function runWorker() {
|
|
313
485
|
while (cursor < pending.length && !aborted) {
|
|
@@ -318,7 +490,7 @@ export async function enrichCoreChunks(chunks, graph, config, { generate, concur
|
|
|
318
490
|
if (parsed) {
|
|
319
491
|
chunk.summary = parsed.summary;
|
|
320
492
|
chunk.concepts = parsed.concepts;
|
|
321
|
-
chunk.hyde = parsed.hyde;
|
|
493
|
+
chunk.hyde = parsed.hyde;
|
|
322
494
|
if (chunk.content_hash) {
|
|
323
495
|
cache.set(chunk.content_hash, { summary: parsed.summary, concepts: parsed.concepts, model });
|
|
324
496
|
}
|
|
@@ -327,8 +499,10 @@ export async function enrichCoreChunks(chunks, graph, config, { generate, concur
|
|
|
327
499
|
failures++;
|
|
328
500
|
}
|
|
329
501
|
process.stdout.write(`\r enriched ${enriched}/${pending.length} (failures: ${failures}) `);
|
|
330
|
-
// Bail early if the model is clearly unreachable.
|
|
331
|
-
|
|
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) {
|
|
332
506
|
aborted = true;
|
|
333
507
|
console.log(`\n⚠️ LLM enrichment: generator unreachable (${failures} failures) — continuing without enrichment.`);
|
|
334
508
|
}
|
package/git-signals.mjs
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file git-signals.mjs
|
|
3
|
+
* @description Lightweight, air-gapped git-history signals for ranking and
|
|
4
|
+
* blast-radius hints. Reads ONLY the local repository's commit log
|
|
5
|
+
* (no network, no remote, no code sent anywhere) and derives three
|
|
6
|
+
* cheap per-file signals competitors mostly ignore:
|
|
7
|
+
*
|
|
8
|
+
* • churn — how often a file changes (commit count);
|
|
9
|
+
* • recency — how recently it last changed (last-commit time);
|
|
10
|
+
* • co-change — which files historically change *together* (the
|
|
11
|
+
* "if you touch A you usually touch B" hint that
|
|
12
|
+
* sharpens the refactoring/blast-radius story).
|
|
13
|
+
*
|
|
14
|
+
* Computed once at index time into a sidecar (code-index.git.json),
|
|
15
|
+
* kept out of the storage contract and the ranking math: the
|
|
16
|
+
* co-change hint is surfaced by the blast-radius tools, and the
|
|
17
|
+
* recency/churn boost is applied opt-in in the tool layer (default
|
|
18
|
+
* off), so the measured retrieval ranking is unchanged by default.
|
|
19
|
+
* @author MaquinaTech <https://github.com/MaquinaTech>
|
|
20
|
+
* @copyright (c) 2026 MaquinaTech. All rights reserved.
|
|
21
|
+
* @license MIT
|
|
22
|
+
*/
|
|
23
|
+
import { execFileSync } from 'child_process';
|
|
24
|
+
import fs from 'fs';
|
|
25
|
+
import path from 'path';
|
|
26
|
+
|
|
27
|
+
// Record/field separators chosen to never collide with file paths.
|
|
28
|
+
const REC = '\x01';
|
|
29
|
+
const FS_ = '\x1f';
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Collect git signals for a repository from its local commit log.
|
|
33
|
+
*
|
|
34
|
+
* Strictly local: shells out to `git log` on `repoRoot` and parses stdout. No
|
|
35
|
+
* remote is contacted. Returns `null` (a no-op) when the directory is not a git
|
|
36
|
+
* repo, git is unavailable, or there is no history — callers degrade silently.
|
|
37
|
+
*
|
|
38
|
+
* @param {string} repoRoot
|
|
39
|
+
* @param {object} [opts]
|
|
40
|
+
* @param {number} [opts.maxCommits=500] History depth (bounds cost + size).
|
|
41
|
+
* @param {number} [opts.topCoChange=8] Co-changed files kept per file.
|
|
42
|
+
* @param {number} [opts.maxFilesPerCommit=50] Skip sweeping commits (mass renames,
|
|
43
|
+
* formatting) whose co-change is noise.
|
|
44
|
+
* @returns {null | {generated:number, commits:number, maxChurn:number,
|
|
45
|
+
* churn:Object<string,number>, lastCommit:Object<string,number>,
|
|
46
|
+
* coChange:Object<string,Array<[string,number]>>}}
|
|
47
|
+
*/
|
|
48
|
+
export function collectGitSignals(repoRoot, { maxCommits = 500, topCoChange = 8, maxFilesPerCommit = 50 } = {}) {
|
|
49
|
+
// Resolve the actual repo root. If `repoRoot` is a SUBDIRECTORY of a larger
|
|
50
|
+
// repo (e.g. indexing one package, or a fixture nested in this repo), a bare
|
|
51
|
+
// `git -C <dir> log` walks UP and returns the PARENT repo's history with
|
|
52
|
+
// parent-root-relative paths that never match this index's file paths. So we
|
|
53
|
+
// find the toplevel, scope the log to this subtree, and strip the prefix so
|
|
54
|
+
// emitted paths are relative to `repoRoot` (matching chunk.file_path).
|
|
55
|
+
let prefix = '';
|
|
56
|
+
try {
|
|
57
|
+
const toplevel = execFileSync('git', ['-C', repoRoot, 'rev-parse', '--show-toplevel'],
|
|
58
|
+
{ encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
|
59
|
+
if (!toplevel) return null;
|
|
60
|
+
// Compare REAL paths: git returns the physical toplevel, while repoRoot may
|
|
61
|
+
// contain symlinks (e.g. macOS /var → /private/var), which would otherwise
|
|
62
|
+
// produce a bogus prefix.
|
|
63
|
+
const realTop = (() => { try { return fs.realpathSync(toplevel); } catch { return toplevel; } })();
|
|
64
|
+
const realRoot = (() => { try { return fs.realpathSync(path.resolve(repoRoot)); } catch { return path.resolve(repoRoot); } })();
|
|
65
|
+
const rel = path.relative(realTop, realRoot).replace(/\\/g, '/');
|
|
66
|
+
prefix = rel && rel !== '.' ? rel : '';
|
|
67
|
+
} catch {
|
|
68
|
+
return null; // not a git repo / git missing — air-gapped no-op
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
let raw;
|
|
72
|
+
try {
|
|
73
|
+
const args = ['-C', repoRoot, 'log', '--no-merges', `-n${maxCommits}`,
|
|
74
|
+
`--pretty=format:${REC}%H${FS_}%ct`, '--name-only'];
|
|
75
|
+
if (prefix) args.push('--', '.'); // limit to this subtree (paths stay toplevel-relative)
|
|
76
|
+
raw = execFileSync('git', args,
|
|
77
|
+
{ encoding: 'utf-8', maxBuffer: 128 * 1024 * 1024, stdio: ['ignore', 'pipe', 'ignore'] });
|
|
78
|
+
} catch {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
if (!raw || !raw.includes(REC)) return null;
|
|
82
|
+
const stripPrefix = prefix ? prefix + '/' : '';
|
|
83
|
+
|
|
84
|
+
const churn = Object.create(null);
|
|
85
|
+
const lastCommit = Object.create(null);
|
|
86
|
+
const pairCounts = new Map(); // file → Map<coFile, count>
|
|
87
|
+
let commits = 0;
|
|
88
|
+
|
|
89
|
+
const bump = (f, ts) => {
|
|
90
|
+
churn[f] = (churn[f] || 0) + 1;
|
|
91
|
+
if (ts > (lastCommit[f] || 0)) lastCommit[f] = ts;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
for (const block of raw.split(REC)) {
|
|
95
|
+
if (!block) continue;
|
|
96
|
+
const nl = block.indexOf('\n');
|
|
97
|
+
const header = nl === -1 ? block : block.slice(0, nl);
|
|
98
|
+
const ts = parseInt(header.split(FS_)[1], 10);
|
|
99
|
+
if (!Number.isFinite(ts)) continue;
|
|
100
|
+
commits++;
|
|
101
|
+
|
|
102
|
+
const files = [];
|
|
103
|
+
if (nl !== -1) {
|
|
104
|
+
for (const line of block.slice(nl + 1).split('\n')) {
|
|
105
|
+
let f = line.trim();
|
|
106
|
+
if (!f) continue;
|
|
107
|
+
// git quotes paths with unusual chars: "src/é.ts" — unwrap.
|
|
108
|
+
if (f.startsWith('"') && f.endsWith('"')) f = f.slice(1, -1);
|
|
109
|
+
if (stripPrefix) {
|
|
110
|
+
if (!f.startsWith(stripPrefix)) continue; // outside the indexed subtree
|
|
111
|
+
f = f.slice(stripPrefix.length);
|
|
112
|
+
}
|
|
113
|
+
files.push(f);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
for (const f of files) bump(f, ts);
|
|
117
|
+
|
|
118
|
+
// Co-change: only for focused commits — a 400-file sweep links nothing useful.
|
|
119
|
+
if (files.length >= 2 && files.length <= maxFilesPerCommit) {
|
|
120
|
+
for (let i = 0; i < files.length; i++) {
|
|
121
|
+
const a = files[i];
|
|
122
|
+
let row = pairCounts.get(a);
|
|
123
|
+
if (!row) { row = new Map(); pairCounts.set(a, row); }
|
|
124
|
+
for (let j = 0; j < files.length; j++) {
|
|
125
|
+
if (i === j) continue;
|
|
126
|
+
const b = files[j];
|
|
127
|
+
row.set(b, (row.get(b) || 0) + 1);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (commits === 0) return null;
|
|
134
|
+
|
|
135
|
+
// Stamp HEAD so clients can report "indexed at commit X" and detect drift
|
|
136
|
+
// (the freshness contract). Best-effort — absence just omits the commit.
|
|
137
|
+
let head = null;
|
|
138
|
+
try {
|
|
139
|
+
head = execFileSync('git', ['-C', repoRoot, 'rev-parse', '--short', 'HEAD'],
|
|
140
|
+
{ encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] }).trim() || null;
|
|
141
|
+
} catch { /* detached/empty — no stamp */ }
|
|
142
|
+
|
|
143
|
+
const coChange = Object.create(null);
|
|
144
|
+
for (const [file, row] of pairCounts) {
|
|
145
|
+
const top = [...row.entries()]
|
|
146
|
+
.sort((x, y) => y[1] - x[1] || x[0].localeCompare(y[0]))
|
|
147
|
+
.slice(0, topCoChange);
|
|
148
|
+
if (top.length) coChange[file] = top;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
let maxChurn = 1;
|
|
152
|
+
for (const k in churn) if (churn[k] > maxChurn) maxChurn = churn[k];
|
|
153
|
+
|
|
154
|
+
return { generated: Math.floor(Date.now() / 1000), head, commits, maxChurn, churn, lastCommit, coChange };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Persist signals next to the index (best-effort). */
|
|
158
|
+
export function writeGitSignals(filePath, signals) {
|
|
159
|
+
try { fs.writeFileSync(filePath, JSON.stringify(signals)); return true; }
|
|
160
|
+
catch { return false; }
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Load a signals sidecar, or null if absent/corrupt. */
|
|
164
|
+
export function loadGitSignals(filePath) {
|
|
165
|
+
try { return JSON.parse(fs.readFileSync(filePath, 'utf-8')); }
|
|
166
|
+
catch { return null; }
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Files that historically change together with `file`, strongest first. */
|
|
170
|
+
export function coChangesFor(signals, file, limit = 5) {
|
|
171
|
+
if (!signals || !signals.coChange) return [];
|
|
172
|
+
const row = signals.coChange[file];
|
|
173
|
+
if (!row) return [];
|
|
174
|
+
return row.slice(0, limit).map(([f, count]) => ({ file: f, count }));
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ─── Index freshness (query-time drift detection) ────────────────────────────
|
|
178
|
+
// The blast-radius tools are only trustworthy if the index reflects the working
|
|
179
|
+
// tree. We surface a compact freshness signal on tool responses: index age, the
|
|
180
|
+
// commit it was built at (stamped above), and whether the tree has drifted since.
|
|
181
|
+
// The git read is cheap and cached briefly so a burst of tool calls costs one
|
|
182
|
+
// `git status`; it degrades silently (null) outside a git repo.
|
|
183
|
+
|
|
184
|
+
const _SRC_RE = /\.(js|jsx|mjs|cjs|ts|tsx|py|go|rs|java|kt|cs|rb|php|swift|c|h|cc|cpp|hpp|scss|css|sh|bash)$/i;
|
|
185
|
+
let _gitStateCache = null; // { at, root, head, dirtyCount }
|
|
186
|
+
|
|
187
|
+
/** Reset the cached working-tree state (tests). */
|
|
188
|
+
export function _resetGitStateCache() { _gitStateCache = null; }
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Cheap, cached working-tree state: current short HEAD + count of changed SOURCE
|
|
192
|
+
* files (uncommitted). Cached `ttlMs` per root so a burst of tool calls shells out
|
|
193
|
+
* at most once. `exec`/`nowMs` injectable for tests. Returns {head:null,dirtyCount:null}
|
|
194
|
+
* outside a git repo.
|
|
195
|
+
*/
|
|
196
|
+
export function currentGitState(repoRoot, { ttlMs = 5000, nowMs = Date.now(), exec = execFileSync } = {}) {
|
|
197
|
+
if (_gitStateCache && _gitStateCache.root === repoRoot && (nowMs - _gitStateCache.at) < ttlMs) return _gitStateCache;
|
|
198
|
+
let head = null, dirtyCount = null;
|
|
199
|
+
try {
|
|
200
|
+
head = exec('git', ['-C', repoRoot, 'rev-parse', '--short', 'HEAD'],
|
|
201
|
+
{ encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] }).trim() || null;
|
|
202
|
+
const status = exec('git', ['-C', repoRoot, 'status', '--porcelain'],
|
|
203
|
+
{ encoding: 'utf-8', maxBuffer: 16 * 1024 * 1024, stdio: ['ignore', 'pipe', 'ignore'] });
|
|
204
|
+
dirtyCount = status.split('\n').filter(l => l.trim() && _SRC_RE.test(l)).length;
|
|
205
|
+
} catch { /* not a git repo / git missing → unknown */ }
|
|
206
|
+
_gitStateCache = { at: nowMs, root: repoRoot, head, dirtyCount };
|
|
207
|
+
return _gitStateCache;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Derive the freshness verdict from index age + the stamped index commit + the
|
|
212
|
+
* current working-tree state + whether the watch daemon is live. Pure (no I/O) so
|
|
213
|
+
* it is trivially testable.
|
|
214
|
+
*
|
|
215
|
+
* @returns {{ageSeconds:number|null, ageLabel:string, indexedCommit:string|null,
|
|
216
|
+
* currentCommit:string|null, pendingChanges:number|null, commitMoved:boolean,
|
|
217
|
+
* daemonRunning:boolean, stale:boolean, syncing:boolean}}
|
|
218
|
+
*/
|
|
219
|
+
export function computeFreshness({ ageSeconds = null, indexedCommit = null, current = null, daemonRunning = false } = {}) {
|
|
220
|
+
const ageLabel = ageSeconds == null ? 'unknown'
|
|
221
|
+
: ageSeconds < 60 ? `${ageSeconds}s` : ageSeconds < 3600 ? `${Math.floor(ageSeconds / 60)}m` : `${Math.floor(ageSeconds / 3600)}h`;
|
|
222
|
+
const currentCommit = current?.head ?? null;
|
|
223
|
+
const pendingChanges = current?.dirtyCount ?? null;
|
|
224
|
+
const commitMoved = Boolean(indexedCommit && currentCommit && indexedCommit !== currentCommit);
|
|
225
|
+
const hasPending = (pendingChanges != null && pendingChanges > 0) || commitMoved;
|
|
226
|
+
// A live daemon syncs changes incrementally, so pending work is "syncing", not
|
|
227
|
+
// "stale". Only when no daemon is keeping the index fresh is drift truly stale.
|
|
228
|
+
return {
|
|
229
|
+
ageSeconds, ageLabel, indexedCommit, currentCommit, pendingChanges, commitMoved,
|
|
230
|
+
daemonRunning, stale: hasPending && !daemonRunning, syncing: hasPending && daemonRunning,
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* A bounded 0..1 "this file is hot" score combining recency and churn, for the
|
|
236
|
+
* OPT-IN ranking boost only. Returns 0 when there is no signal for the file, so a
|
|
237
|
+
* disabled or signal-less repo never perturbs ranking.
|
|
238
|
+
*/
|
|
239
|
+
export function gitBoostScore(signals, file, nowSec = Date.now() / 1000) {
|
|
240
|
+
if (!signals) return 0;
|
|
241
|
+
const churn = (signals.churn && signals.churn[file]) || 0;
|
|
242
|
+
const last = (signals.lastCommit && signals.lastCommit[file]) || 0;
|
|
243
|
+
if (!churn && !last) return 0;
|
|
244
|
+
const ageDays = last ? Math.max(0, (nowSec - last) / 86400) : 3650;
|
|
245
|
+
const recency = Math.exp(-ageDays / 90); // ~quarter half-life
|
|
246
|
+
const churnScore = Math.log1p(churn) / Math.log1p(signals.maxChurn || 1); // 0..1
|
|
247
|
+
return 0.5 * recency + 0.5 * churnScore;
|
|
248
|
+
}
|