graph-indexer 1.1.0 → 2.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.
Files changed (59) hide show
  1. package/CHANGELOG.md +412 -0
  2. package/README.md +228 -350
  3. package/SECURITY.md +68 -0
  4. package/TRADEMARK.md +31 -0
  5. package/cli-ui.mjs +78 -0
  6. package/config.mjs +269 -0
  7. package/daemon-ctl.mjs +175 -0
  8. package/daemon-lock.mjs +69 -0
  9. package/embedders/python/mlx_embed_server.py +61 -0
  10. package/embedders/python/requirements-mlx.txt +5 -0
  11. package/embedders/setup-mlx.mjs +110 -0
  12. package/embeddings.mjs +489 -0
  13. package/engine/binary.mjs +399 -0
  14. package/{core-engine.mjs → engine/memory.mjs} +359 -249
  15. package/engine/sqlite.mjs +863 -0
  16. package/enrichment.mjs +426 -0
  17. package/git-signals.mjs +248 -0
  18. package/indexer.mjs +216 -77
  19. package/init.mjs +1604 -170
  20. package/layout.mjs +123 -0
  21. package/mcp/format.mjs +61 -0
  22. package/mcp/tools.mjs +1157 -0
  23. package/mcp/topology.mjs +551 -0
  24. package/mcp-server.mjs +118 -573
  25. package/package.json +56 -16
  26. package/parse/extractor.mjs +617 -0
  27. package/parse/imports.mjs +365 -0
  28. package/parse/languages.mjs +89 -0
  29. package/parse/metadata.mjs +655 -0
  30. package/parse/routes.mjs +327 -0
  31. package/prompts/CORE.md +86 -0
  32. package/prompts/DOMAIN_TEMPLATE.md +46 -0
  33. package/prompts/INTEGRATION.md +123 -0
  34. package/prompts/frameworks/ANDROID.md +17 -0
  35. package/prompts/frameworks/ASPNET_CORE.md +19 -0
  36. package/prompts/frameworks/FASTAPI_DJANGO.md +19 -0
  37. package/prompts/frameworks/LARAVEL_SYMFONY.md +22 -0
  38. package/prompts/frameworks/NODE_EXPRESS_NESTJS.md +18 -0
  39. package/prompts/frameworks/RAILS.md +20 -0
  40. package/prompts/frameworks/REACT.md +20 -0
  41. package/prompts/frameworks/SPRING_BOOT.md +19 -0
  42. package/prompts/languages/BASH.md +22 -0
  43. package/prompts/languages/C.md +21 -0
  44. package/prompts/languages/CSHARP.md +24 -0
  45. package/prompts/languages/CSS_SCSS.md +22 -0
  46. package/prompts/languages/GO.md +23 -0
  47. package/prompts/languages/JAVA.md +23 -0
  48. package/prompts/languages/JAVASCRIPT_TYPESCRIPT.md +24 -0
  49. package/prompts/languages/KOTLIN.md +24 -0
  50. package/prompts/languages/PHP.md +23 -0
  51. package/prompts/languages/PYTHON.md +23 -0
  52. package/prompts/languages/RUBY.md +23 -0
  53. package/prompts/languages/RUST.md +21 -0
  54. package/prompts/languages/SWIFT.md +21 -0
  55. package/search-core.mjs +627 -0
  56. package/storage.mjs +68 -0
  57. package/watch-daemon.mjs +204 -69
  58. package/PROMPT.md +0 -143
  59. package/parser-utils.mjs +0 -861
