Skip to content

Commit 633bc48

Browse files
committed
fix(memory): inject memories into ~/.claude/CLAUDE.md so Claude actually loads them
Stock Claude Code (in the CODA container, version 2.1.19) auto-loads CLAUDE.md files from cwd-walked-up parents and ~/.claude/CLAUDE.md, but does NOT auto-load files under ~/.claude/projects/<encoded>/memory/ — that path is a harness-level convention, not stock behavior. The injector now splices the rendered memory section into ~/.claude/CLAUDE.md between explicit <!-- BEGIN/END CODA MEMORY --> markers, so regenerations replace just our section without clobbering other content. setup_memory.py does a single startup splice with project_name=None (all memories), and extractor.py does the same after each Stop hook. Cross-project lessons are visible in every session since the user might cd anywhere. Verified end-to-end: a fresh `claude -p` session quoted the content_hash dedup rule verbatim from ~/.claude/CLAUDE.md, with no tool calls or file reads. Co-authored-by: Isaac
1 parent 322b367 commit 633bc48

3 files changed

Lines changed: 76 additions & 41 deletions

File tree

memory/extractor.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -229,15 +229,17 @@ def stop_hook_handler() -> None:
229229
_trace(f"Lakebase write error: {type(e).__name__}: {e}")
230230
return
231231

232-
# Regenerate local MEMORY.md so next session sees updated memories
232+
# Splice the full memory set into ~/.claude/CLAUDE.md so the next Claude
233+
# session sees everything — cross-project lessons too, since the user may
234+
# cd anywhere. project_name=None pulls all of this owner's memories.
233235
try:
234236
from memory.injector import regenerate_memory_file
235237

236-
path = regenerate_memory_file(owner_email, project_name)
238+
path = regenerate_memory_file(owner_email, None)
237239
if path:
238-
print(f"[coda-memory] memory file updated: {path}", file=sys.stderr)
240+
_trace(f"memory file updated: {path}")
239241
except Exception as e:
240-
print(f"[coda-memory] memory file update error: {e}", file=sys.stderr)
242+
_trace(f"memory file update error: {e}")
241243

242244

243245
if __name__ == "__main__":

memory/injector.py

Lines changed: 65 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,22 @@
1-
"""Regenerate Claude Code's MEMORY.md from Lakebase-backed memories.
2-
3-
Claude Code auto-loads every file inside ~/.claude/projects/<hash>/memory/
4-
at session start. By writing coda_memory.md there after each session,
5-
memories are injected into the next session's context at zero extra cost.
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.)
617
"""
718
from __future__ import annotations
819

9-
import hashlib
1020
import os
1121
from datetime import datetime, timezone
1222
from pathlib import Path
@@ -21,59 +31,78 @@
2131

2232
_CAP_PER_SECTION = 12
2333

34+
_BEGIN_MARKER = "<!-- BEGIN CODA MEMORY -->"
35+
_END_MARKER = "<!-- END CODA MEMORY -->"
2436

25-
def _memory_dir(project_name: str | None) -> Path:
26-
"""Return the Claude Code memory directory for a project (or global)."""
27-
home = Path(os.environ.get("HOME", "/app/python/source_code"))
28-
29-
if project_name:
30-
# Claude Code hashes the absolute project path to locate the memory dir
31-
project_path = str(home / "projects" / project_name)
32-
path_hash = hashlib.md5(project_path.encode()).hexdigest()
33-
mem_dir = home / ".claude" / "projects" / path_hash / "memory"
34-
else:
35-
mem_dir = home / ".claude" / "memory"
36-
37-
mem_dir.mkdir(parents=True, exist_ok=True)
38-
return mem_dir
39-
40-
41-
def regenerate_memory_file(owner_email: str, project_name: str | None) -> Path | None:
42-
"""Write coda_memory.md to Claude Code's memory directory.
4337

44-
Returns the path written, or None if there were no memories to write.
45-
"""
46-
from memory.store import load_memories
38+
def _claude_md_path() -> Path:
39+
"""Return the user-global CLAUDE.md path that Claude Code auto-loads."""
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
4744

48-
memories = load_memories(owner_email, project_name, limit=60)
49-
if not memories:
50-
return None
5145

52-
# Group by type, project-specific entries first within each type
46+
def _render_memory_section(memories: list[dict]) -> str:
47+
"""Render the memories as a CLAUDE.md fragment between markers."""
5348
by_type: dict[str, list[dict]] = {}
5449
for mem in memories:
5550
by_type.setdefault(mem["type"], []).append(mem)
5651

5752
now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
5853
lines: list[str] = [
54+
_BEGIN_MARKER,
5955
"# CODA Memory",
6056
f"_Synced from Lakebase: {now}_",
6157
"",
6258
"These memories were extracted from past coding sessions and are stored in",
6359
"Lakebase for durability across app restarts and CODA instances.",
6460
"",
6561
]
66-
6762
for mem_type, heading in _TYPE_HEADINGS.items():
6863
items = by_type.get(mem_type, [])
6964
if not items:
7065
continue
7166
lines.append(heading)
7267
for item in items[:_CAP_PER_SECTION]:
73-
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+
)
7473
lines.append(f"- {item['content']}{project_tag}")
7574
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
76103

77-
output_path = _memory_dir(project_name) / "coda_memory.md"
78-
output_path.write_text("\n".join(lines), encoding="utf-8")
79-
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

setup_memory.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,11 +46,15 @@
4646
except Exception as e:
4747
print(f"CODA memory: schema init warning: {e}")
4848

49+
# Splice all of this user's Lakebase memories into ~/.claude/CLAUDE.md so
50+
# they're available the moment a Claude session starts (Claude Code auto-loads
51+
# that file). project_name=None means "all projects" — the user might `cd` to
52+
# any of them, and cross-project lessons should be visible everywhere.
4953
try:
5054
from memory.injector import regenerate_memory_file
5155
path = regenerate_memory_file(app_owner, None)
5256
if path:
53-
print(f"CODA memory: memory file populated → {path}")
57+
print(f"CODA memory: spliced into {path}")
5458
else:
5559
print("CODA memory: no memories yet (new instance)")
5660
except Exception as e:

0 commit comments

Comments
 (0)