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.
- memgraph_context_graph/__init__.py +71 -0
- memgraph_context_graph/adapters/__init__.py +0 -0
- memgraph_context_graph/adapters/_identity.py +520 -0
- memgraph_context_graph/adapters/_opencode_plugin.js +112 -0
- memgraph_context_graph/adapters/_spec.py +441 -0
- memgraph_context_graph/adapters/antigravity_cli.py +152 -0
- memgraph_context_graph/adapters/claude.py +297 -0
- memgraph_context_graph/adapters/claude_code.py +216 -0
- memgraph_context_graph/adapters/claude_code_memory.py +181 -0
- memgraph_context_graph/adapters/codex.py +118 -0
- memgraph_context_graph/adapters/codex_memory.py +185 -0
- memgraph_context_graph/adapters/copilot_cli.py +236 -0
- memgraph_context_graph/adapters/cursor.py +189 -0
- memgraph_context_graph/adapters/grok.py +187 -0
- memgraph_context_graph/adapters/openai.py +240 -0
- memgraph_context_graph/adapters/opencode.py +168 -0
- memgraph_context_graph/cli.py +845 -0
- memgraph_context_graph/events.py +210 -0
- memgraph_context_graph/hooks/__init__.py +1 -0
- memgraph_context_graph/hooks/cli.py +184 -0
- memgraph_context_graph/hooks/runner.py +311 -0
- memgraph_context_graph/hooks/runtime_plugin.py +85 -0
- memgraph_context_graph/link.py +50 -0
- memgraph_context_graph/mcp_server.py +102 -0
- memgraph_context_graph/memory_backend.py +144 -0
- memgraph_context_graph/protocols.py +67 -0
- memgraph_context_graph/spaces.py +214 -0
- memgraph_context_graph/tools.py +130 -0
- memgraph_context_graph-0.5.0.dist-info/METADATA +718 -0
- memgraph_context_graph-0.5.0.dist-info/RECORD +33 -0
- memgraph_context_graph-0.5.0.dist-info/WHEEL +4 -0
- memgraph_context_graph-0.5.0.dist-info/entry_points.txt +11 -0
- 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()
|