context-mcp-server 1.1.2 → 1.1.4

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 (47) hide show
  1. package/README.md +2 -3
  2. package/codegraph/__pycache__/callflow_html.cpython-313.pyc +0 -0
  3. package/codegraph/__pycache__/export.cpython-313.pyc +0 -0
  4. package/codegraph/__pycache__/report.cpython-313.pyc +0 -0
  5. package/codegraph/__pycache__/scanner.cpython-313.pyc +0 -0
  6. package/codegraph/__pycache__/server.cpython-313.pyc +0 -0
  7. package/codegraph/__pycache__/tree_html.cpython-313.pyc +0 -0
  8. package/codegraph/callflow_html.py +6 -4
  9. package/codegraph/export.py +25 -10
  10. package/codegraph/extractors/__pycache__/ast_extractor.cpython-313.pyc +0 -0
  11. package/codegraph/extractors/ast_extractor.py +37 -8
  12. package/codegraph/graph/__pycache__/builder.cpython-313.pyc +0 -0
  13. package/codegraph/graph/__pycache__/clustering.cpython-313.pyc +0 -0
  14. package/codegraph/graph/__pycache__/query.cpython-313.pyc +0 -0
  15. package/codegraph/graph/__pycache__/symbol_resolution.cpython-313.pyc +0 -0
  16. package/codegraph/graph/builder.py +27 -23
  17. package/codegraph/graph/clustering.py +5 -3
  18. package/codegraph/graph/query.py +5 -4
  19. package/codegraph/graph/symbol_resolution.py +14 -3
  20. package/codegraph/report.py +1 -1
  21. package/codegraph/scanner.py +1 -1
  22. package/codegraph/server.py +26 -5
  23. package/codegraph/tree_html.py +28 -30
  24. package/package.json +2 -2
  25. package/pyproject.toml +72 -72
  26. package/src/cli.js +12 -42
  27. package/src/db.js +26 -14
  28. package/src/http.js +1 -1
  29. package/src/search.js +4 -9
  30. package/src/server.js +16 -7
  31. package/src/templates/antigravity/GEMINI.md +18 -6
  32. package/src/templates/claude/CLAUDE.md +14 -1
  33. package/src/templates/claude/skills/SKILL.md +15 -3
  34. package/src/templates/codex/AGENTS.md +9 -2
  35. package/src/templates/cursor/cursor-rules.mdc +13 -4
  36. package/src/templates/gemini/GEMINI.md +14 -3
  37. package/src/templates/windsurf/windsurf-rules.md +14 -3
  38. package/src/tools/codegraph.js +4 -1
  39. package/src/tools/context.js +6 -6
  40. package/src/tools/errorCheck.js +3 -3
  41. package/src/tools/fileTools.js +2 -2
  42. package/src/tools/gitTools.js +1 -1
  43. package/src/tools/search.js +1 -1
  44. package/src/tools/symbolDetail.js +74 -0
  45. package/src/tools/toolRegistry.js +77 -0
  46. package/src/vector.js +7 -2
  47. package/uv.lock +3 -3
package/README.md CHANGED
@@ -11,7 +11,7 @@
11
11
 
12
12
  Persistent memory and codebase knowledge graph for AI coding assistants — delivered as a single MCP server.
13
13
 
14
- One shared context store across Claude Code, Cursor, Gemini CLI, Codex, Windsurf, VS Code Copilot, Antigravity IDE, Claude.ai, and ChatGPT. Save context from one AI, pick it up in another.
14
+ One shared context store across Claude Code, Cursor, Gemini CLI, Codex, Windsurf, VS Code Copilot, Claude.ai, and ChatGPT. Save context from one AI, pick it up in another.
15
15
 
16
16
  ---
17
17
 
@@ -77,8 +77,7 @@ ctx install --cursor # Cursor
77
77
  ctx install --vscode # VS Code Copilot
78
78
  ctx install --gemini # Gemini CLI
79
79
  ctx install --codex # Codex CLI
