memgraph-context-graph 0.5.0__py3-none-any.whl

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 (33) hide show
  1. memgraph_context_graph/__init__.py +71 -0
  2. memgraph_context_graph/adapters/__init__.py +0 -0
  3. memgraph_context_graph/adapters/_identity.py +520 -0
  4. memgraph_context_graph/adapters/_opencode_plugin.js +112 -0
  5. memgraph_context_graph/adapters/_spec.py +441 -0
  6. memgraph_context_graph/adapters/antigravity_cli.py +152 -0
  7. memgraph_context_graph/adapters/claude.py +297 -0
  8. memgraph_context_graph/adapters/claude_code.py +216 -0
  9. memgraph_context_graph/adapters/claude_code_memory.py +181 -0
  10. memgraph_context_graph/adapters/codex.py +118 -0
  11. memgraph_context_graph/adapters/codex_memory.py +185 -0
  12. memgraph_context_graph/adapters/copilot_cli.py +236 -0
  13. memgraph_context_graph/adapters/cursor.py +189 -0
  14. memgraph_context_graph/adapters/grok.py +187 -0
  15. memgraph_context_graph/adapters/openai.py +240 -0
  16. memgraph_context_graph/adapters/opencode.py +168 -0
  17. memgraph_context_graph/cli.py +845 -0
  18. memgraph_context_graph/events.py +210 -0
  19. memgraph_context_graph/hooks/__init__.py +1 -0
  20. memgraph_context_graph/hooks/cli.py +184 -0
  21. memgraph_context_graph/hooks/runner.py +311 -0
  22. memgraph_context_graph/hooks/runtime_plugin.py +85 -0
  23. memgraph_context_graph/link.py +50 -0
  24. memgraph_context_graph/mcp_server.py +102 -0
  25. memgraph_context_graph/memory_backend.py +144 -0
  26. memgraph_context_graph/protocols.py +67 -0
  27. memgraph_context_graph/spaces.py +214 -0
  28. memgraph_context_graph/tools.py +130 -0
  29. memgraph_context_graph-0.5.0.dist-info/METADATA +718 -0
  30. memgraph_context_graph-0.5.0.dist-info/RECORD +33 -0
  31. memgraph_context_graph-0.5.0.dist-info/WHEEL +4 -0
  32. memgraph_context_graph-0.5.0.dist-info/entry_points.txt +11 -0
  33. memgraph_context_graph-0.5.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,845 @@
