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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. memgraph_context_graph/__init__.py +71 -0
  2. memgraph_context_graph/adapters/__init__.py +0 -0
  3. memgraph_context_graph/adapters/_identity.py +520 -0
  4. memgraph_context_graph/adapters/_opencode_plugin.js +112 -0
  5. memgraph_context_graph/adapters/_spec.py +441 -0
  6. memgraph_context_graph/adapters/antigravity_cli.py +152 -0
  7. memgraph_context_graph/adapters/claude.py +297 -0
  8. memgraph_context_graph/adapters/claude_code.py +216 -0
  9. memgraph_context_graph/adapters/claude_code_memory.py +181 -0
  10. memgraph_context_graph/adapters/codex.py +118 -0
  11. memgraph_context_graph/adapters/codex_memory.py +185 -0
  12. memgraph_context_graph/adapters/copilot_cli.py +236 -0
  13. memgraph_context_graph/adapters/cursor.py +189 -0
  14. memgraph_context_graph/adapters/grok.py +187 -0
  15. memgraph_context_graph/adapters/openai.py +240 -0
  16. memgraph_context_graph/adapters/opencode.py +168 -0
  17. memgraph_context_graph/cli.py +845 -0
  18. memgraph_context_graph/events.py +210 -0
  19. memgraph_context_graph/hooks/__init__.py +1 -0
  20. memgraph_context_graph/hooks/cli.py +184 -0
  21. memgraph_context_graph/hooks/runner.py +311 -0
  22. memgraph_context_graph/hooks/runtime_plugin.py +85 -0
  23. memgraph_context_graph/link.py +50 -0
  24. memgraph_context_graph/mcp_server.py +102 -0
  25. memgraph_context_graph/memory_backend.py +144 -0
  26. memgraph_context_graph/protocols.py +67 -0
  27. memgraph_context_graph/spaces.py +214 -0
  28. memgraph_context_graph/tools.py +130 -0
  29. memgraph_context_graph-0.5.0.dist-info/METADATA +718 -0
  30. memgraph_context_graph-0.5.0.dist-info/RECORD +33 -0
  31. memgraph_context_graph-0.5.0.dist-info/WHEEL +4 -0
  32. memgraph_context_graph-0.5.0.dist-info/entry_points.txt +11 -0
  33. memgraph_context_graph-0.5.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,181 @@
