memgraph-context-graph 0.12.3__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.3 → memgraph_context_graph-0.12.4}/PKG-INFO +1 -1
  2. {memgraph_context_graph-0.12.3 → memgraph_context_graph-0.12.4}/docs/memory.md +20 -4
  3. {memgraph_context_graph-0.12.3 → memgraph_context_graph-0.12.4}/pyproject.toml +1 -1
  4. {memgraph_context_graph-0.12.3 → memgraph_context_graph-0.12.4}/src/memgraph_context_graph/adapters/_identity.py +2 -1
  5. {memgraph_context_graph-0.12.3 → memgraph_context_graph-0.12.4}/src/memgraph_context_graph/adapters/_spec.py +12 -3
  6. memgraph_context_graph-0.12.4/src/memgraph_context_graph/adapters/_text_file.py +23 -0
  7. {memgraph_context_graph-0.12.3 → memgraph_context_graph-0.12.4}/src/memgraph_context_graph/adapters/agent_mcp.py +2 -1
  8. {memgraph_context_graph-0.12.3 → memgraph_context_graph-0.12.4}/src/memgraph_context_graph/adapters/claude_code_memory.py +26 -10
  9. memgraph_context_graph-0.12.4/src/memgraph_context_graph/adapters/codex.py +248 -0
  10. {memgraph_context_graph-0.12.3 → memgraph_context_graph-0.12.4}/src/memgraph_context_graph/adapters/opencode.py +2 -1
  11. {memgraph_context_graph-0.12.3 → memgraph_context_graph-0.12.4}/src/memgraph_context_graph/cli.py +16 -2
  12. {memgraph_context_graph-0.12.3 → memgraph_context_graph-0.12.4}/src/memgraph_context_graph/connectors.py +1 -1
  13. {memgraph_context_graph-0.12.3 → memgraph_context_graph-0.12.4}/src/memgraph_context_graph/hooks/runner.py +27 -4
  14. {memgraph_context_graph-0.12.3 → memgraph_context_graph-0.12.4}/src/memgraph_context_graph/memory_backend.py +5 -1
  15. {memgraph_context_graph-0.12.3 → memgraph_context_graph-0.12.4}/src/memgraph_context_graph/setup/tool.py +58 -8
  16. {memgraph_context_graph-0.12.3 → memgraph_context_graph-0.12.4}/src/memgraph_context_graph/tools.py +40 -15
  17. {memgraph_context_graph-0.12.3 → memgraph_context_graph-0.12.4}/tests/test_codex_adapter.py +107 -0
  18. {memgraph_context_graph-0.12.3 → memgraph_context_graph-0.12.4}/tests/test_setup.py +39 -0
  19. memgraph_context_graph-0.12.4/tests/test_text_file.py +38 -0
  20. {memgraph_context_graph-0.12.3 → memgraph_context_graph-0.12.4}/tests/test_tools.py +58 -0
  21. memgraph_context_graph-0.12.3/src/memgraph_context_graph/adapters/codex.py +0 -121
  22. {memgraph_context_graph-0.12.3 → memgraph_context_graph-0.12.4}/.gitignore +0 -0
  23. {memgraph_context_graph-0.12.3 → memgraph_context_graph-0.12.4}/CONTEXT.md +0 -0
  24. {memgraph_context_graph-0.12.3 → memgraph_context_graph-0.12.4}/LICENSE +0 -0
  25. {memgraph_context_graph-0.12.3 → memgraph_context_graph-0.12.4}/README.md +0 -0
  26. {memgraph_context_graph-0.12.3 → memgraph_context_graph-0.12.4}/docs/adr/0001-runtime-adapter-terminology.md +0 -0
  27. {memgraph_context_graph-0.12.3 → memgraph_context_graph-0.12.4}/docs/adr/0002-config-file-only-hook-resolution.md +0 -0
  28. {memgraph_context_graph-0.12.3 → memgraph_context_graph-0.12.4}/docs/adr/0003-config-path-override.md +0 -0
  29. {memgraph_context_graph-0.12.3 → memgraph_context_graph-0.12.4}/docs/adr/0004-user-id-in-session-start-event.md +0 -0
  30. {memgraph_context_graph-0.12.3 → memgraph_context_graph-0.12.4}/docs/adr/0005-config-file-covers-llm-key-and-subprocess-env.md +0 -0
  31. {memgraph_context_graph-0.12.3 → memgraph_context_graph-0.12.4}/docs/adr/0006-memory-tool-writes-explicit-memory.md +0 -0
  32. {memgraph_context_graph-0.12.3 → memgraph_context_graph-0.12.4}/docs/adr/0007-spawned-processes-read-the-config-file-themselves.md +0 -0
  33. {memgraph_context_graph-0.12.3 → memgraph_context_graph-0.12.4}/docs/command-hooks.md +0 -0
  34. {memgraph_context_graph-0.12.3 → memgraph_context_graph-0.12.4}/docs/configuration.md +0 -0
  35. {memgraph_context_graph-0.12.3 → memgraph_context_graph-0.12.4}/docs/extending.md +0 -0
  36. {memgraph_context_graph-0.12.3 → memgraph_context_graph-0.12.4}/docs/research/2026-10-codex-memory.md +0 -0
  37. {memgraph_context_graph-0.12.3 → memgraph_context_graph-0.12.4}/docs/runtime-setup.md +0 -0
  38. {memgraph_context_graph-0.12.3 → memgraph_context_graph-0.12.4}/docs/sdk-memory.md +0 -0
  39. {memgraph_context_graph-0.12.3 → memgraph_context_graph-0.12.4}/examples/sdk_claude_agent.py +0 -0
  40. {memgraph_context_graph-0.12.3 → memgraph_context_graph-0.12.4}/examples/sdk_openai_agents.py +0 -0
  41. {memgraph_context_graph-0.12.3 → memgraph_context_graph-0.12.4}/src/memgraph_context_graph/__init__.py +0 -0
  42. {memgraph_context_graph-0.12.3 → memgraph_context_graph-0.12.4}/src/memgraph_context_graph/adapters/__init__.py +0 -0
  43. {memgraph_context_graph-0.12.3 → memgraph_context_graph-0.12.4}/src/memgraph_context_graph/adapters/_opencode_plugin.js +0 -0
  44. {memgraph_context_graph-0.12.3 → memgraph_context_graph-0.12.4}/src/memgraph_context_graph/adapters/agent_memory.py +0 -0
  45. {memgraph_context_graph-0.12.3 → memgraph_context_graph-0.12.4}/src/memgraph_context_graph/adapters/antigravity_cli.py +0 -0
  46. {memgraph_context_graph-0.12.3 → memgraph_context_graph-0.12.4}/src/memgraph_context_graph/adapters/claude_code.py +0 -0
  47. {memgraph_context_graph-0.12.3 → memgraph_context_graph-0.12.4}/src/memgraph_context_graph/adapters/codex_memory.py +0 -0
  48. {memgraph_context_graph-0.12.3 → memgraph_context_graph-0.12.4}/src/memgraph_context_graph/adapters/copilot_cli.py +0 -0
  49. {memgraph_context_graph-0.12.3 → memgraph_context_graph-0.12.4}/src/memgraph_context_graph/adapters/cursor.py +0 -0
  50. {memgraph_context_graph-0.12.3 → memgraph_context_graph-0.12.4}/src/memgraph_context_graph/adapters/grok.py +0 -0
  51. {memgraph_context_graph-0.12.3 → memgraph_context_graph-0.12.4}/src/memgraph_context_graph/capture_log.py +0 -0
  52. {memgraph_context_graph-0.12.3 → memgraph_context_graph-0.12.4}/src/memgraph_context_graph/capture_policy.py +0 -0
  53. {memgraph_context_graph-0.12.3 → memgraph_context_graph-0.12.4}/src/memgraph_context_graph/config_schema.py +0 -0
  54. {memgraph_context_graph-0.12.3 → memgraph_context_graph-0.12.4}/src/memgraph_context_graph/events.py +0 -0
  55. {memgraph_context_graph-0.12.3 → memgraph_context_graph-0.12.4}/src/memgraph_context_graph/forget.py +0 -0
  56. {memgraph_context_graph-0.12.3 → memgraph_context_graph-0.12.4}/src/memgraph_context_graph/graph_version.py +0 -0
  57. {memgraph_context_graph-0.12.3 → memgraph_context_graph-0.12.4}/src/memgraph_context_graph/hooks/__init__.py +0 -0
  58. {memgraph_context_graph-0.12.3 → memgraph_context_graph-0.12.4}/src/memgraph_context_graph/hooks/cli.py +0 -0
  59. {memgraph_context_graph-0.12.3 → memgraph_context_graph-0.12.4}/src/memgraph_context_graph/hooks/runtime_plugin.py +0 -0
  60. {memgraph_context_graph-0.12.3 → memgraph_context_graph-0.12.4}/src/memgraph_context_graph/link.py +0 -0
  61. {memgraph_context_graph-0.12.3 → memgraph_context_graph-0.12.4}/src/memgraph_context_graph/mcp_server.py +0 -0
  62. {memgraph_context_graph-0.12.3 → memgraph_context_graph-0.12.4}/src/memgraph_context_graph/protocols.py +0 -0
  63. {memgraph_context_graph-0.12.3 → memgraph_context_graph-0.12.4}/src/memgraph_context_graph/refused_events.py +0 -0
  64. {memgraph_context_graph-0.12.3 → memgraph_context_graph-0.12.4}/src/memgraph_context_graph/sdk/__init__.py +0 -0
  65. {memgraph_context_graph-0.12.3 → memgraph_context_graph-0.12.4}/src/memgraph_context_graph/sdk/claude.py +0 -0
  66. {memgraph_context_graph-0.12.3 → memgraph_context_graph-0.12.4}/src/memgraph_context_graph/sdk/openai.py +0 -0
  67. {memgraph_context_graph-0.12.3 → memgraph_context_graph-0.12.4}/src/memgraph_context_graph/session.py +0 -0
  68. {memgraph_context_graph-0.12.3 → memgraph_context_graph-0.12.4}/src/memgraph_context_graph/setup/__init__.py +0 -0
  69. {memgraph_context_graph-0.12.3 → memgraph_context_graph-0.12.4}/src/memgraph_context_graph/setup/agents.py +0 -0
  70. {memgraph_context_graph-0.12.3 → memgraph_context_graph-0.12.4}/src/memgraph_context_graph/setup/cli.py +0 -0
  71. {memgraph_context_graph-0.12.3 → memgraph_context_graph-0.12.4}/src/memgraph_context_graph/setup/engine.py +0 -0
  72. {memgraph_context_graph-0.12.3 → memgraph_context_graph-0.12.4}/src/memgraph_context_graph/setup/memgraph.py +0 -0
  73. {memgraph_context_graph-0.12.3 → memgraph_context_graph-0.12.4}/src/memgraph_context_graph/setup/prompts.py +0 -0
  74. {memgraph_context_graph-0.12.3 → memgraph_context_graph-0.12.4}/src/memgraph_context_graph/setup/uninstall.py +0 -0
  75. {memgraph_context_graph-0.12.3 → memgraph_context_graph-0.12.4}/src/memgraph_context_graph/setup/update.py +0 -0
  76. {memgraph_context_graph-0.12.3 → memgraph_context_graph-0.12.4}/src/memgraph_context_graph/spaces.py +0 -0
  77. {memgraph_context_graph-0.12.3 → memgraph_context_graph-0.12.4}/src/memgraph_context_graph/terminal.py +0 -0
  78. {memgraph_context_graph-0.12.3 → memgraph_context_graph-0.12.4}/tests/__init__.py +0 -0
  79. {memgraph_context_graph-0.12.3 → memgraph_context_graph-0.12.4}/tests/conftest.py +0 -0
  80. {memgraph_context_graph-0.12.3 → memgraph_context_graph-0.12.4}/tests/fake_agent_cli.py +0 -0
  81. {memgraph_context_graph-0.12.3 → memgraph_context_graph-0.12.4}/tests/test_agent_memory_wiring.py +0 -0
  82. {memgraph_context_graph-0.12.3 → memgraph_context_graph-0.12.4}/tests/test_antigravity_cli_adapter.py +0 -0
  83. {memgraph_context_graph-0.12.3 → memgraph_context_graph-0.12.4}/tests/test_capture_log.py +0 -0
  84. {memgraph_context_graph-0.12.3 → memgraph_context_graph-0.12.4}/tests/test_capture_policy.py +0 -0
  85. {memgraph_context_graph-0.12.3 → memgraph_context_graph-0.12.4}/tests/test_claude_code_adapter.py +0 -0
  86. {memgraph_context_graph-0.12.3 → memgraph_context_graph-0.12.4}/tests/test_claude_code_memory.py +0 -0
  87. {memgraph_context_graph-0.12.3 → memgraph_context_graph-0.12.4}/tests/test_codex_memory.py +0 -0
  88. {memgraph_context_graph-0.12.3 → memgraph_context_graph-0.12.4}/tests/test_config_cli.py +0 -0
  89. {memgraph_context_graph-0.12.3 → memgraph_context_graph-0.12.4}/tests/test_connectors.py +0 -0
  90. {memgraph_context_graph-0.12.3 → memgraph_context_graph-0.12.4}/tests/test_context_before_tool.py +0 -0
  91. {memgraph_context_graph-0.12.3 → memgraph_context_graph-0.12.4}/tests/test_copilot_cli_adapter.py +0 -0
  92. {memgraph_context_graph-0.12.3 → memgraph_context_graph-0.12.4}/tests/test_cursor_adapter.py +0 -0
  93. {memgraph_context_graph-0.12.3 → memgraph_context_graph-0.12.4}/tests/test_dev_scripts.py +0 -0
  94. {memgraph_context_graph-0.12.3 → memgraph_context_graph-0.12.4}/tests/test_events.py +0 -0
  95. {memgraph_context_graph-0.12.3 → memgraph_context_graph-0.12.4}/tests/test_graph_version.py +0 -0
  96. {memgraph_context_graph-0.12.3 → memgraph_context_graph-0.12.4}/tests/test_grok_adapter.py +0 -0
  97. {memgraph_context_graph-0.12.3 → memgraph_context_graph-0.12.4}/tests/test_hook_cli.py +0 -0
  98. {memgraph_context_graph-0.12.3 → memgraph_context_graph-0.12.4}/tests/test_identity.py +0 -0
  99. {memgraph_context_graph-0.12.3 → memgraph_context_graph-0.12.4}/tests/test_link.py +0 -0
  100. {memgraph_context_graph-0.12.3 → memgraph_context_graph-0.12.4}/tests/test_opencode_adapter.py +0 -0
  101. {memgraph_context_graph-0.12.3 → memgraph_context_graph-0.12.4}/tests/test_plugin_hooks.py +0 -0
  102. {memgraph_context_graph-0.12.3 → memgraph_context_graph-0.12.4}/tests/test_refused_events.py +0 -0
  103. {memgraph_context_graph-0.12.3 → memgraph_context_graph-0.12.4}/tests/test_runtime_plugin.py +0 -0
  104. {memgraph_context_graph-0.12.3 → memgraph_context_graph-0.12.4}/tests/test_session.py +0 -0
  105. {memgraph_context_graph-0.12.3 → memgraph_context_graph-0.12.4}/tests/test_setup_docker.py +0 -0
  106. {memgraph_context_graph-0.12.3 → memgraph_context_graph-0.12.4}/tests/test_spaces.py +0 -0
  107. {memgraph_context_graph-0.12.3 → memgraph_context_graph-0.12.4}/tests/test_terminal.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: memgraph-context-graph
