memgraph-context-graph 0.7.2__tar.gz → 0.7.4__tar.gz

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 (76) hide show
  1. {memgraph_context_graph-0.7.2 → memgraph_context_graph-0.7.4}/PKG-INFO +1 -1
  2. {memgraph_context_graph-0.7.2 → memgraph_context_graph-0.7.4}/pyproject.toml +1 -1
  3. {memgraph_context_graph-0.7.2 → memgraph_context_graph-0.7.4}/src/memgraph_context_graph/adapters/codex_memory.py +52 -0
  4. {memgraph_context_graph-0.7.2 → memgraph_context_graph-0.7.4}/src/memgraph_context_graph/cli.py +19 -3
  5. {memgraph_context_graph-0.7.2 → memgraph_context_graph-0.7.4}/src/memgraph_context_graph/setup/agents.py +35 -3
  6. {memgraph_context_graph-0.7.2 → memgraph_context_graph-0.7.4}/src/memgraph_context_graph/setup/cli.py +8 -3
  7. {memgraph_context_graph-0.7.2 → memgraph_context_graph-0.7.4}/src/memgraph_context_graph/setup/engine.py +60 -6
  8. {memgraph_context_graph-0.7.2 → memgraph_context_graph-0.7.4}/src/memgraph_context_graph/setup/prompts.py +16 -13
  9. {memgraph_context_graph-0.7.2 → memgraph_context_graph-0.7.4}/src/memgraph_context_graph/setup/tool.py +27 -8
  10. {memgraph_context_graph-0.7.2 → memgraph_context_graph-0.7.4}/src/memgraph_context_graph/spaces.py +4 -2
  11. {memgraph_context_graph-0.7.2 → memgraph_context_graph-0.7.4}/tests/test_dev_scripts.py +1 -1
  12. {memgraph_context_graph-0.7.2 → memgraph_context_graph-0.7.4}/tests/test_setup.py +166 -0
  13. {memgraph_context_graph-0.7.2 → memgraph_context_graph-0.7.4}/.gitignore +0 -0
  14. {memgraph_context_graph-0.7.2 → memgraph_context_graph-0.7.4}/CONTEXT.md +0 -0
  15. {memgraph_context_graph-0.7.2 → memgraph_context_graph-0.7.4}/LICENSE +0 -0
  16. {memgraph_context_graph-0.7.2 → memgraph_context_graph-0.7.4}/README.md +0 -0
  17. {memgraph_context_graph-0.7.2 → memgraph_context_graph-0.7.4}/docs/adr/0001-runtime-adapter-terminology.md +0 -0
  18. {memgraph_context_graph-0.7.2 → memgraph_context_graph-0.7.4}/docs/adr/0002-config-file-only-hook-resolution.md +0 -0
  19. {memgraph_context_graph-0.7.2 → memgraph_context_graph-0.7.4}/docs/adr/0003-config-path-override.md +0 -0
  20. {memgraph_context_graph-0.7.2 → memgraph_context_graph-0.7.4}/docs/adr/0004-user-id-in-session-start-event.md +0 -0
  21. {memgraph_context_graph-0.7.2 → memgraph_context_graph-0.7.4}/docs/adr/0005-config-file-covers-llm-key-and-subprocess-env.md +0 -0
  22. {memgraph_context_graph-0.7.2 → memgraph_context_graph-0.7.4}/docs/adr/0006-memory-tool-writes-explicit-memory.md +0 -0
  23. {memgraph_context_graph-0.7.2 → memgraph_context_graph-0.7.4}/docs/command-hooks.md +0 -0
  24. {memgraph_context_graph-0.7.2 → memgraph_context_graph-0.7.4}/docs/research/2026-10-codex-memory.md +0 -0
  25. {memgraph_context_graph-0.7.2 → memgraph_context_graph-0.7.4}/src/memgraph_context_graph/__init__.py +0 -0
  26. {memgraph_context_graph-0.7.2 → memgraph_context_graph-0.7.4}/src/memgraph_context_graph/adapters/__init__.py +0 -0
  27. {memgraph_context_graph-0.7.2 → memgraph_context_graph-0.7.4}/src/memgraph_context_graph/adapters/_identity.py +0 -0
  28. {memgraph_context_graph-0.7.2 → memgraph_context_graph-0.7.4}/src/memgraph_context_graph/adapters/_opencode_plugin.js +0 -0
  29. {memgraph_context_graph-0.7.2 → memgraph_context_graph-0.7.4}/src/memgraph_context_graph/adapters/_spec.py +0 -0
  30. {memgraph_context_graph-0.7.2 → memgraph_context_graph-0.7.4}/src/memgraph_context_graph/adapters/antigravity_cli.py +0 -0
  31. {memgraph_context_graph-0.7.2 → memgraph_context_graph-0.7.4}/src/memgraph_context_graph/adapters/claude.py +0 -0
  32. {memgraph_context_graph-0.7.2 → memgraph_context_graph-0.7.4}/src/memgraph_context_graph/adapters/claude_code.py +0 -0
  33. {memgraph_context_graph-0.7.2 → memgraph_context_graph-0.7.4}/src/memgraph_context_graph/adapters/claude_code_memory.py +0 -0
  34. {memgraph_context_graph-0.7.2 → memgraph_context_graph-0.7.4}/src/memgraph_context_graph/adapters/codex.py +0 -0
  35. {memgraph_context_graph-0.7.2 → memgraph_context_graph-0.7.4}/src/memgraph_context_graph/adapters/copilot_cli.py +0 -0
  36. {memgraph_context_graph-0.7.2 → memgraph_context_graph-0.7.4}/src/memgraph_context_graph/adapters/cursor.py +0 -0
  37. {memgraph_context_graph-0.7.2 → memgraph_context_graph-0.7.4}/src/memgraph_context_graph/adapters/grok.py +0 -0
  38. {memgraph_context_graph-0.7.2 → memgraph_context_graph-0.7.4}/src/memgraph_context_graph/adapters/openai.py +0 -0
  39. {memgraph_context_graph-0.7.2 → memgraph_context_graph-0.7.4}/src/memgraph_context_graph/adapters/opencode.py +0 -0
  40. {memgraph_context_graph-0.7.2 → memgraph_context_graph-0.7.4}/src/memgraph_context_graph/capture_log.py +0 -0
  41. {memgraph_context_graph-0.7.2 → memgraph_context_graph-0.7.4}/src/memgraph_context_graph/events.py +0 -0
  42. {memgraph_context_graph-0.7.2 → memgraph_context_graph-0.7.4}/src/memgraph_context_graph/hooks/__init__.py +0 -0
  43. {memgraph_context_graph-0.7.2 → memgraph_context_graph-0.7.4}/src/memgraph_context_graph/hooks/cli.py +0 -0
  44. {memgraph_context_graph-0.7.2 → memgraph_context_graph-0.7.4}/src/memgraph_context_graph/hooks/runner.py +0 -0
  45. {memgraph_context_graph-0.7.2 → memgraph_context_graph-0.7.4}/src/memgraph_context_graph/hooks/runtime_plugin.py +0 -0
  46. {memgraph_context_graph-0.7.2 → memgraph_context_graph-0.7.4}/src/memgraph_context_graph/link.py +0 -0
  47. {memgraph_context_graph-0.7.2 → memgraph_context_graph-0.7.4}/src/memgraph_context_graph/mcp_server.py +0 -0
  48. {memgraph_context_graph-0.7.2 → memgraph_context_graph-0.7.4}/src/memgraph_context_graph/memory_backend.py +0 -0
  49. {memgraph_context_graph-0.7.2 → memgraph_context_graph-0.7.4}/src/memgraph_context_graph/protocols.py +0 -0
  50. {memgraph_context_graph-0.7.2 → memgraph_context_graph-0.7.4}/src/memgraph_context_graph/setup/__init__.py +0 -0
  51. {memgraph_context_graph-0.7.2 → memgraph_context_graph-0.7.4}/src/memgraph_context_graph/setup/memgraph.py +0 -0
  52. {memgraph_context_graph-0.7.2 → memgraph_context_graph-0.7.4}/src/memgraph_context_graph/setup/uninstall.py +0 -0
  53. {memgraph_context_graph-0.7.2 → memgraph_context_graph-0.7.4}/src/memgraph_context_graph/tools.py +0 -0
  54. {memgraph_context_graph-0.7.2 → memgraph_context_graph-0.7.4}/tests/__init__.py +0 -0
  55. {memgraph_context_graph-0.7.2 → memgraph_context_graph-0.7.4}/tests/conftest.py +0 -0
  56. {memgraph_context_graph-0.7.2 → memgraph_context_graph-0.7.4}/tests/fake_agent_cli.py +0 -0
  57. {memgraph_context_graph-0.7.2 → memgraph_context_graph-0.7.4}/tests/test_antigravity_cli_adapter.py +0 -0
  58. {memgraph_context_graph-0.7.2 → memgraph_context_graph-0.7.4}/tests/test_capture_log.py +0 -0
  59. {memgraph_context_graph-0.7.2 → memgraph_context_graph-0.7.4}/tests/test_claude_code_adapter.py +0 -0
  60. {memgraph_context_graph-0.7.2 → memgraph_context_graph-0.7.4}/tests/test_codex_adapter.py +0 -0
  61. {memgraph_context_graph-0.7.2 → memgraph_context_graph-0.7.4}/tests/test_config_cli.py +0 -0
  62. {memgraph_context_graph-0.7.2 → memgraph_context_graph-0.7.4}/tests/test_context_before_tool.py +0 -0
  63. {memgraph_context_graph-0.7.2 → memgraph_context_graph-0.7.4}/tests/test_copilot_cli_adapter.py +0 -0
  64. {memgraph_context_graph-0.7.2 → memgraph_context_graph-0.7.4}/tests/test_cursor_adapter.py +0 -0
  65. {memgraph_context_graph-0.7.2 → memgraph_context_graph-0.7.4}/tests/test_events.py +0 -0
  66. {memgraph_context_graph-0.7.2 → memgraph_context_graph-0.7.4}/tests/test_grok_adapter.py +0 -0
  67. {memgraph_context_graph-0.7.2 → memgraph_context_graph-0.7.4}/tests/test_hook_cli.py +0 -0
  68. {memgraph_context_graph-0.7.2 → memgraph_context_graph-0.7.4}/tests/test_identity.py +0 -0
  69. {memgraph_context_graph-0.7.2 → memgraph_context_graph-0.7.4}/tests/test_link.py +0 -0
  70. {memgraph_context_graph-0.7.2 → memgraph_context_graph-0.7.4}/tests/test_opencode_adapter.py +0 -0
  71. {memgraph_context_graph-0.7.2 → memgraph_context_graph-0.7.4}/tests/test_plugin_hooks.py +0 -0
  72. {memgraph_context_graph-0.7.2 → memgraph_context_graph-0.7.4}/tests/test_runner_sessions_graph_connector.py +0 -0
  73. {memgraph_context_graph-0.7.2 → memgraph_context_graph-0.7.4}/tests/test_runtime_plugin.py +0 -0
  74. {memgraph_context_graph-0.7.2 → memgraph_context_graph-0.7.4}/tests/test_setup_docker.py +0 -0
  75. {memgraph_context_graph-0.7.2 → memgraph_context_graph-0.7.4}/tests/test_spaces.py +0 -0
  76. {memgraph_context_graph-0.7.2 → memgraph_context_graph-0.7.4}/tests/test_tools.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: memgraph-context-graph
