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,112 @@
1
+ """
2
+ graph/symbol_resolution.py — cross-file call edge resolution.
3
+
4
+ Resolves unresolved callee names in node['calls'] to actual node IDs,
5
+ creating 'calls' edges with INFERRED confidence where a unique match exists.
6
+
7
+ Our nodes use 'name'/'file' (graphify uses 'label'/'source_file').
8
+ """
9
+ from __future__ import annotations
10
+
11
+ from pathlib import Path
12
+
13
+
14
+ def build_name_index(all_nodes: list[dict]) -> dict[str, list[str]]:
15
+ """Map symbol name → [node_ids] for function/class/struct nodes."""
16
+ index: dict[str, list[str]] = {}
17
+ callable_types = {"function", "method", "class", "struct", "interface", "trait"}
18
+ for node in all_nodes:
19
+ if node.get("type") in callable_types:
20
+ name = node.get("name", "")
21
+ nid = node.get("id", "")
22
+ if name and nid:
23
+ index.setdefault(name, []).append(nid)
24
+ return index
25
+
26
+
27
+ def build_module_name_index(all_nodes: list[dict]) -> dict[tuple[str, str], list[str]]:
28
+ """Map (module_stem, symbol_name) → [node_ids] for scoped resolution."""
29
+ index: dict[tuple[str, str], list[str]] = {}
30
+ callable_types = {"function", "method", "class", "struct", "interface", "trait"}
31
+ for node in all_nodes:
32
+ if node.get("type") in callable_types:
33
+ name = node.get("name", "")
34
+ nid = node.get("id", "")
35
+ file_path = node.get("file", "")
36
+ if name and nid and file_path:
37
+ stem = Path(file_path.replace("\\", "/")).stem
38
+ index.setdefault((stem, name), []).append(nid)
39
+ return index
40
+
41
+
42
+ def resolve_calls(
43
+ all_nodes: list[dict],
44
+ existing_edge_keys: set[tuple[str, str]],
45
+ ) -> list[dict]:
46
+ """Resolve node['calls'] lists to graph edges.
47
+
48
+ For each node that declares `calls: ["FunctionName", ...]`, look up the
49
+ name in the global index. Only emit an edge when the name resolves
50
+ uniquely (avoids false positives for common names like 'get' or 'init').
51
+
52
+ Returns a list of new edge dicts {from, to, relation, confidence}.
53
+ """
54
+ name_index = build_name_index(all_nodes)
55
+ module_index = build_module_name_index(all_nodes)
56
+
57
+ new_edges: list[dict] = []
58
+
59
+ for node in all_nodes:
60
+ caller_id = node.get("id", "")
61
+ if not caller_id:
62
+ continue
63
+ calls = node.get("calls", [])
64
+ if not calls:
65
+ continue
66
+
67
+ caller_file = node.get("file", "")
68
+ # Try module-scoped resolution first (same file prefix → higher confidence)
69
+ imports = node.get("imports", [])
70
+ imported_stems = set()
71
+ for imp in imports:
72
+ clean = imp.lstrip(".").replace("\\", "/")
73
+ stem = Path(clean).stem
74
+ if stem:
75
+ imported_stems.add(stem)
76
+
77
+ for callee_name in calls:
78
+ if not isinstance(callee_name, str) or not callee_name:
79
+ continue
80
+
81
+ target_ids: list[str] = []
82
+
83
+ # 1. Try (imported_module_stem, callee_name) for import-guided resolution
84
+ for stem in imported_stems:
85
+ candidates = module_index.get((stem, callee_name), [])
86
+ target_ids.extend(candidates)
87
+
88
+ # 2. Fall back to global unique name match
89
+ if not target_ids:
90
+ target_ids = name_index.get(callee_name, [])
91
+
92
+ # Only emit when unambiguous
93
+ if len(target_ids) != 1:
94
+ continue
95
+
96
+ target_id = target_ids[0]
97
+ if target_id == caller_id:
98
+ continue # skip self-calls
99
+ key = (caller_id, target_id)
100
+ if key in existing_edge_keys:
101
+ continue
102
+
103
+ existing_edge_keys.add(key)
104
+ confidence = "EXTRACTED" if imported_stems else "INFERRED"
105
+ new_edges.append({
106
+ "from": caller_id,
107
+ "to": target_id,
108
+ "relation": "calls",
109
+ "confidence": confidence,
110
+ })
111
+
112
+ return new_edges
@@ -84,10 +84,63 @@ def _build_report(g: dict) -> str:
84
84
  lines += ["## Confidence Breakdown", ""]
