graph-indexer 2.1.1 → 3.0.1
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 +312 -1
- package/README.md +236 -312
- package/SECURITY.md +53 -58
- package/bin/graph-indexer.mjs +251 -0
- package/integrations/claude-code/.claude-plugin/plugin.json +9 -0
- package/integrations/claude-code/.mcp.json +8 -0
- package/integrations/claude-code/agents/code-structure.md +22 -0
- package/integrations/claude-code/hooks/gi-hook.mjs +103 -0
- package/integrations/claude-code/hooks/hooks.json +16 -0
- package/integrations/opencode/graph-indexer.js +129 -0
- package/package.json +32 -109
- package/src/cli/helper.mjs +67 -0
- package/src/cli/hook.mjs +366 -0
- package/src/cli/init.mjs +272 -0
- package/src/cli/resident.mjs +185 -0
- package/src/index/discover.mjs +128 -0
- package/src/index/fingerprint.mjs +30 -0
- package/src/index/graph.mjs +61 -0
- package/src/index/indexer.mjs +476 -0
- package/src/index/modules.mjs +332 -0
- package/src/index/resolver.mjs +961 -0
- package/src/mcp/render.mjs +60 -0
- package/src/mcp/server.mjs +147 -0
- package/src/mcp/tools.mjs +974 -0
- package/src/parse/arity.mjs +153 -0
- package/src/parse/extract.mjs +1038 -0
- package/src/parse/lang/csharp.mjs +106 -0
- package/src/parse/lang/ecma.mjs +438 -0
- package/src/parse/lang/go.mjs +194 -0
- package/src/parse/lang/jvm.mjs +118 -0
- package/src/parse/lang/kotlin.mjs +97 -0
- package/src/parse/lang/misc.mjs +115 -0
- package/src/parse/lang/native.mjs +126 -0
- package/src/parse/lang/python.mjs +153 -0
- package/src/parse/lang/rust.mjs +157 -0
- package/src/parse/lang/scripting.mjs +219 -0
- package/src/parse/languages.mjs +48 -0
- package/src/parse/runtime.mjs +56 -0
- package/src/query/check.mjs +345 -0
- package/src/query/facts.mjs +323 -0
- package/src/query/intel.mjs +683 -0
- package/src/query/read.mjs +81 -0
- package/src/query/testcmd.mjs +130 -0
- package/src/query/textsearch.mjs +178 -0
- package/src/search/search.mjs +210 -0
- package/src/search/tokenize.mjs +188 -0
- package/src/store/db.mjs +169 -0
- package/src/util/git.mjs +30 -0
- package/src/util/paths.mjs +63 -0
- package/src/util/regex.mjs +2 -0
- package/vendor/grammars/bash.wasm.gz +0 -0
- package/vendor/grammars/c.wasm.gz +0 -0
- package/vendor/grammars/c_sharp.wasm.gz +0 -0
- package/vendor/grammars/cpp.wasm.gz +0 -0
- package/vendor/grammars/css.wasm.gz +0 -0
- package/vendor/grammars/go.wasm.gz +0 -0
- package/vendor/grammars/java.wasm.gz +0 -0
- package/vendor/grammars/javascript.wasm.gz +0 -0
- package/vendor/grammars/kotlin.wasm.gz +0 -0
- package/vendor/grammars/manifest.json +148 -0
- package/vendor/grammars/php.wasm.gz +0 -0
- package/vendor/grammars/python.wasm.gz +0 -0
- package/vendor/grammars/ruby.wasm.gz +0 -0
- package/vendor/grammars/rust.wasm.gz +0 -0
- package/vendor/grammars/scala.wasm.gz +0 -0
- package/vendor/grammars/tsx.wasm.gz +0 -0
- package/vendor/grammars/typescript.wasm.gz +0 -0
- package/vendor/web-tree-sitter/web-tree-sitter.js +3980 -0
- package/vendor/web-tree-sitter/web-tree-sitter.wasm +0 -0
- package/cli-ui.mjs +0 -78
- package/colbert.mjs +0 -168
- package/config.mjs +0 -494
- package/daemon-ctl.mjs +0 -175
- package/daemon-lock.mjs +0 -69
- package/embedders/python/mlx_embed_server.py +0 -61
- package/embedders/python/requirements-mlx.txt +0 -5
- package/embedders/setup-mlx.mjs +0 -110
- package/embeddings.mjs +0 -571
- package/engine/binary.mjs +0 -437
- package/engine/memory.mjs +0 -1003
- package/engine/sqlite.mjs +0 -1136
- package/enrichment.mjs +0 -521
- package/git-signals.mjs +0 -248
- package/indexer.mjs +0 -546
- package/init.mjs +0 -1809
- package/layout.mjs +0 -123
- package/mcp/centrality.mjs +0 -117
- package/mcp/format.mjs +0 -61
- package/mcp/resolver.mjs +0 -116
- package/mcp/symbolgraph.mjs +0 -137
- package/mcp/taint.mjs +0 -225
- package/mcp/tools.mjs +0 -1582
- package/mcp/topology.mjs +0 -676
- package/mcp-server.mjs +0 -197
- package/parse/extractor.mjs +0 -628
- package/parse/imports.mjs +0 -365
- package/parse/interprocedural.mjs +0 -134
- package/parse/languages.mjs +0 -253
- package/parse/metadata.mjs +0 -701
- package/parse/routes.mjs +0 -327
- package/parse/scip.mjs +0 -391
- package/parse/taint-patterns.mjs +0 -119
- package/prompts/CORE.md +0 -91
- package/prompts/DOMAIN_TEMPLATE.md +0 -46
- package/prompts/INTEGRATION.md +0 -123
- package/prompts/frameworks/ANDROID.md +0 -17
- package/prompts/frameworks/ASPNET_CORE.md +0 -19
- package/prompts/frameworks/FASTAPI_DJANGO.md +0 -20
- package/prompts/frameworks/LARAVEL_SYMFONY.md +0 -22
- package/prompts/frameworks/NODE_EXPRESS_NESTJS.md +0 -19
- package/prompts/frameworks/RAILS.md +0 -20
- package/prompts/frameworks/REACT.md +0 -20
- package/prompts/frameworks/SPRING_BOOT.md +0 -20
- package/prompts/languages/BASH.md +0 -22
- package/prompts/languages/C.md +0 -21
- package/prompts/languages/CSHARP.md +0 -24
- package/prompts/languages/CSS_SCSS.md +0 -22
- package/prompts/languages/GO.md +0 -24
- package/prompts/languages/JAVA.md +0 -23
- package/prompts/languages/JAVASCRIPT_TYPESCRIPT.md +0 -24
- package/prompts/languages/KOTLIN.md +0 -24
- package/prompts/languages/PHP.md +0 -23
- package/prompts/languages/PYTHON.md +0 -23
- package/prompts/languages/RUBY.md +0 -23
- package/prompts/languages/RUST.md +0 -21
- package/prompts/languages/SWIFT.md +0 -21
- package/seal.mjs +0 -400
- package/search-core.mjs +0 -777
- package/search-sparse.mjs +0 -183
- package/storage.mjs +0 -73
- 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
|
-
}
|