memgraph-context-graph 0.12.2__tar.gz → 0.12.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 (107) hide show
  1. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.4}/CONTEXT.md +8 -0
  2. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.4}/PKG-INFO +1 -1
  3. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.4}/docs/memory.md +20 -4
  4. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.4}/docs/runtime-setup.md +5 -0
  5. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.4}/docs/sdk-memory.md +4 -0
  6. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.4}/pyproject.toml +1 -1
  7. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.4}/src/memgraph_context_graph/adapters/_identity.py +2 -1
  8. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.4}/src/memgraph_context_graph/adapters/_spec.py +12 -3
  9. memgraph_context_graph-0.12.4/src/memgraph_context_graph/adapters/_text_file.py +23 -0
  10. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.4}/src/memgraph_context_graph/adapters/agent_mcp.py +2 -1
  11. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.4}/src/memgraph_context_graph/adapters/claude_code_memory.py +26 -10
  12. memgraph_context_graph-0.12.4/src/memgraph_context_graph/adapters/codex.py +248 -0
  13. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.4}/src/memgraph_context_graph/adapters/opencode.py +2 -1
  14. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.4}/src/memgraph_context_graph/cli.py +30 -2
  15. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.4}/src/memgraph_context_graph/connectors.py +1 -1
  16. memgraph_context_graph-0.12.4/src/memgraph_context_graph/graph_version.py +327 -0
  17. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.4}/src/memgraph_context_graph/hooks/runner.py +60 -6
  18. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.4}/src/memgraph_context_graph/mcp_server.py +18 -3
  19. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.4}/src/memgraph_context_graph/memory_backend.py +5 -1
  20. memgraph_context_graph-0.12.4/src/memgraph_context_graph/refused_events.py +317 -0
  21. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.4}/src/memgraph_context_graph/sdk/__init__.py +32 -1
  22. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.4}/src/memgraph_context_graph/session.py +16 -3
  23. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.4}/src/memgraph_context_graph/setup/cli.py +4 -1
  24. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.4}/src/memgraph_context_graph/setup/tool.py +58 -8
  25. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.4}/src/memgraph_context_graph/setup/update.py +24 -1
  26. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.4}/src/memgraph_context_graph/tools.py +49 -15
  27. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.4}/tests/conftest.py +11 -0
  28. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.4}/tests/test_codex_adapter.py +107 -0
  29. memgraph_context_graph-0.12.4/tests/test_graph_version.py +249 -0
  30. memgraph_context_graph-0.12.4/tests/test_refused_events.py +335 -0
  31. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.4}/tests/test_session.py +9 -0
  32. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.4}/tests/test_setup.py +41 -0
  33. memgraph_context_graph-0.12.4/tests/test_text_file.py +38 -0
  34. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.4}/tests/test_tools.py +58 -0
  35. memgraph_context_graph-0.12.2/src/memgraph_context_graph/adapters/codex.py +0 -121
  36. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.4}/.gitignore +0 -0
  37. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.4}/LICENSE +0 -0
  38. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.4}/README.md +0 -0
  39. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.4}/docs/adr/0001-runtime-adapter-terminology.md +0 -0
  40. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.4}/docs/adr/0002-config-file-only-hook-resolution.md +0 -0
  41. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.4}/docs/adr/0003-config-path-override.md +0 -0
  42. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.4}/docs/adr/0004-user-id-in-session-start-event.md +0 -0
  43. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.4}/docs/adr/0005-config-file-covers-llm-key-and-subprocess-env.md +0 -0
  44. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.4}/docs/adr/0006-memory-tool-writes-explicit-memory.md +0 -0
  45. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.4}/docs/adr/0007-spawned-processes-read-the-config-file-themselves.md +0 -0
  46. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.4}/docs/command-hooks.md +0 -0
  47. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.4}/docs/configuration.md +0 -0
  48. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.4}/docs/extending.md +0 -0
  49. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.4}/docs/research/2026-10-codex-memory.md +0 -0
  50. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.4}/examples/sdk_claude_agent.py +0 -0
  51. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.4}/examples/sdk_openai_agents.py +0 -0
  52. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.4}/src/memgraph_context_graph/__init__.py +0 -0
  53. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.4}/src/memgraph_context_graph/adapters/__init__.py +0 -0
  54. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.4}/src/memgraph_context_graph/adapters/_opencode_plugin.js +0 -0
  55. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.4}/src/memgraph_context_graph/adapters/agent_memory.py +0 -0
  56. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.4}/src/memgraph_context_graph/adapters/antigravity_cli.py +0 -0
  57. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.4}/src/memgraph_context_graph/adapters/claude_code.py +0 -0
  58. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.4}/src/memgraph_context_graph/adapters/codex_memory.py +0 -0
  59. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.4}/src/memgraph_context_graph/adapters/copilot_cli.py +0 -0
  60. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.4}/src/memgraph_context_graph/adapters/cursor.py +0 -0
  61. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.4}/src/memgraph_context_graph/adapters/grok.py +0 -0
  62. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.4}/src/memgraph_context_graph/capture_log.py +0 -0
  63. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.4}/src/memgraph_context_graph/capture_policy.py +0 -0
  64. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.4}/src/memgraph_context_graph/config_schema.py +0 -0
  65. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.4}/src/memgraph_context_graph/events.py +0 -0
  66. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.4}/src/memgraph_context_graph/forget.py +0 -0
  67. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.4}/src/memgraph_context_graph/hooks/__init__.py +0 -0
  68. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.4}/src/memgraph_context_graph/hooks/cli.py +0 -0
  69. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.4}/src/memgraph_context_graph/hooks/runtime_plugin.py +0 -0
  70. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.4}/src/memgraph_context_graph/link.py +0 -0
  71. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.4}/src/memgraph_context_graph/protocols.py +0 -0
  72. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.4}/src/memgraph_context_graph/sdk/claude.py +0 -0
  73. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.4}/src/memgraph_context_graph/sdk/openai.py +0 -0
  74. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.4}/src/memgraph_context_graph/setup/__init__.py +0 -0
  75. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.4}/src/memgraph_context_graph/setup/agents.py +0 -0
  76. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.4}/src/memgraph_context_graph/setup/engine.py +0 -0
  77. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.4}/src/memgraph_context_graph/setup/memgraph.py +0 -0
  78. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.4}/src/memgraph_context_graph/setup/prompts.py +0 -0
  79. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.4}/src/memgraph_context_graph/setup/uninstall.py +0 -0
  80. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.4}/src/memgraph_context_graph/spaces.py +0 -0
  81. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.4}/src/memgraph_context_graph/terminal.py +0 -0
  82. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.4}/tests/__init__.py +0 -0
  83. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.4}/tests/fake_agent_cli.py +0 -0
  84. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.4}/tests/test_agent_memory_wiring.py +0 -0
  85. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.4}/tests/test_antigravity_cli_adapter.py +0 -0
  86. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.4}/tests/test_capture_log.py +0 -0
  87. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.4}/tests/test_capture_policy.py +0 -0
  88. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.4}/tests/test_claude_code_adapter.py +0 -0
  89. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.4}/tests/test_claude_code_memory.py +0 -0
  90. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.4}/tests/test_codex_memory.py +0 -0
  91. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.4}/tests/test_config_cli.py +0 -0
  92. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.4}/tests/test_connectors.py +0 -0
  93. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.4}/tests/test_context_before_tool.py +0 -0
  94. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.4}/tests/test_copilot_cli_adapter.py +0 -0
  95. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.4}/tests/test_cursor_adapter.py +0 -0
  96. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.4}/tests/test_dev_scripts.py +0 -0
  97. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.4}/tests/test_events.py +0 -0
  98. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.4}/tests/test_grok_adapter.py +0 -0
  99. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.4}/tests/test_hook_cli.py +0 -0
  100. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.4}/tests/test_identity.py +0 -0
  101. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.4}/tests/test_link.py +0 -0
  102. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.4}/tests/test_opencode_adapter.py +0 -0
  103. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.4}/tests/test_plugin_hooks.py +0 -0
  104. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.4}/tests/test_runtime_plugin.py +0 -0
  105. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.4}/tests/test_setup_docker.py +0 -0
  106. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.4}/tests/test_spaces.py +0 -0
  107. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.4}/tests/test_terminal.py +0 -0