80
- ctx install --windsurf # Windsurf
81
- ctx install --antigravity # Antigravity IDE
80
+ ctx install --windsurf # Windsurf
82
81
  ```
83
82
 
84
83
  For Codex project installs, `ctx install --codex` writes:
@@ -52,8 +52,10 @@ hr{border:none;border-top:1px solid var(--border);margin:40px 0;}
52
52
  # ── Mermaid helpers ───────────────────────────────────────────────────────────
53
53
 
54
54
  def _mermaid_id(s: str) -> str:
55
- """Sanitize string for use as a Mermaid node ID."""
56
- return re.sub(r"[^a-zA-Z0-9_]", "_", s)[:40] or "node"
55
+ """Sanitize for Mermaid node ID; include hash suffix to prevent collisions."""
56
+ sanitized = re.sub(r"[^a-zA-Z0-9_]", "_", s)[:32] or "node"
57
+ h = format(hash(s) & 0xFFFF, "04x")
58
+ return f"{sanitized}_{h}"
57
59
 
58
60
 
59
61
  def _mermaid_label(s: str) -> str:
@@ -210,7 +212,7 @@ def to_html(graph_dict: dict, output_path: str) -> str:
210
212
  <p>{len(nodes)} nodes · {len(edges)} edges · {len(communities)} communities · generated {escape(generated)}</p>
211
213
  {god_html}
212
214
  <div class="mermaid">
213
- {escape(overview_mermaid)}
215
+ {overview_mermaid}
214
216
  </div>
215
217
  </section>"""
216
218
 
@@ -239,7 +241,7 @@ def to_html(graph_dict: dict, output_path: str) -> str:
239
241
  <div class="card"><h3>Key Files</h3><ul>{files_html}</ul></div>
240
242
  </div>
241
243
  <div class="mermaid">
242
- {escape(diagram)}
244
+ {diagram}
243
245
  </div>
244
246
  {"<h3>Incoming Cross-Community Calls</h3>" + table if table else ""}
245
247
  <hr>
@@ -128,6 +128,10 @@ def to_html(graph_dict: dict, output_path: str) -> str:
128
128
  <body>
129
129
  <div id="graph"></div>
130
130
  <div id="sidebar">
131
+ <div id="toolbar">
132
+ <button class="tb-btn" onclick="network.fit()">Fit</button>
133
+ <button class="tb-btn" onclick="network.setOptions({{physics:{{enabled:true}}}});setTimeout(()=>network.setOptions({{physics:{{enabled:false}}}}),2000)">Relayout</button>
134
+ </div>
131
135
  <div id="search-wrap">
132
136
  <input id="search" type="text" placeholder="Search nodes…" autocomplete="off">
133
137
  <div id="search-results"></div>
@@ -184,14 +188,20 @@ def _html_styles() -> str:
184
188
  .legend-dot { width: 12px; height: 12px; border-radius: 50%; flex-shrink: 0; }
185
189
  .legend-label { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
186
190
  .legend-count { color: #666; font-size: 11px; }
187
- #stats { padding: 10px 14px; border-top: 1px solid #2a2a4e; font-size: 11px; color: #555; }
191
+ #stats { padding: 10px 14px; border-top: 1px solid #2a2a4e; font-size: 11px; color: #888; }
192
+ #toolbar { padding: 6px 8px; border-bottom: 1px solid #2a2a4e; display: flex; gap: 6px; }
193
+ .tb-btn { background: #1a1a2e; border: 1px solid #3a3a5e; color: #c0c0d0; border-radius: 4px; padding: 3px 10px; font-size: 11px; cursor: pointer; }
194
+ .tb-btn:hover { background: #2a2a4e; border-color: #4E79A7; color: #e0e0e0; }
188
195
  #legend-controls { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; padding: 4px 0; }
189
196
  #legend-controls label { display: flex; align-items: center; gap: 6px; cursor: pointer; font-size: 12px; color: #aaa; user-select: none; }
190
197
  #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; }
198
+ #select-all-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; }
192
199
  #select-all-cb:checked { background: #4E79A7; border-color: #4E79A7; }
200
+ #select-all-cb:checked::after { content: "✓"; position: absolute; color: #fff; font-size: 10px; top: -2px; left: 1px; }
193
201
  .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
202
  .legend-cb:checked { background: #4E79A7; border-color: #4E79A7; }
203
+ .legend-cb:checked::after { content: "✓"; position: absolute; color: #fff; font-size: 10px; top: -2px; left: 1px; }
204
+ .type-badge { display: inline-block; font-size: 10px; padding: 1px 5px; border-radius: 3px; margin-left: 4px; background: #2a2a4e; color: #888; vertical-align: middle; }
195
205
  </style>"""
196
206
 
197
207
 
@@ -205,15 +215,21 @@ function esc(s) {{
205
215
  return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
206
216
  }}
207
217
 
218
+ function makeTooltip(html) {{
219
+ const d = document.createElement('div');
220
+ d.style.cssText = 'background:#1a1a2e;border:1px solid #3a3a5e;border-radius:6px;padding:8px 12px;font-size:12px;color:#e0e0e0;max-width:260px;line-height:1.6;';
221
+ d.innerHTML = html;
222
+ return d;
223
+ }}
208
224
  const nodesDS = new vis.DataSet(RAW_NODES.map(n => ({{
209
225
  id: n.id, label: n.label, color: n.color, size: n.size,
210
- font: n.font, title: n.title,
226
+ font: n.font, title: makeTooltip(n.title),
211
227
  _community: n.community, _community_name: n.community_name,
212
228
  _source_file: n.source_file, _file_type: n.file_type, _degree: n.degree,
213
229
  }})));
214
230
 
215
231
  const edgesDS = new vis.DataSet(RAW_EDGES.map((e, i) => ({{
216
- id: i, from: e.from, to: e.to, title: e.title,
232
+ id: i, from: e.from, to: e.to, title: e.title ? makeTooltip(esc(e.title)) : undefined,
217
233
  dashes: e.dashes, width: e.width, color: e.color,
218
234
  arrows: {{ to: {{ enabled: true, scaleFactor: 0.5 }} }},
219
235
  }})));
@@ -243,11 +259,10 @@ function showInfo(nodeId) {{
243
259
  return `<span class="neighbor-link" style="border-left-color:${{esc(color)}}" onclick="focusNode(${{JSON.stringify(nid)}})">${{esc(nb ? nb.label : nid)}}</span>`;
244
260
  }}).join('');
245
261
  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>
262
+ <div class="field"><b>${{esc(n.label)}}</b><span class="type-badge">${{esc(n._file_type || '?')}}</span></div>
263
+ <div class="field">Community: ${{esc(n._community_name || '')}}</div>
264
+ <div class="field" title="${{esc(n._source_file || '')}}">File: ${{esc((n._source_file || '').split('/').pop() || n._source_file || '—')}}</div>
265
+ <div class="field">Connections: ${{n._degree}}</div>
251
266
  ${{neighborIds.length ? `<div style="margin-top:8px;color:#aaa;font-size:11px">Neighbors (${{neighborIds.length}})</div><div id="neighbors-list">${{neighborItems}}</div>` : ''}}
252
267
  `;