85
85
  for label, count in sorted(conf_counts.items()):
86
86
  lines.append(f"- **{label}**: {count} edges")
87
+ lines.append("")
88
+
89
+ # Knowledge gaps
90
+ lines += _knowledge_gaps_section(nodes, edges, communities, node_map)
87
91
 
88
92
  return "\n".join(lines)
89
93
 
90
94
 
95
+ def _knowledge_gaps_section(
96
+ nodes: list, edges: list, communities: list, node_map: dict
97
+ ) -> list[str]:
98
+ """Identify under-connected areas: isolated nodes, thin communities, ambiguous edges."""
99
+ lines = ["## Knowledge Gaps", ""]
100
+
101
+ # Isolated nodes (no edges at all)
102
+ connected_ids: set[str] = set()
103
+ for e in edges:
104
+ connected_ids.add(e.get("from", ""))
105
+ connected_ids.add(e.get("to", ""))
106
+ isolated = [n for n in nodes if n["id"] not in connected_ids]
107
+ if isolated:
108
+ lines.append(f"**Isolated nodes** ({len(isolated)} with no edges):")
109
+ for n in isolated[:8]:
110
+ lines.append(f" - `{n.get('name', n['id'])}` in `{n.get('file', '?')}`")
111
+ if len(isolated) > 8:
112
+ lines.append(f" - …and {len(isolated) - 8} more")
113
+ else:
114
+ lines.append("_No isolated nodes._")
115
+ lines.append("")
116
+
117
+ # Thin communities (single-node clusters)
118
+ thin = [c for c in communities if len(c.get("members", [])) == 1]
119
+ if thin:
120
+ lines.append(f"**Thin communities** ({len(thin)} single-node clusters):")
121
+ for c in thin[:5]:
122
+ nid = c["members"][0]
123
+ n = node_map.get(nid, {})
124
+ lines.append(f" - `{n.get('name', nid)}` in `{n.get('file', '?')}`")
125
+ if len(thin) > 5:
126
+ lines.append(f" - …and {len(thin) - 5} more")
127
+ else:
128
+ lines.append("_No thin communities._")
129
+ lines.append("")
130
+
131
+ # High-ambiguity edges
132
+ ambiguous = [e for e in edges if e.get("confidence") == "AMBIGUOUS"]
133
+ if ambiguous and edges:
134
+ pct = round(100 * len(ambiguous) / len(edges), 1)
135
+ lines.append(f"**Ambiguous edges**: {len(ambiguous)} of {len(edges)} ({pct}%) "
136
+ "have low-confidence resolution — consider adding type annotations.")
137
+ else:
138
+ lines.append("_No ambiguous edges._")
139
+ lines.append("")
140
+
141
+ return lines
142
+
143
+
91
144
  def _cross_module_edges(edges: list, node_map: dict) -> list[tuple]:
92
145
  results = []
93
146
  for e in edges:
@@ -3,11 +3,12 @@
3
3
  codegraph/server.py — MCP server exposing codebase knowledge graph tools.
4
4
 
5
5
  Tools:
6
- codegraph_build — scan project, extract AST nodes, build graph (local only, no API)
7
- codegraph_query — structural question OR single-node lookup (or both); replaces codegraph_explain
8
- codegraph_report return full CODEGRAPH_REPORT.md
9
- codegraph_nodeslist nodes of a given type
10
- codegraph_pathshortest path between two concepts
6
+ codegraph_build — scan project, extract AST nodes, build graph (local only, no API)
7
+ codegraph_query — structural question OR single-node lookup (or both)
8
+ codegraph_arch module map: every file with its exports and imports
9
+ codegraph_reportreturn full CODEGRAPH_REPORT.md
10
+ codegraph_nodeslist nodes of a given type
11
+ codegraph_affected — BFS: what breaks if I change node X?
11
12
  """
12
13
 
13
14
  import asyncio
@@ -25,9 +26,13 @@ from .config import classify_file
25
26
  from .cache import file_hash, set_cached_nodes, save_cache
26
27
  from .extractors.ast_extractor import extract as ast_extract
27
28
  from .graph.builder import build, to_json_dict, save_graph, load_graph
28
- from .graph.query import answer as graph_answer, find_path
29
+ from .graph.query import answer as graph_answer, module_map
29
30
  from .graph.clustering import detect_communities
30
31
  from .report import generate as generate_report
32
+ from .affected import run_affected
33
+ from .export import to_html as export_html, to_graphml, to_obsidian, generate_all as export_all
34
+ from .tree_html import to_html as tree_html
35
+ from .callflow_html import to_html as callflow_html
31
36
 
32
37
  app = Server("codegraph")
33
38
 
@@ -95,16 +100,53 @@ TOOLS = [
95
100
  },
96
101
  ),
97
102
  Tool(
98
- name="codegraph_path",
99
- description="Find the shortest relationship path between two concepts in the graph.",
103
+ name="codegraph_html",
104
+ description=(
105
+ "Generate interactive vis.js HTML graph visualization. "
106
+ "Dark theme, search box, community toggle, click-to-inspect node panel. "
107
+ "Outputs codegraph-cache/graph.html. Also generates graph.graphml and obsidian/ vault."
108
+ ),
109
+ inputSchema={
110
+ "type": "object",
111
+ "properties": {
112
+ "path": {"type": "string", "description": "Project root"},
113
+ "formats": {"type": "array", "items": {"type": "string"}, "description": "Formats to generate: html, graphml, obsidian, tree, callflow (default: all)"},
114
+ },
115
+ "required": ["path"],
116
+ },
117
+ ),
118
+ Tool(
119
+ name="codegraph_affected",
120
+ description=(
121
+ "BFS traversal: given a node name, find every node that would be affected "
122
+ "if you change it — callers, importers, inheritors, etc. "
123
+ "Use before refactoring to understand blast radius. "
124
+ "Returns affected nodes with file paths, relation types, and traversal depth."
125
+ ),
126
+ inputSchema={
127
+ "type": "object",
128
+ "properties": {
129
+ "path": {"type": "string", "description": "Project root"},
130
+ "node": {"type": "string", "description": "Node name, ID, or file path to start from"},
131
+ "depth": {"type": "integer", "description": "BFS depth (default 2, max 5)"},
132
+ },
133
+ "required": ["path", "node"],
134
+ },
135
+ ),
136
+ Tool(
137
+ name="codegraph_arch",
138
+ description=(
139
+ "Return a module map: every file with its exported functions/classes and what it imports. "
140
+ "Use this to understand project structure without reading any files. "
141
+ "Call after codegraph_build. Much faster than reading each file."
142
+ ),
100
143
  inputSchema={
101
144
  "type": "object",
102
145
  "properties": {
103
- "path": {"type": "string"},
104
- "from": {"type": "string"},
105
- "to": {"type": "string"},
146
+ "path": {"type": "string", "description": "Project root"},
147
+ "limit": {"type": "integer", "description": "Max files in output (default 100)"},
106
148
  },
107
- "required": ["path", "from", "to"],
149
+ "required": ["path"],
108
150
  },
109
151
  ),
110
152
  ]
@@ -125,12 +167,13 @@ async def call_tool(name: str, arguments: dict):
125
167
 
126
168
 
127
169
  async def _dispatch(name: str, args: dict):
128
- if name == "codegraph_build": return await _build(args)
129
- if name == "codegraph_query": return await _query(args)
130
- if name == "codegraph_explain": return await _query(args)
131
- if name == "codegraph_report": return await _report(args)
132
- if name == "codegraph_nodes": return await _nodes(args)
133
- if name == "codegraph_path": return await _path(args)
170
+ if name == "codegraph_build": return await _build(args)
171
+ if name == "codegraph_query": return await _query(args)
172
+ if name == "codegraph_report": return await _report(args)
173
+ if name == "codegraph_nodes": return await _nodes(args)
174
+ if name == "codegraph_arch": return await _arch(args)
175
+ if name == "codegraph_affected": return await _affected(args)
176
+ if name == "codegraph_html": return await _export_viz(args)
134
177
  raise ValueError(f"Unknown tool: {name}")
135
178
 
136
179
 
@@ -188,6 +231,10 @@ async def _build(args: dict) -> dict:
188
231
  save_graph(root, graph_dict)
189
232
  generate_report(graph_dict, root)
190
233
  save_cache(root, cache)
234
+ try:
235
+ export_all(graph_dict, root)
236
+ except Exception:
237
+ pass
191
238
 
192
239
  elapsed_ms = int((time.time() - t0) * 1000)
193
240
  result = {
@@ -285,11 +332,56 @@ async def _nodes(args: dict) -> dict:
285
332
  return {"type": node_type, "count": len(matched), "nodes": matched[:limit]}
286
333
 
287
334
 
288
- async def _path(args: dict) -> dict:
335
+ async def _arch(args: dict) -> dict:
336
+ graph_dict = load_graph(args["path"])
337
+ if not graph_dict:
338
+ raise ValueError("No graph found. Run codegraph_build first.")
339
+ limit = args.get("limit", 100)
340
+ return module_map(graph_dict, limit=limit)
341
+
342
+
343
+ async def _affected(args: dict) -> dict:
289
344
  graph_dict = load_graph(args["path"])
290
345
  if not graph_dict:
291
346
  raise ValueError("No graph found. Run codegraph_build first.")
292
- return find_path(args["from"], args["to"], graph_dict)
347
+ depth = min(int(args.get("depth", 2)), 5)
348
+ return run_affected(graph_dict, args["node"], depth=depth)
349
+
350
+
351
+ async def _export_viz(args: dict) -> dict:
352
+ graph_dict = load_graph(args["path"])
353
+ if not graph_dict:
354
+ raise ValueError("No graph found. Run codegraph_build first.")
355
+ cache_dir = str(Path(args["path"]) / "codegraph-cache")
356
+ formats = args.get("formats") or ["html", "graphml", "obsidian", "tree", "callflow"]
357
+ results: dict[str, str] = {}
358
+ if "html" in formats:
359
+ try:
360
+ results["html"] = export_html(graph_dict, str(Path(cache_dir) / "graph.html"))
361
+ except Exception as e:
362
+ results["html_error"] = str(e)
363
+ if "graphml" in formats:
364
+ try:
365
+ results["graphml"] = to_graphml(graph_dict, str(Path(cache_dir) / "graph.graphml"))
366
+ except Exception as e:
367
+ results["graphml_error"] = str(e)
368
+ if "obsidian" in formats:
369
+ try:
370
+ results["obsidian"] = to_obsidian(graph_dict, str(Path(cache_dir) / "obsidian"))
371
+ except Exception as e:
372
+ results["obsidian_error"] = str(e)
373
+ if "tree" in formats:
374
+ try:
375
+ results["tree"] = tree_html(graph_dict, str(Path(cache_dir) / "tree.html"))
376
+ except Exception as e:
377
+ results["tree_error"] = str(e)
378
+ if "callflow" in formats:
379
+ try:
380
+ results["callflow"] = callflow_html(graph_dict, str(Path(cache_dir) / "callflow.html"))
381
+ except Exception as e:
382
+ results["callflow_error"] = str(e)
383
+ results["summary"] = f"Generated: {', '.join(k for k in results if not k.endswith('_error'))}"
384
+ return results
293
385
 
294
386
 
295
387
  # ── Entry point ───────────────────────────────────────────────────────────────
@@ -0,0 +1,241 @@
1
+ """
2
+ tree_html.py — D3 v7 collapsible file-tree HTML from graph.json.
3
+
4
+ Adapted from graphify's tree_html.py.
5
+ Field name differences from graphify: we use 'file' not 'source_file', 'name' not 'label'.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import html as _html
10
+ import json
11
+ from collections import defaultdict
12
+ from pathlib import Path
13
+
14
+ DEFAULT_MAX_CHILDREN = 200
15
+
16
+
17
+ # ── Tree builder ──────────────────────────────────────────────────────────────
18
+
19
+ def _common_root(paths: list[str]) -> str:
20
+ if not paths:
21
+ return ""
22
+ parts = [Path(p.replace("\\", "/")).parts for p in paths if p]
23
+ if not parts:
24
+ return ""
25
+ common = list(parts[0])
26
+ for p in parts[1:]:
27
+ i = 0
28
+ while i < len(common) and i < len(p) and common[i] == p[i]:
29
+ i += 1
30
+ common = common[:i]
31
+ return str(Path(*common)) if common else ""
32
+
33
+
34
+ def build_tree(
35
+ graph_dict: dict,
36
+ *,
37
+ root: str | None = None,
38
+ max_children: int = DEFAULT_MAX_CHILDREN,
39
+ project_label: str | None = None,
40
+ ) -> dict:
41
+ """Build {name, total_count, children} hierarchy from graph nodes."""
42
+ nodes = graph_dict.get("nodes", [])
43
+ file_nodes = [n for n in nodes if n.get("file")]
44
+ if not file_nodes:
45
+ return {"name": "(empty graph)", "total_count": 0, "children": []}
46
+
47
+ if root is None:
48
+ root = _common_root([n["file"].replace("\\", "/") for n in file_nodes])
49
+ root_path = Path(root.replace("\\", "/")) if root else Path(".")
50
+
51
+ by_file: dict[str, list[dict]] = defaultdict(list)
52
+ for n in file_nodes:
53
+ by_file[n["file"].replace("\\", "/")].append(n)
54
+
55
+ dir_index: dict[str, dict] = {}
56
+ label_root = project_label or root_path.name or root or "/"
57
+ root_node: dict = {"name": label_root, "total_count": 0, "children": []}
58
+ dir_index[str(root_path)] = root_node
59
+
60
+ def _ensure_dir(abs_path: Path) -> dict:
61
+ key = str(abs_path)
62
+ if key in dir_index:
63
+ return dir_index[key]
64
+ if abs_path == abs_path.parent:
65
+ return root_node
66
+ parent = _ensure_dir(abs_path.parent) if abs_path.parent != abs_path else root_node
67
+ node = {"name": abs_path.name, "total_count": 0, "children": []}
68
+ dir_index[key] = node
69
+ parent["children"].append(node)
70
+ return node
71
+
72
+ for src_file, syms in sorted(by_file.items()):
73
+ src_path = Path(src_file)
74
+ try:
75
+ rel = src_path.relative_to(root_path)
76
+ parent_path = (root_path / rel).parent
77
+ except ValueError:
78
+ parent_path = root_path
79
+ parent_dir = _ensure_dir(parent_path)
80
+
81
+ sym_children = []
82
+ for n in syms:
83
+ sym_name = n.get("name", n.get("id", "?"))
84
+ if sym_name == src_path.name:
85
+ continue
86
+ sym_children.append({"name": sym_name, "total_count": 1, "children": []})
87
+ sym_children.sort(key=lambda c: (c["name"].startswith("_"), c["name"].lower()))
88
+ if len(sym_children) > max_children:
89
+ extra = len(sym_children) - max_children
90
+ sym_children = sym_children[:max_children] + [{"name": f"(+{extra} more)", "total_count": extra, "children": []}]
91
+ file_node = {"name": src_path.name, "total_count": len(sym_children) or 1, "children": sym_children}
92
+ parent_dir["children"].append(file_node)
93
+
94
+ def _finalise(d: dict) -> int:
95
+ kids = d.get("children") or []
96
+ kids.sort(key=lambda c: (0 if (c.get("children") and len(c["children"]) > 0) else 1, c["name"].lower()))
97
+ if not kids:
98
+ return d.get("total_count") or 1
99
+ n = sum(_finalise(c) for c in kids)
100
+ d["total_count"] = n or 1
101
+ return d["total_count"]
102
+
103
+ _finalise(root_node)
104
+ return root_node
105
+
106
+
107
+ # ── HTML emitter ──────────────────────────────────────────────────────────────
108
+
109
+ _HTML_TEMPLATE = r"""<!DOCTYPE html>
110
+ <html lang="en">
111
+ <head>
112
+ <meta charset="UTF-8">
113
+ <title>{title}</title>
114
+ <style>
115
+ body {{ font-family: 'Segoe UI', sans-serif; margin: 0; padding: 0; background: #f9f9f9; color: #333; }}
116
+ h1 {{ margin: 20px 0 0 24px; font-size: 2.2rem; font-weight: bold; color: #1e3a56; }}
117
+ .controls {{ margin: 20px 0 15px 24px; }}
118
+ button {{ margin-right: 10px; padding: 8px 18px; background: #007bff; color: #fff; border: none; border-radius: 5px; font-size: 0.95rem; cursor: pointer; }}
119
+ button:hover {{ background: #0056b3; }}
120
+ #tree-container {{ width: calc(100vw - 48px); height: 85vh; overflow: auto; border-radius: 8px; background: #fff; margin-left: 24px; box-shadow: 0 4px 12px rgba(0,0,0,0.08); border: 1px solid #ddd; }}
121
+ svg {{ background: #fff; border-radius: 8px; display: block; }}
122
+ .node circle {{ stroke-width: 2.5px; }}
123
+ .node text {{ font: 13px 'Segoe UI', sans-serif; paint-order: stroke fill; stroke: #fff; stroke-width: 3px; stroke-linejoin: round; stroke-opacity: 0.85; }}
124
+ .link {{ fill: none; stroke-opacity: 0.7; stroke-width: 2px; }}
125
+ </style>
126
+ </head>
127
+ <body>
128
+ <h1>{header}</h1>
129
+ <div class="controls">
130
+ <button onclick="expandAll()">Expand All</button>
131
+ <button onclick="collapseAll()">Collapse All</button>
132
+ <button onclick="resetView()">Reset View</button>
133
+ </div>
134
+ <div id="tree-container">
135
+ <svg id="tree-svg" width="{svg_width}" height="{svg_height}"></svg>
136
+ </div>
137
+ <script src="https://d3js.org/d3.v7.min.js"></script>
138
+ <script>
139
+ const initialJsonData = {data_json};
140
+ function transformData(d) {{
141
+ function p(node, parentL1) {{
142
+ let dn = node.name;
143
+ if (node.total_count !== undefined && !/\(Total Count: \d+\)$/.test(dn))
144
+ dn += ` (Total Count: ${{node.total_count}})`;
145
+ const r = {{ name: dn, originalStageName: parentL1 === "Root" ? node.name : parentL1 }};
146
+ if (node.children && node.children.length > 0)
147
+ r.children = node.children.map(c => p(c, parentL1 === "Root" ? node.name : parentL1));
148
+ return r;
149
+ }}
150
+ let rn = d.name;
151
+ if (d.total_count !== undefined && !/\(Total Count: \d+\)$/.test(rn)) rn += ` (Total Count: ${{d.total_count}})`;
152
+ return {{ name: rn, originalStageName: "Root", children: (d.children || []).map(c => p(c, "Root")) }};
153
+ }}
154
+ const treeData = transformData(initialJsonData);
155
+ const PALETTE = [
156
+ ["#3498DB","#2980B9","#AED6F1"],["#2ECC71","#27AE60","#A9DFBF"],
157
+ ["#E74C3C","#C0392B","#F5B7B1"],["#9B59B6","#8E44AD","#D7BDE2"],
158
+ ["#F39C12","#D68910","#FAD7A0"],["#1ABC9C","#117864","#A2D9CE"],
159
+ ["#34495E","#1B2631","#ABB2B9"],["#E67E22","#BA4A00","#F5CBA7"],
160
+ ];
161
+ const phaseColors = {{ "Root": {{ fill:"#4A4A4A",stroke:"#333333",collapsedFill:"#6C757D" }}, "Default": {{ fill:"#BDC3C7",stroke:"#95A5A6",collapsedFill:"#ECF0F1" }} }};
162
+ (initialJsonData.children||[]).forEach((c,i) => {{ const pal=PALETTE[i%PALETTE.length]; phaseColors[c.name]={{ fill:pal[0],stroke:pal[1],collapsedFill:pal[2] }}; }});
163
+ const levelPalettes = {{
164
+ 0:{{fill:"#4A4A4A",stroke:"#333333",collapsedFill:"#6C757D"}},
165
+ 2:{{fill:"#6ab04c",stroke:"#508a38",collapsedFill:"#a3d391"}},
166
+ 3:{{fill:"#f0932b",stroke:"#d0730f",collapsedFill:"#f6c07e"}},
167
+ 4:{{fill:"#be2edd",stroke:"#a01cb3",collapsedFill:"#e08bf2"}},
168
+ default:{{fill:"#747d8c",stroke:"#57606f",collapsedFill:"#a4b0be"}}
169
+ }};
170
+ const svg = d3.select("#tree-svg");
171
+ const margin = {{top:40,right:120,bottom:80,left:450}};
172
+ const duration = 500;
173
+ let nc = 0;
174
+ const g = svg.append("g").attr("transform",`translate(${{margin.left}},${{margin.top}})`);
175
+ const treemap = d3.tree().nodeSize([40,0]);
176
+ let root = d3.hierarchy(treeData, d=>d.children);
177
+ root.x0=0; root.y0=0;
178
+ if (root.children) root.children.forEach(collapse);
179
+ update(root);
180
+ function collapse(d) {{ if(d.children){{ d._children=d.children; d._children.forEach(collapse); d.children=null; }} }}
181
+ function expand(d) {{ if(d._children){{ d.children=d._children; d._children=null; }} if(d.children) d.children.forEach(expand); }}
182
+ window.expandAll=()=>{{ expand(root); update(root); }};
183
+ window.collapseAll=()=>{{ if(root.children) root.children.forEach(collapse); update(root); }};
184
+ window.resetView=()=>{{ if(root.children) root.children.forEach(c=>{{ if(c.children||c._children) collapse(c); }}); update(root); }};
185
+ function pal(d) {{
186
+ if (d.depth===0) return levelPalettes[0];
187
+ if (d.depth===1) return phaseColors[d.data.originalStageName]||phaseColors.Default;
188
+ return levelPalettes[d.depth]||levelPalettes.default;
189
+ }}
190
+ function update(src) {{
191
+ const td = treemap(root);
192
+ const nodes = td.descendants(), links = td.descendants().slice(1);
193
+ const minX = d3.min(nodes,d=>d.x)||0, maxX = d3.max(nodes,d=>d.x)||0;
194
+ svg.transition().duration(duration/2).attr("height",Math.max(+svg.attr("height"),maxX-minX+margin.top+margin.bottom+100));
195
+ g.transition().duration(duration/2).attr("transform",`translate(${{margin.left}},${{margin.top-minX+40}})`);
196
+ nodes.forEach(d=>{{ d.y=d.depth*400; }});
197
+ const node = g.selectAll('g.node').data(nodes, d=>d.id||(d.id=++nc));
198
+ const ne = node.enter().append('g')
199
+ .attr('class','node')
200
+ .attr('transform',`translate(${{src.y0}},${{src.x0}})`)
201
+ .style('cursor',d=>(d.children||d._children)?'pointer':'default')
202
+ .on('click',(e,d)=>{{ if(d.children){{ d._children=d.children; d.children=null; }} else if(d._children){{ d.children=d._children; d._children=null; }} update(d); }});
203
+ ne.append('circle').attr('r',1e-6);
204
+ ne.append('text').attr('dy','.35em').attr('x',d=>d.children||d._children?-14:14).attr('text-anchor',d=>d.children||d._children?'end':'start').style("fill-opacity",1e-6);
205
+ const nu = ne.merge(node);
206
+ nu.transition().duration(duration).attr('transform',d=>`translate(${{d.y}},${{d.x}})`);
207
+ nu.select('circle').attr('r',8.5)
208
+ .style('fill',d=>d._children?pal(d).collapsedFill:d.children?pal(d).fill:"#fff")
209
+ .style('stroke',d=>pal(d).stroke);
210
+ nu.select('text').style("fill-opacity",1).text(d=>d.data.name.replace(/\s*\(Total Count: \d+\)$/,''));
211
+ node.exit().transition().duration(duration).attr('transform',`translate(${{src.y}},${{src.x}})`).remove();
212
+ const link = g.selectAll('path.link').data(links,d=>d.id);
213
+ link.enter().insert('path',"g").attr('class','link').attr('d',d=>{{ const o={{x:src.x0,y:src.y0}}; return `M${{o.y}} ${{o.x}} C${{(o.y+o.y)/2}} ${{o.x}},${{(o.y+o.y)/2}} ${{o.x}},${{o.y}} ${{o.x}}`; }})
214
+ .merge(link).transition().duration(duration).attr('d',d=>`M${{d.y}} ${{d.x}} C${{(d.y+d.parent.y)/2}} ${{d.x}},${{(d.y+d.parent.y)/2}} ${{d.parent.x}},${{d.parent.y}} ${{d.parent.x}}`)
215
+ .style('stroke',d=>pal(d.parent).stroke);
216
+ link.exit().transition().duration(duration).attr('d',d=>{{ const o={{x:src.x,y:src.y}}; return `M${{o.y}} ${{o.x}} C${{o.y}} ${{o.x}},${{o.y}} ${{o.x}},${{o.y}} ${{o.x}}`; }}).remove();
217
+ nodes.forEach(d=>{{ d.x0=d.x; d.y0=d.y; }});
218
+ }}
219
+ </script>
220
+ </body>
221
+ </html>"""
222
+
223
+
224
+ def to_html(graph_dict: dict, output_path: str, *, project_label: str | None = None) -> str:
225
+ """Generate D3 collapsible tree HTML. Returns path written."""
226
+ tree = build_tree(graph_dict, project_label=project_label)
227
+ title = _html.escape(f"{tree['name']} — CodeGraph file tree")
228
+ header = _html.escape(f"{tree['name']} — File Tree")
229
+ data_json = json.dumps(tree, ensure_ascii=False, separators=(",", ":")).replace("</", "<\\/")
230
+
231
+ html_content = _HTML_TEMPLATE.format(
232
+ title=title,
233
+ header=header,
234
+ svg_width=6000,
235
+ svg_height=8000,
236
+ data_json=data_json,
237
+ )
238
+ out = Path(output_path)
239
+ out.parent.mkdir(parents=True, exist_ok=True)
240
+ out.write_text(html_content, encoding="utf-8")
241
+ return str(out)
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "context-mcp-server",
3
- "version": "1.0.8",
4
- "description": "Persistent AI memory + codebase knowledge graph MCP server. Works across Claude Code, Cursor, Gemini CLI, Codex, Windsurf, VS Code Copilot, Claude.ai, and ChatGPT.",
3
+ "version": "1.1.1",
4
+ "description": "Persistent AI memory + codebase knowledge graph MCP server. Works across Claude Code, Cursor, Gemini CLI, Codex, Windsurf, VS Code Copilot, Antigravity IDE, Claude.ai, and ChatGPT.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "context-mcp": "./src/index.js",
package/pyproject.toml CHANGED
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "codegraph-mcp"
7
- version = "1.0.8"
7
+ version = "1.1.1"
8
8
  description = "Codebase knowledge graph MCP server — AST extraction, graph queries, community detection"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -18,6 +18,9 @@ dependencies = [
18
18
  ]
19
19
 
20
20
  [project.optional-dependencies]
21
+ leiden = [
22
+ "graspologic>=3.3",
23
+ ]
21
24
  treesitter = [
22
25
  "tree-sitter>=0.23.0",
23
26
  "tree-sitter-python",