Skip to content

Commit f2447f5

Browse files
committed
fix(memory): splice memories into ~/.claude/CLAUDE.md (stock Claude Code path)
The previous fix targeted ~/.claude/projects/<encoded-cwd>/memory/MEMORY.md on the assumption Claude Code auto-loads that path. Verified empirically with a fresh `claude -p` session in the deployed CODA container running Claude Code 2.1.19: that path is NOT auto-loaded. The auto-memory-dir mechanism is a HARNESS-LEVEL feature (added by some agent system prompts), not part of stock Claude Code. Stock Claude Code auto-loads CLAUDE.md files from cwd-walk-up parents and ~/.claude/CLAUDE.md. Switched to splicing the rendered memory section into ~/.claude/CLAUDE.md between explicit BEGIN/END markers so future regenerations replace just our section without clobbering other content. Also dropped the per-project iteration in setup_memory.py (now a single splice with project_name=None = all memories) — multiple splices to the same file would have clobbered each other anyway. Co-authored-by: Isaac
1 parent f062260 commit f2447f5

3 files changed

Lines changed: 77 additions & 76 deletions

File tree

memory/extractor.py

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

232-
# Regenerate MEMORY.md in the per-project auto-load dir so the next session
233-
# opens with these memories already in context. The dir name is derived from
234-
# `cwd` (not `project_name`) — Claude Code's encoding hashes the full path.
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.
235235
try:
236236
from memory.injector import regenerate_memory_file
237237

238-
path = regenerate_memory_file(owner_email, project_name, cwd=cwd or None)
238+
path = regenerate_memory_file(owner_email, None)
239239
if path:
240240
_trace(f"memory file updated: {path}")
241241
except Exception as e:

memory/injector.py

Lines changed: 64 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,19 @@
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.)
1317
"""
1418
from __future__ import annotations
1519

@@ -27,72 +31,78 @@
2731

2832
_CAP_PER_SECTION = 12
2933

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

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(".", "-")
3437

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."""
4240
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
4344

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
7145

46+
def _render_memory_section(memories: list[dict]) -> str:
47+
"""Render the memories as a CLAUDE.md fragment between markers."""
7248
by_type: dict[str, list[dict]] = {}
7349
for mem in memories:
7450
by_type.setdefault(mem["type"], []).append(mem)
7551

7652
now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
7753
lines: list[str] = [
54+
_BEGIN_MARKER,
7855
"# CODA Memory",
7956
f"_Synced from Lakebase: {now}_",
8057
"",
8158
"These memories were extracted from past coding sessions and are stored in",
8259
"Lakebase for durability across app restarts and CODA instances.",
8360
"",
8461
]
85-
8662
for mem_type, heading in _TYPE_HEADINGS.items():
8763
items = by_type.get(mem_type, [])
8864
if not items:
8965
continue
9066
lines.append(heading)
9167
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+
)
9373
lines.append(f"- {item['content']}{project_tag}")
9474
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
95103

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

setup_memory.py

Lines changed: 9 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -46,26 +46,17 @@
4646
except Exception as e:
4747
print(f"CODA memory: schema init warning: {e}")
4848

49-
# Regenerate MEMORY.md per-project at startup so prior memories are loaded the
50-
# moment the user `cd`s into a project and runs `claude`. The auto-load path
51-
# `~/.claude/projects/<encoded-cwd>/memory/MEMORY.md` is cwd-specific, so we
52-
# iterate over every project directory that has memories.
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.
5353
try:
5454
from memory.injector import regenerate_memory_file
55-
projects_root = home / "projects"
56-
refreshed = 0
57-
if projects_root.exists():
58-
for project_dir in sorted(projects_root.iterdir()):
59-
if not project_dir.is_dir():
60-
continue
61-
path = regenerate_memory_file(
62-
app_owner, project_dir.name, cwd=str(project_dir)
63-
)
64-
if path:
65-
refreshed += 1
66-
print(f"CODA memory: refreshed {project_dir.name}{path}")
67-
if refreshed == 0:
68-
print("CODA memory: no per-project memories yet (new instance)")
55+
path = regenerate_memory_file(app_owner, None)
56+
if path:
57+
print(f"CODA memory: spliced into {path}")
58+
else:
59+
print("CODA memory: no memories yet (new instance)")
6960
except Exception as e:
7061
print(f"CODA memory: memory file warning: {e}")
7162

0 commit comments

Comments
 (0)