253
268
  }}
@@ -416,7 +431,7 @@ def to_obsidian(graph_dict: dict, output_dir: str) -> str:
416
431
  fpath = n.get("file", "")
417
432
  ntype = n.get("type", "")
418
433
  comm = node_community.get(nid)
419
- comm_tag = _obsidian_tag(comm["label"]) if comm else "misc"
434
+ comm_tag = _obsidian_tag(comm.get("label", f"community_{comm['id']}")) if comm else "misc"
420
435
 
421
436
  lines = [
422
437
  "---",
@@ -326,14 +326,35 @@ def _extract_with_treesitter(source: bytes, rel_path: str, cfg: dict) -> list[di
326
326
  import_names: list[str] = []
327
327
  for node in _walk(root, cfg["import_types"]):
328
328
  text = node.text.decode("utf-8", errors="ignore").strip()
329
- m = re.match(r'(?:import|from)\s+([\w./"\']+)', text)
329
+ raw = None
330
+ # Try 'from "module"' or "from 'module'" first (JS/TS/Python)
331
+ m = re.search(r'from\s+["\']([^"\']+)["\']', text)
330
332
  if m:
331
- raw = m.group(1).strip("\"'").split(".")[0].split("/")[-1]
332
- if raw and raw not in import_names:
333
- import_names.append(raw)
334
- if import_names:
335
- for entry in nodes:
336
- entry["imports"] = import_names[:]
333
+ raw = m.group(1)
334
+ if not raw:
335
+ # Plain: import module or import "module"
336
+ m = re.search(r'import\s+["\']?([a-zA-Z_][\w./\\-]*)["\']?', text)
337
+ if m:
338
+ raw = m.group(1)
339
+ if not raw:
340
+ # require("module")
341
+ m = re.search(r'require\s*\(\s*["\']([^"\']+)["\']\s*\)', text)
342
+ if m:
343
+ raw = m.group(1)
344
+ if raw:
345
+ stem = raw.strip("\"'").replace("\\", "/").split("/")[-1].split(".")[0]
346
+ if stem and stem not in import_names:
347
+ import_names.append(stem)
348
+ # For languages with empty import_types (Ruby, Lua), fall back to regex
349
+ if not import_names and not cfg.get("import_types"):
350
+ source_str = source_bytes.decode("utf-8", errors="ignore")
351
+ ext = Path(rel_path).suffix.lower()
352
+ lang_name = _EXT_TO_LANG_NAME.get(ext, "")
353
+ import_names = _collect_imports_regex(source_str, lang_name)
354
+
355
+ # Always assign (empty list is valid — don't gate on non-empty)
356
+ for entry in nodes:
357
+ entry["imports"] = import_names[:]
337
358
 
338
359
  return nodes
339
360
 
@@ -435,6 +456,12 @@ _IMPORT_RE: dict[str, re.Pattern] = {
435
456
  "rust": re.compile(r'use\s+([\w:]+)', re.MULTILINE),
436
457
  "java": re.compile(r'import\s+([\w.]+)', re.MULTILINE),
437
458
  "csharp": re.compile(r'using\s+([\w.]+)', re.MULTILINE),
459
+ "ruby": re.compile(r'require(?:_relative)?\s*["\']([^"\']+)["\']', re.MULTILINE),
460
+ "lua": re.compile(r'require\s*\(?["\']([^"\']+)["\']\)?', re.MULTILINE),
461
+ "c": re.compile(r'#include\s+[<"]([^>"]+)[>"]', re.MULTILINE),
462
+ "cpp": re.compile(r'#include\s+[<"]([^>"]+)[>"]', re.MULTILINE),
463
+ "dart": re.compile(r'import\s+["\']([^"\']+)["\']', re.MULTILINE),
464
+ "swift": re.compile(r'^import\s+([\w.]+)', re.MULTILINE),
438
465
  }
439
466
 
440
467
  # Keywords that look like calls but aren't
@@ -530,8 +557,10 @@ def _extract_with_regex(source: str, rel_path: str, ext: str) -> list[dict]:
530
557
  "imports": import_names[:],
531
558
  })