3
- Version: 0.12.3
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "memgraph-context-graph"
3
- version = "0.12.3"
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
 
@@ -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.3",
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
 
@@ -22,6 +22,7 @@ from memgraph_context_graph.connectors import (
22
22
  from memgraph_context_graph.events import ToolStartEvent
23
23
  from memgraph_context_graph.link import AgentLink, ConnectorFailuresError
24
24
  from memgraph_context_graph.protocols import GraphConnector
25
+ from memgraph_context_graph.tools import SESSION_CONTEXT_CHARS
25
26
 
26
27
  if TYPE_CHECKING:
27
28
  from collections.abc import Callable, Iterable, Sequence
@@ -402,7 +403,12 @@ def _response(
402
403
  response = plugin.response_for_payload(payload) or {}
403
404
  render_session = getattr(plugin, "session_context_response", None)
404
405
  if render_session is not None:
405
- response.update(render_session(payload, lambda: session_start_text(connector_names, payload)) or {})
406
+ limit = getattr(plugin, "session_context_limit", SESSION_CONTEXT_CHARS)
407
+
408
+ def text() -> str | None:
409
+ return session_start_text(connector_names, payload, max_chars=limit)
410
+
411
+ response.update(render_session(payload, text) or {})
406
412
  render = getattr(plugin, "context_before_tool_response", None)
407
413
  if before_tool and render is not None:
408
414
  response.update(render("\n".join(before_tool)) or {})
@@ -437,7 +443,9 @@ def _context_before_tool(link: AgentLink, events: list[ToolStartEvent]) -> list[
437
443
  return lines
438
444
 
439
445
 
440
- def session_start_text(connector_names: list[str], payload: dict[str, Any] | None = None) -> str | None:
446
+ def session_start_text(
447
+ connector_names: list[str], payload: dict[str, Any] | None = None, *, max_chars: int = SESSION_CONTEXT_CHARS
448
+ ) -> str | None:
441
449
  """The session-start text telling the model which memory tools it has; None when there are none.
442
450
 
443
451
  Recall announces itself and adds where the user left off in this project,
@@ -450,13 +458,18 @@ def session_start_text(connector_names: list[str], payload: dict[str, Any] | Non
450
458
  While a newer Context Graph's graph refuses this client, a notice comes first
451
459
  (:func:`~memgraph_context_graph.graph_version.configured_notice`), so the
452
460
  model can tell the user to update.
461
+
462
+ All of it fits in *max_chars*, what the harness shows: the tools fit
463
+ their own text, and anything still over is cut here at a line, with a
464
+ note, rather than by the harness wherever it likes.
453
465
  """
454
466
  from memgraph_context_graph.tools import session_hints
455
467
 
456
468
  notice = graph_version.configured_notice()
457
469
  lines = [notice] if notice else []
458
470
  try:
459
- lines += session_hints(connector_names, payload=_with_cwd(payload or {}))
471
+ room = max_chars - len(notice) - 1 if notice else max_chars
472
+ lines += session_hints(connector_names, payload=_with_cwd(payload or {}), max_chars=room)
460
473
  except Exception as exc: # A tool that fails to load must not fail the hook; the model just isn't told.
461
474
  capture_log.record_failure(
462
475
  "respond",
@@ -465,7 +478,17 @@ def session_start_text(connector_names: list[str], payload: dict[str, Any] | Non
465
478
  hook_event="SessionStart",
466
479
  session_id=_payload_session_id(payload or {}),
467
480
  )
468
- return "\n".join(lines) or None
481
+ return _fit("\n".join(lines), max_chars) or None
482
+
483
+
484
+ def _fit(text: str, max_chars: int) -> str:
485
+ """*text*, or as many of its first lines as fit in *max_chars* with a note that the rest was cut."""
486
+ if len(text) <= max_chars:
487
+ return text
488
+ note = f"[Session-start text cut to the {max_chars:,} characters this agent shows.]"
489
+ kept = text[: max(max_chars - len(note) - 1, 0)]
490
+ whole_lines = kept.rpartition("\n")[0] or kept
491
+ return f"{whole_lines}\n{note}"
469
492
 
470
493
 
471
494
  def _with_cwd(payload: dict[str, Any]) -> dict[str, Any]: