agent-memory-git 0.1.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.
@@ -0,0 +1,231 @@
1
+ """One-off import of the native Claude Code and Codex memories.
2
+
3
+ Native files are only read, never modified. Re-running an import skips
4
+ memories that were already imported (matched on `source.original`).
5
+ """
6
+
7
+ import json
8
+ import os
9
+ import re
10
+ from datetime import datetime
11
+ from functools import lru_cache
12
+ from pathlib import Path
13
+
14
+ from . import frontmatter, gitsync, sessions, workdir
15
+ from .context import detect
16
+ from .store import load_all, memories_dir, normalize, read
17
+ from .util import HOME, atomic_write, id_path, new_id, now, parse_time, slugify
18
+
19
+ CLAUDE_PROJECTS = Path(os.environ.get("CLAUDE_CONFIG_DIR", HOME / ".claude")) / "projects"
20
+ CODEX_MEMORIES = Path(os.environ.get("CODEX_HOME", HOME / ".codex")) / "memories"
21
+ ADHOC_TAG = "[ad-hoc note]"
22
+
23
+
24
+ def _git_source(cwd):
25
+ if not cwd:
26
+ return {}
27
+ ctx = detect(cwd) if os.path.isdir(cwd) else {"cwd": cwd, "host": detect(HOME)["host"]}
28
+ ctx.pop("git_commit", None) # the commit at import time says nothing about the memory
29
+ return ctx
30
+
31
+
32
+ def _record(*, name, typ, scope, description, body, created, source):
33
+ when = parse_time(created) or now()
34
+ memory_id = new_id(name, when)
35
+ meta = {"id": memory_id, "name": slugify(name), "type": typ, "scope": scope,
36
+ "description": " ".join(description.split())[:300], "created": when.isoformat(),
37
+ "source": {k: v for k, v in source.items() if v}}
38
+ return memory_id, meta, body
39
+
40
+
41
+ # ---------------------------------------------------------------- Claude Code
42
+
43
+ def _decode_project_dir(name):
44
+ """Recover a folder from Claude's dash-encoded project dir name, if it still exists."""
45
+ tokens = name.lstrip("-").split("-")
46
+
47
+ @lru_cache(maxsize=None)
48
+ def entries(base):
49
+ try:
50
+ return tuple(os.listdir(base))
51
+ except OSError:
52
+ return ()
53
+
54
+ def walk(i, base, comp):
55
+ if i == len(tokens):
56
+ return str(base / comp) if (base / comp).exists() else None
57
+ tok = tokens[i]
58
+ if (base / comp).is_dir():
59
+ hit = walk(i + 1, base / comp, tok)
60
+ if hit:
61
+ return hit
62
+ for sep in ("-", ".", "_", " "):
63
+ longer = comp + sep + tok
64
+ if any(e.startswith(longer) for e in entries(base)):
65
+ hit = walk(i + 1, base, longer)
66
+ if hit:
67
+ return hit
68
+ return None
69
+
70
+ return walk(1, Path("/"), tokens[0]) if tokens else None
71
+
72
+
73
+ def _transcript_cwd(project_dir, session_id):
74
+ candidates = []
75
+ if session_id:
76
+ candidates.append(project_dir / f"{session_id}.jsonl")
77
+ candidates += sorted(project_dir.glob("*.jsonl"))
78
+ for path in candidates:
79
+ if not path.exists():
80
+ continue
81
+ try:
82
+ with path.open(encoding="utf-8", errors="replace") as f:
83
+ for raw in f:
84
+ if '"cwd"' in raw:
85
+ cwd = json.loads(raw).get("cwd")
86
+ if cwd:
87
+ return cwd, path
88
+ except (OSError, ValueError):
89
+ continue
90
+ return None, None
91
+
92
+
93
+ def claude_items():
94
+ for mem_dir in sorted(CLAUDE_PROJECTS.glob("*/memory")):
95
+ project_dir = mem_dir.parent
96
+ for path in sorted(mem_dir.glob("*.md")):
97
+ if path.name == "MEMORY.md":
98
+ continue
99
+ meta, body = read(path)
100
+ legacy = meta.get("metadata") if isinstance(meta.get("metadata"), dict) else {}
101
+ m = normalize(meta, body, path.stem)
102
+ origin = legacy.get("originSessionId") or meta.get("originSessionId")
103
+ cwd, transcript = _transcript_cwd(project_dir, origin)
104
+ cwd = cwd or _decode_project_dir(project_dir.name)
105
+ original_cwd = None
106
+ if workdir.is_throwaway(cwd):
107
+ inferred = workdir.infer(transcript, cwd, workdir.default_exclude())
108
+ if inferred:
109
+ original_cwd, cwd = cwd, inferred
110
+ created = legacy.get("modified") or meta.get("modified") or _mtime(path)
111
+ title = sessions.title("claude-code", origin, transcript) if transcript else ""
112
+ source = {"tool": "claude-code", "session_id": origin, "session_name": title,
113
+ **_git_source(cwd),
114
+ "original_cwd": original_cwd,
115
+ "imported_from": "claude-code auto memory",
116
+ "original": "~/" + path.relative_to(HOME).as_posix()}
117
+ yield _record(name=m["name"] or path.stem, typ=m["type"],
118
+ scope="global" if m["type"] in ("user", "feedback") else "here",
119
+ description=m["description"], body=body, created=created,
120
+ source=source)
121
+
122
+
123
+ def _mtime(path):
124
+ return datetime.fromtimestamp(path.stat().st_mtime).astimezone().isoformat()
125
+
126
+
127
+ # ---------------------------------------------------------------- Codex
128
+
129
+ def _sections(text):
130
+ out, current = {}, None
131
+ for line in text.splitlines():
132
+ if line.startswith("## "):
133
+ current = line[3:].strip()
134
+ out[current] = []
135
+ elif current:
136
+ out[current].append(line)
137
+ return out
138
+
139
+
140
+ def _bullets(lines):
141
+ items, cur = [], None
142
+ for line in lines:
143
+ if line.startswith("- "):
144
+ cur = [line[2:].strip()]
145
+ items.append(cur)
146
+ elif cur is not None and line.startswith(" ") and line.strip():
147
+ cur.append(line.strip())
148
+ else:
149
+ cur = None
150
+ return [" ".join(i) for i in items]
151
+
152
+
153
+ def _codex_source(original, cwd=None, thread=None):
154
+ title = sessions.title("codex", thread) if thread else ""
155
+ original_cwd = None
156
+ if thread and workdir.is_throwaway(cwd):
157
+ rollout = sessions.transcript_path("codex", thread)
158
+ inferred = workdir.infer(rollout, cwd, workdir.default_exclude())
159
+ if inferred:
160
+ original_cwd, cwd = cwd, inferred
161
+ return {"tool": "codex", "session_id": thread, "session_name": title,
162
+ **_git_source(cwd), "original_cwd": original_cwd, "imported_from": "codex memories",
163
+ "original": "~/" + (CODEX_MEMORIES / original).relative_to(HOME).as_posix()}
164
+
165
+
166
+ def codex_items():
167
+ summary = CODEX_MEMORIES / "memory_summary.md"
168
+ if summary.exists():
169
+ sec = _sections(summary.read_text("utf-8"))
170
+ created = _mtime(summary)
171
+ profile = " ".join(line.strip() for line in sec.get("User Profile", []) if line.strip())
172
+ if profile:
173
+ profile = profile.replace(ADHOC_TAG, "").strip()
174
+ yield _record(name="user-profile-from-codex", typ="user", scope="global",
175
+ description="User profile as summarised by Codex: " + profile[:200],
176
+ body=profile + "\n", created=created,
177
+ source=_codex_source("memory_summary.md#user-profile"))
178
+ for heading in ("User preferences", "General Tips"):
179
+ for n, text in enumerate(_bullets(sec.get(heading, []))):
180
+ if ADHOC_TAG in text or text.startswith("Skills:"):
181
+ continue # ad-hoc notes are imported from their own files
182
+ yield _record(name=" ".join(text.split()[:6]), typ="feedback", scope="global",
183
+ description=text, body=text + "\n", created=created,
184
+ source=_codex_source(f"memory_summary.md#{slugify(heading)}-{n}"))
185
+
186
+ handbook = CODEX_MEMORIES / "MEMORY.md"
187
+ if handbook.exists():
188
+ blocks = re.split(r"(?m)^(?=# Task Group: )", handbook.read_text("utf-8"))
189
+ for block in blocks:
190
+ if not block.startswith("# Task Group: "):
191
+ continue
192
+ title = block.splitlines()[0][len("# Task Group: "):].strip()
193
+ cwd = next(iter(re.findall(r"cwd=([^\s;,)]+)", block)), None)
194
+ thread = next(iter(re.findall(r"thread_id=([0-9a-f-]{36})", block)), None)
195
+ stamps = sorted(re.findall(r"updated_at=([0-9T:.+-]+Z?)", block))
196
+ yield _record(name=title, typ="project", scope="here",
197
+ description=f"Codex task history: {title}", body=block.rstrip() + "\n",
198
+ created=stamps[-1] if stamps else _mtime(handbook),
199
+ source=_codex_source(f"MEMORY.md#{slugify(title)}", cwd, thread))
200
+
201
+ for path in sorted((CODEX_MEMORIES / "extensions" / "ad_hoc" / "notes").glob("*.md")):
202
+ body = path.read_text("utf-8")
203
+ _, raw = frontmatter.split(body)
204
+ desc = normalize({}, raw, path.stem)["description"]
205
+ name = re.sub(r"^[0-9T_-]+", "", path.stem) or path.stem
206
+ yield _record(name=name, typ="reference", scope="global", description=desc, body=raw,
207
+ created=_mtime(path),
208
+ source=_codex_source(path.relative_to(CODEX_MEMORIES).as_posix()))
209
+
210
+
211
+ # ---------------------------------------------------------------- driver
212
+
213
+ def run(which, data_dir, dry_run=False):
214
+ items = list(claude_items() if which == "claude" else codex_items())
215
+ seen = {m["source"].get("original") for m in load_all(data_dir)}
216
+ fresh = [it for it in items if it[1]["source"].get("original") not in seen]
217
+ for _, meta, _ in fresh:
218
+ src = meta["source"]
219
+ where = src.get("git_remote") or src.get("cwd") or "-"
220
+ print(f"{meta['type']:<9} {meta['scope']:<6} {meta['description'][:70]:<70} {where}")
221
+ print(f"\n{len(fresh)} to import, {len(items) - len(fresh)} already imported")
222
+ if dry_run or not fresh:
223
+ return
224
+ with gitsync.Lock(data_dir, timeout=30) as locked:
225
+ if not locked:
226
+ raise SystemExit("lock busy")
227
+ for memory_id, meta, body in fresh:
228
+ atomic_write(memories_dir(data_dir) / id_path(memory_id), frontmatter.join(meta, body))
229
+ label = "claude-code auto memory" if which == "claude" else "codex memories"
230
+ gitsync.commit(data_dir, f"import: {len(fresh)} memories from {label}\n\nTool: import")
231
+ print("imported and committed")