@monoes/cli 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.
@@ -1,66 +1,100 @@
1
1
  /**
2
2
  * Graphify MCP Tools (compiled)
3
- * Bridges graphify's knowledge graph into monobrain's MCP tool surface.
3
+ *
4
+ * Bridges @monobrain/graph's knowledge graph into monobrain's MCP tool surface.
5
+ * Graph is built automatically on `monobrain init` and stored at
6
+ * .monobrain/graph/graph.json (legacy: graphify-out/graph.json).
4
7
  */
5
- import { spawnSync } from 'child_process';
6
8
  import { existsSync, readFileSync } from 'fs';
7
9
  import { join, resolve } from 'path';
8
10
  import { getProjectCwd } from './types.js';
9
11
 
10
- function getGraphPath(cwd) {
11
- return resolve(join(cwd, 'graphify-out', 'graph.json'));
12
- }
12
+ // ── Path helpers ──────────────────────────────────────────────────────────────
13
13
 
14
- function isGraphifyInstalled() {
15
- try {
16
- const result = spawnSync('python', ['-c', 'import graphify; print("ok")'], {
17
- timeout: 5000, encoding: 'utf-8',
18
- });
19
- return result.status === 0 && result.stdout.includes('ok');
20
- }
21
- catch { return false; }
14
+ function getGraphPath(cwd) {
15
+ const nativePath = resolve(join(cwd, '.monobrain', 'graph', 'graph.json'));
16
+ const legacyPath = resolve(join(cwd, 'graphify-out', 'graph.json'));
17
+ if (existsSync(nativePath)) return nativePath;
18
+ if (existsSync(legacyPath)) return legacyPath;
19
+ return nativePath;
22
20
  }
23
21
 
24
22
  function graphExists(cwd) {
25
23
  return existsSync(getGraphPath(cwd));
26
24
  }
27
25
 
28
- function runGraphifyPython(snippet, cwd) {
26
+ // ── Graph loading ─────────────────────────────────────────────────────────────
27
+
28
+ async function loadKnowledgeGraph(cwd) {
29
29
  const graphPath = getGraphPath(cwd);
30
- const script = `
31
- import json, sys
32
- sys.path.insert(0, '${cwd.replace(/'/g, "\\'")}')
33
- graph_path = '${graphPath.replace(/'/g, "\\'")}'
34
- ${snippet}
35
- `.trim();
36
- const result = spawnSync('python', ['-c', script], {
37
- timeout: 15000,
38
- encoding: 'utf-8',
39
- cwd,
40
- });
41
- if (result.status !== 0) {
42
- throw new Error(result.stderr?.trim() || 'graphify python call failed');
30
+ let rawNodes = [];
31
+ let rawEdges = [];
32
+
33
+ try {
34
+ const { loadGraph } = await import('@monoes/graph');
35
+ const loaded = loadGraph(graphPath);
36
+ rawNodes = loaded.nodes;
37
+ rawEdges = loaded.edges;
38
+ } catch {
39
+ const data = JSON.parse(readFileSync(graphPath, 'utf-8'));
40
+ rawNodes = data.nodes || [];
41
+ rawEdges = data.links || data.edges || [];
43
42
  }
44
- const output = result.stdout?.trim();
45
- if (!output) throw new Error('No output from graphify');
46
- return JSON.parse(output);
43
+
44
+ const nodes = new Map();
45
+ for (const n of rawNodes) nodes.set(n.id, n);
46
+
47
+ const adj = new Map();
48
+ const radj = new Map();
49
+ const degree = new Map();
50
+ for (const n of rawNodes) {
51
+ adj.set(n.id, []);
52
+ radj.set(n.id, []);
53
+ degree.set(n.id, 0);
54
+ }
55
+ for (const e of rawEdges) {
56
+ adj.get(e.source)?.push(e.target);
57
+ radj.get(e.target)?.push(e.source);
58
+ degree.set(e.source, (degree.get(e.source) ?? 0) + 1);
59
+ degree.set(e.target, (degree.get(e.target) ?? 0) + 1);
60
+ }
61
+
62
+ return { nodes, adj, radj, edges: rawEdges, degree, graphPath };
63
+ }
64
+
65
+ function nodeOut(g, id) {
66
+ const d = g.nodes.get(id) ?? { id };
67
+ return {
68
+ id,
69
+ label: d.label ?? id,
70
+ file: d.source_file ?? '',
71
+ location: d.source_location ?? '',
72
+ community: d.community ?? null,
73
+ degree: g.degree.get(id) ?? 0,
74
+ file_type: d.file_type ?? '',
75
+ };
76
+ }
77
+
78
+ function scoreNode(g, id, terms) {
79
+ const d = g.nodes.get(id);
80
+ if (!d) return 0;
81
+ const label = (d.label ?? id).toLowerCase();
82
+ const file = (d.source_file ?? '').toLowerCase();
83
+ return terms.reduce((s, t) => {
84
+ if (label.includes(t)) s += 1;
85
+ if (file.includes(t)) s += 0.5;
86
+ return s;
87
+ }, 0);
47
88
  }
48
89
 
49
- const LOAD_GRAPH_SNIPPET = `
50
- import json
51
- from pathlib import Path
52
- import networkx as nx
53
- from networkx.readwrite import json_graph
54
- data = json.loads(Path(graph_path).read_text())
55
- try:
56
- G = json_graph.node_link_graph(data, edges='links')
57
- except TypeError:
58
- G = json_graph.node_link_graph(data)
59
- `;
90
+ // ── Tool Definitions ──────────────────────────────────────────────────────────
60
91
 
61
92
  export const graphifyBuildTool = {
62
93
  name: 'graphify_build',
63
- description: 'Build (or rebuild) the knowledge graph for a project directory. Extracts AST structure from code files, semantic relationships from docs, and clusters them into communities. Run this first before using other graphify tools.',
94
+ description: 'Build (or rebuild) the knowledge graph for a project directory. ' +
95
+ 'Extracts AST structure from code files, semantic relationships from docs, ' +
96
+ 'and clusters them into communities. Run this first before using other graphify tools. ' +
97
+ 'Code-only changes are fast (tree-sitter, no LLM). Doc changes require an LLM call.',
64
98
  category: 'graphify',
65
99
  tags: ['knowledge-graph', 'codebase', 'architecture', 'analysis'],
66
100
  inputSchema: {
@@ -73,30 +107,37 @@ export const graphifyBuildTool = {
73
107
  handler: async (params) => {
74
108
  const cwd = getProjectCwd();
75
109
  const targetPath = params.path || cwd;
76
- if (!isGraphifyInstalled()) {
77
- return { error: true, message: 'graphify not installed. Run: pip install graphifyy[mcp]', hint: 'After installing, call graphify_build again.' };
78
- }
79
110
  try {
80
- const result = spawnSync('python', ['-m', 'graphify', targetPath, ...(params.codeOnly ? ['--update'] : [])], {
81
- timeout: 120000, encoding: 'utf-8', cwd,
111
+ const { buildGraph } = await import('@monoes/graph');
112
+ const outputDir = join(targetPath, '.monobrain', 'graph');
113
+ const result = await buildGraph(targetPath, {
114
+ codeOnly: Boolean(params.codeOnly),
115
+ outputDir,
82
116
  });
83
- const graphPath = getGraphPath(cwd);
84
- const built = existsSync(graphPath);
85
117
  return {
86
- success: result.status === 0,
87
- graphPath: built ? graphPath : null,
88
- output: result.stdout?.trim().slice(-500) || '',
89
- error: result.status !== 0 ? result.stderr?.trim().slice(-500) : undefined,
90
- message: built ? `Knowledge graph built at ${graphPath}` : 'Build may have failed — graph.json not found',
118
+ success: true,
119
+ graphPath: result.graphPath,
120
+ filesProcessed: result.filesProcessed,
121
+ nodes: result.analysis.stats.nodes,
122
+ edges: result.analysis.stats.edges,
123
+ message: `Knowledge graph built at ${result.graphPath}`,
124
+ };
125
+ } catch (err) {
126
+ return {
127
+ error: true,
128
+ message: String(err),
129
+ hint: '@monoes/graph package not available — ensure it is installed and built.',
91
130
  };
92
131
  }
93
- catch (err) { return { error: true, message: String(err) }; }
94
132
  },
95
133
  };
96
134
 
97
135
  export const graphifyQueryTool = {
98
136
  name: 'graphify_query',
99
- description: 'Search the knowledge graph with a natural language question or keywords. Returns relevant nodes and edges as structured context — use this instead of reading many files when you want to understand how components relate.',
137
+ description: 'Search the knowledge graph with a natural language question or keywords. ' +
138
+ 'Returns relevant nodes and edges as structured context — use this instead of reading ' +
139
+ 'many files when you want to understand how components relate. ' +
140
+ 'BFS mode gives broad context; DFS traces a specific call path.',
100
141
  category: 'graphify',
101
142
  tags: ['knowledge-graph', 'search', 'architecture', 'codebase'],
102
143
  inputSchema: {
@@ -112,82 +153,128 @@ export const graphifyQueryTool = {
112
153
  handler: async (params) => {
113
154
  const cwd = getProjectCwd();
114
155
  if (!graphExists(cwd)) return { error: true, message: 'No graph found. Run graphify_build first.', hint: `Expected: ${getGraphPath(cwd)}` };
156
+ const question = params.question;
157
+ const mode = params.mode || 'bfs';
158
+ const depth = params.depth || 3;
115
159
  try {
116
- return runGraphifyPython(`
117
- ${LOAD_GRAPH_SNIPPET}
118
- question = ${JSON.stringify(params.question)}
119
- terms = [t.lower() for t in question.split() if len(t) > 2]
120
- mode = ${JSON.stringify(params.mode || 'bfs')}
121
- depth = ${params.depth || 3}
122
- scored = []
123
- for nid, d in G.nodes(data=True):
124
- label = d.get('label', '').lower()
125
- source = d.get('source_file', '').lower()
126
- score = sum(1 for t in terms if t in label) + sum(0.5 for t in terms if t in source)
127
- if score > 0:
128
- scored.append((score, nid))
129
- scored.sort(reverse=True)
130
- start_nodes = [nid for _, nid in scored[:5]]
131
- if not start_nodes:
132
- start_nodes = sorted(G.nodes(), key=lambda n: G.degree(n), reverse=True)[:3]
133
- visited = set(start_nodes)
134
- frontier = set(start_nodes)
135
- edges_seen = []
136
- if mode == 'bfs':
137
- for _ in range(depth):
138
- next_frontier = set()
139
- for n in frontier:
140
- for nbr in G.neighbors(n):
141
- if nbr not in visited:
142
- next_frontier.add(nbr)
143
- edges_seen.append((n, nbr))
144
- visited.update(next_frontier)
145
- frontier = next_frontier
146
- else:
147
- stack = [(n, 0) for n in reversed(start_nodes)]
148
- while stack:
149
- node, d = stack.pop()
150
- if node in visited or d > depth: continue
151
- visited.add(node)
152
- for nbr in G.neighbors(node):
153
- if nbr not in visited:
154
- stack.append((nbr, d + 1))
155
- edges_seen.append((node, nbr))
156
- nodes_out = [{'id': nid, 'label': G.nodes[nid].get('label', nid), 'file': G.nodes[nid].get('source_file', ''), 'location': G.nodes[nid].get('source_location', ''), 'community': G.nodes[nid].get('community'), 'degree': G.degree(nid), 'file_type': G.nodes[nid].get('file_type', '')} for nid in sorted(visited, key=lambda n: G.degree(n), reverse=True)]
157
- edges_out = [{'from': G.nodes[u].get('label', u), 'to': G.nodes[v].get('label', v), 'relation': G.edges.get((u, v), {}).get('relation', ''), 'confidence': G.edges.get((u, v), {}).get('confidence', '')} for u, v in edges_seen if u in visited and v in visited]
158
- print(json.dumps({'question': question, 'mode': mode, 'depth': depth, 'nodes': nodes_out[:60], 'edges': edges_out[:80], 'total_nodes': len(visited), 'total_edges': len(edges_seen)}))
159
- `, cwd);
160
- }
161
- catch (err) { return { error: true, message: String(err) }; }
160
+ const g = await loadKnowledgeGraph(cwd);
161
+ const terms = question.toLowerCase().split(/\s+/).filter(t => t.length > 2);
162
+
163
+ let startNodes = [];
164
+ if (terms.length > 0) {
165
+ const scored = [];
166
+ for (const id of g.nodes.keys()) {
167
+ const s = scoreNode(g, id, terms);
168
+ if (s > 0) scored.push([s, id]);
169
+ }
170
+ scored.sort((a, b) => b[0] - a[0]);
171
+ startNodes = scored.slice(0, 5).map(([, id]) => id);
172
+ }
173
+ if (startNodes.length === 0) {
174
+ startNodes = [...g.nodes.keys()]
175
+ .sort((a, b) => (g.degree.get(b) ?? 0) - (g.degree.get(a) ?? 0))
176
+ .slice(0, 3);
177
+ }
178
+
179
+ const visited = new Set(startNodes);
180
+ const edgesSeen = [];
181
+
182
+ if (mode === 'bfs') {
183
+ let frontier = new Set(startNodes);
184
+ for (let d = 0; d < depth; d++) {
185
+ const next = new Set();
186
+ for (const n of frontier) {
187
+ for (const nbr of g.adj.get(n) ?? []) {
188
+ if (!visited.has(nbr)) {
189
+ next.add(nbr);
190
+ edgesSeen.push([n, nbr]);
191
+ }
192
+ }
193
+ }
194
+ for (const n of next) visited.add(n);
195
+ frontier = next;
196
+ }
197
+ } else {
198
+ const stack = startNodes.map(n => [n, 0]);
199
+ while (stack.length > 0) {
200
+ const [node, d] = stack.pop();
201
+ if (visited.has(node) && d > 0) continue;
202
+ if (d > depth) continue;
203
+ visited.add(node);
204
+ for (const nbr of g.adj.get(node) ?? []) {
205
+ if (!visited.has(nbr)) {
206
+ stack.push([nbr, d + 1]);
207
+ edgesSeen.push([node, nbr]);
208
+ }
209
+ }
210
+ }
211
+ }
212
+
213
+ const nodesOut = [...visited]
214
+ .sort((a, b) => (g.degree.get(b) ?? 0) - (g.degree.get(a) ?? 0))
215
+ .slice(0, 60)
216
+ .map(id => nodeOut(g, id));
217
+
218
+ const edgeLookup = new Map();
219
+ for (const e of g.edges) edgeLookup.set(`${e.source}__${e.target}`, e);
220
+
221
+ const edgesOut = edgesSeen
222
+ .filter(([u, v]) => visited.has(u) && visited.has(v))
223
+ .slice(0, 80)
224
+ .map(([u, v]) => {
225
+ const e = edgeLookup.get(`${u}__${v}`) ?? {};
226
+ return {
227
+ from: g.nodes.get(u)?.label ?? u,
228
+ to: g.nodes.get(v)?.label ?? v,
229
+ relation: e.relation ?? '',
230
+ confidence: e.confidence ?? '',
231
+ };
232
+ });
233
+
234
+ return { question, mode, depth, nodes: nodesOut, edges: edgesOut, total_nodes: visited.size, total_edges: edgesSeen.length };
235
+ } catch (err) { return { error: true, message: String(err) }; }
162
236
  },
163
237
  };
164
238
 
165
239
  export const graphifyGodNodesTool = {
166
240
  name: 'graphify_god_nodes',
167
- description: 'Return the most connected nodes in the knowledge graph — the core abstractions and central concepts of the codebase. Use this at the start of any architectural analysis.',
241
+ description: 'Return the most connected nodes in the knowledge graph — the core abstractions ' +
242
+ 'and central concepts of the codebase. Use this at the start of any architectural analysis ' +
243
+ 'to understand what the most important components are before diving into details.',
168
244
  category: 'graphify',
169
245
  tags: ['knowledge-graph', 'architecture', 'abstractions', 'codebase'],
170
246
  inputSchema: { type: 'object', properties: { topN: { type: 'integer', default: 15 } } },
171
247
  handler: async (params) => {
172
248
  const cwd = getProjectCwd();
173
249
  if (!graphExists(cwd)) return { error: true, message: 'No graph found. Run graphify_build first.' };
250
+ const topN = params.topN || 15;
174
251
  try {
175
- return runGraphifyPython(`
176
- ${LOAD_GRAPH_SNIPPET}
177
- top_n = ${params.topN || 15}
178
- degree_map = dict(G.degree())
179
- sorted_nodes = sorted(degree_map, key=degree_map.get, reverse=True)[:top_n]
180
- nodes_out = [{'label': G.nodes[nid].get('label', nid), 'degree': degree_map[nid], 'file': G.nodes[nid].get('source_file', ''), 'location': G.nodes[nid].get('source_location', ''), 'community': G.nodes[nid].get('community'), 'file_type': G.nodes[nid].get('file_type', ''), 'neighbors': [G.nodes[n].get('label', n) for n in G.neighbors(nid)][:8]} for nid in sorted_nodes]
181
- print(json.dumps({'god_nodes': nodes_out, 'total_nodes': G.number_of_nodes()}))
182
- `, cwd);
183
- }
184
- catch (err) { return { error: true, message: String(err) }; }
252
+ const g = await loadKnowledgeGraph(cwd);
253
+ const sortedIds = [...g.nodes.keys()]
254
+ .sort((a, b) => (g.degree.get(b) ?? 0) - (g.degree.get(a) ?? 0))
255
+ .slice(0, topN);
256
+ const godNodes = sortedIds.map(id => {
257
+ const d = g.nodes.get(id) ?? { id };
258
+ const neighbors = (g.adj.get(id) ?? []).slice(0, 8).map(nid => g.nodes.get(nid)?.label ?? nid);
259
+ return {
260
+ label: d.label ?? id,
261
+ degree: g.degree.get(id) ?? 0,
262
+ file: d.source_file ?? '',
263
+ location: d.source_location ?? '',
264
+ community: d.community ?? null,
265
+ file_type: d.file_type ?? '',
266
+ neighbors,
267
+ };
268
+ });
269
+ return { god_nodes: godNodes, total_nodes: g.nodes.size };
270
+ } catch (err) { return { error: true, message: String(err) }; }
185
271
  },
186
272
  };
187
273
 
188
274
  export const graphifyGetNodeTool = {
189
275
  name: 'graphify_get_node',
190
- description: 'Get all details for a specific concept/node in the knowledge graph: source location, community, all relationships, and confidence levels.',
276
+ description: 'Get all details for a specific concept/node in the knowledge graph: ' +
277
+ 'its source location, community, all relationships, and confidence levels.',
191
278
  category: 'graphify',
192
279
  tags: ['knowledge-graph', 'node', 'details'],
193
280
  inputSchema: { type: 'object', properties: { label: { type: 'string', description: 'Node label or ID (case-insensitive)' } }, required: ['label'] },
@@ -195,28 +282,50 @@ export const graphifyGetNodeTool = {
195
282
  const cwd = getProjectCwd();
196
283
  if (!graphExists(cwd)) return { error: true, message: 'No graph found. Run graphify_build first.' };
197
284
  try {
198
- return runGraphifyPython(`
199
- ${LOAD_GRAPH_SNIPPET}
200
- term = ${JSON.stringify(params.label.toLowerCase())}
201
- matches = [nid for nid, d in G.nodes(data=True) if term in d.get('label', '').lower() or term == nid.lower()]
202
- if not matches:
203
- print(json.dumps({'error': 'Node not found', 'searched': term}))
204
- else:
205
- nid = matches[0]
206
- d = dict(G.nodes[nid])
207
- edges = []
208
- for u, v, ed in G.edges(nid, data=True):
209
- edges.append({'direction': 'outgoing', 'to': G.nodes[v].get('label', v), 'relation': ed.get('relation', ''), 'confidence': ed.get('confidence', ''), 'confidence_score': ed.get('confidence_score')})
210
- print(json.dumps({'id': nid, 'label': d.get('label', nid), 'file': d.get('source_file', ''), 'location': d.get('source_location', ''), 'community': d.get('community'), 'file_type': d.get('file_type', ''), 'degree': G.degree(nid), 'edges': edges[:40], 'all_matches': [G.nodes[m].get('label', m) for m in matches[:10]]}))
211
- `, cwd);
212
- }
213
- catch (err) { return { error: true, message: String(err) }; }
285
+ const g = await loadKnowledgeGraph(cwd);
286
+ const term = params.label.toLowerCase();
287
+ const matches = [...g.nodes.entries()]
288
+ .filter(([id, d]) => (d.label ?? id).toLowerCase().includes(term) || id.toLowerCase() === term)
289
+ .sort(([aId], [bId]) => (g.degree.get(bId) ?? 0) - (g.degree.get(aId) ?? 0))
290
+ .map(([id]) => id);
291
+ if (matches.length === 0) return { error: 'Node not found', searched: term };
292
+ const id = matches[0];
293
+ const d = g.nodes.get(id) ?? { id };
294
+ const edgeLookup = new Map();
295
+ for (const e of g.edges) edgeLookup.set(`${e.source}__${e.target}`, e);
296
+ const outEdges = (g.adj.get(id) ?? []).slice(0, 40).map(tgt => {
297
+ const e = edgeLookup.get(`${id}__${tgt}`) ?? {};
298
+ return { direction: 'outgoing', to: g.nodes.get(tgt)?.label ?? tgt, relation: e.relation ?? '', confidence: e.confidence ?? '', confidence_score: e.confidence_score ?? null };
299
+ });
300
+ const inEdges = (g.radj.get(id) ?? []).slice(0, 40).map(src => {
301
+ const e = edgeLookup.get(`${src}__${id}`) ?? {};
302
+ return { direction: 'incoming', from: g.nodes.get(src)?.label ?? src, relation: e.relation ?? '', confidence: e.confidence ?? '' };
303
+ });
304
+ const knownKeys = new Set(['label', 'source_file', 'source_location', 'community', 'file_type', 'id']);
305
+ const attributes = {};
306
+ for (const [k, v] of Object.entries(d)) {
307
+ if (!knownKeys.has(k)) attributes[k] = v;
308
+ }
309
+ return {
310
+ id,
311
+ label: d.label ?? id,
312
+ file: d.source_file ?? '',
313
+ location: d.source_location ?? '',
314
+ community: d.community ?? null,
315
+ file_type: d.file_type ?? '',
316
+ degree: g.degree.get(id) ?? 0,
317
+ attributes,
318
+ edges: [...outEdges, ...inEdges],
319
+ all_matches: matches.slice(0, 10).map(m => g.nodes.get(m)?.label ?? m),
320
+ };
321
+ } catch (err) { return { error: true, message: String(err) }; }
214
322
  },
215
323
  };
216
324
 
217
325
  export const graphifyShortestPathTool = {
218
326
  name: 'graphify_shortest_path',
219
- description: 'Find the shortest relationship path between two concepts in the knowledge graph. Reveals coupling chains between any two components.',
327
+ description: 'Find the shortest relationship path between two concepts in the knowledge graph. ' +
328
+ 'Reveals coupling chains between any two components.',
220
329
  category: 'graphify',
221
330
  tags: ['knowledge-graph', 'path', 'dependencies', 'coupling'],
222
331
  inputSchema: {
@@ -232,54 +341,83 @@ export const graphifyShortestPathTool = {
232
341
  const cwd = getProjectCwd();
233
342
  if (!graphExists(cwd)) return { error: true, message: 'No graph found. Run graphify_build first.' };
234
343
  try {
235
- return runGraphifyPython(`
236
- ${LOAD_GRAPH_SNIPPET}
237
- import networkx as nx
238
- def find_node(G, term):
239
- t = term.lower()
240
- matches = [nid for nid, d in G.nodes(data=True) if t in d.get('label', '').lower() or t == nid.lower()]
241
- return sorted(matches, key=lambda n: G.degree(n), reverse=True)
242
- src_nodes = find_node(G, ${JSON.stringify(params.source)})
243
- tgt_nodes = find_node(G, ${JSON.stringify(params.target)})
244
- max_hops = ${params.maxHops || 8}
245
- if not src_nodes:
246
- print(json.dumps({'error': f'Source not found: ${params.source}'}))
247
- elif not tgt_nodes:
248
- print(json.dumps({'error': f'Target not found: ${params.target}'}))
249
- else:
250
- UG = G.to_undirected() if G.is_directed() else G
251
- best_path = None
252
- best_len = max_hops + 1
253
- for src in src_nodes[:3]:
254
- for tgt in tgt_nodes[:3]:
255
- try:
256
- p = nx.shortest_path(UG, src, tgt)
257
- if len(p) < best_len:
258
- best_len = len(p)
259
- best_path = p
260
- except nx.NetworkXNoPath: pass
261
- if best_path is None:
262
- print(json.dumps({'found': False, 'message': 'No path found within hop limit'}))
263
- else:
264
- steps = []
265
- for i, nid in enumerate(best_path):
266
- d = G.nodes[nid]
267
- step = {'label': d.get('label', nid), 'file': d.get('source_file', ''), 'location': d.get('source_location', '')}
268
- if i < len(best_path) - 1:
269
- edge = G.edges.get((nid, best_path[i+1]), G.edges.get((best_path[i+1], nid), {}))
270
- step['next_relation'] = edge.get('relation', '')
271
- step['confidence'] = edge.get('confidence', '')
272
- steps.append(step)
273
- print(json.dumps({'found': True, 'hops': len(best_path) - 1, 'path': steps}))
274
- `, cwd);
275
- }
276
- catch (err) { return { error: true, message: String(err) }; }
344
+ const g = await loadKnowledgeGraph(cwd);
345
+ const maxHops = params.maxHops || 8;
346
+
347
+ function findNodes(term) {
348
+ const t = term.toLowerCase();
349
+ return [...g.nodes.entries()]
350
+ .filter(([id, d]) => (d.label ?? id).toLowerCase().includes(t) || id.toLowerCase() === t)
351
+ .sort(([aId], [bId]) => (g.degree.get(bId) ?? 0) - (g.degree.get(aId) ?? 0))
352
+ .map(([id]) => id);
353
+ }
354
+
355
+ const srcNodes = findNodes(params.source);
356
+ const tgtNodes = findNodes(params.target);
357
+ if (!srcNodes.length) return { error: true, message: `Source not found: ${params.source}` };
358
+ if (!tgtNodes.length) return { error: true, message: `Target not found: ${params.target}` };
359
+
360
+ function bfsPath(start, end) {
361
+ const prev = new Map();
362
+ const queue = [start];
363
+ const visited = new Set([start]);
364
+ while (queue.length > 0) {
365
+ const cur = queue.shift();
366
+ if (cur === end) {
367
+ const path = [];
368
+ let node = end;
369
+ while (node !== undefined) { path.unshift(node); node = prev.get(node); }
370
+ return path.length - 1 <= maxHops ? path : null;
371
+ }
372
+ const nbrs = [...(g.adj.get(cur) ?? []), ...(g.radj.get(cur) ?? [])];
373
+ for (const nbr of nbrs) {
374
+ if (!visited.has(nbr)) {
375
+ visited.add(nbr);
376
+ prev.set(nbr, cur);
377
+ if (queue.length < 100000) queue.push(nbr);
378
+ }
379
+ }
380
+ }
381
+ return null;
382
+ }
383
+
384
+ let bestPath = null;
385
+ for (const src of srcNodes.slice(0, 3)) {
386
+ for (const tgt of tgtNodes.slice(0, 3)) {
387
+ const p = bfsPath(src, tgt);
388
+ if (p && (!bestPath || p.length < bestPath.length)) bestPath = p;
389
+ }
390
+ }
391
+
392
+ if (!bestPath) return { found: false, message: `No path within ${maxHops} hops between "${params.source}" and "${params.target}"` };
393
+
394
+ const edgeLookup = new Map();
395
+ for (const e of g.edges) {
396
+ edgeLookup.set(`${e.source}__${e.target}`, e);
397
+ edgeLookup.set(`${e.target}__${e.source}`, e);
398
+ }
399
+
400
+ const steps = bestPath.map((id, i) => {
401
+ const d = g.nodes.get(id) ?? { id };
402
+ const step = { label: d.label ?? id, file: d.source_file ?? '', location: d.source_location ?? '' };
403
+ if (i < bestPath.length - 1) {
404
+ const nextId = bestPath[i + 1];
405
+ const e = edgeLookup.get(`${id}__${nextId}`) ?? edgeLookup.get(`${nextId}__${id}`) ?? {};
406
+ step.next_relation = e.relation ?? '';
407
+ step.confidence = e.confidence ?? '';
408
+ }
409
+ return step;
410
+ });
411
+
412
+ return { found: true, hops: bestPath.length - 1, path: steps };
413
+ } catch (err) { return { error: true, message: String(err) }; }
277
414
  },
278
415
  };
279
416
 
280
417
  export const graphifyGetCommunityTool = {
281
418
  name: 'graphify_community',
282
- description: 'Get all nodes in a specific community cluster. Communities are groups of tightly related components. Use graphify_stats first to see community count.',
419
+ description: 'Get all nodes in a specific community cluster. Communities are groups of ' +
420
+ 'tightly related components detected by graph clustering. Use graphify_stats first to see community count.',
283
421
  category: 'graphify',
284
422
  tags: ['knowledge-graph', 'community', 'clusters', 'subsystems'],
285
423
  inputSchema: { type: 'object', properties: { communityId: { type: 'integer', description: 'Community ID (0 = largest)' } }, required: ['communityId'] },
@@ -287,67 +425,96 @@ export const graphifyGetCommunityTool = {
287
425
  const cwd = getProjectCwd();
288
426
  if (!graphExists(cwd)) return { error: true, message: 'No graph found. Run graphify_build first.' };
289
427
  try {
290
- return runGraphifyPython(`
291
- ${LOAD_GRAPH_SNIPPET}
292
- cid = ${params.communityId}
293
- members = [{'label': d.get('label', nid), 'file': d.get('source_file', ''), 'location': d.get('source_location', ''), 'degree': G.degree(nid), 'file_type': d.get('file_type', '')} for nid, d in G.nodes(data=True) if d.get('community') == cid]
294
- members.sort(key=lambda x: x['degree'], reverse=True)
295
- external_edges = []
296
- for nid, d in G.nodes(data=True):
297
- if d.get('community') == cid:
298
- for nbr in G.neighbors(nid):
299
- if G.nodes[nbr].get('community') != cid:
300
- ed = G.edges.get((nid, nbr), {})
301
- external_edges.append({'from': d.get('label', nid), 'to': G.nodes[nbr].get('label', nbr), 'to_community': G.nodes[nbr].get('community'), 'relation': ed.get('relation', '')})
302
- print(json.dumps({'community_id': cid, 'member_count': len(members), 'members': members[:50], 'external_connections': external_edges[:30]}))
303
- `, cwd);
304
- }
305
- catch (err) { return { error: true, message: String(err) }; }
428
+ const g = await loadKnowledgeGraph(cwd);
429
+ const cid = params.communityId;
430
+ const members = [...g.nodes.entries()]
431
+ .filter(([, d]) => d.community === cid)
432
+ .sort(([aId], [bId]) => (g.degree.get(bId) ?? 0) - (g.degree.get(aId) ?? 0))
433
+ .slice(0, 50)
434
+ .map(([id, d]) => ({
435
+ label: d.label ?? id,
436
+ file: d.source_file ?? '',
437
+ location: d.source_location ?? '',
438
+ degree: g.degree.get(id) ?? 0,
439
+ file_type: d.file_type ?? '',
440
+ }));
441
+ const edgeLookup = new Map();
442
+ for (const e of g.edges) edgeLookup.set(`${e.source}__${e.target}`, e);
443
+ const externalEdges = [];
444
+ for (const [id, d] of g.nodes.entries()) {
445
+ if (d.community !== cid) continue;
446
+ for (const nbr of g.adj.get(id) ?? []) {
447
+ const nbrD = g.nodes.get(nbr);
448
+ if (nbrD?.community !== cid) {
449
+ const e = edgeLookup.get(`${id}__${nbr}`) ?? {};
450
+ externalEdges.push({ from: d.label ?? id, to: nbrD?.label ?? nbr, to_community: nbrD?.community ?? null, relation: e.relation ?? '' });
451
+ }
452
+ }
453
+ }
454
+ return { community_id: cid, member_count: members.length, members, external_connections: externalEdges.slice(0, 30) };
455
+ } catch (err) { return { error: true, message: String(err) }; }
306
456
  },
307
457
  };
308
458
 
309
459
  export const graphifyStatsTool = {
310
460
  name: 'graphify_stats',
311
- description: 'Get summary statistics for the knowledge graph: node count, edge count, community count, confidence breakdown, and top god nodes. Use this first to understand graph size.',
461
+ description: 'Get summary statistics for the knowledge graph: node count, edge count, ' +
462
+ 'community count, confidence breakdown, and top god nodes. Use this first to understand graph size.',
312
463
  category: 'graphify',
313
464
  tags: ['knowledge-graph', 'stats', 'overview'],
314
465
  inputSchema: { type: 'object', properties: {} },
315
466
  handler: async (_params) => {
316
467
  const cwd = getProjectCwd();
317
468
  if (!graphExists(cwd)) return { error: true, message: 'No graph found. Run graphify_build first.', hint: `Expected: ${getGraphPath(cwd)}` };
318
- if (!isGraphifyInstalled()) {
319
- try {
320
- const raw = JSON.parse(readFileSync(getGraphPath(cwd), 'utf-8'));
321
- const nodes = raw.nodes || [];
322
- const edges = raw.links || raw.edges || [];
323
- return { nodes: nodes.length, edges: edges.length, note: 'graphify Python package not installed — basic stats only', install: 'pip install graphifyy[mcp]' };
324
- }
325
- catch (err) { return { error: true, message: String(err) }; }
326
- }
327
469
  try {
328
- return runGraphifyPython(`
329
- ${LOAD_GRAPH_SNIPPET}
330
- from collections import Counter
331
- communities = {}
332
- for nid, d in G.nodes(data=True):
333
- c = d.get('community')
334
- if c is not None:
335
- communities.setdefault(int(c), []).append(nid)
336
- confidence_counts = Counter(d.get('confidence', 'UNKNOWN') for u, v, d in G.edges(data=True))
337
- relation_counts = Counter(d.get('relation', 'unknown') for u, v, d in G.edges(data=True))
338
- file_types = Counter(d.get('file_type', 'unknown') for nid, d in G.nodes(data=True))
339
- degree_map = dict(G.degree())
340
- top_nodes = sorted(degree_map, key=degree_map.get, reverse=True)[:5]
341
- print(json.dumps({'nodes': G.number_of_nodes(), 'edges': G.number_of_edges(), 'communities': len(communities), 'community_sizes': {str(k): len(v) for k, v in sorted(communities.items(), key=lambda x: len(x[1]), reverse=True)[:10]}, 'confidence': dict(confidence_counts), 'top_relations': dict(relation_counts.most_common(10)), 'file_types': dict(file_types), 'graph_path': graph_path, 'top_god_nodes': [G.nodes[n].get('label', n) for n in top_nodes], 'is_directed': G.is_directed()}))
342
- `, cwd);
343
- }
344
- catch (err) { return { error: true, message: String(err) }; }
470
+ const g = await loadKnowledgeGraph(cwd);
471
+ const communities = new Map();
472
+ for (const d of g.nodes.values()) {
473
+ if (d.community != null) communities.set(d.community, (communities.get(d.community) ?? 0) + 1);
474
+ }
475
+ const communitySizes = {};
476
+ [...communities.entries()].sort((a, b) => b[1] - a[1]).slice(0, 10)
477
+ .forEach(([cid, count]) => { communitySizes[String(cid)] = count; });
478
+ const confidenceCounts = {};
479
+ const relationCounts = {};
480
+ const fileTypeCounts = {};
481
+ for (const e of g.edges) {
482
+ const conf = e.confidence ?? 'UNKNOWN';
483
+ confidenceCounts[conf] = (confidenceCounts[conf] ?? 0) + 1;
484
+ const rel = e.relation ?? 'unknown';
485
+ relationCounts[rel] = (relationCounts[rel] ?? 0) + 1;
486
+ }
487
+ for (const d of g.nodes.values()) {
488
+ const ft = d.file_type ?? 'unknown';
489
+ fileTypeCounts[ft] = (fileTypeCounts[ft] ?? 0) + 1;
490
+ }
491
+ const topRelations = Object.entries(relationCounts)
492
+ .sort((a, b) => b[1] - a[1]).slice(0, 10)
493
+ .reduce((acc, [k, v]) => { acc[k] = v; return acc; }, {});
494
+ const topGodNodes = [...g.nodes.keys()]
495
+ .sort((a, b) => (g.degree.get(b) ?? 0) - (g.degree.get(a) ?? 0))
496
+ .slice(0, 5)
497
+ .map(id => g.nodes.get(id)?.label ?? id);
498
+ return {
499
+ nodes: g.nodes.size,
500
+ edges: g.edges.length,
501
+ communities: communities.size,
502
+ community_sizes: communitySizes,
503
+ confidence: confidenceCounts,
504
+ top_relations: topRelations,
505
+ file_types: fileTypeCounts,
506
+ graph_path: g.graphPath,
507
+ top_god_nodes: topGodNodes,
508
+ is_directed: true,
509
+ };
510
+ } catch (err) { return { error: true, message: String(err) }; }
345
511
  },
346
512
  };
347
513
 
348
514
  export const graphifySurprisesTool = {
349
515
  name: 'graphify_surprises',
350
- description: 'Find surprising connections between components in different communities with strong relationships. These unexpected couplings often reveal hidden dependencies or important architectural patterns.',
516
+ description: 'Find surprising connections between components in different communities with strong relationships. ' +
517
+ 'These unexpected couplings often reveal hidden dependencies or important architectural patterns.',
351
518
  category: 'graphify',
352
519
  tags: ['knowledge-graph', 'architecture', 'coupling', 'surprises'],
353
520
  inputSchema: { type: 'object', properties: { topN: { type: 'integer', default: 10 } } },
@@ -355,20 +522,31 @@ export const graphifySurprisesTool = {
355
522
  const cwd = getProjectCwd();
356
523
  if (!graphExists(cwd)) return { error: true, message: 'No graph found. Run graphify_build first.' };
357
524
  try {
358
- return runGraphifyPython(`
359
- ${LOAD_GRAPH_SNIPPET}
360
- top_n = ${params.topN || 10}
361
- surprises = []
362
- for u, v, d in G.edges(data=True):
363
- cu = G.nodes[u].get('community')
364
- cv = G.nodes[v].get('community')
365
- if cu is not None and cv is not None and cu != cv:
366
- surprises.append({'score': G.degree(u) * G.degree(v), 'from': G.nodes[u].get('label', u), 'from_community': cu, 'from_file': G.nodes[u].get('source_file', ''), 'to': G.nodes[v].get('label', v), 'to_community': cv, 'to_file': G.nodes[v].get('source_file', ''), 'relation': d.get('relation', ''), 'confidence': d.get('confidence', '')})
367
- surprises.sort(key=lambda x: x['score'], reverse=True)
368
- print(json.dumps({'surprises': surprises[:top_n], 'total_cross_community_edges': len(surprises)}))
369
- `, cwd);
370
- }
371
- catch (err) { return { error: true, message: String(err) }; }
525
+ const g = await loadKnowledgeGraph(cwd);
526
+ const topN = params.topN || 10;
527
+ const surprises = [];
528
+ for (const e of g.edges) {
529
+ const uD = g.nodes.get(e.source);
530
+ const vD = g.nodes.get(e.target);
531
+ const cu = uD?.community ?? null;
532
+ const cv = vD?.community ?? null;
533
+ if (cu != null && cv != null && cu !== cv) {
534
+ surprises.push({
535
+ score: (g.degree.get(e.source) ?? 0) * (g.degree.get(e.target) ?? 0),
536
+ from: uD?.label ?? e.source,
537
+ from_community: cu,
538
+ from_file: uD?.source_file ?? '',
539
+ to: vD?.label ?? e.target,
540
+ to_community: cv,
541
+ to_file: vD?.source_file ?? '',
542
+ relation: e.relation ?? '',
543
+ confidence: e.confidence ?? '',
544
+ });
545
+ }
546
+ }
547
+ surprises.sort((a, b) => b.score - a.score);
548
+ return { surprises: surprises.slice(0, topN), total_cross_community_edges: surprises.length };
549
+ } catch (err) { return { error: true, message: String(err) }; }
372
550
  },
373
551
  };
374
552