Persistent memory for Claude Code with codemem
codemem gives Claude Code persistent memory. It watches sessions through lifecycle hooks, embeds everything locally with a BERT model on Metal GPU, and builds a knowledge graph on top of SQLite. Next session, relevant context comes back. When autocompact wipes your conversation, you don’t start from zero.
Single binary, no daemon, no cloud calls.
Setup
cargo install --git https://github.com/cogniplex/codemem codemem
cd ~/your-project && codemem init
codemem init downloads a ~440MB embedding model (BAAI/bge-base-en-v1.5), registers 9 lifecycle hooks in Claude Code, sets up MCP server config, and drops some agent definitions and a skill file. Everything lives on disk:
~/.cargo/bin/codemem # binary
~/.codemem/codemem.db # SQLite database
~/.codemem/codemem.idx # HNSW vector index
~/.codemem/models/ # embedding model
~/Code/.claude/settings.json # hook config
~/Code/.mcp.json # MCP server config
No idle memory. It spins up for each hook event (~1-2GB during embedding), then exits.
Confirm Metal GPU with codemem stats:
INFO Using Metal GPU for embeddings
Embedding provider: candle (768d)
Importing history
Claude Code keeps transcripts as JSONL in ~/.claude/projects/. I wrote a converter (at the bottom of this post) to turn those into codemem’s import format:
python3 codemem_export.py -o memories.jsonl
codemem import memories.jsonl
codemem consolidate --cycle creative # builds graph edges
My 270 sessions produced 787 memories across 31 projects. Import writes memories and embeddings but not graph edges, so the consolidate step is needed.
Getting Claude to use it
Having the MCP tools available doesn’t mean Claude uses them. It’ll default to Grep/Read/Explore instead of checking codemem (sub-second). I added this to my global ~/.claude/CLAUDE.md and it helps:
## Codemem (MCP memory)
Use codemem MCP tools actively, not just passively through hooks.
- **Search codemem first**: Before Grep/Glob/Read, try `recall` or
`search_code` first. Fall back only if codemem doesn't have it.
- **Store decisions**: After non-obvious design choices, `store_memory`
- **Recall before acting**: On complex tasks, `recall` first.
- **Refine, don't duplicate**: Use `refine_memory` to update, not store new.
Run /codemem in Claude Code for the full tool reference.
Contributing
The codebase is a Rust workspace with SQLite, HNSW vectors, and petgraph for the knowledge graph. Some PRs I’ve sent:
- Lockfile to prevent concurrent index runs from exhausting the CPU (#38)
- Namespace-scoped PageRank so dense projects don’t inflate centrality scores globally (#61). The database is shared across all projects, so without this a project with lots of internal edges would pollute scores in unrelated ones. The fix filters edges at namespace boundaries and treats cross-namespace links as sinks.
- Document indexer that splits Markdown/YAML/Bash files into searchable nodes alongside code symbols (#48, still in draft)
Gotchas
MCP server caches the DB. If you rebuild the database mid-session, the MCP server still reads the old one. Run /mcp in Claude Code to reconnect, or restart the session.
Graph is empty after import. Normal. Run codemem consolidate --cycle creative.
Converter script
Parses Claude Code session transcripts into codemem import format. Change CLAUDE_DIR and the project name parsing for your setup.
#!/usr/bin/env python3
"""Convert Claude Code sessions to codemem import JSONL."""
from __future__ import annotations
import json, re, sys
from pathlib import Path
CLAUDE_DIR = Path.home() / ".claude" / "projects"
DECISION_MARKERS = re.compile(
r"instead of|root cause|the fix|switched to|decided|won't work because", re.I
)
NOISE_START = re.compile(r"^(let me |checking |looking at |I'll |ok,? )", re.I)
def parse_session(path: Path) -> list[dict]:
messages = []
for line in path.read_text().splitlines():
try:
msg = json.loads(line)
except json.JSONDecodeError:
continue
if msg.get("type") == "assistant" and isinstance(msg.get("message", {}).get("content"), list):
for block in msg["message"]["content"]:
if isinstance(block, dict) and block.get("type") == "text":
messages.append(block["text"])
return messages
def extract_decisions(messages: list[str], cap: int = 3) -> list[str]:
decisions = []
for text in messages:
if len(text) < 150 or NOISE_START.match(text):
continue
if DECISION_MARKERS.search(text):
decisions.append(text[:2000])
if len(decisions) >= cap:
break
return decisions
def process_project(project_dir: Path) -> list[dict]:
project = project_dir.name.replace("-Users-fran-Code-", "").replace("-", "/")
memories = []
for session_file in project_dir.glob("*.jsonl"):
messages = parse_session(session_file)
if len(messages) < 3:
continue
summary = messages[-1][:3000] if messages else ""
if summary:
memories.append({
"content": f"[{project}] {summary}",
"memory_type": "context",
"namespace": project,
"tags": [project, "session-summary"],
"importance": min(0.3 + len(messages) * 0.02, 0.8),
})
for dec in extract_decisions(messages):
memories.append({
"content": f"[{project}] {dec}",
"memory_type": "decision",
"namespace": project,
"tags": [project, "decision"],
"importance": 0.6,
})
return memories
def main():
out = Path(sys.argv[1]) if len(sys.argv) > 1 else Path("memories.jsonl")
memories = []
for project_dir in CLAUDE_DIR.iterdir():
if project_dir.is_dir():
memories.extend(process_project(project_dir))
out.write_text("\n".join(json.dumps(m) for m in memories) + "\n")
print(f"Exported {len(memories)} memories to {out}")
if __name__ == "__main__":
main()