graph-indexer 1.2.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 (60) hide show
  1. package/CHANGELOG.md +412 -0
  2. package/README.md +216 -596
  3. package/SECURITY.md +68 -0
  4. package/TRADEMARK.md +31 -0
  5. package/cli-ui.mjs +78 -0
  6. package/config.mjs +179 -28
  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} +107 -440
  15. package/{sqlite-store.mjs → engine/sqlite.mjs} +137 -42
  16. package/enrichment.mjs +111 -32
  17. package/git-signals.mjs +248 -0
  18. package/indexer.mjs +151 -42
  19. package/init.mjs +1603 -171
  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 +55 -24
  25. package/package.json +44 -13
  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 +254 -43
  56. package/storage.mjs +28 -3
  57. package/watch-daemon.mjs +69 -23
  58. package/PROMPT.md +0 -182
  59. package/mcp-tools.mjs +0 -545
  60. package/parser-utils.mjs +0 -1170
@@ -1,5 +1,5 @@
1
1
  /**
2
- * @file core-engine.mjs
2
+ * @file engine/memory.mjs
3
3
  * @description In-Memory Graph Indexer Core Engine.
4
4
  * @author MaquinaTech <https://github.com/MaquinaTech>
5
5
  * @copyright (c) 2026 MaquinaTech. All rights reserved.
@@ -7,405 +7,14 @@
7
7
  */
8
8
  import fs from 'fs';
