graph-indexer 1.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/LICENSE +677 -0
- package/PROMPT.md +191 -0
- package/README.md +512 -0
- package/core-engine.mjs +405 -0
- package/indexer.mjs +180 -0
- package/mcp-server.mjs +295 -0
- package/package.json +81 -0
- package/parser-utils.mjs +458 -0
- package/watch-daemon.mjs +136 -0
package/core-engine.mjs
ADDED
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file core-engine.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 GPL-3.0-only
|
|
7
|
+
* * This program is free software: you can redistribute it and/or modify
|
|
8
|
+
* it under the terms of the GNU General Public License as published by
|
|
9
|
+
* the Free Software Foundation, either version 3 of the License, or
|
|
10
|
+
* (at your option) any later version.
|
|
11
|
+
*
|
|
12
|
+
* This program is distributed in the hope that it will be useful,
|
|
13
|
+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
14
|
+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
15
|
+
* GNU General Public License for more details.
|
|
16
|
+
*/
|
|
17
|
+
import fs from 'fs';
|
|
18
|
+
|
|
19
|
+
export const EMBEDDING_CONTEXT_LIMIT = 8000;
|
|
20
|
+
|
|
21
|
+
// Optional HNSW accelerator for large corpora (≥ HNSW_THRESHOLD vectors).
|
|
22
|
+
// Falls back silently to exact flat scan if the package is not installed.
|
|
23
|
+
const HNSW_THRESHOLD = 5000;
|
|
24
|
+
let HierarchicalNSW = null;
|
|
25
|
+
try {
|
|
26
|
+
const mod = await import('hnswlib-node');
|
|
27
|
+
HierarchicalNSW = mod.HierarchicalNSW ?? mod.default?.HierarchicalNSW ?? null;
|
|
28
|
+
} catch { /* not installed — flat scan will be used */ }
|
|
29
|
+
|
|
30
|
+
export function truncateForEmbedding(text) {
|
|
31
|
+
return text.length > EMBEDDING_CONTEXT_LIMIT ? text.slice(0, EMBEDDING_CONTEXT_LIMIT) : text;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function cosineSimilarity(vecA, vecB) {
|
|
35
|
+
let dotProduct = 0, normA = 0, normB = 0;
|
|
36
|
+
for (let i = 0; i < vecA.length; i++) {
|
|
37
|
+
dotProduct += vecA[i] * vecB[i];
|
|
38
|
+
normA += vecA[i] * vecA[i];
|
|
39
|
+
normB += vecB[i] * vecB[i];
|
|
40
|
+
}
|
|
41
|
+
return normA === 0 || normB === 0 ? 0 : dotProduct / (Math.sqrt(normA) * Math.sqrt(normB));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function tokenize(text) {
|
|
45
|
+
if (!text) return [];
|
|
46
|
+
const rawTokens = text.split(/[\s\W_]+/);
|
|
47
|
+
const tokens = [];
|
|
48
|
+
|
|
49
|
+
for (const word of rawTokens) {
|
|
50
|
+
if (word.length < 2) continue; // Skip very short tokens (e.g., single letters)
|
|
51
|
+
tokens.push(word.toLowerCase());
|
|
52
|
+
|
|
53
|
+
// Split CamelCase and PascalCase into parts
|
|
54
|
+
// E.g.: "TripList" -> "Trip", "List" -> "trip", "list"
|
|
55
|
+
// E.g.: "useTripsPage" -> "use", "Trips", "Page" -> "use", "trips", "page"
|
|
56
|
+
const camelParts = word.replace(/([a-z])([A-Z])/g, '$1 $2').split(' ');
|
|
57
|
+
|
|
58
|
+
if (camelParts.length > 1) {
|
|
59
|
+
for (const part of camelParts) {
|
|
60
|
+
if (part.length >= 2) tokens.push(part.toLowerCase());
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return tokens;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Serializes an embeddingCache (Map<hash,Float32Array> or plain object {hash:number[]})
|
|
69
|
+
* to a compact binary buffer. Format per entry:
|
|
70
|
+
* [uint32 hashLen][utf8 hash][uint32 dim][float32 * dim]
|
|
71
|
+
* The buffer is ~4.8× smaller than JSON float text.
|
|
72
|
+
*/
|
|
73
|
+
export function writeEmbeddingBinary(embeddingCache) {
|
|
74
|
+
const entries = embeddingCache instanceof Map
|
|
75
|
+
? Array.from(embeddingCache.entries())
|
|
76
|
+
: Object.entries(embeddingCache);
|
|
77
|
+
let size = 4; // count header
|
|
78
|
+
for (const [hash, vec] of entries) {
|
|
79
|
+
size += 4 + Buffer.byteLength(hash, 'utf8') + 4 + vec.length * 4;
|
|
80
|
+
}
|
|
81
|
+
const buf = Buffer.allocUnsafe(size);
|
|
82
|
+
let off = 0;
|
|
83
|
+
buf.writeUInt32LE(entries.length, off); off += 4;
|
|
84
|
+
for (const [hash, vec] of entries) {
|
|
85
|
+
const hashBytes = Buffer.from(hash, 'utf8');
|
|
86
|
+
buf.writeUInt32LE(hashBytes.length, off); off += 4;
|
|
87
|
+
hashBytes.copy(buf, off); off += hashBytes.length;
|
|
88
|
+
buf.writeUInt32LE(vec.length, off); off += 4;
|
|
89
|
+
for (let d = 0; d < vec.length; d++) { buf.writeFloatLE(vec[d], off); off += 4; }
|
|
90
|
+
}
|
|
91
|
+
return buf;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export class MemoryGraphIndex {
|
|
95
|
+
constructor(indexPath, { rrfK = 60 } = {}) {
|
|
96
|
+
this.indexPath = indexPath;
|
|
97
|
+
this.chunks = new Map();
|
|
98
|
+
this.vectors = new Map();
|
|
99
|
+
this.graph = { dependencies: {}, importedBy: {} };
|
|
100
|
+
this.embeddingCache = new Map();
|
|
101
|
+
this.rrfK = rrfK;
|
|
102
|
+
this.docCount = 0;
|
|
103
|
+
this.df = new Map();
|
|
104
|
+
this.tf = new Map();
|
|
105
|
+
this._saveTimer = null;
|
|
106
|
+
// Flat vector matrix for O(N·d) exact cosine search — no N-API overhead
|
|
107
|
+
this._matrixDirty = true;
|
|
108
|
+
this._vecMatrix = null; // Float32Array(N × dim)
|
|
109
|
+
this._vecNorms = null; // Float32Array(N) — pre-computed L2 norms
|
|
110
|
+
this._vecIds = []; // string[N] — row-index → chunk-id lookup
|
|
111
|
+
this._dim = 0;
|
|
112
|
+
this._hnsw = null; // HierarchicalNSW instance (optional large-repo accelerator)
|
|
113
|
+
this._embeddingPath = indexPath.replace(/\.json$/, '.embeddings.bin');
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
load() {
|
|
117
|
+
if (!fs.existsSync(this.indexPath)) return;
|
|
118
|
+
const data = JSON.parse(fs.readFileSync(this.indexPath, 'utf-8'));
|
|
119
|
+
this.graph = data.graph || { dependencies: {}, importedBy: {} };
|
|
120
|
+
|
|
121
|
+
// Prefer binary sidecar (~4.8× smaller); fall back to JSON embeddingCache for old indexes
|
|
122
|
+
if (fs.existsSync(this._embeddingPath)) {
|
|
123
|
+
this._loadEmbeddingBinary(fs.readFileSync(this._embeddingPath));
|
|
124
|
+
} else {
|
|
125
|
+
for (const [hash, vec] of Object.entries(data.embeddingCache || {})) {
|
|
126
|
+
this.embeddingCache.set(hash, new Float32Array(vec));
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
for (const chunk of data.chunks) {
|
|
131
|
+
this.chunks.set(chunk.id, chunk);
|
|
132
|
+
if (chunk.content_hash && this.embeddingCache.has(chunk.content_hash)) {
|
|
133
|
+
this.vectors.set(chunk.id, this.embeddingCache.get(chunk.content_hash));
|
|
134
|
+
} else if (chunk.embedding) {
|
|
135
|
+
const vec = new Float32Array(chunk.embedding);
|
|
136
|
+
this.vectors.set(chunk.id, vec);
|
|
137
|
+
if (chunk.content_hash) this.embeddingCache.set(chunk.content_hash, vec);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const deps = this.graph.dependencies[chunk.file_path] || [];
|
|
141
|
+
const cleanDeps = deps.map(d => d.split('/').pop().split('.')[0]);
|
|
142
|
+
|
|
143
|
+
const enrichedContext = `${chunk.name} ${chunk.docstring || ''} ${cleanDeps.join(' ')} ${(chunk.calls || []).join(' ')} ${chunk.code_snippet}`;
|
|
144
|
+
this._indexLexical(chunk.id, enrichedContext);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
_indexLexical(chunkId, text) {
|
|
149
|
+
const tokens = tokenize(text);
|
|
150
|
+
if (tokens.length === 0) return;
|
|
151
|
+
|
|
152
|
+
const termCounts = new Map();
|
|
153
|
+
for (const token of tokens) {
|
|
154
|
+
termCounts.set(token, (termCounts.get(token) || 0) + 1);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const tfMap = new Map();
|
|
158
|
+
for (const [term, count] of termCounts.entries()) {
|
|
159
|
+
tfMap.set(term, 1 + Math.log(count));
|
|
160
|
+
this.df.set(term, (this.df.get(term) || 0) + 1);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
this.tf.set(chunkId, tfMap);
|
|
164
|
+
this.docCount++;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
_removeLexical(chunkId) {
|
|
168
|
+
const tfMap = this.tf.get(chunkId);
|
|
169
|
+
if (!tfMap) return;
|
|
170
|
+
for (const term of tfMap.keys()) {
|
|
171
|
+
const freq = this.df.get(term);
|
|
172
|
+
if (freq === undefined) continue;
|
|
173
|
+
if (freq <= 1) this.df.delete(term);
|
|
174
|
+
else this.df.set(term, freq - 1);
|
|
175
|
+
}
|
|
176
|
+
this.tf.delete(chunkId);
|
|
177
|
+
this.docCount = Math.max(0, this.docCount - 1);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
_searchLexical(queryText) {
|
|
181
|
+
const queryTokens = tokenize(queryText);
|
|
182
|
+
const scores = new Map();
|
|
183
|
+
for (const token of queryTokens) {
|
|
184
|
+
const docFreq = this.df.get(token);
|
|
185
|
+
if (!docFreq) continue;
|
|
186
|
+
const idf = Math.log(this.docCount / docFreq);
|
|
187
|
+
for (const [chunkId, tfMap] of this.tf.entries()) {
|
|
188
|
+
const tf = tfMap.get(token);
|
|
189
|
+
if (tf) scores.set(chunkId, (scores.get(chunkId) || 0) + tf * idf);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return Array.from(scores.entries())
|
|
193
|
+
.sort((a, b) => b[1] - a[1])
|
|
194
|
+
.map(([id, score], rank) => ({ id, score, rank: rank + 1 }));
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
_invalidateMatrix() {
|
|
198
|
+
this._matrixDirty = true;
|
|
199
|
+
this._hnsw = null;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
addVector(id, vec) {
|
|
203
|
+
this.vectors.set(id, vec);
|
|
204
|
+
this._invalidateMatrix();
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
removeVector(id) {
|
|
208
|
+
if (this.vectors.delete(id)) this._invalidateMatrix();
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
_rebuildMatrix() {
|
|
212
|
+
const ids = Array.from(this.vectors.keys());
|
|
213
|
+
const n = ids.length;
|
|
214
|
+
this._vecIds = ids;
|
|
215
|
+
if (n === 0) {
|
|
216
|
+
this._vecMatrix = null;
|
|
217
|
+
this._vecNorms = null;
|
|
218
|
+
this._dim = 0;
|
|
219
|
+
this._hnsw = null;
|
|
220
|
+
this._matrixDirty = false;
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
const dim = this.vectors.get(ids[0]).length;
|
|
224
|
+
this._dim = dim;
|
|
225
|
+
const matrix = new Float32Array(n * dim);
|
|
226
|
+
const norms = new Float32Array(n);
|
|
227
|
+
for (let i = 0; i < n; i++) {
|
|
228
|
+
const v = this.vectors.get(ids[i]);
|
|
229
|
+
let normSq = 0;
|
|
230
|
+
const base = i * dim;
|
|
231
|
+
for (let d = 0; d < dim; d++) {
|
|
232
|
+
const vd = v[d];
|
|
233
|
+
matrix[base + d] = vd;
|
|
234
|
+
normSq += vd * vd;
|
|
235
|
+
}
|
|
236
|
+
norms[i] = Math.sqrt(normSq);
|
|
237
|
+
}
|
|
238
|
+
this._vecMatrix = matrix;
|
|
239
|
+
this._vecNorms = norms;
|
|
240
|
+
this._matrixDirty = false;
|
|
241
|
+
|
|
242
|
+
// Build HNSW index for large corpora when library is available
|
|
243
|
+
if (HierarchicalNSW && n >= HNSW_THRESHOLD) {
|
|
244
|
+
try {
|
|
245
|
+
const hnsw = new HierarchicalNSW('cosine', dim);
|
|
246
|
+
hnsw.initIndex(n, 16, 200, 100);
|
|
247
|
+
if (typeof hnsw.setEf === 'function') hnsw.setEf(100);
|
|
248
|
+
const buf = new Array(dim);
|
|
249
|
+
for (let i = 0; i < n; i++) {
|
|
250
|
+
const base = i * dim;
|
|
251
|
+
for (let d = 0; d < dim; d++) buf[d] = matrix[base + d];
|
|
252
|
+
hnsw.addPoint(buf, i);
|
|
253
|
+
}
|
|
254
|
+
this._hnsw = hnsw;
|
|
255
|
+
} catch (e) {
|
|
256
|
+
process.stderr.write(`[core-engine] HNSW build failed, using flat scan: ${e.message}\n`);
|
|
257
|
+
this._hnsw = null;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
_searchVector(queryVector, minScore = 0.3) {
|
|
263
|
+
if (this.vectors.size === 0) return [];
|
|
264
|
+
if (this._matrixDirty) this._rebuildMatrix();
|
|
265
|
+
if (!this._vecMatrix) return [];
|
|
266
|
+
if (queryVector.length !== this._dim) return [];
|
|
267
|
+
|
|
268
|
+
const n = this._vecIds.length;
|
|
269
|
+
const dim = this._dim;
|
|
270
|
+
|
|
271
|
+
// Pre-compute query L2 norm once
|
|
272
|
+
let qNorm = 0;
|
|
273
|
+
for (let d = 0; d < dim; d++) qNorm += queryVector[d] * queryVector[d];
|
|
274
|
+
qNorm = Math.sqrt(qNorm);
|
|
275
|
+
if (qNorm === 0) return [];
|
|
276
|
+
|
|
277
|
+
// Large-corpus fast path: approximate nearest-neighbour via HNSW
|
|
278
|
+
if (this._hnsw) {
|
|
279
|
+
const topK = Math.min(200, n);
|
|
280
|
+
try {
|
|
281
|
+
const qArr = Array.isArray(queryVector) ? queryVector : Array.from(queryVector);
|
|
282
|
+
const { neighbors, distances } = this._hnsw.searchKnn(qArr, topK);
|
|
283
|
+
const results = [];
|
|
284
|
+
for (let i = 0; i < neighbors.length; i++) {
|
|
285
|
+
const score = 1 - distances[i]; // cosine distance → similarity
|
|
286
|
+
if (score > minScore) results.push({ id: this._vecIds[neighbors[i]], score, rank: i + 1 });
|
|
287
|
+
}
|
|
288
|
+
return results;
|
|
289
|
+
} catch {
|
|
290
|
+
// HNSW search failed; fall through to exact flat scan
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Exact brute-force flat scan — O(N·d), no marshalling overhead
|
|
295
|
+
const results = [];
|
|
296
|
+
for (let i = 0; i < n; i++) {
|
|
297
|
+
const base = i * dim;
|
|
298
|
+
let dp = 0;
|
|
299
|
+
for (let d = 0; d < dim; d++) dp += queryVector[d] * this._vecMatrix[base + d];
|
|
300
|
+
const score = dp / (qNorm * this._vecNorms[i]);
|
|
301
|
+
if (score > minScore) results.push({ id: this._vecIds[i], score, rank: 0 });
|
|
302
|
+
}
|
|
303
|
+
results.sort((a, b) => b.score - a.score);
|
|
304
|
+
for (let i = 0; i < results.length; i++) results[i].rank = i + 1;
|
|
305
|
+
return results;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
searchHybrid(queryText, queryVector, topK = 5, minScore = 0.3, exactBoostName = null) {
|
|
309
|
+
const lexicalResults = this._searchLexical(queryText);
|
|
310
|
+
const vectorResults = queryVector ? this._searchVector(queryVector, minScore) : [];
|
|
311
|
+
const rrfScores = new Map();
|
|
312
|
+
const K = this.rrfK;
|
|
313
|
+
const allResults = [...vectorResults, ...lexicalResults];
|
|
314
|
+
|
|
315
|
+
const queryLower = queryText.toLowerCase();
|
|
316
|
+
for (const { id, rank } of allResults) {
|
|
317
|
+
let baseScore = 1 / (K + rank);
|
|
318
|
+
const chunk = this.chunks.get(id);
|
|
319
|
+
if (!chunk) continue;
|
|
320
|
+
|
|
321
|
+
// Generic: demote test/spec files when the query is not about tests
|
|
322
|
+
if (/\.(test|spec)\.|[/\\]__tests__[/\\]|_test\./.test(chunk.file_path)) {
|
|
323
|
+
if (!queryLower.includes('test') && !queryLower.includes('spec')) baseScore *= 0.6;
|
|
324
|
+
}
|
|
325
|
+
// Prefer definitions (functions/classes/impls) over pure usage/expression sites
|
|
326
|
+
if (chunk.node_type === 'expression_statement' || chunk.node_type === 'call_expression') {
|
|
327
|
+
baseScore *= 0.8;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
rrfScores.set(id, (rrfScores.get(id) || 0) + baseScore);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
if (exactBoostName) {
|
|
334
|
+
const boostTerm = String(exactBoostName).toLowerCase().trim();
|
|
335
|
+
for (const [id, chunk] of this.chunks.entries()) {
|
|
336
|
+
if (chunk.name && String(chunk.name).toLowerCase() === boostTerm) {
|
|
337
|
+
rrfScores.set(id, (rrfScores.get(id) || 0) + 1 / (K + 1));
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
return Array.from(rrfScores.entries())
|
|
343
|
+
.sort((a, b) => b[1] - a[1])
|
|
344
|
+
.slice(0, topK)
|
|
345
|
+
.map(([id, rrfScore]) => ({ score: rrfScore, chunk: this.chunks.get(id) }))
|
|
346
|
+
.filter(r => r.chunk !== undefined);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
_loadEmbeddingBinary(buf) {
|
|
350
|
+
let off = 0;
|
|
351
|
+
const count = buf.readUInt32LE(off); off += 4;
|
|
352
|
+
for (let i = 0; i < count; i++) {
|
|
353
|
+
const hashLen = buf.readUInt32LE(off); off += 4;
|
|
354
|
+
const hash = buf.subarray(off, off + hashLen).toString('utf8'); off += hashLen;
|
|
355
|
+
const dim = buf.readUInt32LE(off); off += 4;
|
|
356
|
+
const vec = new Float32Array(dim);
|
|
357
|
+
for (let d = 0; d < dim; d++) { vec[d] = buf.readFloatLE(off); off += 4; }
|
|
358
|
+
this.embeddingCache.set(hash, vec);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
async save() {
|
|
363
|
+
const chunksData = Array.from(this.chunks.values()).map(c => ({
|
|
364
|
+
id: c.id, file_path: c.file_path, node_type: c.node_type,
|
|
365
|
+
name: c.name, docstring: c.docstring || '', code_snippet: c.code_snippet,
|
|
366
|
+
content_hash: c.content_hash, start_line: c.start_line, end_line: c.end_line,
|
|
367
|
+
calls: c.calls || []
|
|
368
|
+
}));
|
|
369
|
+
// Embeddings go to a compact binary sidecar — NOT in the JSON payload
|
|
370
|
+
const payload = JSON.stringify({ chunks: chunksData, graph: this.graph });
|
|
371
|
+
const tmpPath = `${this.indexPath}.tmp`;
|
|
372
|
+
const tmpBinPath = `${this._embeddingPath}.tmp`;
|
|
373
|
+
await Promise.all([
|
|
374
|
+
fs.promises.writeFile(tmpPath, payload),
|
|
375
|
+
fs.promises.writeFile(tmpBinPath, writeEmbeddingBinary(this.embeddingCache)),
|
|
376
|
+
]);
|
|
377
|
+
await Promise.all([
|
|
378
|
+
fs.promises.rename(tmpPath, this.indexPath),
|
|
379
|
+
fs.promises.rename(tmpBinPath, this._embeddingPath),
|
|
380
|
+
]);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
saveDebounced(delayMs = 3000) {
|
|
384
|
+
if (this._saveTimer) clearTimeout(this._saveTimer);
|
|
385
|
+
this._saveTimer = setTimeout(async () => {
|
|
386
|
+
this._saveTimer = null;
|
|
387
|
+
try { await this.save(); }
|
|
388
|
+
catch (err) { process.stderr.write(`[core-engine] ❌ Async save failed: ${err.message}\n`); }
|
|
389
|
+
}, delayMs);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
updateFileGraph(filePath, imports) {
|
|
393
|
+
const oldDeps = this.graph.dependencies[filePath] || [];
|
|
394
|
+
for (const oldDep of oldDeps) {
|
|
395
|
+
if (this.graph.importedBy[oldDep]) {
|
|
396
|
+
this.graph.importedBy[oldDep] = this.graph.importedBy[oldDep].filter(f => f !== filePath);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
this.graph.dependencies[filePath] = imports;
|
|
400
|
+
for (const dep of imports) {
|
|
401
|
+
if (!this.graph.importedBy[dep]) this.graph.importedBy[dep] = [];
|
|
402
|
+
if (!this.graph.importedBy[dep].includes(filePath)) this.graph.importedBy[dep].push(filePath);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
}
|
package/indexer.mjs
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* @file indexer.mjs
|
|
4
|
+
* @description In-Memory Graph Indexer — Bootstrap Engine. Reads .ts/.tsx/.js/.jsx files → Tree-sitter AST → Local Embeddings → code-index.json. Zero external dependencies (only Tree-sitter). No ChromaDB.
|
|
5
|
+
* @author MaquinaTech <https://github.com/MaquinaTech>
|
|
6
|
+
* @copyright (c) 2026 MaquinaTech. All rights reserved.
|
|
7
|
+
* @license GPL-3.0-only
|
|
8
|
+
* * This program is free software: you can redistribute it and/or modify
|
|
9
|
+
* it under the terms of the GNU General Public License as published by
|
|
10
|
+
* the Free Software Foundation, either version 3 of the License, or
|
|
11
|
+
* (at your option) any later version.
|
|
12
|
+
*
|
|
13
|
+
* This program is distributed in the hope that it will be useful,
|
|
14
|
+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
15
|
+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
16
|
+
* GNU General Public License for more details.
|
|
17
|
+
*/
|
|
18
|
+
import fs from 'fs';
|
|
19
|
+
import path from 'path';
|
|
20
|
+
import {
|
|
21
|
+
MAX_FILE_SIZE_BYTES, EXTENSIONS, getParserForFile, buildIgnoreFilter, getLocalEmbeddingsBatch,
|
|
22
|
+
extractImportsFromAST, extractSemanticChunks, resolveLocalImports
|
|
23
|
+
} from './parser-utils.mjs';
|
|
24
|
+
import { MemoryGraphIndex, writeEmbeddingBinary } from './core-engine.mjs';
|
|
25
|
+
|
|
26
|
+
const args = process.argv.slice(2);
|
|
27
|
+
const repoArg = args[args.indexOf("--repo") + 1] ?? process.cwd();
|
|
28
|
+
const PROJECT_ROOT = path.resolve(repoArg);
|
|
29
|
+
const INDEX_PATH = path.join(PROJECT_ROOT, 'code-index.json');
|
|
30
|
+
|
|
31
|
+
function walkRepo(dir, root, ig, files = []) {
|
|
32
|
+
for (const entry of fs.readdirSync(dir)) {
|
|
33
|
+
if (entry.startsWith('.')) continue;
|
|
34
|
+
const fullPath = path.join(dir, entry);
|
|
35
|
+
const relPath = path.relative(root, fullPath).replace(/\\/g, '/');
|
|
36
|
+
if (ig.ignores(relPath)) continue;
|
|
37
|
+
if (fs.statSync(fullPath).isDirectory()) {
|
|
38
|
+
walkRepo(fullPath, root, ig, files);
|
|
39
|
+
} else if (EXTENSIONS.has(path.extname(fullPath))) {
|
|
40
|
+
files.push(fullPath);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return files;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function main() {
|
|
47
|
+
console.log(`\n🚀 Starting Optimized Indexer\n📂 Directory: ${PROJECT_ROOT}\n`);
|
|
48
|
+
|
|
49
|
+
const ig = buildIgnoreFilter(PROJECT_ROOT);
|
|
50
|
+
const files = walkRepo(PROJECT_ROOT, PROJECT_ROOT, ig);
|
|
51
|
+
console.log(`Found ${files.length} files to analyse.\n`);
|
|
52
|
+
|
|
53
|
+
const db = new MemoryGraphIndex(INDEX_PATH);
|
|
54
|
+
db.load();
|
|
55
|
+
const existingCache = db.embeddingCache;
|
|
56
|
+
console.log(`📦 Loaded ${existingCache.size} cached embeddings from previous runs.\n`);
|
|
57
|
+
|
|
58
|
+
const indexData = { chunks: [], graph: { dependencies: {}, importedBy: {} }, embeddingCache: {} };
|
|
59
|
+
let totalCheckedFiles = 0;
|
|
60
|
+
|
|
61
|
+
for (const absolutePath of files) {
|
|
62
|
+
totalCheckedFiles++;
|
|
63
|
+
const relPath = path.relative(PROJECT_ROOT, absolutePath).replace(/\\/g, '/');
|
|
64
|
+
process.stdout.write(`\r⚡ Parsing AST: [${totalCheckedFiles}/${files.length}] Procesando: ${relPath.slice(-40)} `);
|
|
65
|
+
|
|
66
|
+
if (relPath.includes('.bundle.') || relPath.includes('.min.')) continue;
|
|
67
|
+
|
|
68
|
+
try {
|
|
69
|
+
const stats = await fs.promises.stat(absolutePath);
|
|
70
|
+
if (stats.size > MAX_FILE_SIZE_BYTES) continue;
|
|
71
|
+
|
|
72
|
+
let content = await fs.promises.readFile(absolutePath, 'utf-8');
|
|
73
|
+
if (!content.trim()) continue;
|
|
74
|
+
|
|
75
|
+
const ext = path.extname(absolutePath);
|
|
76
|
+
const parser = getParserForFile(ext);
|
|
77
|
+
if (!parser) continue;
|
|
78
|
+
|
|
79
|
+
let tree = parser.parse(content);
|
|
80
|
+
const rawImports = extractImportsFromAST(tree.rootNode, ext);
|
|
81
|
+
const imports = resolveLocalImports(rawImports, relPath, PROJECT_ROOT);
|
|
82
|
+
indexData.graph.dependencies[relPath] = imports;
|
|
83
|
+
|
|
84
|
+
const chunks = extractSemanticChunks(tree.rootNode, relPath, content, ext);
|
|
85
|
+
for (const chunk of chunks) {
|
|
86
|
+
if (existingCache.has(chunk.content_hash)) {
|
|
87
|
+
indexData.embeddingCache[chunk.content_hash] = Array.from(existingCache.get(chunk.content_hash));
|
|
88
|
+
indexData.chunks.push(chunk);
|
|
89
|
+
} else {
|
|
90
|
+
indexData.chunksToEmbed = indexData.chunksToEmbed || [];
|
|
91
|
+
indexData.chunksToEmbed.push(chunk);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
} catch (err) {
|
|
95
|
+
console.error(`\n💥 Error en ${relPath}: ${err.message}`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const pendingChunks = indexData.chunksToEmbed || [];
|
|
100
|
+
console.log(`\n\n🧠 Embedding Generation (Ollama)`);
|
|
101
|
+
console.log(`Chunks reciclados de la caché: ${indexData.chunks.length}`);
|
|
102
|
+
console.log(`Chunks nuevos a procesar: ${pendingChunks.length}`);
|
|
103
|
+
|
|
104
|
+
if (pendingChunks.length > 0) {
|
|
105
|
+
const BATCH_SIZE = 64;
|
|
106
|
+
const CONCURRENCY = 4;
|
|
107
|
+
const batches = [];
|
|
108
|
+
for (let i = 0; i < pendingChunks.length; i += BATCH_SIZE) {
|
|
109
|
+
batches.push(pendingChunks.slice(i, i + BATCH_SIZE));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
let completedChunksCount = 0;
|
|
113
|
+
console.time("Embedding Generation Duration");
|
|
114
|
+
|
|
115
|
+
const worker = async (batch) => {
|
|
116
|
+
const textsToEmbed = batch.map(c => {
|
|
117
|
+
const dependencies = indexData.graph.dependencies[c.file_path] || [];
|
|
118
|
+
const cleanDeps = dependencies.map(d => {
|
|
119
|
+
const depsFile = path.relative(PROJECT_ROOT, d);
|
|
120
|
+
const chunkMatches = indexData.chunks.filter(c => c.file_path === depsFile);
|
|
121
|
+
return chunkMatches.map(c => c.name).join(' ');
|
|
122
|
+
});
|
|
123
|
+
const topologicalContext = cleanDeps.length > 0
|
|
124
|
+
? `This code architectural neighborhood connects with: ${cleanDeps.join(', ')}.`
|
|
125
|
+
: '';
|
|
126
|
+
|
|
127
|
+
// 🥇 BLEED PROTECTION: Flattened linear payload for Ollama without hidden indentation
|
|
128
|
+
return [
|
|
129
|
+
`File Location: ${c.file_path}`,
|
|
130
|
+
`Symbol Name: ${c.node_type} -> ${c.name}`,
|
|
131
|
+
c.docstring ? `Developer Documentation: ${c.docstring}` : '',
|
|
132
|
+
topologicalContext,
|
|
133
|
+
`--- Source Code ---`,
|
|
134
|
+
c.code_snippet
|
|
135
|
+
].filter(Boolean).join('\n');
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
const embeddingsMatrix = await getLocalEmbeddingsBatch(textsToEmbed, true);
|
|
139
|
+
if (embeddingsMatrix && embeddingsMatrix.length === batch.length) {
|
|
140
|
+
for (let j = 0; j < batch.length; j++) {
|
|
141
|
+
const chunk = batch[j];
|
|
142
|
+
indexData.embeddingCache[chunk.content_hash] = embeddingsMatrix[j];
|
|
143
|
+
indexData.chunks.push(chunk);
|
|
144
|
+
}
|
|
145
|
+
} else {
|
|
146
|
+
for (const chunk of batch) indexData.chunks.push(chunk);
|
|
147
|
+
}
|
|
148
|
+
completedChunksCount += batch.length;
|
|
149
|
+
process.stdout.write(`\r🤖 Embedding Progress: [${completedChunksCount}/${pendingChunks.length}] Chunks procesados...`);
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
for (let i = 0; i < batches.length; i += CONCURRENCY) {
|
|
153
|
+
const ráfaga = batches.slice(i, i + CONCURRENCY);
|
|
154
|
+
await Promise.all(ráfaga.map(batch => worker(batch)));
|
|
155
|
+
}
|
|
156
|
+
console.timeEnd("Embedding Generation Duration");
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
for (const [filePath, imports] of Object.entries(indexData.graph.dependencies)) {
|
|
160
|
+
for (const dep of imports) {
|
|
161
|
+
if (!indexData.graph.importedBy[dep]) indexData.graph.importedBy[dep] = [];
|
|
162
|
+
if (!indexData.graph.importedBy[dep].includes(filePath)) indexData.graph.importedBy[dep].push(filePath);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const EMBEDDINGS_PATH = INDEX_PATH.replace(/\.json$/, '.embeddings.bin');
|
|
167
|
+
const tmpPath = `${INDEX_PATH}.tmp`;
|
|
168
|
+
const tmpBinPath = `${EMBEDDINGS_PATH}.tmp`;
|
|
169
|
+
await Promise.all([
|
|
170
|
+
fs.promises.writeFile(tmpPath, JSON.stringify({ chunks: indexData.chunks, graph: indexData.graph })),
|
|
171
|
+
fs.promises.writeFile(tmpBinPath, writeEmbeddingBinary(indexData.embeddingCache)),
|
|
172
|
+
]);
|
|
173
|
+
await Promise.all([
|
|
174
|
+
fs.promises.rename(tmpPath, INDEX_PATH),
|
|
175
|
+
fs.promises.rename(tmpBinPath, EMBEDDINGS_PATH),
|
|
176
|
+
]);
|
|
177
|
+
console.log(`\n🎉 Indexing completed blazingly fast. Total fragments: ${indexData.chunks.length}\n`);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
main().catch(console.error);
|