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.
- package/README.md +2 -3
- package/codegraph/__pycache__/callflow_html.cpython-313.pyc +0 -0
- package/codegraph/__pycache__/export.cpython-313.pyc +0 -0
- package/codegraph/__pycache__/report.cpython-313.pyc +0 -0
- package/codegraph/__pycache__/scanner.cpython-313.pyc +0 -0
- package/codegraph/__pycache__/server.cpython-313.pyc +0 -0
- package/codegraph/__pycache__/tree_html.cpython-313.pyc +0 -0
- package/codegraph/callflow_html.py +6 -4
- package/codegraph/export.py +25 -10
- package/codegraph/extractors/__pycache__/ast_extractor.cpython-313.pyc +0 -0
- package/codegraph/extractors/ast_extractor.py +37 -8
- package/codegraph/graph/__pycache__/builder.cpython-313.pyc +0 -0
- package/codegraph/graph/__pycache__/clustering.cpython-313.pyc +0 -0
- package/codegraph/graph/__pycache__/query.cpython-313.pyc +0 -0
- package/codegraph/graph/__pycache__/symbol_resolution.cpython-313.pyc +0 -0
- package/codegraph/graph/builder.py +27 -23
- package/codegraph/graph/clustering.py +5 -3
- package/codegraph/graph/query.py +5 -4
- package/codegraph/graph/symbol_resolution.py +14 -3
- package/codegraph/report.py +1 -1
- package/codegraph/scanner.py +1 -1
- package/codegraph/server.py +26 -5
- package/codegraph/tree_html.py +28 -30
- package/package.json +2 -2
- package/pyproject.toml +72 -72
- package/src/cli.js +12 -42
- package/src/db.js +26 -14
- package/src/http.js +1 -1
- package/src/search.js +4 -9
- package/src/server.js +16 -7
- package/src/templates/antigravity/GEMINI.md +18 -6
- package/src/templates/claude/CLAUDE.md +14 -1
- package/src/templates/claude/skills/SKILL.md +15 -3
- package/src/templates/codex/AGENTS.md +9 -2
- package/src/templates/cursor/cursor-rules.mdc +13 -4
- package/src/templates/gemini/GEMINI.md +14 -3
- package/src/templates/windsurf/windsurf-rules.md +14 -3
- package/src/tools/codegraph.js +4 -1
- package/src/tools/context.js +6 -6
- package/src/tools/errorCheck.js +3 -3
- package/src/tools/fileTools.js +2 -2
- package/src/tools/gitTools.js +1 -1
- package/src/tools/search.js +1 -1
- package/src/tools/symbolDetail.js +74 -0
- package/src/tools/toolRegistry.js +77 -0
- package/src/vector.js +7 -2
- 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,
|
|
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
|
|
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:
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -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
|
|
56
|
-
|
|
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
|
-
{
|
|
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
|
-
{
|
|
244
|
+
{diagram}
|
|
243
245
|
</div>
|
|
244
246
|
{"<h3>Incoming Cross-Community Calls</h3>" + table if table else ""}
|
|
245
247
|
<hr>
|
package/codegraph/export.py
CHANGED
|
@@ -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: #
|
|
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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
|
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">
|
|
248
|
-
<div class="field">
|
|
249
|
-
<div class="field">
|
|
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
|
|
434
|
+
comm_tag = _obsidian_tag(comm.get("label", f"community_{comm['id']}")) if comm else "misc"
|
|
420
435
|
|
|
421
436
|
lines = [
|
|
422
437
|
"---",
|
|
Binary file
|
|
@@ -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
|
-
|
|
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)
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
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
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -25,44 +25,48 @@ def build(all_nodes: list[dict]) -> "nx.DiGraph | dict":
|
|
|
25
25
|
|
|
26
26
|
G = nx.DiGraph()
|
|
27
27
|
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
37
|
-
if
|
|
38
|
-
|
|
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,
|
|
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,
|
|
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:
|
|
63
|
+
# Import edges: file → file (aggregated from all node types per file)
|
|
60
64
|
seen_edges: set[tuple] = set()
|
|
61
|
-
for
|
|
62
|
-
|
|
65
|
+
for frel, imports in file_imports.items():
|
|
66
|
+
src_id = file_rep.get(frel)
|
|
67
|
+
if not src_id:
|
|
63
68
|
continue
|
|
64
|
-
|
|
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
|
-
|
|
68
|
-
|
|
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
|
|
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
|
|
package/codegraph/graph/query.py
CHANGED
|
@@ -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.
|
|
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
|
-
#
|
|
93
|
-
if len(target_ids)
|
|
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
|
|
115
|
+
confidence = "EXTRACTED" if via_import else "INFERRED"
|
|
105
116
|
new_edges.append({
|
|
106
117
|
"from": caller_id,
|
|
107
118
|
"to": target_id,
|
package/codegraph/report.py
CHANGED
package/codegraph/scanner.py
CHANGED
|
@@ -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
|
|
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:
|
package/codegraph/server.py
CHANGED
|
@@ -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.
|
|
196
|
-
|
|
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,
|
|
236
|
-
except Exception:
|
|
237
|
-
|
|
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
|
package/codegraph/tree_html.py
CHANGED
|
@@ -112,24 +112,27 @@ _HTML_TEMPLATE = r"""<!DOCTYPE html>
|
|
|
112
112
|
<meta charset="UTF-8">
|
|
113
113
|
<title>{title}</title>
|
|
114
114
|
<style>
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
["#
|
|
157
|
-
["#
|
|
158
|
-
["#
|
|
159
|
-
["#
|
|
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:"#
|
|
162
|
-
(initialJsonData.children||[]).forEach((c,i) => {{ const pal=PALETTE[i%PALETTE.length]; phaseColors[c.name]={{ fill:pal[0],stroke:pal[
|
|
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:"#
|
|
165
|
-
2:{{fill:"#
|
|
166
|
-
3:{{fill:"#
|
|
167
|
-
4:{{fill:"#
|
|
168
|
-
default:{{fill:"#
|
|
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.
|
|
4
|
-
"description": "Persistent AI memory + codebase knowledge graph MCP server. Works across Claude Code, Cursor, Gemini CLI, Codex, Windsurf, VS Code Copilot,
|
|
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",
|