1
+ """Top-level CLI for memgraph-context-graph."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import os
8
+ import shutil
9
+ import socket
10
+ import subprocess
11
+ import sys
12
+ from importlib import metadata
13
+ from typing import TYPE_CHECKING, Any, TypedDict, cast
14
+
15
+ if TYPE_CHECKING:
16
+ from collections.abc import Sequence
17
+
18
+ _HELP = """usage: memgraph-context-graph <command> [options]
19
+
20
+ Commands:
21
+ bootstrap Install runtime dependencies and verify hook capture.
22
+ config Get or set persistent configuration values.
23
+ doctor Check runtime hook dependencies and Memgraph connectivity.
24
+ setup <runtime> Configure an agent runtime; --memory-backend context-graph|native
25
+ switches whose memory its model uses.
26
+ join <url> <login> Use an Enterprise Memgraph as <login> (edition = enterprise); --password or prompt.
27
+ hook <command> Configure or run command hooks.
28
+ mcp Serve the registered tools (recall, ...) over stdio MCP.
29
+ recall <question> Search your own past sessions; --json for the data.
30
+ """
31
+
32
+
33
+ def main(argv: Sequence[str] | None = None) -> int:
34
+ """Dispatch top-level memgraph-context-graph commands."""
35
+ args = list(argv) if argv is not None else sys.argv[1:]
36
+ if not args:
37
+ print(_HELP)
38
+ return 2
39
+ if args[0] in {"-h", "--help"}:
40
+ print(_HELP)
41
+ return 0
42
+ if args[0] in {"--version", "version"}:
43
+ print(_package_version("memgraph-context-graph"))
44
+ return 0
45
+
46
+ command = args[0]
47
+ if command == "bootstrap":
48
+ return _bootstrap(args[1:])
49
+
50
+ if command == "config":
51
+ return _config(args[1:])
52
+
53
+ if command == "doctor":
54
+ return _doctor(args[1:])
55
+
56
+ if command == "mcp":
57
+ return _mcp(args[1:])
58
+
59
+ if command == "recall":
60
+ return _recall(args[1:])
61
+
62
+ if command == "join":
63
+ return _join(args[1:])
64
+
65
+ if command == "hook":
66
+ from memgraph_context_graph.hooks.cli import main as hook_main
67
+
68
+ return hook_main(args[1:])
69
+
70
+ if command == "setup":
71
+ from memgraph_context_graph.hooks.cli import main as hook_main
72
+
73
+ return hook_main(["init", *args[1:]])
74
+
75
+ print(f"Unknown command: {command}", file=sys.stderr)
76
+ print(_HELP)
77
+ return 2
78
+
79
+
80
+ def _config(argv: list[str]) -> int:
81
+ """Get or set persistent config values in ~/.config/context-graph/config.toml."""
82
+ from memgraph_context_graph.adapters._identity import (
83
+ FALSY_VALUES,
84
+ MEMORY_BACKENDS,
85
+ TRUTHY_VALUES,
86
+ config_file_path,
87
+ load_config,
88
+ parse_bool_flag,
89
+ write_config,
90
+ )
91
+ from memgraph_context_graph.spaces import EDITION_COMMUNITY, EDITIONS
92
+
93
+ config_path = config_file_path()
94
+
95
+ _CONFIG_KEYS = {
96
+ "edition": "edition",
97
+ "identity.user_id": "user_id",
98
+ "memgraph.url": "memgraph_url",
99
+ "memgraph.user": "memgraph_user",
100
+ "memgraph.password": "memgraph_password",
101
+ "memgraph.database": "memgraph_database",
102
+ "llm.openai_api_key": "openai_api_key",
103
+ "llm.anthropic_api_key": "anthropic_api_key",
104
+ "reconcile.auto_reconcile": "auto_reconcile",
105
+ "recall.embedding_model": "embedding_model",
106
+ "ontology.path": "ontology_path",
107
+ "ontology.derive": "ontology_derive",
108
+ "github.token": "github_token",
109
+ "memory.backend": "memory_backend",
110
+ }
111
+ _SECRET_KEYS = {"memgraph.password", "llm.openai_api_key", "llm.anthropic_api_key", "github.token"}
112
+ _BOOL_KEYS = {"reconcile.auto_reconcile"}
113
+
114
+ if not argv or argv[0] in {"-h", "--help"}:
115
+ print("usage: memgraph-context-graph config set <key> <value>")
116
+ print(" memgraph-context-graph config get <key>")
117
+ print(" memgraph-context-graph config show")
118
+ print("")
119
+ print("Supported keys:")
120
+ for key in _CONFIG_KEYS:
121
+ print(f" {key}")
122
+ print(f"\nConfig file: {config_path}")
123
+ return 0
124
+
125
+ action = argv[0]
126
+
127
+ if action == "show":
128
+ config = load_config()
129
+ print(f"# {config_path}")
130
+ print(f"edition = {config.edition or EDITION_COMMUNITY!r}")
131
+ print(f"identity.user_id = {config.user_id!r}")
132
+ print(f"memgraph.url = {config.memgraph_url!r}")
133
+ print(f"memgraph.user = {config.memgraph_user!r}")
134
+ print(f"memgraph.password = {'***' if config.memgraph_password else repr('')}")
135
+ print(f"memgraph.database = {config.memgraph_database!r}")
136
+ print(f"llm.openai_api_key = {'***' if config.openai_api_key else repr('')}")
137
+ print(f"llm.anthropic_api_key = {'***' if config.anthropic_api_key else repr('')}")
138
+ if config.auto_reconcile is None:
139
+ print("reconcile.auto_reconcile = unset (defaults to false)")
140
+ else:
141
+ print(f"reconcile.auto_reconcile = {'true' if config.auto_reconcile else 'false'}")
142
+ if config.embedding_model is None:
143
+ print("recall.embedding_model = unset (defaults to sessions-graph's model)")
144
+ else:
145
+ print(f"recall.embedding_model = {config.embedding_model!r}")
146
+ print(f"ontology.path = {config.ontology_path!r}" if config.ontology_path else "ontology.path = unset")
147
+ if config.ontology_derive is None:
148
+ print("ontology.derive = unset (defaults to extend)")
149
+ else:
150
+ print(f"ontology.derive = {config.ontology_derive!r}")
151
+ print(f"github.token = {'***' if config.github_token else 'unset'}")
152
+ print(
153
+ f"memory.backend = {config.memory_backend!r}"
154
+ if config.memory_backend
155
+ else "memory.backend = unset (native)"
156
+ )
157
+ return 0
158
+
159
+ if action == "set":
160
+ if len(argv) < 2:
161
+ print("usage: memgraph-context-graph config set <key> <value>", file=sys.stderr)
162
+ return 2
163
+ key = argv[1]
164
+ if key not in _CONFIG_KEYS:
165
+ print(f"Unknown config key: {key}", file=sys.stderr)
166
+ print(f"Supported keys: {', '.join(_CONFIG_KEYS)}", file=sys.stderr)
167
+ return 2
168
+
169
+ # Secrets: read from stdin if value not provided.
170
+ if len(argv) < 3:
171
+ if key in _SECRET_KEYS:
172
+ import getpass
173
+
174
+ value = getpass.getpass(f"Enter {key}: ")
175
+ else:
176
+ print(f"usage: memgraph-context-graph config set {key} <value>", file=sys.stderr)
177
+ return 2
178
+ else:
179
+ value = argv[2]
180
+
181
+ write_value: str | bool = value
182
+ if key in _BOOL_KEYS:
183
+ normalized = value.strip().lower()
184
+ if normalized not in TRUTHY_VALUES | FALSY_VALUES:
185
+ print(f"Invalid value for {key}: {value!r} (expected true/false)", file=sys.stderr)
186
+ return 2
187
+ write_value = parse_bool_flag(normalized)
188
+ if key == "edition" and value not in EDITIONS:
189
+ print(f"Invalid value for {key}: {value!r} (expected {'/'.join(sorted(EDITIONS))})", file=sys.stderr)
190
+ return 2
191
+ if key == "memory.backend" and value not in MEMORY_BACKENDS:
192
+ print(f"Invalid value for {key}: {value!r} (expected {'/'.join(sorted(MEMORY_BACKENDS))})", file=sys.stderr)
193
+ return 2
194
+ if key == "ontology.derive" and value not in {"extend", "off"}:
195
+ print(f"Invalid value for {key}: {value!r} (expected extend/off)", file=sys.stderr)
196
+ return 2
197
+
198
+ # The key/value pairing is only known at runtime (bool iff key is in
199
+ # _BOOL_KEYS, str otherwise); write_config's own parameters are precisely
200
+ # typed per key, so the checker can't verify this dynamic dispatch itself.
201
+ write_config(**cast("dict[str, Any]", {_CONFIG_KEYS[key]: write_value}))
202
+ if key in _SECRET_KEYS:
203
+ display_value = "***"
204
+ elif key in _BOOL_KEYS:
205
+ display_value = str(write_value).lower()
206
+ else:
207
+ display_value = repr(write_value)
208
+ print(f"Wrote {key} = {display_value} to {config_path}")
209
+ return 0
210
+
211
+ if action == "get":
212
+ if len(argv) < 2:
213
+ print("usage: memgraph-context-graph config get <key>", file=sys.stderr)
214
+ return 2
215
+ key = argv[1]
216
+ if key not in _CONFIG_KEYS:
217
+ print(f"Unknown config key: {key}", file=sys.stderr)
218
+ return 2
219
+
220
+ config = load_config()
221
+ attr = _CONFIG_KEYS[key]
222
+ value = getattr(config, attr)
223
+ if value is None:
224
+ print(f"{key} is not set in {config_path}", file=sys.stderr)
225
+ return 1
226
+ # Don't print secrets to stdout unless explicitly requested.
227
+ if key in _SECRET_KEYS:
228
+ print("***")
229
+ else:
230
+ print(value)
231
+ return 0
232
+
233
+ print(f"Unknown config action: {action}", file=sys.stderr)
234
+ return 2
235
+
236
+
237
+ def _mcp(argv: list[str]) -> int:
238
+ argparse.ArgumentParser(
239
+ prog="memgraph-context-graph mcp", description="Serve the registered tools over stdio MCP."
240
+ ).parse_args(argv)
241
+ try:
242
+ from memgraph_context_graph.mcp_server import serve
243
+ except ImportError as exc:
244
+ print(f"memgraph-context-graph mcp needs the mcp extra: uv tool install 'memgraph-context-graph[mcp]' ({exc})",
245
+ file=sys.stderr) # fmt: skip
246
+ return 1
247
+ return serve()
248
+
249
+
250
+ def _recall(argv: list[str]) -> int:
251
+ from memgraph_context_graph.adapters._identity import load_config
252
+ from memgraph_context_graph.tools import ToolError, load_tools
253
+
254
+ parser = argparse.ArgumentParser(
255
+ prog="memgraph-context-graph recall", description="Search your own past sessions, as the recall tool does."
256
+ )
257
+ parser.add_argument("question", nargs="+", help="What to look for, as you would ask it.")
258
+ parser.add_argument("--json", action="store_true", help="Print the turns and facts as JSON.")
259
+ args = parser.parse_args(argv)
260
+
261
+ tool = load_tools().get("recall")
262
+ if tool is None:
263
+ print("No recall tool is installed: it comes with sessions-graph[memgraph-context-graph].", file=sys.stderr)
264
+ return 1
265
+ try:
266
+ result = tool.call({"question": " ".join(args.question)}, load_config())
267
+ except ToolError as exc:
268
+ print(str(exc), file=sys.stderr)
269
+ return 1
270
+ print(json.dumps(result.structured, indent=2) if args.json else result.text)
271
+ return 0
272
+
273
+
274
+ def _join(argv: list[str]) -> int:
275
+ """Point the config file at an Enterprise Memgraph as one login, then prove the login works."""
276
+ import getpass
277
+
278
+ from memgraph_context_graph.adapters._identity import config_file_path, reload_config, write_config
279
+ from memgraph_context_graph.spaces import EDITION_ENTERPRISE, SpaceError, spaces_for
280
+
281
+ parser = argparse.ArgumentParser(
282
+ prog="memgraph-context-graph join", description="Use an Enterprise Memgraph's personal and shared spaces."
283
+ )
284
+ parser.add_argument("url", help="The Memgraph Bolt URL, e.g. bolt://memgraph.corp:7687.")
285
+ parser.add_argument("login", help="Your Memgraph login; it is also who you are in Context Graph.")
286
+ parser.add_argument("--password", default=None, help="Prompted for when omitted.")
287
+ args = parser.parse_args(argv)
288
+ password = args.password if args.password is not None else getpass.getpass(f"Password for {args.login}: ")
289
+
290
+ write_config(
291
+ edition=EDITION_ENTERPRISE, memgraph_url=args.url, memgraph_user=args.login, memgraph_password=password
292
+ )
293
+ try:
294
+ spaces = spaces_for(reload_config())
295
+ from memgraph_toolbox.api.memgraph import Memgraph
296
+
297
+ client = Memgraph(**spaces.personal().target.client_kwargs())
298
+ try:
299
+ (row,) = client.query("SHOW CURRENT DATABASE")
300
+ finally:
301
+ client.close()
302
+ shared = [space.name for space in spaces.readable() if space.shared]
303
+ except (SpaceError, ValueError) as exc:
304
+ print(f"Wrote {config_file_path()}, but the login doesn't work yet: {exc}", file=sys.stderr)
305
+ return 1
306
+ print(f"Wrote {config_file_path()}: edition = enterprise, login {args.login!r}.")
307
+ print(f"Personal space: {row['Current']}. Shared spaces: {', '.join(shared) or 'none yet'}.")
308
+ print("Next: memgraph-context-graph setup <runtime> (e.g. claude-code) if this machine isn't set up yet.")
309
+ return 0
310
+
311
+
312
+ def _bootstrap(argv: list[str]) -> int:
313
+ from memgraph_context_graph.hooks.runtime_plugin import load_runtime_plugins
314
+
315
+ parser = argparse.ArgumentParser(description="Install and verify Memgraph Context Graph runtime dependencies.")
316
+ parser.add_argument(
317
+ "--runtime",
318
+ choices=sorted(load_runtime_plugins()),
319
+ required=True,
320
+ help="Runtime hook adapter to bootstrap.",
321
+ )
322
+ parser.add_argument(
323
+ "--connector",
324
+ action="append",
325
+ default=None,
326
+ help="Graph connector to enable. Defaults to skills-graph.",
327
+ )
328
+ parser.add_argument(
329
+ "--memgraph-host",
330
+ default=None,
331
+ help="Memgraph host. Defaults to MEMGRAPH_HOST or localhost.",
332
+ )
333
+ parser.add_argument(
334
+ "--memgraph-port",
335
+ type=int,
336
+ default=None,
337
+ help="Memgraph Bolt port. Defaults to MEMGRAPH_PORT or 7687.",
338
+ )
339
+ parser.add_argument(
340
+ "--no-reinstall",
341
+ action="store_true",
342
+ help="Do not pass --reinstall to uv tool install.",
343
+ )
344
+ args = parser.parse_args(argv)
345
+
346
+ connectors = args.connector or ["skills-graph"]
347
+ # Resolve Memgraph URL: MEMGRAPH_URL env takes priority (supports neo4j:// for HA).
348
+ # Falls back to host:port construction for backward compatibility.
349
+ memgraph_url_env = os.environ.get("MEMGRAPH_URL")
350
+ if memgraph_url_env:
351
+ memgraph_url = memgraph_url_env
352
+ # For connectivity check, we still need host:port — skip if using neo4j:// routing.
353
+ from urllib.parse import urlparse
354
+
355
+ parsed = urlparse(memgraph_url)
356
+ host = parsed.hostname or "localhost"
357
+ port = parsed.port or 7687
358
+ else:
359
+ host = args.memgraph_host if args.memgraph_host is not None else os.environ.get("MEMGRAPH_HOST", "localhost")
360
+ port = args.memgraph_port if args.memgraph_port is not None else int(os.environ.get("MEMGRAPH_PORT", "7687"))
361
+ memgraph_url = f"bolt://{host}:{port}"
362
+
363
+ uv = shutil.which("uv")
364
+ if not uv:
365
+ print("FAIL uv: not found on PATH", file=sys.stderr)
366
+ print("Install uv first:", file=sys.stderr)
367
+ print(" curl -LsSf https://astral.sh/uv/install.sh | sh", file=sys.stderr)
368
+ print(
369
+ "uv manages Python for the tool. If uv-managed Python downloads are blocked, install Python 3.10+.",
370
+ file=sys.stderr,
371
+ )
372
+ return 1
373
+
374
+ if not _memgraph_reachable(host, port):
375
+ print(f"FAIL memgraph: bolt://{host}:{port} is not reachable", file=sys.stderr)
376
+ print("Start Memgraph, then rerun bootstrap:", file=sys.stderr)
377
+ print(f" {_memgraph_command(port)}", file=sys.stderr)
378
+ print("Or point bootstrap at an existing instance:", file=sys.stderr)
379
+ print(
380
+ f" memgraph-context-graph bootstrap --runtime {args.runtime} "
381
+ f"--connector {' --connector '.join(connectors)} --memgraph-host <host> --memgraph-port <port>",
382
+ file=sys.stderr,
383
+ )
384
+ return 1
385
+
386
+ print(f"OK uv: {uv}")
387
+ print(f"OK memgraph: bolt://{host}:{port} reachable")
388
+
389
+ install_cmd = [uv, "tool", "install", "memgraph-context-graph[mcp]"]
390
+ for connector in connectors:
391
+ requirement = _connector_requirement(connector)
392
+ if requirement is None:
393
+ print(f"FAIL connector:{connector}: unsupported connector", file=sys.stderr)
394
+ return 1
395
+ install_cmd.extend(["--with", requirement])
396
+ if not args.no_reinstall:
397
+ install_cmd.append("--reinstall")
398
+
399
+ try:
400
+ subprocess.run(install_cmd, check=True)
401
+ except subprocess.CalledProcessError as exc:
402
+ print(f"FAIL install: uv tool install exited with status {exc.returncode}", file=sys.stderr)
403
+ return exc.returncode
404
+
405
+ executable = shutil.which("memgraph-context-graph")
406
+ if not executable:
407
+ print("FAIL memgraph-context-graph: installed but not on PATH", file=sys.stderr)
408
+ print("uv usually installs tools into ~/.local/bin.", file=sys.stderr)
409
+ print("Add that directory to PATH, restart your shell, then rerun doctor.", file=sys.stderr)
410
+ return 1
411
+
412
+ print(f"OK memgraph-context-graph executable: {executable}")
413
+
414
+ # Write hook configuration file (auto-write + inform).
415
+ from memgraph_context_graph.adapters._identity import write_full_config
416
+
417
+ user_id = os.environ.get("MEMGRAPH_CONTEXT_GRAPH_USER_ID", "")
418
+ memgraph_user = os.environ.get("MEMGRAPH_USER", "")
419
+ memgraph_password = os.environ.get("MEMGRAPH_PASSWORD", "")
420
+ memgraph_database = os.environ.get("MEMGRAPH_DATABASE", "memgraph")
421
+ openai_api_key = os.environ.get("OPENAI_API_KEY", "")
422
+ anthropic_api_key = os.environ.get("ANTHROPIC_API_KEY", "")
423
+
424
+ # auto_reconcile is deliberately NOT captured from SESSIONS_GRAPH_AUTO_RECONCILE
425
+ # here, unlike the keys above: those have a legitimate ambient-env source
426
+ # (people already have OPENAI_API_KEY/MEMGRAPH_PASSWORD exported for other
427
+ # tools), but nobody has this env var set for an unrelated reason -- it's
428
+ # only ever set via `config set reconcile.auto_reconcile`. write_full_config
429
+ # preserves the existing value when auto_reconcile isn't passed, so
430
+ # re-running bootstrap can't silently revert it to off.
431
+ config_path = write_full_config(
432
+ user_id=user_id,
433
+ memgraph_url=memgraph_url,
434
+ memgraph_user=memgraph_user,
435
+ memgraph_password=memgraph_password,
436
+ memgraph_database=memgraph_database,
437
+ openai_api_key=openai_api_key,
438
+ anthropic_api_key=anthropic_api_key,
439
+ )
440
+ print(f"OK config: wrote {config_path}")
441
+ if user_id:
442
+ print(f" identity.user_id = {user_id!r}")
443
+ else:
444
+ print(
445
+ " identity.user_id is empty — set it with: memgraph-context-graph config set identity.user_id <your-name>"
446
+ )
447
+ print(f" memgraph.url = {memgraph_url!r}")
448
+ if openai_api_key or anthropic_api_key:
449
+ found = ", ".join(
450
+ name
451
+ for name, value in (("llm.openai_api_key", openai_api_key), ("llm.anthropic_api_key", anthropic_api_key))
452
+ if value
453
+ )
454
+ print(f" {found} captured from environment")
455
+ else:
456
+ print(
457
+ " no LLM API key found in environment — sessions-graph reconciliation needs one; "
458
+ "set with: memgraph-context-graph config set llm.openai_api_key"
459
+ )
460
+ doctor_args = ["--runtime", args.runtime]
461
+ for connector in connectors:
462
+ doctor_args.extend(["--connector", connector])
463
+ return _doctor(doctor_args)
464
+
465
+
466
+ def _doctor(argv: list[str]) -> int:
467
+ from memgraph_context_graph.hooks.runtime_plugin import load_runtime_plugins
468
+
469
+ parser = argparse.ArgumentParser(description="Check Memgraph Context Graph runtime hook setup.")
470
+ parser.add_argument(
471
+ "--runtime",
472
+ choices=sorted(load_runtime_plugins()),
473
+ default="claude-code",
474
+ help="Runtime hook adapter to smoke test.",
475
+ )
476
+ parser.add_argument(
477
+ "--connector",
478
+ action="append",
479
+ default=None,
480
+ help="Graph connector to enable. Defaults to skills-graph.",
481
+ )
482
+ parser.add_argument(
483
+ "--json",
484
+ action="store_true",
485
+ help="Print machine-readable JSON.",
486
+ )
487
+ args = parser.parse_args(argv)
488
+
489
+ connectors = args.connector or ["skills-graph"]
490
+ checks = [
491
+ _check_cli(),
492
+ _check_package("memgraph-context-graph"),
493
+ _check_config(),
494
+ ]
495
+ # Every later check connects through the edition's space resolver; when the config can't
496
+ # produce one, the config check already says why, and the rest would only repeat it.
497
+ if _edition_resolves():
498
+ checks.append(_check_memgraph())
499
+ for connector in connectors:
500
+ checks.append(_check_connector(connector))
501
+ if any(connector.strip().replace("_", "-") == "sessions-graph" for connector in connectors):
502
+ checks.append(_check_embeddings())
503
+ checks.append(_check_mcp())
504
+ checks.append(_check_runtime(args.runtime, connectors))
505
+ if (memory_check := _check_memory_backend(args.runtime)) is not None:
506
+ checks.append(memory_check)
507
+
508
+ ok = all(check["ok"] for check in checks)
509
+ payload: _DoctorPayload = {"ok": ok, "checks": checks}
510
+ if args.json:
511
+ print(json.dumps(payload, indent=2))
512
+ else:
513
+ _print_doctor(payload)
514
+ return 0 if ok else 1
515
+
516
+
517
+ class _CheckResult(TypedDict):
518
+ name: str
519
+ ok: bool
520
+ detail: str
521
+
522
+
523
+ class _DoctorPayload(TypedDict):
524
+ ok: bool
525
+ checks: list[_CheckResult]
526
+
527
+
528
+ def _edition_resolves() -> bool:
529
+ from memgraph_context_graph.adapters._identity import load_config
530
+ from memgraph_context_graph.spaces import SpaceError, spaces_for
531
+
532
+ try:
533
+ spaces_for(load_config())
534
+ except SpaceError:
535
+ return False
536
+ return True
537
+
538
+
539
+ def _check_config() -> _CheckResult:
540
+ from memgraph_context_graph.adapters._identity import config_file_path, load_config
541
+
542
+ path = config_file_path()
543
+ if not path.is_file():
544
+ return {
545
+ "name": "config",
546
+ "ok": False,
547
+ "detail": f"{path} not found — run: memgraph-context-graph bootstrap or memgraph-context-graph config set identity.user_id <name>",
548
+ }
549
+ from memgraph_context_graph.spaces import SpaceError, spaces_for
550
+
551
+ config = load_config()
552
+ try:
553
+ spaces = spaces_for(config)
554
+ except SpaceError as exc:
555
+ return {"name": "config", "ok": False, "detail": f"{path} — {exc}"}
556
+ user_id = spaces.user_id()
557
+ parts = [f"edition={spaces.edition}"]
558
+ if user_id:
559
+ parts.append(f"user_id={user_id!r}")
560
+ else:
561
+ parts.append("user_id=NOT SET")
562
+ parts.append(f"memgraph.url={spaces.personal().target.url!r}")
563
+ llm_key_set = bool(config.openai_api_key or config.anthropic_api_key)
564
+ parts.append(
565
+ "llm_key=SET" if llm_key_set else "llm_key=NOT SET (needed only for sessions-graph auto-reconciliation)"
566
+ )
567
+ auto_reconcile_on = bool(config.auto_reconcile)
568
+ parts.append(f"auto_reconcile={'ON' if auto_reconcile_on else 'OFF'}")
569
+ broken_auto_reconcile = auto_reconcile_on and not llm_key_set
570
+ if broken_auto_reconcile:
571
+ parts.append("WARNING: auto_reconcile is ON but no LLM key is set — reconciliation will fail")
572
+ ok = bool(user_id) and not broken_auto_reconcile
573
+ return {"name": "config", "ok": ok, "detail": f"{path} — {'; '.join(parts)}"}
574
+
575
+
576
+ def _check_memgraph() -> _CheckResult:
577
+ from memgraph_context_graph.adapters._identity import resolve_memgraph_env
578
+
579
+ env = resolve_memgraph_env()
580
+ url = env["MEMGRAPH_URL"]
581
+ try:
582
+ from memgraph_toolbox.api.memgraph import Memgraph
583
+
584
+ db = Memgraph(
585
+ url=env["MEMGRAPH_URL"],
586
+ username=env["MEMGRAPH_USER"],
587
+ password=env["MEMGRAPH_PASSWORD"],
588
+ database=env["MEMGRAPH_DATABASE"],
589
+ )
590
+ db.query("RETURN 1")
591
+ return {"name": "memgraph", "ok": True, "detail": f"{url} reachable"}
592
+ except Exception as exc:
593
+ return {"name": "memgraph", "ok": False, "detail": f"{url} — {type(exc).__name__}: {exc}"}
594
+ finally:
595
+ driver = getattr(getattr(locals().get("db", None), "driver", None), "driver", None)
596
+ if driver is not None:
597
+ driver.close()
598
+
599
+
600
+ def _check_cli() -> _CheckResult:
601
+ path = shutil.which("memgraph-context-graph") or sys.argv[0]
602
+ return {"name": "memgraph-context-graph executable", "ok": bool(path), "detail": path or "not found"}
603
+
604
+
605
+ def _check_package(package_name: str) -> _CheckResult:
606
+ version = _package_version(package_name)
607
+ ok = version != "unknown"
608
+ return {"name": package_name, "ok": ok, "detail": version if ok else "not installed"}
609
+
610
+
611
+ def _check_connector(connector_name: str) -> _CheckResult:
612
+ normalized = connector_name.strip().replace("-", "_")
613
+ if normalized == "skills_graph":
614
+ try:
615
+ from skills_graph import SkillGraph
616
+
617
+ graph = SkillGraph()
618
+ version = _package_version("skills-graph")
619
+ return {"name": "connector:skills-graph", "ok": True, "detail": f"installed={version}; memgraph=reachable"}
620
+ except Exception as exc:
621
+ return {"name": "connector:skills-graph", "ok": False, "detail": f"{type(exc).__name__}: {exc}"}
622
+ finally:
623
+ driver = getattr(getattr(locals().get("graph", None), "_db", None), "driver", None)
624
+ if driver is not None:
625
+ driver.close()
626
+
627
+ if normalized == "actions_graph":
628
+ try:
629
+ from actions_graph import ActionsGraph
630
+
631
+ graph = ActionsGraph()
632
+ version = _package_version("actions-graph")
633
+ return {"name": "connector:actions-graph", "ok": True, "detail": f"installed={version}; memgraph=reachable"}
634
+ except Exception as exc:
635
+ return {"name": "connector:actions-graph", "ok": False, "detail": f"{type(exc).__name__}: {exc}"}
636
+ finally:
637
+ driver = getattr(getattr(locals().get("graph", None), "_db", None), "driver", None)
638
+ if driver is not None:
639
+ driver.close()
640
+
641
+ if normalized == "sessions_graph":
642
+ try:
643
+ from sessions_graph import SessionsGraph
644
+
645
+ from memgraph_context_graph.adapters._identity import resolve_memgraph_env, resolve_user_id
646
+
647
+ env = resolve_memgraph_env()
648
+ graph = SessionsGraph(
649
+ url=env["MEMGRAPH_URL"],
650
+ username=env["MEMGRAPH_USER"],
651
+ password=env["MEMGRAPH_PASSWORD"],
652
+ database=env["MEMGRAPH_DATABASE"],
653
+ )
654
+ version = _package_version("sessions-graph")
655
+ user_id = resolve_user_id({})
656
+ if not user_id:
657
+ return {
658
+ "name": "connector:sessions-graph",
659
+ "ok": False,
660
+ "detail": "user_id not resolved — run: memgraph-context-graph config set identity.user_id <your-name>",
661
+ }
662
+ return {
663
+ "name": "connector:sessions-graph",
664
+ "ok": True,
665
+ "detail": f"installed={version}; memgraph=reachable; user_id={user_id!r}",
666
+ }
667
+ except Exception as exc:
668
+ return {"name": "connector:sessions-graph", "ok": False, "detail": f"{type(exc).__name__}: {exc}"}
669
+ finally:
670
+ driver = getattr(getattr(locals().get("graph", None), "_db", None), "driver", None)
671
+ if driver is not None:
672
+ driver.close()
673
+
674
+ if normalized == "resources_graph":
675
+ try:
676
+ from memgraph_context_graph.adapters._identity import load_config, resolve_memgraph_env
677
+ from resources_graph import ResourcesGraph
678
+
679
+ env = resolve_memgraph_env()
680
+ graph = ResourcesGraph(
681
+ url=env["MEMGRAPH_URL"],
682
+ username=env["MEMGRAPH_USER"],
683
+ password=env["MEMGRAPH_PASSWORD"],
684
+ database=env["MEMGRAPH_DATABASE"],
685
+ )
686
+ version = _package_version("resources-graph")
687
+ # Touches are recorded without a token; the Sweep falls back to the user's gh login.
688
+ token = "set" if load_config().github_token else "unset (the Sweep uses the gh login)"
689
+ return {
690
+ "name": "connector:resources-graph",
691
+ "ok": True,
692
+ "detail": f"installed={version}; memgraph=reachable; github.token {token}",
693
+ }
694
+ except Exception as exc:
695
+ return {"name": "connector:resources-graph", "ok": False, "detail": f"{type(exc).__name__}: {exc}"}
696
+ finally:
697
+ driver = getattr(getattr(locals().get("graph", None), "_db", None), "driver", None)
698
+ if driver is not None:
699
+ driver.close()
700
+
701
+ else:
702
+ return {"name": f"connector:{connector_name}", "ok": False, "detail": "unsupported connector"}
703
+
704
+
705
+ def _check_embeddings() -> _CheckResult:
706
+ """Whether Memgraph can embed for recall: MAGE's ``embeddings`` module, with the configured model loading.
707
+
708
+ The first run downloads the model inside Memgraph, so this can take a while once.
709
+ """
710
+ from memgraph_context_graph.adapters._identity import resolve_embedding_model, resolve_memgraph_env
711
+
712
+ try:
713
+ from sessions_graph.embeddings import DEFAULT_EMBEDDING_MODEL, check_available
714
+
715
+ from memgraph_toolbox.api.memgraph import Memgraph
716
+ except ImportError as exc:
717
+ return {"name": "embeddings", "ok": False, "detail": f"{type(exc).__name__}: {exc}"}
718
+
719
+ model = resolve_embedding_model() or DEFAULT_EMBEDDING_MODEL
720
+ env = resolve_memgraph_env()
721
+ db = None
722
+ try:
723
+ db = Memgraph(
724
+ url=env["MEMGRAPH_URL"],
725
+ username=env["MEMGRAPH_USER"],
726
+ password=env["MEMGRAPH_PASSWORD"],
727
+ database=env["MEMGRAPH_DATABASE"],
728
+ )
729
+ dimension = check_available(db, model)
730
+ return {"name": "embeddings", "ok": True, "detail": f"{model} ({dimension} dimensions) inside Memgraph"}
731
+ except Exception as exc:
732
+ return {
733
+ "name": "embeddings",
734
+ "ok": False,
735
+ "detail": f"{model} — {exc}. Recall needs Memgraph with MAGE (memgraph/memgraph-mage, 2 GiB or more); "
736
+ "without it, recall's vector lanes are off",
737
+ }
738
+ finally:
739
+ driver = getattr(getattr(db, "driver", None), "driver", None)
740
+ if driver is not None:
741
+ driver.close()
742
+
743
+
744
+ def _check_mcp() -> _CheckResult:
745
+ """Whether ``memgraph-context-graph mcp`` can start and serves recall."""
746
+ from memgraph_context_graph.tools import load_tools
747
+
748
+ try:
749
+ import mcp # noqa: F401 -- only whether the extra is installed
750
+ except ImportError:
751
+ return {
752
+ "name": "mcp",
753
+ "ok": False,
754
+ "detail": "the mcp extra is missing, so the recall tool can't be served — "
755
+ "run: uv tool install 'memgraph-context-graph[mcp]' --with 'sessions-graph[memgraph-context-graph]'",
756
+ }
757
+ names = sorted(load_tools())
758
+ if "recall" not in names:
759
+ return {"name": "mcp", "ok": False, "detail": f"no recall tool registered (tools: {names or 'none'})"}
760
+ return {"name": "mcp", "ok": True, "detail": f"serves {', '.join(names)}"}
761
+
762
+
763
+ def _check_memory_backend(runtime: str) -> _CheckResult | None:
764
+ """Which memory the runtime's model uses; fails when both are on. None for runtimes that can't switch."""
765
+ from pathlib import Path
766
+
767
+ from memgraph_context_graph.adapters._identity import load_config
768
+ from memgraph_context_graph.hooks.runtime_plugin import get_runtime_plugin
769
+ from memgraph_context_graph.memory_backend import MemoryBackendError, native_memory_of
770
+
771
+ native = native_memory_of(get_runtime_plugin(runtime))
772
+ if native is None:
773
+ return None
774
+ try:
775
+ native_on = native.enabled(Path.home())
776
+ except MemoryBackendError as exc:
777
+ return {"name": "memory", "ok": False, "detail": str(exc)}
778
+ if not load_config().graph_memory:
779
+ return {"name": "memory", "ok": True, "detail": f"{runtime}'s built-in memory (memory.backend native)"}
780
+ if native_on:
781
+ return {
782
+ "name": "memory",
783
+ "ok": False,
784
+ "detail": f"memory.backend is context-graph but {runtime}'s built-in memory is still on, so the model "
785
+ f"has two memories — run: memgraph-context-graph setup {runtime} --memory-backend context-graph",
786
+ }
787
+ return {"name": "memory", "ok": True, "detail": f"Context Graph; {runtime}'s built-in memory is off"}
788
+
789
+
790
+ def _check_runtime(runtime: str, connectors: list[str]) -> _CheckResult:
791
+ try:
792
+ from memgraph_context_graph.hooks.runner import create_link
793
+ from memgraph_context_graph.hooks.runtime_plugin import get_runtime_plugin
794
+
795
+ plugin = get_runtime_plugin(runtime)
796
+ link = create_link(connectors)
797
+ adapter = plugin.adapter_class(link)
798
+ adapter.handle_payload(dict(plugin.probe_payload))
799
+ return {"name": f"runtime:{runtime}", "ok": True, "detail": "strict hook smoke passed"}
800
+ except Exception as exc:
801
+ return {"name": f"runtime:{runtime}", "ok": False, "detail": f"{type(exc).__name__}: {exc}"}
802
+
803
+
804
+ def _print_doctor(payload: _DoctorPayload) -> None:
805
+ for check in payload["checks"]:
806
+ status = "OK" if check["ok"] else "FAIL"
807
+ print(f"{status} {check['name']}: {check['detail']}")
808
+
809
+
810
+ def _package_version(package_name: str) -> str:
811
+ try:
812
+ return metadata.version(package_name)
813
+ except metadata.PackageNotFoundError:
814
+ return "unknown"
815
+
816
+
817
+ def _memgraph_reachable(host: str, port: int) -> bool:
818
+ try:
819
+ with socket.create_connection((host, port), timeout=2):
820
+ return True
821
+ except OSError:
822
+ return False
823
+
824
+
825
+ def _memgraph_command(port: int) -> str:
826
+ published = f"{port}:7687"
827
+ # MAGE, not plain memgraph: sessions-graph embeds for recall with its embeddings module.
828
+ return f"docker run --rm -p {published} memgraph/memgraph-mage"
829
+
830
+
831
+ def _connector_requirement(connector: str) -> str | None:
832
+ normalized = connector.strip().replace("_", "-")
833
+ if normalized == "skills-graph":
834
+ return "skills-graph[memgraph-context-graph]"
835
+ if normalized == "actions-graph":
836
+ return "actions-graph[memgraph-context-graph]"
837
+ if normalized == "sessions-graph":
838
+ return "sessions-graph[memgraph-context-graph]"
839
+ if normalized == "resources-graph":
840
+ return "resources-graph[memgraph-context-graph]"
841
+ return None
842
+
843
+
844
+ if __name__ == "__main__":
845
+ raise SystemExit(main())