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
@@ -1,81 +1,20 @@
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.
6
6
  * @license MIT
7
7
  */
8
8
  import fs from 'fs';
9
-
10
- export const EMBEDDING_CONTEXT_LIMIT = 8000;
11
-
12
- // Thresholds for adaptive vector loading strategy
13
- const HNSW_THRESHOLD = 5000; // Build HNSW index above this (eager mode only)
14
- const LAZY_VEC_THRESHOLD = 10000; // Switch to lazy (disk-backed) vector access above this
15
- const TFIDF_PREFILTER_K = 2000; // TF-IDF candidates fed into lazy vector scoring
16
-
17
- // Optional HNSW accelerator for medium corpora (eager mode only)
18
- let HierarchicalNSW = null;
19
- try {
20
- const mod = await import('hnswlib-node');
21
- HierarchicalNSW = mod.HierarchicalNSW ?? mod.default?.HierarchicalNSW ?? null;
22
- } catch { /* not installed — flat scan used */ }
23
-
24
- export function truncateForEmbedding(text) {
25
- return text.length > EMBEDDING_CONTEXT_LIMIT ? text.slice(0, EMBEDDING_CONTEXT_LIMIT) : text;
26
- }
27
-
28
- export function cosineSimilarity(vecA, vecB) {
29
- let dot = 0, nA = 0, nB = 0;
30
- for (let i = 0; i < vecA.length; i++) {
31
- dot += vecA[i] * vecB[i];
32
- nA += vecA[i] * vecA[i];
33
- nB += vecB[i] * vecB[i];
34
- }
35
- return nA === 0 || nB === 0 ? 0 : dot / (Math.sqrt(nA) * Math.sqrt(nB));
36
- }
37
-
38
- function tokenize(text) {
39
- if (!text) return [];
40
- const rawTokens = text.split(/[\s\W_]+/);
41
- const tokens = [];
42
- for (const word of rawTokens) {
43
- if (word.length < 2) continue;
44
- tokens.push(word.toLowerCase());
45
- const camelParts = word.replace(/([a-z])([A-Z])/g, '$1 $2').split(' ');
46
- if (camelParts.length > 1) {
47
- for (const part of camelParts) {
48
- if (part.length >= 2) tokens.push(part.toLowerCase());
49
- }
50
- }
51
- }
52
- return tokens;
53
- }
54
-
55
- /**
56
- * Serializes embeddingCache to compact binary.
57
- * Format per entry: [uint32 hashLen][utf8 hash][uint32 dim][float32 × dim]
58
- */
59
- export function writeEmbeddingBinary(embeddingCache) {
60
- const entries = embeddingCache instanceof Map
61
- ? Array.from(embeddingCache.entries())
62
- : Object.entries(embeddingCache);
63
- let size = 4;
64
- for (const [hash, vec] of entries) {
65
- size += 4 + Buffer.byteLength(hash, 'utf8') + 4 + vec.length * 4;
66
- }
67
- const buf = Buffer.allocUnsafe(size);
68
- let off = 0;
69
- buf.writeUInt32LE(entries.length, off); off += 4;
70
- for (const [hash, vec] of entries) {
71
- const hashBytes = Buffer.from(hash, 'utf8');
72
- buf.writeUInt32LE(hashBytes.length, off); off += 4;
73
- hashBytes.copy(buf, off); off += hashBytes.length;
74
- buf.writeUInt32LE(vec.length, off); off += 4;
75
- for (let d = 0; d < vec.length; d++) { buf.writeFloatLE(vec[d], off); off += 4; }
76
- }
77
- return buf;
78
- }
9
+ import {
10
+ tokenize, okapiIdf, bm25Score, fuseAndRank, buildLexicalDocument, embeddingKeyFor,
11
+ SUMMARY_VEC_SUFFIX, LEXICAL_FUSION_CAP, VECTOR_SCAN_RAW_N, finalizeVectorCandidates,
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';
79
18
 
80
19
  export class MemoryGraphIndex {
81
20
  /**
@@ -126,6 +65,8 @@ export class MemoryGraphIndex {
126
65
  this._embeddingBuf = null; // Buffer kept for small-lazy corpora (<50k)
127
66
  this._vecFd = -1; // open fd for disk-backed access (≥50k)
128
67
  this._lazyMode = false;
68
+ this._keyToIds = null; // embedding key → chunkId[] (built lazily for scans)
69
+ this._sketch = null; // binary-quantized sketch (lazy mode, large corpora)
129
70
 
130
71
  this.rrfK = rrfK;
131
72
  this._saveTimer = null;
@@ -137,10 +78,13 @@ export class MemoryGraphIndex {
137
78
  if (!fs.existsSync(this.indexPath)) return;
138
79
  const data = JSON.parse(fs.readFileSync(this.indexPath, 'utf-8'));
139
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;
140
85
 
141
86
  const chunkCount = (data.chunks || []).length;
142
87
 
143
- // Decide loading strategy based on corpus size and cacheEmbeddings flag
144
88
  this._lazyMode = (!this._cacheEmbeddings) && chunkCount >= LAZY_VEC_THRESHOLD;
145
89
 
146
90
  if (fs.existsSync(this._embeddingPath)) {
@@ -150,13 +94,10 @@ export class MemoryGraphIndex {
150
94
  // For very large corpora: open persistent fd, release buffer from heap
151
95
  if (chunkCount >= 50000) {
152
96
  try { this._vecFd = fs.openSync(this._embeddingPath, 'r'); } catch { this._vecFd = -1; }
153
- // binBuf goes out of scope → GC can collect it
154
97
  } else {
155
- // Medium-large: keep buffer for zero-copy slice access
156
98
  this._embeddingBuf = binBuf;
157
99
  }
158
100
  } else {
159
- // Eager: fill embeddingCache (needed by indexer + small-corpus MCP server)
160
101
  this._loadEmbeddingBinary(binBuf);
161
102
  }
162
103
  } else if (data.embeddingCache) {
@@ -167,10 +108,28 @@ export class MemoryGraphIndex {
167
108
 
168
109
  for (const chunk of (data.chunks || [])) {
169
110
  this.chunks.set(chunk.id, chunk);
111
+ if (!this._corpusEnriched && (chunk.summary || (chunk.concepts && chunk.concepts.length))) {
112
+ this._corpusEnriched = true;
113
+ }
170
114
 
171
- // Eager vector population
115
+ // Eager vector population. Vectors are keyed by embeddingKeyFor(chunk)
116
+ // (content_hash + enrichment digest); plain content_hash is the
117
+ // backward-compatible fallback for bins written before enrichment keys.
172
118
  if (!this._lazyMode) {
173
- if (chunk.content_hash && this.embeddingCache.has(chunk.content_hash)) {
119
+ const vecKey = embeddingKeyFor(chunk);
120
+ if (chunk.content_hash && this.embeddingCache.has(vecKey)) {
121
+ this.vectors.set(chunk.id, this.embeddingCache.get(vecKey));
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).
125
+ const sVec = this.embeddingCache.get(vecKey + SUMMARY_VEC_SUFFIX);
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
+ }
132
+ } else if (chunk.content_hash && this.embeddingCache.has(chunk.content_hash)) {
174
133
  this.vectors.set(chunk.id, this.embeddingCache.get(chunk.content_hash));
175
134
  } else if (chunk.embedding) {
176
135
  const vec = new Float32Array(chunk.embedding);
@@ -179,23 +138,9 @@ export class MemoryGraphIndex {
179
138
  }
180
139
  }
181
140
 
182
- // Build inverted lexical index
183
141
  const deps = this.graph.dependencies[chunk.file_path] || [];
184
- const cleanDeps = deps.map(d => d.split('/').pop().split('.')[0]);
185
- const enrichedContext = [
186
- chunk.name,
187
- chunk.docstring || '',
188
- cleanDeps.join(' '),
189
- (chunk.calls || []).join(' '),
190
- (chunk.params || []).join(' '),
191
- chunk.return_type || '',
192
- chunk.class_context ? `${chunk.class_context}.${chunk.name}` : '',
193
- (chunk.type_refs || []).join(' '),
194
- chunk.code_snippet
195
- ].join(' ');
196
- this._indexLexical(chunk.id, enrichedContext, chunk.file_path);
197
-
198
- // Build symbol table (Frontier 2)
142
+ this._indexLexical(chunk.id, buildLexicalDocument(chunk, deps), chunk.file_path);
143
+
199
144
  if (chunk.name && chunk.name !== 'anonymous') {
200
145
  const n = chunk.name.toLowerCase();
201
146
  if (!this.symbolTable.has(n)) this.symbolTable.set(n, new Set());
@@ -237,7 +182,8 @@ export class MemoryGraphIndex {
237
182
  _getVecForChunk(chunkId) {
238
183
  const chunk = this.chunks.get(chunkId);
239
184
  if (!chunk?.content_hash) return null;
240
- const entry = this._vecOffsets.get(chunk.content_hash);
185
+ const entry = this._vecOffsets.get(embeddingKeyFor(chunk))
186
+ ?? this._vecOffsets.get(chunk.content_hash); // pre-enrichment-key bins
241
187
  if (!entry) return null;
242
188
 
243
189
  if (this._embeddingBuf) {
@@ -275,7 +221,7 @@ export class MemoryGraphIndex {
275
221
  // ─── Lexical index (TRUE inverted index) ──────────────────────────────────
276
222
 
277
223
  _indexLexical(chunkId, text, filePath = '') {
278
- const tokens = tokenize(text);
224
+ const tokens = tokenize(text); // raw + additive Porter stems
279
225
  if (tokens.length === 0) return;
280
226
 
281
227
  const termCounts = new Map();
@@ -283,16 +229,16 @@ export class MemoryGraphIndex {
283
229
  termCounts.set(token, (termCounts.get(token) || 0) + 1);
284
230
  }
285
231
 
286
- // Track document length for BM25 length normalization.
287
- // Only content tokens are counted — path tokens are handled separately
288
- // in searchHybrid to avoid BM25 length normalisation amplifying short
289
- // export stubs that share a path with a larger implementation.
290
- this.docLens.set(chunkId, tokens.length);
291
- 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;
292
239
 
293
240
  const chunkTokenSet = new Set();
294
241
  for (const [term, count] of termCounts) {
295
- // Store raw term count — BM25 applies saturation at search time
296
242
  this.df.set(term, (this.df.get(term) || 0) + 1);
297
243
  let posting = this.invertedIndex.get(term);
298
244
  if (!posting) { posting = new Map(); this.invertedIndex.set(term, posting); }
@@ -307,7 +253,7 @@ export class MemoryGraphIndex {
307
253
  // Path matching is done multiplicatively in searchHybrid, which avoids
308
254
  // amplifying short stubs that happen to share a path with a long function.
309
255
  if (filePath) {
310
- const pathTokenSet = new Set(tokenize(filePath.replace(/[/\-_.]/g, ' ')));
256
+ const pathTokenSet = new Set(tokenize(filePath.replace(/[/\-_.]/g, ' '), false));
311
257
  if (!this.pathTokens) this.pathTokens = new Map();
312
258
  this.pathTokens.set(chunkId, pathTokenSet);
313
259
  }
@@ -328,7 +274,6 @@ export class MemoryGraphIndex {
328
274
  else this.df.set(term, freq - 1);
329
275
  }
330
276
  }
331
- // Update BM25 length accounting
332
277
  const dl = this.docLens.get(chunkId);
333
278
  if (dl !== undefined) {
334
279
  this.totalDocLen = Math.max(0, this.totalDocLen - dl);
@@ -340,32 +285,27 @@ export class MemoryGraphIndex {
340
285
  }
341
286
 
342
287
  _searchLexical(queryText) {
343
- 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));
344
292
  const scores = new Map();
345
-
346
- // BM25 parameters tuned for code (b lowered from 0.75 to 0.3 — code chunks
347
- // have purposeful length variation unlike prose, so heavy length normalization
348
- // incorrectly penalises long implementations vs short export stubs).
349
- const k1 = 1.5;
350
- const b = 0.3;
351
293
  const avgdl = this.docCount > 0 ? this.totalDocLen / this.docCount : 1;
352
294
 
353
295
  for (const token of queryTokens) {
354
296
  const docFreq = this.df.get(token);
355
297
  if (!docFreq) continue;
356
- // Okapi IDF — always positive, avoids negative IDF for very common terms
357
- const idf = Math.log((this.docCount - docFreq + 0.5) / (docFreq + 0.5) + 1);
298
+ const idf = okapiIdf(this.docCount, docFreq);
358
299
  const posting = this.invertedIndex.get(token);
359
300
  if (!posting) continue;
360
301
  for (const [chunkId, tf] of posting) {
361
302
  const dl = this.docLens.get(chunkId) ?? avgdl;
362
- // BM25: diminishing returns for high TF + document-length normalisation
363
- const bm25 = idf * (tf * (k1 + 1)) / (tf + k1 * (1 - b + b * dl / avgdl));
364
- scores.set(chunkId, (scores.get(chunkId) || 0) + bm25);
303
+ scores.set(chunkId, (scores.get(chunkId) || 0) + bm25Score(idf, tf, dl, avgdl));
365
304
  }
366
305
  }
367
306
  return Array.from(scores.entries())
368
- .sort((a, b) => b[1] - a[1])
307
+ .sort((a, b) => (b[1] - a[1]) || (a[0] < b[0] ? -1 : 1)) // id tie-break: backend parity
308
+ .slice(0, LEXICAL_FUSION_CAP) // same cap as SQLite: parity
369
309
  .map(([id, score], rank) => ({ id, score, rank: rank + 1 }));
370
310
  }
371
311
 
@@ -397,7 +337,10 @@ export class MemoryGraphIndex {
397
337
  const dim = this.vectors.get(ids[0]).length;
398
338
  this._dim = dim;
399
339
  const matrix = new Float32Array(n * dim);
400
- const norms = new Float32Array(n);
340
+ // Float64: norms must carry full precision — f32-rounded norms produced
341
+ // ~1e-7 score skew vs the disk-backed store's double-precision rescore,
342
+ // which was enough to flip near-tied ranks between backends.
343
+ const norms = new Float64Array(n);
401
344
  for (let i = 0; i < n; i++) {
402
345
  const v = this.vectors.get(ids[i]);
403
346
  let normSq = 0;
@@ -413,7 +356,6 @@ export class MemoryGraphIndex {
413
356
  this._vecNorms = norms;
414
357
  this._matrixDirty = false;
415
358
 
416
- // HNSW for large eager corpora
417
359
  if (HierarchicalNSW && n >= HNSW_THRESHOLD) {
418
360
  try {
419
361
  const hnsw = new HierarchicalNSW('cosine', dim);
@@ -427,7 +369,7 @@ export class MemoryGraphIndex {
427
369
  }
428
370
  this._hnsw = hnsw;
429
371
  } catch (e) {
430
- process.stderr.write(`[core-engine] HNSW build failed: ${e.message}\n`);
372
+ process.stderr.write(`[engine/memory] HNSW build failed: ${e.message}\n`);
431
373
  this._hnsw = null;
432
374
  }
433
375
  }
@@ -447,7 +389,6 @@ export class MemoryGraphIndex {
447
389
  qNorm = Math.sqrt(qNorm);
448
390
  if (qNorm === 0) return [];
449
391
 
450
- // HNSW fast path
451
392
  if (this._hnsw) {
452
393
  const topK = Math.min(200, n);
453
394
  try {
@@ -462,7 +403,6 @@ export class MemoryGraphIndex {
462
403
  } catch { /* fall through to flat scan */ }
463
404
  }
464
405
 
465
- // Exact flat scan
466
406
  const results = [];
467
407
  for (let i = 0; i < n; i++) {
468
408
  const base = i * dim;
@@ -477,34 +417,53 @@ export class MemoryGraphIndex {
477
417
  }
478
418
 
479
419
  /**
480
- * Two-phase vector search (lazy mode):
481
- * Only compute cosine for candidateIds (top-K TF-IDF pre-filter).
482
- * Reads vectors on-demand from buffer or disk — no full matrix in RAM.
420
+ * Full vector search in lazy mode: stream the entire embeddings bin (bounded
421
+ * buffers, no full matrix in RAM) and map the top entry keys back to chunks.
422
+ * Replaces the old lexical-prefiltered scoring, which could never surface a
423
+ * chunk for a conceptual query that shared no tokens with the code.
483
424
  */
484
- _searchVectorCandidates(queryVector, candidateIds, minScore = 0.3) {
485
- const qDim = queryVector.length;
486
- let qNorm = 0;
487
- for (let d = 0; d < qDim; d++) qNorm += queryVector[d] * queryVector[d];
488
- qNorm = Math.sqrt(qNorm);
489
- if (qNorm === 0) return [];
490
-
491
- const results = [];
492
- for (const chunkId of candidateIds) {
493
- const vec = this._getVecForChunk(chunkId);
494
- if (!vec || vec.length !== qDim) continue;
495
- let dp = 0, normVec = 0;
496
- for (let d = 0; d < qDim; d++) {
497
- dp += queryVector[d] * vec[d];
498
- normVec += vec[d] * vec[d];
425
+ _scanVectorsLazy(queryVector, minScore = 0.3) {
426
+ const source = this._embeddingBuf ? { buffer: this._embeddingBuf } : { fd: this._vecFd };
427
+ if (!this._embeddingBuf && this._vecFd < 0) return [];
428
+ // Large corpora: binary sketch (built once on first vector query) replaces
429
+ // the exact O(corpus) scan with a Hamming prefilter + bounded rescore.
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) {
438
+ try { this._sketch = updateVectorSketch(null, source); } catch { this._sketch = null; }
439
+ }
440
+ const hits = this._sketch
441
+ ? searchVectorSketch(this._sketch, source, queryVector, { topN: VECTOR_SCAN_RAW_N, minScore })
442
+ : scanEmbeddingBinary(source, queryVector, { topN: VECTOR_SCAN_RAW_N, minScore });
443
+ if (hits.length === 0) return [];
444
+
445
+ if (!this._keyToIds) {
446
+ const map = new Map();
447
+ const put = (key, id) => {
448
+ if (!map.has(key)) map.set(key, []);
449
+ map.get(key).push(id);
450
+ };
451
+ for (const c of this.chunks.values()) {
452
+ if (!c.content_hash) continue;
453
+ const key = embeddingKeyFor(c);
454
+ put(key, c.id);
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
457
+ if (key !== c.content_hash) put(c.content_hash, c.id); // legacy bins
499
458
  }
500
- const normV = Math.sqrt(normVec);
501
- if (normV === 0) continue;
502
- const score = dp / (qNorm * normV);
503
- if (score > minScore) results.push({ id: chunkId, score });
459
+ this._keyToIds = map;
504
460
  }
505
- results.sort((a, b) => b.score - a.score);
506
- for (let i = 0; i < results.length; i++) results[i].rank = i + 1;
507
- return results;
461
+
462
+ const entries = [];
463
+ for (const { key, score } of hits) {
464
+ for (const id of (this._keyToIds.get(key) || [])) entries.push({ id, score });
465
+ }
466
+ return finalizeVectorCandidates(entries);
508
467
  }
509
468
 
510
469
  // ─── Hybrid search ─────────────────────────────────────────────────────────
@@ -515,111 +474,39 @@ export class MemoryGraphIndex {
515
474
  let vectorResults;
516
475
  if (queryVector) {
517
476
  if (this._lazyMode && this._vecOffsets.size > 0) {
518
- // Two-phase: TF-IDF pre-filter → lazy vector load
519
- const candidateIds = lexicalResults.slice(0, TFIDF_PREFILTER_K).map(r => r.id);
520
- vectorResults = this._searchVectorCandidates(queryVector, candidateIds, minScore);
477
+ // Full streaming scan of the bin — covers conceptual queries with
478
+ // zero lexical overlap, which the old TF-IDF prefilter never could.
479
+ vectorResults = this._scanVectorsLazy(queryVector, minScore);
521
480
  } else {
522
- vectorResults = this._searchVector(queryVector, minScore);
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).
485
+ const entries = this._searchVector(queryVector, minScore).map(r => ({
486
+ id: baseEmbeddingKey(r.id),
487
+ score: r.score,
488
+ }));
489
+ vectorResults = finalizeVectorCandidates(entries);
523
490
  }
524
491
  } else {
525
492
  vectorResults = [];
526
493
  }
527
494
 
528
- const rrfScores = new Map();
529
- const K = this.rrfK;
530
- const queryLower = queryText.toLowerCase();
531
-
532
- const LEXICAL_WEIGHT = 1.5;
533
- const VECTOR_WEIGHT = 1.0;
534
-
535
- // Pre-compute query file tokens once for path boosting
536
- const _queryPathTokens = queryLower.split(/[\s\W_]+/).filter(t => t.length >= 3);
537
-
538
- const allResults = [
539
- ...vectorResults.map(r => ({ ...r, _w: VECTOR_WEIGHT })),
540
- ...lexicalResults.map(r => ({ ...r, _w: LEXICAL_WEIGHT })),
541
- ];
542
-
543
- for (const { id, rank, _w } of allResults) {
544
- let baseScore = (_w ?? 1.0) / (K + rank);
545
- const chunk = this.chunks.get(id);
546
- if (!chunk) continue;
547
-
548
- // Demotion: test / spec files
549
- if (/\.(test|spec)\.|[/\\]__tests__[/\\]|_test\.|^tests?[/\\]|[/\\]tests?[/\\]|[/\\]spec[/\\]/.test(chunk.file_path)) {
550
- if (!queryLower.includes('test') && !queryLower.includes('spec')) baseScore *= 0.25;
551
- }
552
- // Demotion: example / docs dirs (tutorial snippets over-rank due to short length
553
- // and high keyword density relative to the actual framework implementation).
554
- if (/^examples?[/\\]|[/\\]examples?[/\\]|^samples?[/\\]|[/\\]samples?[/\\]|^demos?[/\\]|[/\\]demos?[/\\]|[/\\]tutorials?[/\\]|^docs_src[/\\]|[/\\]docs_src[/\\]/.test(chunk.file_path)) {
555
- baseScore *= 0.5;
556
- }
557
- // Demotion: pure expression sites
558
- if (chunk.node_type === 'expression_statement' || chunk.node_type === 'call_expression') {
559
- baseScore *= 0.8;
560
- }
561
- // Demotion: TypeScript barrel re-exports (e.g. "controller.decorator_export_statement"
562
- // in .ts files). These are synthetic chunks created for `export { X } from 'y'`
563
- // declarations — no implementation, just re-exporting from another file.
564
- // JavaScript .js exports (e.g. "http_export_statement") are intentionally excluded
565
- // since those often ARE the primary module export (the file IS the implementation).
566
- if (chunk.name && chunk.name.endsWith('_export_statement') && chunk.file_path?.endsWith('.ts')) {
567
- baseScore *= 0.7;
568
- }
569
-
570
- // File-path boost: applied to ALL queries (not just when vectors are absent).
571
- // Uses the separate pathTokens index (not the BM25 inverted index) so that
572
- // length normalisation does not penalise long implementations sharing a path
573
- // with short stubs.
574
- if (_queryPathTokens.length > 0) {
575
- const pathToks = this.pathTokens.get(id);
576
- if (pathToks) {
577
- const hasExact = _queryPathTokens.some(t => pathToks.has(t));
578
- const hasPrefix = !hasExact && _queryPathTokens.some(t =>
579
- t.length >= 4 && Array.from(pathToks).some(pt => pt.startsWith(t.slice(0, 5)))
580
- );
581
- if (hasExact) baseScore *= 1.4;
582
- else if (hasPrefix) baseScore *= 1.2;
583
- }
584
- }
585
-
586
- // Name boost:
587
- // 2.0x exact match — a query token IS the full chunk name.
588
- // 1.4x snake_case suffix — the last underscore/dot component of the name
589
- // matches a query token. Intentionally uses only [._] splitting so
590
- // camelCase names stay atomic: "dispatchRequest" is one token, not
591
- // ["dispatch","request"], preventing false boosts from generic suffixes.
592
- if (chunk.name && chunk.name !== 'anonymous') {
593
- const nameLower = chunk.name.toLowerCase();
594
- const queryTokensAll = queryLower.split(/[\s\W_]+/).filter(t => t.length >= 5);
595
- if (queryTokensAll.some(t => nameLower === t || nameLower.endsWith('.' + t))) {
596
- baseScore *= 2.0;
597
- } else {
598
- const snakeParts = nameLower.split(/[._]+/);
599
- const lastSnake = snakeParts[snakeParts.length - 1] ?? '';
600
- if (snakeParts.length >= 2 && lastSnake.length >= 5 && queryTokensAll.includes(lastSnake)) {
601
- baseScore *= 1.4;
602
- }
603
- }
604
- }
605
-
606
- rrfScores.set(id, (rrfScores.get(id) || 0) + baseScore);
607
- }
608
-
609
- if (exactBoostName) {
610
- const boostTerm = String(exactBoostName).toLowerCase().trim();
611
- for (const [id, chunk] of this.chunks.entries()) {
612
- if (chunk.name && String(chunk.name).toLowerCase() === boostTerm) {
613
- rrfScores.set(id, (rrfScores.get(id) || 0) + 1 / (K + 1));
614
- }
615
- }
616
- }
617
-
618
- return Array.from(rrfScores.entries())
619
- .sort((a, b) => b[1] - a[1])
620
- .slice(0, topK)
621
- .map(([id, rrfScore]) => ({ score: rrfScore, chunk: this.chunks.get(id) }))
622
- .filter(r => r.chunk !== undefined);
495
+ return fuseAndRank({
496
+ lexicalResults,
497
+ vectorResults,
498
+ getChunk: (id) => this.chunks.get(id),
499
+ getPathTokens: (id) => this.pathTokens.get(id),
500
+ getDf: (t) => this.df.get(t) || 0,
501
+ docCount: this.docCount,
502
+ rrfK: this.rrfK,
503
+ topK,
504
+ queryText,
505
+ exactBoostName,
506
+ corpusEnriched: this._corpusEnriched,
507
+ // O(1) vs the old full-scan; same contract implemented by the SQLite backend.
508
+ resolveExact: (term) => this.symbolTable.get(term) || [],
509
+ });
623
510
  }
624
511
 
625
512
  // ─── Persistence ───────────────────────────────────────────────────────────
@@ -629,9 +516,11 @@ export class MemoryGraphIndex {
629
516
  id: c.id, file_path: c.file_path, node_type: c.node_type,
630
517
  name: c.name, docstring: c.docstring || '', code_snippet: c.code_snippet,
631
518
  content_hash: c.content_hash, start_line: c.start_line, end_line: c.end_line,
632
- calls: c.calls || [], params: c.params || [],
519
+ calls: c.calls || [], call_sites: c.call_sites || [], params: c.params || [],
633
520
  return_type: c.return_type || '', class_context: c.class_context || '',
634
- type_refs: c.type_refs || [],
521
+ type_refs: c.type_refs || [], decorators: c.decorators || [],
522
+ extends: c.extends || [],
523
+ hyde: c.hyde || '', summary: c.summary || '', concepts: c.concepts || [],
635
524
  }));
