graph-indexer 1.1.0 → 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,36 +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,
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,
25
37
  extractImportsFromAST, extractSemanticChunks, resolveLocalImports, getLocalEmbeddingsBatch,
26
- buildEmbeddingPayload
38
+ buildEmbeddingPayload,
27
39
  } from './parser-utils.mjs';
40
+ import {
41
+ loadEnrichmentCache, saveEnrichmentCache, attachEnrichment,
42
+ ollamaGenerate, parseEnrichResponse, buildEnrichPrompt,
43
+ } from './enrichment.mjs';
28
44
 
29
- const PROJECT_ROOT = process.env.MCP_PROJECT_ROOT || process.cwd();
30
- const INDEX_PATH = path.join(PROJECT_ROOT, 'code-index.json');
45
+ const config = resolveConfig();
46
+ const PROJECT_ROOT = config.projectRoot;
31
47
 
32
48
  const ignoreFilter = buildIgnoreFilter(PROJECT_ROOT);
33
- const db = new MemoryGraphIndex(INDEX_PATH);
49
+ const db = await createStore(config, { cacheEmbeddings: true });
34
50
  db.load();
35
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
+
36
126
  async function processFileChange(absolutePath) {
37
127
  const filename = path.relative(PROJECT_ROOT, absolutePath).replace(/\\/g, '/');
38
128
  if (ignoreFilter.ignores(filename)) return;
39
129
  if (filename.includes('.bundle.') || filename.includes('.min.')) return;
130
+ if (filename.startsWith('code-index.')) return; // never index our own artifacts
40
131
 
41
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
+
42
138
  if (!fs.existsSync(absolutePath)) {
43
- for (const [id, chunk] of db.chunks.entries()) {
44
- if (chunk.file_path === filename) {
45
- db._removeLexical(id);
46
- db.chunks.delete(id);
47
- db.removeVector(id);
48
- }
49
- }
50
- db.updateFileGraph(filename, []);
51
- db.saveDebounced();
139
+ if (typeof db.removeFile === 'function') db.removeFile(filename);
140
+ else db.applyFileUpdate(filename, { chunks: [], imports: [] });
141
+ _coreFiles = null;
52
142
  process.stderr.write(`[daemon] 🗑️ Purged: ${filename}\n`);
53
143
  return;
54
144
  }
@@ -56,7 +146,7 @@ async function processFileChange(absolutePath) {
56
146
  const stats = fs.statSync(absolutePath);
57
147
  if (stats.size > MAX_FILE_SIZE_BYTES) return;
58
148
 
59
- let content = fs.readFileSync(absolutePath, 'utf-8');
149
+ const content = fs.readFileSync(absolutePath, 'utf-8');
60
150
  if (!content.trim()) return;
61
151
 
62
152
  const ext = path.extname(absolutePath);
@@ -65,67 +155,53 @@ async function processFileChange(absolutePath) {
65
155
 
66
156
  const tree = parser.parse((offset) => offset < content.length ? content.slice(offset, offset + 4096) : null);
67
157
  const imports = resolveLocalImports(extractImportsFromAST(tree.rootNode, ext), filename, PROJECT_ROOT);
68
- db.updateFileGraph(filename, imports);
69
-
70
158
  const newChunks = extractSemanticChunks(tree.rootNode, filename, content, ext);
159
+ _coreFiles = null; // dependency graph may change → PageRank core set is stale
71
160
 
72
- for (const [id, chunk] of db.chunks.entries()) {
73
- if (chunk.file_path === filename) {
74
- db._removeLexical(id);
75
- db.chunks.delete(id);
76
- db.removeVector(id); // invalidates flat matrix so next search rebuilds it
77
- }
78
- }
79
-
80
- 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);
81
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();
82
170
  if (chunksToEmbed.length > 0) {
83
- // 🥇 STRICT PAYLOAD PARITY: identical embedding payload to indexer.mjs
84
- // (shared helper) so incremental updates match the bootstrap index.
85
- const textsToEmbed = chunksToEmbed.map(c =>
86
- buildEmbeddingPayload(c, db.graph.dependencies[c.file_path] || [])
87
- );
88
-
89
- const embeddingsMatrix = await getLocalEmbeddingsBatch(textsToEmbed, true);
90
- if (embeddingsMatrix) {
91
- chunksToEmbed.forEach((chunk, j) => {
92
- if (embeddingsMatrix[j]) {
93
- db.embeddingCache.set(chunk.content_hash, new Float32Array(embeddingsMatrix[j]));
94
- }
95
- });
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 });
96
176
  }
97
- }
98
-
99
- for (const chunk of newChunks) {
100
- if (db.embeddingCache.has(chunk.content_hash)) {
101
- db.addVector(chunk.id, db.embeddingCache.get(chunk.content_hash));
177
+ const embeddingsMatrix = await getLocalEmbeddingsBatch(entries.map(e => e.text), true, {
178
+ ollamaHost: config.ollamaHost, model: config.embedModel,
179
+ });
180
+ if (embeddingsMatrix && embeddingsMatrix.length === entries.length) {
181
+ entries.forEach((entry, j) => {
182
+ if (embeddingsMatrix[j]) embeddings.set(entry.key, new Float32Array(embeddingsMatrix[j]));
183
+ });
102
184
  }
103
-
104
- // Unified lexical re-indexing of neighborhood
105
- const cleanDeps = imports.map(d => d.split('/').pop().split('.')[0]);
106
- const enrichedContext = [
107
- chunk.name,
108
- chunk.docstring || '',
109
- cleanDeps.join(' '),
110
- (chunk.calls || []).join(' '),
111
- (chunk.params || []).join(' '),
112
- chunk.return_type || '',
113
- chunk.class_context ? `${chunk.class_context}.${chunk.name}` : '',
114
- chunk.code_snippet
115
- ].join(' ');
116
-
117
- db._indexLexical(chunk.id, enrichedContext, chunk.file_path);
118
- db.chunks.set(chunk.id, chunk);
119
185
  }
120
186
 
121
- db.saveDebounced();
122
- 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
+ );
123
192
  } catch (err) {
124
193
  process.stderr.write(`[daemon] ❌ Error in ${filename}: ${err.message}\n`);
125
194
  }
126
195
  }
127
196
 
128
- 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`);
129
205
 
130
206
  // Only watch source files we can actually index. Critically, this prevents
131
207
  // chokidar from descending into node_modules / dist / .git / .gitignored dirs —
@@ -136,6 +212,7 @@ function shouldIgnore(absPath) {
136
212
  const rel = path.relative(PROJECT_ROOT, absPath).replace(/\\/g, '/');
137
213
  if (!rel) return false; // the project root itself
138
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)
139
216
  try { return ignoreFilter.ignores(rel); } // node_modules, dist, .gitignore, …
140
217
  catch { return false; }
141
218
  }
@@ -145,5 +222,17 @@ const watcher = chokidar.watch(PROJECT_ROOT, {
145
222
  persistent: true,
146
223
  awaitWriteFinish: { stabilityThreshold: 300, pollInterval: 100 }
147
224
  });
148
- watcher.on('add', processFileChange).on('change', processFileChange).on('unlink', (p) => processFileChange(p));
149
- 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);