graph-indexer 1.1.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +412 -0
- package/README.md +228 -350
- package/SECURITY.md +68 -0
- package/TRADEMARK.md +31 -0
- package/cli-ui.mjs +78 -0
- package/config.mjs +269 -0
- package/daemon-ctl.mjs +175 -0
- package/daemon-lock.mjs +69 -0
- package/embedders/python/mlx_embed_server.py +61 -0
- package/embedders/python/requirements-mlx.txt +5 -0
- package/embedders/setup-mlx.mjs +110 -0
- package/embeddings.mjs +489 -0
- package/engine/binary.mjs +399 -0
- package/{core-engine.mjs → engine/memory.mjs} +359 -249
- package/engine/sqlite.mjs +863 -0
- package/enrichment.mjs +426 -0
- package/git-signals.mjs +248 -0
- package/indexer.mjs +216 -77
- package/init.mjs +1604 -170
- package/layout.mjs +123 -0
- package/mcp/format.mjs +61 -0
- package/mcp/tools.mjs +1157 -0
- package/mcp/topology.mjs +551 -0
- package/mcp-server.mjs +118 -573
- package/package.json +56 -16
- package/parse/extractor.mjs +617 -0
- package/parse/imports.mjs +365 -0
- package/parse/languages.mjs +89 -0
- package/parse/metadata.mjs +655 -0
- package/parse/routes.mjs +327 -0
- package/prompts/CORE.md +86 -0
- package/prompts/DOMAIN_TEMPLATE.md +46 -0
- package/prompts/INTEGRATION.md +123 -0
- package/prompts/frameworks/ANDROID.md +17 -0
- package/prompts/frameworks/ASPNET_CORE.md +19 -0
- package/prompts/frameworks/FASTAPI_DJANGO.md +19 -0
- package/prompts/frameworks/LARAVEL_SYMFONY.md +22 -0
- package/prompts/frameworks/NODE_EXPRESS_NESTJS.md +18 -0
- package/prompts/frameworks/RAILS.md +20 -0
- package/prompts/frameworks/REACT.md +20 -0
- package/prompts/frameworks/SPRING_BOOT.md +19 -0
- package/prompts/languages/BASH.md +22 -0
- package/prompts/languages/C.md +21 -0
- package/prompts/languages/CSHARP.md +24 -0
- package/prompts/languages/CSS_SCSS.md +22 -0
- package/prompts/languages/GO.md +23 -0
- package/prompts/languages/JAVA.md +23 -0
- package/prompts/languages/JAVASCRIPT_TYPESCRIPT.md +24 -0
- package/prompts/languages/KOTLIN.md +24 -0
- package/prompts/languages/PHP.md +23 -0
- package/prompts/languages/PYTHON.md +23 -0
- package/prompts/languages/RUBY.md +23 -0
- package/prompts/languages/RUST.md +21 -0
- package/prompts/languages/SWIFT.md +21 -0
- package/search-core.mjs +627 -0
- package/storage.mjs +68 -0
- package/watch-daemon.mjs +204 -69
- package/PROMPT.md +0 -143
- package/parser-utils.mjs +0 -861
|
@@ -1,81 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @file
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
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
|
-
|
|
185
|
-
|
|
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
|
|
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
|
-
//
|
|
287
|
-
//
|
|
288
|
-
//
|
|
289
|
-
//
|
|
290
|
-
|
|
291
|
-
this.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(`[
|
|
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
|
-
*
|
|
481
|
-
*
|
|
482
|
-
*
|
|
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
|
-
|
|
485
|
-
const
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
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
|
-
|
|
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
|
-
|
|
506
|
-
|
|
507
|
-
|
|
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
|
-
//
|
|
519
|
-
|
|
520
|
-
vectorResults = this.
|
|
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
|
-
|
|
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
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
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(`[
|
|
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() {
|