graph-indexer 2.1.1 → 3.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 (129) hide show
  1. package/CHANGELOG.md +260 -1
  2. package/README.md +232 -312
  3. package/SECURITY.md +37 -59
  4. package/bin/graph-indexer.mjs +251 -0
  5. package/integrations/claude-code/.claude-plugin/plugin.json +8 -0
  6. package/integrations/claude-code/.mcp.json +8 -0
  7. package/integrations/claude-code/agents/code-structure.md +22 -0
  8. package/integrations/claude-code/hooks/gi-hook.mjs +99 -0
  9. package/integrations/claude-code/hooks/hooks.json +16 -0
  10. package/integrations/opencode/graph-indexer.js +124 -0
  11. package/package.json +32 -109
  12. package/src/cli/helper.mjs +67 -0
  13. package/src/cli/hook.mjs +356 -0
  14. package/src/cli/init.mjs +255 -0
  15. package/src/cli/resident.mjs +175 -0
  16. package/src/index/discover.mjs +130 -0
  17. package/src/index/fingerprint.mjs +30 -0
  18. package/src/index/graph.mjs +61 -0
  19. package/src/index/indexer.mjs +476 -0
  20. package/src/index/modules.mjs +331 -0
  21. package/src/index/resolver.mjs +954 -0
  22. package/src/mcp/render.mjs +60 -0
  23. package/src/mcp/server.mjs +147 -0
  24. package/src/mcp/tools.mjs +973 -0
  25. package/src/parse/arity.mjs +152 -0
  26. package/src/parse/extract.mjs +1031 -0
  27. package/src/parse/lang/csharp.mjs +106 -0
  28. package/src/parse/lang/ecma.mjs +438 -0
  29. package/src/parse/lang/go.mjs +194 -0
  30. package/src/parse/lang/jvm.mjs +118 -0
  31. package/src/parse/lang/kotlin.mjs +97 -0
  32. package/src/parse/lang/misc.mjs +115 -0
  33. package/src/parse/lang/native.mjs +126 -0
  34. package/src/parse/lang/python.mjs +153 -0
  35. package/src/parse/lang/rust.mjs +157 -0
  36. package/src/parse/lang/scripting.mjs +219 -0
  37. package/src/parse/languages.mjs +48 -0
  38. package/src/parse/runtime.mjs +56 -0
  39. package/src/query/check.mjs +343 -0
  40. package/src/query/facts.mjs +323 -0
  41. package/src/query/intel.mjs +680 -0
  42. package/src/query/read.mjs +81 -0
  43. package/src/query/testcmd.mjs +119 -0
  44. package/src/query/textsearch.mjs +160 -0
  45. package/src/search/search.mjs +210 -0
  46. package/src/search/tokenize.mjs +188 -0
  47. package/src/store/db.mjs +169 -0
  48. package/src/util/paths.mjs +24 -0
  49. package/vendor/grammars/bash.wasm.gz +0 -0
  50. package/vendor/grammars/c.wasm.gz +0 -0
  51. package/vendor/grammars/c_sharp.wasm.gz +0 -0
  52. package/vendor/grammars/cpp.wasm.gz +0 -0
  53. package/vendor/grammars/css.wasm.gz +0 -0
  54. package/vendor/grammars/go.wasm.gz +0 -0
  55. package/vendor/grammars/java.wasm.gz +0 -0
  56. package/vendor/grammars/javascript.wasm.gz +0 -0
  57. package/vendor/grammars/kotlin.wasm.gz +0 -0
  58. package/vendor/grammars/manifest.json +148 -0
  59. package/vendor/grammars/php.wasm.gz +0 -0
  60. package/vendor/grammars/python.wasm.gz +0 -0
  61. package/vendor/grammars/ruby.wasm.gz +0 -0
  62. package/vendor/grammars/rust.wasm.gz +0 -0
  63. package/vendor/grammars/scala.wasm.gz +0 -0
  64. package/vendor/grammars/tsx.wasm.gz +0 -0
  65. package/vendor/grammars/typescript.wasm.gz +0 -0
  66. package/vendor/web-tree-sitter/web-tree-sitter.js +3980 -0
  67. package/vendor/web-tree-sitter/web-tree-sitter.wasm +0 -0
  68. package/cli-ui.mjs +0 -78
  69. package/colbert.mjs +0 -168
  70. package/config.mjs +0 -494
  71. package/daemon-ctl.mjs +0 -175
  72. package/daemon-lock.mjs +0 -69
  73. package/embedders/python/mlx_embed_server.py +0 -61
  74. package/embedders/python/requirements-mlx.txt +0 -5
  75. package/embedders/setup-mlx.mjs +0 -110
  76. package/embeddings.mjs +0 -571
  77. package/engine/binary.mjs +0 -437
  78. package/engine/memory.mjs +0 -1003
  79. package/engine/sqlite.mjs +0 -1136
  80. package/enrichment.mjs +0 -521
  81. package/git-signals.mjs +0 -248
  82. package/indexer.mjs +0 -546
  83. package/init.mjs +0 -1809
  84. package/layout.mjs +0 -123
  85. package/mcp/centrality.mjs +0 -117
  86. package/mcp/format.mjs +0 -61
  87. package/mcp/resolver.mjs +0 -116
  88. package/mcp/symbolgraph.mjs +0 -137
  89. package/mcp/taint.mjs +0 -225
  90. package/mcp/tools.mjs +0 -1582
  91. package/mcp/topology.mjs +0 -676
  92. package/mcp-server.mjs +0 -197
  93. package/parse/extractor.mjs +0 -628
  94. package/parse/imports.mjs +0 -365
  95. package/parse/interprocedural.mjs +0 -134
  96. package/parse/languages.mjs +0 -253
  97. package/parse/metadata.mjs +0 -701
  98. package/parse/routes.mjs +0 -327
  99. package/parse/scip.mjs +0 -391
  100. package/parse/taint-patterns.mjs +0 -119
  101. package/prompts/CORE.md +0 -91
  102. package/prompts/DOMAIN_TEMPLATE.md +0 -46
  103. package/prompts/INTEGRATION.md +0 -123
  104. package/prompts/frameworks/ANDROID.md +0 -17
  105. package/prompts/frameworks/ASPNET_CORE.md +0 -19
  106. package/prompts/frameworks/FASTAPI_DJANGO.md +0 -20
  107. package/prompts/frameworks/LARAVEL_SYMFONY.md +0 -22
  108. package/prompts/frameworks/NODE_EXPRESS_NESTJS.md +0 -19
  109. package/prompts/frameworks/RAILS.md +0 -20
  110. package/prompts/frameworks/REACT.md +0 -20
  111. package/prompts/frameworks/SPRING_BOOT.md +0 -20
  112. package/prompts/languages/BASH.md +0 -22
  113. package/prompts/languages/C.md +0 -21
  114. package/prompts/languages/CSHARP.md +0 -24
  115. package/prompts/languages/CSS_SCSS.md +0 -22
  116. package/prompts/languages/GO.md +0 -24
  117. package/prompts/languages/JAVA.md +0 -23
  118. package/prompts/languages/JAVASCRIPT_TYPESCRIPT.md +0 -24
  119. package/prompts/languages/KOTLIN.md +0 -24
  120. package/prompts/languages/PHP.md +0 -23
  121. package/prompts/languages/PYTHON.md +0 -23
  122. package/prompts/languages/RUBY.md +0 -23
  123. package/prompts/languages/RUST.md +0 -21
  124. package/prompts/languages/SWIFT.md +0 -21
  125. package/seal.mjs +0 -400
  126. package/search-core.mjs +0 -777
  127. package/search-sparse.mjs +0 -183
  128. package/storage.mjs +0 -73
  129. package/watch-daemon.mjs +0 -289