532
559
 
560
+ # brace-scope tracking only works for brace-delimited languages
561
+ _BRACE_LANGS = {"javascript", "typescript", "go", "rust", "java", "c", "cpp", "csharp", "php", "swift"}
533
562
  func_nodes = [n for n in nodes if n["type"] == "function"]
534
- if func_nodes:
563
+ if func_nodes and lang in _BRACE_LANGS:
535
564
  _attach_calls_brace(lines, func_nodes)
536
565
 
537
566
  return nodes
@@ -25,44 +25,48 @@ def build(all_nodes: list[dict]) -> "nx.DiGraph | dict":
25
25
 
26
26
  G = nx.DiGraph()
27
27
 
28
- node_by_name: dict[str, str] = {} # name -> id
29
- module_by_file: dict[str, str] = {} # rel_path -> module node id
28
+ _name_to_ids: dict[str, list[str]] = {} # name -> [ids] (may be multiple)
29
+ file_rep: dict[str, str] = {} # rel_path -> representative node id (module > file > first)
30
+ file_imports: dict[str, list[str]] = {} # rel_path -> aggregated import names
30
31
 
31
32
  for node in all_nodes:
32
33
  nid = node.get("id", "")
33
34
  if not nid:
34
35
  continue
35
36
  G.add_node(nid, **{k: v for k, v in node.items() if k not in ("imports", "calls", "relations")})
