@monoes/cli 1.1.0 → 1.2.1

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.
Files changed (36) hide show
  1. package/dist/src/appliance/rvfa-format.d.ts +1 -1
  2. package/dist/src/appliance/rvfa-format.d.ts.map +1 -1
  3. package/dist/src/commands/init.d.ts.map +1 -1
  4. package/dist/src/commands/init.js +2 -0
  5. package/dist/src/commands/init.js.map +1 -1
  6. package/dist/src/init/executor.d.ts +0 -3
  7. package/dist/src/init/executor.d.ts.map +1 -1
  8. package/dist/src/init/executor.js +50 -10
  9. package/dist/src/init/executor.js.map +1 -1
  10. package/dist/src/init/helpers-generator.d.ts.map +1 -1
  11. package/dist/src/init/helpers-generator.js +38 -1
  12. package/dist/src/init/helpers-generator.js.map +1 -1
  13. package/dist/src/init/mcp-generator.d.ts.map +1 -1
  14. package/dist/src/init/mcp-generator.js +2 -12
  15. package/dist/src/init/mcp-generator.js.map +1 -1
  16. package/dist/src/init/statusline-generator.d.ts +0 -3
  17. package/dist/src/init/statusline-generator.d.ts.map +1 -1
  18. package/dist/src/init/statusline-generator.js +9 -4
  19. package/dist/src/init/statusline-generator.js.map +1 -1
  20. package/dist/src/init/types.d.ts +4 -0
  21. package/dist/src/init/types.d.ts.map +1 -1
  22. package/dist/src/init/types.js +5 -0
  23. package/dist/src/init/types.js.map +1 -1
  24. package/dist/src/mcp-tools/graphify-tools.d.ts +52 -0
  25. package/dist/src/mcp-tools/graphify-tools.d.ts.map +1 -0
  26. package/dist/src/mcp-tools/graphify-tools.js +712 -258
  27. package/dist/src/mcp-tools/graphify-tools.js.map +1 -0
  28. package/dist/src/mcp-tools/index.d.ts +1 -0
  29. package/dist/src/mcp-tools/index.d.ts.map +1 -1
  30. package/dist/src/mcp-tools/index.js.map +1 -1
  31. package/dist/src/update/checker.d.ts.map +1 -1
  32. package/dist/src/update/checker.js +2 -5
  33. package/dist/src/update/checker.js.map +1 -1
  34. package/dist/src/workflow/dsl-schema.d.ts +1 -1
  35. package/dist/tsconfig.tsbuildinfo +1 -1
  36. package/package.json +2 -1
@@ -1,222 +1,450 @@
1
1
  /**
2
- * Graphify MCP Tools (compiled)
3
- * Bridges graphify's knowledge graph into monobrain's MCP tool surface.
2
+ * Graphify MCP Tools
3
+ *
4
+ * Bridges @monobrain/graph's knowledge graph into monobrain's MCP tool surface.
5
+ * Agents can query the codebase knowledge graph without reading files —
6
+ * god_nodes(), query_graph(), shortest_path() give structural understanding
7
+ * in milliseconds vs. reading dozens of source files.
8
+ *
9
+ * Graph is built automatically on `monobrain init` and stored at
10
+ * .monobrain/graph/graph.json (legacy: graphify-out/graph.json).
11
+ * Rebuild manually: call graphify_build via MCP.
4
12
  */
5
- import { spawnSync } from 'child_process';
6
13
  import { existsSync, readFileSync } from 'fs';
7
14
  import { join, resolve } from 'path';
8
15
  import { getProjectCwd } from './types.js';
9
-
16
+ // ── Path helpers ──────────────────────────────────────────────────────────────
17
+ /** Resolve graph path: prefer native monobrain path, fall back to legacy graphify path. */
10
18
  function getGraphPath(cwd) {
11
- return resolve(join(cwd, 'graphify-out', 'graph.json'));
19
+ const nativePath = resolve(join(cwd, '.monobrain', 'graph', 'graph.json'));
20
+ const legacyPath = resolve(join(cwd, 'graphify-out', 'graph.json'));
21
+ if (existsSync(nativePath))
22
+ return nativePath;
23
+ if (existsSync(legacyPath))
24
+ return legacyPath;
25
+ return nativePath; // return expected path even if not yet built
12
26
  }
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; }
22
- }
23
-
24
27
  function graphExists(cwd) {
25
28
  return existsSync(getGraphPath(cwd));
26
29
  }
