graph-indexer 1.0.3 → 1.2.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/PROMPT.md +182 -143
- package/README.md +347 -74
- package/config.mjs +118 -0
- package/core-engine.mjs +631 -188
- package/enrichment.mjs +347 -0
- package/indexer.mjs +100 -88
- package/init.mjs +4 -2
- package/mcp-server.mjs +87 -564
- package/mcp-tools.mjs +545 -0
- package/package.json +17 -7
- package/parser-utils.mjs +351 -10
- package/search-core.mjs +416 -0
- package/sqlite-store.mjs +768 -0
- package/storage.mjs +43 -0
- package/watch-daemon.mjs +172 -79
package/storage.mjs
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file storage.mjs
|
|
3
|
+
* @description Storage backend factory. Selects the in-memory engine (default,
|
|
4
|
+
* zero-dependency) or the disk-backed SQLite store based on config,
|
|
5
|
+
* and documents the single read contract that the MCP tools depend
|
|
6
|
+
* on so they remain backend-agnostic.
|
|
7
|
+
* @author MaquinaTech <https://github.com/MaquinaTech>
|
|
8
|
+
* @copyright (c) 2026 MaquinaTech. All rights reserved.
|
|
9
|
+
* @license MIT
|
|
10
|
+
*
|
|
11
|
+
* ── Store contract (implemented by both MemoryGraphIndex and SqliteGraphStore) ──
|
|
12
|
+
* load() Prepare for queries (open db / parse json).
|
|
13
|
+
* get backend() 'memory' | 'sqlite'.
|
|
14
|
+
* get graph() { dependencies, importedBy } (file-level).
|
|
15
|
+
* searchHybrid(q, vec, topK, minScore, exactBoost) → [{ score, chunk }]
|
|
16
|
+
* getChunk(id) → chunk | null
|
|
17
|
+
* getChunksByFile(path) → chunk[]
|
|
18
|
+
* resolveSymbol(name) → chunk[] (exact, case-insensitive)
|
|
19
|
+
* findCallers(funcName) → chunk[]
|
|
20
|
+
* iterateChunks() → Iterable<chunk> (cursor on SQLite)
|
|
21
|
+
* getDependencies(path) / getImportedBy(path) → string[]
|
|
22
|
+
* chunkCount() / fileCount() / symbolCount() / vectorCount() → number
|
|
23
|
+
* stats() → engine health facts
|
|
24
|
+
* close() Release fds / db handles.
|
|
25
|
+
*/
|
|
26
|
+
import { MemoryGraphIndex } from './core-engine.mjs';
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Construct (but do not yet load) the configured store.
|
|
30
|
+
*
|
|
31
|
+
* @param {object} config Resolved config from config.mjs.
|
|
32
|
+
* @param {object} [opts]
|
|
33
|
+
* @param {boolean} [opts.cacheEmbeddings] Eager vector cache (in-memory backend only).
|
|
34
|
+
* @returns {Promise<object>} a store implementing the contract above.
|
|
35
|
+
*/
|
|
36
|
+
export async function createStore(config, { cacheEmbeddings = false } = {}) {
|
|
37
|
+
if (config.storage === 'sqlite') {
|
|
38
|
+
// Imported lazily so the default path never loads node:sqlite.
|
|
39
|
+
const { SqliteGraphStore } = await import('./sqlite-store.mjs');
|
|
40
|
+
return new SqliteGraphStore(config.sqlitePath, { embeddingPath: config.embeddingPath });
|
|
41
|
+
}
|
|
42
|
+
return new MemoryGraphIndex(config.indexPath, { cacheEmbeddings });
|
|
43
|
+
}
|
package/watch-daemon.mjs
CHANGED
|
@@ -1,7 +1,14 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
3
|
* @file watch-daemon.mjs
|
|
4
|
-
* @description Native FileSystem Watcher Daemon
|
|
4
|
+
* @description Native FileSystem Watcher Daemon that keeps the configured index
|
|
5
|
+
* backend fresh incrementally. Backend-agnostic: file changes are
|
|
6
|
+
* parsed once (Tree-sitter), optionally enriched (LLM cache-first)
|
|
7
|
+
* and embedded (Ollama), then handed to the store's applyFileUpdate
|
|
8
|
+
* — the in-memory engine persists a debounced JSON snapshot, the
|
|
9
|
+
* SQLite store commits a per-file WAL transaction that running MCP
|
|
10
|
+
* servers pick up live via PRAGMA data_version. No more "SQLite
|
|
11
|
+
* requires a full re-index on every change".
|
|
5
12
|
* @author MaquinaTech <https://github.com/MaquinaTech>
|
|
6
13
|
* @copyright (c) 2026 MaquinaTech. All rights reserved.
|
|
7
14
|
* @license MIT
|
|
@@ -19,35 +26,119 @@
|
|
|
19
26
|
import fs from 'fs';
|
|
20
27
|
import path from 'path';
|
|
21
28
|
import chokidar from 'chokidar';
|
|
22
|
-
import {
|
|
29
|
+
import { resolveConfig } from './config.mjs';
|
|
30
|
+
import { createStore } from './storage.mjs';
|
|
23
31
|
import {
|
|
24
|
-
|
|
25
|
-
|
|
32
|
+
embeddingKeyFor, computePageRank, TEST_FILE_RE, EXAMPLE_DIR_RE,
|
|
33
|
+
summaryEmbeddingText, SUMMARY_VEC_SUFFIX,
|
|
34
|
+
} from './search-core.mjs';
|
|
35
|
+
import {
|
|
36
|
+
MAX_FILE_SIZE_BYTES, getParserForFile, buildIgnoreFilter,
|
|
37
|
+
extractImportsFromAST, extractSemanticChunks, resolveLocalImports, getLocalEmbeddingsBatch,
|
|
38
|
+
buildEmbeddingPayload,
|
|
26
39
|
} from './parser-utils.mjs';
|
|
40
|
+
import {
|
|
41
|
+
loadEnrichmentCache, saveEnrichmentCache, attachEnrichment,
|
|
42
|
+
ollamaGenerate, parseEnrichResponse, buildEnrichPrompt,
|
|
43
|
+
} from './enrichment.mjs';
|
|
27
44
|
|
|
28
|
-
const
|
|
29
|
-
const
|
|
45
|
+
const config = resolveConfig();
|
|
46
|
+
const PROJECT_ROOT = config.projectRoot;
|
|
30
47
|
|
|
31
48
|
const ignoreFilter = buildIgnoreFilter(PROJECT_ROOT);
|
|
32
|
-
const db =
|
|
49
|
+
const db = await createStore(config, { cacheEmbeddings: true });
|
|
33
50
|
db.load();
|
|
34
51
|
|
|
52
|
+
// ─── Enrichment (cache-first, live for core files) ──────────────────────────────
|
|
53
|
+
// Cached entries re-attach for free on every change. When enrichment is enabled
|
|
54
|
+
// and the changed file sits in the PageRank core, new/changed chunks are enriched
|
|
55
|
+
// live (best-effort, low concurrency) so edits don't silently lose their semantic
|
|
56
|
+
// metadata until the next full index run.
|
|
57
|
+
const enrichCache = config.enrichment.enabled
|
|
58
|
+
? loadEnrichmentCache(config.enrichmentCachePath)
|
|
59
|
+
: null;
|
|
60
|
+
let enrichCacheDirty = false;
|
|
61
|
+
let saveCacheTimer = null;
|
|
62
|
+
function scheduleEnrichCacheSave() {
|
|
63
|
+
if (!enrichCacheDirty) return;
|
|
64
|
+
if (saveCacheTimer) clearTimeout(saveCacheTimer);
|
|
65
|
+
saveCacheTimer = setTimeout(() => {
|
|
66
|
+
saveCacheTimer = null;
|
|
67
|
+
try { saveEnrichmentCache(config.enrichmentCachePath, enrichCache); enrichCacheDirty = false; }
|
|
68
|
+
catch (err) { process.stderr.write(`[daemon] ⚠️ enrichment cache save failed: ${err.message}\n`); }
|
|
69
|
+
}, 3000);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
let _coreFiles = null;
|
|
73
|
+
function isEnrichableFile(filename) {
|
|
74
|
+
if (TEST_FILE_RE.test(filename) || EXAMPLE_DIR_RE.test(filename)) return false;
|
|
75
|
+
if (config.enrichment.coreRatio >= 1) return true; // all production files
|
|
76
|
+
if (!_coreFiles) {
|
|
77
|
+
const pr = computePageRank(db.graph);
|
|
78
|
+
const files = Array.from(pr.keys()).sort((a, b) => (pr.get(b) ?? 0) - (pr.get(a) ?? 0));
|
|
79
|
+
const coreCount = Math.max(1, Math.ceil(files.length * config.enrichment.coreRatio));
|
|
80
|
+
_coreFiles = new Set(files.slice(0, coreCount));
|
|
81
|
+
}
|
|
82
|
+
return _coreFiles.has(filename);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function enrichChunks(filename, chunks) {
|
|
86
|
+
if (!enrichCache) return;
|
|
87
|
+
const pending = [];
|
|
88
|
+
for (const chunk of chunks) {
|
|
89
|
+
if (!chunk.content_hash) continue;
|
|
90
|
+
if (attachEnrichment(chunk, enrichCache.get(chunk.content_hash))) continue;
|
|
91
|
+
pending.push(chunk);
|
|
92
|
+
}
|
|
93
|
+
if (pending.length === 0 || !isEnrichableFile(filename)) return;
|
|
94
|
+
// Live enrichment of changed core chunks: sequential and best-effort — a file
|
|
95
|
+
// save touches a handful of chunks, so latency stays low and failures only
|
|
96
|
+
// mean "no enrichment until the next full index run".
|
|
97
|
+
for (const chunk of pending) {
|
|
98
|
+
if ((chunk.end_line - chunk.start_line) < 4) continue; // trivial stubs
|
|
99
|
+
const raw = await ollamaGenerate(buildEnrichPrompt(chunk), {
|
|
100
|
+
model: config.enrichment.model, ollamaHost: config.ollamaHost, timeoutMs: 20000,
|
|
101
|
+
});
|
|
102
|
+
const parsed = parseEnrichResponse(raw);
|
|
103
|
+
if (!parsed) break; // model unreachable — stop trying for this batch
|
|
104
|
+
chunk.summary = parsed.summary;
|
|
105
|
+
chunk.concepts = parsed.concepts;
|
|
106
|
+
chunk.hyde = parsed.hyde;
|
|
107
|
+
enrichCache.set(chunk.content_hash, {
|
|
108
|
+
summary: parsed.summary, concepts: parsed.concepts, model: config.enrichment.model,
|
|
109
|
+
});
|
|
110
|
+
enrichCacheDirty = true;
|
|
111
|
+
}
|
|
112
|
+
scheduleEnrichCacheSave();
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ─── Incremental file sync ──────────────────────────────────────────────────────
|
|
116
|
+
|
|
117
|
+
// Catch-up scan: chokidar fires 'add' for every existing file at startup. Files
|
|
118
|
+
// not modified since the index artifact was last written are already indexed —
|
|
119
|
+
// skipping them turns daemon start from O(repo) parse work into O(changed files),
|
|
120
|
+
// while still picking up edits made while the daemon was down.
|
|
121
|
+
const ARTIFACT_PATH = config.storage === 'sqlite' ? config.sqlitePath : config.indexPath;
|
|
122
|
+
let indexBuiltAt = 0;
|
|
123
|
+
try { indexBuiltAt = fs.statSync(ARTIFACT_PATH).mtimeMs; } catch { /* no index yet → full scan */ }
|
|
124
|
+
let initialScan = true;
|
|
125
|
+
|
|
35
126
|
async function processFileChange(absolutePath) {
|
|
36
127
|
const filename = path.relative(PROJECT_ROOT, absolutePath).replace(/\\/g, '/');
|
|
37
128
|
if (ignoreFilter.ignores(filename)) return;
|
|
38
129
|
if (filename.includes('.bundle.') || filename.includes('.min.')) return;
|
|
130
|
+
if (filename.startsWith('code-index.')) return; // never index our own artifacts
|
|
39
131
|
|
|
40
132
|
try {
|
|
133
|
+
if (initialScan && indexBuiltAt > 0 && fs.existsSync(absolutePath)
|
|
134
|
+
&& fs.statSync(absolutePath).mtimeMs <= indexBuiltAt) {
|
|
135
|
+
return; // unchanged since the last index write
|
|
136
|
+
}
|
|
137
|
+
|
|
41
138
|
if (!fs.existsSync(absolutePath)) {
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
db.chunks.delete(id);
|
|
46
|
-
db.removeVector(id);
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
db.updateFileGraph(filename, []);
|
|
50
|
-
db.saveDebounced();
|
|
139
|
+
if (typeof db.removeFile === 'function') db.removeFile(filename);
|
|
140
|
+
else db.applyFileUpdate(filename, { chunks: [], imports: [] });
|
|
141
|
+
_coreFiles = null;
|
|
51
142
|
process.stderr.write(`[daemon] 🗑️ Purged: ${filename}\n`);
|
|
52
143
|
return;
|
|
53
144
|
}
|
|
@@ -55,7 +146,7 @@ async function processFileChange(absolutePath) {
|
|
|
55
146
|
const stats = fs.statSync(absolutePath);
|
|
56
147
|
if (stats.size > MAX_FILE_SIZE_BYTES) return;
|
|
57
148
|
|
|
58
|
-
|
|
149
|
+
const content = fs.readFileSync(absolutePath, 'utf-8');
|
|
59
150
|
if (!content.trim()) return;
|
|
60
151
|
|
|
61
152
|
const ext = path.extname(absolutePath);
|
|
@@ -64,82 +155,84 @@ async function processFileChange(absolutePath) {
|
|
|
64
155
|
|
|
65
156
|
const tree = parser.parse((offset) => offset < content.length ? content.slice(offset, offset + 4096) : null);
|
|
66
157
|
const imports = resolveLocalImports(extractImportsFromAST(tree.rootNode, ext), filename, PROJECT_ROOT);
|
|
67
|
-
db.updateFileGraph(filename, imports);
|
|
68
|
-
|
|
69
158
|
const newChunks = extractSemanticChunks(tree.rootNode, filename, content, ext);
|
|
159
|
+
_coreFiles = null; // dependency graph may change → PageRank core set is stale
|
|
70
160
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
db.chunks.delete(id);
|
|
75
|
-
db.removeVector(id); // invalidates flat matrix so next search rebuilds it
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
const chunksToEmbed = newChunks.filter(c => !db.embeddingCache.has(c.content_hash));
|
|
161
|
+
// Enrichment BEFORE embedding so the summary rides the vector payload
|
|
162
|
+
// (embeddingKeyFor accounts for it, so cache lookups stay correct).
|
|
163
|
+
await enrichChunks(filename, newChunks);
|
|
80
164
|
|
|
165
|
+
// 🥇 STRICT PAYLOAD PARITY: identical embedding payloads to indexer.mjs
|
|
166
|
+
// (shared helpers) so incremental updates match the bootstrap index —
|
|
167
|
+
// including the summary-only second vector for enriched chunks.
|
|
168
|
+
const chunksToEmbed = newChunks.filter(c => !db.hasEmbedding(embeddingKeyFor(c)));
|
|
169
|
+
const embeddings = new Map();
|
|
81
170
|
if (chunksToEmbed.length > 0) {
|
|
82
|
-
const
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
const
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
return [
|
|
91
|
-
`File Location: ${c.file_path}`,
|
|
92
|
-
`Symbol Name: ${c.node_type} -> ${c.name}`,
|
|
93
|
-
c.docstring ? `Developer Documentation: ${c.docstring}` : '',
|
|
94
|
-
topologicalContext,
|
|
95
|
-
`--- Source Code ---`,
|
|
96
|
-
c.code_snippet
|
|
97
|
-
].filter(Boolean).join('\n');
|
|
171
|
+
const entries = [];
|
|
172
|
+
for (const c of chunksToEmbed) {
|
|
173
|
+
entries.push({ key: embeddingKeyFor(c), text: buildEmbeddingPayload(c, imports) });
|
|
174
|
+
const sText = summaryEmbeddingText(c);
|
|
175
|
+
if (sText) entries.push({ key: embeddingKeyFor(c) + SUMMARY_VEC_SUFFIX, text: sText });
|
|
176
|
+
}
|
|
177
|
+
const embeddingsMatrix = await getLocalEmbeddingsBatch(entries.map(e => e.text), true, {
|
|
178
|
+
ollamaHost: config.ollamaHost, model: config.embedModel,
|
|
98
179
|
});
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
chunksToEmbed.forEach((chunk, j) => {
|
|
103
|
-
if (embeddingsMatrix[j]) {
|
|
104
|
-
db.embeddingCache.set(chunk.content_hash, new Float32Array(embeddingsMatrix[j]));
|
|
105
|
-
}
|
|
180
|
+
if (embeddingsMatrix && embeddingsMatrix.length === entries.length) {
|
|
181
|
+
entries.forEach((entry, j) => {
|
|
182
|
+
if (embeddingsMatrix[j]) embeddings.set(entry.key, new Float32Array(embeddingsMatrix[j]));
|
|
106
183
|
});
|
|
107
184
|
}
|
|
108
185
|
}
|
|
109
186
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
// Unified lexical re-indexing of neighborhood
|
|
116
|
-
const cleanDeps = imports.map(d => d.split('/').pop().split('.')[0]);
|
|
117
|
-
const enrichedContext = [
|
|
118
|
-
chunk.name,
|
|
119
|
-
chunk.docstring || '',
|
|
120
|
-
cleanDeps.join(' '),
|
|
121
|
-
(chunk.calls || []).join(' '),
|
|
122
|
-
(chunk.params || []).join(' '),
|
|
123
|
-
chunk.return_type || '',
|
|
124
|
-
chunk.class_context ? `${chunk.class_context}.${chunk.name}` : '',
|
|
125
|
-
chunk.code_snippet
|
|
126
|
-
].join(' ');
|
|
127
|
-
|
|
128
|
-
db._indexLexical(chunk.id, enrichedContext, chunk.file_path);
|
|
129
|
-
db.chunks.set(chunk.id, chunk);
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
db.saveDebounced();
|
|
133
|
-
process.stderr.write(`[daemon] 🔄 Synced: ${filename} (Cache Hit: ${newChunks.length - chunksToEmbed.length}/${newChunks.length})\n`);
|
|
187
|
+
db.applyFileUpdate(filename, { chunks: newChunks, imports, embeddings });
|
|
188
|
+
process.stderr.write(
|
|
189
|
+
`[daemon] 🔄 Synced: ${filename} (${newChunks.length} chunks, `
|
|
190
|
+
+ `${newChunks.length - chunksToEmbed.length} cached vectors, ${config.storage})\n`
|
|
191
|
+
);
|
|
134
192
|
} catch (err) {
|
|
135
193
|
process.stderr.write(`[daemon] ❌ Error in ${filename}: ${err.message}\n`);
|
|
136
194
|
}
|
|
137
195
|
}
|
|
138
196
|
|
|
139
|
-
|
|
197
|
+
// Serialize updates: chokidar can fire bursts (git checkout, format-on-save across
|
|
198
|
+
// files); per-file work must not interleave inside the store.
|
|
199
|
+
let queue = Promise.resolve();
|
|
200
|
+
function enqueue(absolutePath) {
|
|
201
|
+
queue = queue.then(() => processFileChange(absolutePath)).catch(() => {});
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
process.stderr.write(`🚀 Watcher Daemon started in: ${PROJECT_ROOT} (backend: ${config.storage})\n`);
|
|
205
|
+
|
|
206
|
+
// Only watch source files we can actually index. Critically, this prevents
|
|
207
|
+
// chokidar from descending into node_modules / dist / .git / .gitignored dirs —
|
|
208
|
+
// watching those would exhaust the OS file-watcher limit (ENOSPC on Linux) and
|
|
209
|
+
// waste CPU/memory on large projects. The ignore decision happens at the watcher
|
|
210
|
+
// level, not just in processFileChange, so ignored trees are never traversed.
|
|
211
|
+
function shouldIgnore(absPath) {
|
|
212
|
+
const rel = path.relative(PROJECT_ROOT, absPath).replace(/\\/g, '/');
|
|
213
|
+
if (!rel) return false; // the project root itself
|
|
214
|
+
if (path.basename(absPath).startsWith('.')) return true; // dotfiles / dot-dirs
|
|
215
|
+
if (rel.startsWith('code-index.')) return true; // our own artifacts (.db/-wal/.bin/.json)
|
|
216
|
+
try { return ignoreFilter.ignores(rel); } // node_modules, dist, .gitignore, …
|
|
217
|
+
catch { return false; }
|
|
218
|
+
}
|
|
219
|
+
|
|
140
220
|
const watcher = chokidar.watch(PROJECT_ROOT, {
|
|
141
|
-
ignored:
|
|
221
|
+
ignored: shouldIgnore,
|
|
222
|
+
persistent: true,
|
|
142
223
|
awaitWriteFinish: { stabilityThreshold: 300, pollInterval: 100 }
|
|
143
224
|
});
|
|
144
|
-
watcher.on('add',
|
|
145
|
-
watcher.on('
|
|
225
|
+
watcher.on('add', enqueue).on('change', enqueue).on('unlink', enqueue);
|
|
226
|
+
watcher.on('ready', () => { queue = queue.then(() => { initialScan = false; }); });
|
|
227
|
+
watcher.on('error', (err) => process.stderr.write(`[daemon] 💥 OS Watcher panic: ${err.message}\n`));
|
|
228
|
+
|
|
229
|
+
function shutdown() {
|
|
230
|
+
watcher.close().catch(() => {});
|
|
231
|
+
if (enrichCache && enrichCacheDirty) {
|
|
232
|
+
try { saveEnrichmentCache(config.enrichmentCachePath, enrichCache); } catch { /* best effort */ }
|
|
233
|
+
}
|
|
234
|
+
if (typeof db.close === 'function') db.close();
|
|
235
|
+
process.exit(0);
|
|
236
|
+
}
|
|
237
|
+
process.on('SIGTERM', shutdown);
|
|
238
|
+
process.on('SIGINT', shutdown);
|