36
- node_by_name[node.get("name", "")] = nid
37
- if node.get("type") == "module":
38
- module_by_file[node.get("file", "")] = nid
37
+ name = node.get("name", "")
38
+ if name:
39
+ _name_to_ids.setdefault(name, []).append(nid)
40
+ # Track a representative node per file (prefer module, then file, then first seen)
41
+ frel = node.get("file", "")
42
+ ntype = node.get("type", "")
43
+ if frel and (frel not in file_rep or ntype in ("module", "file")):
44
+ file_rep[frel] = nid
45
+ # Aggregate imports per file from any node type
46
+ for imp in node.get("imports", []):
47
+ lst = file_imports.setdefault(frel, [])
48
+ if imp not in lst:
49
+ lst.append(imp)
50
+
51
+ # Unambiguous name→id map: only include names that resolve to exactly one node
52
+ node_by_name: dict[str, str] = {n: ids[0] for n, ids in _name_to_ids.items() if len(ids) == 1}
39
53
 
40
- # Build file-path lookup from module nodes
54
+ # Build file-path lookup from all file/module representative nodes
41
55
  file_node: dict[str, str] = {}
42
- for rel_path, mod_id in module_by_file.items():
56
+ for rel_path, rep_id in file_rep.items():
43
57
  p = rel_path.replace("\\", "/")
44
58
  stem = p.split("/")[-1].split(".")[0]
45
59
  base = p.split("/")[-1]
46
60
  for key in (stem, base, p):
47
- file_node.setdefault(key, mod_id)
48
-
49
- # defined-in edges: child nodes → their module
50
- for node in all_nodes:
51
- nid = node.get("id", "")
52
- for rel in node.get("relations", []):
53
- target_id = rel.get("id") or node_by_name.get(rel.get("name", ""))
54
- if target_id and target_id != nid:
55
- G.add_edge(nid, target_id,
56
- relation=rel.get("relation", "relates-to"),
57
- confidence=rel.get("confidence", "EXTRACTED"))
61
+ file_node.setdefault(key, rep_id)
58
62
 
59
- # Import edges: modulemodule
63
+ # Import edges: filefile (aggregated from all node types per file)
60
64
  seen_edges: set[tuple] = set()
61
- for node in all_nodes:
62
- if node.get("type") != "module":
65
+ for frel, imports in file_imports.items():
66
+ src_id = file_rep.get(frel)
67
+ if not src_id:
63
68
  continue
64
- src_id = node.get("id", "")
65
- for imp in node.get("imports", []):
69
+ for imp in imports:
66
70
  clean = imp.lstrip(".")
67
71
  parts = clean.replace("\\", "/").split("/")
68
72
  last = parts[-1]
@@ -64,8 +64,9 @@ def _partition(G: nx.Graph, resolution: float = 1.0) -> dict[str, int]:
64
64
  except ImportError:
65
65
  pass
66
66
 
67
- kwargs = {"seed": 42, "threshold": 1e-4, "resolution": resolution}
68
- if "max_level" in inspect.signature(nx.community.louvain_communities).parameters:
67
+ louvain_sig = inspect.signature(nx.community.louvain_communities).parameters
68
+ kwargs = {"seed": 42, "resolution": resolution}
69
+ if "max_level" in louvain_sig:
69
70
  kwargs["max_level"] = 10
70
71
  communities = nx.community.louvain_communities(stable, **kwargs)
71
72
  return {node: cid for cid, nodes in enumerate(communities) for node in nodes}
