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()