memgraph-context-graph 0.12.1__tar.gz → 0.12.2__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 (100) hide show
  1. {memgraph_context_graph-0.12.1 → memgraph_context_graph-0.12.2}/PKG-INFO +1 -1
  2. {memgraph_context_graph-0.12.1 → memgraph_context_graph-0.12.2}/docs/memory.md +10 -3
  3. {memgraph_context_graph-0.12.1 → memgraph_context_graph-0.12.2}/pyproject.toml +1 -1
  4. {memgraph_context_graph-0.12.1 → memgraph_context_graph-0.12.2}/src/memgraph_context_graph/adapters/_spec.py +33 -5
  5. {memgraph_context_graph-0.12.1 → memgraph_context_graph-0.12.2}/src/memgraph_context_graph/adapters/antigravity_cli.py +3 -1
  6. {memgraph_context_graph-0.12.1 → memgraph_context_graph-0.12.2}/src/memgraph_context_graph/adapters/claude_code_memory.py +19 -3
  7. {memgraph_context_graph-0.12.1 → memgraph_context_graph-0.12.2}/src/memgraph_context_graph/adapters/codex_memory.py +13 -3
  8. {memgraph_context_graph-0.12.1 → memgraph_context_graph-0.12.2}/src/memgraph_context_graph/connectors.py +1 -1
  9. {memgraph_context_graph-0.12.1 → memgraph_context_graph-0.12.2}/src/memgraph_context_graph/hooks/runner.py +83 -7
  10. {memgraph_context_graph-0.12.1 → memgraph_context_graph-0.12.2}/src/memgraph_context_graph/setup/engine.py +4 -1
  11. {memgraph_context_graph-0.12.1 → memgraph_context_graph-0.12.2}/src/memgraph_context_graph/setup/tool.py +2 -2
  12. {memgraph_context_graph-0.12.1 → memgraph_context_graph-0.12.2}/tests/test_antigravity_cli_adapter.py +16 -0
  13. {memgraph_context_graph-0.12.1 → memgraph_context_graph-0.12.2}/tests/test_capture_log.py +43 -0
  14. memgraph_context_graph-0.12.2/tests/test_claude_code_memory.py +31 -0
  15. {memgraph_context_graph-0.12.1 → memgraph_context_graph-0.12.2}/tests/test_codex_adapter.py +13 -1
  16. memgraph_context_graph-0.12.2/tests/test_codex_memory.py +69 -0
  17. {memgraph_context_graph-0.12.1 → memgraph_context_graph-0.12.2}/tests/test_setup.py +1 -1
  18. {memgraph_context_graph-0.12.1 → memgraph_context_graph-0.12.2}/.gitignore +0 -0
  19. {memgraph_context_graph-0.12.1 → memgraph_context_graph-0.12.2}/CONTEXT.md +0 -0
  20. {memgraph_context_graph-0.12.1 → memgraph_context_graph-0.12.2}/LICENSE +0 -0
  21. {memgraph_context_graph-0.12.1 → memgraph_context_graph-0.12.2}/README.md +0 -0
  22. {memgraph_context_graph-0.12.1 → memgraph_context_graph-0.12.2}/docs/adr/0001-runtime-adapter-terminology.md +0 -0
  23. {memgraph_context_graph-0.12.1 → memgraph_context_graph-0.12.2}/docs/adr/0002-config-file-only-hook-resolution.md +0 -0
  24. {memgraph_context_graph-0.12.1 → memgraph_context_graph-0.12.2}/docs/adr/0003-config-path-override.md +0 -0
  25. {memgraph_context_graph-0.12.1 → memgraph_context_graph-0.12.2}/docs/adr/0004-user-id-in-session-start-event.md +0 -0
  26. {memgraph_context_graph-0.12.1 → memgraph_context_graph-0.12.2}/docs/adr/0005-config-file-covers-llm-key-and-subprocess-env.md +0 -0
  27. {memgraph_context_graph-0.12.1 → memgraph_context_graph-0.12.2}/docs/adr/0006-memory-tool-writes-explicit-memory.md +0 -0
  28. {memgraph_context_graph-0.12.1 → memgraph_context_graph-0.12.2}/docs/adr/0007-spawned-processes-read-the-config-file-themselves.md +0 -0
  29. {memgraph_context_graph-0.12.1 → memgraph_context_graph-0.12.2}/docs/command-hooks.md +0 -0
  30. {memgraph_context_graph-0.12.1 → memgraph_context_graph-0.12.2}/docs/configuration.md +0 -0
  31. {memgraph_context_graph-0.12.1 → memgraph_context_graph-0.12.2}/docs/extending.md +0 -0
  32. {memgraph_context_graph-0.12.1 → memgraph_context_graph-0.12.2}/docs/research/2026-10-codex-memory.md +0 -0
  33. {memgraph_context_graph-0.12.1 → memgraph_context_graph-0.12.2}/docs/runtime-setup.md +0 -0
  34. {memgraph_context_graph-0.12.1 → memgraph_context_graph-0.12.2}/docs/sdk-memory.md +0 -0
  35. {memgraph_context_graph-0.12.1 → memgraph_context_graph-0.12.2}/examples/sdk_claude_agent.py +0 -0
  36. {memgraph_context_graph-0.12.1 → memgraph_context_graph-0.12.2}/examples/sdk_openai_agents.py +0 -0
  37. {memgraph_context_graph-0.12.1 → memgraph_context_graph-0.12.2}/src/memgraph_context_graph/__init__.py +0 -0
  38. {memgraph_context_graph-0.12.1 → memgraph_context_graph-0.12.2}/src/memgraph_context_graph/adapters/__init__.py +0 -0
  39. {memgraph_context_graph-0.12.1 → memgraph_context_graph-0.12.2}/src/memgraph_context_graph/adapters/_identity.py +0 -0
  40. {memgraph_context_graph-0.12.1 → memgraph_context_graph-0.12.2}/src/memgraph_context_graph/adapters/_opencode_plugin.js +0 -0
  41. {memgraph_context_graph-0.12.1 → memgraph_context_graph-0.12.2}/src/memgraph_context_graph/adapters/agent_mcp.py +0 -0
  42. {memgraph_context_graph-0.12.1 → memgraph_context_graph-0.12.2}/src/memgraph_context_graph/adapters/agent_memory.py +0 -0
  43. {memgraph_context_graph-0.12.1 → memgraph_context_graph-0.12.2}/src/memgraph_context_graph/adapters/claude_code.py +0 -0
  44. {memgraph_context_graph-0.12.1 → memgraph_context_graph-0.12.2}/src/memgraph_context_graph/adapters/codex.py +0 -0
  45. {memgraph_context_graph-0.12.1 → memgraph_context_graph-0.12.2}/src/memgraph_context_graph/adapters/copilot_cli.py +0 -0
  46. {memgraph_context_graph-0.12.1 → memgraph_context_graph-0.12.2}/src/memgraph_context_graph/adapters/cursor.py +0 -0
  47. {memgraph_context_graph-0.12.1 → memgraph_context_graph-0.12.2}/src/memgraph_context_graph/adapters/grok.py +0 -0
  48. {memgraph_context_graph-0.12.1 → memgraph_context_graph-0.12.2}/src/memgraph_context_graph/adapters/opencode.py +0 -0
  49. {memgraph_context_graph-0.12.1 → memgraph_context_graph-0.12.2}/src/memgraph_context_graph/capture_log.py +0 -0
  50. {memgraph_context_graph-0.12.1 → memgraph_context_graph-0.12.2}/src/memgraph_context_graph/capture_policy.py +0 -0
  51. {memgraph_context_graph-0.12.1 → memgraph_context_graph-0.12.2}/src/memgraph_context_graph/cli.py +0 -0
  52. {memgraph_context_graph-0.12.1 → memgraph_context_graph-0.12.2}/src/memgraph_context_graph/config_schema.py +0 -0
  53. {memgraph_context_graph-0.12.1 → memgraph_context_graph-0.12.2}/src/memgraph_context_graph/events.py +0 -0
  54. {memgraph_context_graph-0.12.1 → memgraph_context_graph-0.12.2}/src/memgraph_context_graph/forget.py +0 -0
  55. {memgraph_context_graph-0.12.1 → memgraph_context_graph-0.12.2}/src/memgraph_context_graph/hooks/__init__.py +0 -0
  56. {memgraph_context_graph-0.12.1 → memgraph_context_graph-0.12.2}/src/memgraph_context_graph/hooks/cli.py +0 -0
  57. {memgraph_context_graph-0.12.1 → memgraph_context_graph-0.12.2}/src/memgraph_context_graph/hooks/runtime_plugin.py +0 -0
  58. {memgraph_context_graph-0.12.1 → memgraph_context_graph-0.12.2}/src/memgraph_context_graph/link.py +0 -0
  59. {memgraph_context_graph-0.12.1 → memgraph_context_graph-0.12.2}/src/memgraph_context_graph/mcp_server.py +0 -0
  60. {memgraph_context_graph-0.12.1 → memgraph_context_graph-0.12.2}/src/memgraph_context_graph/memory_backend.py +0 -0
  61. {memgraph_context_graph-0.12.1 → memgraph_context_graph-0.12.2}/src/memgraph_context_graph/protocols.py +0 -0
  62. {memgraph_context_graph-0.12.1 → memgraph_context_graph-0.12.2}/src/memgraph_context_graph/sdk/__init__.py +0 -0
  63. {memgraph_context_graph-0.12.1 → memgraph_context_graph-0.12.2}/src/memgraph_context_graph/sdk/claude.py +0 -0
  64. {memgraph_context_graph-0.12.1 → memgraph_context_graph-0.12.2}/src/memgraph_context_graph/sdk/openai.py +0 -0
  65. {memgraph_context_graph-0.12.1 → memgraph_context_graph-0.12.2}/src/memgraph_context_graph/session.py +0 -0
  66. {memgraph_context_graph-0.12.1 → memgraph_context_graph-0.12.2}/src/memgraph_context_graph/setup/__init__.py +0 -0
  67. {memgraph_context_graph-0.12.1 → memgraph_context_graph-0.12.2}/src/memgraph_context_graph/setup/agents.py +0 -0
  68. {memgraph_context_graph-0.12.1 → memgraph_context_graph-0.12.2}/src/memgraph_context_graph/setup/cli.py +0 -0
  69. {memgraph_context_graph-0.12.1 → memgraph_context_graph-0.12.2}/src/memgraph_context_graph/setup/memgraph.py +0 -0
  70. {memgraph_context_graph-0.12.1 → memgraph_context_graph-0.12.2}/src/memgraph_context_graph/setup/prompts.py +0 -0
  71. {memgraph_context_graph-0.12.1 → memgraph_context_graph-0.12.2}/src/memgraph_context_graph/setup/uninstall.py +0 -0
  72. {memgraph_context_graph-0.12.1 → memgraph_context_graph-0.12.2}/src/memgraph_context_graph/setup/update.py +0 -0
  73. {memgraph_context_graph-0.12.1 → memgraph_context_graph-0.12.2}/src/memgraph_context_graph/spaces.py +0 -0
  74. {memgraph_context_graph-0.12.1 → memgraph_context_graph-0.12.2}/src/memgraph_context_graph/terminal.py +0 -0
  75. {memgraph_context_graph-0.12.1 → memgraph_context_graph-0.12.2}/src/memgraph_context_graph/tools.py +0 -0
  76. {memgraph_context_graph-0.12.1 → memgraph_context_graph-0.12.2}/tests/__init__.py +0 -0
  77. {memgraph_context_graph-0.12.1 → memgraph_context_graph-0.12.2}/tests/conftest.py +0 -0
  78. {memgraph_context_graph-0.12.1 → memgraph_context_graph-0.12.2}/tests/fake_agent_cli.py +0 -0
  79. {memgraph_context_graph-0.12.1 → memgraph_context_graph-0.12.2}/tests/test_agent_memory_wiring.py +0 -0
  80. {memgraph_context_graph-0.12.1 → memgraph_context_graph-0.12.2}/tests/test_capture_policy.py +0 -0
  81. {memgraph_context_graph-0.12.1 → memgraph_context_graph-0.12.2}/tests/test_claude_code_adapter.py +0 -0
  82. {memgraph_context_graph-0.12.1 → memgraph_context_graph-0.12.2}/tests/test_config_cli.py +0 -0
  83. {memgraph_context_graph-0.12.1 → memgraph_context_graph-0.12.2}/tests/test_connectors.py +0 -0
  84. {memgraph_context_graph-0.12.1 → memgraph_context_graph-0.12.2}/tests/test_context_before_tool.py +0 -0
  85. {memgraph_context_graph-0.12.1 → memgraph_context_graph-0.12.2}/tests/test_copilot_cli_adapter.py +0 -0
  86. {memgraph_context_graph-0.12.1 → memgraph_context_graph-0.12.2}/tests/test_cursor_adapter.py +0 -0
  87. {memgraph_context_graph-0.12.1 → memgraph_context_graph-0.12.2}/tests/test_dev_scripts.py +0 -0
  88. {memgraph_context_graph-0.12.1 → memgraph_context_graph-0.12.2}/tests/test_events.py +0 -0
  89. {memgraph_context_graph-0.12.1 → memgraph_context_graph-0.12.2}/tests/test_grok_adapter.py +0 -0
  90. {memgraph_context_graph-0.12.1 → memgraph_context_graph-0.12.2}/tests/test_hook_cli.py +0 -0
  91. {memgraph_context_graph-0.12.1 → memgraph_context_graph-0.12.2}/tests/test_identity.py +0 -0
  92. {memgraph_context_graph-0.12.1 → memgraph_context_graph-0.12.2}/tests/test_link.py +0 -0
  93. {memgraph_context_graph-0.12.1 → memgraph_context_graph-0.12.2}/tests/test_opencode_adapter.py +0 -0
  94. {memgraph_context_graph-0.12.1 → memgraph_context_graph-0.12.2}/tests/test_plugin_hooks.py +0 -0
  95. {memgraph_context_graph-0.12.1 → memgraph_context_graph-0.12.2}/tests/test_runtime_plugin.py +0 -0
  96. {memgraph_context_graph-0.12.1 → memgraph_context_graph-0.12.2}/tests/test_session.py +0 -0
  97. {memgraph_context_graph-0.12.1 → memgraph_context_graph-0.12.2}/tests/test_setup_docker.py +0 -0
  98. {memgraph_context_graph-0.12.1 → memgraph_context_graph-0.12.2}/tests/test_spaces.py +0 -0
  99. {memgraph_context_graph-0.12.1 → memgraph_context_graph-0.12.2}/tests/test_terminal.py +0 -0
  100. {memgraph_context_graph-0.12.1 → memgraph_context_graph-0.12.2}/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.12.1
3
+ Version: 0.12.2
4
4
  Summary: Connect agent SDKs to context-graph components (actions-graph, skills-graph, etc.)
5
5
  License: MIT
6
6
  License-File: LICENSE
@@ -111,8 +111,13 @@ its global store, with three differences:
111
111
  file is first copied to `config.toml.context-graph-memory.bak`, apart from
112
112
  the backup setup's own writes leave. Handing memory back restores those keys
113
113
  from it, drops a `[memories]` table the switch created, and deletes the
114
- backup. If the user's own `[mcp_servers.context-graph]` narrows tools with
115
- `enabled_tools`, `memory` is added there.
114
+ backup. Repeating the hand-back preserves the restored settings. If the
115
+ switch backup is missing, including on older installs, current settings stay
116
+ unchanged. The general `config.toml.context-graph.bak` may predate the switch
117
+ and is never used to guess the prior settings; the output explains how to
118
+ turn Codex memories on explicitly when needed. If the user's own
119
+ `[mcp_servers.context-graph]` narrows tools with `enabled_tools`, `memory` is
120
+ added there.
116
121
  - **Compaction.** The Codex plugin's `SessionStart` hook also fires after
117
122
  compaction (`compact`), so the memory index comes back once the context is
118
123
  summarized.
@@ -161,7 +166,9 @@ isn't left again.
161
166
 
162
167
  The backend is one setting for every agent, and the copy runs only while it
163
168
  is `context-graph`. So `setup codex claude-code --memory-backend native` hands
164
- each named agent back first and changes the setting last. An agent left out
169
+ each named agent back first and changes the setting last. Setup stops handing
170
+ back agents at the first failure and keeps the graph backend, so retrying the
171
+ command can still export their memories. An agent left out
165
172
  of that command gets nothing copied back once the setting is `native`, so name
166
173
  every agent that uses the graph's memory.
167
174
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "memgraph-context-graph"
3
- version = "0.12.1"
3
+ version = "0.12.2"
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" }
@@ -9,8 +9,10 @@ only declares what differs about it.
9
9
  from __future__ import annotations
10
10
 
11
11
  import json
12
+ import os
12
13
  import shlex
13
14
  import shutil
15
+ import subprocess
14
16
  import sys
15
17
  from collections.abc import Callable, Mapping
16
18
  from dataclasses import dataclass, field
@@ -69,6 +71,9 @@ class FieldMap:
69
71
  reply: tuple[str, ...] = ("last_assistant_message",)
70
72
 
71
73
 
74
+ _ON_WINDOWS = os.name == "nt"
75
+
76
+
72
77
  @dataclass(frozen=True)
73
78
  class HookConfig:
74
79
  """Describe how a runtime represents and stores command hooks.
@@ -78,6 +83,9 @@ class HookConfig:
78
83
  ``flat_hooks`` names events written with the flat layout even when
79
84
  ``layout`` is nested, for runtimes that only accept matcher groups on some
80
85
  events.
86
+ ``windows_shell`` is the shell the runtime hands hook commands to on
87
+ Windows. ``cmd.exe`` doesn't read POSIX single quotes, so a ``"cmd"``
88
+ runtime's commands are quoted for it instead.
81
89
  """
