Memory Persistence Skill
Memory Architecture Overview
Claude Code has no built-in cross-session memory — every new session starts blank. There are six patterns to solve this, ranging from simple to powerful:
| Pattern | Best for | Complexity |
|---|---|---|
| CLAUDE.md injection | Small, stable facts | Low |
| Session summary files | Resuming complex work | Low |
| Append-only JSONL | Evolving agent knowledge | Medium |
| Graph-based memory | Relationship-rich knowledge | Medium |
| Hooks (stop/pre/post) | Auto-saving and auto-loading | Medium |
| MCP memory servers | Production multi-user agents | High |
Pattern 1: CLAUDE.md Injection (Always-On Facts)
CLAUDE.md is Claude Code's built-in memory layer. It loads automatically at session start, making it the simplest persistence mechanism. There are three levels:
| File | Scope | Loads when |
|---|---|---|
~/.claude/CLAUDE.md |
All projects (global) | Every session |
CLAUDE.md (project root) |
This project only | When in project dir |
.claude/CLAUDE.md |
This project (hidden) | When in project dir |
All three merge at session start. Use the right level for each fact.
Example CLAUDE.md structure:
## Project Context
- Stack: Next.js 14, Supabase, Tailwind, Vercel
- Auth: Clerk (not NextAuth)
- Primary branch: main (never commit directly)
- Test command: pnpm test:unit
- Deployment: auto on push to main via Vercel
## Key Decisions
- We use Zod for all validation, not Yup
- All API routes live in /app/api, never /pages/api
- Error handling pattern: see /lib/errors.ts
CLAUDE.md Size Limits
Keep it under 150 lines — it loads every session, consuming tokens permanently. Move verbose details to reference files and @-import them only when needed.
| Content type | Put in CLAUDE.md? | Alternative |
|---|---|---|
| Stack, tools, test commands | Yes | — |
| Architectural decisions (< 10) | Yes | — |
| Long reference tables | No | references/*.md + @-import |
| API docs | No | Separate file, load on demand |
| Session state | No | Session files (Pattern 2) |
Router Pattern for Large Projects
When CLAUDE.md would exceed 150 lines, use a router that points to detail files:
## Memory Index
- [Architecture](references/architecture.md) — system design, service map
- [Conventions](references/conventions.md) — naming, patterns, anti-patterns
- [API Reference](references/api.md) — endpoints, auth, rate limits
- [Decisions Log](references/decisions.md) — ADRs with rationale
Claude loads only the index (4 lines). When it needs architecture details, it reads the referenced file. This scales to thousands of lines of memory without bloating the context window.
Pattern 2: Session Summary Files (Resumable Work)
At the end of a working session, have Claude write a handoff file:
Prompt to use at session end:
Summarise this session for tomorrow. Include:
- What we were building and current state
- Exact file paths changed and what changed
- Decisions made and why
- Next steps in order
- Any blockers or open questions
Save to .claude/sessions/YYYY-MM-DD.md
To resume next session:
@.claude/sessions/2026-03-20.md — pick up where we left off
Session file template (.claude/sessions/template.md):
# Session: [DATE]
## Goal
[What we set out to do]
## State
[Current state — what's working, what's not]
## Files Changed
- path/to/file.ts — [what changed and why]
## Decisions
- [Decision] because [reason]
## Next Steps
1. [First thing to do]
2. [Second thing]
## Blockers
- [Any open question or blocker]
Session Continuity Across Compaction
When a session is long, Claude Code compacts context to free tokens. Session files survive compaction because they are on disk, not in context. To protect critical state during long sessions:
- Write state to disk early — do not keep critical decisions only in chat
- Re-read the session file after compaction (
@.claude/sessions/today.md) - Use incremental writes — append to the file as the session progresses
Pattern 3: Append-Only JSONL Memory (Evolving Knowledge)
For agents that learn over time — each discovered insight gets appended as a JSON line. Never overwrite; always append. This preserves history and is agent-friendly.
Schema line 1 (always first — defines the structure):
{"schema": "v1", "fields": ["ts", "type", "key", "value", "context", "importance"]}
Append a memory:
{"ts": "2026-03-20T14:00:00Z", "type": "pattern", "key": "auth_error_fix", "value": "Always check Clerk webhook secret matches env var CLERK_WEBHOOK_SECRET", "context": "Spent 2h debugging this in session 2026-03-20", "importance": 0.9}
{"ts": "2026-03-20T15:30:00Z", "type": "preference", "key": "test_style", "value": "User prefers Vitest over Jest; never suggest Jest", "context": "Corrected by user", "importance": 0.8}
{"ts": "2026-03-21T09:00:00Z", "type": "decision", "key": "db_client", "value": "Using Supabase JS v2 client, not direct Postgres", "context": "Architectural decision", "importance": 1.0}
Reading memory in a new session:
import json
def load_memories(path: str, types: list[str] = None, min_importance: float = 0.0) -> list[dict]:
memories = []
with open(path) as f:
next(f) # skip schema line
for line in f:
m = json.loads(line)
if types and m["type"] not in types:
continue
if m.get("importance", 1.0) < min_importance:
continue
memories.append(m)
return memories
# Load only high-importance patterns and decisions
relevant = load_memories(".claude/memory.jsonl", types=["pattern", "decision"], min_importance=0.7)
Memory Types
| Type | What to store | Typical importance |
|---|---|---|
fact |
Project-specific facts (env names, URLs, keys) | 0.7 - 1.0 |
decision |
Architectural decisions and rationale | 0.8 - 1.0 |
pattern |
Debugging techniques, workarounds discovered | 0.6 - 0.9 |
preference |
User preferences on style, tools, approach | 0.7 - 0.9 |
error |
Recurring errors and their fixes | 0.5 - 0.8 |
relationship |
How entities relate (X depends on Y) | 0.5 - 0.8 |
Memory Decay and Relevance Scoring
Not all memories stay relevant forever. Apply decay to prioritize recent knowledge:
import math
from datetime import datetime, timezone
def score_memory(memory: dict, now: datetime = None) -> float:
"""Score = importance * recency_decay"""
now = now or datetime.now(timezone.utc)
ts = datetime.fromisoformat(memory["ts"].replace("Z", "+00:00"))
age_days = (now - ts).total_seconds() / 86400
recency = math.exp(-0.01 * age_days) # half-life ~69 days
importance = memory.get("importance", 0.7)
return importance * recency
Retrieval strategy: load all memories, score each, return top-N. This keeps context lean while surfacing the most relevant knowledge.
Pattern 4: Graph-Based Memory (Relationship-Rich Knowledge)
For complex projects where entities relate to each other — use a graph structure stored as a JSON file. Faster context loading, fewer tokens, safer for refactoring.
{
"nodes": {
"UserModel": {"type": "entity", "file": "lib/models/user.ts", "deps": ["AuthService", "DB"]},
"AuthService": {"type": "service", "file": "lib/auth.ts", "deps": ["Clerk", "UserModel"]},
"DB": {"type": "external", "url": "Supabase"}
},
"edges": [
{"from": "UserModel", "to": "AuthService", "rel": "used-by"},
{"from": "AuthService", "to": "Clerk", "rel": "delegates-auth-to"}
]
}
When to use: projects with 20+ modules, microservices, or complex dependency chains. Claude can query: "what depends on AuthService?" without loading all files.
Graph Queries
Common queries an agent can run against the graph:
| Query | How |
|---|---|
| What depends on X? | Filter edges where from == X |
| What does X depend on? | Read nodes[X].deps |
| Impact of changing X? | BFS from X along used-by edges |
| Which services touch the DB? | Filter nodes where "DB" in deps |
Pattern 5: Hooks for Auto-Save and Auto-Load
Hooks let you automate memory operations so the user never has to remember to save.
Stop Hook — Auto-Save Session Learnings
A Stop hook runs at session end. It captures what Claude discovered and appends
to memory automatically. Use Stop not UserPromptSubmit — runs once per session,
not on every message, so no latency impact.
.claude/hooks/stop-memory.sh:
#!/bin/bash
# Runs at end of every session
# Asks Claude to extract learnings and append to JSONL
claude -p "Review this session. Extract any:
- Debugging patterns discovered
- Project-specific facts learned
- User preferences observed
- Decisions made
Format each as a JSON line:
{\"ts\": \"$(date -u +%Y-%m-%dT%H:%M:%SZ)\", \"type\": \"[type]\", \"key\": \"[short_key]\", \"value\": \"[what to remember]\", \"context\": \"[brief context]\", \"importance\": [0.0-1.0]}
Append to .claude/memory.jsonl — do not overwrite."
Register in .claude/settings.json:
{
"hooks": {
"Stop": [{"command": "bash .claude/hooks/stop-memory.sh"}]
}
}
PreToolUse Hook — Auto-Load Relevant Memory
Load memory before specific tool calls to give Claude context exactly when needed:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Edit|Write",
"command": "bash .claude/hooks/load-memory.sh"
}
]
}
}
.claude/hooks/load-memory.sh:
#!/bin/bash
# Before editing files, surface relevant memories
# Read last 20 memories and output as context
tail -20 .claude/memory.jsonl 2>/dev/null || true
PostToolUse Hook — Capture Outcomes
After a tool runs, record what happened for future sessions:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Bash",
"command": "bash .claude/hooks/record-outcome.sh"
}
]
}
}
Pattern 6: MCP Memory Servers
For production memory, connect a memory MCP server instead of flat files:
| Server | Storage | Best for |
|---|---|---|
@modelcontextprotocol/memory |
In-process key-value | Simple persistence |
| Chroma MCP | Vector DB | Semantic search over memories |
| SQLite MCP | Relational | Structured queries over memory |
Important: if a memory MCP is configured but no skill/hook actually writes to it, disable it — it consumes context window tokens with no benefit.
Memory Retrieval Strategies
When memory grows large, you need strategies to load only what matters:
| Strategy | How it works | Best for |
|---|---|---|
| Recency | Load last N entries | Fast-moving projects |
| Type filter | Load only decision + pattern |
Focused recall |
| Importance threshold | Load entries with importance >= 0.7 | Mature memory stores |
| Decay scoring | Score = importance * recency_decay | Balanced retrieval |
| Semantic search | Vector similarity via MCP | Large memory stores (1000+) |
| Key prefix | Filter by key prefix (auth_*) |
Domain-specific queries |
Combining Strategies
For best results, combine multiple strategies:
def retrieve_relevant(path: str, query_types: list[str], top_n: int = 20) -> list[dict]:
all_memories = load_memories(path, types=query_types)
scored = [(m, score_memory(m)) for m in all_memories]
scored.sort(key=lambda x: x[1], reverse=True)
return [m for m, s in scored[:top_n]]
Quick Reference — Which Pattern to Use?
Need to remember forever? -> CLAUDE.md (if < 20 lines) or JSONL memory
Resuming complex work tomorrow? -> Session summary file
Agent that learns over time? -> Append-only JSONL + Stop hook
Complex codebase relationships? -> Graph-based JSON
Auto-save without user action? -> Stop hook + JSONL
Production multi-user agent? -> Memory MCP server
Surviving context compaction? -> Write to disk early and often
Pattern 7: Groq-Powered Memory Processing (Zero Claude Token Cost)
Use Groq's free tier to handle memory operations that would otherwise consume Claude tokens: scoring, compaction, summarisation, and relevance ranking.
Why offload memory ops to Groq?
Memory operations are repetitive, low-reasoning tasks — perfect for Llama 3.1 8B. Scoring 500 memories on Claude Sonnet costs ~$2. On Groq: $0.
Groq setup
pip install groq
export GROQ_API_KEY="gsk_..." # Free at console.groq.com
Memory scoring with Groq
Instead of loading all memories into Claude's context, score them on Groq first and feed only the top-N:
import os, json, time
from groq import Groq
groq_client = Groq(api_key=os.environ["GROQ_API_KEY"])
def score_memories_groq(memories: list[dict], context: str,
top_n: int = 20) -> list[dict]:
"""Score memory relevance using Groq free tier, return top-N."""
scored = []
batch_size = 15 # Send 15 per request to reduce RPM usage
for i in range(0, len(memories), batch_size):
batch = memories[i:i+batch_size]
batch_text = "\n".join([
f"{j+1}. [{m['type']}] {m['key']}: {m['value']}"
for j, m in enumerate(batch)
])
resp = groq_client.chat.completions.create(
model="llama-3.1-8b-instant",
messages=[{
"role": "user",
"content": f"""Rate each memory's relevance to this context (0.0-1.0).
Return ONLY a JSON array of numbers.
Context: {context}
Memories:
{batch_text}
Scores:"""
}],
temperature=0,
max_tokens=200
)
try:
scores = json.loads(resp.choices[0].message.content)
if isinstance(scores, dict):
scores = list(scores.values())[0]
for m, s in zip(batch, scores):
scored.append((m, float(s)))
except:
for m in batch:
scored.append((m, 0.5))
time.sleep(2) # Respect 30 RPM limit
scored.sort(key=lambda x: x[1], reverse=True)
return [m for m, s in scored[:top_n]]
# Usage: score 500 memories for free, feed top 20 to Claude
all_memories = load_memories(".claude/memory.jsonl")
relevant = score_memories_groq(all_memories, "debugging auth webhook")
# Now pass only 20 memories to Claude instead of 500
Memory summarisation with Groq
Compress old memories into summaries to reduce JSONL file size:
def summarise_old_memories_groq(memories: list[dict],
group_size: int = 20) -> list[str]:
"""Summarise groups of old memories into compact paragraphs."""
summaries = []
for i in range(0, len(memories), group_size):
group = memories[i:i+group_size]
group_text = "\n".join([
f"- [{m['type']}] {m['key']}: {m['value']}"
for m in group
])
resp = groq_client.chat.completions.create(
model="llama-3.1-8b-instant",
messages=[{
"role": "user",
"content": f"Summarise these memories into 3-5 bullet points, preserving the most important facts:\n\n{group_text}"
}],
temperature=0.1,
max_tokens=300
)
summaries.append(resp.choices[0].message.content.strip())
time.sleep(2)
return summaries
Memory deduplication with Groq
Find and merge duplicate memories without burning Claude tokens:
def find_duplicates_groq(memories: list[dict]) -> list[list[int]]:
"""Identify duplicate/near-duplicate memories using Groq."""
batch_text = "\n".join([
f"{i+1}. [{m['type']}] {m['key']}: {m['value']}"
for i, m in enumerate(memories)
])
resp = groq_client.chat.completions.create(
model="llama-3.1-8b-instant",
messages=[{
"role": "user",
"content": f"""Find groups of duplicate or near-duplicate memories.
Return JSON array of arrays, e.g. [[1,5], [3,7,12]].
If no duplicates, return [].
Memories:
{batch_text[:4000]}
Duplicate groups:"""
}],
temperature=0,
max_tokens=500
)
try:
return json.loads(resp.choices[0].message.content)
except:
return []
Haiku for real-time memory retrieval
For in-session memory operations that need to stay inside Claude Code, use Haiku subagents instead of the main model:
{
"env": {
"CLAUDE_CODE_SUBAGENT_MODEL": "haiku"
}
}
Then delegate memory tasks to subagents:
Use a subagent to: read .claude/memory.jsonl, find all memories
related to "authentication", and return the top 5 most relevant.
Haiku costs 4% of Opus — memory retrieval via subagent is nearly free.
Combined workflow: Groq + Haiku + Claude
| Memory operation | Route to | Cost |
|---|---|---|
| Score 500 memories for relevance | Groq | $0.00 |
| Summarise old memories (bulk) | Groq | $0.00 |
| Find and merge duplicates | Groq | $0.00 |
| Load top-20 into session context | Haiku subagent | ~$0.001 |
| Use memories for reasoning | Sonnet/Opus (main) | Normal |
| Write new memories (Stop hook) | Haiku subagent | ~$0.001 |
Result: memory operations that cost $2-5/day on Claude now cost <$0.01/day.
See references/groq-memory.md for complete integration scripts.
Anti-Patterns to Avoid
| Anti-pattern | Why it fails | Fix |
|---|---|---|
| CLAUDE.md over 300 lines | Wastes tokens every session | Router pattern + reference files |
| Overwriting memory files | Loses history, breaks diffs | Append-only JSONL |
| Memory in chat only | Lost on compaction or new session | Write to disk |
| No importance scoring | All memories treated equal | Add importance field |
| Hook on every message | Latency on each prompt | Use Stop hook (once per session) |
| MCP server with no writes | Token cost, zero benefit | Disable unused MCP tools |