3
- Version: 0.7.2
3
+ Version: 0.7.4
4
4
  Summary: Connect agent SDKs to context-graph components (actions-graph, skills-graph, etc.)
5
5
  License: MIT
6
6
  License-File: LICENSE
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "memgraph-context-graph"
3
- version = "0.7.2"
3
+ version = "0.7.4"
4
4
  description = "Connect agent SDKs to context-graph components (actions-graph, skills-graph, etc.)"
5
5
  readme = "README.md"
6
6
  license = { text = "MIT" }
@@ -126,6 +126,58 @@ class CodexMemory:
126
126
  return report
127
127
 
128
128
 
129
+ def allow_plugin_tools(home: Path, plugin_id: str) -> list[str]:
130
+ """Let the plugin's MCP tools run without a prompt, unless the user already set a mode for them.
131
+
132
+ Codex asks before every MCP tool call, and under ``approval_policy = "never"`` an ask is a refusal, so
133
+ ``recall`` would fail for exactly the users who turned prompts off. The tools are read-only over the user's
134
+ own memory (``memory`` writes only ``/memories`` files), which is what ``approve`` is for.
135
+
136
+ Raises:
137
+ MemoryBackendError: the config file isn't valid TOML.
138
+ """
139
+ path = _config_path(home)
140
+ config = _read(path)
141
+ server = _plugin_server(config, plugin_id, create=True)
142
+ if "default_tools_approval_mode" in server:
143
+ return [f"Unchanged {path}"]
144
+ server["default_tools_approval_mode"] = "approve"
145
+ return _write(path, config)
146
+
147
+
148
+ def disallow_plugin_tools(home: Path, plugin_id: str) -> list[str]:
149
+ """Drop what :func:`allow_plugin_tools` wrote, leaving the rest of the plugin's table as the user has it."""
150
+ path = _config_path(home)
151
+ if not path.exists():
152
+ return []
153
+ config = _read(path)
154
+ server = _plugin_server(config, plugin_id, create=False)
155
+ if server is None or server.get("default_tools_approval_mode") != "approve":
156
+ return []
157
+ del server["default_tools_approval_mode"]
158
+ plugin = config["plugins"][plugin_id]
159
+ if not server:
160
+ del plugin["mcp_servers"][_SERVER]
161
+ if not plugin["mcp_servers"]:
162
+ del plugin["mcp_servers"]
163
+ if not plugin:
164
+ del config["plugins"][plugin_id]
165
+ return _write(path, config)
166
+
167
+
168
+ def _plugin_server(config: Any, plugin_id: str, *, create: bool) -> Any:
169
+ import tomlkit
170
+
171
+ node = config
172
+ for key in ("plugins", plugin_id, "mcp_servers", _SERVER):
173
+ if key not in node:
174
+ if not create:
175
+ return None
176
+ node[key] = tomlkit.table(is_super_table=key != _SERVER)
177
+ node = node[key]
178
+ return node
179
+
180
+
129
181
  def _codex_home(home: Path) -> Path:
