context-mcp-server 1.0.8 → 1.1.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 (81) hide show
  1. package/README.md +29 -7
  2. package/codegraph/__pycache__/affected.cpython-313.pyc +0 -0
  3. package/codegraph/__pycache__/cache.cpython-313.pyc +0 -0
  4. package/codegraph/__pycache__/callflow_html.cpython-313.pyc +0 -0
  5. package/codegraph/__pycache__/export.cpython-313.pyc +0 -0
  6. package/codegraph/__pycache__/report.cpython-313.pyc +0 -0
  7. package/codegraph/__pycache__/server.cpython-313.pyc +0 -0
  8. package/codegraph/__pycache__/tree_html.cpython-313.pyc +0 -0
  9. package/codegraph/affected.py +233 -0
  10. package/codegraph/cache.py +51 -2
  11. package/codegraph/callflow_html.py +273 -0
  12. package/codegraph/export.py +544 -0
  13. package/codegraph/extractors/__pycache__/ast_extractor.cpython-313.pyc +0 -0
  14. package/codegraph/extractors/ast_extractor.py +143 -16
  15. package/codegraph/graph/__pycache__/builder.cpython-313.pyc +0 -0
  16. package/codegraph/graph/__pycache__/clustering.cpython-313.pyc +0 -0
  17. package/codegraph/graph/__pycache__/query.cpython-313.pyc +0 -0
  18. package/codegraph/graph/__pycache__/symbol_resolution.cpython-313.pyc +0 -0
  19. package/codegraph/graph/builder.py +10 -0
  20. package/codegraph/graph/clustering.py +247 -10
  21. package/codegraph/graph/query.py +99 -0
  22. package/codegraph/graph/symbol_resolution.py +112 -0
  23. package/codegraph/report.py +53 -0
  24. package/codegraph/server.py +112 -20
  25. package/codegraph/tree_html.py +241 -0
  26. package/package.json +2 -2
  27. package/pyproject.toml +4 -1
  28. package/src/cli.js +329 -227
  29. package/src/db.js +79 -102
  30. package/src/search.js +73 -9
  31. package/src/server.js +7 -1
  32. package/src/templates/antigravity/GEMINI.md +96 -0
  33. package/src/templates/antigravity/hooks/context-mcp-post-tool-use.js +62 -0
  34. package/src/templates/antigravity/workflows/context-resume.md +20 -0
  35. package/src/templates/antigravity/workflows/graph-build.md +23 -0
  36. package/src/templates/antigravity/workflows/save-context.md +29 -0
  37. package/src/templates/claude/CLAUDE.md +140 -0
  38. package/src/templates/claude/commands/graph-build.md +9 -0
  39. package/src/templates/claude/commands/save-context.md +19 -0
  40. package/src/templates/claude/hooks/context-mcp-post-tool-use.js +59 -0
  41. package/src/templates/claude/hooks/context-mcp-pre-tool-use.js +26 -0
  42. package/src/templates/claude/skills/SKILL.md +144 -0
  43. package/src/templates/codex/AGENTS.md +107 -0
  44. package/src/templates/codex/hooks/context-mcp-post-tool-use.js +46 -0
  45. package/src/templates/codex/hooks/context-mcp-pre-tool-use.js +23 -0
  46. package/src/templates/codex/prompts/context-resume.md +15 -0
  47. package/src/templates/codex/prompts/graph-build.md +14 -0
  48. package/src/templates/codex/prompts/save-context.md +24 -0
  49. package/src/templates/cursor/commands/context-resume.md +7 -0
  50. package/src/templates/cursor/commands/graph-build.md +7 -0
  51. package/src/templates/cursor/commands/save-context.md +12 -0
  52. package/src/templates/{cursor-rules.mdc → cursor/cursor-rules.mdc} +13 -3
  53. package/src/templates/cursor/hooks/context-mcp-post-tool-use.js +55 -0
  54. package/src/templates/gemini/GEMINI.md +92 -0
  55. package/src/templates/gemini/commands/context-resume.toml +15 -0
  56. package/src/templates/gemini/commands/graph-build.toml +14 -0
  57. package/src/templates/gemini/commands/save-context.toml +24 -0
  58. package/src/templates/gemini/hooks/context-mcp-after-tool.js +59 -0
  59. package/src/templates/gemini/hooks/context-mcp-before-tool.js +26 -0
  60. package/src/templates/vscode/commands/context-resume.prompt.md +15 -0
  61. package/src/templates/vscode/commands/graph-build.prompt.md +10 -0
  62. package/src/templates/vscode/commands/save-context.prompt.md +16 -0
  63. package/src/templates/vscode/hooks/context-mcp-post-tool-use.js +58 -0
  64. package/src/templates/windsurf/hooks/context-mcp-post-run-command.js +57 -0
  65. package/src/templates/windsurf/windsurf-rules.md +86 -0
  66. package/src/templates/windsurf/workflows/context-resume.md +11 -0
  67. package/src/templates/windsurf/workflows/graph-build.md +11 -0
  68. package/src/templates/windsurf/workflows/save-context.md +18 -0
  69. package/src/tools/codegraph.js +83 -43
  70. package/src/tools/context.js +42 -24
  71. package/src/tools/plan.js +14 -11
  72. package/uv.lock +1101 -4
  73. package/src/migrator.js +0 -124
  74. package/src/templates/AGENTS.md +0 -80
  75. package/src/templates/CLAUDE.md +0 -103
  76. package/src/templates/GEMINI.md +0 -80
  77. package/src/templates/commands/graph-build.md +0 -5
  78. package/src/templates/commands/save-context.md +0 -9
  79. package/src/templates/skills/SKILL.md +0 -108
  80. package/src/templates/windsurf-rules.md +0 -35
  81. /package/src/templates/{commands → claude/commands}/context-resume.md +0 -0
