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/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 to maintain an in-memory graph index. Utilizes Tree-sitter for AST parsing and local embedding generation.
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 { MemoryGraphIndex } from './core-engine.mjs';
29
+ import { resolveConfig } from './config.mjs';
30
+ import { createStore } from './storage.mjs';
23
31
  import {
24
- MAX_FILE_SIZE_BYTES, EXTENSIONS, getParserForFile, buildIgnoreFilter,
25
- extractImportsFromAST, extractSemanticChunks, resolveLocalImports, getLocalEmbeddingsBatch
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 PROJECT_ROOT = process.env.MCP_PROJECT_ROOT || process.cwd();
29
- const INDEX_PATH = path.join(PROJECT_ROOT, 'code-index.json');
45
+ const config = resolveConfig();
46
+ const PROJECT_ROOT = config.projectRoot;
30
47
 
31
48
  const ignoreFilter = buildIgnoreFilter(PROJECT_ROOT);
32
- const db = new MemoryGraphIndex(INDEX_PATH);
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
- for (const [id, chunk] of db.chunks.entries()) {
43
- if (chunk.file_path === filename) {
44
- db._removeLexical(id);
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
- let content = fs.readFileSync(absolutePath, 'utf-8');
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
- for (const [id, chunk] of db.chunks.entries()) {
72
- if (chunk.file_path === filename) {
73
- db._removeLexical(id);
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 textsToEmbed = chunksToEmbed.map(c => {
83
- const dependencies = db.graph.dependencies[c.file_path] || [];
84
- const cleanDeps = dependencies.map(d => path.basename(d, path.extname(d)));
85
- const topologicalContext = cleanDeps.length > 0
86
- ? `This code architectural neighborhood connects with: ${cleanDeps.join(', ')}.`
87
- : '';
88
-
89
- // 🥇 STRICT PAYLOAD PARITY: Eliminate real-time bleed
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
- const embeddingsMatrix = await getLocalEmbeddingsBatch(textsToEmbed, true);
101
- if (embeddingsMatrix) {
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
- for (const chunk of newChunks) {
111
- if (db.embeddingCache.has(chunk.content_hash)) {
112
- db.addVector(chunk.id, db.embeddingCache.get(chunk.content_hash));
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
- process.stderr.write(`🚀 Chokidar Watcher Daemon started in: ${PROJECT_ROOT}\n`);
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: /(^|[\/\\])\../, persistent: true,
221
+ ignored: shouldIgnore,
222
+ persistent: true,
142
223
  awaitWriteFinish: { stabilityThreshold: 300, pollInterval: 100 }
143
224
  });
144
- watcher.on('add', processFileChange).on('change', processFileChange).on('unlink', (p) => processFileChange(p));
145
- watcher.on('error', (err) => process.stderr.write(`[daemon] 💥 OS Watcher panic: ${err.message}\n`));
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);