9
9
  import {
10
- EMBEDDING_CONTEXT_LIMIT, truncateForEmbedding, cosineSimilarity, tokenize,
11
- okapiIdf, bm25Score, fuseAndRank, buildLexicalDocument, embeddingKeyFor,
10
+ tokenize, okapiIdf, bm25Score, fuseAndRank, buildLexicalDocument, embeddingKeyFor,
12
11
  SUMMARY_VEC_SUFFIX, LEXICAL_FUSION_CAP, VECTOR_SCAN_RAW_N, finalizeVectorCandidates,
13
- } from './search-core.mjs';
14
-
15
- // Re-exported for backward compatibility — callers historically imported these
16
- // retrieval primitives from core-engine; they now live in search-core.mjs.
17
- export { EMBEDDING_CONTEXT_LIMIT, truncateForEmbedding, cosineSimilarity };
18
-
19
- // Thresholds for adaptive vector loading strategy
20
- const HNSW_THRESHOLD = 5000; // Build HNSW index above this (eager mode only)
21
- const LAZY_VEC_THRESHOLD = 10000; // Switch to lazy (disk-backed) vector access above this
22
-
23
- // Optional HNSW accelerator for medium corpora (eager mode only)
24
- let HierarchicalNSW = null;
25
- try {
26
- const mod = await import('hnswlib-node');
27
- HierarchicalNSW = mod.HierarchicalNSW ?? mod.default?.HierarchicalNSW ?? null;
28
- } catch { /* not installed — flat scan used */ }
29
-
30
- /**
31
- * Serializes embeddingCache to compact binary.
32
- * Format per entry: [uint32 hashLen][utf8 hash][uint32 dim][float32 × dim]
33
- */
34
- export function writeEmbeddingBinary(embeddingCache) {
35
- const entries = embeddingCache instanceof Map
36
- ? Array.from(embeddingCache.entries())
37
- : Object.entries(embeddingCache);
38
- let size = 4;
39
- for (const [hash, vec] of entries) {
40
- size += 4 + Buffer.byteLength(hash, 'utf8') + 4 + vec.length * 4;
41
- }
42
- const buf = Buffer.allocUnsafe(size);
43
- let off = 0;
44
- buf.writeUInt32LE(entries.length, off); off += 4;
45
- for (const [hash, vec] of entries) {
46
- const hashBytes = Buffer.from(hash, 'utf8');
47
- buf.writeUInt32LE(hashBytes.length, off); off += 4;
48
- hashBytes.copy(buf, off); off += hashBytes.length;
49
- buf.writeUInt32LE(vec.length, off); off += 4;
50
- for (let d = 0; d < vec.length; d++) { buf.writeFloatLE(vec[d], off); off += 4; }
51
- }
52
- return buf;
53
- }
54
-
55
- /**
56
- * Decode an `.embeddings.bin` file into a Map<content_hash, Float32Array>.
57
- * Standalone (mirrors the instance loader) so the indexer can reuse cached
58
- * vectors across runs regardless of the active storage backend.
59
- */
60
- export function readEmbeddingBinary(filePath) {
61
- const cache = new Map();
62
- if (!fs.existsSync(filePath)) return cache;
63
- const buf = fs.readFileSync(filePath);
64
- let off = 0;
65
- const count = buf.readUInt32LE(off); off += 4;
66
- for (let i = 0; i < count; i++) {
67
- const hashLen = buf.readUInt32LE(off); off += 4;
68
- const hash = buf.subarray(off, off + hashLen).toString('utf8'); off += hashLen;
69
- const dim = buf.readUInt32LE(off); off += 4;
70
- const vec = new Float32Array(dim);
71
- for (let d = 0; d < dim; d++) { vec[d] = buf.readFloatLE(off); off += 4; }
72
- cache.set(hash, vec);
73
- }
74
- return cache;
75
- }
76
-
77
- /**
78
- * Append embedding entries to an `.embeddings.bin` file in place (creating it if
79
- * absent) and return the absolute float-data offset of each appended entry.
80
- * The leading uint32 entry count is updated so readEmbeddingBinary stays valid.
81
- *
82
- * Used by the watch daemon for incremental updates: a full bin rewrite on every
83
- * file change would be O(corpus) — appending is O(changed chunks). Dead entries
84
- * from replaced chunks are compacted away on the next full index run.
85
- *
86
- * @param {string} filePath
87
- * @param {Map<string, Float32Array|number[]>|object} entries key → vector
88
- * @returns {Map<string, {offset:number, dim:number}>}
89
- */
90
- export function appendEmbeddingBinary(filePath, entries) {
91
- const list = entries instanceof Map ? Array.from(entries.entries()) : Object.entries(entries || {});
92
- const offsets = new Map();
93
- if (list.length === 0) return offsets;
94
-
95
- if (!fs.existsSync(filePath)) {
96
- const hdr = Buffer.alloc(4);
97
- hdr.writeUInt32LE(0, 0);
98
- fs.writeFileSync(filePath, hdr);
99
- }
100
- const fd = fs.openSync(filePath, 'r+');
101
- try {
102
- const pos = fs.fstatSync(fd).size;
103
- let size = 0;
104
- for (const [key, vec] of list) size += 4 + Buffer.byteLength(key, 'utf8') + 4 + vec.length * 4;
105
- const buf = Buffer.allocUnsafe(size);
106
- let off = 0;
107
- for (const [key, vec] of list) {
108
- const kb = Buffer.from(key, 'utf8');
109
- buf.writeUInt32LE(kb.length, off); off += 4;
110
- kb.copy(buf, off); off += kb.length;
111
- buf.writeUInt32LE(vec.length, off); off += 4;
112
- offsets.set(key, { offset: pos + off, dim: vec.length });
113
- for (let d = 0; d < vec.length; d++) { buf.writeFloatLE(vec[d], off); off += 4; }
114
- }
115
- fs.writeSync(fd, buf, 0, buf.length, pos);
116
- const hdr = Buffer.allocUnsafe(4);
117
- fs.readSync(fd, hdr, 0, 4, 0);
118
- hdr.writeUInt32LE(hdr.readUInt32LE(0) + list.length, 0);
119
- fs.writeSync(fd, hdr, 0, 4, 0);
120
- } finally {
121
- fs.closeSync(fd);
122
- }
123
- return offsets;
124
- }
125
-
126
- /**
127
- * Cosine-score EVERY vector in an `.embeddings.bin` against a query vector,
128
- * streaming from disk in bounded buffers, and return the top-N entry keys.
129
- *
130
- * This is the semantic channel for the disk-backed stores. The previous design
131
- * scored vectors only for chunks that already matched lexically, which silently
132
- * disabled semantic search exactly where it matters: conceptual queries that
133
- * share no tokens with the code. A sequential scan of the bin is fast (the OS
134
- * page cache holds it after the first query) and keeps resident RAM flat.
135
- *
136
- * @param {{fd?: number, buffer?: Buffer}} source Open fd or in-memory buffer of the bin.
137
- * @param {Float32Array|number[]} queryVector
138
- * @param {{topN?: number, minScore?: number}} [opts]
139
- * @returns {Array<{key: string, score: number}>} Sorted by score, descending.
140
- */
141
- export function scanEmbeddingBinary({ fd = -1, buffer = null }, queryVector, { topN = 200, minScore = 0 } = {}) {
142
- const qDim = queryVector.length;
143
- let qNorm = 0;
144
- for (let d = 0; d < qDim; d++) qNorm += queryVector[d] * queryVector[d];
145
- qNorm = Math.sqrt(qNorm);
146
- if (qNorm === 0) return [];
147
-
148
- const top = []; // sorted desc, capped at topN
149
- let worst = -Infinity;
150
- const push = (key, score) => {
151
- if (score <= minScore) return;
152
- if (top.length >= topN && score <= worst) return;
153
- let lo = 0, hi = top.length;
154
- while (lo < hi) { const mid = (lo + hi) >> 1; if (top[mid].score >= score) lo = mid + 1; else hi = mid; }
155
- top.splice(lo, 0, { key, score });
156
- if (top.length > topN) top.pop();
157
- worst = top[top.length - 1].score;
158
- };
159
-
160
- // Parses complete entries in buf starting at startOff; returns the offset of
161
- // the first incomplete entry (callers carry the remainder into the next read).
162
- const processEntries = (buf, startOff) => {
163
- let off = startOff;
164
- for (;;) {
165
- if (off + 4 > buf.length) return off;
166
- const keyLen = buf.readUInt32LE(off);
167
- if (off + 4 + keyLen + 4 > buf.length) return off;
168
- const dim = buf.readUInt32LE(off + 4 + keyLen);
169
- const entryEnd = off + 4 + keyLen + 4 + dim * 4;
170
- if (entryEnd > buf.length) return off;
171
- if (dim === qDim) {
172
- const key = buf.toString('utf8', off + 4, off + 4 + keyLen);
173
- let dp = 0, nv = 0;
174
- let p = off + 4 + keyLen + 4;
175
- for (let d = 0; d < dim; d++, p += 4) {
176
- const v = buf.readFloatLE(p);
177
- dp += queryVector[d] * v;
178
- nv += v * v;
179
- }
180
- const n = Math.sqrt(nv);
181
- if (n > 0) push(key, dp / (qNorm * n));
182
- }
183
- off = entryEnd;
184
- }
185
- };
186
-
187
- if (buffer) { processEntries(buffer, 4); return top; }
188
- if (fd < 0) return [];
189
-
190
- const CHUNK = 4 * 1024 * 1024;
191
- let filePos = 0;
192
- let carry = null;
193
- let first = true;
194
- for (;;) {
195
- const buf = Buffer.allocUnsafe(CHUNK);
196
- const read = fs.readSync(fd, buf, 0, CHUNK, filePos);
197
- if (read <= 0) break;
198
- filePos += read;
199
- const work = carry && carry.length
200
- ? Buffer.concat([carry, buf.subarray(0, read)])
201
- : buf.subarray(0, read);
202
- const consumed = processEntries(work, first ? 4 : 0);
203
- first = false;
204
- carry = work.subarray(consumed);
205
- if (read < CHUNK) break;
206
- }
207
- return top;
208
- }
209
-
210
- // ─── Binary-quantized vector sketch ─────────────────────────────────────────────
211
- // The streaming full scan above is exact but O(corpus × dim) per query: ~104 ms
212
- // at 50k chunks and ~519 ms at 200k (measured warm). The sketch removes that
213
- // ceiling while keeping resident RAM bounded: each vector is reduced to its SIGN
214
- // BITS (768 dims → 96 bytes, 0.1% of the float32 data). A query is answered by
215
- // (1) a Hamming-distance pass over the packed bits (XOR + popcount over Uint32
216
- // words — a few ms even at 200k rows), keeping the best `oversample × topN`
217
- // candidates, then (2) an exact cosine rescore of only those candidates, pread
218
- // from the bin. Sign quantization of normalized embeddings preserves cosine
219
- // ordering well enough that a 4× oversampled rescore recovers the exact top-N
220
- // (validated in test/unit.mjs against the exhaustive scan).
221
- //
222
- // The sketch is APPEND-AWARE: the bin only ever grows between full rebuilds
223
- // (the daemon appends), so `updateVectorSketch` re-reads just the tail beyond
224
- // what it has already consumed, keeping refresh O(changed chunks).
225
-
226
- function popcnt32(x) {
227
- x -= (x >> 1) & 0x55555555;
228
- x = (x & 0x33333333) + ((x >> 2) & 0x33333333);
229
- x = (x + (x >> 4)) & 0x0f0f0f0f;
230
- return (x * 0x01010101) >> 24;
231
- }
232
-
233
- /** Pack a float vector's sign bits into `words` Uint32 words. */
234
- function quantizeToBits(vec, out, base, words) {
235
- for (let w = 0; w < words; w++) {
236
- let bits = 0;
237
- const d0 = w * 32;
238
- const dMax = Math.min(d0 + 32, vec.length);
239
- for (let d = d0; d < dMax; d++) {
240
- if (vec[d] > 0) bits |= (1 << (d - d0));
241
- }
242
- out[base + w] = bits;
243
- }
244
- }
245
-
246
- /**
247
- * Build or incrementally extend a binary sketch of an `.embeddings.bin`.
248
- *
249
- * @param {object|null} sketch Existing sketch to extend, or null to create.
250
- * @param {{fd?: number, buffer?: Buffer}} source
251
- * @returns {object} sketch { dim, words, n, keys[], offsets[], bits: Uint32Array,
252
- * consumed, headerCount, firstKey }
253
- */
254
- export function updateVectorSketch(sketch, { fd = -1, buffer = null }) {
255
- const readAt = (buf, pos, len) => {
256
- if (buffer) return buffer.subarray(pos, pos + len);
257
- const b = Buffer.allocUnsafe(len);
258
- const r = fs.readSync(fd, b, 0, len, pos);
259
- return r < len ? b.subarray(0, r) : b;
260
- };
261
- const fileSize = buffer ? buffer.length : (fd >= 0 ? fs.fstatSync(fd).size : 0);
262
- if (fileSize < 4) return sketch;
263
-
264
- const headerCount = readAt(null, 0, 4).readUInt32LE(0);
265
- if (!sketch) {
266
- sketch = {
267
- dim: 0, words: 0, n: 0, keys: [], offsets: [],
268
- bits: new Uint32Array(0), consumed: 4, headerCount: 0, firstKey: null,
269
- };
270
- }
271
- if (fileSize <= sketch.consumed) return sketch;
272
-
273
- // Read the unseen tail in large blocks. No carry buffers / Buffer.concat —
274
- // the next block simply re-reads from the last complete entry boundary
275
- // (at most one partial entry of overlap), so the build leaves no transient
276
- // garbage behind. Sign bits are quantized straight off the block buffer.
277
- const CHUNK = 8 * 1024 * 1024;
278
- const reusable = buffer ? null : Buffer.allocUnsafe(CHUNK);
279
- let pos = sketch.consumed;
280
- while (pos < fileSize) {
281
- const len = Math.min(CHUNK, fileSize - pos);
282
- let work;
283
- if (buffer) {
284
- work = buffer.subarray(pos, pos + len);
285
- } else {
286
- const r = fs.readSync(fd, reusable, 0, len, pos);
287
- work = r < len ? reusable.subarray(0, r) : (len < CHUNK ? reusable.subarray(0, len) : reusable);
288
- }
289
- let off = 0;
290
- let parsedAny = false;
291
- for (;;) {
292
- if (off + 4 > work.length) break;
293
- const keyLen = work.readUInt32LE(off);
294
- if (off + 4 + keyLen + 4 > work.length) break;
295
- const dim = work.readUInt32LE(off + 4 + keyLen);
296
- const entryEnd = off + 4 + keyLen + 4 + dim * 4;
297
- if (entryEnd > work.length) break;
298
-
299
- const key = work.toString('utf8', off + 4, off + 4 + keyLen);
300
- const floatBase = off + 4 + keyLen + 4;
301
- if (sketch.dim === 0) {
302
- sketch.dim = dim;
303
- sketch.words = Math.ceil(dim / 32);
304
- }
305
- if (dim === sketch.dim) {
306
- if ((sketch.n + 1) * sketch.words > sketch.bits.length) {
307
- const grown = new Uint32Array(Math.max(1024 * sketch.words, sketch.bits.length * 2));
308
- grown.set(sketch.bits);
309
- sketch.bits = grown;
310
- }
311
- // Quantize sign bits directly from the block buffer.
312
- const bitBase = sketch.n * sketch.words;
313
- for (let w = 0; w < sketch.words; w++) {
314
- let bits = 0;
315
- const d0 = w * 32;
316
- const dMax = Math.min(d0 + 32, dim);
317
- for (let d = d0; d < dMax; d++) {
318
- if (work.readFloatLE(floatBase + d * 4) > 0) bits |= (1 << (d - d0));
319
- }
320
- sketch.bits[bitBase + w] = bits;
321
- }
322
- sketch.keys.push(key);
323
- sketch.offsets.push(pos + floatBase);
324
- if (sketch.firstKey === null) sketch.firstKey = key;
325
- sketch.n++;
326
- }
327
- off = entryEnd;
328
- parsedAny = true;
329
- }
330
- if (!parsedAny) break; // partial entry larger than the block — corrupt tail
331
- pos += off; // resume exactly at the last complete entry boundary
332
- sketch.consumed = pos;
333
- }
334
- sketch.headerCount = headerCount;
335
- return sketch;
336
- }
337
-
338
- /**
339
- * Approximate top-N search via the sketch + exact cosine rescore from the bin.
340
- * Drop-in replacement for scanEmbeddingBinary (same return shape), ~20–50×
341
- * faster at scale.
342
- *
343
- * @returns {Array<{key: string, score: number}>} sorted by exact cosine, desc.
344
- */
345
- export function searchVectorSketch(sketch, { fd = -1, buffer = null }, queryVector, {
346
- topN = 200, minScore = 0, oversample = 4,
347
- } = {}) {
348
- if (!sketch || sketch.n === 0 || queryVector.length !== sketch.dim) return [];
349
- const words = sketch.words;
350
-
351
- // 1. Quantize the query and rank rows by Hamming distance (bounded top-M).
352
- const qbits = new Uint32Array(words);
353
- quantizeToBits(queryVector, qbits, 0, words);
354
-
355
- const M = Math.min(sketch.n, Math.max(topN * oversample, 64));
356
- const candIdx = new Int32Array(M);
357
- const candHam = new Int32Array(M);
358
- let count = 0, worst = -1;
359
- const bits = sketch.bits;
360
- for (let i = 0; i < sketch.n; i++) {
361
- const base = i * words;
362
- let ham = 0;
363
- for (let w = 0; w < words; w++) ham += popcnt32((bits[base + w] ^ qbits[w]) >>> 0);
364
- if (count >= M && ham >= worst) continue;
365
- // bounded insertion sort (ascending hamming)
366
- let lo = 0, hi = count;
367
- while (lo < hi) { const mid = (lo + hi) >> 1; if (candHam[mid] <= ham) lo = mid + 1; else hi = mid; }
368
- const end = Math.min(count, M - 1);
369
- for (let j = end; j > lo; j--) { candHam[j] = candHam[j - 1]; candIdx[j] = candIdx[j - 1]; }
370
- candHam[lo] = ham; candIdx[lo] = i;
371
- if (count < M) count++;
372
- worst = candHam[count - 1];
373
- }
374
-
375
- // 2. Exact cosine rescore of the candidates only (pread per candidate).
376
- let qNorm = 0;
377
- for (let d = 0; d < queryVector.length; d++) qNorm += queryVector[d] * queryVector[d];
378
- qNorm = Math.sqrt(qNorm);
379
- if (qNorm === 0) return [];
380
-
381
- const byteLen = sketch.dim * 4;
382
- const raw = Buffer.allocUnsafe(byteLen);
383
- const scored = [];
384
- for (let c = 0; c < count; c++) {
385
- const i = candIdx[c];
386
- let view;
387
- if (buffer) {
388
- view = buffer.subarray(sketch.offsets[i], sketch.offsets[i] + byteLen);
389
- if (view.length < byteLen) continue;
390
- } else {
391
- const read = fs.readSync(fd, raw, 0, byteLen, sketch.offsets[i]);
392
- if (read < byteLen) continue;
393
- view = raw;
394
- }
395
- let dp = 0, nv = 0;
396
- for (let d = 0; d < sketch.dim; d++) {
397
- const v = view.readFloatLE(d * 4);
398
- dp += queryVector[d] * v;
399
- nv += v * v;
400
- }
401
- const n = Math.sqrt(nv);
402
- if (n === 0) continue;
403
- const score = dp / (qNorm * n);
404
- if (score > minScore) scored.push({ key: sketch.keys[i], score });
405
- }
406
- scored.sort((a, b) => b.score - a.score);
407
- return scored.slice(0, topN);
408
- }
12
+ isNaturalLanguageQuery, WINDOW_VEC_SUFFIX, EMBEDDING_MAX_WINDOWS, baseEmbeddingKey,
13
+ } from '../search-core.mjs';
14
+ import {
15
+ HierarchicalNSW, HNSW_THRESHOLD, LAZY_VEC_THRESHOLD, SKETCH_THRESHOLD,
16
+ writeEmbeddingBinary, scanEmbeddingBinary, updateVectorSketch, searchVectorSketch,
17
+ } from './binary.mjs';
409
18
 
