Using claude-memory after install¶
Day-to-day operation on the laptop once INSTALL.md is done. Server-side
operations (backups, restore, upgrades) are in ../DEPLOY.md.
Check that it works¶
Open a new Claude Code session (hooks and MCP servers are read at
session start) in any repo and ask something you know is stored, e.g.
"which branch do I open the PR into" from billing-service. The prompt gets a
short "Possible relevant memories" block with title / repo / id lines.
No block means nothing scored above MEMORY_HOOK_SIM_THRESHOLD (0.50) — or
something is down; check with:
claude mcp list | grep claude-memory # ✔ Connected
ollama ps # bge-m3 … UNTIL Forever (after first use)
echo '{"prompt":"which branch do I open the PR into","cwd":"'"$PWD"'"}' \
| ~/.claude/hooks/claude-memory/user-prompt-submit.sh
The hook is silent on any error by design (it must never block a prompt).
For details: MEMORY_DEBUG=1 claude-memory hook < input.json.
What leaves your machine¶
- Embeddings are local. Text is embedded by your own Ollama instance; it is never sent elsewhere for embedding.
- Extraction sends text to Anthropic. Session transcripts (at session end) and PR text (during PR ingest) are sent to Anthropic through your own
claude -p --model haikucall, using your own Claude Code login. Before sending, the text is run through the built-in secret scrubber (tokens, API keys, JWTs, bearer headers, connection strings with passwords, private keys,password=-style assignments). - The scrub is pattern-based and best-effort, not a guarantee. A secret in an unusual shape will not be caught. Do not rely on it for highly sensitive sessions; disable the hook for those.
- Nothing is sent to any other third party.
- PR ingest calls the provider CLIs (
az,gh,glab) on your machine with your own credentials, to read PRs from your own repositories.
What gets captured, and how¶
| Path | When | Needs |
|---|---|---|
| Hook cards (read) | every prompt | hooks in ~/.claude/settings.json |
| Session extraction | on SessionEnd, sessions with file edits or > 20 messages |
hooks; uses claude -p (haiku) |
/remember |
when you ask | skill in ~/.claude/skills/ |
| Inline capture by Claude | during work, without asking | the rules from claude-md-snippet.md pasted into the shared CLAUDE.md — without them Claude does not store or search on its own |
| PR ingest | manual or launchd | az / gh / glab logged in, MEMORY_PR_INGEST_REPOS |
/memory-digest summarizes what memory holds for a repo.
PR ingest (run manually)¶
Each PR costs two haiku calls; the first run looks back 30 days
(MEMORY_PR_INGEST_LOOKBACK). Run it when you have quota to spare:
claude-memory ingest-pr --dry-run # what would be ingested, no haiku, no writes
claude-memory ingest-pr # real run; cursors in ~/.local/state/claude-memory
# one repo only
MEMORY_PR_INGEST_REPOS=$HOME/work/acme/billing-service claude-memory ingest-pr --dry-run
A linked git worktree is not ingested separately when its main checkout is also among the discovered repos (same origin, same PRs); with no main checkout in the list it is ingested as usual.
Azure DevOps (az), GitHub (gh) and GitLab (glab) repos are ingested;
the provider comes from the repo's origin. Log in once per CLI
(gh auth login, glab auth login); claude-memory doctor shows the
tools.gh / tools.glab rows. Re-running after a failure is safe: a PR whose
haiku extraction fails (call error, error envelope, unparsable output) is not
passed by the cursor. The cursor stays at the last PR processed before it, that
repo's batch stops with a WARN, and the next run retries it. A PR that fails 3
runs in a row is skipped with an ERROR log naming it (so one bad PR cannot
block a repo forever); its knowledge is not extracted.
Cursors live in ~/.local/state/claude-memory/pr-cursors/<provider>__<repo>.json:
cat ~/.local/state/claude-memory/pr-cursors/*.json # since, failed_pr, failures
# re-ingest from a date: edit "since" (and delete "failed_pr"/"failures")
# re-ingest a repo from the lookback window: delete its file
rm ~/.local/state/claude-memory/pr-cursors/azuredevops__billing-service.json
failed_pr/failures count consecutive failed runs of the PR currently
blocking that repo.
- GitHub is
github.comonly (no GitHub Enterprise). - GitLab is auto-detected for
gitlab.comonly. A self-hosted GitLab needs an explicit opt-in on its namespace (see below). The GitLab adapter is not verified against a live GitLab (tested with fixtures written from the API docs): start with--dry-run. The GitHub adapter is also tested with hand-written fixtures; live GitHub verification is a manual step (--dry-run, then one real run). - Only trusted authors are ingested (GitHub:
OWNER/MEMBER/COLLABORATOR; GitLab: Developer or higher), and bots are skipped. PR text from strangers on a public repo would otherwise become memory records that are injected into your sessions. Only their comments are kept, and PR text is scrubbed and capped before it reaches haiku. - The CLIs run with
GH_TOKEN/GITHUB_TOKEN/GITLAB_TOKENand similar variables removed from their environment: they use their own login (keychain), never a token from claude-memory's environment. - A first run on a busy repo can hit the page cap (1000 PRs updated since the
cursor); the error names the cursor file: move its
sinceforward.
Per-namespace opt-out or provider override in namespaces.yaml:
namespaces:
- namespace: sandbox
paths: ["~/src/sandbox/**"]
pr_ingest: {enabled: false} # skip this namespace's repos
- namespace: corp
paths: ["~/work/corp/**"]
pr_ingest: {provider: gitlab} # self-hosted GitLab (host from origin)
A bad pr_ingest never breaks namespace resolution: ingest-pr skips that
namespace with a warning and doctor reports it on the namespaces row.
To schedule it daily instead, load the launchd job from INSTALL.md step 8.
Internal claude -p calls and CLAUDE_MEMORY_SUBPROCESS¶
Extraction (SessionEnd and ingest-pr) shells out to
claude -p --model haiku --output-format json --no-session-persistence
--strict-mcp-config --tools "": no saved session, no MCP servers, no tools.
The subprocess runs with CLAUDE_MEMORY_SUBPROCESS=1, and claude-memory hook
and claude-memory extract exit 0 immediately (hook prints nothing) when it is
set. Without that guard your own hooks would inject memory hints into the
extraction prompt and run extract on the call's own transcript. Your own
scripts that call claude -p for something unrelated can set the same variable
to keep claude-memory's hooks out of them.
Cleanup¶
Candidates nobody used or re-saw for 180 days (MEMORY_CANDIDATE_TTL) are
deleted; active and deprecated records are never touched.
claude-memory cleanup
It prints the number of deleted candidates, then the number of usage events
pruned (events older than 365 days), and a third line
spool: N draining, M failed files left only when spool files remain (see
Usage stats). It also moves any pending hook events into the database and
removes per-session staleness caches older than 7 days.
Usage stats¶
The hook, memory_feedback and every write record small usage events (record
ids, enums and numbers only, never text) in the events table. The hook only
appends them to ~/.local/state/claude-memory/events/spool.jsonl; serve,
extract, ingest-pr and cleanup move the spool into Postgres.
claude-memory stats [--since 30d] [--json] # window: Nd or a Go duration, e.g. 36h
One block for all namespaces and one per namespace: cards injected and
distinct records, the precision proxy (records marked useful within 2 h of a
card / distinct records injected) next to the same ratio without the 2 h limit,
feedback by outcome, records created by source and the current inventory,
candidate-to-active promotion rate (imported candidates are left out and shown
on their own import promotion line), stale-flag rate and check coverage (cards
whose staleness could be checked), superseded/deprecated/TTL-deleted counts.
A ratio with no data prints n/a. The last line, N events still in the
spool, tells you the report may lag. The proxy depends on Claude calling
memory_feedback(useful); compare trends, not absolute numbers.
Reliability counters¶
Memory fails softly, so stats also counts the calls themselves: one event per
memory_search / memory_store call of the MCP server and one per hook run
(searches only). Events hold an outcome (ok, degraded for an embedding-down
fallback, error, and for stores added, updated, superseded, noop,
needs_judgment) and, for errors, a class: db_unavailable,
embedding_unavailable, invalid_request, timeout or internal. Never a
query, title, content or error text. serve runs under a random session id, so
the latest serve session line shows the most recent session across all namespaces (not necessarily the current one): its searches, store
attempts and failures. Each block prints searches (mcp), searches (hook),
store attempts, failures, failure rate and db-down hours (UTC clock
hours with at least one db_unavailable event).
- Events go through the file spool, so they are recorded while Postgres is down.
statsadds spool events the table does not hold yet (reliability events counted from the spool), so the report is current without a drain. - The hook now appends on every prompt, sharing the 10 MB spool cap with card
events. Past the cap events are dropped and not counted:
statsthen printsspool is full: new events are being dropped and not counted.servestart,cleanup,extractandingest-prdrain the spool. - Mixed versions: an old binary draining the spool skips the new event types and deletes the file (logged at Debug only), so those events are lost. This matters only while old and new binaries run side by side.
- A
timeoutclass can hide an unreachable database inserve: a connect that hits the deadline surfaces as a plain deadline error. The hook records any service-build failure asdb_unavailable. statsreads the table counts, then scans the spool. A drain between the two can briefly undercount, never double count.- Writes by
extract,ingest-prandimportare not counted. - An outage that spans a whole session is invisible to the MCP counters (
servedoes not start when Postgres is down); only the hook'sdb_unavailableevents show it.
Managing records from the terminal¶
Look at and fix memory without a Claude session. Every command works on one
namespace: the current directory's, or --namespace NAME (which always wins).
Writes go through the same path as the MCP tools (scrubbing, re-embedding,
events), so edit needs Ollama; ls, show, rm and promote do not.
claude-memory ls [--status S] [--kind K] [--repo R] [--limit N]
claude-memory show ID
claude-memory rm ID [--reason TEXT] # deprecates (reversible)
claude-memory rm ID --hard [--yes] # deletes the row; asks y/N first
claude-memory edit ID # opens $VISUAL, else $EDITOR, else vi
claude-memory promote ID # candidate -> active
claude-memory review # weekly pass over candidates
- ids are the full UUID or the 8-hex short id that
lsprints. A short id is matched only inside the namespace; aglobalrecord needs its full UUID.rm,edit,promoteandreviewrefuse a record of another namespace (includingglobal): pass--namespace globalfor those. lsshowscandidateandactiverecords, newest first (50 by default,--limit 0= all);--status deprecatedshows the retired ones.showprints every field, the content and a stale line:stale: N commits,stale,freshorunchecked(unchecked unless the record's repo is the current directory's git checkout and it has a baseline commit and files).rm --hardasksdelete <id> "<title>" permanently? [y/N]; EOF and an empty answer mean no,--yesskips the question. It refuses a record that another record'ssuperseded_bypoints at; deprecate it instead.editshowstitle:,tags:,files:(comma-separated), a---line and the content. Nothing changed means nothing written; an emptytags:/files:line cannot clear them. If the editor fails or the file does not parse, nothing is written and the temp file is kept (its path is printed).reviewwalks candidates newest first. Per record it shows age, counts, the stale line, tags, files, the first 15 content lines and up to 3 similar records, then asks[a]pprove [e]dit [d]eprecate [x]delete [s]kip [v]iew [q]uit:apromotes,dasks a reason (Enter =rejected in review),xasks y/N,sor Enter skips,vprints the whole content,qor EOF stops. EOF at the reason or y/N prompt aborts that action. It prints a summary of the counts at the end. It reads plain lines, so it can be scripted.
Importing existing knowledge¶
One-off import of Claude Code auto-memory files and INSIGHTS.md logs as
candidate records (source import, tags imported plus auto-memory or
insights). Run --dry-run first: it needs no database, Ollama or env file
and prints, per item, the kind, repo, the scrubbed title, the file count, an
import-key prefix and would redact: yes|no, plus every skip with its reason
(never file content). It does not evaluate dedup against existing records.
claude-memory import automem --dry-run [--projects-dir DIR] [--namespace NS]
claude-memory import automem # real run: needs Postgres and Ollama
claude-memory import insights [PATH...] [--dry-run] [--namespace NS]
- Namespace. Imports go to
--namespace, else the current directory's namespace fromnamespaces.yaml(MEMORY_NAMESPACEis ignored). With no mapping for the directory and no--namespacethe command refuses (exit 2);globalonly with an explicit--namespace global. An item whose own directory maps to another namespace is skipped (other namespace (X)). - automem reads
<projects-dir>/*/memory/*.md(default~/.claude/projects), regular files only.MEMORY.md,CLAUDE.md, files without frontmatter ordescription, andtype: userare skipped;feedbackbecomesconvention,projectdecision,referencepattern. The repo is the git toplevel's name of the decoded project directory, else*. - insights reads dated bullets (
- YYYY-MM-DD — text) underWhat Works,Codebase Patterns(pattern) andWhat Doesn't Work,Recurring Errors & Fixes,Tool & Library Notes(gotcha); other sections (includingOpen Questions,Session Notes) are skipped. A PATH is a file or a directory (default: the git toplevel of the current directory), walked without following symlinks. Backticked file references that name an existing file inside the checkout become the record'sfiles(no commit baseline). - Re-runs are no-ops. Each item has an import key (a hash of its location
and text) stored on the record, so a second run prints
skipped (already imported)even after you edited or deprecated the imported record. A new item whose similarity to an existing record is 0.85 or more is skipped asduplicate of <id>; all writes go through the normal path (scrubbing, embedding, events). A bad item (for example oversized) is skipped with a reason; a database or Ollama error stops the run, and running it again continues where it stopped. - TTL. Imported candidates are ordinary candidates:
cleanupdeletes the unreviewed ones after the candidate TTL, and a later import re-creates them (the key is deleted with the row). The run ends withN candidates awaitclaude-memory review; unreviewed candidates are deleted after <TTL> days (MEMORY_CANDIDATE_TTL)(default 180): review them soon after importing. - Live before review. Imported candidates are searchable and are injected
into sessions (marked unverified) immediately, before you review them, and
the database is shared. Run
--dry-runfirst and review soon. - Files that are not valid UTF-8 or contain NUL bytes, symlinked
memorydirectories and undated or malformed INSIGHTS bullets are skipped with a reason.
Seeding and correcting records¶
claude-memory seed --file seed/facts.yaml --dry-run # parse only
claude-memory seed --file seed/facts.yaml # store via the normal write path
Re-running seed is safe: near-identical facts resolve to NOOP. A fact whose
closest existing record scores 0.65–0.85 is reported as
ASK (needs manual judgment, not stored) — store it from a Claude session
with memory_store + decision: {"action": "ADD"} (or UPDATE with
target_id). Fix an existing record with memory_update, retire it with
memory_deprecate.
Config reference¶
~/.config/claude-memory/env (must be chmod 600, otherwise it is refused):
| Variable | Default | Meaning |
|---|---|---|
MEMORY_PG_DSN |
— (required) | postgresql://claude_memory:<pw>@<server-tailscale-ip>:5432/claude_memory |
MEMORY_OLLAMA_URL |
http://127.0.0.1:11434 |
local Ollama |
MEMORY_OLLAMA_MODEL |
bge-m3 |
embedding model |
MEMORY_EMBED_MAX_TOKENS |
2048 |
embedding input cap (Ollama num_batch) |
MEMORY_HOOK_SIM_THRESHOLD |
0.50 |
min cosine similarity for a hook card |
MEMORY_STORE_SIM_ASK / _UPDATE |
0.65 / 0.85 |
inline dedup bands |
MEMORY_HOOK_TIMEOUT |
800ms |
hard hook timeout |
MEMORY_EXTRACT_MIN_MESSAGES |
20 |
session extraction gate |
MEMORY_CANDIDATE_TTL |
180d |
cleanup age for unused candidates |
MEMORY_PR_INGEST_REPOS |
— | comma-separated repo dirs or roots |
MEMORY_PR_INGEST_LOOKBACK |
30d |
first-run window per repo |
Troubleshooting¶
| Symptom | Check |
|---|---|
| No cards ever | claude mcp list; Tailscale up; psql to the DSN works; prompt really matches something (memory_search from a session) |
| First prompt after reboot slow | Ollama loads the model on first use; warm it: curl -s localhost:11434/api/embed -d '{"model":"bge-m3","input":"warmup","keep_alive":-1}' |
env file … permissions error |
chmod 600 ~/.config/claude-memory/env |
| Away from home network | Hook stays silent, memory_store returns an "unavailable" error (no offline queue yet) |
| Undo the hooks | restore ~/.claude/settings.json.bak-claude-memory or remove the hooks entries; see INSTALL.md → Uninstall |