package/enrichment.mjs ADDED
@@ -0,0 +1,426 @@
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
+ /**
284
+ * Select the chunks worth enriching.
285
+ *
286
+ * v1 selected the top-PageRank files only — and measured terribly: PageRank
287
+ * rewards the most-IMPORTED files, which are generic leaf utilities (utils.js,
288
+ * bind.js), while the behavioural code agents actually search for conceptually
289
+ * (request dispatchers, interceptor managers, adapters) sits mid-graph and was
290
+ * never enriched. v2 selects ALL substantive chunks in production source —
291
+ * tests, specs and example/docs trees are excluded, since agents search for
292
+ * implementations — and uses PageRank only to ORDER the queue, so the per-run
293
+ * `maxChunks` cap eats the most central files first. Combined with the
294
+ * persistent cache, coverage converges over a few index runs at a bounded
295
+ * per-run cost.
296
+ *
297
+ * `coreRatio` (default 1.0 = all production files) is kept for installs that
298
+ * want to bound enrichment to the most central share of files.
299
+ */
300
+ export function selectCoreChunks(chunks, graph, { coreRatio = 1.0, maxChunks = 500 } = {}) {
301
+ const pr = computePageRank(graph);
302
+ let coreFiles = null;
303
+ if (coreRatio > 0 && coreRatio < 1) {
304
+ const files = Array.from(pr.keys()).sort((a, b) => (pr.get(b) ?? 0) - (pr.get(a) ?? 0));
305
+ coreFiles = new Set(files.slice(0, Math.max(1, Math.ceil(files.length * coreRatio))));
306
+ }
307
+
308
+ const selected = chunks.filter(c =>
309
+ c.name && c.name !== 'anonymous' && c.name !== 'default_export'
310
+ && (c.end_line - c.start_line) >= 4 // skip trivial stubs (too little code for useful enrichment)
311
+ && !TEST_FILE_RE.test(c.file_path) // agents search implementations, not test bodies
312
+ && !EXAMPLE_DIR_RE.test(c.file_path)
313
+ && (!coreFiles || coreFiles.has(c.file_path))
314
+ );
315
+ selected.sort((a, b) => (pr.get(b.file_path) ?? 0) - (pr.get(a.file_path) ?? 0));
316
+ return selected.slice(0, maxChunks);
317
+ }
318
+
319
+ /**
320
+ * Enrich the core chunks in place, attaching `chunk.hyde` and `chunk.summary`.
321
+ *
322
+ * Uses a true sliding-window pool: each worker immediately picks the next task
323
+ * when done, so concurrency is always saturated rather than waiting for the
324
+ * slowest request in a batch before advancing.
325
+ *
326
+ * @param {object[]} chunks All extracted chunks (mutated in place).
327
+ * @param {object} graph { dependencies, importedBy }.
328
+ * @param {object} config Resolved config (uses config.enrichment + config.ollamaHost).
329
+ * @param {object} [deps]
330
+ * @param {(prompt:string)=>Promise<string|null>} [deps.generate] Override generator (tests).
331
+ * @param {number} [deps.concurrency] Defaults to config.enrichment.concurrency.
332
+ * @param {string} [deps.cachePath] Defaults to config.enrichmentCachePath. Pass false to disable.
333
+ * @returns {Promise<{enriched:number, attempted:number, cached:number}>}
334
+ */
335
+ export async function enrichCoreChunks(chunks, graph, config, { generate, concurrency, cachePath } = {}) {
336
+ const { coreRatio, maxChunks } = config.enrichment;
337
+ const ollamaHost = config.ollamaHost;
338
+ const mlxLmHost = config.mlxLmHost;
339
+ const useMlx = config.llmProvider === 'mlx';
340
+ // Resolve "auto" → the strongest code model the local Ollama has (1.5B floor).
341
+ // A pinned generate (tests) skips the network probe — model is only used to
342
+ // build the default generator and to stamp cache provenance.
343
+ const model = generate ? config.enrichment.model
344
+ : (useMlx ? config.enrichment.model : await resolveEnrichModel(config.enrichment.model, ollamaHost));
345
+ // 60 s timeout: a larger judge model on a busy/CPU-bound local Ollama can take
346
+ // 10–25 s per generation; the old 20 s cap made every call fail there and aborted
347
+ // enrichment entirely. Concurrency defaults low for the same reason — parallel
348
+ // requests to one local model thrash and slow each other (set higher only when
349
+ // OLLAMA_NUM_PARALLEL and the hardware support it).
350
+ const gen = generate
351
+ || (useMlx
352
+ ? (prompt) => mlxLmGenerate(prompt, { mlxLmHost, timeoutMs: 60000 })
353
+ : (prompt) => ollamaGenerate(prompt, { model, ollamaHost, timeoutMs: 60000 }));
354
+ const slots = concurrency ?? config.enrichment.concurrency ?? 12;
355
+ const cacheFile = cachePath === false ? null : (cachePath || config.enrichmentCachePath || null);
356
+ const cache = cacheFile ? loadEnrichmentCache(cacheFile) : new Map();
357
+
358
+ // Cache pass FIRST, over ALL chunks — not just the current selection. The
359
+ // selection rules (test/example filters, coreRatio, caps) evolve between
360
+ // versions and runs; if cached enrichment were only attached to selected
361
+ // chunks, the same code would carry an enriched embedding key in one build
362
+ // and a plain one in the next, silently desynchronising the JSON and SQLite
363
+ // artifacts that share one embeddings bin. Cache membership is the single
364
+ // deterministic source of truth for "is this chunk enriched"; selection only
365
+ // decides which NEW chunks get LLM calls.
366
+ let cached = 0;
367
+ for (const chunk of chunks) {
368
+ if (chunk.content_hash && attachEnrichment(chunk, cache.get(chunk.content_hash))) cached++;
369
+ }
370
+
371
+ const core = selectCoreChunks(chunks, graph, { coreRatio, maxChunks });
372
+ if (core.length === 0 && cached === 0) {
373
+ console.log('\n 🧠 LLM enrichment: no core chunks selected (empty graph) — skipping.');
374
+ return { enriched: 0, attempted: 0, cached: 0 };
375
+ }
376
+ const pending = core.filter(c => !c.summary && !(c.concepts?.length > 0));
377
+
378
+ if (pending.length === 0) {
379
+ console.log(`\n 🧠 LLM enrichment: all ${cached} core chunks served from cache.`);
380
+ return { enriched: cached, attempted: 0, cached };
381
+ }
382
+
383
+ console.log(`\n 🧠 LLM enrichment: ${pending.length} chunks via ${model} (${cached} from cache, concurrency: ${slots}) …`);
384
+ let enriched = 0, attempted = 0, failures = 0;
385
+ let aborted = false;
386
+
387
+
388
+ let cursor = 0;
389
+ async function runWorker() {
390
+ while (cursor < pending.length && !aborted) {
391
+ const chunk = pending[cursor++];
392
+ attempted++;
393
+ const raw = await gen(buildEnrichPrompt(chunk));
394
+ const parsed = parseEnrichResponse(raw);
395
+ if (parsed) {
396
+ chunk.summary = parsed.summary;
397
+ chunk.concepts = parsed.concepts;
398
+ chunk.hyde = parsed.hyde;
399
+ if (chunk.content_hash) {
400
+ cache.set(chunk.content_hash, { summary: parsed.summary, concepts: parsed.concepts, model });
401
+ }
402
+ enriched++;
403
+ } else {
404
+ failures++;
405
+ }
406
+ process.stdout.write(`\r enriched ${enriched}/${pending.length} (failures: ${failures}) `);
407
+ // Bail early if the model is clearly unreachable. Require a real minimum
408
+ // of attempts (not just slots×2) so a slow first generation at low
409
+ // concurrency doesn't abort a perfectly healthy run.
410
+ if (attempted >= Math.max(8, slots * 2) && enriched === 0) {
411
+ aborted = true;
412
+ console.log(`\n⚠️ LLM enrichment: generator unreachable (${failures} failures) — continuing without enrichment.`);
413
+ }
414
+ }
415
+ }
416
+
417
+ await Promise.all(Array.from({ length: Math.min(slots, pending.length) }, runWorker));
418
+
419
+ if (cacheFile && enriched > 0) {
420
+ try { saveEnrichmentCache(cacheFile, cache); }
421
+ catch (err) { console.log(`⚠️ enrichment cache save failed: ${err.message}`); }
422
+ }
423
+
424
+ console.log(`\n ✓ enrichment complete: ${enriched} enriched, ${cached} cached, ${failures} failed.`);
425
+ return { enriched: enriched + cached, attempted, cached };
426
+ }
@@ -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
+ }