|
1 | | -"""Regenerate Claude Code's MEMORY.md from Lakebase-backed memories. |
2 | | -
|
3 | | -Claude Code auto-loads `MEMORY.md` from a per-project directory at |
4 | | -`~/.claude/projects/<encoded-cwd>/memory/MEMORY.md`, where `<encoded-cwd>` is |
5 | | -the absolute project path with every `/` and `.` replaced by `-`. Other `*.md` |
6 | | -files in that dir are referenced from MEMORY.md and loaded on demand. By |
7 | | -writing MEMORY.md there after each session, memories are injected into the |
8 | | -next session's context at zero extra cost. |
9 | | -
|
10 | | -Reference: this is the same encoding the running Claude Code harness uses for |
11 | | -its own auto-memory dir — verified against an active session at |
12 | | -`~/.claude/projects/-Users-...-coding-agents-databricks-apps/memory/`. |
| 1 | +"""Inject Lakebase-backed memories into Claude Code's auto-loaded CLAUDE.md. |
| 2 | +
|
| 3 | +Stock Claude Code (used in CODA) auto-loads `CLAUDE.md` files from: |
| 4 | + - `./CLAUDE.md` and parents walked up (project-level) |
| 5 | + - `~/.claude/CLAUDE.md` (user global) |
| 6 | +
|
| 7 | +We write the rendered memories into `~/.claude/CLAUDE.md`, between explicit |
| 8 | +markers so we can update just our section on each Stop hook without clobbering |
| 9 | +any user-authored content above or below. The user-global path means memories |
| 10 | +are visible in every Claude session regardless of cwd, which matches the way |
| 11 | +Lakebase already aggregates rows across projects under `owner_email`. |
| 12 | +
|
| 13 | +(The `~/.claude/projects/<encoded>/memory/MEMORY.md` path some harnesses use |
| 14 | +for auto-memory is NOT part of stock Claude Code 2.x — verified empirically |
| 15 | +against `claude --version` 2.1.19 in the CODA container, which did not load |
| 16 | +files from that path.) |
13 | 17 | """ |
14 | 18 | from __future__ import annotations |
15 | 19 |
|
|
27 | 31 |
|
28 | 32 | _CAP_PER_SECTION = 12 |
29 | 33 |
|
| 34 | +_BEGIN_MARKER = "<!-- BEGIN CODA MEMORY -->" |
| 35 | +_END_MARKER = "<!-- END CODA MEMORY -->" |
30 | 36 |
|
31 | | -def _encode_cwd(cwd: str) -> str: |
32 | | - """Match Claude Code's per-project memory dir encoding: replace / and . with -.""" |
33 | | - return cwd.replace("/", "-").replace(".", "-") |
34 | 37 |
|
35 | | - |
36 | | -def _memory_dir(cwd: str | None) -> Path: |
37 | | - """Return the Claude Code memory directory for a project (or global). |
38 | | -
|
39 | | - Per-project: `~/.claude/projects/<encoded-cwd>/memory/` |
40 | | - Global (cwd=None): `~/.claude/memory/` |
41 | | - """ |
| 38 | +def _claude_md_path() -> Path: |
| 39 | + """Return the user-global CLAUDE.md path that Claude Code auto-loads.""" |
42 | 40 | home = Path(os.environ.get("HOME", "/app/python/source_code")) |
| 41 | + path = home / ".claude" / "CLAUDE.md" |
| 42 | + path.parent.mkdir(parents=True, exist_ok=True) |
| 43 | + return path |
43 | 44 |
|
44 | | - if cwd: |
45 | | - mem_dir = home / ".claude" / "projects" / _encode_cwd(cwd) / "memory" |
46 | | - else: |
47 | | - mem_dir = home / ".claude" / "memory" |
48 | | - |
49 | | - mem_dir.mkdir(parents=True, exist_ok=True) |
50 | | - return mem_dir |
51 | | - |
52 | | - |
53 | | -def regenerate_memory_file( |
54 | | - owner_email: str, |
55 | | - project_name: str | None, |
56 | | - cwd: str | None = None, |
57 | | -) -> Path | None: |
58 | | - """Write MEMORY.md to Claude Code's auto-loaded memory directory. |
59 | | -
|
60 | | - `project_name` is the leaf-name tag used for filtering Lakebase rows |
61 | | - (matches the `project_name` column). `cwd` is the absolute project path |
62 | | - used to compute the auto-load directory; pass None for the global path. |
63 | | -
|
64 | | - Returns the path written, or None if there were no memories to write. |
65 | | - """ |
66 | | - from memory.store import load_memories |
67 | | - |
68 | | - memories = load_memories(owner_email, project_name, limit=60) |
69 | | - if not memories: |
70 | | - return None |
71 | 45 |
|
| 46 | +def _render_memory_section(memories: list[dict]) -> str: |
| 47 | + """Render the memories as a CLAUDE.md fragment between markers.""" |
72 | 48 | by_type: dict[str, list[dict]] = {} |
73 | 49 | for mem in memories: |
74 | 50 | by_type.setdefault(mem["type"], []).append(mem) |
75 | 51 |
|
76 | 52 | now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC") |
77 | 53 | lines: list[str] = [ |
| 54 | + _BEGIN_MARKER, |
78 | 55 | "# CODA Memory", |
79 | 56 | f"_Synced from Lakebase: {now}_", |
80 | 57 | "", |
81 | 58 | "These memories were extracted from past coding sessions and are stored in", |
82 | 59 | "Lakebase for durability across app restarts and CODA instances.", |
83 | 60 | "", |
84 | 61 | ] |
85 | | - |
86 | 62 | for mem_type, heading in _TYPE_HEADINGS.items(): |
87 | 63 | items = by_type.get(mem_type, []) |
88 | 64 | if not items: |
89 | 65 | continue |
90 | 66 | lines.append(heading) |
91 | 67 | for item in items[:_CAP_PER_SECTION]: |
92 | | - project_tag = f" _(project: {item['project_name']})_" if item.get("project_name") else "" |
| 68 | + project_tag = ( |
| 69 | + f" _(project: {item['project_name']})_" |
| 70 | + if item.get("project_name") |
| 71 | + else "" |
| 72 | + ) |
93 | 73 | lines.append(f"- {item['content']}{project_tag}") |
94 | 74 | lines.append("") |
| 75 | + lines.append(_END_MARKER) |
| 76 | + return "\n".join(lines) |
| 77 | + |
| 78 | + |
| 79 | +def _splice_section(existing: str, new_section: str) -> str: |
| 80 | + """Replace any prior CODA-MEMORY section in `existing`, or append if absent.""" |
| 81 | + if _BEGIN_MARKER in existing and _END_MARKER in existing: |
| 82 | + before, _, rest = existing.partition(_BEGIN_MARKER) |
| 83 | + _, _, after = rest.partition(_END_MARKER) |
| 84 | + return before.rstrip() + "\n\n" + new_section + after.lstrip("\n") |
| 85 | + sep = "\n\n" if existing and not existing.endswith("\n") else "\n" |
| 86 | + return existing + sep + new_section + "\n" |
| 87 | + |
| 88 | + |
| 89 | +def regenerate_memory_file( |
| 90 | + owner_email: str, |
| 91 | + project_name: str | None, |
| 92 | + cwd: str | None = None, # accepted for API compatibility; not used here |
| 93 | +) -> Path | None: |
| 94 | + """Splice Lakebase-backed memories into `~/.claude/CLAUDE.md`. |
| 95 | +
|
| 96 | + Returns the CLAUDE.md path on success, or None if there were no memories. |
| 97 | + """ |
| 98 | + from memory.store import load_memories |
| 99 | + |
| 100 | + memories = load_memories(owner_email, project_name, limit=60) |
| 101 | + if not memories: |
| 102 | + return None |
95 | 103 |
|
96 | | - output_path = _memory_dir(cwd) / "MEMORY.md" |
97 | | - output_path.write_text("\n".join(lines), encoding="utf-8") |
98 | | - return output_path |
| 104 | + new_section = _render_memory_section(memories) |
| 105 | + path = _claude_md_path() |
| 106 | + existing = path.read_text(encoding="utf-8") if path.exists() else "" |
| 107 | + path.write_text(_splice_section(existing, new_section), encoding="utf-8") |
| 108 | + return path |
0 commit comments