@@ -0,0 +1,544 @@
1
+ """
2
+ export.py — generate interactive visualizations from graph.json.
3
+
4
+ Formats:
5
+ to_html() — self-contained vis.js HTML (dark theme, search, community toggle)
6
+ to_graphml() — GraphML for Gephi/yEd
7
+ to_obsidian() — per-node .md files with [[wikilinks]] + community summaries
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import html as _html
12
+ import json
13
+ import re
14
+ from pathlib import Path
15
+
16
+
17
+ # ── Color palette (same as graphify for visual consistency) ──────────────────
18
+
19
+ COMMUNITY_COLORS = [
20
+ "#4E79A7", "#F28E2B", "#E15759", "#76B7B2", "#59A14F",
21
+ "#EDC948", "#B07AA1", "#FF9DA7", "#9C755F", "#BAB0AC",
22
+ ]
23
+
24
+ MAX_VIZ_NODES = 5_000
25
+
26
+
27
+ # ── HTML export ───────────────────────────────────────────────────────────────
28
+
29
+ def to_html(graph_dict: dict, output_path: str) -> str:
30
+ """Generate self-contained vis.js HTML. Returns path written."""
31
+ nodes = graph_dict.get("nodes", [])
32
+ edges = graph_dict.get("edges", [])
33
+ communities_list = graph_dict.get("communities", [])
34
+
35
+ # Build community lookup: node_id → community_id
36
+ node_community: dict[str, int] = {}
37
+ for c in communities_list:
38
+ for mid in c.get("members", []):
39
+ node_community[mid] = c["id"]
40
+
41
+ # Community color map
42
+ comm_colors: dict[int, str] = {}
43
+ for c in communities_list:
44
+ comm_colors[c["id"]] = COMMUNITY_COLORS[c["id"] % len(COMMUNITY_COLORS)]
45
+
46
+ # Degree map
47
+ degree: dict[str, int] = {}
48
+ for e in edges:
49
+ degree[e.get("from", "")] = degree.get(e.get("from", ""), 0) + 1
50
+ degree[e.get("to", "")] = degree.get(e.get("to", ""), 0) + 1
51
+
52
+ # Limit nodes for viz
53
+ if len(nodes) > MAX_VIZ_NODES:
54
+ nodes = sorted(nodes, key=lambda n: -degree.get(n.get("id", ""), 0))[:MAX_VIZ_NODES]
55
+ node_ids_kept = {n["id"] for n in nodes}
56
+ edges = [e for e in edges if e.get("from") in node_ids_kept and e.get("to") in node_ids_kept]
57
+
58
+ # Build vis node list
59
+ viz_nodes = []
60
+ for n in nodes:
61
+ nid = n.get("id", "")
62
+ cid = node_community.get(nid, -1)
63
+ color = comm_colors.get(cid, "#607D8B")
64
+ deg = degree.get(nid, 0)
65
+ size = max(8, min(30, 8 + deg * 1.5))
66
+ name = _html.escape(str(n.get("name", nid)))
67
+ file_path = _html.escape(str(n.get("file", "-")))
68
+ node_type = _html.escape(str(n.get("type", "?")))
69
+ comm_label = ""
70
+ if cid >= 0:
71
+ comm = next((c for c in communities_list if c["id"] == cid), None)
72
+ comm_label = comm["label"] if comm else f"Community {cid}"
73
+ tooltip = f"<b>{name}</b><br>type: {node_type}<br>file: {file_path}<br>degree: {deg}"
74
+ viz_nodes.append({
75
+ "id": nid,
76
+ "label": n.get("name", nid),
77
+ "color": {"background": color, "border": color},
78
+ "size": size,
79
+ "font": {"color": "#e0e0e0", "size": 11},
80
+ "title": tooltip,
81
+ "community": cid,
82
+ "community_name": comm_label,
83
+ "source_file": n.get("file", ""),
84
+ "file_type": n.get("type", ""),
85
+ "degree": deg,
86
+ })
87
+
88
+ # Build vis edge list
89
+ viz_edges = []
90
+ for e in edges:
91
+ conf = e.get("confidence", "EXTRACTED")
92
+ dashes = conf in ("INFERRED", "AMBIGUOUS")
93
+ width = 1 if conf == "AMBIGUOUS" else (1.5 if conf == "INFERRED" else 2)
94
+ viz_edges.append({
95
+ "from": e.get("from"),
96
+ "to": e.get("to"),
97
+ "title": e.get("relation", ""),
98
+ "dashes": dashes,
99
+ "width": width,
100
+ "color": {"color": "#3a3a5e", "highlight": "#6a6aae"},
101
+ })
102
+
103
+ # Legend data
104
+ legend = []
105
+ for c in sorted(communities_list, key=lambda c: -len(c.get("members", []))):
106
+ cid = c["id"]
107
+ legend.append({
108
+ "cid": cid,
109
+ "label": c.get("label", f"Community {cid}"),
110
+ "color": comm_colors.get(cid, "#607D8B"),
111
+ "count": len(c.get("members", [])),
112
+ })
113
+
114
+ nodes_json = json.dumps(viz_nodes)
115
+ edges_json = json.dumps(viz_edges)
116
+ legend_json = json.dumps(legend)
117
+ stats_text = f"{len(nodes)} nodes · {len(edges)} edges · {len(communities_list)} communities"
118
+
119
+ html = f"""<!DOCTYPE html>
120
+ <html lang="en">
121
+ <head>
122
+ <meta charset="utf-8">
123
+ <title>CodeGraph</title>
124
+ <script src="https://unpkg.com/vis-network@9.1.9/dist/vis-network.min.js"></script>
125
+ <link rel="stylesheet" href="https://unpkg.com/vis-network@9.1.9/dist/dist/vis-network.min.css">
126
+ {_html_styles()}
127
+ </head>
128
+ <body>
129
+ <div id="graph"></div>
130
+ <div id="sidebar">
131
+ <div id="search-wrap">
132
+ <input id="search" type="text" placeholder="Search nodes…" autocomplete="off">
133
+ <div id="search-results"></div>
134
+ </div>
135
+ <div id="info-panel">
136
+ <h3>Node Info</h3>
137
+ <div id="info-content"><span class="empty">Click a node to inspect it</span></div>
138
+ </div>
139
+ <div id="legend-wrap">
140
+ <h3>Communities</h3>
141
+ <div id="legend-controls">
142
+ <label><input type="checkbox" id="select-all-cb" checked> All</label>
143
+ </div>
144
+ <div id="legend"></div>
145
+ </div>
146
+ <div id="stats">{stats_text}</div>
147
+ </div>
148
+ {_html_script(nodes_json, edges_json, legend_json)}
149
+ </body>
150
+ </html>"""
151
+
152
+ out = Path(output_path)
153
+ out.parent.mkdir(parents=True, exist_ok=True)
154
+ out.write_text(html, encoding="utf-8")
155
+ return str(out)
156
+
157
+
158
+ def _html_styles() -> str:
159
+ return """<style>
160
+ * { box-sizing: border-box; margin: 0; padding: 0; }
161
+ body { background: #0f0f1a; color: #e0e0e0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; display: flex; height: 100vh; overflow: hidden; }
162
+ #graph { flex: 1; }
163
+ #sidebar { width: 280px; background: #1a1a2e; border-left: 1px solid #2a2a4e; display: flex; flex-direction: column; overflow: hidden; }
164
+ #search-wrap { padding: 12px; border-bottom: 1px solid #2a2a4e; }
165
+ #search { width: 100%; background: #0f0f1a; border: 1px solid #3a3a5e; color: #e0e0e0; padding: 7px 10px; border-radius: 6px; font-size: 13px; outline: none; }
166
+ #search:focus { border-color: #4E79A7; }
167
+ #search-results { max-height: 140px; overflow-y: auto; padding: 4px 12px; border-bottom: 1px solid #2a2a4e; display: none; }
168
+ .search-item { padding: 4px 6px; cursor: pointer; border-radius: 4px; font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
169
+ .search-item:hover { background: #2a2a4e; }
170
+ #info-panel { padding: 14px; border-bottom: 1px solid #2a2a4e; min-height: 140px; }
171
+ #info-panel h3 { font-size: 13px; color: #aaa; margin-bottom: 8px; text-transform: uppercase; letter-spacing: 0.05em; }
172
+ #info-content { font-size: 13px; color: #ccc; line-height: 1.6; }
173
+ #info-content .field { margin-bottom: 5px; }
174
+ #info-content .field b { color: #e0e0e0; }
175
+ #info-content .empty { color: #555; font-style: italic; }
176
+ .neighbor-link { display: block; padding: 2px 6px; margin: 2px 0; border-radius: 3px; cursor: pointer; font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; border-left: 3px solid #333; }
177
+ .neighbor-link:hover { background: #2a2a4e; }
178
+ #neighbors-list { max-height: 160px; overflow-y: auto; margin-top: 4px; }
179
+ #legend-wrap { flex: 1; overflow-y: auto; padding: 12px; }
180
+ #legend-wrap h3 { font-size: 13px; color: #aaa; margin-bottom: 10px; text-transform: uppercase; letter-spacing: 0.05em; }
181
+ .legend-item { display: flex; align-items: center; gap: 8px; padding: 4px 0; cursor: pointer; border-radius: 4px; font-size: 12px; }
182
+ .legend-item:hover { background: #2a2a4e; padding-left: 4px; }
183
+ .legend-item.dimmed { opacity: 0.35; }
184
+ .legend-dot { width: 12px; height: 12px; border-radius: 50%; flex-shrink: 0; }
185
+ .legend-label { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
186
+ .legend-count { color: #666; font-size: 11px; }
187
+ #stats { padding: 10px 14px; border-top: 1px solid #2a2a4e; font-size: 11px; color: #555; }
188
+ #legend-controls { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; padding: 4px 0; }
189
+ #legend-controls label { display: flex; align-items: center; gap: 6px; cursor: pointer; font-size: 12px; color: #aaa; user-select: none; }
190
+ #legend-controls label:hover { color: #e0e0e0; }
191
+ #select-all-cb { appearance: none; -webkit-appearance: none; width: 14px; height: 14px; border: 1.5px solid #3a3a5e; border-radius: 3px; background: #0f0f1a; cursor: pointer; }
192
+ #select-all-cb:checked { background: #4E79A7; border-color: #4E79A7; }
193
+ .legend-cb { appearance: none; -webkit-appearance: none; width: 14px; height: 14px; border: 1.5px solid #3a3a5e; border-radius: 3px; background: #0f0f1a; cursor: pointer; position: relative; flex-shrink: 0; }
194
+ .legend-cb:checked { background: #4E79A7; border-color: #4E79A7; }
195
+ </style>"""
196
+
197
+
198
+ def _html_script(nodes_json: str, edges_json: str, legend_json: str) -> str:
199
+ return f"""<script>
200
+ const RAW_NODES = {nodes_json};
201
+ const RAW_EDGES = {edges_json};
202
+ const LEGEND = {legend_json};
203
+
204
+ function esc(s) {{
205
+ return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
206
+ }}
207
+
208
+ const nodesDS = new vis.DataSet(RAW_NODES.map(n => ({{
209
+ id: n.id, label: n.label, color: n.color, size: n.size,
210
+ font: n.font, title: n.title,
211
+ _community: n.community, _community_name: n.community_name,
212
+ _source_file: n.source_file, _file_type: n.file_type, _degree: n.degree,
213
+ }})));
214
+
215
+ const edgesDS = new vis.DataSet(RAW_EDGES.map((e, i) => ({{
216
+ id: i, from: e.from, to: e.to, title: e.title,
217
+ dashes: e.dashes, width: e.width, color: e.color,
218
+ arrows: {{ to: {{ enabled: true, scaleFactor: 0.5 }} }},
219
+ }})));
220
+
221
+ const container = document.getElementById('graph');
222
+ const network = new vis.Network(container, {{ nodes: nodesDS, edges: edgesDS }}, {{
223
+ physics: {{
224
+ enabled: true,
225
+ solver: 'forceAtlas2Based',
226
+ forceAtlas2Based: {{ gravitationalConstant: -60, centralGravity: 0.005, springLength: 120, springConstant: 0.08, damping: 0.4, avoidOverlap: 0.8 }},
227
+ stabilization: {{ iterations: 200, fit: true }},
228
+ }},
229
+ interaction: {{ hover: true, tooltipDelay: 100, hideEdgesOnDrag: true }},
230
+ nodes: {{ shape: 'dot', borderWidth: 1.5 }},
231
+ edges: {{ smooth: {{ type: 'continuous', roundness: 0.2 }}, selectionWidth: 3 }},
232
+ }});
233
+
234
+ network.once('stabilizationIterationsDone', () => network.setOptions({{ physics: {{ enabled: false }} }}));
235
+
236
+ function showInfo(nodeId) {{
237
+ const n = nodesDS.get(nodeId);
238
+ if (!n) return;
239
+ const neighborIds = network.getConnectedNodes(nodeId);
240
+ const neighborItems = neighborIds.map(nid => {{
241
+ const nb = nodesDS.get(nid);
242
+ const color = nb ? nb.color.background : '#555';
243
+ return `<span class="neighbor-link" style="border-left-color:${{esc(color)}}" onclick="focusNode(${{JSON.stringify(nid)}})">${{esc(nb ? nb.label : nid)}}</span>`;
244
+ }}).join('');
245
+ document.getElementById('info-content').innerHTML = `
246
+ <div class="field"><b>${{esc(n.label)}}</b></div>
247
+ <div class="field">Type: ${{esc(n._file_type || 'unknown')}}</div>
248
+ <div class="field">Community: ${{esc(n._community_name || '-')}}</div>
249
+ <div class="field">Source: ${{esc(n._source_file || '-')}}</div>
250
+ <div class="field">Degree: ${{n._degree}}</div>
251
+ ${{neighborIds.length ? `<div style="margin-top:8px;color:#aaa;font-size:11px">Neighbors (${{neighborIds.length}})</div><div id="neighbors-list">${{neighborItems}}</div>` : ''}}
252
+ `;
253
+ }}
254
+
255
+ function focusNode(nodeId) {{
256
+ network.focus(nodeId, {{ scale: 1.4, animation: true }});
257
+ network.selectNodes([nodeId]);
258
+ showInfo(nodeId);
259
+ }}
260
+
261
+ let hoveredNodeId = null;
262
+ network.on('hoverNode', p => {{ hoveredNodeId = p.node; container.style.cursor = 'pointer'; }});
263
+ network.on('blurNode', () => {{ hoveredNodeId = null; container.style.cursor = 'default'; }});
264
+ container.addEventListener('click', () => {{ if (hoveredNodeId !== null) {{ showInfo(hoveredNodeId); network.selectNodes([hoveredNodeId]); }} }});
265
+ network.on('click', p => {{
266
+ if (p.nodes.length > 0) showInfo(p.nodes[0]);
267
+ else if (hoveredNodeId === null) document.getElementById('info-content').innerHTML = '<span class="empty">Click a node to inspect it</span>';
268
+ }});
269
+
270
+ const searchInput = document.getElementById('search');
271
+ const searchResults = document.getElementById('search-results');
272
+ searchInput.addEventListener('input', () => {{
273
+ const q = searchInput.value.toLowerCase().trim();
274
+ searchResults.innerHTML = '';
275
+ if (!q) {{ searchResults.style.display = 'none'; return; }}
276
+ const matches = RAW_NODES.filter(n => String(n.label).toLowerCase().includes(q)).slice(0, 20);
277
+ if (!matches.length) {{ searchResults.style.display = 'none'; return; }}
278
+ searchResults.style.display = 'block';
279
+ matches.forEach(n => {{
280
+ const el = document.createElement('div');
281
+ el.className = 'search-item';
282
+ el.textContent = n.label;
283
+ el.style.borderLeft = `3px solid ${{n.color.background}}`;
284
+ el.style.paddingLeft = '8px';
285
+ el.onclick = () => {{ network.focus(n.id, {{ scale: 1.5, animation: true }}); network.selectNodes([n.id]); showInfo(n.id); searchResults.style.display = 'none'; searchInput.value = ''; }};
286
+ searchResults.appendChild(el);
287
+ }});
288
+ }});
289
+ document.addEventListener('click', e => {{ if (!searchResults.contains(e.target) && e.target !== searchInput) searchResults.style.display = 'none'; }});
290
+
291
+ const hiddenCommunities = new Set();
292
+ const selectAllCb = document.getElementById('select-all-cb');
293
+ function updateSelectAllState() {{
294
+ selectAllCb.checked = hiddenCommunities.size === 0;
295
+ selectAllCb.indeterminate = hiddenCommunities.size > 0 && hiddenCommunities.size < LEGEND.length;
296
+ }}
297
+ selectAllCb.addEventListener('change', () => {{
298
+ const hide = !selectAllCb.checked;
299
+ document.querySelectorAll('.legend-item').forEach(item => hide ? item.classList.add('dimmed') : item.classList.remove('dimmed'));
300
+ document.querySelectorAll('.legend-cb').forEach(cb => {{ cb.checked = !hide; }});
301
+ LEGEND.forEach(c => {{ if (hide) hiddenCommunities.add(c.cid); else hiddenCommunities.delete(c.cid); }});
302
+ nodesDS.update(RAW_NODES.map(n => ({{ id: n.id, hidden: hide }})));
303
+ updateSelectAllState();
304
+ }});
305
+
306
+ const legendEl = document.getElementById('legend');
307
+ LEGEND.forEach(c => {{
308
+ const item = document.createElement('div');
309
+ item.className = 'legend-item';
310
+ const cb = document.createElement('input');
311
+ cb.type = 'checkbox'; cb.className = 'legend-cb'; cb.checked = true;
312
+ cb.addEventListener('change', e => {{
313
+ e.stopPropagation();
314
+ if (cb.checked) {{ hiddenCommunities.delete(c.cid); item.classList.remove('dimmed'); }}
315
+ else {{ hiddenCommunities.add(c.cid); item.classList.add('dimmed'); }}
316
+ nodesDS.update(RAW_NODES.filter(n => n.community === c.cid).map(n => ({{ id: n.id, hidden: !cb.checked }})));
317
+ updateSelectAllState();
318
+ }});
319
+ item.innerHTML = `<div class="legend-dot" style="background:${{c.color}}"></div><span class="legend-label">${{esc(c.label)}}</span><span class="legend-count">${{c.count}}</span>`;
320
+ item.prepend(cb);
321
+ item.onclick = e => {{ if (e.target === cb) return; cb.checked = !cb.checked; cb.dispatchEvent(new Event('change')); }};
322
+ legendEl.appendChild(item);
323
+ }});
324
+ </script>"""
325
+
326
+
327
+ # ── GraphML export ────────────────────────────────────────────────────────────
328
+
329
+ def to_graphml(graph_dict: dict, output_path: str) -> str:
330
+ """Write GraphML for Gephi/yEd. Returns path written."""
331
+ nodes = graph_dict.get("nodes", [])
332
+ edges = graph_dict.get("edges", [])
333
+
334
+ def esc(s: str) -> str:
335
+ return _html.escape(str(s), quote=True)
336
+
337
+ lines = [
338
+ '<?xml version="1.0" encoding="UTF-8"?>',
339
+ '<graphml xmlns="http://graphml.graphdrawing.org/graphml">',
340
+ ' <key id="name" for="node" attr.name="name" attr.type="string"/>',
341
+ ' <key id="type" for="node" attr.name="type" attr.type="string"/>',
342
+ ' <key id="file" for="node" attr.name="file" attr.type="string"/>',
343
+ ' <key id="community" for="node" attr.name="community" attr.type="int"/>',
344
+ ' <key id="relation" for="edge" attr.name="relation" attr.type="string"/>',
345
+ ' <key id="confidence" for="edge" attr.name="confidence" attr.type="string"/>',
346
+ ' <graph id="G" edgedefault="directed">',
347
+ ]
348
+
349
+ for n in nodes:
350
+ nid = esc(n.get("id", ""))
351
+ lines.append(f' <node id="{nid}">')
352
+ lines.append(f' <data key="name">{esc(n.get("name", ""))}</data>')
353
+ lines.append(f' <data key="type">{esc(n.get("type", ""))}</data>')
354
+ lines.append(f' <data key="file">{esc(n.get("file", ""))}</data>')
355
+ if "community" in n:
356
+ lines.append(f' <data key="community">{int(n["community"])}</data>')
357
+ lines.append(' </node>')
358
+
359
+ for i, e in enumerate(edges):
360
+ src = esc(e.get("from", ""))
361
+ tgt = esc(e.get("to", ""))
362
+ lines.append(f' <edge id="e{i}" source="{src}" target="{tgt}">')
363
+ lines.append(f' <data key="relation">{esc(e.get("relation", ""))}</data>')
364
+ lines.append(f' <data key="confidence">{esc(e.get("confidence", ""))}</data>')
365
+ lines.append(' </edge>')
366
+
367
+ lines += [' </graph>', '</graphml>']
368
+
369
+ out = Path(output_path)
370
+ out.parent.mkdir(parents=True, exist_ok=True)
371
+ out.write_text("\n".join(lines), encoding="utf-8")
372
+ return str(out)
373
+
374
+
375
+ # ── Obsidian vault export ─────────────────────────────────────────────────────
376
+
377
+ def _obsidian_tag(name: str) -> str:
378
+ return re.sub(r"[^a-zA-Z0-9_\-/]", "", name.replace(" ", "_"))
379
+
380
+
381
+ def to_obsidian(graph_dict: dict, output_dir: str) -> str:
382
+ """Write per-node .md files with [[wikilinks]] and community summaries.
383
+
384
+ Returns the path of the vault directory.
385
+ """
386
+ nodes = graph_dict.get("nodes", [])
387
+ edges = graph_dict.get("edges", [])
388
+ communities_list = graph_dict.get("communities", [])
389
+
390
+ vault = Path(output_dir)
391
+ vault.mkdir(parents=True, exist_ok=True)
392
+ nodes_dir = vault / "nodes"
393
+ nodes_dir.mkdir(exist_ok=True)
394
+ comms_dir = vault / "communities"
395
+ comms_dir.mkdir(exist_ok=True)
396
+
397
+ # Build adjacency for quick lookup
398
+ node_map = {n["id"]: n for n in nodes}
399
+ out_edges: dict[str, list[dict]] = {}
400
+ in_edges: dict[str, list[dict]] = {}
401
+ for e in edges:
402
+ src, tgt = e.get("from", ""), e.get("to", "")
403
+ out_edges.setdefault(src, []).append(e)
404
+ in_edges.setdefault(tgt, []).append(e)
405
+
406
+ # Community membership
407
+ node_community: dict[str, dict] = {}
408
+ for c in communities_list:
409
+ for mid in c.get("members", []):
410
+ node_community[mid] = c
411
+
412
+ # Write per-node files
413
+ for n in nodes:
414
+ nid = n.get("id", "")
415
+ name = n.get("name", nid)
416
+ fpath = n.get("file", "")
417
+ ntype = n.get("type", "")
418
+ comm = node_community.get(nid)
419
+ comm_tag = _obsidian_tag(comm["label"]) if comm else "misc"
420
+
421
+ lines = [
422
+ "---",
423
+ f'name: "{name}"',
424
+ f'type: "{ntype}"',
425
+ f'file: "{fpath}"',
426
+ f'community: "{comm_tag}"',
427
+ "---",
428
+ "",
429
+ f"# {name}",
430
+ "",
431
+ ]
432
+ if n.get("description"):
433
+ lines += [n["description"], ""]
434
+
435
+ lines += [f"**Type:** `{ntype}` **File:** `{fpath}`", ""]
436
+
437
+ if comm:
438
+ lines += [f"**Community:** [[communities/{_obsidian_tag(comm['label'])}]]", ""]
439
+
440
+ depends = out_edges.get(nid, [])
441
+ if depends:
442
+ lines += ["## Depends On", ""]
443
+ for e in depends[:30]:
444
+ tgt_node = node_map.get(e.get("to", ""), {})
445
+ tgt_name = tgt_node.get("name", e.get("to", ""))
446
+ rel = e.get("relation", "→")
447
+ lines.append(f"- [[nodes/{tgt_name}]] _{rel}_")
448
+ lines.append("")
449
+
450
+ callers = in_edges.get(nid, [])
451
+ if callers:
452
+ lines += ["## Used By", ""]
453
+ for e in callers[:30]:
454
+ src_node = node_map.get(e.get("from", ""), {})
455
+ src_name = src_node.get("name", e.get("from", ""))
456
+ rel = e.get("relation", "→")
457
+ lines.append(f"- [[nodes/{src_name}]] _{rel}_")
458
+ lines.append("")
459
+
460
+ safe_name = re.sub(r"[^\w\-. ]", "_", name)
461
+ (nodes_dir / f"{safe_name}.md").write_text("\n".join(lines), encoding="utf-8")
462
+
463
+ # Write per-community summary files
464
+ for c in communities_list:
465
+ cid = c["id"]
466
+ label = c.get("label", f"Community {cid}")
467
+ tag = _obsidian_tag(label)
468
+ members = c.get("members", [])
469
+ member_nodes = [node_map[m] for m in members if m in node_map]
470
+
471
+ # Cohesion score (simple inline calc)
472
+ intra = sum(1 for e in edges
473
+ if e.get("from") in set(members) and e.get("to") in set(members))
474
+ n = len(members)
475
+ cohesion = round(intra / (n * (n - 1) / 2), 3) if n > 1 else 1.0
476
+
477
+ lines = [
478
+ "---",
479
+ f'community_id: {cid}',
480
+ f'label: "{label}"',
481
+ f'members: {n}',
482
+ f'cohesion: {cohesion}',
483
+ "---",
484
+ "",
485
+ f"# {label}",
486
+ "",
487
+ f"**{n} members** · cohesion score: `{cohesion}`",
488
+ "",
489
+ "## Members",
490
+ "",
491
+ ]
492
+ for mn in sorted(member_nodes, key=lambda x: x.get("name", ""))[:50]:
493
+ mname = mn.get("name", mn.get("id", ""))
494
+ safe_mname = re.sub(r"[^\w\-. ]", "_", mname)
495
+ lines.append(f"- [[nodes/{safe_mname}]] `{mn.get('type', '?')}` · `{mn.get('file', '')}`")
496
+ if n > 50:
497
+ lines.append(f"- …and {n - 50} more")
498
+ lines.append("")
499
+
500
+ (comms_dir / f"{tag}.md").write_text("\n".join(lines), encoding="utf-8")
501
+
502
+ # Write index
503
+ index_lines = ["# CodeGraph Vault", "", "## Communities", ""]
504
+ for c in sorted(communities_list, key=lambda c: -len(c.get("members", []))):
505
+ tag = _obsidian_tag(c.get("label", f"Community {c['id']}"))
506
+ index_lines.append(f"- [[communities/{tag}]] ({len(c.get('members', []))} nodes)")
507
+ index_lines += ["", "## All Nodes", ""]
508
+ for n in sorted(nodes, key=lambda x: x.get("name", ""))[:200]:
509
+ safe_name = re.sub(r"[^\w\-. ]", "_", n.get("name", n.get("id", "")))
510
+ index_lines.append(f"- [[nodes/{safe_name}]]")
511
+
512
+ (vault / "index.md").write_text("\n".join(index_lines), encoding="utf-8")
513
+ return str(vault)
514
+
515
+
516
+ # ── Convenience: generate all exports ────────────────────────────────────────
517
+
518
+ def generate_all(graph_dict: dict, cache_dir: str) -> dict[str, str]:
519
+ """Generate all visualizations and exports. Returns {format: path}."""
520
+ from .tree_html import to_html as _tree_html
521
+ from .callflow_html import to_html as _callflow_html
522
+ base = Path(cache_dir)
523
+ results: dict[str, str] = {}
524
+ try:
525
+ results["html"] = to_html(graph_dict, str(base / "graph.html"))
526
+ except Exception as e:
527
+ results["html_error"] = str(e)
528
+ try:
529
+ results["tree"] = _tree_html(graph_dict, str(base / "tree.html"))
530
+ except Exception as e:
531
+ results["tree_error"] = str(e)
532
+ try:
533
+ results["callflow"] = _callflow_html(graph_dict, str(base / "callflow.html"))
534
+ except Exception as e:
535
+ results["callflow_error"] = str(e)
536
+ try:
537
+ results["graphml"] = to_graphml(graph_dict, str(base / "graph.graphml"))
538
+ except Exception as e:
539
+ results["graphml_error"] = str(e)
540
+ try:
541
+ results["obsidian"] = to_obsidian(graph_dict, str(base / "obsidian"))
542
+ except Exception as e:
543
+ results["obsidian_error"] = str(e)
544
+ return results