410
19
  export class MemoryGraphIndex {
411
20
  /**
@@ -469,10 +78,13 @@ export class MemoryGraphIndex {
469
78
  if (!fs.existsSync(this.indexPath)) return;
470
79
  const data = JSON.parse(fs.readFileSync(this.indexPath, 'utf-8'));
471
80
  this.graph = data.graph || { dependencies: {}, importedBy: {} };
81
+ // Does this corpus carry LLM enrichment? Drives the NL vector-channel weight
82
+ // in fuseAndRank (strong summary vectors earn full weight; plain code vectors
83
+ // stay a low-weight rescue). Set during the chunk load loop below.
84
+ this._corpusEnriched = false;
472
85
 
473
86
  const chunkCount = (data.chunks || []).length;
474
87
 
475
- // Decide loading strategy based on corpus size and cacheEmbeddings flag
476
88
  this._lazyMode = (!this._cacheEmbeddings) && chunkCount >= LAZY_VEC_THRESHOLD;
477
89
 
478
90
  if (fs.existsSync(this._embeddingPath)) {
@@ -482,13 +94,10 @@ export class MemoryGraphIndex {
482
94
  // For very large corpora: open persistent fd, release buffer from heap
483
95
  if (chunkCount >= 50000) {
484
96
  try { this._vecFd = fs.openSync(this._embeddingPath, 'r'); } catch { this._vecFd = -1; }
485
- // binBuf goes out of scope → GC can collect it
486
97
  } else {
487
- // Medium-large: keep buffer for zero-copy slice access
488
98
  this._embeddingBuf = binBuf;
489
99
  }
490
100
  } else {
491
- // Eager: fill embeddingCache (needed by indexer + small-corpus MCP server)
492
101
  this._loadEmbeddingBinary(binBuf);
493
102
  }
494
103
  } else if (data.embeddingCache) {
@@ -499,6 +108,9 @@ export class MemoryGraphIndex {
499
108
 
500
109
  for (const chunk of (data.chunks || [])) {
501
110
  this.chunks.set(chunk.id, chunk);
111
+ if (!this._corpusEnriched && (chunk.summary || (chunk.concepts && chunk.concepts.length))) {
112
+ this._corpusEnriched = true;
113
+ }
502
114
 
503
115
  // Eager vector population. Vectors are keyed by embeddingKeyFor(chunk)
504
116
  // (content_hash + enrichment digest); plain content_hash is the
@@ -507,10 +119,16 @@ export class MemoryGraphIndex {
507
119
  const vecKey = embeddingKeyFor(chunk);
508
120
  if (chunk.content_hash && this.embeddingCache.has(vecKey)) {
509
121
  this.vectors.set(chunk.id, this.embeddingCache.get(vecKey));
510
- // Summary-only second vector (enriched chunks): stored under a
511
- // pseudo row id; searchHybrid folds hits back onto the chunk id.
122
+ // Summary-only and per-window vectors (enriched / oversized chunks):
123
+ // stored under pseudo row ids; searchHybrid folds hits back onto the
124
+ // chunk id (max-sim across all of the chunk's vectors).
512
125
  const sVec = this.embeddingCache.get(vecKey + SUMMARY_VEC_SUFFIX);
513
126
  if (sVec) this.vectors.set(chunk.id + SUMMARY_VEC_SUFFIX, sVec);
127
+ for (let i = 1; i < EMBEDDING_MAX_WINDOWS; i++) {
128
+ const wVec = this.embeddingCache.get(vecKey + WINDOW_VEC_SUFFIX + i);
129
+ if (!wVec) break;
130
+ this.vectors.set(chunk.id + WINDOW_VEC_SUFFIX + i, wVec);
131
+ }
514
132
  } else if (chunk.content_hash && this.embeddingCache.has(chunk.content_hash)) {
515
133
  this.vectors.set(chunk.id, this.embeddingCache.get(chunk.content_hash));
516
134
  } else if (chunk.embedding) {
@@ -520,12 +138,9 @@ export class MemoryGraphIndex {
520
138
  }
521
139
  }
522
140
 
523
- // Build inverted lexical index from the shared document builder
524
- // (search-core.buildLexicalDocument) — identical text across backends.
525
141
  const deps = this.graph.dependencies[chunk.file_path] || [];
526
142
  this._indexLexical(chunk.id, buildLexicalDocument(chunk, deps), chunk.file_path);
527
143
 
528
- // Build symbol table (Frontier 2)
529
144
  if (chunk.name && chunk.name !== 'anonymous') {
530
145
  const n = chunk.name.toLowerCase();
531
146
  if (!this.symbolTable.has(n)) this.symbolTable.set(n, new Set());
@@ -606,7 +221,7 @@ export class MemoryGraphIndex {
606
221
  // ─── Lexical index (TRUE inverted index) ──────────────────────────────────
607
222
 
608
223
  _indexLexical(chunkId, text, filePath = '') {
609
- const tokens = tokenize(text);
224
+ const tokens = tokenize(text); // raw + additive Porter stems
610
225
  if (tokens.length === 0) return;
611
226
 
612
227
  const termCounts = new Map();
@@ -614,16 +229,16 @@ export class MemoryGraphIndex {
614
229
  termCounts.set(token, (termCounts.get(token) || 0) + 1);
615
230
  }
616
231
 
617
- // Track document length for BM25 length normalization.
618
- // Only content tokens are counted — path tokens are handled separately
619
- // in searchHybrid to avoid BM25 length normalisation amplifying short
620
- // export stubs that share a path with a larger implementation.
621
- this.docLens.set(chunkId, tokens.length);
622
- this.totalDocLen += tokens.length;
232
+ // Additive stem tokens earn their own postings but must NOT inflate docLen —
233
+ // inflated length would perturb BM25 normalisation for exact symbolic matches.
234
+ // raw-based docLen keeps raw-term BM25 byte-identical to the pre-stem index.
235
+ // Path tokens are tallied separately in searchHybrid, not here.
236
+ const rawLen = tokenize(text, false).length;
237
+ this.docLens.set(chunkId, rawLen);
238
+ this.totalDocLen += rawLen;
623
239
 
624
240
  const chunkTokenSet = new Set();
625
241
  for (const [term, count] of termCounts) {
626
- // Store raw term count — BM25 applies saturation at search time
627
242
  this.df.set(term, (this.df.get(term) || 0) + 1);
628
243
  let posting = this.invertedIndex.get(term);
629
244
  if (!posting) { posting = new Map(); this.invertedIndex.set(term, posting); }
@@ -638,7 +253,7 @@ export class MemoryGraphIndex {
638
253
  // Path matching is done multiplicatively in searchHybrid, which avoids
639
254
  // amplifying short stubs that happen to share a path with a long function.
640
255
  if (filePath) {
641
- const pathTokenSet = new Set(tokenize(filePath.replace(/[/\-_.]/g, ' ')));
256
+ const pathTokenSet = new Set(tokenize(filePath.replace(/[/\-_.]/g, ' '), false));
642
257
  if (!this.pathTokens) this.pathTokens = new Map();
643
258
  this.pathTokens.set(chunkId, pathTokenSet);
644
259
  }
@@ -659,7 +274,6 @@ export class MemoryGraphIndex {
659
274
  else this.df.set(term, freq - 1);
660
275
  }
661
276
  }
662
- // Update BM25 length accounting
663
277
  const dl = this.docLens.get(chunkId);
664
278
  if (dl !== undefined) {
665
279
  this.totalDocLen = Math.max(0, this.totalDocLen - dl);
@@ -671,7 +285,10 @@ export class MemoryGraphIndex {
671
285
  }
672
286
 
673
287
  _searchLexical(queryText) {
674
- const queryTokens = tokenize(queryText);
288
+ // Asymmetric stemming: the index always carries stems, but only stem the
289
+ // QUERY for natural-language/behavioural queries — symbolic name lookups
290
+ // stay exact so stem collisions never dilute a precise symbol match.
291
+ const queryTokens = tokenize(queryText, isNaturalLanguageQuery(queryText));
675
292
  const scores = new Map();
676
293
  const avgdl = this.docCount > 0 ? this.totalDocLen / this.docCount : 1;
677
294
 
@@ -739,7 +356,6 @@ export class MemoryGraphIndex {
739
356
  this._vecNorms = norms;
740
357
  this._matrixDirty = false;
741
358
 
742
- // HNSW for large eager corpora
743
359
  if (HierarchicalNSW && n >= HNSW_THRESHOLD) {
744
360
  try {
745
361
  const hnsw = new HierarchicalNSW('cosine', dim);
@@ -753,7 +369,7 @@ export class MemoryGraphIndex {
753
369
  }
754
370
  this._hnsw = hnsw;
755
371
  } catch (e) {
756
- process.stderr.write(`[core-engine] HNSW build failed: ${e.message}\n`);
372
+ process.stderr.write(`[engine/memory] HNSW build failed: ${e.message}\n`);
757
373
  this._hnsw = null;
758
374
  }
759
375
  }
@@ -773,7 +389,6 @@ export class MemoryGraphIndex {
773
389
  qNorm = Math.sqrt(qNorm);
774
390
  if (qNorm === 0) return [];
775
391
 
776
- // HNSW fast path
777
392
  if (this._hnsw) {
778
393
  const topK = Math.min(200, n);
779
394
  try {
@@ -788,7 +403,6 @@ export class MemoryGraphIndex {
788
403
  } catch { /* fall through to flat scan */ }
789
404
  }
790
405
 
791
- // Exact flat scan
792
406
  const results = [];
793
407
  for (let i = 0; i < n; i++) {
794
408
  const base = i * dim;
@@ -813,9 +427,14 @@ export class MemoryGraphIndex {
813
427
  if (!this._embeddingBuf && this._vecFd < 0) return [];
814
428
  // Large corpora: binary sketch (built once on first vector query) replaces
815
429
  // the exact O(corpus) scan with a Hamming prefilter + bounded rescore.
816
- // 10k threshold matches the SQLite store so backends stay rank-identical
817
- // wherever the exact scan is still fast.
818
- if (!this._sketch && this._vecOffsets.size >= 10000) {
430
+ // SKETCH_THRESHOLD is shared with the SQLite store so both switch to the
431
+ // sketch at the same vector count. NOTE: this lazy path only runs when the
432
+ // store is in lazy mode (cacheEmbeddings off AND chunkCount ≥ LAZY_VEC_THRESHOLD);
433
+ // a corpus with < LAZY_VEC_THRESHOLD chunks but ≥ SKETCH_THRESHOLD vectors
434
+ // (heavy enrichment/oversized windows) still scans exactly here while SQLite
435
+ // sketches — a narrow band worth a dedicated scale parity test before relying
436
+ // on byte-identical top-5 above the threshold.
437
+ if (!this._sketch && this._vecOffsets.size >= SKETCH_THRESHOLD) {
819
438
  try { this._sketch = updateVectorSketch(null, source); } catch { this._sketch = null; }
820
439
  }
821
440
  const hits = this._sketch
@@ -834,6 +453,7 @@ export class MemoryGraphIndex {
834
453
  const key = embeddingKeyFor(c);
835
454
  put(key, c.id);
836
455
  put(key + SUMMARY_VEC_SUFFIX, c.id); // summary-only vector → same chunk
456
+ for (let i = 1; i < EMBEDDING_MAX_WINDOWS; i++) put(key + WINDOW_VEC_SUFFIX + i, c.id); // window vectors → same chunk
837
457
  if (key !== c.content_hash) put(c.content_hash, c.id); // legacy bins
838
458
  }
839
459
  this._keyToIds = map;
@@ -858,12 +478,12 @@ export class MemoryGraphIndex {
858
478
  // zero lexical overlap, which the old TF-IDF prefilter never could.
859
479
  vectorResults = this._scanVectorsLazy(queryVector, minScore);
860
480
  } else {
861
- // Eager rows include summary-only pseudo ids (`<id>|s`) — fold each
862
- // back onto its chunk via the shared finalizer (best score per
863
- // chunk, deterministic order, same cap as the disk-backed paths).
481
+ // Eager rows include summary-only and per-window pseudo ids
482
+ // (`<id>|s`, `<id>|wN`) — fold each back onto its chunk via the shared
483
+ // finalizer (best/max-sim score per chunk, deterministic order, same
484
+ // cap as the disk-backed paths).
864
485
  const entries = this._searchVector(queryVector, minScore).map(r => ({
865
- id: r.id.endsWith(SUMMARY_VEC_SUFFIX)
866
- ? r.id.slice(0, -SUMMARY_VEC_SUFFIX.length) : r.id,
486
+ id: baseEmbeddingKey(r.id),
867
487
  score: r.score,
868
488
  }));
869
489
  vectorResults = finalizeVectorCandidates(entries);
@@ -872,9 +492,6 @@ export class MemoryGraphIndex {
872
492
  vectorResults = [];
873
493
  }
874
494
 
875
- // Fusion + boost ladder lives in search-core.mjs so the in-memory engine
876
- // and the SQLite store rank identically. Backend state is reached through
877
- // accessors; the math is measured once and shared.
878
495
  return fuseAndRank({
879
496
  lexicalResults,
880
497
  vectorResults,
@@ -886,8 +503,8 @@ export class MemoryGraphIndex {
886
503
  topK,
887
504
  queryText,
888
505
  exactBoostName,
889
- // Equivalent to the former full-scan (symbolTable is keyed by name.toLowerCase()),
890
- // but O(1) and reusable by the SQLite backend.
506
+ corpusEnriched: this._corpusEnriched,
507
+ // O(1) vs the old full-scan; same contract implemented by the SQLite backend.
891
508
  resolveExact: (term) => this.symbolTable.get(term) || [],
892
509
  });
893
510
  }
@@ -899,7 +516,7 @@ export class MemoryGraphIndex {
899
516
  id: c.id, file_path: c.file_path, node_type: c.node_type,
900
517
  name: c.name, docstring: c.docstring || '', code_snippet: c.code_snippet,
901
518
  content_hash: c.content_hash, start_line: c.start_line, end_line: c.end_line,
902
- calls: c.calls || [], params: c.params || [],
519
+ calls: c.calls || [], call_sites: c.call_sites || [], params: c.params || [],
903
520
  return_type: c.return_type || '', class_context: c.class_context || '',
904
521
  type_refs: c.type_refs || [], decorators: c.decorators || [],
905
522
  extends: c.extends || [],
@@ -923,7 +540,7 @@ export class MemoryGraphIndex {
923
540
  this._saveTimer = setTimeout(async () => {
924
541
  this._saveTimer = null;
925
542
  try { await this.save(); }
926
- catch (err) { process.stderr.write(`[core-engine] ❌ Async save failed: ${err.message}\n`); }
543
+ catch (err) { process.stderr.write(`[engine/memory] ❌ Async save failed: ${err.message}\n`); }
927
544
  }, delayMs);
928
545
  }
929
546
 
@@ -961,6 +578,7 @@ export class MemoryGraphIndex {
961
578
  this._removeLexical(id);
962
579
  this.removeVector(id);
963
580
  this.removeVector(id + SUMMARY_VEC_SUFFIX);
581
+ for (let i = 1; i < EMBEDDING_MAX_WINDOWS; i++) this.removeVector(id + WINDOW_VEC_SUFFIX + i);
964
582
  this._removeSymbol(chunk);
965
583
  this.chunks.delete(id);
966
584
  }
@@ -977,6 +595,10 @@ export class MemoryGraphIndex {
977
595
  if (vec) this.addVector(chunk.id, vec);
978
596
  const sVec = this.embeddingCache.get(vecKey + SUMMARY_VEC_SUFFIX);
979
597
  if (sVec) this.addVector(chunk.id + SUMMARY_VEC_SUFFIX, sVec);
598
+ for (let i = 1; i < EMBEDDING_MAX_WINDOWS; i++) {
599
+ const wVec = this.embeddingCache.get(vecKey + WINDOW_VEC_SUFFIX + i);
600
+ if (wVec) this.addVector(chunk.id + WINDOW_VEC_SUFFIX + i, wVec);
601
+ }
980
602
  this._indexLexical(chunk.id, buildLexicalDocument(chunk, imports), chunk.file_path);
981
603
  this.chunks.set(chunk.id, chunk);
982
604
  if (chunk.name && chunk.name !== 'anonymous') {
@@ -1065,6 +687,51 @@ export class MemoryGraphIndex {
1065
687
  return out;
1066
688
  }
1067
689
 
690
+ /**
691
+ * Chunks that reference a symbol *by name* as a type (`type_refs`) or as a
692
+ * base class/interface (`extends`) — the non-call half of symbol-level
693
+ * references. Calls are served by findCallers; mcp/topology.mjs findReferences fuses
694
+ * the two and classifies each by confidence. Case-insensitive exact match.
695
+ * @returns {object[]}
696
+ */
697
+ findReferers(symbol) {
698
+ const key = String(symbol).toLowerCase().trim();
699
+ if (!key) return [];
700
+ const out = [];
701
+ for (const c of this.chunks.values()) {
702
+ // A class/type lists its own name in type_refs (the name node is a
703
+ // type_identifier) — that self-mention is a definition, not a reference.
704
+ if (c.name && c.name.toLowerCase() === key) continue;
705
+ const hit = (c.type_refs && c.type_refs.some(t => t.toLowerCase() === key))
706
+ || (c.extends && c.extends.some(t => t.toLowerCase() === key));
707
+ if (hit) out.push(c);
708
+ }
709
+ return out;
710
+ }
711
+
712
+ /**
713
+ * HTTP routes filtered by method and/or path prefix, each augmented with its
714
+ * resolved handler chunk (`chunk`, looked up by handler_chunk_id, or null).
715
+ * `graph.routes` round-trips verbatim through load(); a route-less index yields
716
+ * []. Method is matched case-insensitively (HTTP verbs are upper-cased); the
717
+ * path prefix match is case-insensitive. Mirrors SqliteGraphStore.findRoutes so
718
+ * both backends return identical records.
719
+ * @returns {object[]}
720
+ */
721
+ findRoutes({ method = null, pathPrefix = null } = {}) {
722
+ const routes = (this.graph && this.graph.routes) || [];
723
+ const m = method ? String(method).toUpperCase().trim() : null;
724
+ const p = pathPrefix ? String(pathPrefix).toLowerCase() : null;
725
+ const out = [];
726
+ for (const r of routes) {
727
+ if (m && String(r.method || '').toUpperCase() !== m) continue;
728
+ if (p && !String(r.path || '').toLowerCase().startsWith(p)) continue;
729
+ const chunk = r.handler_chunk_id ? (this.chunks.get(r.handler_chunk_id) || null) : null;
730
+ out.push({ ...r, chunk });
731
+ }
732
+ return out;
733
+ }
734
+
1068
735
  /** Lazily iterate every chunk (cursor-friendly parity with the SQLite store). */
1069
736
  *iterateChunks() { yield* this.chunks.values(); }
1070
737
 
@@ -1078,11 +745,11 @@ export class MemoryGraphIndex {
1078
745
  vectorCount() {
1079
746
  if (this._lazyMode) {
1080
747
  let n = 0;
1081
- for (const k of this._vecOffsets.keys()) if (!k.endsWith(SUMMARY_VEC_SUFFIX)) n++;
748
+ for (const k of this._vecOffsets.keys()) if (baseEmbeddingKey(k) === k) n++;
1082
749
  return n;
1083
750
  }
1084
751
  let n = 0;
1085
- for (const k of this.vectors.keys()) if (!k.endsWith(SUMMARY_VEC_SUFFIX)) n++;
752
+ for (const k of this.vectors.keys()) if (baseEmbeddingKey(k) === k) n++;
1086
753
  return n;
1087
754
  }
1088
755