skill
claude-code-memory-hooks
Long-term memory for Claude Code built from hooks
Set up
Skills are installed by placing their folder where your agent loads skills.
The download comes from github.com/Oleg67/claude-code-memory-hooks itself, not from our server, so what you get is exactly what its author published.
Install instructions come from the README
README
synced Oct 22, 2026memory_tools
Long-term memory for Claude Code, built from hooks and plain local files: five memory types, confidence, Ebbinghaus decay, crystallization, a promotion gate for inferred memories, and its own file + SQL + vector + graph storage. No service; stdlib only except the embedding venv. For a portable version that works with any MCP client, see halflife-mcp.
| File | Role | Runs from |
|---|---|---|
memory_trace.py | Appends a redacted raw timeline (Trace) | UserPromptSubmit, PostToolUse, PostToolUseFailure hooks |
memory_distill.py | One headless haiku call per non-trivial session → Episodic + candidates, through the gate | SessionEnd (and --catch-up at SessionStart), detached |
memory_recall.py | Per-prompt hybrid retrieval (BM25 + vector + graph, RRF): injects the ≤3 best memories | UserPromptSubmit |
memory_store.py | SQL + vector + graph index of the memory files, in one SQLite file | imported by recall, distill, index |
memory_index.py | CLI: sync, stats, query, graph, backfill, rebuild | by hand |
memory_maintain.py | Decay, supersession, archive, score-ordered MEMORY.md, trace retention, grouped session-start summary | SessionStart --inject |
skill_maintain.py | Experience-leveled skills report + memory → skill suggestions | SessionStart --inject |
All hooks are global (~/.claude/settings.json), exit 0 on any error and log to maintain.log.
hooks.json is the hooks block to merge into ~/.claude/settings.json; keep the two in sync.
skills/<name>/SKILL.md holds the agent-side memory skill and the skills promoted from
memories (plan-graph, public-core-release, script-timeouts), each installed as a symlink:
ln -s ~/.claude/memory_tools/skills/<name>/SKILL.md ~/.claude/skills/<name>/SKILL.md.
The SessionStart hook has no matcher, so it also fires after context compaction (source: compact) and re-injects the summary; a PreCompact hook could not, as its stdout is not
added to context.
Install
Needs Python 3, a POSIX shell, and the claude CLI on PATH: the distiller runs
claude -p --model haiku once per non-trivial session, using your Claude Code login.
- Clone this repository to
~/.claude/memory_tools;hooks.jsonassumes that location. - Optional, for vector search and skill matching: create
~/.claude/memory_tools/.venvand installfastembedandnumpyinto it. Without it, recall uses keyword and graph search. - Merge the
hooksblock ofhooks.jsoninto~/.claude/settings.json. - Link the skills as shown above.
The five types
| Type | Where | Tier / half-life | Written by |
|---|---|---|---|
| Trace | <memdir>/_traces/<session_id>.jsonl | not a memory; deleted after 30 days | memory_trace.py (every prompt and tool call) |
| Episodic | <memdir>/episode_<date>_<sid8>.md | episodic, 14 d, conf 0.5 | distiller at session end (or the agent) |
| Preference | ~/.claude/memory_global/ (cross-project) or <memdir> | preference, 180 d, conf 0.7 | distiller candidate (or the agent) |
| Procedural | <memdir> | procedural, 365 d, conf 0.85 | distiller candidate (or the agent) |
| Fact / long-term | <memdir> | semantic (aliases fact, long_term), 180 d, conf 0.7 | distiller candidate (or the agent) |
Plus the existing working (1 d), idea (age-immune) and rule (90 d, re-verify) tiers.
<memdir> = dirname(transcript_path)/memory, i.e. ~/.claude/projects/<slug>/memory.
Trace
One line per event: {ts, event, tool, input_summary ≤200ch, ok, error?} or
{ts, event, prompt ≤300ch}. The input summary is a pointer (command, file path, URL,
pattern), never file contents or tool output. Secrets are redacted before writing:
Bearer/Basic credentials, user:pass@ in URLs, JWTs, sk-…/ghp_…/glpat-…/xox…/AKIA
keys, *key*/*token*/*secret*/*password*=value, and any .env-style UPPER_CASE=value.
Never injected into context.
Distiller and promotion gate
At SessionEnd, memory_distill.py --detach forks into the background and:
- Skips trivial sessions (< 3 prompts and < 5 tool calls); writes
_traces/<sid>.done. - Builds a prompt from the trimmed transcript (user/assistant text only, head 4k + tail 26k chars), a trace summary, and the existing memory index (names + descriptions).
- Runs
claude -p --model haiku --output-format json --tools "" --strict-mcp-config --no-session-persistencein/tmp/claude-memory-distillwithCLAUDE_MEMORY_DISTILL=1(every hook script exits at once when that is set: recursion guard) and auto-memory disabled. - Writes the episodic memory (active) and applies up to 3 candidates:
- matches an existing memory (same tier, description-token Jaccard ≥ 0.6) from an
earlier session → reinforce:
confidence +0.1(cap 1),reinforced +1,last_reinforced = today,provisional → active; - no match → new file,
status: provisional,confidence: 0.3; contradicts: <file>is recorded on the new provisional memory; the old one is left alone until the new one is corroborated (see Supersession).
- matches an existing memory (same tier, description-token Jaccard ≥ 0.6) from an
earlier session → reinforce:
- Re-indexes
MEMORY.md; records status, actions and the raw LLM result in.done.
Provisional memories are kept out of MEMORY.md and the injected summary until corroborated;
if never corroborated they fade and archive like anything else. An LLM failure is retried once
(next catch-up), then marked failed. A resumed session is re-distilled when the new activity
is itself non-trivial (same episode file, updated).
--catch-up (SessionStart) distills traces idle > 1 h without a .done (sessions killed
before SessionEnd), skipping the live session. MEMORY_DISTILL_LLM_CMD replaces the LLM
command (tests).
Supersession
When an active memory carries contradicts: <file> (a corroborated distiller candidate,
or one the agent wrote), memory_maintain.py marks <file> status: superseded,
superseded_by: <new>, superseded_at: <date> and moves it to _archive/ with its body
intact. A provisional contradiction never retires anything. The lookup is within the same
memory dir; status: superseded set by hand is archived the same way. Superseded memories
are never indexed, injected or recalled.
Storage: files + SQL + vector + graph
The .md files are the source of truth: Claude Code reads them, and you can edit them. Each
memory can have a graph sidecar, <memdir>/_graph/<file>.json:
{"entities": [{name, type}], "relations": [{src, rel, dst}]}.
data/memory.db (SQLite, WAL) is a derived index over all memory dirs. You can delete it
at any time; memory_index.py rebuild recreates it from the files.
| Table | Holds |
|---|---|
memories | one row per file: frontmatter fields, body, content hash (incremental sync) |
vectors | 384-d float32 embedding (fastembed BAAI/bge-small-en-v1.5, local, offline after first download; cosine in numpy) |
entities / mentions / edges | the knowledge graph: typed entities, which memory names them, typed relations with the memory that asserted them |
links | memory → memory (contradicts, superseded_by) |
Entity types: file, repo, tool, library, service, person, concept, ticket, command. Relations: uses, depends_on, part_of, configures, fixed, caused, contradicts, supersedes, related_to.
- Graph extraction: the session-end distiller returns a
graphfor each episode and candidate, which is saved as the sidecar. This costs no extra LLM call. For memories without a sidecar, runmemory_index.py backfill: one haiku call per 6 memories. - Sync: hash-based, so only changed files (or sidecars) are re-indexed and re-embedded. Recall syncs the current project and global dirs on every prompt. The distiller syncs after writing.
- Python: fastembed and numpy live in
memory_tools/.venv(uv, Python 3.12). The recall and distill hooks run with that interpreter, falling back to systempython3. Without it, the index keeps SQL + graph and recall drops the vector signal. Trace and maintain stay stdlib-only.
Inspect it:
P=~/.claude/memory_tools/.venv/bin/python
$P ~/.claude/memory_tools/memory_index.py stats
$P ~/.claude/memory_tools/memory_index.py query "why do graph tests fail"
$P ~/.claude/memory_tools/memory_index.py graph postgres
sqlite3 ~/.claude/memory_tools/data/memory.db 'select tier, count(*) from memories group by 1'
Per-prompt recall (memory_recall.py)
On each prompt (≥ 2 content words; stopwords dropped, plural -s stripped), three ranked
lists are built over the active project + global memories:
- bm25:
BM25(prompt, name + description + body). A memory must share ≥ 2 distinct prompt terms; for long prompts the bar is 15 % of them, capped at 4. When vectors exist, a keyword hit with cosine <BM25_VEC_FLOOR = 0.50is dropped as noise. - vector: cosine to the prompt embedding, kept when ≥
VEC_MIN = 0.66. Calibrated on these memories: unrelated prompts score about 0.47, the right memory 0.68–0.80. - graph: memories naming an entity that appears in the prompt score 1.0 per entity;
memories naming a 1-hop neighbour of it score 0.5. Hub neighbours (named in more than 3
memories, e.g.
pytest, a repo name) are not expanded, since they would link everything.
They are fused with RRF (k = 60), multiplied by the memory score, cut at half the best hit,
and up to TOP_K = 3 are printed with absolute paths and the signals that fired
(e.g. [bm25+vector+graph:postgres]). Each memory is injected at most once per session
(_traces/<sid>.recall). If the index fails, recall falls back to bm25 alone. Cost is about
0.6 s per prompt, mostly loading the embedding model; no LLM.
Maintenance (memory_maintain.py)
python3 memory_maintain.py [MEMORY_DIR] [--global DIR] # maintain + score table
python3 memory_maintain.py [MEMORY_DIR] [--global DIR] --dry-run # report only
python3 memory_maintain.py [MEMORY_DIR] [--global DIR] --inject # maintain + summary
Without MEMORY_DIR, --inject takes it from the hook's transcript_path on stdin, else from
cwd. Read-only on memory bodies. It:
- scores
confidence × exp(−days_since(last_reinforced) / half_life[tier]); - supersedes memories contradicted by an active memory (see Supersession);
- archives
score < 0.10, unpinned, non-idea/rulememories to_archive/(move, never delete); - rewrites
MEMORY.mdby score, preserving hand-written hooks, excluding provisional; - deletes
_traces/*older than 30 days (raw logs, not memories); --injectprints a block grouped Preferences / Procedural / Facts / Recent episodes (global preferences merged, markedglobal),SUMMARY_TOP_Ksplit round-robin across sections, plus_trace: last session: N tool calls · distilled ✓|pending_and a footer (re-verify, promote, near-duplicates, archived, provisional awaiting corroboration).
Memory → skill suggestions (skill_maintain.py)
A rule that lives only in memory works when recall happens to inject it; a rule in a skill
loads whenever that skill does. At session start, skill_maintain.py --inject prints one
line when memories are ready to become skill rules:
-
Eligible: tier
preferenceorprocedural, not provisional or superseded, confidence ≥SUGGEST_MIN_CONFIDENCE = 0.7or reinforced at least once, and noimplemented_in. -
Target: only the user's own skills, i.e.
~/.claude/skills/*not listed in.antigravity-install-manifest.json(a reinstall would overwrite edits to those). The memory description is embedded against<skill>: <description>; cosine ≥SKILL_MATCH_MIN = 0.70→update <skill>, elsenew skill. Calibrated on the current memories: the real match scored 0.75, unrelated pairs ≤ 0.66. Without fastembed the target is reported as unchecked. -
Cache:
data/skill_suggest_cache.json, keyed on the memory description and all own skills' descriptions, so the model (≈1 s) loads only when one of them changes. -
Apply: agent work, only after the user accepts (see the
memoryskill). Then setimplemented_in: <skill>on the memory, orimplemented_in: declined, and the suggestion stops. The memory itself stays and is still recalled.P=~/.claude/memory_tools/.venv/bin/python $P ~/.claude/memory_tools/skill_maintain.py --global ~/.claude/memory_global # full report
Frontmatter (under metadata:)
type, tier, confidence, reinforced, last_reinforced (decay anchor, not mtime),
status (provisional|active|superseded), implemented_in (skill name or declined), contradicts, superseded_by, superseded_at, revisit (pins), created, originSessionId.
Tests
python3 -m unittest discover ~/.claude/memory_tools/tests -v
Tunables live at the top of each script. Agent-side workflow (inspect, search, remember,
reinforce, supersede, forget): the memory skill, ~/.claude/skills/memory/SKILL.md.
License
Apache License 2.0; see the LICENSE file.