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.
- package/CHANGELOG.md +412 -0
- package/README.md +228 -350
- package/SECURITY.md +68 -0
- package/TRADEMARK.md +31 -0
- package/cli-ui.mjs +78 -0
- package/config.mjs +269 -0
- 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 +489 -0
- package/engine/binary.mjs +399 -0
- package/{core-engine.mjs → engine/memory.mjs} +359 -249
- package/engine/sqlite.mjs +863 -0
- package/enrichment.mjs +426 -0
- package/git-signals.mjs +248 -0
- package/indexer.mjs +216 -77
- package/init.mjs +1604 -170
- package/layout.mjs +123 -0
- package/mcp/format.mjs +61 -0
- package/mcp/tools.mjs +1157 -0
- package/mcp/topology.mjs +551 -0
- package/mcp-server.mjs +118 -573
- package/package.json +56 -16
- package/parse/extractor.mjs +617 -0
- package/parse/imports.mjs +365 -0
- package/parse/languages.mjs +89 -0
- package/parse/metadata.mjs +655 -0
- package/parse/routes.mjs +327 -0
- package/prompts/CORE.md +86 -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 +19 -0
- package/prompts/frameworks/LARAVEL_SYMFONY.md +22 -0
- package/prompts/frameworks/NODE_EXPRESS_NESTJS.md +18 -0
- package/prompts/frameworks/RAILS.md +20 -0
- package/prompts/frameworks/REACT.md +20 -0
- package/prompts/frameworks/SPRING_BOOT.md +19 -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 +23 -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/search-core.mjs +627 -0
- package/storage.mjs +68 -0
- package/watch-daemon.mjs +204 -69
- package/PROMPT.md +0 -143
- 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
|
+
}
|
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
|
+
}
|