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/mcp-tools.mjs
ADDED
|
@@ -0,0 +1,545 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file mcp-tools.mjs
|
|
3
|
+
* @description MCP tool definitions for graph-indexer. Every tool is written
|
|
4
|
+
* against the storage contract (see storage.mjs) — never a concrete
|
|
5
|
+
* backend — so the identical surface serves both the in-memory engine
|
|
6
|
+
* and the disk-backed SQLite store. `registerTools(server, db, opts)`
|
|
7
|
+
* wires them onto an McpServer instance.
|
|
8
|
+
* @author MaquinaTech <https://github.com/MaquinaTech>
|
|
9
|
+
* @copyright (c) 2026 MaquinaTech. All rights reserved.
|
|
10
|
+
* @license MIT
|
|
11
|
+
*/
|
|
12
|
+
import { z } from 'zod';
|
|
13
|
+
import fs from 'fs';
|
|
14
|
+
import path, { resolve } from 'path';
|
|
15
|
+
import { computePageRank, isNaturalLanguageQuery } from './search-core.mjs';
|
|
16
|
+
import { getLocalEmbedding, getParserForFile, extractFileSkeleton } from './parser-utils.mjs';
|
|
17
|
+
import { rerankResults, ollamaGenerate } from './enrichment.mjs';
|
|
18
|
+
|
|
19
|
+
// ─── Rendering helpers ──────────────────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
/** Extract just the function signature (first lines up to the opening brace). */
|
|
22
|
+
export function extractSignatureLine(codeSnippet) {
|
|
23
|
+
if (!codeSnippet) return '';
|
|
24
|
+
const lines = codeSnippet.split('\n');
|
|
25
|
+
const sigLines = [];
|
|
26
|
+
for (let i = 0; i < Math.min(lines.length, 10); i++) {
|
|
27
|
+
sigLines.push(lines[i]);
|
|
28
|
+
const l = lines[i];
|
|
29
|
+
if (i > 0 && (l.trimEnd().endsWith('{') || l.includes('=>') || l.trimEnd().endsWith(':'))) break;
|
|
30
|
+
}
|
|
31
|
+
return sigLines.join('\n');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Prune a function body: keep signature + query-relevant lines + tail.
|
|
36
|
+
*
|
|
37
|
+
* Semantic fallback: when no lexical token matches (the agent used a high-level
|
|
38
|
+
* description like "authentication bottleneck" that isn't in the code verbatim),
|
|
39
|
+
* preserve the structural skeleton — control-flow lines and calls — rather than
|
|
40
|
+
* blindly truncating, so 'smart' detail always returns meaningful context.
|
|
41
|
+
*/
|
|
42
|
+
export function pruneBodyByQuery(codeSnippet, queryTokens, maxLines = 40) {
|
|
43
|
+
if (!codeSnippet) return '';
|
|
44
|
+
const lines = codeSnippet.split('\n');
|
|
45
|
+
if (lines.length <= maxLines) return codeSnippet;
|
|
46
|
+
|
|
47
|
+
const querySet = new Set(queryTokens.filter(t => t.length >= 3).map(t => t.toLowerCase()));
|
|
48
|
+
if (querySet.size === 0) return lines.slice(0, maxLines).join('\n') + '\n// …';
|
|
49
|
+
|
|
50
|
+
const SIG_LINES = Math.min(5, lines.length);
|
|
51
|
+
const TAIL_LINES = Math.min(3, lines.length);
|
|
52
|
+
const sigBlock = lines.slice(0, SIG_LINES);
|
|
53
|
+
const tailBlock = lines.slice(Math.max(lines.length - TAIL_LINES, SIG_LINES));
|
|
54
|
+
|
|
55
|
+
const bodyLines = lines.slice(SIG_LINES, lines.length - TAIL_LINES);
|
|
56
|
+
const relevant = bodyLines.filter(line => {
|
|
57
|
+
const ll = line.toLowerCase();
|
|
58
|
+
if (/^\s*(return|throw|raise|yield)\b/.test(ll)) return true;
|
|
59
|
+
return [...querySet].some(token => ll.includes(token));
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
if (relevant.length === 0) {
|
|
63
|
+
const budget = Math.max(4, maxLines - SIG_LINES - TAIL_LINES);
|
|
64
|
+
const structural = bodyLines.filter(line => {
|
|
65
|
+
const ll = line.trimStart().toLowerCase();
|
|
66
|
+
if (/^(if |else |for |while |switch |try |catch |finally |return |throw |raise |yield |await )/.test(ll)) return true;
|
|
67
|
+
if (/[a-zA-Z_]\w*\s*\(/.test(line) && line.trim().length > 4) return true;
|
|
68
|
+
return false;
|
|
69
|
+
}).slice(0, budget);
|
|
70
|
+
if (structural.length > 0) return [...sigBlock, ...structural, ...tailBlock].join('\n');
|
|
71
|
+
return lines.slice(0, maxLines).join('\n') + '\n// …';
|
|
72
|
+
}
|
|
73
|
+
return [...sigBlock, ...relevant, ...tailBlock].join('\n');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ─── Tool registration ──────────────────────────────────────────────────────────
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Register every graph-indexer tool on an MCP server.
|
|
80
|
+
*
|
|
81
|
+
* @param {import('@modelcontextprotocol/sdk/server/mcp.js').McpServer} server
|
|
82
|
+
* @param {object} db A loaded store implementing the storage contract.
|
|
83
|
+
* @param {object} opts
|
|
84
|
+
* @param {string} opts.projectRoot
|
|
85
|
+
* @param {string} opts.artifactPath Index file whose mtime represents freshness.
|
|
86
|
+
* @param {string} opts.pidFile Watch-daemon PID file (may not exist).
|
|
87
|
+
* @param {boolean} opts.embeddingsEnabled
|
|
88
|
+
* @param {string} [opts.ollamaHost] Resolved Ollama endpoint for query embedding.
|
|
89
|
+
* @param {string} [opts.embedModel] Embedding model (must match the index).
|
|
90
|
+
* @param {{enabled:boolean, model:string, topM:number}} [opts.rerank] LLM rerank config.
|
|
91
|
+
*/
|
|
92
|
+
export function registerTools(server, db, { projectRoot, artifactPath, pidFile, embeddingsEnabled, ollamaHost, embedModel, rerank }) {
|
|
93
|
+
|
|
94
|
+
// ─── search_code ────────────────────────────────────────────────────────────
|
|
95
|
+
server.tool(
|
|
96
|
+
'search_code',
|
|
97
|
+
'CRITICAL: ALWAYS USE THIS TOOL FIRST to find code. High-precision AST hybrid search returning exact chunks and cross-file topology.',
|
|
98
|
+
{
|
|
99
|
+
query: z.string().describe('Natural language description of the logic to find.'),
|
|
100
|
+
exact_tokens: z.string().optional().describe('Exact symbol name for guaranteed rank-1 placement.'),
|
|
101
|
+
include_topology: z.boolean().default(true),
|
|
102
|
+
min_score: z.number().min(0).max(1).default(0.3),
|
|
103
|
+
top_k: z.number().int().min(1).max(20).default(5),
|
|
104
|
+
token_budget: z.number().int().min(100).optional().describe(
|
|
105
|
+
'Token budget for code bodies (1 token ≈ 4 chars). Omit to use smart default.'
|
|
106
|
+
),
|
|
107
|
+
detail: z.enum(['signatures', 'smart', 'full']).default('smart').describe(
|
|
108
|
+
"'signatures': compact cards only (~20 tok each, no bodies) — fastest. "
|
|
109
|
+
+ "'smart' (default): signatures + query-relevant body snippets. "
|
|
110
|
+
+ "'full': signatures + complete bodies."
|
|
111
|
+
),
|
|
112
|
+
rerank: z.boolean().optional().describe(
|
|
113
|
+
'Rerank the top results with a local LLM judge (+50% rank-1 on '
|
|
114
|
+
+ 'natural-language queries, ~1–2 s extra). Defaults to the '
|
|
115
|
+
+ '`rerank.enabled` project config; only fires on NL queries.'
|
|
116
|
+
),
|
|
117
|
+
},
|
|
118
|
+
async ({ query, exact_tokens, include_topology, min_score, top_k, token_budget, detail, rerank: rerankParam }) => {
|
|
119
|
+
try {
|
|
120
|
+
const fullQuery = exact_tokens ? `${query} ${exact_tokens}` : query;
|
|
121
|
+
let queryVector = null;
|
|
122
|
+
try { queryVector = await getLocalEmbedding(fullQuery, true, { ollamaHost, model: embedModel }); }
|
|
123
|
+
catch { /* lexical fallback */ }
|
|
124
|
+
|
|
125
|
+
let matches = db.searchHybrid(fullQuery, queryVector, top_k, min_score, exact_tokens || null);
|
|
126
|
+
|
|
127
|
+
// Opt-in LLM rerank: only for natural-language queries (symbol
|
|
128
|
+
// lookups are already rank-1-dominant), never when the caller
|
|
129
|
+
// pinned an exact symbol. Best-effort — order is preserved on
|
|
130
|
+
// any model failure.
|
|
131
|
+
const wantRerank = rerankParam ?? Boolean(rerank?.enabled);
|
|
132
|
+
if (wantRerank && !exact_tokens && matches.length > 1 && isNaturalLanguageQuery(fullQuery)) {
|
|
133
|
+
matches = await rerankResults(fullQuery, matches, {
|
|
134
|
+
topM: rerank?.topM ?? 8,
|
|
135
|
+
generate: (prompt) => ollamaGenerate(prompt, {
|
|
136
|
+
model: rerank?.model || 'qwen2.5-coder:7b',
|
|
137
|
+
ollamaHost, timeoutMs: 20000,
|
|
138
|
+
options: { temperature: 0, num_predict: 40 },
|
|
139
|
+
}),
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
if (matches.length === 0) return { content: [{ type: 'text', text: 'No results found.' }] };
|
|
143
|
+
|
|
144
|
+
const depSignature = (depPath) => {
|
|
145
|
+
const syms = [];
|
|
146
|
+
for (const c of db.getChunksByFile(depPath)) {
|
|
147
|
+
if (c.name && c.node_type !== 'expression_statement') {
|
|
148
|
+
syms.push(c.name);
|
|
149
|
+
if (syms.length >= 4) break;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return syms.length ? `${depPath} [${syms.join(', ')}]` : depPath;
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
const lines = [`🔍 QUERY: "${fullQuery}" — ${matches.length} result(s)\n`];
|
|
156
|
+
|
|
157
|
+
for (let i = 0; i < matches.length; i++) {
|
|
158
|
+
const { score, chunk } = matches[i];
|
|
159
|
+
lines.push(`${'─'.repeat(50)}`);
|
|
160
|
+
lines.push(`#${i + 1} · **${chunk.name}** [${chunk.node_type}]`);
|
|
161
|
+
lines.push(`📄 ${chunk.file_path}:${chunk.start_line}–${chunk.end_line} · ID: \`${chunk.id}\` · RRF: ${score.toFixed(4)}`);
|
|
162
|
+
|
|
163
|
+
const sig = [];
|
|
164
|
+
if (chunk.params?.length) sig.push(`(${chunk.params.slice(0, 4).join(', ')})`);
|
|
165
|
+
if (chunk.return_type) sig.push(`→ ${chunk.return_type}`);
|
|
166
|
+
if (chunk.type_refs?.length) sig.push(`types: ${chunk.type_refs.slice(0, 4).join(', ')}`);
|
|
167
|
+
if (sig.length) lines.push(`🔤 ${sig.join(' ')}`);
|
|
168
|
+
|
|
169
|
+
if (chunk.decorators?.length) lines.push(`🏷 ${chunk.decorators.slice(0, 6).map(d => '@' + d).join(' ')}`);
|
|
170
|
+
if (chunk.extends?.length) lines.push(`🧬 extends/implements: ${chunk.extends.slice(0, 5).join(', ')}`);
|
|
171
|
+
if (chunk.docstring) lines.push(`💬 ${chunk.docstring.slice(0, 140).replace(/\n/g, ' ')}`);
|
|
172
|
+
|
|
173
|
+
if (include_topology) {
|
|
174
|
+
const deps = db.getDependencies(chunk.file_path).slice(0, 3);
|
|
175
|
+
const usedBy = db.getImportedBy(chunk.file_path).slice(0, 3);
|
|
176
|
+
if (deps.length) lines.push(`⬇️ Deps: ${deps.map(depSignature).join(' | ')}`);
|
|
177
|
+
if (usedBy.length) lines.push(`⬆️ Used by: ${usedBy.join(', ')}`);
|
|
178
|
+
if (chunk.calls?.length) lines.push(`🔗 Calls: ${chunk.calls.slice(0, 6).join(', ')}`);
|
|
179
|
+
}
|
|
180
|
+
lines.push(`↩️ Expand: get_chunk("${chunk.id}")`);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (detail !== 'signatures') {
|
|
184
|
+
const CHARS_PER_TOKEN = 4;
|
|
185
|
+
const defaultBudget = detail === 'full' ? 6000 : 2000;
|
|
186
|
+
let remainingChars = token_budget != null ? token_budget * CHARS_PER_TOKEN : defaultBudget;
|
|
187
|
+
|
|
188
|
+
lines.push(`\n${'═'.repeat(50)}`);
|
|
189
|
+
lines.push(`CODE BODIES (detail: ${detail}, budget: ~${Math.round(remainingChars / CHARS_PER_TOKEN)} tok)\n`);
|
|
190
|
+
|
|
191
|
+
const queryTokens = fullQuery.toLowerCase().split(/[\s\W_]+/).filter(t => t.length >= 3);
|
|
192
|
+
|
|
193
|
+
for (const { chunk } of matches) {
|
|
194
|
+
if (remainingChars <= 0) break;
|
|
195
|
+
const raw = chunk.code_snippet || '';
|
|
196
|
+
if (!raw) continue;
|
|
197
|
+
const snippet = detail === 'full'
|
|
198
|
+
? raw.slice(0, remainingChars)
|
|
199
|
+
: pruneBodyByQuery(raw, queryTokens).slice(0, remainingChars);
|
|
200
|
+
lines.push(`### ${chunk.name} — ${chunk.file_path}`);
|
|
201
|
+
lines.push('```\n' + snippet + '\n```\n');
|
|
202
|
+
remainingChars -= snippet.length;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return { content: [{ type: 'text', text: lines.join('\n') }] };
|
|
207
|
+
} catch (err) {
|
|
208
|
+
return { content: [{ type: 'text', text: `Error: ${err.message}` }], isError: true };
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
// ─── get_chunk ────────────────────────────────────────────────────────────────
|
|
214
|
+
server.tool(
|
|
215
|
+
'get_chunk',
|
|
216
|
+
'CRITICAL: Use this INSTEAD of reading full files. Returns the complete body of a function/class by its chunk_id from search_code results.',
|
|
217
|
+
{
|
|
218
|
+
chunk_id: z.string().describe('The chunk ID shown in search_code results.'),
|
|
219
|
+
view: z.enum(['full', 'signature']).default('full').describe(
|
|
220
|
+
"'full': complete source body. 'signature': just the function signature line (~5 tokens)."
|
|
221
|
+
),
|
|
222
|
+
},
|
|
223
|
+
async ({ chunk_id, view }) => {
|
|
224
|
+
try {
|
|
225
|
+
const chunk = db.getChunk(chunk_id);
|
|
226
|
+
if (!chunk) return { content: [{ type: 'text', text: `Chunk '${chunk_id}' not found. Run search_code to get valid IDs.` }] };
|
|
227
|
+
|
|
228
|
+
const parts = [
|
|
229
|
+
`# ${chunk.name}`,
|
|
230
|
+
`**File:** \`${chunk.file_path}\` · **Lines:** ${chunk.start_line}–${chunk.end_line} · **Type:** ${chunk.node_type}`,
|
|
231
|
+
];
|
|
232
|
+
if (chunk.params?.length) parts.push(`**Params:** ${chunk.params.join(', ')}`);
|
|
233
|
+
if (chunk.return_type) parts.push(`**Returns:** ${chunk.return_type}`);
|
|
234
|
+
if (chunk.type_refs?.length) parts.push(`**Type refs:** ${chunk.type_refs.join(', ')}`);
|
|
235
|
+
if (chunk.decorators?.length) parts.push(`**Decorators:** ${chunk.decorators.map(d => '@' + d).join(', ')}`);
|
|
236
|
+
if (chunk.extends?.length) parts.push(`**Inherits:** ${chunk.extends.join(', ')}`);
|
|
237
|
+
if (chunk.docstring) parts.push(`**Doc:** ${chunk.docstring}`);
|
|
238
|
+
|
|
239
|
+
const deps = db.getDependencies(chunk.file_path);
|
|
240
|
+
const usedBy = db.getImportedBy(chunk.file_path);
|
|
241
|
+
if (deps.length) parts.push(`⬇️ Imports: ${deps.join(', ')}`);
|
|
242
|
+
if (usedBy.length) parts.push(`⬆️ Used by: ${usedBy.join(', ')}`);
|
|
243
|
+
if (chunk.calls?.length) parts.push(`🔗 Calls: ${chunk.calls.join(', ')}`);
|
|
244
|
+
|
|
245
|
+
if (view === 'signature') parts.push('', '```', extractSignatureLine(chunk.code_snippet), '```');
|
|
246
|
+
else parts.push('', '```', chunk.code_snippet, '```');
|
|
247
|
+
return { content: [{ type: 'text', text: parts.join('\n') }] };
|
|
248
|
+
} catch (err) {
|
|
249
|
+
return { content: [{ type: 'text', text: `Error: ${err.message}` }], isError: true };
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
);
|
|
253
|
+
|
|
254
|
+
// ─── resolve_symbol ─────────────────────────────────────────────────────────
|
|
255
|
+
server.tool(
|
|
256
|
+
'resolve_symbol',
|
|
257
|
+
'Instantly finds the definition of any symbol (function, class, type, variable) by exact name — O(1) lookup, no search needed. Returns the defining chunk and cross-file topology.',
|
|
258
|
+
{ symbol: z.string().describe("Exact symbol name (e.g. 'validateToken', 'User', 'PaymentService').") },
|
|
259
|
+
async ({ symbol }) => {
|
|
260
|
+
try {
|
|
261
|
+
const defs = db.resolveSymbol(symbol);
|
|
262
|
+
if (defs.length === 0) {
|
|
263
|
+
return { content: [{ type: 'text', text: `Symbol '${symbol}' not in index. Try search_code(query="${symbol}") for fuzzy search.` }] };
|
|
264
|
+
}
|
|
265
|
+
const lines = [`# Symbol: \`${symbol}\` — ${defs.length} definition(s)\n`];
|
|
266
|
+
for (const chunk of defs) {
|
|
267
|
+
lines.push(`${'─'.repeat(50)}`);
|
|
268
|
+
lines.push(`**${chunk.name}** [${chunk.node_type}]`);
|
|
269
|
+
lines.push(`📄 ${chunk.file_path}:${chunk.start_line}–${chunk.end_line} · ID: \`${chunk.id}\``);
|
|
270
|
+
if (chunk.params?.length) lines.push(`🔤 Params: ${chunk.params.join(', ')}`);
|
|
271
|
+
if (chunk.return_type) lines.push(`🔤 Returns: ${chunk.return_type}`);
|
|
272
|
+
if (chunk.type_refs?.length) lines.push(`🔗 Type refs: ${chunk.type_refs.join(', ')}`);
|
|
273
|
+
if (chunk.decorators?.length) lines.push(`🏷 ${chunk.decorators.slice(0, 6).map(d => '@' + d).join(' ')}`);
|
|
274
|
+
if (chunk.docstring) lines.push(`💬 ${chunk.docstring.slice(0, 160).replace(/\n/g, ' ')}`);
|
|
275
|
+
const deps = db.getDependencies(chunk.file_path).slice(0, 4);
|
|
276
|
+
const usedBy = db.getImportedBy(chunk.file_path).slice(0, 4);
|
|
277
|
+
if (deps.length) lines.push(`⬇️ Imports: ${deps.join(', ')}`);
|
|
278
|
+
if (usedBy.length) lines.push(`⬆️ Used by: ${usedBy.join(', ')}`);
|
|
279
|
+
if (chunk.calls?.length) lines.push(`🔗 Calls: ${chunk.calls.slice(0, 8).join(', ')}`);
|
|
280
|
+
lines.push(`\n\`\`\`\n${extractSignatureLine(chunk.code_snippet)}\n\`\`\``);
|
|
281
|
+
lines.push(`↩️ Full body: get_chunk("${chunk.id}")`);
|
|
282
|
+
}
|
|
283
|
+
return { content: [{ type: 'text', text: lines.join('\n') }] };
|
|
284
|
+
} catch (err) {
|
|
285
|
+
return { content: [{ type: 'text', text: `Error: ${err.message}` }], isError: true };
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
);
|
|
289
|
+
|
|
290
|
+
// ─── get_chunk_summary ──────────────────────────────────────────────────────
|
|
291
|
+
server.tool(
|
|
292
|
+
'get_chunk_summary',
|
|
293
|
+
'Returns the function/class signature + docstring + called functions — no full body. ~50 tokens vs ~300 for full body. Use when you only need to understand the interface, not the implementation.',
|
|
294
|
+
{
|
|
295
|
+
chunk_id: z.string().describe('The chunk ID from search_code results.'),
|
|
296
|
+
expand_calls: z.boolean().default(false).describe(
|
|
297
|
+
'When true, resolves the signatures of outgoing dependencies inline (~150 tok vs ~50 tok). '
|
|
298
|
+
+ 'Use when you need to understand the interfaces of called functions in a single shot, '
|
|
299
|
+
+ 'without issuing a separate tool call per dependency.'
|
|
300
|
+
),
|
|
301
|
+
},
|
|
302
|
+
async ({ chunk_id, expand_calls }) => {
|
|
303
|
+
try {
|
|
304
|
+
const chunk = db.getChunk(chunk_id);
|
|
305
|
+
if (!chunk) return { content: [{ type: 'text', text: `Chunk '${chunk_id}' not found.` }] };
|
|
306
|
+
|
|
307
|
+
const lines = [
|
|
308
|
+
`# ${chunk.name} · ${chunk.file_path}:${chunk.start_line}–${chunk.end_line}`,
|
|
309
|
+
`**Type:** ${chunk.node_type}`,
|
|
310
|
+
];
|
|
311
|
+
if (chunk.params?.length) lines.push(`**Params:** ${chunk.params.join(', ')}`);
|
|
312
|
+
if (chunk.return_type) lines.push(`**Returns:** ${chunk.return_type}`);
|
|
313
|
+
if (chunk.type_refs?.length) lines.push(`**Type refs:** ${chunk.type_refs.join(', ')}`);
|
|
314
|
+
if (chunk.decorators?.length) lines.push(`**Decorators:** ${chunk.decorators.map(d => '@' + d).join(', ')}`);
|
|
315
|
+
if (chunk.docstring) lines.push(`\n**Doc:** ${chunk.docstring.slice(0, 300)}`);
|
|
316
|
+
|
|
317
|
+
if (chunk.calls?.length) {
|
|
318
|
+
if (!expand_calls) {
|
|
319
|
+
lines.push(`**Calls:** ${chunk.calls.join(', ')}`);
|
|
320
|
+
} else {
|
|
321
|
+
const expanded = [];
|
|
322
|
+
const seen = new Set();
|
|
323
|
+
const SIG_BUDGET = 6;
|
|
324
|
+
for (const callName of chunk.calls) {
|
|
325
|
+
if (seen.size >= SIG_BUDGET) break;
|
|
326
|
+
const key = callName.toLowerCase();
|
|
327
|
+
if (seen.has(key)) continue;
|
|
328
|
+
seen.add(key);
|
|
329
|
+
const target = db.resolveSymbol(callName)[0];
|
|
330
|
+
if (!target?.code_snippet) continue;
|
|
331
|
+
const sig = extractSignatureLine(target.code_snippet).split('\n')[0].trim().slice(0, 120);
|
|
332
|
+
expanded.push(
|
|
333
|
+
` **${callName}** → \`${target.file_path}:${target.start_line}\``
|
|
334
|
+
+ `\n \`\`\`\n ${sig}\n \`\`\``
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
const unresolved = chunk.calls.length - expanded.length;
|
|
338
|
+
lines.push(`\n**Calls (${expanded.length} resolved${unresolved > 0 ? `, ${unresolved} unindexed` : ''}):**`);
|
|
339
|
+
if (expanded.length > 0) lines.push(...expanded);
|
|
340
|
+
else lines.push(' _(none resolved in index)_');
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
lines.push('', '```', extractSignatureLine(chunk.code_snippet), '```');
|
|
345
|
+
lines.push(`\n↩️ Full body: get_chunk("${chunk.id}")`);
|
|
346
|
+
return { content: [{ type: 'text', text: lines.join('\n') }] };
|
|
347
|
+
} catch (err) {
|
|
348
|
+
return { content: [{ type: 'text', text: `Error: ${err.message}` }], isError: true };
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
);
|
|
352
|
+
|
|
353
|
+
// ─── get_file_skeleton ──────────────────────────────────────────────────────
|
|
354
|
+
server.tool(
|
|
355
|
+
'get_file_skeleton',
|
|
356
|
+
'Returns all top-level exports and definitions in a file with line numbers — no code bodies (~50 tokens vs 5000).',
|
|
357
|
+
{ file_path: z.string().describe("Relative path (e.g. 'src/app.ts').") },
|
|
358
|
+
async ({ file_path }) => {
|
|
359
|
+
try {
|
|
360
|
+
const absolutePath = resolve(projectRoot, file_path);
|
|
361
|
+
const safeRoot = path.normalize(projectRoot);
|
|
362
|
+
if (!path.normalize(absolutePath).startsWith(safeRoot + path.sep) &&
|
|
363
|
+
path.normalize(absolutePath) !== safeRoot) {
|
|
364
|
+
throw new Error('Access denied: path is outside the project root.');
|
|
365
|
+
}
|
|
366
|
+
if (!fs.existsSync(absolutePath)) throw new Error('File not found.');
|
|
367
|
+
const content = fs.readFileSync(absolutePath, 'utf-8');
|
|
368
|
+
const ext = path.extname(absolutePath);
|
|
369
|
+
const parser = getParserForFile(ext);
|
|
370
|
+
if (!parser) return { content: [{ type: 'text', text: 'Language not supported.' }] };
|
|
371
|
+
const tree = parser.parse((offset) => offset < content.length ? content.slice(offset, offset + 4096) : null);
|
|
372
|
+
const skeleton = extractFileSkeleton(tree.rootNode, content);
|
|
373
|
+
return { content: [{ type: 'text', text: `# Skeleton: ${file_path}\n\n${skeleton || '_No semantic signatures found_'}` }] };
|
|
374
|
+
} catch (err) {
|
|
375
|
+
return { content: [{ type: 'text', text: `Error: ${err.message}` }], isError: true };
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
);
|
|
379
|
+
|
|
380
|
+
// ─── get_call_graph ─────────────────────────────────────────────────────────
|
|
381
|
+
server.tool(
|
|
382
|
+
'get_call_graph',
|
|
383
|
+
'Finds all chunks that call a specific function. CRITICAL for safe refactoring.',
|
|
384
|
+
{ target_function: z.string().describe("Exact function name (e.g. 'validateToken').") },
|
|
385
|
+
async ({ target_function }) => {
|
|
386
|
+
try {
|
|
387
|
+
const callers = db.findCallers(target_function).map(chunk =>
|
|
388
|
+
`- [${chunk.node_type}] \`${chunk.name}\` in \`${chunk.file_path}\` (lines ${chunk.start_line}–${chunk.end_line})`
|
|
389
|
+
);
|
|
390
|
+
if (callers.length === 0) {
|
|
391
|
+
return { content: [{ type: 'text', text: `✅ Safe to modify: no callers of '${target_function}' found.` }] };
|
|
392
|
+
}
|
|
393
|
+
return {
|
|
394
|
+
content: [{
|
|
395
|
+
type: 'text', text: [
|
|
396
|
+
`# ⚠️ Call Graph: \`${target_function}\``,
|
|
397
|
+
`${callers.length} caller(s) depend on this — review before changing signature:`,
|
|
398
|
+
...callers,
|
|
399
|
+
].join('\n')
|
|
400
|
+
}]
|
|
401
|
+
};
|
|
402
|
+
} catch (err) {
|
|
403
|
+
return { content: [{ type: 'text', text: `Error: ${err.message}` }], isError: true };
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
);
|
|
407
|
+
|
|
408
|
+
// ─── get_repo_map ───────────────────────────────────────────────────────────
|
|
409
|
+
server.tool(
|
|
410
|
+
'get_repo_map',
|
|
411
|
+
'Returns a compact symbol map of the entire codebase grouped by file, ordered by importance (most-imported files first via PageRank). Use this FIRST to orient yourself in an unfamiliar codebase — ~1-2k tokens vs reading every file. Combine with path_filter to focus on a subsystem.',
|
|
412
|
+
{
|
|
413
|
+
path_filter: z.string().optional().describe(
|
|
414
|
+
"Only include files whose path contains this string (e.g. 'auth', 'api/v2', 'src/core')."
|
|
415
|
+
),
|
|
416
|
+
max_files: z.number().int().min(1).max(300).default(80).describe('Max files to include in the map.'),
|
|
417
|
+
sort_by: z.enum(['importance', 'path']).default('importance').describe(
|
|
418
|
+
"'importance' (default): most-imported files first (PageRank). 'path': alphabetical."
|
|
419
|
+
),
|
|
420
|
+
},
|
|
421
|
+
async ({ path_filter, max_files, sort_by }) => {
|
|
422
|
+
try {
|
|
423
|
+
const fileChunks = new Map();
|
|
424
|
+
const filterLower = path_filter ? path_filter.toLowerCase() : null;
|
|
425
|
+
for (const chunk of db.iterateChunks()) {
|
|
426
|
+
if (filterLower && !chunk.file_path.toLowerCase().includes(filterLower)) continue;
|
|
427
|
+
if (chunk.name === 'anonymous' || chunk.name === 'default_export') continue;
|
|
428
|
+
if (!fileChunks.has(chunk.file_path)) fileChunks.set(chunk.file_path, []);
|
|
429
|
+
fileChunks.get(chunk.file_path).push(chunk);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
if (fileChunks.size === 0) {
|
|
433
|
+
return {
|
|
434
|
+
content: [{
|
|
435
|
+
type: 'text', text: path_filter
|
|
436
|
+
? `No files found matching '${path_filter}'. Try a broader filter.`
|
|
437
|
+
: 'Index is empty. Run `npm run mcp:index` first.'
|
|
438
|
+
}]
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
let sortedFiles = Array.from(fileChunks.keys());
|
|
443
|
+
if (sort_by === 'importance') {
|
|
444
|
+
const pr = computePageRank(db.graph);
|
|
445
|
+
sortedFiles.sort((a, b) => (pr.get(b) ?? 0) - (pr.get(a) ?? 0));
|
|
446
|
+
} else {
|
|
447
|
+
sortedFiles.sort();
|
|
448
|
+
}
|
|
449
|
+
sortedFiles = sortedFiles.slice(0, max_files);
|
|
450
|
+
|
|
451
|
+
const totalFiles = fileChunks.size;
|
|
452
|
+
const totalSymbols = Array.from(fileChunks.values()).reduce((s, a) => s + a.length, 0);
|
|
453
|
+
const lines = [
|
|
454
|
+
`# Repo Map — ${totalSymbols} symbols across ${totalFiles} files`,
|
|
455
|
+
path_filter ? `(filtered to '${path_filter}')` : '',
|
|
456
|
+
sortedFiles.length < totalFiles ? `(showing top ${sortedFiles.length} by ${sort_by}; use path_filter to narrow)\n` : '',
|
|
457
|
+
].filter(Boolean);
|
|
458
|
+
|
|
459
|
+
for (const filePath of sortedFiles) {
|
|
460
|
+
const chunks = fileChunks.get(filePath);
|
|
461
|
+
lines.push(`\n${filePath}`);
|
|
462
|
+
const seen = new Set();
|
|
463
|
+
const deduped = [];
|
|
464
|
+
for (const c of chunks) {
|
|
465
|
+
const key = c.name.toLowerCase();
|
|
466
|
+
if (seen.has(key)) continue;
|
|
467
|
+
seen.add(key);
|
|
468
|
+
deduped.push(c);
|
|
469
|
+
}
|
|
470
|
+
for (const c of deduped.slice(0, 8)) {
|
|
471
|
+
const kind = c.node_type.includes('class') ? 'class'
|
|
472
|
+
: c.node_type.includes('interface') ? 'interface'
|
|
473
|
+
: c.node_type.includes('enum') ? 'enum'
|
|
474
|
+
: c.node_type.includes('struct') ? 'struct'
|
|
475
|
+
: c.node_type.includes('trait') ? 'trait' : 'fn';
|
|
476
|
+
const params = c.params?.length
|
|
477
|
+
? `(${c.params.slice(0, 3).join(', ')}${c.params.length > 3 ? ', …' : ''})` : '';
|
|
478
|
+
const ret = c.return_type ? ` → ${c.return_type.slice(0, 40)}` : '';
|
|
479
|
+
lines.push(` ${kind} ${c.name}${params}${ret}`);
|
|
480
|
+
}
|
|
481
|
+
if (deduped.length > 8) lines.push(` … (${deduped.length - 8} more)`);
|
|
482
|
+
}
|
|
483
|
+
if (totalFiles > max_files) {
|
|
484
|
+
lines.push(`\n… ${totalFiles - max_files} more files not shown. Use path_filter or increase max_files.`);
|
|
485
|
+
}
|
|
486
|
+
return { content: [{ type: 'text', text: lines.join('\n') }] };
|
|
487
|
+
} catch (err) {
|
|
488
|
+
return { content: [{ type: 'text', text: `Error: ${err.message}` }], isError: true };
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
);
|
|
492
|
+
|
|
493
|
+
// ─── list_index_stats ───────────────────────────────────────────────────────
|
|
494
|
+
server.tool(
|
|
495
|
+
'list_index_stats',
|
|
496
|
+
'Returns index health: chunk count, embedding status, daemon status, search mode, storage backend, and index freshness.',
|
|
497
|
+
{},
|
|
498
|
+
async () => {
|
|
499
|
+
try {
|
|
500
|
+
const s = db.stats();
|
|
501
|
+
|
|
502
|
+
let indexAge = 'unknown';
|
|
503
|
+
try {
|
|
504
|
+
const ageSec = Math.floor((Date.now() - fs.statSync(artifactPath).mtimeMs) / 1000);
|
|
505
|
+
indexAge = ageSec < 60 ? `${ageSec}s ago` : ageSec < 3600 ? `${Math.floor(ageSec / 60)}m ago` : `${Math.floor(ageSec / 3600)}h ago`;
|
|
506
|
+
} catch { }
|
|
507
|
+
|
|
508
|
+
let daemonStatus = 'not running';
|
|
509
|
+
try {
|
|
510
|
+
if (pidFile && fs.existsSync(pidFile)) {
|
|
511
|
+
const pid = parseInt(fs.readFileSync(pidFile, 'utf-8'), 10);
|
|
512
|
+
process.kill(pid, 0);
|
|
513
|
+
daemonStatus = `running (PID: ${pid})`;
|
|
514
|
+
}
|
|
515
|
+
} catch { daemonStatus = 'not running (stale PID)'; }
|
|
516
|
+
|
|
517
|
+
const searchMode = !embeddingsEnabled
|
|
518
|
+
? '🔤 Lexical only (INDEXER_EMBEDDINGS=off)'
|
|
519
|
+
: s.hasVectors
|
|
520
|
+
? `🧠 Hybrid (semantic + lexical RRF) — vectors: ${s.vectorSource}`
|
|
521
|
+
: '🔤 Lexical only (Ollama unavailable or not yet indexed)';
|
|
522
|
+
|
|
523
|
+
const lines = [
|
|
524
|
+
`# 📊 graph-indexer Index Stats`, '',
|
|
525
|
+
`| Metric | Value |`, `| :--- | :--- |`,
|
|
526
|
+
`| **Storage backend** | ${s.backend === 'sqlite' ? '🗄 SQLite (disk-backed)' : '⚡ In-memory'} |`,
|
|
527
|
+
`| **Chunks** | ${s.chunks} |`,
|
|
528
|
+
`| **Files indexed** | ${s.files} |`,
|
|
529
|
+
`| **Symbols in table** | ${s.symbols} |`,
|
|
530
|
+
`| **Vector entries** | ${s.vectors} |`,
|
|
531
|
+
`| **Search mode** | ${searchMode} |`,
|
|
532
|
+
`| **Lazy vec mode** | ${s.lazyMode ? '✅ Yes (enterprise scale)' : '❌ No (small corpus)'} |`,
|
|
533
|
+
`| **Daemon** | ${daemonStatus} |`,
|
|
534
|
+
`| **Index age** | ${indexAge} |`,
|
|
535
|
+
'', `## Extension Breakdown`,
|
|
536
|
+
...Array.from(s.extCounts.entries()).sort((a, b) => b[1] - a[1]).map(([e, n]) => `- .${e}: ${n} chunks`),
|
|
537
|
+
];
|
|
538
|
+
if (s.chunks === 0) lines.push('', `⚠️ Index empty. Run \`npm run mcp:index\`.`);
|
|
539
|
+
return { content: [{ type: 'text', text: lines.join('\n') }] };
|
|
540
|
+
} catch (err) {
|
|
541
|
+
return { content: [{ type: 'text', text: `Error: ${err.message}` }], isError: true };
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
);
|
|
545
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "graph-indexer",
|
|
3
|
-
"version": "1.0
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "1.2.0",
|
|
4
|
+
"description": "Air-gapped code-retrieval MCP server: AST chunking, hybrid RRF search and cross-language call graph, with an optional zero-dependency SQLite backend for monorepo scale and optional local-LLM semantic enrichment.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "core-engine.mjs",
|
|
7
7
|
"exports": {
|
|
@@ -26,6 +26,14 @@
|
|
|
26
26
|
"scripts": {
|
|
27
27
|
"start": "node ./mcp-server.mjs",
|
|
28
28
|
"test": "node test/run.mjs",
|
|
29
|
+
"test:unit": "node test/unit.mjs",
|
|
30
|
+
"test:sqlite": "node test/sqlite.mjs",
|
|
31
|
+
"test:enrich": "node test/enrichment.mjs",
|
|
32
|
+
"test:mcp": "node test/mcp.mjs",
|
|
33
|
+
"test:scale": "node test/scale.mjs",
|
|
34
|
+
"test:all": "node test/unit.mjs && node test/sqlite.mjs && node test/enrichment.mjs && node test/mcp.mjs && node test/scale.mjs",
|
|
35
|
+
"test:eval": "node test/evaluate.mjs",
|
|
36
|
+
"test:eval:embeddings": "OLLAMA_HOST=http://localhost:11435 node test/evaluate.mjs --embeddings",
|
|
29
37
|
"test:setup": "node test/setup.mjs",
|
|
30
38
|
"test:run": "node test/run.mjs",
|
|
31
39
|
"test:embeddings": "OLLAMA_HOST=http://localhost:11435 node test/run-embeddings.mjs",
|
|
@@ -34,6 +42,8 @@
|
|
|
34
42
|
"test:json": "node test/run.mjs --json",
|
|
35
43
|
"test:reset": "rm -rf test/fixtures && node test/setup.mjs",
|
|
36
44
|
"mcp:index": "node indexer.mjs --repo .",
|
|
45
|
+
"mcp:index:sqlite": "node indexer.mjs --repo . --use-sqlite",
|
|
46
|
+
"mcp:index:enriched": "node indexer.mjs --repo . --llm-enrichment",
|
|
37
47
|
"mcp:watch": "node watch-daemon.mjs",
|
|
38
48
|
"mcp:start": "node mcp-server.mjs"
|
|
39
49
|
},
|
|
@@ -77,16 +87,16 @@
|
|
|
77
87
|
"access": "public"
|
|
78
88
|
},
|
|
79
89
|
"optionalDependencies": {
|
|
80
|
-
"hnswlib-node": "
|
|
81
|
-
"tree-sitter-c-sharp": "
|
|
90
|
+
"hnswlib-node": "3.0.0",
|
|
91
|
+
"tree-sitter-c-sharp": "0.21.3",
|
|
82
92
|
"tree-sitter-css": "0.21.1",
|
|
83
93
|
"tree-sitter-go": "0.21.0",
|
|
84
|
-
"tree-sitter-java": "
|
|
94
|
+
"tree-sitter-java": "0.21.0",
|
|
85
95
|
"tree-sitter-javascript": "0.21.4",
|
|
86
|
-
"tree-sitter-kotlin": "
|
|
96
|
+
"tree-sitter-kotlin": "0.3.8",
|
|
87
97
|
"tree-sitter-php": "0.23.1",
|
|
88
98
|
"tree-sitter-python": "0.21.0",
|
|
89
|
-
"tree-sitter-ruby": "
|
|
99
|
+
"tree-sitter-ruby": "0.23.1",
|
|
90
100
|
"tree-sitter-rust": "0.21.0",
|
|
91
101
|
"tree-sitter-typescript": "0.21.2"
|
|
92
102
|
}
|