context-mcp-server 1.1.1 → 1.1.3

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 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:
@@ -210,7 +210,7 @@ def to_html(graph_dict: dict, output_path: str) -> str:
210
210
  <p>{len(nodes)} nodes · {len(edges)} edges · {len(communities)} communities · generated {escape(generated)}</p>
211
211
  {god_html}
212
212
  <div class="mermaid">
213
- {escape(overview_mermaid)}
213
+ {overview_mermaid}
214
214
  </div>
215
215
  </section>"""
216
216
 
@@ -239,7 +239,7 @@ def to_html(graph_dict: dict, output_path: str) -> str:
239
239
  <div class="card"><h3>Key Files</h3><ul>{files_html}</ul></div>
240
240
  </div>
241
241
  <div class="mermaid">
242
- {escape(diagram)}
242
+ {diagram}
243
243
  </div>
244
244
  {"<h3>Incoming Cross-Community Calls</h3>" + table if table else ""}
245
245
  <hr>
@@ -416,7 +416,7 @@ def to_obsidian(graph_dict: dict, output_dir: str) -> str:
416
416
  fpath = n.get("file", "")
417
417
  ntype = n.get("type", "")
418
418
  comm = node_community.get(nid)
419
- comm_tag = _obsidian_tag(comm["label"]) if comm else "misc"
419
+ comm_tag = _obsidian_tag(comm.get("label", f"community_{comm['id']}")) if comm else "misc"
420
420
 
421
421
  lines = [
422
422
  "---",
@@ -530,8 +530,10 @@ def _extract_with_regex(source: str, rel_path: str, ext: str) -> list[dict]:
530
530
  "imports": import_names[:],
531
531
  })
532
532
 
533
+ # brace-scope tracking only works for brace-delimited languages
534
+ _BRACE_LANGS = {"javascript", "typescript", "go", "rust", "java", "c", "cpp", "csharp", "php", "swift"}
533
535
  func_nodes = [n for n in nodes if n["type"] == "function"]
534
- if func_nodes:
536
+ if func_nodes and lang in _BRACE_LANGS:
535
537
  _attach_calls_brace(lines, func_nodes)
536
538
 
537
539
  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,11 +79,14 @@ 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:
@@ -101,7 +104,7 @@ def resolve_calls(
101
104
  continue
102
105
 
103
106
  existing_edge_keys.add(key)
104
- confidence = "EXTRACTED" if imported_stems else "INFERRED"
107
+ confidence = "EXTRACTED" if via_import else "INFERRED"
105
108
  new_edges.append({
106
109
  "from": caller_id,
107
110
  "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":
@@ -232,7 +249,7 @@ async def _build(args: dict) -> dict:
232
249
  generate_report(graph_dict, root)
233
250
  save_cache(root, cache)
234
251
  try:
235
- export_all(graph_dict, root)
252
+ export_all(graph_dict, str(Path(root) / "codegraph-cache"))
236
253
  except Exception:
237
254
  pass
238
255
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "context-mcp-server",
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.",
3
+ "version": "1.1.3",
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",
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.1.1"
7
+ version = "1.1.3"
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"
package/src/cli.js CHANGED
@@ -5,7 +5,7 @@
5
5
  */
6
6
 
7
7
  import readline from 'node:readline';
8
- import { readFileSync, writeFileSync, mkdirSync, existsSync, unlinkSync } from 'node:fs';
8
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, unlinkSync, chmodSync } from 'node:fs';
9
9
  import { dirname, join } from 'node:path';
10
10
  import { homedir } from 'node:os';
11
11
  import { fileURLToPath } from 'node:url';
@@ -414,7 +414,6 @@ const _GLOBAL_GITIGNORE_ENTRIES = [
414
414
  '.gemini/',
415
415
  '.codex/',
416
416
  '.windsurf/',
417
- '.agents/',
418
417
  // Build outputs and session artifacts
419
418
  'codegraph-cache/',
420
419
  '.mcp.json',
@@ -429,7 +428,6 @@ function _graphForProject(graphs, projectName) {
429
428
 
430
429
  const _PROJECT_GITIGNORE_ENTRIES = [
431
430
  '.claude/', '.cursor/', '.vscode/', '.gemini/', '.codex/',
432
- '.agents/',
433
431
  'codegraph-cache/', '.mcp.json', 'CLAUDE.md', 'GEMINI.md', 'AGENTS.md',
434
432
  ];
435
433
 
@@ -498,7 +496,12 @@ function _copyHooks(platform, dotDir, dir, hookFiles) {
498
496
  for (const file of hookFiles) {
499
497
  const src = join(hooksSrc, file);
500
498
  if (existsSync(src)) {
501
- _writeFile(join(hooksDest, file), readFileSync(src, 'utf8'), `${dotDir}/hooks/${file}`);
499
+ const dest = join(hooksDest, file);
500
+ _writeFile(dest, readFileSync(src, 'utf8'), `${dotDir}/hooks/${file}`);
501
+ // Make executable on Unix so shells can run it without explicit `node` prefix
502
+ if (process.platform !== 'win32') {
503
+ try { chmodSync(dest, 0o755); } catch {}
504
+ }
502
505
  }
503
506
  }
504
507
  }
@@ -595,6 +598,14 @@ const PLATFORMS = {
595
598
  }
596
599
  // Slash commands — user-global
597
600
  _writeCommands(homedir());
601
+ // Rules file — project root for project scope, ~/.claude/CLAUDE.md for global
602
+ const claudeMd = _tpl('claude/CLAUDE.md');
603
+ if (claudeMd) {
604
+ const claudeMdPath = scope === 'project'
605
+ ? join(dir, 'CLAUDE.md')
606
+ : join(homedir(), '.claude', 'CLAUDE.md');
607
+ _writeFile(claudeMdPath, claudeMd, scope === 'project' ? 'CLAUDE.md' : '~/.claude/CLAUDE.md');
608
+ }
598
609
  // Hooks — write into the appropriate settings.json scope
599
610
  // Project hooks live in .claude/hooks/ and are committed; user hooks in ~/.claude/hooks/
600
611
  const hooksBase = scope === 'project' ? dir : homedir();
@@ -610,11 +621,11 @@ const PLATFORMS = {
610
621
  _mergeHooksIntoSettings(settingsPath, {
611
622
  PreToolUse: [{
612
623
  matcher: 'Bash',
613
- hooks: [{ type: 'command', command: preHook, timeout: 30, statusMessage: 'Checking shell command' }],
624
+ hooks: [{ type: 'command', command: `node "${preHook}"`, timeout: 30, statusMessage: 'Checking shell command' }],
614
625
  }],
615
626
  PostToolUse: [{
616
627
  matcher: 'Bash',
617
- hooks: [{ type: 'command', command: postHook, timeout: 30, statusMessage: 'Saving failed shell context' }],
628
+ hooks: [{ type: 'command', command: `node "${postHook}"`, timeout: 30, statusMessage: 'Saving failed shell context' }],
618
629
  }],
619
630
  }, scope === 'project' ? '.claude/settings.json' : '~/.claude/settings.json');
620
631
  // Register MCP server via claude CLI
@@ -724,26 +735,31 @@ const PLATFORMS = {
724
735
  );
725
736
  settings.hooks.BeforeTool = stripOld(settings.hooks.BeforeTool).concat([{
726
737
  matcher: 'run_shell_command',
727
- hooks: [{ type: 'command', command: `node ${beforeHook}`, timeout: 30 }],
738
+ hooks: [{ type: 'command', command: `node "${beforeHook}"`, timeout: 30 }],
728
739
  }]);
729
740
  settings.hooks.AfterTool = stripOld(settings.hooks.AfterTool).concat([{
730
741
  matcher: 'run_shell_command',
731
- hooks: [{ type: 'command', command: `node ${afterHook}`, timeout: 30 }],
742
+ hooks: [{ type: 'command', command: `node "${afterHook}"`, timeout: 30 }],
732
743
  }]);
733
744
  _writeFile(settingsPath,
734
745
  JSON.stringify(settings, null, 2),
735
746
  scope === 'project' ? '.gemini/settings.json' : '~/.gemini/settings.json',
736
747
  );
737
- // Slash commands (.toml) — project-scoped only
738
- if (scope === 'project') {
739
- const cmdsSrc = join(TPLS, 'gemini', 'commands');
740
- const cmdsDest = join(dir, '.gemini', 'commands');
741
- for (const file of ['context-resume.toml', 'graph-build.toml', 'save-context.toml']) {
742
- const src = join(cmdsSrc, file);
743
- if (existsSync(src)) _writeFile(join(cmdsDest, file), readFileSync(src, 'utf8'), `.gemini/commands/${file}`);
744
- }
745
- const md = _tpl('gemini/GEMINI.md');
746
- if (md) _writeFile(join(dir, 'GEMINI.md'), md, 'GEMINI.md');
748
+ // Slash commands (.toml) — project: .gemini/commands/, global: ~/.gemini/commands/
749
+ const geminiCmdsDest = join(hooksBase, '.gemini', 'commands');
750
+ const cmdsSrc = join(TPLS, 'gemini', 'commands');
751
+ for (const file of ['context-resume.toml', 'graph-build.toml', 'save-context.toml']) {
752
+ const src = join(cmdsSrc, file);
753
+ const label = scope === 'project' ? `.gemini/commands/${file}` : `~/.gemini/commands/${file}`;
754
+ if (existsSync(src)) _writeFile(join(geminiCmdsDest, file), readFileSync(src, 'utf8'), label);
755
+ }
756
+ // Rules file — project root for project scope, ~/.gemini/GEMINI.md for global
757
+ const geminiMd = _tpl('gemini/GEMINI.md');
758
+ if (geminiMd) {
759
+ const geminiMdPath = scope === 'project'
760
+ ? join(dir, 'GEMINI.md')
761
+ : join(homedir(), '.gemini', 'GEMINI.md');
762
+ _writeFile(geminiMdPath, geminiMd, scope === 'project' ? 'GEMINI.md' : '~/.gemini/GEMINI.md');
747
763
  }
748
764
  },
749
765
  },
@@ -754,9 +770,13 @@ const PLATFORMS = {
754
770
  const includeHooks = scope === 'project';
755
771
  if (includeHooks) _copyCodexHooks(dir);
756
772
  _writeFile(join(dir, '.codex', 'config.toml'), _codexConfigToml(dir, includeHooks), '.codex/config.toml');
757
- if (scope === 'project') {
758
- const md = _tpl('codex/AGENTS.md');
759
- if (md) _writeFile(join(dir, 'AGENTS.md'), md, 'AGENTS.md');
773
+ // Rules file project root for project scope, ~/.codex/AGENTS.md for global
774
+ const codexMd = _tpl('codex/AGENTS.md');
775
+ if (codexMd) {
776
+ const codexMdPath = scope === 'project'
777
+ ? join(dir, 'AGENTS.md')
778
+ : join(homedir(), '.codex', 'AGENTS.md');
779
+ _writeFile(codexMdPath, codexMd, scope === 'project' ? 'AGENTS.md' : '~/.codex/AGENTS.md');
760
780
  }
761
781
  // Prompts (slash commands) — always user-global; Codex only loads ~/.codex/prompts/
762
782
  const promptsSrc = join(TPLS, 'codex', 'prompts');
@@ -812,34 +832,6 @@ const PLATFORMS = {
812
832
  }
813
833
  },
814
834
  },
815
- antigravity: {
816
- label: 'Antigravity IDE',
817
- restartNote: 'Restart your Antigravity session to pick up hooks and rules.',
818
- install(dir, scope) {
819
- // Antigravity uses stdio-incompatible MCP transport — integrate via ctx CLI + GEMINI.md instead.
820
- if (scope === 'project') {
821
- // Post-tool hook — saves failed tool calls to context-mcp via ctx CLI
822
- _copyHooks('antigravity', '.agents', dir, ['context-mcp-post-tool-use.js']);
823
- const hookPath = join(dir, '.agents', 'hooks', 'context-mcp-post-tool-use.js');
824
- _mergeJsonFile(join(dir, '.agents', 'hooks.json'), '.agents/hooks.json', obj => {
825
- const strip = arr => (arr || []).filter(h => !String(h.command || '').includes('context-mcp-'));
826
- obj.hooks = strip(obj.hooks).concat([{
827
- event: 'PostToolUse',
828
- command: `node "${hookPath}"`,
829
- }]);
830
- });
831
- // Workflows (slash commands) — .agents/workflows/
832
- const wfSrc = join(TPLS, 'antigravity', 'workflows');
833
- for (const file of ['context-resume.md', 'graph-build.md', 'save-context.md']) {
834
- const src = join(wfSrc, file);
835
- if (existsSync(src)) _writeFile(join(dir, '.agents', 'workflows', file), readFileSync(src, 'utf8'), `.agents/workflows/${file}`);
836
- }
837
- // Rules file — Antigravity reads GEMINI.md at project root
838
- const md = _tpl('antigravity/GEMINI.md');
839
- if (md) _writeFile(join(dir, 'GEMINI.md'), md, 'GEMINI.md');
840
- }
841
- },
842
- },
843
835
  };
844
836
 
845
837
  async function cmdInstall(args) {
@@ -901,7 +893,7 @@ async function cmdInstall(args) {
901
893
 
902
894
  if (!keys.length) {
903
895
  printSection('Install');
904
- console.log(` ${muted('Usage:')} ctx install ${faint('[--initial] [--claude] [--cursor] [--vscode] [--gemini] [--codex] [--windsurf] [--antigravity] [--all]')}`);
896
+ console.log(` ${muted('Usage:')} ctx install ${faint('[--initial] [--claude] [--cursor] [--vscode] [--gemini] [--codex] [--windsurf] [--all]')}`);
905
897
  console.log('');
906
898
  console.log(` ${accent('--initial ')} ${faint('Install / update Node.js + Python (codegraph) deps')}`);
907
899
  console.log('');
package/src/db.js CHANGED
@@ -178,6 +178,11 @@ function findEntryById(id, projectHint) {
178
178
  const e = search(data);
179
179
  if (e) return { entry: e, projectName: name };
180
180
  }
181
+ // Always check 'global' — it is never in the projects index
182
+ if (!_projectData.has('global') && 'global' !== projectHint) {
183
+ const e = search(loadProjectData('global'));
184
+ if (e) return { entry: e, projectName: 'global' };
185
+ }
181
186
  const idx = loadProjectsIndex();
182
187
  for (const proj of idx) {
183
188
  if (_projectData.has(proj.name) || proj.name === projectHint) continue;
@@ -384,10 +389,12 @@ export function getContext({ project, tags, limit = 20, compact = false, ids } =
384
389
 
385
390
  if (ids && ids.length) {
386
391
  const idSet = new Set(ids);
387
- // Load all projects to find entries
392
+ // Load all projects to find entries — always include 'global' since it is
393
+ // never registered in the projects index (ensureProject skips it)
388
394
  const idx = loadProjectsIndex();
389
395
  const all = [];
390
396
  const loaded = new Set(_projectData.keys());
397
+ loaded.add('global'); // ensure global is always searched
391
398
  for (const proj of idx) loaded.add(proj.name);
392
399
  for (const name of loaded) {
393
400
  for (const e of getAllEntries(name)) {
@@ -403,10 +410,11 @@ export function getContext({ project, tags, limit = 20, compact = false, ids } =
403
410
  const globalEntries = project !== 'global' ? getAllEntries('global') : [];
404
411
  results = [...entries, ...globalEntries];
405
412
  } else {
406
- // No project filter: load all
413
+ // No project filter: load all — always include 'global' since it is never in the index
407
414
  const idx = loadProjectsIndex();
408
415
  const all = [];
409
416
  const seen = new Set(_projectData.keys());
417
+ seen.add('global');
410
418
  for (const proj of idx) seen.add(proj.name);
411
419
  for (const name of seen) {
412
420
  all.push(...getAllEntries(name));
@@ -434,7 +442,7 @@ export function getContextSince(since, project) {
434
442
  } else {
435
443
  const idx = loadProjectsIndex();
436
444
  results = [];
437
- const seen = new Set([..._projectData.keys(), ...idx.map(p => p.name)]);
445
+ const seen = new Set([..._projectData.keys(), 'global', ...idx.map(p => p.name)]);
438
446
  for (const name of seen) results.push(...getAllEntries(name));
439
447
  }
440
448
  return results.filter(c => c.createdAt >= since);
@@ -450,7 +458,7 @@ export function searchContext({ query, project, limit = 10, compact = false }) {
450
458
  } else {
451
459
  const idx = loadProjectsIndex();
452
460
  results = [];
453
- const seen = new Set([..._projectData.keys(), ...idx.map(p => p.name)]);
461
+ const seen = new Set([..._projectData.keys(), 'global', ...idx.map(p => p.name)]);
454
462
  for (const name of seen) results.push(...getAllEntries(name));
455
463
  }
456
464
  const scored = results.map(c => {
@@ -467,8 +475,9 @@ export function deleteContext({ id, ids }) {
467
475
  const idSet = new Set(ids && ids.length ? ids : (id ? [id] : []));
468
476
  if (!idSet.size) return { deleted: 0 };
469
477
  let deleted = 0;
470
- // Scan all loaded projects
478
+ // Scan all loaded projects — always include 'global' since it is never in the index
471
479
  const seen = new Set(_projectData.keys());
480
+ seen.add('global');
472
481
  loadProjectsIndex().forEach(p => seen.add(p.name));
473
482
  for (const name of seen) {
474
483
  const data = loadProjectData(name);
@@ -524,7 +533,7 @@ export function countContext(project) {
524
533
  if (!project) {
525
534
  const idx = loadProjectsIndex();
526
535
  let total = 0;
527
- const seen = new Set([..._projectData.keys(), ...idx.map(p => p.name)]);
536
+ const seen = new Set([..._projectData.keys(), 'global', ...idx.map(p => p.name)]);
528
537
  for (const name of seen) total += getAllEntries(name).length;
529
538
  return total;
530
539
  }
@@ -658,7 +667,7 @@ export function updateDiscussion({ id, name, title, description, content, status
658
667
  let disc = null;
659
668
  let projName = null;
660
669
  const idx = loadProjectsIndex();
661
- const seen = new Set([..._projectData.keys(), ...idx.map(p => p.name)]);
670
+ const seen = new Set([..._projectData.keys(), 'global', ...idx.map(p => p.name)]);
662
671
  for (const pName of seen) {
663
672
  const d = loadProjectData(pName);
664
673
  const found = id ? d.discussions.find(x => x.id === id) : d.discussions.find(x => x.name === name);
@@ -690,9 +699,9 @@ export function getDiscussion({ project, name, id } = {}) {
690
699
  if (name) return list.find(d => d.name === name) || null;
691
700
  return null;
692
701
  }
693
- // Search all
702
+ // Search all — always include 'global' since it is never in the projects index
694
703
  const idx = loadProjectsIndex();
695
- const seen = new Set([..._projectData.keys(), ...idx.map(p => p.name)]);
704
+ const seen = new Set([..._projectData.keys(), 'global', ...idx.map(p => p.name)]);
696
705
  for (const pName of seen) {
697
706
  const d = loadProjectData(pName);
698
707
  const found = id ? d.discussions.find(x => x.id === id) : d.discussions.find(x => x.name === name);
@@ -709,7 +718,7 @@ export function listDiscussions({ project, status, type } = {}) {
709
718
  if (project !== 'global') list = [...list, ...loadProjectData('global').discussions];
710
719
  } else {
711
720
  const idx = loadProjectsIndex();
712
- const seen = new Set([..._projectData.keys(), ...idx.map(p => p.name)]);
721
+ const seen = new Set([..._projectData.keys(), 'global', ...idx.map(p => p.name)]);
713
722
  for (const pName of seen) list.push(...loadProjectData(pName).discussions);
714
723
  }
715
724
  if (status) list = list.filter(d => d.status === status);
@@ -730,7 +739,7 @@ export function linkContextToDiscussion({ discussionId, discussionName, contextI
730
739
  let disc = null;
731
740
  let discProject = null;
732
741
  const idx = loadProjectsIndex();
733
- const seen = new Set([..._projectData.keys(), ...idx.map(p => p.name)]);
742
+ const seen = new Set([..._projectData.keys(), 'global', ...idx.map(p => p.name)]);
734
743
  for (const pName of seen) {
735
744
  const d = loadProjectData(pName);
736
745
  const found = discussionId
@@ -764,7 +773,7 @@ export function linkContextToDiscussion({ discussionId, discussionName, contextI
764
773
  export function deleteDiscussion({ name, id }) {
765
774
  init();
766
775
  const idx = loadProjectsIndex();
767
- const seen = new Set([..._projectData.keys(), ...idx.map(p => p.name)]);
776
+ const seen = new Set([..._projectData.keys(), 'global', ...idx.map(p => p.name)]);
768
777
  for (const pName of seen) {
769
778
  const data = loadProjectData(pName);
770
779
  const before = data.discussions.length;
@@ -803,7 +812,7 @@ export function archiveExpired(project) {
803
812
  processEntries(getAllEntries(project), project);
804
813
  } else {
805
814
  const idx = loadProjectsIndex();
806
- const seen = new Set([..._projectData.keys(), ...idx.map(p => p.name)]);
815
+ const seen = new Set([..._projectData.keys(), 'global', ...idx.map(p => p.name)]);
807
816
  for (const name of seen) processEntries(getAllEntries(name).slice(), name);
808
817
  }
809
818
  if (count > 0) markDirty();
package/src/http.js CHANGED
@@ -622,7 +622,7 @@ async function handleRequest(req, res) {
622
622
  <div class="step">
623
623
  <span class="step-num">2</span>
624
624
  <span class="step-title">Server URL</span>
625
- <span class="step-content">Enter: <code>${req.headers['x-forwarded-proto'] || req.socket?.encrypted ? 'https' : 'http'}://${req.headers.host}</code></span>
625
+ <span class="step-content">Enter: <code>${req.headers['x-forwarded-proto'] === 'https' || req.socket?.encrypted ? 'https' : 'http'}://${req.headers.host}</code></span>
626
626
  </div>
627
627
 
628
628
  <div class="step">
@@ -35,7 +35,7 @@ export async function handle(args, state) {
35
35
 
36
36
  if (action === 'check') {
37
37
  const results = unifiedSearch({ mode: 'semantic', query: errorMessage, project, limit: 5 })
38
- .filter(r => r.type === 'bug');
38
+ .filter(r => Array.isArray(r.tags) && r.tags.includes('error-log'));
39
39
  if (results.length > 0 && results[0].similarity > 0.4) {
40
40
  return {
41
41
  success: true, found: true,
@@ -53,8 +53,8 @@ export async function handle(args, state) {
53
53
  sessionId: state.sessionId || null,
54
54
  title: `Error: ${errorMessage.split('\n')[0].slice(0, 60)}`,
55
55
  content: `Command: ${command || 'unknown'}\n\nError:\n${errorMessage}\n\nSolution:\n${solution}`,
56
- type: 'bug',
57
- status: 'done',
56
+ type: 'note',
57
+ status: 'active',
58
58
  tags: ['error-log', command].filter(Boolean),
59
59
  });
60
60
  fireAutoLink(entry.id, state);
@@ -107,7 +107,7 @@ export async function handle(name, args, state) {
107
107
  saveAutoContext({
108
108
  title: `wrote ${filePath.split(/[\\/]/).pop()}`,
109
109
  content: `write_file: created/overwrote ${filePath}`,
110
- type: 'code',
110
+ type: 'note',
111
111
  files: [{ path: filePath, action: 'modified' }],
112
112
  tags: ['file-write'],
113
113
  state,
@@ -159,7 +159,7 @@ export async function handle(name, args, state) {
159
159
  title: `patched ${filePath.split(/[\\/]/).pop()}${sorted.length > 1 ? ` (${sorted.length} edits)` : ''}`,
160
160
  content: `patch_file: ${sorted.length} edit(s) in ${filePath}\n` +
161
161
  sorted.map(e => ` ${e.description}: -${e.old_str.split('\n').length} +${e.new_str.split('\n').length} lines`).join('\n'),
162
- type: 'code',
162
+ type: 'note',
163
163
  files: [{ path: filePath, action: 'modified' }],
164
164
  tags: ['file-patch'],
165
165
  state,
@@ -175,7 +175,7 @@ export async function handle(name, args, state) {
175
175
  saveAutoContext({
176
176
  title: `git commit: ${args.message.slice(0, 57)}${args.message.length > 57 ? '...' : ''}`,
177
177
  content: `hash: ${hash} | branch: ${branch}\nmessage: ${args.message}\nfiles: ${stagedFiles.map(f => f.path).join(', ')}`,
178
- type: 'decision',
178
+ type: 'note',
179
179
  files: stagedFiles,
180
180
  tags: ['git', 'commit', branch],
181
181
  state,
package/uv.lock CHANGED
@@ -201,7 +201,7 @@ wheels = [
201
201
 
202
202
  [[package]]
203
203
  name = "codegraph-mcp"
204
- version = "1.1.0"
204
+ version = "1.1.2"
205
205
  source = { editable = "." }
206
206
  dependencies = [
207
207
  { name = "mcp" },
@@ -2308,8 +2308,8 @@ name = "uvicorn"
2308
2308
  version = "0.46.0"
2309
2309
  source = { registry = "https://pypi.org/simple" }
2310
2310
  dependencies = [
2311
- { name = "click" },
2312
- { name = "h11" },
2311
+ { name = "click", marker = "python_full_version < '3.15' or sys_platform != 'emscripten'" },
2312
+ { name = "h11", marker = "python_full_version < '3.15' or sys_platform != 'emscripten'" },
2313
2313
  ]
2314
2314
  sdist = { url = "https://files.pythonhosted.org/packages/1f/93/041fca8274050e40e6791f267d82e0e2e27dd165627bd640d3e0e378d877/uvicorn-0.46.0.tar.gz", hash = "sha256:fb9da0926999cc6cb22dc7cd71a94a632f078e6ae47ff683c5c420750fb7413d", size = 88758, upload-time = "2026-04-23T07:16:00.151Z" }
2315
2315
  wheels = [