82
90
 
83
91
  layout: Literal["nested", "flat"]
@@ -89,6 +97,7 @@ class HookConfig:
89
97
  inject_event_name: bool = False
90
98
  matchers: Mapping[str, str] = field(default_factory=dict)
91
99
  flat_hooks: frozenset[str] = frozenset()
100
+ windows_shell: Literal["posix", "cmd"] = "posix"
92
101
 
93
102
 
94
103
  @dataclass(frozen=True)
@@ -300,7 +309,7 @@ def build_hooks_config(spec: RuntimeSpec, command: str, *, timeout: int = 30) ->
300
309
  for hook_name in spec.hooks:
301
310
  hook_command = command
302
311
  if spec.config.inject_event_name:
303
- hook_command = f"{command} --event-name {shlex.quote(hook_name)}"
312
+ hook_command = f"{command} --event-name {_quote(hook_name, spec.config.windows_shell)}"
304
313
  command_entry: dict[str, Any] = {"command": hook_command, "type": "command"}
305
314
  if spec.config.timeout_key is not None:
306
315
  command_entry[spec.config.timeout_key] = timeout
@@ -343,7 +352,11 @@ def write_hook_config(
343
352
  if config_path.exists() and not spec.config.merge and not force:
344
353
  raise FileExistsError(f"Refusing to overwrite existing {spec.name} config: {config_path} (pass force=True)")
345
354
 
346
- command = hook_command or hook_command_for(spec.name, connectors)
355
+ command = hook_command or hook_command_for(spec.name, connectors, windows_shell=spec.config.windows_shell)
356
+ if hook_command is None and timeout != 30:
357
+ from memgraph_context_graph.hooks.runner import deadline_for
358
+
359
+ command = f"{command} --deadline {deadline_for(timeout):g}"
347
360
  hooks = build_hooks_config(spec, command, timeout=timeout)
348
361
  document: dict[str, Any]
349
362
  if config_path.exists() and spec.config.merge:
@@ -444,9 +457,24 @@ def hook_command_argv(runtime: str, connectors: list[str]) -> list[str]:
444
457
  return argv
445
458
 
446
459
 
447
- def hook_command_for(runtime: str, connectors: list[str]) -> str:
448
- """Build the installed hook command for *runtime* and *connectors* as a shell string."""
449
- return shlex.join(hook_command_argv(runtime, connectors))
460
+ def hook_command_for(runtime: str, connectors: list[str], *, windows_shell: Literal["posix", "cmd"] = "posix") -> str:
461
+ """Build the installed hook command for *runtime* and *connectors* as a shell string.
462
+
463
+ On Windows a ``"cmd"`` *windows_shell* gets ``cmd.exe`` quoting, which
464
+ leaves a path without spaces bare; elsewhere the command is POSIX-quoted.
465
+ """
466
+ argv = hook_command_argv(runtime, connectors)
467
+ if _uses_cmd(windows_shell):
468
+ return subprocess.list2cmdline(argv)
469
+ return shlex.join(argv)
470
+
471
+
472
+ def _uses_cmd(windows_shell: Literal["posix", "cmd"]) -> bool:
473
+ return windows_shell == "cmd" and _ON_WINDOWS
474
+
475
+
476
+ def _quote(word: str, windows_shell: Literal["posix", "cmd"]) -> str:
477
+ return subprocess.list2cmdline([word]) if _uses_cmd(windows_shell) else shlex.quote(word)
450
478
 
451
479
 
452
480
  def _metadata_from_payload(payload: dict[str, Any], keys: tuple[str, ...]) -> dict[str, Any]:
@@ -183,7 +183,8 @@ SPEC = RuntimeSpec(
183
183
  ),
184
184
  # hooks.json is keyed by named hook groups; this runtime owns one group.
185
185
  # Only tool events take matcher groups: agy rejects the whole file if a
186
- # lifecycle event is nested, so those are written as flat handlers.
186
+ # lifecycle event is nested, so those are written as flat handlers. On
187
+ # Windows agy runs each command through cmd.exe, inside its own quotes.
187
188
  config=HookConfig(
188
189
  path=".agents/hooks.json",
189
190
  layout="nested",
@@ -191,6 +192,7 @@ SPEC = RuntimeSpec(
191
192
  merge=True,
192
193
  inject_event_name=True,
193
194
  flat_hooks=frozenset({"SessionStart", "Stop"}),
195
+ windows_shell="cmd",
194
196
  ),
195
197
  fields=FieldMap(session_id=("conversationId",)),
196
198
  # PostToolUse requires an object; an empty PreToolUse answer leaves agy's
@@ -265,14 +265,30 @@ def _lineage(files: MemoryFiles, path: str) -> set[str]:
265
265
 
266
266
 
267
267
  def _checkout_for(slug: str) -> Path | None:
268
- """The directory Claude Code named *slug*, found by walking the filesystem from ``/``.
268
+ """The directory Claude Code named *slug*, found by walking the filesystem from its root.
269
269
 
270
270
  The slug is lossy: ``/repos/ai-toolkit`` and ``/repos/ai/toolkit`` give
271
271
  the same one. So each step matches the directory's real entries by their
272
272
  own slugs, preferring the longest, and backtracks when a path dead-ends.
273
273
  """
274
- tokens = slug.lstrip("-").split("-")
275
- return _walk(Path("/"), tokens) if tokens and tokens[0] else None
274
+ start = _slug_root(slug)
275
+ return _walk(*start) if start else None
276
+
277
+
278
+ def _slug_root(slug: str) -> tuple[Path, list[str]] | None:
279
+ """The filesystem root *slug* starts from, and the tokens left to walk below it.
280
+
281
+ A POSIX slug starts with the ``-`` of ``/``. A Windows one starts with the
282
+ drive letter, and the drive's ``:\\`` makes a double dash: ``C:\\source\\x`` is
283
+ ``C--source-x``.
284
+ """
285
+ if slug.startswith("-"):
286
+ tokens = slug[1:].split("-")
287
+ return (Path("/"), tokens) if tokens[0] else None
288
+ drive, double_dash, rest = slug.partition("--")
289
+ if len(drive) == 1 and drive.isalpha() and double_dash and rest:
290
+ return Path(f"{drive}:/"), rest.split("-")
291
+ return None
276
292
 
277
293
 
278
294
  def _walk(directory: Path, tokens: list[str]) -> Path | None:
@@ -104,15 +104,25 @@ class CodexMemory:
104
104
 
105
105
  Keys that didn't exist then are removed, which leaves Codex's own
106
106
  defaults (``features.memories`` is off by default), and so is a table
107
- the switch created and left empty.
107
+ the switch created and left empty. Without a switch backup, settings
108
+ are preserved; the output explains how to enable memories if needed.
108
109
 
109
110
  Raises:
110
111
  MemoryBackendError: the config file or its backup isn't valid TOML.
111
112
  """
112
113
  path = _config_path(home)
113
114
  switch_backup = _switch_backup(path)
114
- before = _read(switch_backup)
115
115
  config = _read(path)
116
+ if not switch_backup.exists():
117
+ lines = [f"Unchanged {path}: no memory-switch backup; kept the current memory settings."]
118
+ if any(_get(config, keys, None) is False for keys, _off in _SWITCHES):
119
+ lines.append(
120
+ f"To turn Codex memories on, review {path} and set features.memories, memories.use_memories"
121
+ " and memories.generate_memories to true. An older config.toml.context-graph.bak may predate"
122
+ " the memory switch, so it wasn't restored automatically."
123
+ )
124
+ return lines
125
+ before = _read(switch_backup)
116
126
  for keys, _off in _SWITCHES:
117
127
  previous = _get(before, keys, None)
118
128
  if previous is None:
@@ -169,7 +179,7 @@ class CodexMemory:
169
179
  Codex still has them.
170
180
  """
171
181
  notes = _memory_root(home).joinpath(*_NOTES_DIR)
172
- left = {path.read_text(encoding="utf-8") for path in notes.glob(f"*-{_NOTE_PREFIX}*.md")}
182
+ left = {_read_text(path) for path in notes.glob(f"*-{_NOTE_PREFIX}*.md")}
173
183
  stamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H-%M-%S")
174
184
  report = ExportReport()
175
185
  for memory in source.files():
@@ -60,7 +60,7 @@ class ConnectorSpec:
60
60
  INSTALLABLE: dict[str, str] = {
61
61
  "skills-graph": "skills-graph[memgraph-context-graph]>=0.4.0",
62
62
  "actions-graph": "actions-graph[memgraph-context-graph]>=0.7.1",
63
- "sessions-graph": "sessions-graph[memgraph-context-graph]>=0.12.1",
63
+ "sessions-graph": "sessions-graph[memgraph-context-graph]>=0.12.2",
64
64
  "resources-graph": "resources-graph[memgraph-context-graph]>=0.3.0",
65
65
  }
66
66
 
@@ -6,6 +6,7 @@ import argparse
6
6
  import json
7
7
  import os
8
8
  import sys
9
+ import threading
9
10
  import time
10
11
  from typing import TYPE_CHECKING, Any, NoReturn, TypedDict
11
12
 
@@ -36,6 +37,16 @@ if TYPE_CHECKING:
36
37
  #: so the runtime killed it before the failure could be logged (#378).
37
38
  HOOK_CONNECT_TIMEOUT = 5.0
38
39
 
40
+ #: Seconds a hook runs before it logs where it was stuck, answers the runtime, and exits, for the 30 s hook
41
+ #: timeout our plugins declare. A query waiting on Memgraph's storage access never raises, so without a deadline
42
+ #: of its own the hook is killed at the runtime's timeout and nothing is logged.
43
+ HOOK_DEADLINE = 25.0
44
+
45
+
46
+ def deadline_for(timeout: float) -> float:
47
+ """The ``--deadline`` for a hook the runtime kills after *timeout* seconds: enough margin to log and answer."""
48
+ return max(timeout - 5.0, timeout / 2)
49
+
39
50
 
40
51
  def load_payload(stream: Any | None = None) -> dict[str, Any]:
41
52
  """Read one JSON hook payload from a text stream (stdin by default)."""
@@ -160,6 +171,13 @@ def _hook_parser(plugin: RuntimeCLIPlugin) -> argparse.ArgumentParser:
160
171
  parser.add_argument("--memgraph-user", default=None, help="Memgraph username. Overrides config file value.")
161
172
  parser.add_argument("--memgraph-password", default=None, help="Memgraph password. Overrides config file value.")
162
173
  parser.add_argument("--memgraph-database", default=None, help="Memgraph database. Overrides config file value.")
174
+ parser.add_argument(
175
+ "--deadline",
176
+ type=float,
177
+ default=HOOK_DEADLINE,
178
+ help=f"Seconds before the hook gives up, logs why, and answers the runtime (default: {HOOK_DEADLINE:g}). "
179
+ "Keep it under the runtime's hook timeout.",
180
+ )
163
181
  parser.add_argument(
164
182
  "--strict",
165
183
  action="store_true",
@@ -198,6 +216,54 @@ class _FailureReport:
198
216
  self.record("emit", exc, connector=name, event_type=event.event_type.value, session_id=event.session_id)
199
217
 
200
218
 
219
+ class _Deadline:
220
+ """Answers the runtime and ends the process once a hook has run too long, logging the stage it was stuck in.
221
+
222
+ Every response goes through :meth:`respond`, so the runtime reads one
223
+ answer whichever of the hook and the deadline gets there first.
224
+ """
225
+
226
+ def __init__(self, plugin: RuntimeCLIPlugin, report: _FailureReport) -> None:
227
+ self._plugin = plugin
228
+ self._report = report
229
+ self._lock = threading.Lock()
230
+ self._responded = False
231
+ self._timer: threading.Timer | None = None
232
+
233
+ def start(self, seconds: float) -> None:
234
+ self._timer = threading.Timer(seconds, self._expire, args=(seconds,))
235
+ self._timer.daemon = True
236
+ self._timer.start()
237
+
238
+ def cancel(self) -> None:
239
+ if self._timer is not None:
240
+ self._timer.cancel()
241
+
242
+ def respond(self, response: dict[str, Any]) -> None:
243
+ with self._lock:
244
+ if self._responded:
245
+ return
246
+ self._responded = True
247
+ if response:
248
+ print(json.dumps(response))
249
+
250
+ def _expire(self, seconds: float) -> None:
251
+ # Never released once answering here: the hook's own thread may still be blocked and must not answer too.
252
+ self._lock.acquire()
253
+ if self._responded:
254
+ self._lock.release()
255
+ return
256
+ self._report.record(
257
+ self._report.stage, error_type="HookDeadline", error=f"hook still running after {seconds:g} s"
258
+ )
259
+ # The plain response: the session-start text would query the graph that is holding the hook up.
260
+ response = self._plugin.response_for_payload(self._report.payload) or {}
261
+ if response:
262
+ print(json.dumps(response))
263
+ sys.stdout.flush()
264
+ os._exit(0)
265
+
266
+
201
267
  def _text(value: Any) -> str | None:
202
268
  return value if isinstance(value, str) else None
203
269
 
@@ -216,14 +282,21 @@ def run_hook(plugin: RuntimeCLIPlugin, argv: Sequence[str] | None = None) -> int
216
282
  normal response, and the exit status is 0. ``--strict`` (or the
217
283
  ``_STRICT`` env flag) re-raises after logging, for developers; a connector
218
284
  failure then raises :class:`ConnectorFailuresError` once every connector has run.
285
+
286
+ A hook that blocks, on a query waiting for Memgraph's storage access, never
287
+ raises: at ``--deadline`` it logs a ``HookDeadline`` failure at the stage it
288
+ was stuck in, prints the runtime's plain response, and exits 0 itself,
289
+ before the runtime's own timeout would kill it unlogged.
219
290
  """
220
291
  report = _FailureReport(plugin.name)
292
+ deadline = _Deadline(plugin, report)
221
293
  strict = os.environ.get(f"{_env_prefix(plugin.name)}_STRICT") == "1"
222
294
  payload: dict[str, Any] = {}
223
295
  connector_names: list[str] = []
224
296
  try:
225
297
  args = _hook_parser(plugin).parse_args(argv)
226
298
  strict = strict or args.strict
299
+ deadline.start(args.deadline)
227
300
 
228
301
  from memgraph_context_graph.adapters._identity import load_config, resolve_memgraph_env
229
302
 
@@ -245,7 +318,7 @@ def run_hook(plugin: RuntimeCLIPlugin, argv: Sequence[str] | None = None) -> int
245
318
  if problems := [*load_config().problems, *policy_problems]:
246
319
  report.record("config", level="warning", error_type="MalformedConfig", error="; ".join(problems))
247
320
  if policy.excludes_project(_with_cwd(payload)["cwd"]):
248
- _print_response(plugin, payload, connector_names)
321
+ deadline.respond(_response(plugin, payload, connector_names))
249
322
  return 0
250
323
 
251
324
  report.stage = "setup"
@@ -258,7 +331,9 @@ def run_hook(plugin: RuntimeCLIPlugin, argv: Sequence[str] | None = None) -> int
258
331
  adapter = plugin.adapter_class(link, session_id=args.session_id)
259
332
  adapter.handle_payload(payload)
260
333
  report.stage = "respond"
261
- _print_response(plugin, payload, connector_names, before_tool=_context_before_tool(link, tool_starts.events))
334
+ deadline.respond(
335
+ _response(plugin, payload, connector_names, before_tool=_context_before_tool(link, tool_starts.events))
336
+ )
262
337
  except Exception as exc:
263
338
  if report.stage in {"args", "config"}:
264
339
  # Failed before stdin was read: read it now, so the runtime still gets the response its event expects
@@ -269,9 +344,11 @@ def run_hook(plugin: RuntimeCLIPlugin, argv: Sequence[str] | None = None) -> int
269
344
  report.record(report.stage, exc, error_type="HookArgumentError" if report.stage == "args" else None)
270
345
  if strict:
271
346
  raise
272
- _print_response(plugin, payload, connector_names)
347
+ deadline.respond(_response(plugin, payload, connector_names))
273
348
  _debug_log(plugin.name, f"memgraph-context-graph {plugin.name} hook skipped: {exc}")
274
349
  return 0
350
+ finally:
351
+ deadline.cancel()
275
352
  if strict and report.connector_failures:
276
353
  raise ConnectorFailuresError(report.connector_failures)
277
354
  return 0
@@ -293,9 +370,9 @@ def _payload_session_id(payload: dict[str, Any]) -> str | None:
293
370
  return None
294
371
 
295
372
 
296
- def _print_response(
373
+ def _response(
297
374
  plugin: RuntimeCLIPlugin, payload: dict[str, Any], connector_names: list[str], *, before_tool: Sequence[str] = ()
298
- ) -> None:
375
+ ) -> dict[str, Any]:
299
376
  response = plugin.response_for_payload(payload) or {}
300
377
  render_session = getattr(plugin, "session_context_response", None)
301
378
  if render_session is not None:
@@ -303,8 +380,7 @@ def _print_response(
303
380
  render = getattr(plugin, "context_before_tool_response", None)
304
381
  if before_tool and render is not None:
305
382
  response.update(render("\n".join(before_tool)) or {})
306
- if response:
307
- print(json.dumps(response))
383
+ return response
308
384
 
309
385
 
310
386
  class _ToolStarts(GraphConnector):
@@ -203,7 +203,10 @@ def run(
203
203
  if choices.memory_backend:
204
204
  switchable = [runtime for runtime in choices.runtimes if has_native_memory(runtime)]
205
205
  for runtime in switchable:
206
- done(_memory(runtime, choices.memory_backend, last=runtime == switchable[-1]))
206
+ outcome = done(_memory(runtime, choices.memory_backend, last=runtime == switchable[-1]))
207
+ # Later hand-backs must still see the graph as the backend when this one is retried.
208
+ if outcome.status == "fail" and choices.memory_backend == "native":
209
+ break
207
210
  for outcome in verify(choices.runtimes):
208
211
  done(outcome)
209
212
  return outcomes
@@ -17,11 +17,11 @@ if TYPE_CHECKING:
17
17
  from collections.abc import Callable
18
18
 
19
19
  #: Kept in step with the plugins' bootstrap scripts and install.sh, which install the default connectors first.
20
- BASE = "memgraph-context-graph[mcp]>=0.12.1"
20
+ BASE = "memgraph-context-graph[mcp]>=0.12.2"
21
21
  VIEWER = ("context-graph-viewer", "context-graph-viewer>=0.1.1")
22
22
  SPACES = ("context-graph-spaces", "context-graph-spaces>=0.1.2")
23
23
  #: sessions-graph's local extraction, which setup doesn't install itself; ``unstructured2graph`` tells it is there.
24
- RECONCILIATION = ("unstructured2graph", "sessions-graph[memgraph-context-graph,reconciliation]>=0.12.1")
24
+ RECONCILIATION = ("unstructured2graph", "sessions-graph[memgraph-context-graph,reconciliation]>=0.12.2")
25
25
  #: Connectors whose own command people run: ``sessions-graph reconcile``, ``resources-graph sweep``.
26
26
  EXECUTABLES = ("sessions-graph", "resources-graph")
27
27
 
@@ -6,6 +6,7 @@ Payload shapes are trimmed from a live agy 1.2.13 session.
6
6
  import json
7
7
 
8
8
  from memgraph_context_graph import AgentLink
9
+ from memgraph_context_graph.adapters import _spec
9
10
  from memgraph_context_graph.adapters.antigravity_cli import PLUGIN, AntigravityCLIHooksAdapter, init
10
11
  from memgraph_context_graph.events import (
11
12
  ErrorOccurredEvent,
@@ -162,3 +163,18 @@ def test_init_writes_a_named_group_with_flat_lifecycle_hooks(tmp_path):
162
163
  # agy rejects a nested entry on a lifecycle event, and with it the whole file.
163
164
  assert group["Stop"] == [{"command": "capture --event-name Stop", "type": "command", "timeout": 12}]
164
165
  assert group["SessionStart"] == [{"command": "capture --event-name SessionStart", "type": "command", "timeout": 12}]
166
+
167
+
168
+ def test_init_on_windows_writes_commands_cmd_exe_can_run(tmp_path, monkeypatch):
169
+ # agy hands each command to cmd.exe inside its own quotes: a POSIX-quoted
170
+ # 'C:\...' path is not a filename there, and a double-quoted one is quoted twice.
171
+ exe = r"C:\Users\dev\.local\bin\memgraph-context-graph.EXE"
172
+ monkeypatch.setattr(_spec, "_ON_WINDOWS", True)
173
+ monkeypatch.setattr(_spec.shutil, "which", lambda name: exe)
174
+
175
+ init(tmp_path, ["actions-graph"])
176
+
177
+ group = json.loads((tmp_path / ".agents" / "hooks.json").read_text())["memgraph-context-graph"]
178
+ assert group["Stop"][0]["command"] == (
179
+ rf"{exe} hook run antigravity-cli --connector actions-graph --event-name Stop"
180
+ )
@@ -284,6 +284,49 @@ def test_a_blackholed_memgraph_fails_inside_the_hook_timeout(monkeypatch, tmp_pa
284
284
  assert [line["stage"] for line in _lines(_capture_log_in_tmp)] == ["connect"]
285
285
 
286
286
 
287
+ def test_a_hook_blocked_past_its_deadline_answers_and_logs_where(tmp_path):
288
+ """A query waiting on Memgraph's storage never raises, so the runtime killed the hook at its timeout, unlogged.
289
+
290
+ Run as its own process: the deadline ends the process, as it must to beat a hook thread that is still blocked.
291
+ """
292
+ log = tmp_path / "capture-failures.jsonl"
293
+ hook = textwrap.dedent(
294
+ f"""
295
+ import dataclasses, sys, threading
296
+ from pathlib import Path
297
+ from memgraph_context_graph import AgentLink, capture_log
298
+ from memgraph_context_graph.adapters.claude_code import PLUGIN
299
+ from memgraph_context_graph.hooks import runner
300
+
301
+ capture_log._path_override = Path({str(log)!r})
302
+ runner.create_link = lambda *args, **kwargs: AgentLink()
303
+
304
+ class BlockedAdapter:
305
+ def __init__(self, link, session_id=None):
306
+ pass
307
+
308
+ def handle_payload(self, payload):
309
+ threading.Event().wait()
310
+
311
+ sys.exit(runner.run_hook(dataclasses.replace(PLUGIN, adapter_class=BlockedAdapter), ["--deadline", "1"]))
312
+ """
313
+ )
314
+
315
+ start = time.monotonic()
316
+ done = subprocess.run(
317
+ [sys.executable, "-c", hook],
318
+ input='{"hook_event_name":"Stop","session_id":"s1"}',
319
+ capture_output=True,
320
+ text=True,
321
+ timeout=60,
322
+ )
323
+
324
+ assert time.monotonic() - start < 30
325
+ assert (done.returncode, done.stdout.strip()) == (0, '{"continue": true}')
326
+ (line,) = _lines(log)
327
+ assert (line["stage"], line["error_type"], line["session_id"]) == ("translate", "HookDeadline", "s1")
328
+
329
+
287
330
  @pytest.mark.skipif(os.name == "nt", reason="POSIX permissions")
288
331
  def test_writer_cost_is_small(_capture_log_in_tmp):
289
332
  start = time.perf_counter()
@@ -0,0 +1,31 @@
1
+ """Tests for reading Claude Code's project-folder slugs back into checkouts.
2
+
3
+ Walking a slug against a real checkout is covered end to end in sessions-graph's
4
+ memory backend tests; a Windows drive can't be walked off Windows, so its root is
5
+ tested here.
6
+ """
7
+
8
+ from pathlib import Path
9
+
10
+ import pytest
11
+
12
+ from memgraph_context_graph.adapters.claude_code_memory import _slug_root
13
+
14
+
15
+ @pytest.mark.parametrize(
16
+ ("slug", "root"),
17
+ [
18
+ ("-Users-dev-repos-ai-toolkit", (Path("/"), ["Users", "dev", "repos", "ai", "toolkit"])),
19
+ ("C--source-x", (Path("C:/"), ["source", "x"])),
20
+ ("d--work-my-repo", (Path("d:/"), ["work", "my", "repo"])),
21
+ # A dot-folder after the drive makes a third dash: C:\.config\x.
22
+ ("C---config-x", (Path("C:/"), ["", "config", "x"])),
23
+ ],
24
+ )
25
+ def test_a_slug_walks_from_the_root_its_path_started_at(slug, root):
26
+ assert _slug_root(slug) == root
27
+
28
+
29
+ @pytest.mark.parametrize("slug", ["-", "", "C--", "source-x", "CD--x"])
30
+ def test_a_slug_with_no_root_has_no_checkout(slug):
31
+ assert _slug_root(slug) is None
@@ -1,9 +1,10 @@
1
1
  """Tests for the OpenAI Codex hooks adapter."""
2
2
 
3
3
  import io
4
+ import json
4
5
 
5
6
  from memgraph_context_graph import AgentLink
6
- from memgraph_context_graph.adapters.codex import PLUGIN, CodexHooksAdapter
7
+ from memgraph_context_graph.adapters.codex import PLUGIN, CodexHooksAdapter, init
7
8
  from memgraph_context_graph.events import (
8
9
  Event,
9
10
  EventType,
@@ -212,3 +213,14 @@ def test_load_payload_requires_json_object():
212
213
  payload = load_payload(io.StringIO('{"hook_event_name": "Stop"}'))
213
214
 
214
215
  assert payload == {"hook_event_name": "Stop"}
216
+
217
+
218
+ def test_init_with_a_shorter_timeout_gives_the_hook_a_deadline_inside_it(tmp_path):
219
+ init(tmp_path, ["actions-graph"], timeout=10)
220
+ init(tmp_path / "default", ["actions-graph"])
221
+
222
+ def stop_command(project):
223
+ return json.loads((project / ".codex" / "hooks.json").read_text())["hooks"]["Stop"][0]["hooks"][0]["command"]
224
+
225
+ assert stop_command(tmp_path).endswith("--connector actions-graph --deadline 5")
226
+ assert stop_command(tmp_path / "default").endswith("--connector actions-graph")
@@ -0,0 +1,69 @@
1
+ """Codex memory switches preserve user settings in a throwaway home."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import pytest
6
+
7
+ from memgraph_context_graph.adapters.codex_memory import CodexMemory
8
+
9
+
10
+ @pytest.fixture
11
+ def codex_config(tmp_path, monkeypatch):
12
+ monkeypatch.delenv("CODEX_HOME", raising=False)
13
+ path = tmp_path / ".codex" / "config.toml"
14
+ path.parent.mkdir()
15
+ return tmp_path, path
16
+
17
+
18
+ @pytest.mark.parametrize(
19
+ "original",
20
+ [
21
+ "[features]\nmemories = true\n",
22
+ "[features]\nmemories = false\n[memories]\nuse_memories = false\ngenerate_memories = false\n",
23
+ ],
24
+ )
25
+ def test_repeating_hand_back_preserves_restored_settings(codex_config, original):
26
+ home, config = codex_config
27
+ config.write_text(original)
28
+ # Setup's older backup must not become the restoration source on a later retry.
29
+ config.with_name(config.name + ".context-graph.bak").write_text("[features]\nmemories = true\n")
30
+ native = CodexMemory()
31
+ native.disable(home)
32
+ native.enable(home)
33
+ restored = config.read_bytes()
34
+
35
+ native.enable(home)
36
+
37
+ assert config.read_bytes() == restored
38
+
39
+
40
+ def test_hand_back_without_a_switch_preserves_native_settings(codex_config):
41
+ home, config = codex_config
42
+ original = b"[features]\nmemories = true\n[memories]\ngenerate_memories = false\n"
43
+ config.write_bytes(original)
44
+
45
+ CodexMemory().enable(home)
46
+
47
+ assert config.read_bytes() == original
48
+
49
+
50
+ def test_hand_back_without_a_config_does_not_create_one(codex_config):
51
+ home, config = codex_config
52
+
53
+ CodexMemory().enable(home)
54
+
55
+ assert not config.exists()
56
+
57
+
58
+ def test_a_legacy_backup_is_not_restored_as_the_missing_switch_backup(codex_config):
59
+ home, config = codex_config
60
+ original = b"[features]\nmemories = false\n[memories]\nuse_memories = false\ngenerate_memories = false\n"
61
+ config.write_bytes(original)
62
+ backup = config.with_name(config.name + ".context-graph.bak")
63
+ backup.write_text("[features]\nmemories = true\n")
64
+
65
+ lines = CodexMemory().enable(home)
66
+
67
+ assert config.read_bytes() == original
68
+ assert backup.read_text() == "[features]\nmemories = true\n"
69
+ assert any("To turn Codex memories on" in line and "wasn't restored automatically" in line for line in lines)
@@ -93,7 +93,7 @@ def test_wiring_refuses_the_home_directory(setup_home):
93
93
 
94
94
 
95
95
  @pytest.mark.parametrize("runtime, binary", [("claude-code", "claude"), ("codex", "codex")])
96
- def test_plugin_wiring_registers_installs_and_comes_off_again(fake_agents, runtime, binary):
96
+ def test_plugin_wiring_registers_installs_and_comes_off_again(fake_agents, setup_home, runtime, binary):
97
97
  agent = BY_RUNTIME[runtime]
98
98
  lines, log = _logged()
99
99