graph-indexer 1.0.3 → 1.1.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/README.md +16 -1
- package/indexer.mjs +4 -22
- package/mcp-server.mjs +10 -1
- package/package.json +2 -1
- package/parser-utils.mjs +32 -0
- package/watch-daemon.mjs +23 -19
package/README.md
CHANGED
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
- [Best practices](#best-practices)
|
|
27
27
|
- [Troubleshooting](#troubleshooting)
|
|
28
28
|
- [Development](#development)
|
|
29
|
+
- [Security](#security)
|
|
29
30
|
- [License](#license)
|
|
30
31
|
|
|
31
32
|
---
|
|
@@ -350,7 +351,9 @@ When you run `npm run mcp:index`:
|
|
|
350
351
|
4. **Creates two indexes**: a BM25 inverted index (lexical) and per-chunk float32 embeddings via Ollama `nomic-embed-text` (optional).
|
|
351
352
|
5. **Serializes to disk**: `code-index.json` + `code-index.embeddings.bin`.
|
|
352
353
|
|
|
353
|
-
A background watcher daemon re-indexes changed files automatically.
|
|
354
|
+
A background watcher daemon re-indexes changed files automatically. It respects
|
|
355
|
+
`.gitignore` and skips `node_modules`, build output, and dot-directories, so it
|
|
356
|
+
never traverses (or exhausts OS file-watcher limits on) dependency trees.
|
|
354
357
|
|
|
355
358
|
### Search pipeline
|
|
356
359
|
|
|
@@ -420,6 +423,18 @@ npm run mcp:start # start MCP server
|
|
|
420
423
|
|
|
421
424
|
---
|
|
422
425
|
|
|
426
|
+
## Security
|
|
427
|
+
|
|
428
|
+
graph-indexer runs locally and is air-gapped by default — its only outbound call
|
|
429
|
+
is to a local Ollama endpoint for embeddings (skipped entirely with
|
|
430
|
+
`INDEXER_EMBEDDINGS=off`). It never executes the code it indexes, and the index
|
|
431
|
+
artifacts contain source snippets, so keep them git-ignored (as `init` configures).
|
|
432
|
+
|
|
433
|
+
See [SECURITY.md](SECURITY.md) for the full threat model and how to report a
|
|
434
|
+
vulnerability.
|
|
435
|
+
|
|
436
|
+
---
|
|
437
|
+
|
|
423
438
|
## License
|
|
424
439
|
|
|
425
440
|
Released under the [MIT License](LICENSE). Copyright (c) 2026 MaquinaTech.
|
package/indexer.mjs
CHANGED
|
@@ -19,7 +19,7 @@ import fs from 'fs';
|
|
|
19
19
|
import path from 'path';
|
|
20
20
|
import {
|
|
21
21
|
MAX_FILE_SIZE_BYTES, EXTENSIONS, getParserForFile, buildIgnoreFilter, getLocalEmbeddingsBatch,
|
|
22
|
-
extractImportsFromAST, extractSemanticChunks, resolveLocalImports
|
|
22
|
+
extractImportsFromAST, extractSemanticChunks, resolveLocalImports, buildEmbeddingPayload
|
|
23
23
|
} from './parser-utils.mjs';
|
|
24
24
|
import { MemoryGraphIndex, writeEmbeddingBinary } from './core-engine.mjs';
|
|
25
25
|
|
|
@@ -113,27 +113,9 @@ async function main() {
|
|
|
113
113
|
console.time("Embedding Generation Duration");
|
|
114
114
|
|
|
115
115
|
const worker = async (batch) => {
|
|
116
|
-
const textsToEmbed = batch.map(c =>
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
return [
|
|
128
|
-
`File Location: ${c.file_path}`,
|
|
129
|
-
`Symbol Name: ${c.node_type} -> ${c.name}`,
|
|
130
|
-
c.docstring ? `Developer Documentation: ${c.docstring}` : '',
|
|
131
|
-
c.type_refs?.length ? `Type References: ${c.type_refs.join(', ')}` : '',
|
|
132
|
-
topologicalContext,
|
|
133
|
-
`--- Source Code ---`,
|
|
134
|
-
c.code_snippet
|
|
135
|
-
].filter(Boolean).join('\n');
|
|
136
|
-
});
|
|
116
|
+
const textsToEmbed = batch.map(c =>
|
|
117
|
+
buildEmbeddingPayload(c, indexData.graph.dependencies[c.file_path] || [])
|
|
118
|
+
);
|
|
137
119
|
|
|
138
120
|
const embeddingsMatrix = await getLocalEmbeddingsBatch(textsToEmbed, true);
|
|
139
121
|
if (embeddingsMatrix && embeddingsMatrix.length === batch.length) {
|
package/mcp-server.mjs
CHANGED
|
@@ -21,6 +21,15 @@ const PROJECT_ROOT = process.env.MCP_PROJECT_ROOT || process.cwd();
|
|
|
21
21
|
const INDEX_PATH = resolve(PROJECT_ROOT, "code-index.json");
|
|
22
22
|
const PID_FILE = resolve(PROJECT_ROOT, ".idx-daemon.pid");
|
|
23
23
|
|
|
24
|
+
// Single source of truth for the version: graph-indexer's own package.json
|
|
25
|
+
// (next to this file), never the consumer project's package.json.
|
|
26
|
+
const PACKAGE_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
27
|
+
function readPackageVersion() {
|
|
28
|
+
try {
|
|
29
|
+
return JSON.parse(fs.readFileSync(path.join(PACKAGE_DIR, "package.json"), "utf-8")).version || "0.0.0";
|
|
30
|
+
} catch { return "0.0.0"; }
|
|
31
|
+
}
|
|
32
|
+
|
|
24
33
|
// ─── Daemon Orchestration ─────────────────────────────────────────────────────
|
|
25
34
|
|
|
26
35
|
function ensureDaemonRunning() {
|
|
@@ -100,7 +109,7 @@ function pruneBodyByQuery(codeSnippet, queryTokens, maxLines = 40) {
|
|
|
100
109
|
// ─── Server Initialization ────────────────────────────────────────────────────
|
|
101
110
|
|
|
102
111
|
ensureDaemonRunning();
|
|
103
|
-
const version =
|
|
112
|
+
const version = readPackageVersion();
|
|
104
113
|
const server = new McpServer({ name: "graph-indexer", version });
|
|
105
114
|
|
|
106
115
|
// Use lazy (disk-backed) vector loading for large corpora — cacheEmbeddings:false
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "graph-indexer",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "Zero-DB, Air-gapped, In-Memory Graph Indexer MCP Server with Hybrid RRF Search and Cross-Language Topology",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "core-engine.mjs",
|
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
"scripts": {
|
|
27
27
|
"start": "node ./mcp-server.mjs",
|
|
28
28
|
"test": "node test/run.mjs",
|
|
29
|
+
"test:unit": "node test/unit.mjs",
|
|
29
30
|
"test:setup": "node test/setup.mjs",
|
|
30
31
|
"test:run": "node test/run.mjs",
|
|
31
32
|
"test:embeddings": "OLLAMA_HOST=http://localhost:11435 node test/run-embeddings.mjs",
|
package/parser-utils.mjs
CHANGED
|
@@ -637,6 +637,38 @@ export function resolveLocalImports(rawImports, fromFileRelPath, projectRoot) {
|
|
|
637
637
|
return resolved;
|
|
638
638
|
}
|
|
639
639
|
|
|
640
|
+
/**
|
|
641
|
+
* Build the text payload sent to the embedding model for a chunk.
|
|
642
|
+
*
|
|
643
|
+
* Shared by the bootstrap indexer (indexer.mjs) and the watch daemon
|
|
644
|
+
* (watch-daemon.mjs) so a chunk yields the SAME embedding regardless of which
|
|
645
|
+
* path embedded it first. This matters because the cache key (content_hash) is
|
|
646
|
+
* derived from code + docstring only — it does NOT include this payload — so two
|
|
647
|
+
* divergent payloads for the same hash would silently produce inconsistent
|
|
648
|
+
* embeddings across a full re-index vs. an incremental update.
|
|
649
|
+
*
|
|
650
|
+
* @param {object} chunk Semantic chunk: { file_path, node_type, name, docstring, type_refs, code_snippet }.
|
|
651
|
+
* @param {string[]} depRelPaths Resolved local imports of the chunk's file (project-relative paths).
|
|
652
|
+
* @returns {string}
|
|
653
|
+
*/
|
|
654
|
+
export function buildEmbeddingPayload(chunk, depRelPaths = []) {
|
|
655
|
+
const neighbors = depRelPaths
|
|
656
|
+
.map(d => path.basename(d, path.extname(d)))
|
|
657
|
+
.filter(Boolean);
|
|
658
|
+
const topologicalContext = neighbors.length
|
|
659
|
+
? `This code architectural neighborhood connects with: ${neighbors.join(', ')}.`
|
|
660
|
+
: '';
|
|
661
|
+
return [
|
|
662
|
+
`File Location: ${chunk.file_path}`,
|
|
663
|
+
`Symbol Name: ${chunk.node_type} -> ${chunk.name}`,
|
|
664
|
+
chunk.docstring ? `Developer Documentation: ${chunk.docstring}` : '',
|
|
665
|
+
chunk.type_refs?.length ? `Type References: ${chunk.type_refs.join(', ')}` : '',
|
|
666
|
+
topologicalContext,
|
|
667
|
+
`--- Source Code ---`,
|
|
668
|
+
chunk.code_snippet,
|
|
669
|
+
].filter(Boolean).join('\n');
|
|
670
|
+
}
|
|
671
|
+
|
|
640
672
|
export async function getLocalEmbedding(text, graceful = true) {
|
|
641
673
|
if (process.env.INDEXER_EMBEDDINGS === 'off') return null; // lexical-only mode
|
|
642
674
|
const MAX_RETRIES = 3;
|
package/watch-daemon.mjs
CHANGED
|
@@ -22,7 +22,8 @@ import chokidar from 'chokidar';
|
|
|
22
22
|
import { MemoryGraphIndex } from './core-engine.mjs';
|
|
23
23
|
import {
|
|
24
24
|
MAX_FILE_SIZE_BYTES, EXTENSIONS, getParserForFile, buildIgnoreFilter,
|
|
25
|
-
extractImportsFromAST, extractSemanticChunks, resolveLocalImports, getLocalEmbeddingsBatch
|
|
25
|
+
extractImportsFromAST, extractSemanticChunks, resolveLocalImports, getLocalEmbeddingsBatch,
|
|
26
|
+
buildEmbeddingPayload
|
|
26
27
|
} from './parser-utils.mjs';
|
|
27
28
|
|
|
28
29
|
const PROJECT_ROOT = process.env.MCP_PROJECT_ROOT || process.cwd();
|
|
@@ -79,23 +80,11 @@ async function processFileChange(absolutePath) {
|
|
|
79
80
|
const chunksToEmbed = newChunks.filter(c => !db.embeddingCache.has(c.content_hash));
|
|
80
81
|
|
|
81
82
|
if (chunksToEmbed.length > 0) {
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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');
|
|
98
|
-
});
|
|
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
|
+
);
|
|
99
88
|
|
|
100
89
|
const embeddingsMatrix = await getLocalEmbeddingsBatch(textsToEmbed, true);
|
|
101
90
|
if (embeddingsMatrix) {
|
|
@@ -137,8 +126,23 @@ async function processFileChange(absolutePath) {
|
|
|
137
126
|
}
|
|
138
127
|
|
|
139
128
|
process.stderr.write(`🚀 Chokidar Watcher Daemon started in: ${PROJECT_ROOT}\n`);
|
|
129
|
+
|
|
130
|
+
// Only watch source files we can actually index. Critically, this prevents
|
|
131
|
+
// chokidar from descending into node_modules / dist / .git / .gitignored dirs —
|
|
132
|
+
// watching those would exhaust the OS file-watcher limit (ENOSPC on Linux) and
|
|
133
|
+
// waste CPU/memory on large projects. The ignore decision happens at the watcher
|
|
134
|
+
// level, not just in processFileChange, so ignored trees are never traversed.
|
|
135
|
+
function shouldIgnore(absPath) {
|
|
136
|
+
const rel = path.relative(PROJECT_ROOT, absPath).replace(/\\/g, '/');
|
|
137
|
+
if (!rel) return false; // the project root itself
|
|
138
|
+
if (path.basename(absPath).startsWith('.')) return true; // dotfiles / dot-dirs
|
|
139
|
+
try { return ignoreFilter.ignores(rel); } // node_modules, dist, .gitignore, …
|
|
140
|
+
catch { return false; }
|
|
141
|
+
}
|
|
142
|
+
|
|
140
143
|
const watcher = chokidar.watch(PROJECT_ROOT, {
|
|
141
|
-
ignored:
|
|
144
|
+
ignored: shouldIgnore,
|
|
145
|
+
persistent: true,
|
|
142
146
|
awaitWriteFinish: { stabilityThreshold: 300, pollInterval: 100 }
|
|
143
147
|
});
|
|
144
148
|
watcher.on('add', processFileChange).on('change', processFileChange).on('unlink', (p) => processFileChange(p));
|