@@ -78,6 +78,14 @@ _Avoid_: MCP tool (the protocol it is served over, not the thing), Graph Connect
78
78
  `ConnectorSpec` (`connectors.py`): a graph component's name, its graph class, how to build its **Graph Connector** from **Hook Configuration**, and an optional `doctor` check. Published via the `memgraph_context_graph.connectors` entry-point group; the hook runner, schema setup and `doctor` find connectors only through it. The hub keeps just what it needs before a component is installed: each installable connector's requirement (`INSTALLABLE`) and the default set.
79
79
  _Avoid_: Component (the package), Graph Connector (the object a registration builds)
80
80
 
81
+ **Graph Epoch**:
82
+ The version of one component's shape in the graph, an integer that only a migration raises; every graph is at epoch 1 until one does. The graph states per component the oldest epoch that may write and read it, `(:ContextGraphSchema {component, epoch, min_writer_epoch, min_reader_epoch})`, and every client checks that before writing or reading (`graph_version.py`): a client older than the graph stops, never corrupts. No node means no constraint. Clients record themselves as `(:ContextGraphClient)`, so a migration can see who still writes an old shape. Not a package version: a release that leaves the graph's shape alone keeps the epoch.
83
+ _Avoid_: schema version, package version, migration (raises an epoch; isn't one)
84
+
85
+ **Refused-Event Spool**:
86
+ Where a connector's events go while the graph refuses it (**Graph Epoch**): a local, 0600, size-capped JSONL file beside the capture-failure log (`refused_events.py`), holding each event as the capture policy left it, original timestamp included. `update` replays it into the connectors the graph allows again. Its line format is versioned and read by later releases, so a change keeps a reader for every earlier format.
87
+ _Avoid_: queue, buffer, outbox (nothing sends it on its own; `update` replays it)
88
+
81
89
  ## Relationships
82
90
 
83
91
  - **Memgraph Context Graph** belongs to broader **Context Graph** family.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: memgraph-context-graph
3
- Version: 0.12.2
3
+ Version: 0.12.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
@@ -93,7 +93,11 @@ Opting in does three things, in this order:
93
93
  project already put a different memory of the same name there, this one
94
94
  goes to its own project's folder instead, so neither is lost;
95
95
  - `project`/`reference` memories go to that project's folder;
96
- - `MEMORY.md` is skipped, and anything already in the graph is never overwritten.
96
+ - `MEMORY.md` is skipped, and anything already in the graph is never overwritten;
97
+ - each file remembers the project it came from and keeps its own
98
+ modification time, so the session-start index can still put a project's
99
+ `user`/`feedback` memories with that project. Running the switch again
100
+ records this for files an earlier import brought in.
97
101
  2. Sets `autoMemoryEnabled: false` in `~/.claude/settings.json`, the user-level
98
102
  settings file, because a plugin can't set it itself. The file is backed up
99
103
  once to `settings.json.context-graph.bak`.
@@ -122,8 +126,8 @@ its global store, with three differences:
122
126
  compaction (`compact`), so the memory index comes back once the context is
123
127
  summarized.
124
128
 
125
- Codex caps hook context at about 2,500 tokens, so pinned files come before the
126
- index and a long index is cut from its tail. See
129
+ Codex caps hook context at about 2,500 tokens, so its session-start text is
130
+ kept to 9,000 characters, with pinned files before the index. See
127
131
  [the Codex research note](research/2026-10-codex-memory.md).
128
132
 
129
133
  If the import can't run, for example because Memgraph is down, nothing is
@@ -192,10 +196,22 @@ change shows from the next session.
192
196
  At session start the hook loads the memory, the way the harness's built-in
193
197
  memory would have. It adds:
194
198
  - an index of the root and the current project's files, one line each with
195
- the file's `description:` frontmatter or first line, up to 200 lines;
199
+ the file's `description:` frontmatter or first line, up to 200 lines. The
200
+ current project's files come first, then root files from this project
201
+ (imported from its memory, or written in its sessions by an SDK app), then
202
+ the rest, newest first, so an import of every project's `user`/`feedback`
203
+ memories can't crowd this project's out;
196
204
  - the full text of any file with `pin: true` in its frontmatter;
197
205
  - the current project's folder.
198
206
 
207
+ All of that, with recall's "where you left off", fits in what the agent shows
208
+ its model at session start: 10,000 characters, Claude Code's cap on a hook's
209
+ context (past it, the model sees only a 2,000-character preview), and 9,000 for
210
+ Codex. Pinned text takes at most half. The index lists every file by its path
211
+ before it describes any, then gives descriptions in its order, so the current
212
+ project's files keep theirs longest. When even the paths don't fit, it ends
213
+ with how many it left out and where to list them.
214
+
199
215
  The index is generated from the graph, so the model never maintains an index
200
216
  file and it can't go stale. The project folder is keyed by the checkout's git
201
217
  `origin` remote (e.g. `github.com_memgraph_ai-toolkit`), so every clone of a
@@ -76,6 +76,11 @@ and constraints. It then runs `doctor`'s checks. Name runtimes to refresh only t
76
76
  and the graph's data are untouched, and running it again changes nothing.
77
77
  Running agent sessions keep the hooks they loaded: restart them. Outside the uv tool (a virtualenv, say) the
78
78
  package step only warns, and you upgrade it yourself first.
79
+ Once a newer Context Graph has reshaped your graph, an older install stops writing (and, if needed, reading) the
80
+ parts it no longer knows instead of corrupting them: the session-start context, the capture-failure log and
81
+ `doctor` say so, and `update` brings it level. What the agents did meanwhile isn't lost: it is kept, redacted
82
+ like any capture, in `refused-events.jsonl` beside the capture-failure log (readable only by you, at most 64 MB),
83
+ and `update` adds it to the graph with its original times.
79
84
 
80
85
  **Identity and connection.** setup writes `~/.config/context-graph/config.toml`; hooks read their configuration from that file at runtime (see [Configuration](configuration.md) — env vars are **not** read at hook time). To change your identity later:
81
86
 
@@ -31,6 +31,10 @@ from memgraph_context_graph.sdk import ContextGraphMemory
31
31
  memory = ContextGraphMemory(user_id="alice")
32
32
  ```
33
33
 
34
+ A long-running app keeps working across a Context Graph upgrade elsewhere. About every ten minutes it checks whether
35
+ a newer Context Graph has reshaped the graph. If it has, the app stops writing the parts it no longer knows, keeps
36
+ those events on the machine, and `memgraph-context-graph update` adds them once this install is upgraded.
37
+
34
38
  ## Claude Agent SDK
35
39
 
36
40
  The SDK's hook callbacks get Claude Code's own hook payloads, so the `claude-code` rules translate them, under `source_sdk = "claude-agent-sdk"`. Python callbacks get no `SessionStart` or `SessionEnd`, which changes two things:
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "memgraph-context-graph"
3
- version = "0.12.2"
3
+ version = "0.12.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" }
@@ -30,6 +30,7 @@ from dataclasses import dataclass, field, replace
30
30
  from pathlib import Path
31
31
  from typing import Any
32
32
 
33
+ from memgraph_context_graph.adapters._text_file import write_text_keeping_newlines
33
34
  from memgraph_context_graph.config_schema import (
34
35
  CONFIG_KEYS,
35
36
  SECTIONS,
@@ -355,7 +356,7 @@ def _write_config_file(content: str) -> Path:
355
356
  global _cached_config
356
357
  path = config_file()
357
358
  path.parent.mkdir(parents=True, exist_ok=True)
358
- path.write_text(content, encoding="utf-8")
359
+ write_text_keeping_newlines(path, content)
359
360
  path.chmod(stat.S_IRUSR | stat.S_IWUSR) # 0600: the file holds credentials.
360
361
  _cached_config = None
361
362
  return path
@@ -19,6 +19,7 @@ from dataclasses import dataclass, field
19
19
  from typing import TYPE_CHECKING, Any, Literal
20
20
 
21
21
  from memgraph_context_graph.adapters._identity import resolve_user_id
22
+ from memgraph_context_graph.adapters._text_file import write_text_keeping_newlines
22
23
  from memgraph_context_graph.events import (
23
24
  AgentEndEvent,
24
25
  AgentStartEvent,
@@ -30,6 +31,7 @@ from memgraph_context_graph.events import (
30
31
  TurnEndEvent,
31
32
  )
32
33
  from memgraph_context_graph.protocols import RuntimeAdapter
34
+ from memgraph_context_graph.tools import SESSION_CONTEXT_CHARS
33
35
 
34
36
  if TYPE_CHECKING:
35
37
  from pathlib import Path
@@ -115,7 +117,8 @@ class RuntimeSpec:
115
117
  modifying the call. ``session_context`` renders the stdout JSON that puts
116
118
  the session-start text (memory index, where the user left off) in front of
117
119
  the model when ``session_context_event`` fires; None where no hook output
118
- reaches the model at session start.
120
+ reaches the model at session start. ``session_context_limit`` is how many
121
+ characters of that text the runtime shows its model.
119
122
  """
120
123
 
121
124
  name: str
@@ -133,6 +136,7 @@ class RuntimeSpec:
133
136
  context_before_tool: Callable[[str], Mapping[str, Any]] | None = None
134
137
  session_context: Callable[[str], Mapping[str, Any]] | None = None
135
138
  session_context_event: str = "SessionStart"
139
+ session_context_limit: int = SESSION_CONTEXT_CHARS
136
140
 
137
141
  def event_name(self, payload: Mapping[str, Any]) -> str:
138
142
  """Return *payload*'s canonical event name."""
@@ -251,6 +255,11 @@ class SpecPlugin:
251
255
  """Return the runtime's registered name."""
252
256
  return self.spec.name
253
257
 
258
+ @property
259
+ def session_context_limit(self) -> int:
260
+ """Return how many characters of session-start text the runtime shows its model."""
261
+ return self.spec.session_context_limit
262
+
254
263
  @property
255
264
  def probe_payload(self) -> Mapping[str, Any]:
256
265
  """Return the payload ``doctor`` replays to smoke-test the runtime."""
@@ -377,7 +386,7 @@ def write_hook_config(
377
386
  document[spec.config.root_key] = merged
378
387
 
379
388
  config_path.parent.mkdir(parents=True, exist_ok=True)
380
- config_path.write_text(json.dumps(document, indent=2) + "\n", encoding="utf-8")
389
+ write_text_keeping_newlines(config_path, json.dumps(document, indent=2) + "\n")
381
390
  return config_path
382
391
 
383
392
 
@@ -408,7 +417,7 @@ def remove_hook_config(spec: RuntimeSpec, project_dir: Path) -> bool:
408
417
  if set(document) <= {"version"}:
409
418
  config_path.unlink()
410
419
  else:
411
- config_path.write_text(json.dumps(document, indent=2) + "\n", encoding="utf-8")
420
+ write_text_keeping_newlines(config_path, json.dumps(document, indent=2) + "\n")
412
421
  return True
413
422
 
414
423
 
@@ -0,0 +1,23 @@
1
+ """Writing a user's files without changing their line endings.
2
+
3
+ ``Path.write_text`` turns ``\\n`` into the platform's line ending, so on Windows every file setup or
4
+ the memory switch rewrote came back CRLF, even when its content didn't change. A rewrite here keeps
5
+ the ending the file already had, and a new file gets ``\\n`` on every platform.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import TYPE_CHECKING
11
+
12
+ if TYPE_CHECKING:
13
+ from pathlib import Path
14
+
15
+
16
+ def write_text_keeping_newlines(path: Path, text: str) -> None:
17
+ """Write *text* to *path* as UTF-8 with the line ending *path* already uses.
18
+
19
+ *text* may use ``\\n`` or ``\\r\\n``; it is written with ``\\r\\n`` only if the existing file holds one,
20
+ else with ``\\n``.
21
+ """
22
+ newline = "\r\n" if path.is_file() and b"\r\n" in path.read_bytes() else "\n"
23
+ path.write_text(text.replace("\r\n", "\n"), encoding="utf-8", newline=newline)
@@ -27,6 +27,7 @@ import sys
27
27
  from dataclasses import dataclass
28
28
  from typing import TYPE_CHECKING, Any
29
29
 
30
+ from memgraph_context_graph.adapters._text_file import write_text_keeping_newlines
30
31
  from memgraph_context_graph.memory_backend import MemoryBackendError
31
32
 
32
33
  if TYPE_CHECKING:
@@ -322,5 +323,5 @@ def _write(path: Path, rendered: str) -> list[str]:
322
323
  if path.exists() and not backup.exists():
323
324
  shutil.copy2(path, backup)
324
325
  path.parent.mkdir(parents=True, exist_ok=True)
325
- path.write_text(rendered, encoding="utf-8")
326
+ write_text_keeping_newlines(path, rendered)
326
327
  return [f"Wrote {path}"]
@@ -21,9 +21,11 @@ import re
21
21
  import shutil
22
22
  import subprocess
23
23
  from dataclasses import dataclass
24
+ from datetime import datetime, timezone
24
25
  from pathlib import Path
25
26
  from typing import TYPE_CHECKING, Any
26
27
 
28
+ from memgraph_context_graph.adapters._text_file import write_text_keeping_newlines
27
29
  from memgraph_context_graph.memory_backend import ExportReport, ImportReport, MemoryBackendError
28
30
 
29
31
  if TYPE_CHECKING:
@@ -92,9 +94,16 @@ class ClaudeCodeMemory:
92
94
  goes under its project's folder under its own filename instead, where
93
95
  handing memory back finds it again. ``MEMORY.md`` is skipped: the index
94
96
  is generated now. The original files are left as they are.
97
+
98
+ Each file is marked with the project it came from and when it last
99
+ changed there, which the session-start index ranks by. A file already
100
+ in the graph with the same text is marked too, so re-running the import
101
+ fills that in for an earlier one.
95
102
  """
96
103
  from sessions_graph.memory_index import project_key_for
97
104
 
105
+ # An older sessions-graph can't record where a file came from; the import still works without it.
106
+ mark = getattr(files, "mark_imported", None)
98
107
  report = ImportReport()
99
108
  for memory_dir in sorted((home / ".claude" / "projects").glob("*/memory")):
100
109
  checkout = _checkout_for(memory_dir.parent.name)
@@ -112,13 +121,15 @@ class ClaudeCodeMemory:
112
121
  lineage = _lineage(files, target)
113
122
  if lineage:
114
123
  report.skipped.append(target)
115
- continue
116
- try:
117
- files.create(target, text)
118
- except Exception: # Too large, or the path is taken by a directory: keep going.
119
- report.skipped.append(target)
120
- continue
121
- report.imported.append(target)
124
+ else:
125
+ try:
126
+ files.create(target, text)
127
+ except Exception: # Too large, or the path is taken by a directory: keep going.
128
+ report.skipped.append(target)
129
+ continue
130
+ report.imported.append(target)
131
+ if mark is not None and (not lineage or text in lineage):
132
+ mark(target, project, text=text, modified=_modified(source))
122
133
  return report
123
134
 
124
135
  def export_from(self, source: MemorySource, home: Path) -> ExportReport:
@@ -226,7 +237,7 @@ def _write_memory(folder: Path, name: str, text: str, lineage: set[str]) -> bool
226
237
  if current == text or current not in lineage:
227
238
  return False
228
239
  folder.mkdir(parents=True, exist_ok=True)
229
- path.write_text(text, encoding="utf-8")
240
+ write_text_keeping_newlines(path, text)
230
241
  return True
231
242
 
232
243
 
@@ -240,7 +251,7 @@ def _write_index(folder: Path) -> None:
240
251
  title = match["value"].strip("\"'") if (match := _NAME_RE.search(frontmatter)) else path.stem
241
252
  hook = match["value"].strip("\"'") if (match := _DESCRIPTION_RE.search(frontmatter)) else ""
242
253
  lines.append(f"- [{title}]({path.name})" + (f" — {hook}" if hook else ""))
243
- (folder / _INDEX_FILE).write_text("\n".join(lines) + "\n", encoding="utf-8")
254
+ write_text_keeping_newlines(folder / _INDEX_FILE, "\n".join(lines) + "\n")
244
255
 
245
256
 
246
257
  def _frontmatter(text: str) -> str:
@@ -308,6 +319,11 @@ def _walk(directory: Path, tokens: list[str]) -> Path | None:
308
319
  return None
309
320
 
310
321
 
322
+ def _modified(source: Path) -> str:
323
+ """When *source* last changed, in the ISO 8601 UTC form the graph's ``updated_at`` uses."""
324
+ return datetime.fromtimestamp(source.stat().st_mtime, timezone.utc).isoformat()
325
+
326
+
311
327
  def _fallback_key(slug: str) -> str:
312
328
  """A key for a project whose checkout is gone: the whole slug, since its last part alone may collide."""
313
329
  from sessions_graph.memory_index import safe_key
@@ -344,7 +360,7 @@ def _set(path: Path, value: bool | None) -> list[str]:
344
360
  if path.exists() and not backup.exists():
345
361
  shutil.copy2(path, backup)
346
362
  path.parent.mkdir(parents=True, exist_ok=True)
347
- path.write_text(json.dumps(settings, indent=2) + "\n", encoding="utf-8")
363
+ write_text_keeping_newlines(path, json.dumps(settings, indent=2) + "\n")
348
364
  state = "removed" if value is None else json.dumps(value)
349
365
  return [f"Wrote {path} ({_SETTING} {state})"]
350
366
 
@@ -0,0 +1,248 @@
1
+ """OpenAI Codex command-hook runtime adapter."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import dataclasses
6
+ import json
7
+ import os
8
+ import re
9
+ import time
10
+ from typing import TYPE_CHECKING, Any
11
+
12
+ from memgraph_context_graph.adapters._spec import (
13
+ EventContext,
14
+ HookConfig,
15
+ RuntimeSpec,
16
+ SpecAdapter,
17
+ SpecPlugin,
18
+ hook_specific_session_context,
19
+ pre_tool_use_context,
20
+ session_end,
21
+ session_start,
22
+ tool_end,
23
+ tool_start,
24
+ turn_end,
25
+ write_hook_config,
26
+ )
27
+ from memgraph_context_graph.adapters._text_file import write_text_keeping_newlines
28
+ from memgraph_context_graph.adapters.codex_memory import CODEX_MEMORY
29
+ from memgraph_context_graph.events import MessageEvent, ToolEndEvent
30
+
31
+ if TYPE_CHECKING:
32
+ from pathlib import Path
33
+
34
+ from memgraph_context_graph.events import Event
35
+
36
+ #: Enough of the rollout to hold the turn being handled, without reading a long session's whole file.
37
+ _ROLLOUT_TAIL_BYTES = 4 * 1024 * 1024
38
+ #: How long PostToolUse waits for Codex's rollout writer to record the call it is about.
39
+ _ITEM_WAIT_SECONDS = 0.5
40
+ _ITEM_POLL_SECONDS = 0.05
41
+ #: Rollout item types for the tools Codex's hooks report, and the ``tool_name`` the hooks give each.
42
+ _ITEM_TOOL_NAMES = {"CommandExecution": "Bash", "FileChange": "apply_patch"}
43
+ _TOOL_ITEM_TYPES = frozenset({*_ITEM_TOOL_NAMES, "McpToolCall"})
44
+
45
+
46
+ def _user_prompt(context: EventContext) -> MessageEvent:
47
+ return MessageEvent(
48
+ **context.base(),
49
+ role="user",
50
+ content=context.value("prompt", ""),
51
+ model=context.optional_text("model"),
52
+ )
53
+
54
+
55
+ def _permission(context: EventContext) -> MessageEvent:
56
+ return MessageEvent(**context.base(), role="system", content=context.text("tool_name", "permission_request"))
57
+
58
+
59
+ def _completed_items(context: EventContext) -> dict[str, dict[str, Any]]:
60
+ """This turn's ``item_completed`` records in Codex's rollout (``transcript_path``), by item id.
61
+
62
+ An item's id is the ``tool_use_id`` the tool hooks carry. Only the rollout's tail is read: the turn
63
+ being handled is the last one written. An unreadable rollout yields nothing.
64
+ """
65
+ path, turn_id = context.optional_text("transcript_path"), context.optional_text("turn_id")
66
+ if not path or not turn_id:
67
+ return {}
68
+ try:
69
+ with open(path, "rb") as file:
70
+ start = max(0, file.seek(0, os.SEEK_END) - _ROLLOUT_TAIL_BYTES)
71
+ file.seek(start)
72
+ lines = file.read().split(b"\n")[1 if start else 0 :]
73
+ except OSError:
74
+ return {}
75
+ items = {}
76
+ for line in lines:
77
+ if b'"item_completed"' not in line:
78
+ continue
79
+ try:
80
+ record = json.loads(line)
81
+ except ValueError:
82
+ continue
83
+ payload = record.get("payload") if isinstance(record, dict) else None
84
+ if not isinstance(payload, dict) or payload.get("type") != "item_completed":
85
+ continue
86
+ item = payload.get("item")
87
+ if payload.get("turn_id") == turn_id and isinstance(item, dict) and isinstance(item.get("id"), str):
88
+ items[item["id"]] = item
89
+ return items
90
+
91
+
92
+ def _item_result(item: dict[str, Any]) -> Any:
93
+ """What the tool behind a rollout item returned, shaped as its hook result would be."""
94
+ result = item.get("result")
95
+ if isinstance(result, dict):
96
+ return result.get("content", result)
97
+ return result if result is not None else item.get("aggregated_output", item.get("stdout"))
98
+
99
+
100
+ def _failure(item: dict[str, Any]) -> str | None:
101
+ """The error message of a failed rollout item, or None if it succeeded."""
102
+ exit_code = item.get("exit_code")
103
+ if item.get("status") != "failed" and exit_code in (None, 0):
104
+ return None
105
+ blocks = _item_result(item)
106
+ texts = (
107
+ [b["text"] for b in blocks if isinstance(b, dict) and isinstance(b.get("text"), str)]
108
+ if isinstance(blocks, list)
109
+ else []
110
+ )
111
+ detail = "\n".join(texts) or item.get("error") or item.get("aggregated_output") or item.get("stderr")
112
+ if detail:
113
+ return str(detail)
114
+ return f"exit code {exit_code}" if exit_code not in (None, 0) else f"{item.get('type', 'tool')} failed"
115
+
116
+
117
+ def _hook_tool_name(item: dict[str, Any]) -> str:
118
+ """The ``tool_name`` Codex's hooks give the tool behind a rollout item."""
119
+ if item.get("type") == "McpToolCall":
120
+ server = re.sub(r"[^A-Za-z0-9_]", "_", str(item.get("server", "")))
121
+ return f"mcp__{server}__{item.get('tool', '')}"
122
+ return _ITEM_TOOL_NAMES[item["type"]]
123
+
124
+
125
+ def _tool_end(context: EventContext) -> ToolEndEvent:
126
+ """Codex's tool end, failed when the rollout says so.
127
+
128
+ A ``Bash`` call that exits non-zero still reaches PostToolUse, but the hook gets only its output;
129
+ the exit code is in the rollout, whose record of the call Codex normally writes before running the
130
+ hook (hence the short wait). A result stored as completed here stays so: :func:`_turn_end` can't
131
+ correct it. Other tools reach PostToolUse only when they succeeded.
132
+ """
133
+ event = tool_end(context)
134
+ if event.tool_name != "Bash" or not event.tool_use_id:
135
+ return event
136
+ deadline = time.monotonic() + _ITEM_WAIT_SECONDS
137
+ while (item := _completed_items(context).get(event.tool_use_id)) is None and time.monotonic() < deadline:
138
+ time.sleep(_ITEM_POLL_SECONDS)
139
+ message = _failure(item) if item else None
140
+ return dataclasses.replace(event, is_error=True, error_message=message) if message else event
141
+
142
+
143
+ def _rollout_tool_end(context: EventContext, item_id: str, item: dict[str, Any]) -> ToolEndEvent:
144
+ message = _failure(item)
145
+ return ToolEndEvent(
146
+ **context.base(),
147
+ tool_name=_hook_tool_name(item),
148
+ tool_use_id=item_id,
149
+ result=_item_result(item),
150
+ is_error=message is not None,
151
+ error_message=message,
152
+ )
153
+
154
+
155
+ def _turn_end(context: EventContext) -> list[Event]:
156
+ """End the turn, first recording the outcome of every tool call that finished in it.
157
+
158
+ PostToolUse misses calls: Codex runs none for a failed tool (an MCP error, a rejected patch), nor for
159
+ a command that outlived its first yield and finished later. The turn's rollout records every one,
160
+ so each is recorded from there; one PostToolUse already recorded is the same event again, which
161
+ connectors store once. A patch Codex rejects before applying leaves no rollout item and stays a call
162
+ without a result.
163
+ """
164
+ outcomes: list[Event] = [
165
+ _rollout_tool_end(context, item_id, item)
166
+ for item_id, item in _completed_items(context).items()
167
+ if item.get("type") in _TOOL_ITEM_TYPES
168
+ ]
169
+ # Stop fires after every turn; SessionEnd, registered separately, ends the session.
170
+ return [*outcomes, *turn_end(context, reply=context.value("reply"))]
171
+
172
+
173
+ _HOOKS_PATH = ".codex/hooks.json"
174
+ SPEC = RuntimeSpec(
175
+ name="codex",
176
+ source_sdk="codex",
177
+ event_key="hook_event_name",
178
+ hooks=("SessionStart", "UserPromptSubmit", "PreToolUse", "PostToolUse", "PermissionRequest", "Stop", "SessionEnd"),
179
+ rules={
180
+ "SessionStart": session_start,
181
+ "UserPromptSubmit": _user_prompt,
182
+ "PreToolUse": tool_start,
183
+ "PostToolUse": _tool_end,
184
+ "PermissionRequest": _permission,
185
+ "Stop": _turn_end,
186
+ "SessionEnd": session_end,
187
+ },
188
+ metadata_keys=(
189
+ "cwd",
190
+ "source",
191
+ "transcript_path",
192
+ "turn_id",
193
+ "permission_mode",
194
+ "tool_name",
195
+ "tool_input",
196
+ "tool_use_id",
197
+ "reason",
198
+ "decision",
199
+ "stop_hook_active",
200
+ ),
201
+ config=HookConfig(
202
+ path=_HOOKS_PATH,
203
+ layout="nested",
204
+ matchers={"SessionStart": "startup|resume|clear|compact", "PreToolUse": "*", "PostToolUse": "*"},
205
+ ),
206
+ responses={"Stop": {"continue": True}},
207
+ context_before_tool=pre_tool_use_context,
208
+ session_context=hook_specific_session_context,
209
+ # Codex cuts hook context at about 2,500 tokens (docs/research/2026-10-codex-memory.md);
210
+ # 9,000 characters stays under that even with multi-byte text.
211
+ session_context_limit=9_000,
212
+ probe_payload={"hook_event_name": "Stop", "session_id": "doctor"},
213
+ )
214
+
215
+
216
+ class CodexHooksAdapter(SpecAdapter):
217
+ """Convert Codex command-hook payloads into Event Protocol events."""
218
+
219
+ SPEC = SPEC
220
+
221
+
222
+ def init(
223
+ project_dir: Path,
224
+ connectors: list[str],
225
+ *,
226
+ hook_command: str | None = None,
227
+ timeout: int = 30,
228
+ force: bool = False,
229
+ ) -> None:
230
+ """Generate private Codex ``config.toml`` and ``hooks.json`` files.
231
+
232
+ Raises:
233
+ FileExistsError: if either file exists and *force* is false.
234
+ """
235
+ config_path = project_dir / ".codex" / "config.toml"
236
+ hooks_path = project_dir / _HOOKS_PATH
237
+ existing = [path for path in (config_path, hooks_path) if path.exists()]
238
+ if existing and not force:
239
+ names = ", ".join(str(path) for path in existing)
240
+ raise FileExistsError(f"Refusing to overwrite existing Codex config: {names} (pass force=True to replace)")
241
+
242
+ write_hook_config(SPEC, project_dir, connectors, hook_command=hook_command, timeout=timeout, force=True)
243
+ write_text_keeping_newlines(config_path, "[features]\nhooks = true\n")
244
+ print(f"Wrote {config_path}")
245
+ print(f"Wrote {hooks_path}")
246
+
247
+
248
+ PLUGIN = SpecPlugin(SPEC, CodexHooksAdapter, init=init, native_memory=CODEX_MEMORY)
@@ -19,6 +19,7 @@ from memgraph_context_graph.adapters._spec import (
19
19
  tool_start,
20
20
  turn_end,
21
21
  )
22
+ from memgraph_context_graph.adapters._text_file import write_text_keeping_newlines
22
23
  from memgraph_context_graph.adapters.agent_memory import NO_BUILT_IN_MEMORY
23
24
  from memgraph_context_graph.events import ErrorOccurredEvent, MessageEvent, ToolEndEvent
24
25
 
@@ -162,7 +163,7 @@ def init(
162
163
  )
163
164
  plugin = template.replace("__MEMGRAPH_CONTEXT_GRAPH_COMMAND__", json.dumps(argv))
164
165
  target.parent.mkdir(parents=True, exist_ok=True)
165
- target.write_text(plugin, encoding="utf-8")
166
+ write_text_keeping_newlines(target, plugin)
166
167
  print(f"Wrote {target}")
167
168
 
168
169
 
@@ -311,7 +311,9 @@ def _join(argv: list[str]) -> int:
311
311
  """Point the config file at an Enterprise Memgraph as one login, then prove the login works."""
312
312
  import getpass
313
313
 
314
- from memgraph_context_graph.adapters._identity import config_file, reload_config, write_config
314
+ import neo4j
315
+
316
+ from memgraph_context_graph.adapters._identity import MEMORY_BACKEND_GRAPH, config_file, reload_config, write_config
315
317
  from memgraph_context_graph.spaces import EDITION_ENTERPRISE, SpaceError, spaces_for
316
318
 
317
319
  parser = argparse.ArgumentParser(
@@ -340,9 +342,21 @@ def _join(argv: list[str]) -> int:
340
342
  except (SpaceError, ValueError) as exc:
341
343
  print(f"Wrote {config_file()}, but the login doesn't work yet: {exc}", file=sys.stderr)
342
344
  return 1
345
+ except neo4j.exceptions.Neo4jError as exc:
346
+ refused = "Unauthenticated" in str(exc.code)
347
+ reason = f"Memgraph refused the login {args.login!r}; check its password" if refused else exc.message
348
+ print(f"Wrote {config_file()}, but the login doesn't work yet: {reason}", file=sys.stderr)
349
+ return 1
343
350
  print(f"Wrote {config_file()}: edition = enterprise, login {args.login!r}.")
344
351
  print(f"Personal space: {row['Current']}. Shared spaces: {', '.join(shared) or 'none yet'}.")
345
- print("Next: memgraph-context-graph setup <runtime> (e.g. claude-code) if this machine isn't set up yet.")
352
+ if reload_config().memory_backend == MEMORY_BACKEND_GRAPH:
353
+ print("Your agents' memory tool reaches them under /memories/spaces/<space>/.")
354
+ else:
355
+ # Shared spaces are only reachable through the memory tool, and that is on only with this backend (ADR 0006).
356
+ print(
357
+ "Next: memgraph-context-graph setup <runtime> (e.g. claude-code) --memory-backend context-graph\n"
358
+ " Shared spaces are reached through the memory tool, which only that backend turns on."
359
+ )
346
360
  return 0
347
361
 
348
362
 
@@ -405,6 +419,7 @@ def doctor_checks(runtimes: Sequence[str], connectors: Sequence[str]) -> list[_C
405
419
  # produce one, the config check already says why, and the rest would only repeat it.
406
420
  if _edition_resolves():
407
421
  checks.append(_check_memgraph())
422
+ checks.append(_check_graph_version(connectors))
408
423
  for connector in connectors:
409
424
  checks.append(_check_connector(connector))
410
425
  if any(normalize_connector_name(connector) == "sessions-graph" for connector in connectors):
@@ -503,6 +518,19 @@ def _check_memgraph() -> _CheckResult:
503
518
  _close(db)
504
519
 
505
520
 
521
+ def _check_graph_version(connectors: Sequence[str]) -> _CheckResult:
522
+ """Whether a newer Context Graph has reshaped the graph past what this install can write or read."""
523
+ from memgraph_context_graph import graph_version
524
+
525
+ rules = graph_version.check_configured(fresh=True)
526
+ components = [graph_version.HUB, *(normalize_connector_name(connector) for connector in connectors)]
527
+ refused = [rules.refuse_write(c) for c in components if not rules.may_write(c)]
528
+ refused += [rules.refuse_read(c) for c in components if not rules.may_read(c)]
529
+ if refused:
530
+ return {"name": "graph version", "ok": False, "detail": "; ".join(dict.fromkeys(map(str, refused)))}
531
+ return {"name": "graph version", "ok": True, "detail": "this install can write and read the graph"}
532
+
533
+
506
534
  def _close(db: Any) -> None:
507
535
  if db is not None:
508
536
  db.close()
@@ -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.2",
63
+ "sessions-graph": "sessions-graph[memgraph-context-graph]>=0.12.4",
64
64
  "resources-graph": "resources-graph[memgraph-context-graph]>=0.3.0",
65
65
  }
66
66