@@ -102,7 +103,8 @@ def cluster(
102
103
  if exclude_hubs_percentile is not None:
103
104
  degrees = sorted(d for _, d in G.degree())
104
105
  if degrees:
105
- idx = max(0, int(len(degrees) * exclude_hubs_percentile / 100) - 1)
106
+ # idx is the last position we keep; nodes beyond this are hubs
107
+ idx = min(len(degrees) - 1, int(len(degrees) * exclude_hubs_percentile / 100))
106
108
  threshold = degrees[idx]
107
109
  hub_nodes = {n for n, d in G.degree() if d > threshold}
108
110
 
@@ -5,6 +5,7 @@ No LLM call on query — pure graph + keyword matching.
5
5
  """
6
6
 
7
7
  import re
8
+ from collections import deque
8
9
  from typing import Any
9
10
 
10
11
 
@@ -114,10 +115,10 @@ def _find_by_name(nodes: list, name: str) -> dict | None:
114
115
 
115
116
 
116
117
  def _detect_intent(q: str) -> str:
117
- if any(w in q for w in ("depend", "import", "use", "require")):
118
- return "depends_on"
119
118
  if any(w in q for w in ("used by", "who calls", "caller")):
120
119
  return "used_by"
120
+ if any(w in q for w in ("depend", "import", "use", "require")):
121
+ return "depends_on"
121
122
  if any(w in q for w in ("path", "connect", "relate", "between")):
122
123
  return "path"
123
124
  if any(w in q for w in ("list", "all", "show all", "every")):
@@ -164,9 +165,9 @@ def _shortest_path(from_node: dict, to_node: dict, edges: list, nodes: list) ->
164
165
 
165
166
  start, end = from_node["id"], to_node["id"]
166
167
  visited = {start: None}
167
- queue = [start]
168
+ queue: deque[str] = deque([start])
168
169
  while queue:
169
- cur = queue.pop(0)
170
+ cur = queue.popleft()
170
171
  if cur == end:
171
172
  break
172
173
  for nb in adj.get(cur, []):
@@ -79,19 +79,30 @@ def resolve_calls(
79
79
  continue
80
80
 
81
81
  target_ids: list[str] = []
82
+ via_import = False
82
83
 
83
84
  # 1. Try (imported_module_stem, callee_name) for import-guided resolution
84
85
  for stem in imported_stems:
85
86
  candidates = module_index.get((stem, callee_name), [])
86
87
  target_ids.extend(candidates)
88
+ if target_ids:
89
+ via_import = True
87
90
 
88
91
  # 2. Fall back to global unique name match
89
92
  if not target_ids:
90
93
  target_ids = name_index.get(callee_name, [])
91
94
 
92
- # Only emit when unambiguous
93
- if len(target_ids) != 1:
95
+ # Resolve ambiguity: prefer match in same directory as caller
96
+ if len(target_ids) == 0:
94
97
  continue
98
+ if len(target_ids) > 1:
99
+ caller_dir = str(Path(caller_file.replace("\\", "/")).parent)
100
+ same_dir = [t for t in target_ids
101
+ if t.replace("\\", "/").startswith(caller_dir + "/")]
102
+ if len(same_dir) == 1:
103
+ target_ids = same_dir
104
+ else:
105
+ continue # still ambiguous after narrowing
95
106
 
96
107
  target_id = target_ids[0]
97
108
  if target_id == caller_id:
@@ -101,7 +112,7 @@ def resolve_calls(
101
112
  continue
102
113
 
103
114
  existing_edge_keys.add(key)
104
- confidence = "EXTRACTED" if imported_stems else "INFERRED"
115
+ confidence = "EXTRACTED" if via_import else "INFERRED"
105
116
  new_edges.append({
106
117
  "from": caller_id,
107
118
  "to": target_id,
@@ -22,7 +22,7 @@ def _build_report(g: dict) -> str:
22
22
  god_nodes = g.get("god_nodes", [])
23
23
  generated = g.get("generated_at", "")
24
24
 
25
- node_map = {n["id"]: n for n in nodes}
25
+ node_map = {n["id"]: n for n in nodes if "id" in n}
26
26
 
27
27
  lines = [
28
28
  "# CodeGraph Report",
@@ -19,7 +19,7 @@ def walk_files(root: str, extra_ignore: set | None = None) -> Iterator[str]:
19
19
  ignore = DEFAULT_IGNORE | (extra_ignore or set())
20
20
  for dirpath, dirnames, filenames in os.walk(root):
21
21
  # Prune ignored dirs in-place so os.walk doesn't descend
22
- dirnames[:] = [d for d in dirnames if d not in ignore and not d.startswith(".")]
22
+ dirnames[:] = [d for d in dirnames if not _should_ignore(d, ignore)]
23
23
  for fname in filenames:
24
24
  ext = Path(fname).suffix.lower()
25
25
  if fname in SKIP_FILENAMES or ext in SKIP_EXTENSIONS:
@@ -192,13 +192,30 @@ async def _build(args: dict) -> dict:
192
192
 
193
193
  all_nodes: list[dict] = []
194
194
 
195
- for nodes in cached.values():
196
- all_nodes.extend(nodes)
195
+ for rel_path, nodes in cached.items():
196
+ if nodes:
197
+ all_nodes.extend(nodes)
198
+ else:
199
+ # Previously cached as empty — still add a file-level node so it's visible
200
+ all_nodes.append({
201
+ "id": f"{rel_path}::file::{Path(rel_path).name}",
202
+ "name": Path(rel_path).name,
203
+ "type": "file",
204
+ "file": rel_path,
205
+ })
197
206
 
198
207
  for rel_path, abs_path in changed.items():
199
208
  cat = classify_file(abs_path)
200
209
  if cat in ("code", "sql"):
201
210
  nodes = ast_extract(abs_path, rel_path)
211
+ if not nodes:
212
+ # Extractor found no symbols — still represent the file so it appears in the graph
213
+ nodes = [{
214
+ "id": f"{rel_path}::file::{Path(rel_path).name}",
215
+ "name": Path(rel_path).name,
216
+ "type": "file",
217
+ "file": rel_path,
218
+ }]
202
219
  set_cached_nodes(cache, rel_path, file_hash(abs_path), nodes)
203
220
  all_nodes.extend(nodes)
204
221
  elif cat == "config":
@@ -231,10 +248,13 @@ async def _build(args: dict) -> dict:
231
248
  save_graph(root, graph_dict)
232
249
  generate_report(graph_dict, root)
233
250
  save_cache(root, cache)
251
+
252
+ cache_dir = str(Path(root) / "codegraph-cache")
253
+ viz = {}
234
254
  try:
235
- export_all(graph_dict, root)
236
- except Exception:
237
- pass
255
+ viz = export_all(graph_dict, cache_dir) or {}
256
+ except Exception as e:
257
+ viz = {"error": str(e)}
238
258
 
239
259
  elapsed_ms = int((time.time() - t0) * 1000)
240
260
  result = {
@@ -247,6 +267,7 @@ async def _build(args: dict) -> dict:
247
267
  "deleted": len(deleted),
248
268
  "time_ms": elapsed_ms,
249
269
  "summary": f"Built graph: {len(graph_dict.get('nodes', []))} nodes from code files.",
270
+ "outputs": viz,
250
271
  }
251
272
 
252
273
  return result
@@ -112,24 +112,27 @@ _HTML_TEMPLATE = r"""<!DOCTYPE html>
112
112
  <meta charset="UTF-8">
113
113
  <title>{title}</title>
114
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; }}
115
+ * {{ box-sizing: border-box; margin: 0; padding: 0; }}
116
+ body {{ font-family: 'Segoe UI', system-ui, sans-serif; background: #0f0f1a; color: #e0e0e0; }}
117
+ header {{ padding: 18px 28px 10px; border-bottom: 1px solid #2a2a4e; }}
118
+ h1 {{ font-size: 1.5rem; font-weight: 600; color: #e0e0e0; letter-spacing: -0.01em; }}
119
+ h1 span {{ color: #4E79A7; }}
120
+ .controls {{ padding: 12px 28px; display: flex; gap: 10px; border-bottom: 1px solid #2a2a4e; }}
121
+ button {{ padding: 6px 16px; background: #1a1a2e; color: #c0c0d0; border: 1px solid #3a3a5e; border-radius: 5px; font-size: 0.85rem; cursor: pointer; }}
122
+ button:hover {{ background: #2a2a4e; border-color: #4E79A7; color: #e0e0e0; }}
123
+ #tree-container {{ width: 100vw; height: calc(100vh - 100px); overflow: auto; }}
124
+ svg {{ background: #0f0f1a; display: block; }}
125
+ .node circle {{ stroke-width: 2px; }}
126
+ .node text {{ font: 12px 'Segoe UI', sans-serif; fill: #c8c8d8; paint-order: stroke fill; stroke: #0f0f1a; stroke-width: 3px; stroke-linejoin: round; stroke-opacity: 0.9; }}
127
+ .link {{ fill: none; stroke-opacity: 0.4; stroke-width: 1.5px; }}
125
128
  </style>
126
129
  </head>
127
130
  <body>
128
- <h1>{header}</h1>
131
+ <header><h1><span>◈</span> {header}</h1></header>
129
132
  <div class="controls">
130
133
  <button onclick="expandAll()">Expand All</button>
131
134
  <button onclick="collapseAll()">Collapse All</button>
132
- <button onclick="resetView()">Reset View</button>
135
+ <button onclick="resetView()">Reset</button>
133
136
  </div>
134
137
  <div id="tree-container">
135
138
  <svg id="tree-svg" width="{svg_width}" height="{svg_height}"></svg>
@@ -139,33 +142,28 @@ _HTML_TEMPLATE = r"""<!DOCTYPE html>
139
142
  const initialJsonData = {data_json};
140
143
  function transformData(d) {{
141
144
  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 }};
145
+ const r = {{ name: node.name, count: node.total_count || 0, originalStageName: parentL1 === "Root" ? node.name : parentL1 }};
146
146
  if (node.children && node.children.length > 0)
147
147
  r.children = node.children.map(c => p(c, parentL1 === "Root" ? node.name : parentL1));
148
148
  return r;
149
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")) }};
150
+ return {{ name: d.name, count: d.total_count || 0, originalStageName: "Root", children: (d.children || []).map(c => p(c, "Root")) }};
153
151
  }}
154
152
  const treeData = transformData(initialJsonData);
155
153
  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"],
154
+ ["#4E79A7","#3a5c84","#1e3050"],["#59A14F","#3d7a42","#1e4020"],
155
+ ["#E15759","#b03a3c","#6a1820"],["#B07AA1","#845a78","#4a2040"],
156
+ ["#F28E2B","#b8681c","#6a3808"],["#76B7B2","#4d8a85","#1e4040"],
157
+ ["#EDC948","#b89c20","#6a5808"],["#FF9DA7","#cc6370","#7a1828"],
160
158
  ];
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] }}; }});
159
+ const phaseColors = {{ "Root": {{ fill:"#2a2a4e",stroke:"#4E79A7",collapsedFill:"#1a1a3e" }}, "Default": {{ fill:"#3a3a5e",stroke:"#5a5a8e",collapsedFill:"#2a2a4e" }} }};
160
+ (initialJsonData.children||[]).forEach((c,i) => {{ const pal=PALETTE[i%PALETTE.length]; phaseColors[c.name]={{ fill:pal[0],stroke:pal[0],collapsedFill:pal[2] }}; }});
163
161
  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"}}
162
+ 0:{{fill:"#1a1a3e",stroke:"#4E79A7",collapsedFill:"#0f0f2a"}},
163
+ 2:{{fill:"#59A14F",stroke:"#3d7a42",collapsedFill:"#1e4020"}},
164
+ 3:{{fill:"#F28E2B",stroke:"#b8681c",collapsedFill:"#6a3808"}},
165
+ 4:{{fill:"#B07AA1",stroke:"#845a78",collapsedFill:"#4a2040"}},
166
+ default:{{fill:"#3a3a5e",stroke:"#5a5a8e",collapsedFill:"#2a2a4e"}}
169
167
  }};
170
168
  const svg = d3.select("#tree-svg");
171
169
  const margin = {{top:40,right:120,bottom:80,left:450}};
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "context-mcp-server",
3
- "version": "1.1.2",
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.",
3
+ "version": "1.1.4",
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.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "context-mcp": "./src/index.js",