130
182
  override = os.environ.get("CODEX_HOME")
131
183
  return Path(override) if override else home / ".codex"
@@ -257,9 +257,9 @@ def _ui(argv: list[str]) -> int:
257
257
  from context_graph_viewer.cli import main as viewer_main
258
258
  except ImportError:
259
259
  print(
260
- "memgraph-context-graph ui needs the context-graph-viewer package in the same tool environment.\n"
261
- "Add it to the `uv tool install memgraph-context-graph ...` command your plugin's bootstrap runs:\n"
262
- " --with context-graph-viewer",
260
+ "memgraph-context-graph ui needs the context-graph-viewer package, which isn't installed.\n"
261
+ "Run setup again and say yes to the viewer:\n"
262
+ " memgraph-context-graph setup",
263
263
  file=sys.stderr,
264
264
  )
265
265
  return 1
@@ -305,6 +305,7 @@ def _join(argv: list[str]) -> int:
305
305
  parser.add_argument("--password", default=None, help="Prompted for when omitted.")
306
306
  args = parser.parse_args(argv)
307
307
  password = args.password if args.password is not None else getpass.getpass(f"Password for {args.login}: ")
308
+ _ensure_enterprise_package()
308
309
 
309
310
  write_config(
310
311
  edition=EDITION_ENTERPRISE, memgraph_url=args.url, memgraph_user=args.login, memgraph_password=password
@@ -328,6 +329,21 @@ def _join(argv: list[str]) -> int:
328
329
  return 0
329
330
 
330
331
 
332
+ def _ensure_enterprise_package() -> None:
333
+ """Install ``context-graph-spaces`` when this is the uv tool and it is missing; else the error says how."""
334
+ from memgraph_context_graph.setup import tool
335
+ from memgraph_context_graph.spaces import EDITION_ENTERPRISE, _edition_factory
336
+
337
+ if _edition_factory(EDITION_ENTERPRISE) is not None or not tool.uv_tool_environment():
338
+ return
339
+ print("Enterprise spaces need one more package, with the local extraction models; installing it.", flush=True)
340
+ print("This can take a minute.", flush=True)
341
+ try:
342
+ tool.install(viewer=False, spaces=True, log=lambda line: None)
343
+ except tool.ToolError as exc:
344
+ print(str(exc), file=sys.stderr)
345
+
346
+
331
347
  def _doctor(argv: list[str]) -> int:
332
348
  from memgraph_context_graph.hooks.runtime_plugin import load_runtime_plugins
333
349
 
@@ -12,6 +12,7 @@ import io
12
12
  import json
13
13
  import shutil
14
14
  import subprocess
15
+ import time
15
16
  from dataclasses import dataclass
16
17
  from pathlib import Path
17
18
  from typing import TYPE_CHECKING, Literal
@@ -90,6 +91,7 @@ def wire_plugin(agent: Agent, log: Callable[[str], None]) -> str:
90
91
  """
91
92
  binary = _require(agent)
92
93
  if plugin_wired(agent):
94
+ _allow_recall(agent)
93
95
  return "plugin already installed"
94
96
  if not _marketplace_registered(agent, binary):
95
97
  _stream([binary, "plugin", "marketplace", "add", MARKETPLACE_SOURCE, "--sparse", agent.sparse], log)
@@ -97,6 +99,7 @@ def wire_plugin(agent: Agent, log: Callable[[str], None]) -> str:
97
99
  _stream([binary, "plugin", "install", PLUGIN_ID, "-y"], log)
98
100
  else:
99
101
  _stream([binary, "plugin", "add", PLUGIN_ID], log)
102
+ _allow_recall(agent)
100
103
  return "plugin installed"
101
104
 
102
105
 
@@ -112,12 +115,26 @@ def unwire_plugin(agent: Agent, log: Callable[[str], None]) -> str:
112
115
  verb = "uninstall" if agent.runtime == "claude-code" else "remove"
113
116
  _stream([binary, "plugin", verb, PLUGIN_ID], log)
114
117
  removed.append("plugin")
118
+ if agent.runtime == "codex":
119
+ _codex_memory().disallow_plugin_tools(Path.home(), PLUGIN_ID)
115
120
  if _marketplace_registered(agent, binary):
116
121
  _stream([binary, "plugin", "marketplace", "remove", MARKETPLACE], log)
117
122
  removed.append("marketplace")
118
123
  return f"removed the {' and '.join(removed)}" if removed else "wasn't installed"
119
124
 
120
125
 
126
+ def _allow_recall(agent: Agent) -> None:
127
+ """Codex refuses an MCP tool that needs approval when its policy is ``never``; recall is read-only, so allow it."""
128
+ if agent.runtime == "codex":
129
+ _codex_memory().allow_plugin_tools(Path.home(), PLUGIN_ID)
130
+
131
+
132
+ def _codex_memory():
133
+ from memgraph_context_graph.adapters import codex_memory
134
+
135
+ return codex_memory
136
+
137
+
121
138
  def _marketplace_registered(agent: Agent, binary: str) -> bool:
122
139
  listing = _json([binary, "plugin", "marketplace", "list", "--json"])
123
140
  entries = listing.get("marketplaces", []) if isinstance(listing, dict) else listing or []
@@ -220,11 +237,26 @@ def _require(agent: Agent) -> str:
220
237
 
221
238
 
222
239
  def _json(argv: list[str]):
223
- result = subprocess.run(argv, capture_output=True, text=True, check=False)
224
- if result.returncode != 0:
240
+ """What an agent CLI's ``--json`` listing printed, or None when it didn't print one.
241
+
242
+ A listing that fails once (the CLI busy updating itself, a notice printed ahead of the JSON) would read as
243
+ "not installed" and mislabel a wired agent, so it is asked twice and the JSON is taken from where it starts.
244
+ """
245
+ for attempt in range(2):
246
+ if attempt:
247
+ time.sleep(0.5)
248
+ result = subprocess.run(argv, capture_output=True, text=True, check=False)
249
+ if result.returncode == 0 and (found := _json_in(result.stdout)) is not None:
250
+ return found
251
+ return None
252
+
253
+
254
+ def _json_in(text: str):
255
+ start = min((i for i in (text.find("["), text.find("{")) if i >= 0), default=-1)
256
+ if start < 0:
225
257
  return None
226
258
  try:
227
- return json.loads(result.stdout)
259
+ return json.loads(text[start:])
228
260
  except json.JSONDecodeError:
229
261
  return None
230
262
 
@@ -90,7 +90,7 @@ def setup_main(argv: Sequence[str]) -> int:
90
90
  _print_plan(choices, env)
91
91
  print()
92
92
  outcomes = engine.run(choices, env, log=_log, report=_report)
93
- return _finish(outcomes)
93
+ return _finish(outcomes, choices, env)
94
94
 
95
95
 
96
96
  def uninstall_main(argv: Sequence[str]) -> int:
@@ -141,11 +141,16 @@ def _report(outcome: engine.Outcome) -> None:
141
141
  print(f" {line}")
142
142
 
143
143
 
144
- def _finish(outcomes: Sequence[engine.Outcome]) -> int:
144
+ def _finish(outcomes: Sequence[engine.Outcome], choices: engine.Choices, env: engine.Environment) -> int:
145
145
  failures = [outcome for outcome in outcomes if outcome.status == "fail"]
146
146
  print()
147
147
  if not failures:
148
- print("Context Graph is set up. Use your agents as usual; `memgraph-context-graph ui` shows what they capture.")
148
+ print("Context Graph is set up.")
149
+ print()
150
+ steps = engine.next_steps(choices, env)
151
+ print(steps[0])
152
+ for line in steps[1:]:
153
+ print(f" {line}")
149
154
  return 0
150
155
  print(f"{len(failures)} step(s) failed. To fix:")
151
156
  for outcome in failures:
@@ -269,11 +269,7 @@ def _agent(agent: agents_mod.Agent, choices: Choices, log: Callable[[str], None]
269
269
  fix = f"memgraph-context-graph setup {agent.runtime}"
270
270
  if agent.wiring == "plugin":
271
271
  try:
272
- summary = agents_mod.wire_plugin(agent, log)
273
- if agent.runtime == "codex":
274
- # Codex skips an untrusted plugin's hooks without a word, and `codex exec` never asks.
275
- summary += "; Codex records nothing until you trust its hooks: open codex and accept them (/hooks)"
276
- return Outcome(agent.label, "ok", summary)
272
+ return Outcome(agent.label, "ok", _with_trust_note(agent, agents_mod.wire_plugin(agent, log)))
277
273
  except agents_mod.WiringError as exc:
278
274
  return Outcome(agent.label, "fail", str(exc), fix=fix)
279
275
  if not choices.project_dirs:
@@ -287,7 +283,65 @@ def _agent(agent: agents_mod.Agent, choices: Choices, log: Callable[[str], None]
287
283
  if errors:
288
284
  dirs = " ".join(f"--project-dir {path}" for path in choices.project_dirs)
289
285
  return Outcome(agent.label, "fail", "; ".join(errors + summaries), fix=f"{fix} {dirs}")
290
- return Outcome(agent.label, "ok", "; ".join(summaries))
286
+ return Outcome(agent.label, "ok", _with_trust_note(agent, "; ".join(summaries)))
287
+
288
+
289
+ #: Agents that skip hooks they haven't been told to trust, without a word: nothing is recorded until then.
290
+ _TRUST_NOTES = {
291
+ "codex": "Codex records nothing until you trust its hooks: open codex and accept them (/hooks)",
292
+ "grok": "Grok records nothing until you trust this project's hooks: run /hooks-trust in grok",
293
+ }
294
+
295
+
296
+ def _with_trust_note(agent: agents_mod.Agent, summary: str) -> str:
297
+ note = _TRUST_NOTES.get(agent.runtime)
298
+ return f"{summary}; {note}" if note else summary
299
+
300
+
301
+ #: What setup says before it asks anything: what the user is about to get, in plain words.
302
+ WELCOME = (
303
+ "Your coding agents are about to get a memory.",
304
+ "Each session is kept as one graph in Memgraph, on your machine by default:",
305
+ "prompts, replies and tool activity. No account or cloud needed.",
306
+ "A few questions, then setup does the rest. Nothing changes until you confirm.",
307
+ )
308
+
309
+ #: What a user does once, so an agent starts recording; the same facts as :data:`_TRUST_NOTES`, for the last screen.
310
+ _FIRST_RUN = {
311
+ "codex": "in Codex, open it and accept its hooks (/hooks)",
312
+ "grok": "in Grok, run /hooks-trust once per wired project",
313
+ }
314
+
315
+
316
+ def next_steps(choices: Choices, env: Environment) -> list[str]:
317
+ """What happens now and what to try, for the end of a successful setup: short, and only what applies.
318
+
319
+ The first line is the heading; each bullet after it fits one terminal line, or continues on an indented one.
320
+ """
321
+ from memgraph_context_graph.adapters._identity import MEMORY_BACKEND_GRAPH
322
+
323
+ graph_memory = choices.memory_backend == MEMORY_BACKEND_GRAPH or (
324
+ choices.memory_backend is None and env.configured_memory_backend == MEMORY_BACKEND_GRAPH
325
+ )
326
+ lines = [
327
+ "Your agents now remember. What to expect:",
328
+ "· Every session is kept in your graph. Finish one, start another, and ask:",
329
+ ' "what did we do last time?"',
330
+ ]
331
+ remembering = [agents_mod.BY_RUNTIME[r].label for r in choices.runtimes if has_native_memory(r)]
332
+ if graph_memory and remembering:
333
+ lines += [
334
+ f"· {' and '.join(remembering)} remember through the graph now.",
335
+ ' Say "remember that ..." and every agent can recall it.',
336
+ ]
337
+ if choices.viewer:
338
+ lines.append("· Watch it grow: memgraph-context-graph ui")
339
+ lines += [f"· To start recording {_FIRST_RUN[r]}" for r in choices.runtimes if r in _FIRST_RUN]
340
+ lines += [
341
+ "· Change anything any time: memgraph-context-graph setup",
342
+ "· Undo the wiring: memgraph-context-graph uninstall (your graph stays)",
343
+ ]
344
+ return lines
291
345
 
292
346
 
293
347
  def _memory(runtime: str, backend: str) -> Outcome | None:
@@ -223,13 +223,14 @@ class Prompts:
223
223
  self.write("\n" + "".join(f"{self.style.wordmark(row)}\n" for row in WORDMARK))
224
224
  self.write("".join(f"{self.style.graph(row, width)}\n" for row in GRAPH) + "\n")
225
225
 
226
- def intro(self, title: str, subtitle: str = "") -> None:
227
- """The orange graph divider, some space, then the title: the questions start apart from what came before."""
226
+ def intro(self, title: str, subtitle: str | Sequence[str] = "") -> None:
227
+ """The orange graph divider, some space, then the title and its lines: the questions start apart from what came before."""
228
228
  rule = RULE[: min(len(WORDMARK[0]), self.term.columns - 1)]
229
229
  rule = rule[: rule.rfind("●") + 1] # cut short, it still ends on a node
230
230
  self.write(f"\n{self.style.accent(rule)}\n\n")
231
231
  self.write(f"{self.style.accent('◆')} {self.style.bold(title)}\n")
232
- self.line(self.style.muted(subtitle))
232
+ for text in [subtitle] if isinstance(subtitle, str) else subtitle:
233
+ self.line(self.style.muted(self.fit(text)))
233
234
 
234
235
  def answered(self, label: str, value: str) -> None:
235
236
  value = self.fit(value, _ANSWER_COLUMN)
@@ -400,7 +401,7 @@ def ask(prompts: Prompts, choices: Choices, env: Environment, *, preselect: bool
400
401
 
401
402
  *preselect* starts the agent list with *choices*' agents ticked; off, the user ticks every one.
402
403
  """
403
- prompts.intro("Context Graph setup", "Memory for your coding agents. Nothing changes until you confirm.")
404
+ prompts.intro("Context Graph setup", engine.WELCOME)
404
405
  enterprise = env.edition == "enterprise"
405
406
  prompts.answered("Memgraph", "from your Enterprise login" if enterprise else _memgraph_summary(choices, env))
406
407
 
@@ -418,7 +419,7 @@ def ask(prompts: Prompts, choices: Choices, env: Environment, *, preselect: bool
418
419
  def name(answers: _Answers) -> str | _Back:
419
420
  if enterprise:
420
421
  return SKIPPED
421
- answer = prompts.text("Your name", answers.user_id, hint="how the graph knows you")
422
+ answer = prompts.text("Your name", answers.user_id, hint="your memories are filed under it")
422
423
  if isinstance(answer, str):
423
424
  answers.user_id = answer
424
425
  return answer if isinstance(answer, _Back) else DONE
@@ -444,7 +445,7 @@ def ask(prompts: Prompts, choices: Choices, env: Environment, *, preselect: bool
444
445
  answer = prompts.confirm(
445
446
  "Install the terminal viewer?",
446
447
  answers.viewer,
447
- note="Browse what your agents capture, in this terminal: memgraph-context-graph ui.",
448
+ note="Watch your agents' memory grow, right in this terminal: memgraph-context-graph ui.",
448
449
  short="Viewer",
449
450
  )
450
451
  if isinstance(answer, bool):
@@ -459,7 +460,7 @@ def ask(prompts: Prompts, choices: Choices, env: Environment, *, preselect: bool
459
460
  answer = prompts.confirm(
460
461
  f"Use Context Graph as the memory of {' and '.join(switchable)}?",
461
462
  answers.use_graph_memory,
462
- note="Their own memory is switched off and copied into the graph; they remember from there.",
463
+ note="Their memories move into the graph, shared across agents. Switch back any time.",
463
464
  short="Agent memory",
464
465
  )
465
466
  if isinstance(answer, bool):
@@ -524,7 +525,7 @@ def run(prompts: Prompts, choices: Choices, env: Environment) -> int:
524
525
  style = prompts.style
525
526
  marks = {"ok": style.success("✓"), "skip": style.muted("–"), "warn": style.warning("!"), "fail": style.error("✗")}
526
527
  prompts.line()
527
- prompts.line(style.muted("Working… (the first Memgraph start downloads about 1 GB)"))
528
+ prompts.line(style.muted("Setting up… (a first Memgraph start downloads about 1 GB)"))
528
529
 
529
530
  def log(text: str) -> None:
530
531
  prompts.line(style.muted(f" {text}"))
@@ -537,10 +538,12 @@ def run(prompts: Prompts, choices: Choices, env: Environment) -> int:
537
538
  outcomes = engine.run(choices, env, log=log, report=report)
538
539
  failures = [outcome for outcome in outcomes if outcome.status == "fail"]
539
540
  if not failures:
540
- prompts.outro(
541
- style.success("Context Graph is set up.")
542
- + " Use your agents as usual; `memgraph-context-graph ui` shows what they capture."
543
- )
541
+ steps = engine.next_steps(choices, env)
542
+ prompts.line()
543
+ prompts.line(style.bold(steps[0]))
544
+ for text in steps[1:]:
545
+ prompts.line(prompts.fit(text))
546
+ prompts.outro(style.success("Context Graph is set up."))
544
547
  return 0
545
548
  prompts.line()
546
549
  prompts.line(style.error(f"{len(failures)} step(s) failed. To fix:"))
@@ -577,7 +580,7 @@ def _memgraph_summary(choices: Choices, env: Environment) -> str:
577
580
  return choices.memgraph_url
578
581
  if env.configured_url:
579
582
  return f"{env.configured_url} (configured)"
580
- return "find one on localhost:7687, or start one in Docker"
583
+ return "uses Memgraph on localhost:7687, or starts one in Docker for you"
581
584
 
582
585
 
583
586
  def _agent_options(selected: Sequence[str], wired: Sequence[str]) -> list[_Option]:
@@ -3,6 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import importlib
6
+ import shlex
6
7
  import shutil
7
8
  import subprocess
8
9
  import sys
@@ -14,13 +15,14 @@ if TYPE_CHECKING:
14
15
  from collections.abc import Callable
15
16
 
16
17
  #: Kept in step with the plugins' bootstrap scripts and install.sh, which install the same set first.
17
- BASE = "memgraph-context-graph[mcp]>=0.7.0"
18
+ BASE = "memgraph-context-graph[mcp]>=0.7.3"
18
19
  CONNECTORS = {
19
20
  "skills-graph": "skills-graph[memgraph-context-graph]>=0.2.1",
20
21
  "actions-graph": "actions-graph[memgraph-context-graph]>=0.5.0",
21
22
  "sessions-graph": "sessions-graph[memgraph-context-graph]>=0.8.1",
22
23
  }
23
24
  VIEWER = ("context-graph-viewer", "context-graph-viewer>=0.1.0")
25
+ SPACES = ("context-graph-spaces", "context-graph-spaces>=0.1.0")
24
26
 
25
27
 
26
28
  class ToolError(Exception):
@@ -59,8 +61,29 @@ def uv_tool_environment() -> bool:
59
61
  return Path(sys.prefix).resolve() == tool_dir.resolve()
60
62
 
61
63
 
62
- def install(*, viewer: bool, log: Callable[[str], None]) -> None:
63
- """Install the tool with every connector (and the viewer), keeping what is already there.
64
+ def requirements(*, viewer: bool = False, spaces: bool = False) -> list[str]:
65
+ """The ``uv tool install`` arguments for the tool, every connector, and the optional packages.
66
+
67
+ An optional package already installed is kept: ``uv tool install`` replaces the tool's requirements with
68
+ what it is given, so leaving one out would remove it.
69
+ """
70
+ argv = [BASE]
71
+ for requirement in CONNECTORS.values():
72
+ argv += ["--with", requirement]
73
+ if viewer or installed(VIEWER[0]):
74
+ argv += ["--with", VIEWER[1]]
75
+ if spaces or installed(SPACES[0]):
76
+ argv += ["--with", SPACES[1]]
77
+ return argv
78
+
79
+
80
+ def install_command(*, spaces: bool = False) -> str:
81
+ """The whole ``uv tool install`` command as one line a person can paste, with *spaces* added."""
82
+ return " ".join(["uv tool install", *map(shlex.quote, requirements(spaces=spaces))])
83
+
84
+
85
+ def install(*, viewer: bool, log: Callable[[str], None], spaces: bool = False) -> None:
86
+ """Install the tool with every connector (and the viewer, and the spaces package), keeping what is already there.
64
87
 
65
88
  The full set is always passed: ``uv tool install`` replaces the tool's requirements with what it is
66
89
  given, so naming only the missing package would drop the others.
@@ -71,11 +94,7 @@ def install(*, viewer: bool, log: Callable[[str], None]) -> None:
71
94
  uv = shutil.which("uv")
72
95
  if uv is None:
73
96
  raise ToolError("uv isn't on PATH")
74
- argv = [uv, "--color", "never", "tool", "install", BASE]
75
- for requirement in CONNECTORS.values():
76
- argv += ["--with", requirement]
77
- if viewer or installed(VIEWER[0]):
78
- argv += ["--with", VIEWER[1]]
97
+ argv = [uv, "--color", "never", "tool", "install", *requirements(viewer=viewer, spaces=spaces)]
79
98
  log(" ".join(["uv", *argv[3:]]))
80
99
  process = subprocess.Popen(argv, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
81
100
  last = ""
@@ -192,9 +192,11 @@ def _build(config: HookConfig) -> SpaceResolver:
192
192
  if edition == EDITION_ENTERPRISE:
193
193
  factory = _edition_factory(edition)
194
194
  if factory is None:
195
+ from memgraph_context_graph.setup import tool
196
+
195
197
  raise SpaceError(
196
- "edition = enterprise needs the context-graph-spaces package: "
197
- "uv tool install 'memgraph-context-graph[mcp]' --with context-graph-spaces"
198
+ f"edition = enterprise needs the context-graph-spaces package. Install it, with the rest: "
199
+ f"{tool.install_command(spaces=True)}"
198
200
  )
199
201
  return factory(config)
200
202
  raise SpaceError(f"Unknown edition {edition!r} in the config file; use one of: {', '.join(sorted(EDITIONS))}.")
@@ -107,6 +107,6 @@ def test_installer_installs_the_tool_then_hands_its_arguments_to_setup(tmp_path)
107
107
  ) # fmt: skip
108
108
 
109
109
  lines = calls.read_text().splitlines()
110
- assert lines[0].startswith("uv tool install memgraph-context-graph[mcp]>=")
110
+ assert lines[0].startswith("uv tool install --quiet memgraph-context-graph[mcp]>=")
111
111
  assert "--with sessions-graph[memgraph-context-graph]>=" in lines[0]
112
112
  assert lines[-1] == "memgraph-context-graph setup --no-tui codex --memgraph-url bolt://my-host:7687"
@@ -9,6 +9,7 @@ from __future__ import annotations
9
9
  import contextlib
10
10
  import json
11
11
  import re
12
+ import subprocess
12
13
 
13
14
  import pytest
14
15
 
@@ -440,6 +441,16 @@ def test_the_uv_tool_is_recognised_even_when_the_shell_forces_colour(monkeypatch
440
441
  assert tool.uv_tool_environment()
441
442
 
442
443
 
444
+ def test_grok_is_reported_with_the_trust_it_still_needs(fake_agents, setup_home, project):
445
+ env = engine.look_around(cwd=project)
446
+ choices = engine.default_choices(env, cwd=project)
447
+
448
+ grok = engine._agent(BY_RUNTIME["grok"], choices, lambda _: None)
449
+
450
+ assert grok.status == "ok"
451
+ assert "/hooks-trust" in grok.summary
452
+
453
+
443
454
  def test_codex_is_reported_with_the_trust_it_still_needs_and_claude_code_is_not(fake_agents, setup_home, project):
444
455
  env = engine.look_around(cwd=project)
445
456
  choices = engine.default_choices(env, cwd=project)
@@ -450,3 +461,158 @@ def test_codex_is_reported_with_the_trust_it_still_needs_and_claude_code_is_not(
450
461
  assert codex.status == "ok"
451
462
  assert "records nothing until you trust its hooks" in codex.summary
452
463
  assert "trust" not in claude.summary
464
+
465
+
466
+ def test_reinstalling_the_tool_keeps_the_spaces_package_once_it_is_there(monkeypatch):
467
+ from memgraph_context_graph.setup import tool
468
+
469
+ monkeypatch.setattr(tool, "installed", lambda package: package == "context-graph-spaces")
470
+
471
+ argv = tool.requirements()
472
+
473
+ assert tool.BASE in argv
474
+ assert all(requirement in argv for requirement in tool.CONNECTORS.values())
475
+ assert tool.SPACES[1] in argv
476
+ assert tool.VIEWER[1] not in argv
477
+
478
+ monkeypatch.setattr(tool, "installed", lambda package: False)
479
+ assert tool.SPACES[1] not in tool.requirements()
480
+ assert tool.SPACES[1] in tool.requirements(spaces=True)
481
+
482
+
483
+ def test_the_enterprise_hint_names_the_whole_install_not_just_the_spaces_package(monkeypatch):
484
+ import pytest
485
+
486
+ from memgraph_context_graph import spaces
487
+ from memgraph_context_graph.adapters._identity import HookConfig
488
+
489
+ monkeypatch.setattr(spaces, "_edition_factory", lambda edition: None)
490
+ spaces._reset_cache()
491
+
492
+ with pytest.raises(spaces.SpaceError) as excinfo:
493
+ spaces.spaces_for(HookConfig(edition="enterprise"))
494
+
495
+ hint = str(excinfo.value)
496
+ assert "context-graph-spaces" in hint
497
+ assert "skills-graph" in hint and "sessions-graph" in hint
498
+ spaces._reset_cache()
499
+
500
+
501
+ def test_join_installs_the_spaces_package_inside_the_uv_tool_and_not_elsewhere(monkeypatch, setup_home, capsys):
502
+ from memgraph_context_graph import spaces
503
+ from memgraph_context_graph.setup import tool
504
+
505
+ installs = []
506
+ monkeypatch.setattr(spaces, "_edition_factory", lambda edition: None)
507
+ monkeypatch.setattr(tool, "install", lambda **kwargs: installs.append(kwargs))
508
+
509
+ monkeypatch.setattr(tool, "uv_tool_environment", lambda: False)
510
+ spaces._reset_cache()
511
+ assert main(["join", "bolt://localhost:1", "ante", "--password=x"]) == 1
512
+ assert installs == []
513
+ assert "uv tool install" in capsys.readouterr().err
514
+
515
+ monkeypatch.setattr(tool, "uv_tool_environment", lambda: True)
516
+ spaces._reset_cache()
517
+ main(["join", "bolt://localhost:1", "ante", "--password=x"])
518
+ assert [kwargs["spaces"] for kwargs in installs] == [True]
519
+ spaces._reset_cache()
520
+
521
+
522
+ def test_the_welcome_says_what_the_user_is_getting_before_any_question(fake_agents, setup_home, project):
523
+ from memgraph_context_graph.setup.prompts import ENTER, Prompts, ask
524
+
525
+ env = engine.look_around(cwd=project)
526
+ term = ScriptedTerminal(keys=[ENTER] * 5, columns=100)
527
+
528
+ ask(Prompts(term), engine.default_choices(env, cwd=project), env, preselect=False)
529
+
530
+ first_question = term.text.index("Your name")
531
+ for line in ("about to get a memory", "No account or cloud needed", "Nothing changes until you confirm"):
532
+ assert term.text.index(line) < first_question
533
+
534
+
535
+ def test_the_last_screen_says_what_to_expect_and_only_what_applies():
536
+ choices = engine.Choices(
537
+ runtimes=["claude-code", "codex", "grok"], project_dirs=[], user_id="carol", memory_backend="context-graph"
538
+ )
539
+ env = engine.Environment(edition="community", configured_url=None, configured_user=None, found_agents=())
540
+
541
+ text = "\n".join(engine.next_steps(choices, env))
542
+
543
+ assert "Claude Code and Codex remember through the graph now." in text
544
+ assert "memgraph-context-graph ui" in text
545
+ assert "in Codex, open it and accept its hooks" in text
546
+ assert "in Grok, run /hooks-trust" in text
547
+ assert "memgraph-context-graph uninstall" in text
548
+
549
+ plain = engine.Choices(runtimes=["opencode"], project_dirs=[], user_id="carol", viewer=False)
550
+ quiet = "\n".join(engine.next_steps(plain, env))
551
+
552
+ assert "remember through the graph" not in quiet
553
+ assert "memgraph-context-graph ui" not in quiet
554
+ assert "accept the hooks" not in quiet
555
+ assert "what did we do last time" in quiet
556
+
557
+
558
+ def test_every_line_of_the_welcome_and_the_last_screen_fits_a_narrow_terminal():
559
+ choices = engine.Choices(
560
+ runtimes=["claude-code", "codex", "grok"], project_dirs=[], user_id="carol", memory_backend="context-graph"
561
+ )
562
+ env = engine.Environment(edition="community", configured_url=None, configured_user=None, found_agents=())
563
+
564
+ for line in (*engine.WELCOME, *engine.next_steps(choices, env)):
565
+ assert len(line) <= 78, line
566
+
567
+
568
+ def test_a_listing_that_fails_once_does_not_make_a_wired_agent_look_unwired(monkeypatch):
569
+ listing = json.dumps([{"id": agents.PLUGIN_ID}])
570
+ replies = iter(
571
+ [
572
+ subprocess.CompletedProcess([], 1, "", "busy"),
573
+ subprocess.CompletedProcess([], 0, f"Checking for updates...\n{listing}", ""),
574
+ ]
575
+ )
576
+ monkeypatch.setattr(agents.subprocess, "run", lambda *a, **k: next(replies))
577
+ monkeypatch.setattr(agents.time, "sleep", lambda _: None)
578
+ monkeypatch.setattr(agents.shutil, "which", lambda name: f"/bin/{name}")
579
+
580
+ assert agents.plugin_wired(BY_RUNTIME["claude-code"])
581
+
582
+
583
+ def test_codex_wiring_lets_recall_through_a_never_policy_and_unwiring_takes_it_back(fake_agents, setup_home):
584
+ import tomlkit
585
+
586
+ config = setup_home / ".codex" / "config.toml"
587
+ config.parent.mkdir()
588
+ config.write_text('approval_policy = "never"\n\n[features]\nhooks = true\n')
589
+ codex = BY_RUNTIME["codex"]
590
+
591
+ agents.wire_plugin(codex, lambda _: None)
592
+ agents.wire_plugin(codex, lambda _: None)
593
+
594
+ parsed = tomlkit.parse(config.read_text()).unwrap()
595
+ assert parsed["plugins"][agents.PLUGIN_ID]["mcp_servers"]["context-graph"] == {
596
+ "default_tools_approval_mode": "approve"
597
+ }
598
+ assert parsed["approval_policy"] == "never" and parsed["features"] == {"hooks": True}
599
+
600
+ agents.unwire_plugin(codex, lambda _: None)
601
+
602
+ assert tomlkit.parse(config.read_text()).unwrap() == {"approval_policy": "never", "features": {"hooks": True}}
603
+
604
+
605
+ def test_codex_wiring_keeps_an_approval_mode_the_user_chose(fake_agents, setup_home):
606
+ import tomlkit
607
+
608
+ config = setup_home / ".codex" / "config.toml"
609
+ config.parent.mkdir()
610
+ config.write_text(
611
+ f'[plugins."{agents.PLUGIN_ID}".mcp_servers.context-graph]\ndefault_tools_approval_mode = "prompt"\n'
612
+ )
613
+
614
+ agents.wire_plugin(BY_RUNTIME["codex"], lambda _: None)
615
+ agents.unwire_plugin(BY_RUNTIME["codex"], lambda _: None)
616
+
617
+ mode = tomlkit.parse(config.read_text()).unwrap()["plugins"][agents.PLUGIN_ID]["mcp_servers"]["context-graph"]
618
+ assert mode == {"default_tools_approval_mode": "prompt"}