27
-
28
- function runGraphifyPython(snippet, cwd) {
30
+ /**
31
+ * Load the knowledge graph.
32
+ * Tries @monobrain/graph's loadGraph first; falls back to parsing raw JSON.
33
+ */
34
+ async function loadKnowledgeGraph(cwd) {
29
35
  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');
36
+ let rawNodes = [];
37
+ let rawEdges = [];
38
+ try {
39
+ // Prefer @monobrain/graph's loader which handles format normalization.
40
+ const { loadGraph } = await import('@monoes/graph');
41
+ const loaded = loadGraph(graphPath);
42
+ rawNodes = loaded.nodes;
43
+ rawEdges = loaded.edges;
44
+ }
45
+ catch {
46
+ // Fallback: parse JSON directly
47
+ const data = JSON.parse(readFileSync(graphPath, 'utf-8'));
48
+ rawNodes = data.nodes || [];
49
+ rawEdges = data.links || data.edges || [];
50
+ }
51
+ // Build in-memory graph structures
52
+ const nodes = new Map();
53
+ for (const n of rawNodes) {
54
+ nodes.set(n.id, n);
55
+ }
56
+ const adj = new Map();
57
+ const radj = new Map();
58
+ const degree = new Map();
59
+ for (const n of rawNodes) {
60
+ adj.set(n.id, []);
61
+ radj.set(n.id, []);
62
+ degree.set(n.id, 0);
43
63
  }
44
- const output = result.stdout?.trim();
45
- if (!output) throw new Error('No output from graphify');
46
- return JSON.parse(output);
64
+ for (const e of rawEdges) {
65
+ const src = e.source;
66
+ const tgt = e.target;
67
+ adj.get(src)?.push(tgt);
68
+ radj.get(tgt)?.push(src);
69
+ degree.set(src, (degree.get(src) ?? 0) + 1);
70
+ degree.set(tgt, (degree.get(tgt) ?? 0) + 1);
71
+ }
72
+ return { nodes, adj, radj, edges: rawEdges, degree, graphPath };
73
+ }
74
+ // ── Shared output helpers ─────────────────────────────────────────────────────
75
+ function nodeOut(g, id) {
76
+ const d = g.nodes.get(id) ?? { id };
77
+ return {
78
+ id,
79
+ label: d.label ?? id,
80
+ file: d.source_file ?? '',
81
+ location: d.source_location ?? '',
82
+ community: d.community ?? null,
83
+ degree: g.degree.get(id) ?? 0,
84
+ file_type: d.file_type ?? '',
85
+ };
47
86
  }
48
-
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
- `;
60
-
87
+ /** Score a node against search terms. */
88
+ function scoreNode(g, id, terms) {
89
+ const d = g.nodes.get(id);
90
+ if (!d)
91
+ return 0;
92
+ const label = (d.label ?? id).toLowerCase();
93
+ const file = (d.source_file ?? '').toLowerCase();
94
+ return terms.reduce((s, t) => {
95
+ if (label.includes(t))
96
+ s += 1;
97
+ if (file.includes(t))
98
+ s += 0.5;
99
+ return s;
100
+ }, 0);
101
+ }
102
+ // ── Tool Definitions ──────────────────────────────────────────────────────────
103
+ /**
104
+ * Build or rebuild the knowledge graph for a directory.
105
+ */
61
106
  export const graphifyBuildTool = {
62
107
  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.',
108
+ description: 'Build (or rebuild) the knowledge graph for a project directory. ' +
109
+ 'Extracts AST structure from code files, semantic relationships from docs, ' +
110
+ 'and clusters them into communities. Run this first before using other graphify tools. ' +
111
+ 'Code-only changes are fast (tree-sitter, no LLM). Doc changes require an LLM call.',
64
112
  category: 'graphify',
65
113
  tags: ['knowledge-graph', 'codebase', 'architecture', 'analysis'],
66
114
  inputSchema: {
67
115
  type: 'object',
68
116
  properties: {
69
- path: { type: 'string', description: 'Path to analyse (defaults to current project root)' },
70
- codeOnly: { type: 'boolean', description: 'Only re-extract changed code files — fast rebuild', default: false },
117
+ path: {
118
+ type: 'string',
119
+ description: 'Path to analyse (defaults to current project root)',
120
+ },
121
+ codeOnly: {
122
+ type: 'boolean',
123
+ description: 'Only re-extract changed code files — no LLM, fast rebuild',
124
+ default: false,
125
+ },
71
126
  },
72
127
  },
73
128
  handler: async (params) => {
74
129
  const cwd = getProjectCwd();
75
130
  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
131
  try {
80
- const result = spawnSync('python', ['-m', 'graphify', targetPath, ...(params.codeOnly ? ['--update'] : [])], {
81
- timeout: 120000, encoding: 'utf-8', cwd,
132
+ const { buildGraph } = await import('@monoes/graph');
133
+ const outputDir = join(targetPath, '.monobrain', 'graph');
134
+ const result = await buildGraph(targetPath, {
135
+ codeOnly: Boolean(params.codeOnly),
136
+ outputDir,
82
137
  });
83
- const graphPath = getGraphPath(cwd);
84
- const built = existsSync(graphPath);
85
138
  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',
139
+ success: true,
140
+ graphPath: result.graphPath,
141
+ filesProcessed: result.filesProcessed,
142
+ nodes: result.analysis.stats.nodes,
143
+ edges: result.analysis.stats.edges,
144
+ message: `Knowledge graph built at ${result.graphPath}`,
145
+ };
146
+ }
147
+ catch (err) {
148
+ return {
149
+ error: true,
150
+ message: String(err),
151
+ hint: '@monobrain/graph package not available — ensure it is installed and built.',
91
152
  };
92
153
  }
93
- catch (err) { return { error: true, message: String(err) }; }
94
154
  },
95
155
  };
96
-
156
+ /**
157
+ * Query the knowledge graph with natural language.
158
+ */
97
159
  export const graphifyQueryTool = {
98
160
  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.',
161
+ description: 'Search the knowledge graph with a natural language question or keywords. ' +
162
+ 'Returns relevant nodes and edges as structured context — use this instead of reading ' +
163
+ 'many files when you want to understand how components relate. ' +
164
+ 'BFS mode gives broad context; DFS traces a specific call path.',
100
165
  category: 'graphify',
101
166
  tags: ['knowledge-graph', 'search', 'architecture', 'codebase'],
102
167
  inputSchema: {
103
168
  type: 'object',
104
169
  properties: {
105
- question: { type: 'string', description: 'Natural language question or keyword' },
106
- mode: { type: 'string', enum: ['bfs', 'dfs'], default: 'bfs' },
107
- depth: { type: 'integer', default: 3 },
108
- tokenBudget: { type: 'integer', default: 2000 },
170
+ question: {
171
+ type: 'string',
172
+ description: 'Natural language question or keyword (e.g. "authentication flow", "how does caching work")',
173
+ },
174
+ mode: {
175
+ type: 'string',
176
+ enum: ['bfs', 'dfs'],
177
+ default: 'bfs',
178
+ description: 'bfs = broad context, dfs = trace specific path',
179
+ },
180
+ depth: {
181
+ type: 'integer',
182
+ default: 3,
183
+ description: 'Traversal depth (1–6)',
184
+ },
185
+ tokenBudget: {
186
+ type: 'integer',
187
+ default: 2000,
188
+ description: 'Approximate max output tokens',
189
+ },
109
190
  },
110
191
  required: ['question'],
111
192
  },
112
193
  handler: async (params) => {
113
194
  const cwd = getProjectCwd();
114
- if (!graphExists(cwd)) return { error: true, message: 'No graph found. Run graphify_build first.', hint: `Expected: ${getGraphPath(cwd)}` };
195
+ if (!graphExists(cwd)) {
196
+ return {
197
+ error: true,
198
+ message: 'No graph found. Run graphify_build first.',
199
+ hint: `Expected: ${getGraphPath(cwd)}`,
200
+ };
201
+ }
202
+ const question = params.question;
203
+ const mode = params.mode || 'bfs';
204
+ const depth = params.depth || 3;
115
205
  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);
206
+ const g = await loadKnowledgeGraph(cwd);
207
+ const terms = question.toLowerCase().split(/\s+/).filter(t => t.length > 2);
208
+ // Score nodes; fall back to highest-degree nodes if no match
209
+ let startNodes = [];
210
+ if (terms.length > 0) {
211
+ const scored = [];
212
+ for (const id of g.nodes.keys()) {
213
+ const s = scoreNode(g, id, terms);
214
+ if (s > 0)
215
+ scored.push([s, id]);
216
+ }
217
+ scored.sort((a, b) => b[0] - a[0]);
218
+ startNodes = scored.slice(0, 5).map(([, id]) => id);
219
+ }
220
+ if (startNodes.length === 0) {
221
+ startNodes = [...g.nodes.keys()]
222
+ .sort((a, b) => (g.degree.get(b) ?? 0) - (g.degree.get(a) ?? 0))
223
+ .slice(0, 3);
224
+ }
225
+ const visited = new Set(startNodes);
226
+ const edgesSeen = [];
227
+ if (mode === 'bfs') {
228
+ let frontier = new Set(startNodes);
229
+ for (let d = 0; d < depth; d++) {
230
+ const next = new Set();
231
+ for (const n of frontier) {
232
+ for (const nbr of g.adj.get(n) ?? []) {
233
+ if (!visited.has(nbr)) {
234
+ next.add(nbr);
235
+ edgesSeen.push([n, nbr]);
236
+ }
237
+ }
238
+ }
239
+ for (const n of next)
240
+ visited.add(n);
241
+ frontier = next;
242
+ }
243
+ }
244
+ else {
245
+ // DFS
246
+ const stack = startNodes.map(n => [n, 0]);
247
+ while (stack.length > 0) {
248
+ const [node, d] = stack.pop();
249
+ if (visited.has(node) && d > 0)
250
+ continue;
251
+ if (d > depth)
252
+ continue;
253
+ visited.add(node);
254
+ for (const nbr of g.adj.get(node) ?? []) {
255
+ if (!visited.has(nbr)) {
256
+ stack.push([nbr, d + 1]);
257
+ edgesSeen.push([node, nbr]);
258
+ }
259
+ }
260
+ }
261
+ }
262
+ const nodesOut = [...visited]
263
+ .sort((a, b) => (g.degree.get(b) ?? 0) - (g.degree.get(a) ?? 0))
264
+ .slice(0, 60)
265
+ .map(id => nodeOut(g, id));
266
+ // Build edge lookup for attribute access
267
+ const edgeLookup = new Map();
268
+ for (const e of g.edges) {
269
+ edgeLookup.set(`${e.source}__${e.target}`, e);
270
+ }
271
+ const edgesOut = edgesSeen
272
+ .filter(([u, v]) => visited.has(u) && visited.has(v))
273
+ .slice(0, 80)
274
+ .map(([u, v]) => {
275
+ const e = edgeLookup.get(`${u}__${v}`) ?? {};
276
+ return {
277
+ from: (g.nodes.get(u)?.label ?? u),
278
+ to: (g.nodes.get(v)?.label ?? v),
279
+ relation: e.relation ?? '',
280
+ confidence: e.confidence ?? '',
281
+ };
282
+ });
283
+ return {
284
+ question,
285
+ mode,
286
+ depth,
287
+ nodes: nodesOut,
288
+ edges: edgesOut,
289
+ total_nodes: visited.size,
290
+ total_edges: edgesSeen.length,
291
+ };
292
+ }
293
+ catch (err) {
294
+ return { error: true, message: String(err) };
160
295
  }
161
- catch (err) { return { error: true, message: String(err) }; }
162
296
  },
163
297
  };
164
-
298
+ /**
299
+ * Get the most connected (god) nodes — the core abstractions.
300
+ */
165
301
  export const graphifyGodNodesTool = {
166
302
  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.',
303
+ description: 'Return the most connected nodes in the knowledge graph — the core abstractions ' +
304
+ 'and central concepts of the codebase. Use this at the start of any architectural analysis ' +
305
+ 'to understand what the most important components are before diving into details.',
168
306
  category: 'graphify',
169
307
  tags: ['knowledge-graph', 'architecture', 'abstractions', 'codebase'],
170
- inputSchema: { type: 'object', properties: { topN: { type: 'integer', default: 15 } } },
308
+ inputSchema: {
309
+ type: 'object',
310
+ properties: {
311
+ topN: {
312
+ type: 'integer',
313
+ default: 15,
314
+ description: 'Number of god nodes to return',
315
+ },
316
+ },
317
+ },
171
318
  handler: async (params) => {
172
319
  const cwd = getProjectCwd();
173
- if (!graphExists(cwd)) return { error: true, message: 'No graph found. Run graphify_build first.' };
320
+ if (!graphExists(cwd)) {
321
+ return {
322
+ error: true,
323
+ message: 'No graph found. Run graphify_build first.',
324
+ };
325
+ }
326
+ const topN = params.topN || 15;
174
327
  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);
328
+ const g = await loadKnowledgeGraph(cwd);
329
+ const sortedIds = [...g.nodes.keys()]
330
+ .sort((a, b) => (g.degree.get(b) ?? 0) - (g.degree.get(a) ?? 0))
331
+ .slice(0, topN);
332
+ const godNodes = sortedIds.map(id => {
333
+ const d = g.nodes.get(id) ?? { id };
334
+ const neighbors = (g.adj.get(id) ?? [])
335
+ .slice(0, 8)
336
+ .map(nid => g.nodes.get(nid)?.label ?? nid);
337
+ return {
338
+ label: d.label ?? id,
339
+ degree: g.degree.get(id) ?? 0,
340
+ file: d.source_file ?? '',
341
+ location: d.source_location ?? '',
342
+ community: d.community ?? null,
343
+ file_type: d.file_type ?? '',
344
+ neighbors,
345
+ };
346
+ });
347
+ return { god_nodes: godNodes, total_nodes: g.nodes.size };
348
+ }
349
+ catch (err) {
350
+ return { error: true, message: String(err) };
183
351
  }
184
- catch (err) { return { error: true, message: String(err) }; }
185
352
  },
186
353
  };
187
-
354
+ /**
355
+ * Get full details for a specific node.
356
+ */
188
357
  export const graphifyGetNodeTool = {
189
358
  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.',
359
+ description: 'Get all details for a specific concept/node in the knowledge graph: ' +
360
+ 'its source location, community, all relationships, and confidence levels. ' +
361
+ 'Use this when you need to deeply understand one specific component.',
191
362
  category: 'graphify',
192
363
  tags: ['knowledge-graph', 'node', 'details'],
193
- inputSchema: { type: 'object', properties: { label: { type: 'string', description: 'Node label or ID (case-insensitive)' } }, required: ['label'] },
364
+ inputSchema: {
365
+ type: 'object',
366
+ properties: {
367
+ label: {
368
+ type: 'string',
369
+ description: 'Node label or ID to look up (case-insensitive)',
370
+ },
371
+ },
372
+ required: ['label'],
373
+ },
194
374
  handler: async (params) => {
195
375
  const cwd = getProjectCwd();
196
- if (!graphExists(cwd)) return { error: true, message: 'No graph found. Run graphify_build first.' };
376
+ if (!graphExists(cwd)) {
377
+ return { error: true, message: 'No graph found. Run graphify_build first.' };
378
+ }
197
379
  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);
380
+ const g = await loadKnowledgeGraph(cwd);
381
+ const term = params.label.toLowerCase();
382
+ const matches = [...g.nodes.entries()]
383
+ .filter(([id, d]) => (d.label ?? id).toLowerCase().includes(term) || id.toLowerCase() === term)
384
+ .sort(([aId], [bId]) => (g.degree.get(bId) ?? 0) - (g.degree.get(aId) ?? 0))
385
+ .map(([id]) => id);
386
+ if (matches.length === 0) {
387
+ return { error: 'Node not found', searched: term };
388
+ }
389
+ const id = matches[0];
390
+ const d = g.nodes.get(id) ?? { id };
391
+ // Build edge lookup
392
+ const edgeLookup = new Map();
393
+ for (const e of g.edges) {
394
+ edgeLookup.set(`${e.source}__${e.target}`, e);
395
+ }
396
+ const outEdges = (g.adj.get(id) ?? []).slice(0, 40).map(tgt => {
397
+ const e = edgeLookup.get(`${id}__${tgt}`) ?? {};
398
+ return {
399
+ direction: 'outgoing',
400
+ to: g.nodes.get(tgt)?.label ?? tgt,
401
+ relation: e.relation ?? '',
402
+ confidence: e.confidence ?? '',
403
+ confidence_score: e.confidence_score ?? null,
404
+ };
405
+ });
406
+ const inEdges = (g.radj.get(id) ?? []).slice(0, 40).map(src => {
407
+ const e = edgeLookup.get(`${src}__${id}`) ?? {};
408
+ return {
409
+ direction: 'incoming',
410
+ from: g.nodes.get(src)?.label ?? src,
411
+ relation: e.relation ?? '',
412
+ confidence: e.confidence ?? '',
413
+ };
414
+ });
415
+ // Strip well-known fields from attributes output to avoid duplication
416
+ const knownKeys = new Set(['label', 'source_file', 'source_location', 'community', 'file_type', 'id']);
417
+ const attributes = {};
418
+ for (const [k, v] of Object.entries(d)) {
419
+ if (!knownKeys.has(k))
420
+ attributes[k] = v;
421
+ }
422
+ return {
423
+ id,
424
+ label: d.label ?? id,
425
+ file: d.source_file ?? '',
426
+ location: d.source_location ?? '',
427
+ community: d.community ?? null,
428
+ file_type: d.file_type ?? '',
429
+ degree: g.degree.get(id) ?? 0,
430
+ attributes,
431
+ edges: [...outEdges, ...inEdges],
432
+ all_matches: matches.slice(0, 10).map(m => g.nodes.get(m)?.label ?? m),
433
+ };
434
+ }
435
+ catch (err) {
436
+ return { error: true, message: String(err) };
212
437
  }
213
- catch (err) { return { error: true, message: String(err) }; }
214
438
  },
215
439
  };
216
-
440
+ /**
441
+ * Find shortest path between two concepts.
442
+ */
217
443
  export const graphifyShortestPathTool = {
218
444
  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.',
445
+ description: 'Find the shortest relationship path between two concepts in the knowledge graph. ' +
446
+ 'Use this to trace how component A depends on or relates to component B, ' +
447
+ 'revealing hidden coupling chains (e.g. "how does the router connect to the database?").',
220
448
  category: 'graphify',
221
449
  tags: ['knowledge-graph', 'path', 'dependencies', 'coupling'],
222
450
  inputSchema: {
@@ -224,154 +452,379 @@ export const graphifyShortestPathTool = {
224
452
  properties: {
225
453
  source: { type: 'string', description: 'Source concept label' },
226
454
  target: { type: 'string', description: 'Target concept label' },
227
- maxHops: { type: 'integer', default: 8 },
455
+ maxHops: { type: 'integer', default: 8, description: 'Maximum hops to search' },
228
456
  },
229
457
  required: ['source', 'target'],
230
458
  },
231
459
  handler: async (params) => {
232
460
  const cwd = getProjectCwd();
233
- if (!graphExists(cwd)) return { error: true, message: 'No graph found. Run graphify_build first.' };
461
+ if (!graphExists(cwd)) {
462
+ return { error: true, message: 'No graph found. Run graphify_build first.' };
463
+ }
234
464
  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);
465
+ const g = await loadKnowledgeGraph(cwd);
466
+ const maxHops = params.maxHops || 8;
467
+ /** Find node ids matching a search term, sorted by degree descending. */
468
+ function findNodes(term) {
469
+ const t = term.toLowerCase();
470
+ return [...g.nodes.entries()]
471
+ .filter(([id, d]) => (d.label ?? id).toLowerCase().includes(t) || id.toLowerCase() === t)
472
+ .sort(([aId], [bId]) => (g.degree.get(bId) ?? 0) - (g.degree.get(aId) ?? 0))
473
+ .map(([id]) => id);
474
+ }
475
+ const srcNodes = findNodes(params.source);
476
+ const tgtNodes = findNodes(params.target);
477
+ if (srcNodes.length === 0) {
478
+ return { error: true, message: `Source not found: ${params.source}` };
479
+ }
480
+ if (tgtNodes.length === 0) {
481
+ return { error: true, message: `Target not found: ${params.target}` };
482
+ }
483
+ // BFS on undirected graph (use both adj and radj as neighbours)
484
+ function bfsPath(start, end) {
485
+ const prev = new Map();
486
+ const queue = [start];
487
+ const visited = new Set([start]);
488
+ while (queue.length > 0) {
489
+ const cur = queue.shift();
490
+ if (cur === end) {
491
+ // Reconstruct path
492
+ const path = [];
493
+ let node = end;
494
+ while (node !== undefined) {
495
+ path.unshift(node);
496
+ node = prev.get(node);
497
+ }
498
+ return path.length - 1 <= maxHops ? path : null;
499
+ }
500
+ // Treat edges as undirected
501
+ const nbrs = [...(g.adj.get(cur) ?? []), ...(g.radj.get(cur) ?? [])];
502
+ for (const nbr of nbrs) {
503
+ if (!visited.has(nbr)) {
504
+ visited.add(nbr);
505
+ prev.set(nbr, cur);
506
+ if (queue.length < 100000)
507
+ queue.push(nbr);
508
+ }
509
+ }
510
+ }
511
+ return null;
512
+ }
513
+ let bestPath = null;
514
+ for (const src of srcNodes.slice(0, 3)) {
515
+ for (const tgt of tgtNodes.slice(0, 3)) {
516
+ const p = bfsPath(src, tgt);
517
+ if (p && (!bestPath || p.length < bestPath.length)) {
518
+ bestPath = p;
519
+ }
520
+ }
521
+ }
522
+ if (!bestPath) {
523
+ return {
524
+ found: false,
525
+ message: `No path within ${maxHops} hops between "${params.source}" and "${params.target}"`,
526
+ };
527
+ }
528
+ // Build edge lookup
529
+ const edgeLookup = new Map();
530
+ for (const e of g.edges) {
531
+ edgeLookup.set(`${e.source}__${e.target}`, e);
532
+ edgeLookup.set(`${e.target}__${e.source}`, e); // bidirectional lookup
533
+ }
534
+ const steps = bestPath.map((id, i) => {
535
+ const d = g.nodes.get(id) ?? { id };
536
+ const step = {
537
+ label: d.label ?? id,
538
+ file: d.source_file ?? '',
539
+ location: d.source_location ?? '',
540
+ };
541
+ if (i < bestPath.length - 1) {
542
+ const nextId = bestPath[i + 1];
543
+ const e = edgeLookup.get(`${id}__${nextId}`) ?? edgeLookup.get(`${nextId}__${id}`) ?? {};
544
+ step.next_relation = e.relation ?? '';
545
+ step.confidence = e.confidence ?? '';
546
+ }
547
+ return step;
548
+ });
549
+ return { found: true, hops: bestPath.length - 1, path: steps };
550
+ }
551
+ catch (err) {
552
+ return { error: true, message: String(err) };
275
553
  }
276
- catch (err) { return { error: true, message: String(err) }; }
277
554
  },
278
555
  };
279
-
556
+ /**
557
+ * Get all nodes in a community (cluster of related components).
558
+ */
280
559
  export const graphifyGetCommunityTool = {
281
560
  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.',
561
+ description: 'Get all nodes in a specific community cluster. Communities are groups of ' +
562
+ 'tightly related components detected by graph clustering. Use graph_stats first to ' +
563
+ 'see community count, then explore communities to understand subsystem boundaries.',
283
564
  category: 'graphify',
284
565
  tags: ['knowledge-graph', 'community', 'clusters', 'subsystems'],
285
- inputSchema: { type: 'object', properties: { communityId: { type: 'integer', description: 'Community ID (0 = largest)' } }, required: ['communityId'] },
566
+ inputSchema: {
567
+ type: 'object',
568
+ properties: {
569
+ communityId: {
570
+ type: 'integer',
571
+ description: 'Community ID (0 = largest community)',
572
+ },
573
+ },
574
+ required: ['communityId'],
575
+ },
286
576
  handler: async (params) => {
287
577
  const cwd = getProjectCwd();
288
- if (!graphExists(cwd)) return { error: true, message: 'No graph found. Run graphify_build first.' };
578
+ if (!graphExists(cwd)) {
579
+ return { error: true, message: 'No graph found. Run graphify_build first.' };
580
+ }
289
581
  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);
582
+ const g = await loadKnowledgeGraph(cwd);
583
+ const cid = params.communityId;
584
+ const members = [...g.nodes.entries()]
585
+ .filter(([, d]) => d.community === cid)
586
+ .sort(([aId], [bId]) => (g.degree.get(bId) ?? 0) - (g.degree.get(aId) ?? 0))
587
+ .slice(0, 50)
588
+ .map(([id, d]) => ({
589
+ label: d.label ?? id,
590
+ file: d.source_file ?? '',
591
+ location: d.source_location ?? '',
592
+ degree: g.degree.get(id) ?? 0,
593
+ file_type: d.file_type ?? '',
594
+ }));
595
+ // Build edge lookup
596
+ const edgeLookup = new Map();
597
+ for (const e of g.edges) {
598
+ edgeLookup.set(`${e.source}__${e.target}`, e);
599
+ }
600
+ const externalEdges = [];
601
+ for (const [id, d] of g.nodes.entries()) {
602
+ if (d.community !== cid)
603
+ continue;
604
+ for (const nbr of g.adj.get(id) ?? []) {
605
+ const nbrD = g.nodes.get(nbr);
606
+ if (nbrD?.community !== cid) {
607
+ const e = edgeLookup.get(`${id}__${nbr}`) ?? {};
608
+ externalEdges.push({
609
+ from: d.label ?? id,
610
+ to: nbrD?.label ?? nbr,
611
+ to_community: nbrD?.community ?? null,
612
+ relation: e.relation ?? '',
613
+ });
614
+ }
615
+ }
616
+ }
617
+ return {
618
+ community_id: cid,
619
+ member_count: members.length,
620
+ members,
621
+ external_connections: externalEdges.slice(0, 30),
622
+ };
623
+ }
624
+ catch (err) {
625
+ return { error: true, message: String(err) };
304
626
  }
305
- catch (err) { return { error: true, message: String(err) }; }
306
627
  },
307
628
  };
308
-
629
+ /**
630
+ * Get graph statistics: node/edge counts, communities, confidence breakdown.
631
+ */
309
632
  export const graphifyStatsTool = {
310
633
  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.',
634
+ description: 'Get summary statistics for the knowledge graph: node count, edge count, ' +
635
+ 'community count, confidence breakdown (EXTRACTED/INFERRED/AMBIGUOUS), ' +
636
+ 'and top god nodes. Use this first to understand graph size and structure.',
312
637
  category: 'graphify',
313
638
  tags: ['knowledge-graph', 'stats', 'overview'],
314
- inputSchema: { type: 'object', properties: {} },
639
+ inputSchema: {
640
+ type: 'object',
641
+ properties: {},
642
+ },
315
643
  handler: async (_params) => {
316
644
  const cwd = getProjectCwd();
317
- 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) }; }
645
+ if (!graphExists(cwd)) {
646
+ return {
647
+ error: true,
648
+ message: 'No graph found. Run graphify_build first.',
649
+ hint: `Expected: ${getGraphPath(cwd)}`,
650
+ };
326
651
  }
327
652
  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);
653
+ const g = await loadKnowledgeGraph(cwd);
654
+ // Community sizes
655
+ const communities = new Map();
656
+ for (const d of g.nodes.values()) {
657
+ if (d.community != null) {
658
+ communities.set(d.community, (communities.get(d.community) ?? 0) + 1);
659
+ }
660
+ }
661
+ const communitySizes = {};
662
+ [...communities.entries()]
663
+ .sort((a, b) => b[1] - a[1])
664
+ .slice(0, 10)
665
+ .forEach(([cid, count]) => { communitySizes[String(cid)] = count; });
666
+ // Confidence and relation counts
667
+ const confidenceCounts = {};
668
+ const relationCounts = {};
669
+ const fileTypeCounts = {};
670
+ for (const e of g.edges) {
671
+ const conf = e.confidence ?? 'UNKNOWN';
672
+ confidenceCounts[conf] = (confidenceCounts[conf] ?? 0) + 1;
673
+ const rel = e.relation ?? 'unknown';
674
+ relationCounts[rel] = (relationCounts[rel] ?? 0) + 1;
675
+ }
676
+ for (const d of g.nodes.values()) {
677
+ const ft = d.file_type ?? 'unknown';
678
+ fileTypeCounts[ft] = (fileTypeCounts[ft] ?? 0) + 1;
679
+ }
680
+ const topRelations = Object.entries(relationCounts)
681
+ .sort((a, b) => b[1] - a[1])
682
+ .slice(0, 10)
683
+ .reduce((acc, [k, v]) => { acc[k] = v; return acc; }, {});
684
+ const topGodNodes = [...g.nodes.keys()]
685
+ .sort((a, b) => (g.degree.get(b) ?? 0) - (g.degree.get(a) ?? 0))
686
+ .slice(0, 5)
687
+ .map(id => g.nodes.get(id)?.label ?? id);
688
+ return {
689
+ nodes: g.nodes.size,
690
+ edges: g.edges.length,
691
+ communities: communities.size,
692
+ community_sizes: communitySizes,
693
+ confidence: confidenceCounts,
694
+ top_relations: topRelations,
695
+ file_types: fileTypeCounts,
696
+ graph_path: g.graphPath,
697
+ top_god_nodes: topGodNodes,
698
+ is_directed: true,
699
+ };
700
+ }
701
+ catch (err) {
702
+ return { error: true, message: String(err) };
343
703
  }
344
- catch (err) { return { error: true, message: String(err) }; }
345
704
  },
346
705
  };
347
-
706
+ /**
707
+ * Find surprising cross-community connections (architectural insights).
708
+ */
348
709
  export const graphifySurprisesTool = {
349
710
  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.',
711
+ description: 'Find surprising connections between components that are in different communities ' +
712
+ 'but have strong relationships. These unexpected couplings often reveal hidden dependencies, ' +
713
+ 'design smells, or important architectural patterns worth understanding.',
351
714
  category: 'graphify',
352
715
  tags: ['knowledge-graph', 'architecture', 'coupling', 'surprises'],
353
- inputSchema: { type: 'object', properties: { topN: { type: 'integer', default: 10 } } },
716
+ inputSchema: {
717
+ type: 'object',
718
+ properties: {
719
+ topN: {
720
+ type: 'integer',
721
+ default: 10,
722
+ description: 'Number of surprising connections to return',
723
+ },
724
+ },
725
+ },
354
726
  handler: async (params) => {
355
727
  const cwd = getProjectCwd();
356
- if (!graphExists(cwd)) return { error: true, message: 'No graph found. Run graphify_build first.' };
728
+ if (!graphExists(cwd)) {
729
+ return { error: true, message: 'No graph found. Run graphify_build first.' };
730
+ }
357
731
  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);
732
+ const g = await loadKnowledgeGraph(cwd);
733
+ const topN = params.topN || 10;
734
+ const surprises = [];
735
+ for (const e of g.edges) {
736
+ const uD = g.nodes.get(e.source);
737
+ const vD = g.nodes.get(e.target);
738
+ const cu = uD?.community ?? null;
739
+ const cv = vD?.community ?? null;
740
+ if (cu != null && cv != null && cu !== cv) {
741
+ const score = (g.degree.get(e.source) ?? 0) * (g.degree.get(e.target) ?? 0);
742
+ surprises.push({
743
+ score,
744
+ from: uD?.label ?? e.source,
745
+ from_community: cu,
746
+ from_file: uD?.source_file ?? '',
747
+ to: vD?.label ?? e.target,
748
+ to_community: cv,
749
+ to_file: vD?.source_file ?? '',
750
+ relation: e.relation ?? '',
751
+ confidence: e.confidence ?? '',
752
+ });
753
+ }
754
+ }
755
+ surprises.sort((a, b) => b.score - a.score);
756
+ return {
757
+ surprises: surprises.slice(0, topN),
758
+ total_cross_community_edges: surprises.length,
759
+ };
760
+ }
761
+ catch (err) {
762
+ return { error: true, message: String(err) };
763
+ }
764
+ },
765
+ };
766
+ /**
767
+ * Generate or open the interactive HTML visualization of the knowledge graph.
768
+ */
769
+ export const graphifyVisualizeTool = {
770
+ name: 'graphify_visualize',
771
+ description: 'Generate (and optionally open) the interactive HTML knowledge graph explorer. ' +
772
+ 'Produces graph.html alongside graph.json — a self-contained visualization with force-directed ' +
773
+ 'layout, community coloring, god-node panel, sidebar details, minimap, and search. ' +
774
+ 'No internet connection or server required — just open the HTML file in a browser.',
775
+ category: 'graphify',
776
+ tags: ['knowledge-graph', 'visualization', 'html', 'browser'],
777
+ inputSchema: {
778
+ type: 'object',
779
+ properties: {
780
+ open: {
781
+ type: 'boolean',
782
+ description: 'Open the HTML file in the default browser after generating (macOS/Linux)',
783
+ default: false,
784
+ },
785
+ path: {
786
+ type: 'string',
787
+ description: 'Project path (defaults to current project root)',
788
+ },
789
+ },
790
+ },
791
+ handler: async (params) => {
792
+ const cwd = getProjectCwd();
793
+ const targetPath = params.path || cwd;
794
+ if (!graphExists(targetPath)) {
795
+ return {
796
+ error: true,
797
+ message: 'No graph found. Run graphify_build first.',
798
+ hint: `Expected: ${getGraphPath(targetPath)}`,
799
+ };
800
+ }
801
+ try {
802
+ const graphPath = getGraphPath(targetPath);
803
+ const { readFileSync } = await import('fs');
804
+ const { join, dirname } = await import('path');
805
+ const outputDir = dirname(graphPath);
806
+ const { exportHTML } = await import('@monoes/graph');
807
+ const raw = JSON.parse(readFileSync(graphPath, 'utf-8'));
808
+ const htmlPath = exportHTML(raw, outputDir);
809
+ if (params.open) {
810
+ const { spawn } = await import('child_process');
811
+ const opener = process.platform === 'darwin' ? 'open'
812
+ : process.platform === 'win32' ? 'start' : 'xdg-open';
813
+ spawn(opener, [htmlPath], { detached: true, stdio: 'ignore' }).unref();
814
+ }
815
+ return {
816
+ success: true,
817
+ htmlPath,
818
+ message: `Interactive visualization generated at ${htmlPath}`,
819
+ hint: params.open ? 'Opening in browser…' : `Open in browser: open "${htmlPath}"`,
820
+ };
821
+ }
822
+ catch (err) {
823
+ return { error: true, message: String(err) };
370
824
  }
371
- catch (err) { return { error: true, message: String(err) }; }
372
825
  },
373
826
  };
374
-
827
+ // ── Exports ───────────────────────────────────────────────────────────────────
375
828
  export const graphifyTools = [
376
829
  graphifyBuildTool,
377
830
  graphifyQueryTool,
@@ -381,6 +834,7 @@ export const graphifyTools = [
381
834
  graphifyGetCommunityTool,
382
835
  graphifyStatsTool,
383
836
  graphifySurprisesTool,
837
+ graphifyVisualizeTool,
384
838
  ];
385
-
386
839
  export default graphifyTools;
840
+ //# sourceMappingURL=graphify-tools.js.map