1
+ """Claude Code's auto memory, as Context Graph switches away from and imports it.
2
+
3
+ Auto memory is Markdown files in ``~/.claude/projects/<project>/memory/``, one
4
+ folder per project. ``<project>`` is the project's absolute path with every
5
+ character other than a letter or digit replaced by ``-``. ``MEMORY.md`` there
6
+ is the index Claude Code loads at session start; the other files are
7
+ memories, each with frontmatter naming its ``type``. The ``autoMemoryEnabled``
8
+ setting turns it off; a plugin can't set it, so this edits the user's own
9
+ ``~/.claude/settings.json``.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import re
16
+ import shutil
17
+ from dataclasses import dataclass
18
+ from pathlib import Path
19
+ from typing import TYPE_CHECKING, Any
20
+
21
+ from memgraph_context_graph.memory_backend import ImportReport, MemoryBackendError
22
+
23
+ if TYPE_CHECKING:
24
+ from memgraph_context_graph.memory_backend import MemoryFiles
25
+
26
+ _SETTING = "autoMemoryEnabled"
27
+ _INDEX_FILE = "MEMORY.md"
28
+ _BACKUP_SUFFIX = ".context-graph.bak"
29
+ _TYPE_RE = re.compile(r"^\s*type:\s*[\"']?(?P<type>[A-Za-z]+)", re.MULTILINE)
30
+ _PREFIX_RE = re.compile(r"^(user|feedback|project|reference)_")
31
+ _SLUG_RE = re.compile(r"[^A-Za-z0-9]")
32
+ #: Types about the user, which apply in every project; the rest are about one project.
33
+ _USER_WIDE_TYPES = frozenset({"user", "feedback"})
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class ClaudeCodeMemory:
38
+ """Claude Code's auto memory (``NativeMemory``)."""
39
+
40
+ def enabled(self, home: Path) -> bool:
41
+ """On unless a user-level setting turns it off."""
42
+ settings = _read_settings(_settings_path(home))
43
+ return settings.get(_SETTING) is not False
44
+
45
+ def disable(self, home: Path) -> list[str]:
46
+ """Set ``autoMemoryEnabled: false`` in ``~/.claude/settings.json``, keeping every other setting.
47
+
48
+ Raises:
49
+ MemoryBackendError: the settings file isn't a JSON object.
50
+ """
51
+ return _set(_settings_path(home), False)
52
+
53
+ def enable(self, home: Path) -> list[str]:
54
+ """Drop the ``autoMemoryEnabled: false`` that :meth:`disable` wrote.
55
+
56
+ Raises:
57
+ MemoryBackendError: the settings file isn't a JSON object.
58
+ """
59
+ return _set(_settings_path(home), None)
60
+
61
+ def import_into(self, files: MemoryFiles, home: Path) -> ImportReport:
62
+ """Copy every project's auto-memory files into the graph's ``/memories`` tree.
63
+
64
+ ``user``/``feedback`` memories go under ``/memories/<type>/``, since they
65
+ apply everywhere; ``project``/``reference`` (and untyped) ones under
66
+ that project's ``/memories/projects/<key>/``, keyed as the session-start
67
+ index keys the checkout. ``MEMORY.md`` is skipped: the index is
68
+ generated now. The original files are left as they are.
69
+ """
70
+ from sessions_graph.memory_index import project_key_for
71
+
72
+ report = ImportReport()
73
+ for memory_dir in sorted((home / ".claude" / "projects").glob("*/memory")):
74
+ checkout = _checkout_for(memory_dir.parent.name)
75
+ project = (project_key_for(checkout) if checkout else None) or _fallback_key(memory_dir.parent.name)
76
+ for source in sorted(memory_dir.glob("*.md")):
77
+ if source.name == _INDEX_FILE:
78
+ continue
79
+ text = source.read_text(encoding="utf-8")
80
+ if not text.strip():
81
+ continue
82
+ target = _target_path(source.name, text, project)
83
+ if _exists(files, target):
84
+ report.skipped.append(target)
85
+ continue
86
+ try:
87
+ files.create(target, text)
88
+ except Exception: # Too large, or the path is taken by a directory: keep going.
89
+ report.skipped.append(target)
90
+ continue
91
+ report.imported.append(target)
92
+ return report
93
+
94
+
95
+ def _target_path(filename: str, text: str, project: str) -> str:
96
+ match = _TYPE_RE.search(text.split("\n---", 1)[0]) if text.startswith("---") else None
97
+ memory_type = match["type"].lower() if match else "project"
98
+ name = _PREFIX_RE.sub("", filename)
99
+ if memory_type in _USER_WIDE_TYPES:
100
+ return f"/memories/{memory_type}/{name}"
101
+ return f"/memories/projects/{project}/{name}"
102
+
103
+
104
+ def _exists(files: MemoryFiles, path: str) -> bool:
105
+ try:
106
+ files.view(path)
107
+ except Exception:
108
+ return False
109
+ return True
110
+
111
+
112
+ def _checkout_for(slug: str) -> Path | None:
113
+ """The directory Claude Code named *slug*, found by walking the filesystem from ``/``.
114
+
115
+ The slug is lossy: ``/repos/ai-toolkit`` and ``/repos/ai/toolkit`` give
116
+ the same one. So each step matches the directory's real entries by their
117
+ own slugs, preferring the longest, and backtracks when a path dead-ends.
118
+ """
119
+ tokens = slug.lstrip("-").split("-")
120
+ return _walk(Path("/"), tokens) if tokens and tokens[0] else None
121
+
122
+
123
+ def _walk(directory: Path, tokens: list[str]) -> Path | None:
124
+ if not tokens:
125
+ return directory
126
+ try:
127
+ entries = [entry for entry in directory.iterdir() if entry.is_dir()]
128
+ except OSError:
129
+ return None
130
+ by_slug: dict[str, Path] = {}
131
+ for entry in entries:
132
+ by_slug.setdefault(_SLUG_RE.sub("-", entry.name), entry)
133
+ for width in range(len(tokens), 0, -1):
134
+ entry = by_slug.get("-".join(tokens[:width]))
135
+ if entry is not None and (found := _walk(entry, tokens[width:])) is not None:
136
+ return found
137
+ return None
138
+
139
+
140
+ def _fallback_key(slug: str) -> str:
141
+ """A key for a project whose checkout is gone: the whole slug, since its last part alone may collide."""
142
+ from sessions_graph.memory_index import safe_key
143
+
144
+ return safe_key(slug.strip("-"))
145
+
146
+
147
+ def _settings_path(home: Path) -> Path:
148
+ return home / ".claude" / "settings.json"
149
+
150
+
151
+ def _read_settings(path: Path) -> dict[str, Any]:
152
+ if not path.exists():
153
+ return {}
154
+ try:
155
+ settings = json.loads(path.read_text(encoding="utf-8") or "{}")
156
+ except json.JSONDecodeError as exc:
157
+ raise MemoryBackendError(f"{path} is not valid JSON ({exc}); fix it, then re-run.") from exc
158
+ if not isinstance(settings, dict):
159
+ raise MemoryBackendError(f"{path} must hold a JSON object; fix it, then re-run.")
160
+ return settings
161
+
162
+
163
+ def _set(path: Path, value: bool | None) -> list[str]:
164
+ """Write ``autoMemoryEnabled`` (None removes it), backing the file up once before the first change."""
165
+ settings = _read_settings(path)
166
+ if settings.get(_SETTING) == value or (value is None and _SETTING not in settings):
167
+ return [f"Unchanged {path}"]
168
+ if value is None:
169
+ settings.pop(_SETTING)
170
+ else:
171
+ settings[_SETTING] = value
172
+ backup = path.with_name(path.name + _BACKUP_SUFFIX)
173
+ if path.exists() and not backup.exists():
174
+ shutil.copy2(path, backup)
175
+ path.parent.mkdir(parents=True, exist_ok=True)
176
+ path.write_text(json.dumps(settings, indent=2) + "\n", encoding="utf-8")
177
+ state = "removed" if value is None else json.dumps(value)
178
+ return [f"Wrote {path} ({_SETTING} {state})"]
179
+
180
+
181
+ CLAUDE_CODE_MEMORY = ClaudeCodeMemory()
@@ -0,0 +1,118 @@
1
+ """OpenAI Codex command-hook runtime adapter."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ from memgraph_context_graph.adapters._spec import (
8
+ EventContext,
9
+ HookConfig,
10
+ RuntimeSpec,
11
+ SpecAdapter,
12
+ SpecPlugin,
13
+ pre_tool_use_context,
14
+ session_start,
15
+ tool_end,
16
+ tool_start,
17
+ turn_end,
18
+ write_hook_config,
19
+ )
20
+ from memgraph_context_graph.adapters.codex_memory import CODEX_MEMORY
21
+ from memgraph_context_graph.events import MessageEvent
22
+
23
+ if TYPE_CHECKING:
24
+ from pathlib import Path
25
+
26
+ from memgraph_context_graph.events import Event
27
+
28
+
29
+ def _user_prompt(context: EventContext) -> MessageEvent:
30
+ return MessageEvent(
31
+ **context.base(),
32
+ role="user",
33
+ content=context.value("prompt", ""),
34
+ model=context.optional_text("model"),
35
+ )
36
+
37
+
38
+ def _permission(context: EventContext) -> MessageEvent:
39
+ return MessageEvent(**context.base(), role="system", content=context.text("tool_name", "permission_request"))
40
+
41
+
42
+ def _turn_end(context: EventContext) -> list[Event]:
43
+ # Codex has no session-end hook, and Stop fires after every turn, so it is
44
+ # only ever a turn end.
45
+ return turn_end(context, reply=context.payload.get("last_assistant_message"))
46
+
47
+
48
+ _HOOKS_PATH = ".codex/hooks.json"
49
+ SPEC = RuntimeSpec(
50
+ name="codex",
51
+ source_sdk="codex",
52
+ event_key="hook_event_name",
53
+ hooks=("SessionStart", "UserPromptSubmit", "PreToolUse", "PostToolUse", "PermissionRequest", "Stop"),
54
+ rules={
55
+ "SessionStart": session_start,
56
+ "UserPromptSubmit": _user_prompt,
57
+ "PreToolUse": tool_start,
58
+ "PostToolUse": tool_end,
59
+ "PermissionRequest": _permission,
60
+ "Stop": _turn_end,
61
+ },
62
+ metadata_keys=(
63
+ "cwd",
64
+ "source",
65
+ "transcript_path",
66
+ "turn_id",
67
+ "permission_mode",
68
+ "tool_name",
69
+ "tool_input",
70
+ "tool_use_id",
71
+ "reason",
72
+ "decision",
73
+ "stop_hook_active",
74
+ ),
75
+ config=HookConfig(
76
+ path=_HOOKS_PATH,
77
+ layout="nested",
78
+ matchers={"SessionStart": "startup|resume|clear|compact", "PreToolUse": "*", "PostToolUse": "*"},
79
+ ),
80
+ responses={"Stop": {"continue": True}},
81
+ context_before_tool=pre_tool_use_context,
82
+ probe_payload={"hook_event_name": "Stop", "session_id": "doctor"},
83
+ )
84
+
85
+
86
+ class CodexHooksAdapter(SpecAdapter):
87
+ """Convert Codex command-hook payloads into Event Protocol events."""
88
+
89
+ SPEC = SPEC
90
+
91
+
92
+ def init(
93
+ project_dir: Path,
94
+ connectors: list[str],
95
+ *,
96
+ hook_command: str | None = None,
97
+ timeout: int = 30,
98
+ force: bool = False,
99
+ ) -> None:
100
+ """Generate private Codex ``config.toml`` and ``hooks.json`` files.
101
+
102
+ Raises:
103
+ FileExistsError: if either file exists and *force* is false.
104
+ """
105
+ config_path = project_dir / ".codex" / "config.toml"
106
+ hooks_path = project_dir / _HOOKS_PATH
107
+ existing = [path for path in (config_path, hooks_path) if path.exists()]
108
+ if existing and not force:
109
+ names = ", ".join(str(path) for path in existing)
110
+ raise FileExistsError(f"Refusing to overwrite existing Codex config: {names} (pass force=True to replace)")
111
+
112
+ write_hook_config(SPEC, project_dir, connectors, hook_command=hook_command, timeout=timeout, force=True)
113
+ config_path.write_text("[features]\nhooks = true\n", encoding="utf-8")
114
+ print(f"Wrote {config_path}")
115
+ print(f"Wrote {hooks_path}")
116
+
117
+
118
+ PLUGIN = SpecPlugin(SPEC, CodexHooksAdapter, init=init, native_memory=CODEX_MEMORY)
@@ -0,0 +1,185 @@
1
+ """Codex's built-in memories, as Context Graph switches away from and imports them.
2
+
3
+ Codex keeps one global memory store in ``$CODEX_HOME/memories/`` (default
4
+ ``~/.codex``), written by its own background pipeline. Only
5
+ ``memory_summary.md`` is injected into a session; the model opens
6
+ ``MEMORY.md`` and the rest on demand. Memories are injected only while both
7
+ ``[features] memories`` (off by default) and ``[memories] use_memories`` are
8
+ on, and ``generate_memories`` controls whether new threads feed the pipeline.
9
+
10
+ Switching to Context Graph turns all three off in the user-level
11
+ ``config.toml``, the same scope ``[memory] backend`` has. Handing memory back
12
+ restores whatever they were before. The plugin's MCP server allows every tool
13
+ unless the user's own ``[mcp_servers.context-graph]`` narrows it with
14
+ ``enabled_tools``; then ``memory`` is added there too.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import os
20
+ import shutil
21
+ from dataclasses import dataclass
22
+ from pathlib import Path
23
+ from typing import TYPE_CHECKING, Any
24
+
25
+ from memgraph_context_graph.memory_backend import ImportReport, MemoryBackendError
26
+
27
+ if TYPE_CHECKING:
28
+ from memgraph_context_graph.memory_backend import MemoryFiles
29
+
30
+ _BACKUP_SUFFIX = ".context-graph.bak"
31
+ _SERVER = "context-graph"
32
+ #: (table, key) pairs this switch owns, and the value that turns each off.
33
+ _SWITCHES = (
34
+ (("features", "memories"), False),
35
+ (("memories", "use_memories"), False),
36
+ (("memories", "generate_memories"), False),
37
+ )
38
+ #: Files a Codex memory store may hold, under v1's ``memories/`` or v2's ``memories_v2/``,
39
+ #: and where each lands in ``/memories``. Codex's store is global, so nothing is per project.
40
+ _IMPORTS = (
41
+ ("memory_summary.md", "/memories/user/codex-memory-summary.md", "What Codex had learned about the user", "user"),
42
+ ("MEMORY.md", "/memories/codex/memory.md", "Codex's task-by-task memory, as it consolidated it", "reference"),
43
+ )
44
+
45
+
46
+ @dataclass(frozen=True)
47
+ class CodexMemory:
48
+ """Codex's built-in memories (``NativeMemory``)."""
49
+
50
+ def enabled(self, home: Path) -> bool:
51
+ """Whether Codex would inject its memories: ``features.memories`` on and ``use_memories`` not off."""
52
+ config = _read(_config_path(home))
53
+ return (
54
+ bool(_get(config, ("features", "memories"), False))
55
+ and _get(config, ("memories", "use_memories"), True) is not False
56
+ )
57
+
58
+ def disable(self, home: Path) -> list[str]:
59
+ """Turn Codex's memories off in its user ``config.toml`` and let the ``memory`` tool through.
60
+
61
+ Raises:
62
+ MemoryBackendError: the config file isn't valid TOML.
63
+ """
64
+ path = _config_path(home)
65
+ config = _read(path)
66
+ for keys, off in _SWITCHES:
67
+ _set(config, keys, off)
68
+ server = config.get("mcp_servers", {}).get(_SERVER)
69
+ if (
70
+ server is not None
71
+ and isinstance(server.get("enabled_tools"), list)
72
+ and "memory" not in server["enabled_tools"]
73
+ ):
74
+ server["enabled_tools"].append("memory")
75
+ return _write(path, config)
76
+
77
+ def enable(self, home: Path) -> list[str]:
78
+ """Restore the memory keys to what they were before :meth:`disable` first ran.
79
+
80
+ Keys that didn't exist then are removed, which leaves Codex's own
81
+ defaults (``features.memories`` is off by default).
82
+
83
+ Raises:
84
+ MemoryBackendError: the config file or its backup isn't valid TOML.
85
+ """
86
+ path = _config_path(home)
87
+ backup = path.with_name(path.name + _BACKUP_SUFFIX)
88
+ before = _read(backup) if backup.exists() else {}
89
+ config = _read(path)
90
+ for keys, _off in _SWITCHES:
91
+ previous = _get(before, keys, None)
92
+ if previous is None:
93
+ _remove(config, keys)
94
+ else:
95
+ _set(config, keys, previous)
96
+ return _write(path, config)
97
+
98
+ def import_into(self, files: MemoryFiles, home: Path) -> ImportReport:
99
+ """Copy Codex's consolidated memories into ``/memories``, never overwriting a path.
100
+
101
+ The summary Codex injected each session becomes a ``user`` memory;
102
+ its task-group ``MEMORY.md`` a ``reference`` file the model can open.
103
+ Rollout summaries and raw memories are Codex's intermediate state and
104
+ stay where they are.
105
+ """
106
+ report = ImportReport()
107
+ codex_home = _codex_home(home)
108
+ for store in ("memories", "memories_v2"):
109
+ for source_name, target, description, memory_type in _IMPORTS:
110
+ source = codex_home / store / source_name
111
+ if not source.is_file() or not (text := source.read_text(encoding="utf-8")).strip():
112
+ continue
113
+ if store == "memories_v2":
114
+ target = target.replace(".md", "-v2.md")
115
+ try:
116
+ files.view(target)
117
+ except Exception:
118
+ try:
119
+ files.create(target, f"---\ndescription: {description}\ntype: {memory_type}\n---\n{text}")
120
+ except Exception: # Too large for one memory file: leave it in Codex's store.
121
+ report.skipped.append(target)
122
+ continue
123
+ report.imported.append(target)
124
+ continue
125
+ report.skipped.append(target)
126
+ return report
127
+
128
+
129
+ def _codex_home(home: Path) -> Path:
130
+ override = os.environ.get("CODEX_HOME")
131
+ return Path(override) if override else home / ".codex"
132
+
133
+
134
+ def _config_path(home: Path) -> Path:
135
+ return _codex_home(home) / "config.toml"
136
+
137
+
138
+ def _read(path: Path) -> Any:
139
+ import tomlkit
140
+ from tomlkit.exceptions import ParseError
141
+
142
+ if not path.exists():
143
+ return tomlkit.document()
144
+ try:
145
+ return tomlkit.parse(path.read_text(encoding="utf-8"))
146
+ except ParseError as exc:
147
+ raise MemoryBackendError(f"{path} is not valid TOML ({exc}); fix it, then re-run.") from exc
148
+
149
+
150
+ def _get(config: Any, keys: tuple[str, str], default: Any) -> Any:
151
+ table = config.get(keys[0])
152
+ return table.get(keys[1], default) if hasattr(table, "get") else default
153
+
154
+
155
+ def _set(config: Any, keys: tuple[str, str], value: Any) -> None:
156
+ import tomlkit
157
+
158
+ table = config.get(keys[0])
159
+ if not hasattr(table, "get"):
160
+ table = tomlkit.table()
161
+ config[keys[0]] = table
162
+ table[keys[1]] = value
163
+
164
+
165
+ def _remove(config: Any, keys: tuple[str, str]) -> None:
166
+ table = config.get(keys[0])
167
+ if hasattr(table, "get") and keys[1] in table:
168
+ del table[keys[1]]
169
+
170
+
171
+ def _write(path: Path, config: Any) -> list[str]:
172
+ import tomlkit
173
+
174
+ rendered = tomlkit.dumps(config)
175
+ if path.exists() and path.read_text(encoding="utf-8") == rendered:
176
+ return [f"Unchanged {path}"]
177
+ backup = path.with_name(path.name + _BACKUP_SUFFIX)
178
+ if path.exists() and not backup.exists():
179
+ shutil.copy2(path, backup)
180
+ path.parent.mkdir(parents=True, exist_ok=True)
181
+ path.write_text(rendered, encoding="utf-8")
182
+ return [f"Wrote {path}"]
183
+
184
+
185
+ CODEX_MEMORY = CodexMemory()