636
525
  const payload = JSON.stringify({ chunks: chunksData, graph: this.graph });
637
526
  const tmpPath = `${this.indexPath}.tmp`;
@@ -651,10 +540,107 @@ export class MemoryGraphIndex {
651
540
  this._saveTimer = setTimeout(async () => {
652
541
  this._saveTimer = null;
653
542
  try { await this.save(); }
654
- 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`); }
655
544
  }, delayMs);
656
545
  }
657
546
 
547
+ _removeSymbol(chunk) {
548
+ if (!chunk.name || chunk.name === 'anonymous') return;
549
+ const n = chunk.name.toLowerCase();
550
+ const set = this.symbolTable.get(n);
551
+ if (set) {
552
+ set.delete(chunk.id);
553
+ if (set.size === 0) this.symbolTable.delete(n);
554
+ }
555
+ }
556
+
557
+ /** Whether a vector for this embedding key is already cached (skip re-embedding). */
558
+ hasEmbedding(key) { return this.embeddingCache.has(key); }
559
+
560
+ /**
561
+ * Atomically replace every chunk of one file — the incremental-update entry
562
+ * point used by the watch daemon. Removes the file's old chunks from the
563
+ * lexical, vector AND symbol indexes (the old daemon path leaked symbol-table
564
+ * entries), stores newly computed embeddings, indexes the new chunks, and
565
+ * schedules a debounced save. Pass empty chunks/imports for a deleted file.
566
+ *
567
+ * @param {string} filePath
568
+ * @param {object} p
569
+ * @param {object[]} [p.chunks]
570
+ * @param {string[]} [p.imports]
571
+ * @param {Map<string, Float32Array|number[]>} [p.embeddings] New vectors keyed by embeddingKeyFor(chunk).
572
+ */
573
+ applyFileUpdate(filePath, { chunks = [], imports = [], embeddings = null } = {}) {
574
+ this.updateFileGraph(filePath, imports);
575
+
576
+ for (const [id, chunk] of Array.from(this.chunks.entries())) {
577
+ if (chunk.file_path !== filePath) continue;
578
+ this._removeLexical(id);
579
+ this.removeVector(id);
580
+ this.removeVector(id + SUMMARY_VEC_SUFFIX);
581
+ for (let i = 1; i < EMBEDDING_MAX_WINDOWS; i++) this.removeVector(id + WINDOW_VEC_SUFFIX + i);
582
+ this._removeSymbol(chunk);
583
+ this.chunks.delete(id);
584
+ }
585
+
586
+ if (embeddings) {
587
+ for (const [key, vec] of embeddings) {
588
+ this.embeddingCache.set(key, vec instanceof Float32Array ? vec : new Float32Array(vec));
589
+ }
590
+ }
591
+
592
+ for (const chunk of chunks) {
593
+ const vecKey = embeddingKeyFor(chunk);
594
+ const vec = this.embeddingCache.get(vecKey) ?? this.embeddingCache.get(chunk.content_hash);
595
+ if (vec) this.addVector(chunk.id, vec);
596
+ const sVec = this.embeddingCache.get(vecKey + SUMMARY_VEC_SUFFIX);
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
+ }
602
+ this._indexLexical(chunk.id, buildLexicalDocument(chunk, imports), chunk.file_path);
603
+ this.chunks.set(chunk.id, chunk);
604
+ if (chunk.name && chunk.name !== 'anonymous') {
605
+ const n = chunk.name.toLowerCase();
606
+ if (!this.symbolTable.has(n)) this.symbolTable.set(n, new Set());
607
+ this.symbolTable.get(n).add(chunk.id);
608
+ }
609
+ }
610
+
611
+ this._keyToIds = null;
612
+ this.saveDebounced();
613
+ }
614
+
615
+ /**
616
+ * Drop every in-memory structure and re-read the index artifacts from disk.
617
+ * Used by the MCP server when the watch daemon (a separate process) rewrites
618
+ * code-index.json — without this the server would serve a stale snapshot
619
+ * until restart.
620
+ */
621
+ reload() {
622
+ if (this._vecFd >= 0) { try { fs.closeSync(this._vecFd); } catch {} this._vecFd = -1; }
623
+ this.chunks = new Map();
624
+ this.graph = { dependencies: {}, importedBy: {} };
625
+ this.embeddingCache = new Map();
626
+ this.symbolTable = new Map();
627
+ this.invertedIndex = new Map();
628
+ this.chunkTerms = new Map();
629
+ this.docLens = new Map();
630
+ this.totalDocLen = 0;
631
+ this.pathTokens = new Map();
632
+ this.docCount = 0;
633
+ this.df = new Map();
634
+ this.vectors = new Map();
635
+ this._invalidateMatrix();
636
+ this._vecOffsets = new Map();
637
+ this._embeddingBuf = null;
638
+ this._keyToIds = null;
639
+ this._sketch = null;
640
+ this._lazyMode = false;
641
+ this.load();
642
+ }
643
+
658
644
  updateFileGraph(filePath, imports) {
659
645
  const oldDeps = this.graph.dependencies[filePath] || [];
660
646
  for (const oldDep of oldDeps) {
@@ -669,6 +655,130 @@ export class MemoryGraphIndex {
669
655
  }
670
656
  }
671
657
 
658
+ // ─── Store contract ────────────────────────────────────────────────────────
659
+ // The read surface the MCP tools consume. The SQLite store implements the same
660
+ // methods over disk so tools never reach into a backend's internals.
661
+
662
+ get backend() { return 'memory'; }
663
+
664
+ /** @returns {object|null} */
665
+ getChunk(id) { return this.chunks.get(id) ?? null; }
666
+
667
+ /** All chunks defined in a given file. @returns {object[]} */
668
+ getChunksByFile(filePath) {
669
+ const out = [];
670
+ for (const c of this.chunks.values()) if (c.file_path === filePath) out.push(c);
671
+ return out;
672
+ }
673
+
674
+ /** Chunks whose symbol name matches exactly (case-insensitive). @returns {object[]} */
675
+ resolveSymbol(name) {
676
+ const ids = this.symbolTable.get(String(name).toLowerCase().trim());
677
+ if (!ids) return [];
678
+ const out = [];
679
+ for (const id of ids) { const c = this.chunks.get(id); if (c) out.push(c); }
680
+ return out;
681
+ }
682
+
683
+ /** Chunks that call the given function name. @returns {object[]} */
684
+ findCallers(funcName) {
685
+ const out = [];
686
+ for (const c of this.chunks.values()) if (c.calls?.includes(funcName)) out.push(c);
687
+ return out;
688
+ }
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
+
735
+ /** Lazily iterate every chunk (cursor-friendly parity with the SQLite store). */
736
+ *iterateChunks() { yield* this.chunks.values(); }
737
+
738
+ chunkCount() { return this.chunks.size; }
739
+ symbolCount() { return this.symbolTable.size; }
740
+ fileCount() {
741
+ const files = new Set();
742
+ for (const c of this.chunks.values()) files.add(c.file_path);
743
+ return files.size;
744
+ }
745
+ vectorCount() {
746
+ if (this._lazyMode) {
747
+ let n = 0;
748
+ for (const k of this._vecOffsets.keys()) if (baseEmbeddingKey(k) === k) n++;
749
+ return n;
750
+ }
751
+ let n = 0;
752
+ for (const k of this.vectors.keys()) if (baseEmbeddingKey(k) === k) n++;
753
+ return n;
754
+ }
755
+
756
+ getDependencies(filePath) { return this.graph.dependencies[filePath] || []; }
757
+ getImportedBy(filePath) { return this.graph.importedBy[filePath] || []; }
758
+
759
+ /** Engine-level health facts for list_index_stats (daemon/age added by the tool). */
760
+ stats() {
761
+ const extCounts = new Map();
762
+ for (const c of this.chunks.values()) {
763
+ const ext = c.file_path.split('.').pop() || 'unknown';
764
+ extCounts.set(ext, (extCounts.get(ext) || 0) + 1);
765
+ }
766
+ const vectorSource = this._lazyMode
767
+ ? (this._vecFd >= 0 ? 'disk-backed fd' : 'buffer-lazy')
768
+ : 'eager (in-memory matrix)';
769
+ return {
770
+ backend: 'memory',
771
+ chunks: this.chunks.size,
772
+ files: this.fileCount(),
773
+ symbols: this.symbolTable.size,
774
+ vectors: this.vectorCount(),
775
+ hasVectors: this.vectors.size > 0 || this._vecOffsets.size > 0,
776
+ lazyMode: this._lazyMode,
777
+ vectorSource,
778
+ extCounts,
779
+ };
780
+ }
781
+
672
782
  // ─── Cleanup ───────────────────────────────────────────────────────────────
673
783
 
674
784
  close() {