package/engine/memory.mjs DELETED
@@ -1,1003 +0,0 @@
1
- /**
2
- * @file engine/memory.mjs
3
- * @description In-Memory Graph Indexer Core Engine.
4
- * @author MaquinaTech <https://github.com/MaquinaTech>
5
- * @copyright (c) 2026 MaquinaTech. All rights reserved.
6
- * @license MIT
7
- */
8
- import fs from 'fs';
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
- scoreSparseExpanded,
14
- } from '../search-core.mjs';
15
- import { expandSparseQuery } from '../search-sparse.mjs';
16
- import {
17
- HierarchicalNSW, HNSW_THRESHOLD, LAZY_VEC_THRESHOLD, SKETCH_THRESHOLD,
18
- writeEmbeddingBinary, scanEmbeddingBinary, updateVectorSketch, searchVectorSketch,
19
- } from './binary.mjs';
20
-
21
- export class MemoryGraphIndex {
22
- /**
23
- * @param {string} indexPath
24
- * @param {object} opts
25
- * @param {number} opts.rrfK RRF rank discount constant (default 60)
26
- * @param {boolean} opts.cacheEmbeddings When false, vectors are accessed lazily from disk
27
- * (ideal for MCP server on large corpora). Default true.
28
- */
29
- constructor(indexPath, { rrfK = 60, cacheEmbeddings = true } = {}) {
30
- this.indexPath = indexPath;
31
- this._embeddingPath = indexPath.replace(/\.json$/, '.embeddings.bin');
32
-
33
- // ── Core data ─────────────────────────────────────────────────────────
34
- this.chunks = new Map(); // chunkId → chunk metadata
35
- this.graph = { dependencies: {}, importedBy: {} };
36
-
37
- // ── Embedding cache (used by indexer to avoid re-embedding) ───────────
38
- this.embeddingCache = new Map(); // hash → Float32Array
39
-
40
- // ── Frontier 2: Symbol table ──────────────────────────────────────────
41
- this.symbolTable = new Map(); // nameLower → Set<chunkId>
42
-
43
- // ── Lexical search: TRUE inverted index (BM25 scoring) ───────────────
44
- // invertedIndex: Map<token, Map<chunkId, rawCount>> ← O(1) per-token lookup
45
- // chunkTerms: Map<chunkId, Set<token>> ← for efficient removal
46
- // docLens: Map<chunkId, number> ← token count for BM25 length norm
47
- this.invertedIndex = new Map();
48
- this.chunkTerms = new Map();
49
- this.docLens = new Map(); // chunkId → token count (BM25 length normalization)
50
- this.totalDocLen = 0; // Σ doc lengths for avgdl
51
- this.pathTokens = new Map(); // chunkId → Set<token> from file path (not in BM25 index)
52
- this.docCount = 0;
53
- this.df = new Map(); // token → document frequency
54
-
55
- // ── Vector search: eager mode (small corpora) ─────────────────────────
56
- this.vectors = new Map(); // chunkId → Float32Array (eager only)
57
- this._matrixDirty = true;
58
- this._vecMatrix = null; // Float32Array(N × dim)
59
- this._vecNorms = null; // Float32Array(N)
60
- this._vecIds = []; // row index → chunkId
61
- this._dim = 0;
62
- this._hnsw = null;
63
-
64
- // ── Vector search: lazy mode (large corpora, cacheEmbeddings=false) ───
65
- this._cacheEmbeddings = cacheEmbeddings;
66
- this._vecOffsets = new Map(); // hash → { offset: number, dim: number }
67
- this._embeddingBuf = null; // Buffer kept for small-lazy corpora (<50k)
68
- this._vecFd = -1; // open fd for disk-backed access (≥50k)
69
- this._lazyMode = false;
70
- this._keyToIds = null; // embedding key → chunkId[] (built lazily for scans)
71
- this._sketch = null; // binary-quantized sketch (lazy mode, large corpora)
72
-
73
- this.rrfK = rrfK;
74
- this._saveTimer = null;
75
-
76
- // ── A4: persistent resolved symbol graph (opt-in; null when not built) ──
77
- // _edges is the serialized edge list; the two maps index it by endpoint for
78
- // O(1) getEdges and edge-backed findCallers/findReferers. Cleared on any
79
- // incremental update (daemon) so a stale graph never serves wrong callers.
80
- this._edges = null;
81
- this._edgesByTo = new Map(); // to_chunk_id → edge[]
82
- this._edgesByFrom = new Map(); // from_chunk_id → edge[]
83
-
84
- // ── A5: symbol centrality (PageRank over the edges; built with the graph) ──
85
- // _centrality maps chunk id → { score, rank }; precomputed at index time and
86
- // serialized, so both backends serve identical numbers. Cleared with the edges.
87
- this._centrality = null;
88
- this._centralityTotal = 0;
89
- this._centralRanked = null; // lazy id-by-rank cache for topCentral
90
-
91
- // ── C2: serialized taint flows ({ flows, meta }); precomputed at index time with --taint,
92
- // so the taint tools serve instantly and both backends serve identical flows. Cleared
93
- // on any incremental file update (a code change invalidates the flow set).
94
- this._taint = null;
95
-
96
- // ── A2 v2: SCIP precise references — `{ defChunkId: [refererChunkId, …] }`, built at index
97
- // time from a locally-generated SCIP index (--resolver scip) and serialized as an
98
- // isolated artifact so it cannot perturb A4 edges / A5 centrality. find_references reads
99
- // it for a binding-precise, cross-file `resolved` reference tier. Cleared with the graph.
100
- this._scipRefs = null;
101
-
102
- // ── B3: learned-sparse association model — `{ assoc: { term: [[assocTerm, weight], …] } }`,
103
- // learned from corpus co-occurrence at index time with --learned-sparse and serialized as
104
- // an isolated artifact. searchHybrid uses it to add an NL-only vocabulary-EXPANSION
105
- // channel; absent on default indexes → channel inert → byte-identical. Whole-program →
106
- // cleared on any incremental file update.
107
- this._sparseModel = null;
108
- }
109
-
110
- /** Index the (already deterministically-ordered) edge list by endpoint. */
111
- _indexEdges(edges) {
112
- this._edges = edges;
113
- this._edgesByTo = new Map();
114
- this._edgesByFrom = new Map();
115
- for (const e of edges) {
116
- if (!this._edgesByTo.has(e.to_chunk_id)) this._edgesByTo.set(e.to_chunk_id, []);
117
- this._edgesByTo.get(e.to_chunk_id).push(e);
118
- if (!this._edgesByFrom.has(e.from_chunk_id)) this._edgesByFrom.set(e.from_chunk_id, []);
119
- this._edgesByFrom.get(e.from_chunk_id).push(e);
120
- }
121
- }
122
-
123
- /** Load the precomputed symbol-centrality map (A5). */
124
- _indexCentrality(centrality) {
125
- this._centrality = new Map(Object.entries(centrality));
126
- this._centralityTotal = this._centrality.size;
127
- this._centralRanked = null;
128
- }
129
-
130
- /** Drop the symbol graph + its centrality + serialized taint + SCIP references (an incremental update makes them stale). */
131
- _clearEdges() {
132
- this._edges = null;
133
- this._edgesByTo = new Map();
134
- this._edgesByFrom = new Map();
135
- this._centrality = null;
136
- this._centralityTotal = 0;
137
- this._centralRanked = null;
138
- this._taint = null;
139
- this._scipRefs = null;
140
- this._sparseModel = null;
141
- }
142
-
143
- // ─── Load ─────────────────────────────────────────────────────────────────
144
-
145
- load() {
146
- if (!fs.existsSync(this.indexPath)) return;
147
- const data = JSON.parse(fs.readFileSync(this.indexPath, 'utf-8'));
148
- this.graph = data.graph || { dependencies: {}, importedBy: {} };
149
- // A4 resolved symbol graph (opt-in; absent on default indexes → no-op).
150
- if (Array.isArray(data.edges)) this._indexEdges(data.edges);
151
- // A5 symbol centrality (built with the graph; absent on default indexes → no-op).
152
- if (data.centrality && typeof data.centrality === 'object') this._indexCentrality(data.centrality);
153
- // C2 serialized taint flows (opt-in --taint; absent on default indexes → no-op).
154
- if (data.taint && typeof data.taint === 'object') this._taint = data.taint;
155
- // A2 v2 SCIP precise references (opt-in --resolver scip; absent on default indexes → no-op).
156
- if (data.scip_refs && typeof data.scip_refs === 'object') this._scipRefs = data.scip_refs;
157
- // B3 learned-sparse model (opt-in --learned-sparse; absent on default indexes → no-op).
158
- if (data.sparse_model && typeof data.sparse_model === 'object') this._sparseModel = data.sparse_model;
159
- // Does this corpus carry LLM enrichment? Drives the NL vector-channel weight
160
- // in fuseAndRank (strong summary vectors earn full weight; plain code vectors
161
- // stay a low-weight rescue). Set during the chunk load loop below.
162
- this._corpusEnriched = false;
163
-
164
- const chunkCount = (data.chunks || []).length;
165
-
166
- this._lazyMode = (!this._cacheEmbeddings) && chunkCount >= LAZY_VEC_THRESHOLD;
167
-
168
- if (fs.existsSync(this._embeddingPath)) {
169
- const binBuf = fs.readFileSync(this._embeddingPath);
170
- if (this._lazyMode) {
171
- this._buildVecOffsets(binBuf);
172
- // For very large corpora: open persistent fd, release buffer from heap
173
- if (chunkCount >= 50000) {
174
- try { this._vecFd = fs.openSync(this._embeddingPath, 'r'); } catch { this._vecFd = -1; }
175
- } else {
176
- this._embeddingBuf = binBuf;
177
- }
178
- } else {
179
- this._loadEmbeddingBinary(binBuf);
180
- }
181
- } else if (data.embeddingCache) {
182
- for (const [hash, vec] of Object.entries(data.embeddingCache)) {
183
- this.embeddingCache.set(hash, new Float32Array(vec));
184
- }
185
- }
186
-
187
- for (const chunk of (data.chunks || [])) {
188
- this.chunks.set(chunk.id, chunk);
189
- if (!this._corpusEnriched && (chunk.summary || (chunk.concepts && chunk.concepts.length))) {
190
- this._corpusEnriched = true;
191
- }
192
-
193
- // Eager vector population. Vectors are keyed by embeddingKeyFor(chunk)
194
- // (content_hash + enrichment digest); plain content_hash is the
195
- // backward-compatible fallback for bins written before enrichment keys.
196
- if (!this._lazyMode) {
197
- const vecKey = embeddingKeyFor(chunk);
198
- if (chunk.content_hash && this.embeddingCache.has(vecKey)) {
199
- this.vectors.set(chunk.id, this.embeddingCache.get(vecKey));
200
- // Summary-only and per-window vectors (enriched / oversized chunks):
201
- // stored under pseudo row ids; searchHybrid folds hits back onto the
202
- // chunk id (max-sim across all of the chunk's vectors).
203
- const sVec = this.embeddingCache.get(vecKey + SUMMARY_VEC_SUFFIX);
204
- if (sVec) this.vectors.set(chunk.id + SUMMARY_VEC_SUFFIX, sVec);
205
- for (let i = 1; i < EMBEDDING_MAX_WINDOWS; i++) {
206
- const wVec = this.embeddingCache.get(vecKey + WINDOW_VEC_SUFFIX + i);
207
- if (!wVec) break;
208
- this.vectors.set(chunk.id + WINDOW_VEC_SUFFIX + i, wVec);
209
- }
210
- } else if (chunk.content_hash && this.embeddingCache.has(chunk.content_hash)) {
211
- this.vectors.set(chunk.id, this.embeddingCache.get(chunk.content_hash));
212
- } else if (chunk.embedding) {
213
- const vec = new Float32Array(chunk.embedding);
214
- this.vectors.set(chunk.id, vec);
215
- if (chunk.content_hash) this.embeddingCache.set(chunk.content_hash, vec);
216
- }
217
- }
218
-
219
- const deps = this.graph.dependencies[chunk.file_path] || [];
220
- this._indexLexical(chunk.id, buildLexicalDocument(chunk, deps), chunk.file_path);
221
-
222
- if (chunk.name && chunk.name !== 'anonymous') {
223
- const n = chunk.name.toLowerCase();
224
- if (!this.symbolTable.has(n)) this.symbolTable.set(n, new Set());
225
- this.symbolTable.get(n).add(chunk.id);
226
- }
227
- }
228
- }
229
-
230
- // ─── Embedding binary helpers ──────────────────────────────────────────────
231
-
232
- _loadEmbeddingBinary(buf) {
233
- let off = 0;
234
- const count = buf.readUInt32LE(off); off += 4;
235
- for (let i = 0; i < count; i++) {
236
- const hashLen = buf.readUInt32LE(off); off += 4;
237
- const hash = buf.subarray(off, off + hashLen).toString('utf8'); off += hashLen;
238
- const dim = buf.readUInt32LE(off); off += 4;
239
- const vec = new Float32Array(dim);
240
- for (let d = 0; d < dim; d++) { vec[d] = buf.readFloatLE(off); off += 4; }
241
- this.embeddingCache.set(hash, vec);
242
- }
243
- }
244
-
245
- /** Build offset table without creating Float32Array objects. */
246
- _buildVecOffsets(buf) {
247
- this._vecOffsets = new Map();
248
- let off = 0;
249
- const count = buf.readUInt32LE(off); off += 4;
250
- for (let i = 0; i < count; i++) {
251
- const hashLen = buf.readUInt32LE(off); off += 4;
252
- const hash = buf.subarray(off, off + hashLen).toString('utf8'); off += hashLen;
253
- const dim = buf.readUInt32LE(off); off += 4;
254
- this._vecOffsets.set(hash, { offset: off, dim });
255
- off += dim * 4; // skip float data — never loaded into JS heap
256
- }
257
- }
258
-
259
- /** Return Float32Array for a chunk, using in-memory buffer or disk fd. */
260
- _getVecForChunk(chunkId) {
261
- const chunk = this.chunks.get(chunkId);
262
- if (!chunk?.content_hash) return null;
263
- const entry = this._vecOffsets.get(embeddingKeyFor(chunk))
264
- ?? this._vecOffsets.get(chunk.content_hash); // pre-enrichment-key bins
265
- if (!entry) return null;
266
-
267
- if (this._embeddingBuf) {
268
- // Buffer-backed: zero-copy aligned view or fallback copy
269
- const byteStart = entry.offset;
270
- if ((this._embeddingBuf.byteOffset + byteStart) % 4 === 0) {
271
- return new Float32Array(
272
- this._embeddingBuf.buffer,
273
- this._embeddingBuf.byteOffset + byteStart,
274
- entry.dim
275
- );
276
- }
277
- const vec = new Float32Array(entry.dim);
278
- for (let d = 0; d < entry.dim; d++)
279
- vec[d] = this._embeddingBuf.readFloatLE(byteStart + d * 4);
280
- return vec;
281
- }
282
-
283
- if (this._vecFd >= 0) {
284
- // Disk-backed: single pread syscall
285
- const byteLen = entry.dim * 4;
286
- const raw = Buffer.allocUnsafe(byteLen);
287
- try {
288
- const read = fs.readSync(this._vecFd, raw, 0, byteLen, entry.offset);
289
- if (read < byteLen) return null;
290
- const vec = new Float32Array(entry.dim);
291
- for (let d = 0; d < entry.dim; d++) vec[d] = raw.readFloatLE(d * 4);
292
- return vec;
293
- } catch { return null; }
294
- }
295
-
296
- return null;
297
- }
298
-
299
- // ─── Lexical index (TRUE inverted index) ──────────────────────────────────
300
-
301
- _indexLexical(chunkId, text, filePath = '') {
302
- const tokens = tokenize(text); // raw + additive Porter stems
303
- if (tokens.length === 0) return;
304
-
305
- const termCounts = new Map();
306
- for (const token of tokens) {
307
- termCounts.set(token, (termCounts.get(token) || 0) + 1);
308
- }
309
-
310
- // Additive stem tokens earn their own postings but must NOT inflate docLen —
311
- // inflated length would perturb BM25 normalisation for exact symbolic matches.
312
- // raw-based docLen keeps raw-term BM25 byte-identical to the pre-stem index.
313
- // Path tokens are tallied separately in searchHybrid, not here.
314
- const rawLen = tokenize(text, false).length;
315
- this.docLens.set(chunkId, rawLen);
316
- this.totalDocLen += rawLen;
317
-
318
- const chunkTokenSet = new Set();
319
- for (const [term, count] of termCounts) {
320
- this.df.set(term, (this.df.get(term) || 0) + 1);
321
- let posting = this.invertedIndex.get(term);
322
- if (!posting) { posting = new Map(); this.invertedIndex.set(term, posting); }
323
- posting.set(chunkId, count);
324
- chunkTokenSet.add(term);
325
- }
326
-
327
- this.chunkTerms.set(chunkId, chunkTokenSet);
328
- this.docCount++;
329
-
330
- // ── Path tokens: stored separately from content, not in BM25 index ──────
331
- // Path matching is done multiplicatively in searchHybrid, which avoids
332
- // amplifying short stubs that happen to share a path with a long function.
333
- if (filePath) {
334
- const pathTokenSet = new Set(tokenize(filePath.replace(/[/\-_.]/g, ' '), false));
335
- if (!this.pathTokens) this.pathTokens = new Map();
336
- this.pathTokens.set(chunkId, pathTokenSet);
337
- }
338
- }
339
-
340
- _removeLexical(chunkId) {
341
- const terms = this.chunkTerms.get(chunkId);
342
- if (!terms) return;
343
- for (const term of terms) {
344
- const posting = this.invertedIndex.get(term);
345
- if (posting) {
346
- posting.delete(chunkId);
347
- if (posting.size === 0) this.invertedIndex.delete(term);
348
- }
349
- const freq = this.df.get(term);
350
- if (freq !== undefined) {
351
- if (freq <= 1) this.df.delete(term);
352
- else this.df.set(term, freq - 1);
353
- }
354
- }
355
- const dl = this.docLens.get(chunkId);
356
- if (dl !== undefined) {
357
- this.totalDocLen = Math.max(0, this.totalDocLen - dl);
358
- this.docLens.delete(chunkId);
359
- }
360
- this.pathTokens.delete(chunkId);
361
- this.chunkTerms.delete(chunkId);
362
- this.docCount = Math.max(0, this.docCount - 1);
363
- }
364
-
365
- _searchLexical(queryText) {
366
- // Asymmetric stemming: the index always carries stems, but only stem the
367
- // QUERY for natural-language/behavioural queries — symbolic name lookups
368
- // stay exact so stem collisions never dilute a precise symbol match.
369
- const queryTokens = tokenize(queryText, isNaturalLanguageQuery(queryText));
370
- const scores = new Map();
371
- const avgdl = this.docCount > 0 ? this.totalDocLen / this.docCount : 1;
372
-
373
- for (const token of queryTokens) {
374
- const docFreq = this.df.get(token);
375
- if (!docFreq) continue;
376
- const idf = okapiIdf(this.docCount, docFreq);
377
- const posting = this.invertedIndex.get(token);
378
- if (!posting) continue;
379
- for (const [chunkId, tf] of posting) {
380
- const dl = this.docLens.get(chunkId) ?? avgdl;
381
- scores.set(chunkId, (scores.get(chunkId) || 0) + bm25Score(idf, tf, dl, avgdl));
382
- }
383
- }
384
- return Array.from(scores.entries())
385
- .sort((a, b) => (b[1] - a[1]) || (a[0] < b[0] ? -1 : 1)) // id tie-break: backend parity
386
- .slice(0, LEXICAL_FUSION_CAP) // same cap as SQLite: parity
387
- .map(([id, score], rank) => ({ id, score, rank: rank + 1 }));
388
- }
389
-
390
- // ─── Vector search ─────────────────────────────────────────────────────────
391
-
392
- _invalidateMatrix() {
393
- this._matrixDirty = true;
394
- this._hnsw = null;
395
- }
396
-
397
- addVector(id, vec) {
398
- this.vectors.set(id, vec);
399
- this._invalidateMatrix();
400
- }
401
-
402
- removeVector(id) {
403
- if (this.vectors.delete(id)) this._invalidateMatrix();
404
- }
405
-
406
- _rebuildMatrix() {
407
- const ids = Array.from(this.vectors.keys());
408
- const n = ids.length;
409
- this._vecIds = ids;
410
- if (n === 0) {
411
- this._vecMatrix = null; this._vecNorms = null;
412
- this._dim = 0; this._hnsw = null; this._matrixDirty = false;
413
- return;
414
- }
415
- const dim = this.vectors.get(ids[0]).length;
416
- this._dim = dim;
417
- const matrix = new Float32Array(n * dim);
418
- // Float64: norms must carry full precision — f32-rounded norms produced
419
- // ~1e-7 score skew vs the disk-backed store's double-precision rescore,
420
- // which was enough to flip near-tied ranks between backends.
421
- const norms = new Float64Array(n);
422
- for (let i = 0; i < n; i++) {
423
- const v = this.vectors.get(ids[i]);
424
- let normSq = 0;
425
- const base = i * dim;
426
- for (let d = 0; d < dim; d++) {
427
- const vd = v[d];
428
- matrix[base + d] = vd;
429
- normSq += vd * vd;
430
- }
431
- norms[i] = Math.sqrt(normSq);
432
- }
433
- this._vecMatrix = matrix;
434
- this._vecNorms = norms;
435
- this._matrixDirty = false;
436
-
437
- if (HierarchicalNSW && n >= HNSW_THRESHOLD) {
438
- try {
439
- const hnsw = new HierarchicalNSW('cosine', dim);
440
- hnsw.initIndex(n, 16, 200, 100);
441
- if (typeof hnsw.setEf === 'function') hnsw.setEf(100);
442
- const buf = new Array(dim);
443
- for (let i = 0; i < n; i++) {
444
- const base = i * dim;
445
- for (let d = 0; d < dim; d++) buf[d] = matrix[base + d];
446
- hnsw.addPoint(buf, i);
447
- }
448
- this._hnsw = hnsw;
449
- } catch (e) {
450
- process.stderr.write(`[engine/memory] HNSW build failed: ${e.message}\n`);
451
- this._hnsw = null;
452
- }
453
- }
454
- }
455
-
456
- /** Full flat-scan / HNSW search (eager mode). */
457
- _searchVector(queryVector, minScore = 0.3) {
458
- if (this.vectors.size === 0) return [];
459
- if (this._matrixDirty) this._rebuildMatrix();
460
- if (!this._vecMatrix) return [];
461
- if (queryVector.length !== this._dim) return [];
462
-
463
- const n = this._vecIds.length;
464
- const dim = this._dim;
465
- let qNorm = 0;
466
- for (let d = 0; d < dim; d++) qNorm += queryVector[d] * queryVector[d];
467
- qNorm = Math.sqrt(qNorm);
468
- if (qNorm === 0) return [];
469
-
470
- if (this._hnsw) {
471
- const topK = Math.min(200, n);
472
- try {
473
- const qArr = Array.isArray(queryVector) ? queryVector : Array.from(queryVector);
474
- const { neighbors, distances } = this._hnsw.searchKnn(qArr, topK);
475
- const results = [];
476
- for (let i = 0; i < neighbors.length; i++) {
477
- const score = 1 - distances[i];
478
- if (score > minScore) results.push({ id: this._vecIds[neighbors[i]], score, rank: i + 1 });
479
- }
480
- return results;
481
- } catch { /* fall through to flat scan */ }
482
- }
483
-
484
- const results = [];
485
- for (let i = 0; i < n; i++) {
486
- const base = i * dim;
487
- let dp = 0;
488
- for (let d = 0; d < dim; d++) dp += queryVector[d] * this._vecMatrix[base + d];
489
- const score = dp / (qNorm * this._vecNorms[i]);
490
- if (score > minScore) results.push({ id: this._vecIds[i], score, rank: 0 });
491
- }
492
- results.sort((a, b) => b.score - a.score);
493
- for (let i = 0; i < results.length; i++) results[i].rank = i + 1;
494
- return results;
495
- }
496
-
497
- /**
498
- * Full vector search in lazy mode: stream the entire embeddings bin (bounded
499
- * buffers, no full matrix in RAM) and map the top entry keys back to chunks.
500
- * Replaces the old lexical-prefiltered scoring, which could never surface a
501
- * chunk for a conceptual query that shared no tokens with the code.
502
- */
503
- _scanVectorsLazy(queryVector, minScore = 0.3) {
504
- const source = this._embeddingBuf ? { buffer: this._embeddingBuf } : { fd: this._vecFd };
505
- if (!this._embeddingBuf && this._vecFd < 0) return [];
506
- // Large corpora: binary sketch (built once on first vector query) replaces
507
- // the exact O(corpus) scan with a Hamming prefilter + bounded rescore.
508
- // SKETCH_THRESHOLD is shared with the SQLite store so both switch to the
509
- // sketch at the same vector count. NOTE: this lazy path only runs when the
510
- // store is in lazy mode (cacheEmbeddings off AND chunkCount ≥ LAZY_VEC_THRESHOLD);
511
- // a corpus with < LAZY_VEC_THRESHOLD chunks but ≥ SKETCH_THRESHOLD vectors
512
- // (heavy enrichment/oversized windows) still scans exactly here while SQLite
513
- // sketches — a narrow band worth a dedicated scale parity test before relying
514
- // on byte-identical top-5 above the threshold.
515
- if (!this._sketch && this._vecOffsets.size >= SKETCH_THRESHOLD) {
516
- try { this._sketch = updateVectorSketch(null, source); } catch { this._sketch = null; }
517
- }
518
- const hits = this._sketch
519
- ? searchVectorSketch(this._sketch, source, queryVector, { topN: VECTOR_SCAN_RAW_N, minScore })
520
- : scanEmbeddingBinary(source, queryVector, { topN: VECTOR_SCAN_RAW_N, minScore });
521
- if (hits.length === 0) return [];
522
-
523
- if (!this._keyToIds) {
524
- const map = new Map();
525
- const put = (key, id) => {
526
- if (!map.has(key)) map.set(key, []);
527
- map.get(key).push(id);
528
- };
529
- for (const c of this.chunks.values()) {
530
- if (!c.content_hash) continue;
531
- const key = embeddingKeyFor(c);
532
- put(key, c.id);
533
- put(key + SUMMARY_VEC_SUFFIX, c.id); // summary-only vector → same chunk
534
- for (let i = 1; i < EMBEDDING_MAX_WINDOWS; i++) put(key + WINDOW_VEC_SUFFIX + i, c.id); // window vectors → same chunk
535
- if (key !== c.content_hash) put(c.content_hash, c.id); // legacy bins
536
- }
537
- this._keyToIds = map;
538
- }
539
-
540
- const entries = [];
541
- for (const { key, score } of hits) {
542
- for (const id of (this._keyToIds.get(key) || [])) entries.push({ id, score });
543
- }
544
- return finalizeVectorCandidates(entries);
545
- }
546
-
547
- // ─── Hybrid search ─────────────────────────────────────────────────────────
548
-
549
- searchHybrid(queryText, queryVector, topK = 5, minScore = 0.3, exactBoostName = null) {
550
- const lexicalResults = this._searchLexical(queryText);
551
-
552
- let vectorResults;
553
- if (queryVector) {
554
- if (this._lazyMode && this._vecOffsets.size > 0) {
555
- // Full streaming scan of the bin — covers conceptual queries with
556
- // zero lexical overlap, which the old TF-IDF prefilter never could.
557
- vectorResults = this._scanVectorsLazy(queryVector, minScore);
558
- } else {
559
- // Eager rows include summary-only and per-window pseudo ids
560
- // (`<id>|s`, `<id>|wN`) — fold each back onto its chunk via the shared
561
- // finalizer (best/max-sim score per chunk, deterministic order, same
562
- // cap as the disk-backed paths).
563
- const entries = this._searchVector(queryVector, minScore).map(r => ({
564
- id: baseEmbeddingKey(r.id),
565
- score: r.score,
566
- }));
567
- vectorResults = finalizeVectorCandidates(entries);
568
- }
569
- } else {
570
- vectorResults = [];
571
- }
572
-
573
- // B3 learned-sparse channel: opt-in (model present) + NL-asymmetric (symbolic queries skip
574
- // it → byte-identical). expandSparseQuery returns null when nothing fires → no channel.
575
- const sparseResults = this._searchSparseExpanded(queryText);
576
-
577
- return fuseAndRank({
578
- lexicalResults,
579
- vectorResults,
580
- sparseResults,
581
- getChunk: (id) => this.chunks.get(id),
582
- getPathTokens: (id) => this.pathTokens.get(id),
583
- getDf: (t) => this.df.get(t) || 0,
584
- docCount: this.docCount,
585
- rrfK: this.rrfK,
586
- topK,
587
- queryText,
588
- exactBoostName,
589
- corpusEnriched: this._corpusEnriched,
590
- // O(1) vs the old full-scan; same contract implemented by the SQLite backend.
591
- resolveExact: (term) => this.symbolTable.get(term) || [],
592
- });
593
- }
594
-
595
- /**
596
- * B3 — the learned-sparse expansion channel: NL queries only, model present only. Returns the
597
- * ranked sparse candidates, or [] when the channel is inert (no model / symbolic query / no
598
- * association fired) so fusion is byte-identical to the default path.
599
- */
600
- _searchSparseExpanded(queryText) {
601
- if (!this._sparseModel || !isNaturalLanguageQuery(queryText)) return [];
602
- const termWeights = expandSparseQuery(queryText, this._sparseModel);
603
- if (!termWeights) return [];
604
- const avgdl = this.docCount > 0 ? this.totalDocLen / this.docCount : 1;
605
- return scoreSparseExpanded(termWeights, {
606
- docCount: this.docCount,
607
- avgdl,
608
- getDf: (t) => this.df.get(t) || 0,
609
- getPostings:(t) => this.invertedIndex.get(t) || [],
610
- getDocLen: (id) => this.docLens.get(id),
611
- });
612
- }
613
-
614
- /** Whether opt-in learned-sparse associations (B3 --learned-sparse) are loaded. */
615
- hasSparseModel() { return this._sparseModel != null; }
616
-
617
- /** Path to the shared `.embeddings.bin` (B4 late-interaction reranker reads candidate vectors from it). */
618
- embeddingBinPath() { return this._embeddingPath; }
619
-
620
- // ─── Persistence ───────────────────────────────────────────────────────────
621
-
622
- async save() {
623
- const chunksData = Array.from(this.chunks.values()).map(c => ({
624
- id: c.id, file_path: c.file_path, node_type: c.node_type,
625
- name: c.name, docstring: c.docstring || '', code_snippet: c.code_snippet,
626
- content_hash: c.content_hash, start_line: c.start_line, end_line: c.end_line,
627
- calls: c.calls || [], call_sites: c.call_sites || [], params: c.params || [],
628
- return_type: c.return_type || '', class_context: c.class_context || '',
629
- type_refs: c.type_refs || [], decorators: c.decorators || [],
630
- extends: c.extends || [],
631
- hyde: c.hyde || '', summary: c.summary || '', concepts: c.concepts || [],
632
- }));
633
- const payload = JSON.stringify({
634
- chunks: chunksData, graph: this.graph,
635
- ...(this._edges ? { edges: this._edges } : {}),
636
- ...(this._centrality ? { centrality: Object.fromEntries(this._centrality) } : {}),
637
- ...(this._taint ? { taint: this._taint } : {}),
638
- ...(this._scipRefs ? { scip_refs: this._scipRefs } : {}),
639
- ...(this._sparseModel ? { sparse_model: this._sparseModel } : {}),
640
- });
641
- const tmpPath = `${this.indexPath}.tmp`;
642
- const tmpBinPath = `${this._embeddingPath}.tmp`;
643
- await Promise.all([
644
- fs.promises.writeFile(tmpPath, payload),
645
- fs.promises.writeFile(tmpBinPath, writeEmbeddingBinary(this.embeddingCache)),
646
- ]);
647
- await Promise.all([
648
- fs.promises.rename(tmpPath, this.indexPath),
649
- fs.promises.rename(tmpBinPath, this._embeddingPath),
650
- ]);
651
- }
652
-
653
- saveDebounced(delayMs = 3000) {
654
- if (this._saveTimer) clearTimeout(this._saveTimer);
655
- this._saveTimer = setTimeout(async () => {
656
- this._saveTimer = null;
657
- try { await this.save(); }
658
- catch (err) { process.stderr.write(`[engine/memory] ❌ Async save failed: ${err.message}\n`); }
659
- }, delayMs);
660
- }
661
-
662
- _removeSymbol(chunk) {
663
- if (!chunk.name || chunk.name === 'anonymous') return;
664
- const n = chunk.name.toLowerCase();
665
- const set = this.symbolTable.get(n);
666
- if (set) {
667
- set.delete(chunk.id);
668
- if (set.size === 0) this.symbolTable.delete(n);
669
- }
670
- }
671
-
672
- /** Whether a vector for this embedding key is already cached (skip re-embedding). */
673
- hasEmbedding(key) { return this.embeddingCache.has(key); }
674
-
675
- /**
676
- * Atomically replace every chunk of one file — the incremental-update entry
677
- * point used by the watch daemon. Removes the file's old chunks from the
678
- * lexical, vector AND symbol indexes (the old daemon path leaked symbol-table
679
- * entries), stores newly computed embeddings, indexes the new chunks, and
680
- * schedules a debounced save. Pass empty chunks/imports for a deleted file.
681
- *
682
- * @param {string} filePath
683
- * @param {object} p
684
- * @param {object[]} [p.chunks]
685
- * @param {string[]} [p.imports]
686
- * @param {Map<string, Float32Array|number[]>} [p.embeddings] New vectors keyed by embeddingKeyFor(chunk).
687
- */
688
- applyFileUpdate(filePath, { chunks = [], imports = [], embeddings = null } = {}) {
689
- // A per-file edit makes the whole-program symbol graph + its centrality stale; drop
690
- // both so findCallers/findReferers fall back to the always-correct scan until the next
691
- // full `idx-index` rebuilds them (the daemon never rebuilds the graph).
692
- if (this._edges || this._centrality || this._taint || this._scipRefs || this._sparseModel) this._clearEdges();
693
- this.updateFileGraph(filePath, imports);
694
-
695
- for (const [id, chunk] of Array.from(this.chunks.entries())) {
696
- if (chunk.file_path !== filePath) continue;
697
- this._removeLexical(id);
698
- this.removeVector(id);
699
- this.removeVector(id + SUMMARY_VEC_SUFFIX);
700
- for (let i = 1; i < EMBEDDING_MAX_WINDOWS; i++) this.removeVector(id + WINDOW_VEC_SUFFIX + i);
701
- this._removeSymbol(chunk);
702
- this.chunks.delete(id);
703
- }
704
-
705
- if (embeddings) {
706
- for (const [key, vec] of embeddings) {
707
- this.embeddingCache.set(key, vec instanceof Float32Array ? vec : new Float32Array(vec));
708
- }
709
- }
710
-
711
- for (const chunk of chunks) {
712
- const vecKey = embeddingKeyFor(chunk);
713
- const vec = this.embeddingCache.get(vecKey) ?? this.embeddingCache.get(chunk.content_hash);
714
- if (vec) this.addVector(chunk.id, vec);
715
- const sVec = this.embeddingCache.get(vecKey + SUMMARY_VEC_SUFFIX);
716
- if (sVec) this.addVector(chunk.id + SUMMARY_VEC_SUFFIX, sVec);
717
- for (let i = 1; i < EMBEDDING_MAX_WINDOWS; i++) {
718
- const wVec = this.embeddingCache.get(vecKey + WINDOW_VEC_SUFFIX + i);
719
- if (wVec) this.addVector(chunk.id + WINDOW_VEC_SUFFIX + i, wVec);
720
- }
721
- this._indexLexical(chunk.id, buildLexicalDocument(chunk, imports), chunk.file_path);
722
- this.chunks.set(chunk.id, chunk);
723
- if (chunk.name && chunk.name !== 'anonymous') {
724
- const n = chunk.name.toLowerCase();
725
- if (!this.symbolTable.has(n)) this.symbolTable.set(n, new Set());
726
- this.symbolTable.get(n).add(chunk.id);
727
- }
728
- }
729
-
730
- this._keyToIds = null;
731
- this.saveDebounced();
732
- }
733
-
734
- /**
735
- * Drop every in-memory structure and re-read the index artifacts from disk.
736
- * Used by the MCP server when the watch daemon (a separate process) rewrites
737
- * code-index.json — without this the server would serve a stale snapshot
738
- * until restart.
739
- */
740
- reload() {
741
- if (this._vecFd >= 0) { try { fs.closeSync(this._vecFd); } catch {} this._vecFd = -1; }
742
- this.chunks = new Map();
743
- this.graph = { dependencies: {}, importedBy: {} };
744
- this.embeddingCache = new Map();
745
- this.symbolTable = new Map();
746
- this.invertedIndex = new Map();
747
- this.chunkTerms = new Map();
748
- this.docLens = new Map();
749
- this.totalDocLen = 0;
750
- this.pathTokens = new Map();
751
- this.docCount = 0;
752
- this.df = new Map();
753
- this.vectors = new Map();
754
- this._invalidateMatrix();
755
- this._vecOffsets = new Map();
756
- this._embeddingBuf = null;
757
- this._keyToIds = null;
758
- this._sketch = null;
759
- this._lazyMode = false;
760
- this.load();
761
- }
762
-
763
- updateFileGraph(filePath, imports) {
764
- const oldDeps = this.graph.dependencies[filePath] || [];
765
- for (const oldDep of oldDeps) {
766
- if (this.graph.importedBy[oldDep]) {
767
- this.graph.importedBy[oldDep] = this.graph.importedBy[oldDep].filter(f => f !== filePath);
768
- }
769
- }
770
- this.graph.dependencies[filePath] = imports;
771
- for (const dep of imports) {
772
- if (!this.graph.importedBy[dep]) this.graph.importedBy[dep] = [];
773
- if (!this.graph.importedBy[dep].includes(filePath)) this.graph.importedBy[dep].push(filePath);
774
- }
775
- }
776
-
777
- // ─── Store contract ────────────────────────────────────────────────────────
778
- // The read surface the MCP tools consume. The SQLite store implements the same
779
- // methods over disk so tools never reach into a backend's internals.
780
-
781
- get backend() { return 'memory'; }
782
-
783
- /** @returns {object|null} */
784
- getChunk(id) { return this.chunks.get(id) ?? null; }
785
-
786
- /** All chunks defined in a given file. @returns {object[]} */
787
- getChunksByFile(filePath) {
788
- const out = [];
789
- for (const c of this.chunks.values()) if (c.file_path === filePath) out.push(c);
790
- return out;
791
- }
792
-
793
- /** Chunks whose symbol name matches exactly (case-insensitive). @returns {object[]} */
794
- resolveSymbol(name) {
795
- const ids = this.symbolTable.get(String(name).toLowerCase().trim());
796
- if (!ids) return [];
797
- const out = [];
798
- for (const id of ids) { const c = this.chunks.get(id); if (c) out.push(c); }
799
- return out;
800
- }
801
-
802
- /**
803
- * Resolved symbol-graph edges incident to a chunk (A4). Returns [] unless the
804
- * opt-in graph was built. Deterministically ordered (the edge list is pre-sorted).
805
- * @param {string} chunkId
806
- * @param {object} [opts]
807
- * @param {string|null} [opts.kind] Filter to 'calls' | 'extends' | 'type'.
808
- * @param {'in'|'out'} [opts.direction] 'in' = referrers of chunkId (default), 'out' = its referents.
809
- * @returns {Array<{from_chunk_id:string,to_chunk_id:string,kind:string,confidence:string,chunk:object|null}>}
810
- */
811
- /** Whether the opt-in resolved symbol graph (A4) is loaded. */
812
- hasSymbolGraph() { return this._edges != null; }
813
-
814
- getEdges(chunkId, { kind = null, direction = 'in' } = {}) {
815
- if (!this._edges) return [];
816
- const src = direction === 'out' ? this._edgesByFrom : this._edgesByTo;
817
- const list = src.get(chunkId) || [];
818
- const out = [];
819
- for (const e of list) {
820
- if (kind && e.kind !== kind) continue;
821
- const otherId = direction === 'out' ? e.to_chunk_id : e.from_chunk_id;
822
- out.push({ ...e, chunk: this.chunks.get(otherId) ?? null });
823
- }
824
- return out;
825
- }
826
-
827
- /** Whether opt-in symbol centrality (A5) is loaded. */
828
- hasCentrality() { return this._centrality != null; }
829
-
830
- /** Whether opt-in serialized taint flows (C2 --taint) are loaded. */
831
- hasTaint() { return this._taint != null; }
832
-
833
- /** The serialized taint payload `{ flows, meta }` (C2), or null. */
834
- getTaintFlows() { return this._taint; }
835
-
836
- /** Whether opt-in SCIP precise references (A2 v2 --resolver scip) are loaded. */
837
- hasScipRefs() { return this._scipRefs != null; }
838
-
839
- /** SCIP-confirmed referer chunk ids for a definition chunk (A2 v2), or [] when none / not loaded. */
840
- getScipReferers(defChunkId) {
841
- const refs = this._scipRefs && this._scipRefs[defChunkId];
842
- return Array.isArray(refs) ? refs : [];
843
- }
844
-
845
- /** How many definitions carry SCIP precise references (A2 v2). 0 when not loaded. */
846
- scipRefCount() { return this._scipRefs ? Object.keys(this._scipRefs).length : 0; }
847
-
848
- /**
849
- * Symbol centrality (A5) for one chunk: its PageRank score, dense rank (1 = most central),
850
- * and the total number of ranked (connected) chunks. Null when the chunk is unranked
851
- * (isolated) or centrality was not built. @returns {{score:number,rank:number,total:number}|null}
852
- */
853
- getCentrality(chunkId) {
854
- if (!this._centrality) return null;
855
- const e = this._centrality.get(chunkId);
856
- return e ? { score: e.score, rank: e.rank, total: this._centralityTotal } : null;
857
- }
858
-
859
- /** The most central symbols (A5), rank-ascending. @returns {Array<{chunk,score,rank}>} */
860
- topCentral(limit = 20) {
861
- if (!this._centrality) return [];
862
- if (!this._centralRanked) {
863
- this._centralRanked = [...this._centrality.entries()]
864
- .map(([id, v]) => ({ id, score: v.score, rank: v.rank }))
865
- .sort((a, b) => a.rank - b.rank || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
866
- }
867
- const out = [];
868
- for (const { id, score, rank } of this._centralRanked) {
869
- const chunk = this.chunks.get(id);
870
- if (!chunk) continue;
871
- out.push({ chunk, score, rank });
872
- if (out.length >= limit) break;
873
- }
874
- return out;
875
- }
876
-
877
- /** Chunks that call the given function name. @returns {object[]} */
878
- findCallers(funcName) {
879
- // Edge-backed (A4) when the resolved graph is present and the name is defined —
880
- // returns the SAME set as the scan below (every name-match caller → each def).
881
- if (this._edges) {
882
- const defs = this.resolveSymbol(funcName);
883
- if (defs.length > 0) return this._referrersByEdge(defs, e => e.kind === 'calls');
884
- }
885
- const out = [];
886
- for (const c of this.chunks.values()) if (c.calls?.includes(funcName)) out.push(c);
887
- return out;
888
- }
889
-
890
- /** From-chunks of edges of a given kind that point at any of `defs`, id-sorted. */
891
- _referrersByEdge(defs, keep) {
892
- const ids = new Set();
893
- for (const def of defs) for (const e of (this._edgesByTo.get(def.id) || [])) if (keep(e)) ids.add(e.from_chunk_id);
894
- const out = [];
895
- for (const id of [...ids].sort()) { const c = this.chunks.get(id); if (c) out.push(c); }
896
- return out;
897
- }
898
-
899
- /**
900
- * Chunks that reference a symbol *by name* as a type (`type_refs`) or as a
901
- * base class/interface (`extends`) — the non-call half of symbol-level
902
- * references. Calls are served by findCallers; mcp/topology.mjs findReferences fuses
903
- * the two and classifies each by confidence. Case-insensitive exact match.
904
- * @returns {object[]}
905
- */
906
- findReferers(symbol) {
907
- const key = String(symbol).toLowerCase().trim();
908
- if (!key) return [];
909
- if (this._edges) {
910
- const defs = this.resolveSymbol(symbol);
911
- if (defs.length > 0) return this._referrersByEdge(defs, e => e.kind === 'extends' || e.kind === 'type');
912
- }
913
- const out = [];
914
- for (const c of this.chunks.values()) {
915
- // A class/type lists its own name in type_refs (the name node is a
916
- // type_identifier) — that self-mention is a definition, not a reference.
917
- if (c.name && c.name.toLowerCase() === key) continue;
918
- const hit = (c.type_refs && c.type_refs.some(t => t.toLowerCase() === key))
919
- || (c.extends && c.extends.some(t => t.toLowerCase() === key));
920
- if (hit) out.push(c);
921
- }
922
- return out;
923
- }
924
-
925
- /**
926
- * HTTP routes filtered by method and/or path prefix, each augmented with its
927
- * resolved handler chunk (`chunk`, looked up by handler_chunk_id, or null).
928
- * `graph.routes` round-trips verbatim through load(); a route-less index yields
929
- * []. Method is matched case-insensitively (HTTP verbs are upper-cased); the
930
- * path prefix match is case-insensitive. Mirrors SqliteGraphStore.findRoutes so
931
- * both backends return identical records.
932
- * @returns {object[]}
933
- */
934
- findRoutes({ method = null, pathPrefix = null } = {}) {
935
- const routes = (this.graph && this.graph.routes) || [];
936
- const m = method ? String(method).toUpperCase().trim() : null;
937
- const p = pathPrefix ? String(pathPrefix).toLowerCase() : null;
938
- const out = [];
939
- for (const r of routes) {
940
- if (m && String(r.method || '').toUpperCase() !== m) continue;
941
- if (p && !String(r.path || '').toLowerCase().startsWith(p)) continue;
942
- const chunk = r.handler_chunk_id ? (this.chunks.get(r.handler_chunk_id) || null) : null;
943
- out.push({ ...r, chunk });
944
- }
945
- return out;
946
- }
947
-
948
- /** Lazily iterate every chunk (cursor-friendly parity with the SQLite store). */
949
- *iterateChunks() { yield* this.chunks.values(); }
950
-
951
- chunkCount() { return this.chunks.size; }
952
- symbolCount() { return this.symbolTable.size; }
953
- fileCount() {
954
- const files = new Set();
955
- for (const c of this.chunks.values()) files.add(c.file_path);
956
- return files.size;
957
- }
958
- vectorCount() {
959
- if (this._lazyMode) {
960
- let n = 0;
961
- for (const k of this._vecOffsets.keys()) if (baseEmbeddingKey(k) === k) n++;
962
- return n;
963
- }
964
- let n = 0;
965
- for (const k of this.vectors.keys()) if (baseEmbeddingKey(k) === k) n++;
966
- return n;
967
- }
968
-
969
- getDependencies(filePath) { return this.graph.dependencies[filePath] || []; }
970
- getImportedBy(filePath) { return this.graph.importedBy[filePath] || []; }
971
-
972
- /** Engine-level health facts for list_index_stats (daemon/age added by the tool). */
973
- stats() {
974
- const extCounts = new Map();
975
- for (const c of this.chunks.values()) {
976
- const ext = c.file_path.split('.').pop() || 'unknown';
977
- extCounts.set(ext, (extCounts.get(ext) || 0) + 1);
978
- }
979
- const vectorSource = this._lazyMode
980
- ? (this._vecFd >= 0 ? 'disk-backed fd' : 'buffer-lazy')
981
- : 'eager (in-memory matrix)';
982
- return {
983
- backend: 'memory',
984
- chunks: this.chunks.size,
985
- files: this.fileCount(),
986
- symbols: this.symbolTable.size,
987
- vectors: this.vectorCount(),
988
- hasVectors: this.vectors.size > 0 || this._vecOffsets.size > 0,
989
- lazyMode: this._lazyMode,
990
- vectorSource,
991
- extCounts,
992
- };
993
- }
994
-
995
- // ─── Cleanup ───────────────────────────────────────────────────────────────
996
-
997
- close() {
998
- if (this._vecFd >= 0) {
999
- try { fs.closeSync(this._vecFd); } catch {}
1000
- this._vecFd = -1;
1001
- }
1002
- }
1003
- }