ai newsDi., 3. Nov.
EilmeldungClaude's API Now Holds Your Agent's Conversation for 5 Minutes. Keep Your Own Copy

How I Gave Claude Code Long-Term Memory With Markdown and Python Hooks

Claude Code forgets past sessions. Discover how a three-layer memory system preserves decisions, mistakes, and context using Python and Markdown.

HackerNoonDienstag 05:44 · 03.11.11 Min. Lesezeit
Bild: HackerNoon

Signup Write New Story How I Gave Claude Code Long-Term Memory With Markdown and Python Hooks by lachy by lachy | @ lachlancb Growth Operator. I build AI systems for agency owners and founders, and run my own business from Claude Code. Based in Chiang Mai.

Growth Operator. I build AI systems for agency owners and founders, and run my own business from Claude Code. Based in Chiang Mai.

Claude Code forgets everything when a session ends. Close the terminal and the decisions, the dead ends and the "never do that again" lessons go with it. The next session starts from zero, and you pay for it in re-explaining.

I run my agency and side projects from one Claude Code workspace, and most of this year has gone into fixing that one problem. The fix isn't a plugin or an MCP server. It's three layers of plain files, plus four small Python hooks that move context in and out at the right moments.

This post is the code. Everything below runs on my machine today: an Obsidian vault of just over 1,500 notes, 170 memory files, 186 skills, and about 30 hook commands across 10 Claude Code events. This post covers the four hooks that handle memory.

Three layers, each answering a different question:

What I know: strategy, SOPs, people, decisions, diary

How to work with me: corrections, gotchas, preferences

Session state: last handoff, learned failures, skill hints

Over one session, the flow looks like this:

SessionStart -> session-handoff.py read + load-instincts.py -> printed into context UserPromptSubmit -> skill-suggest.py -> one hint line (during the task) Claude greps and reads vault files; nested CLAUDE.md files load with them Stop -> session-handoff.py write + extract-instincts.py -> files on disk One rule sits under all of it: inject pointers, not content. Nothing loads the whole vault. The hooks add a few hundred tokens at most, and everything else is fetched with grep when the task needs it.

Obsidian is markdown files in a folder. Put that folder inside the workspace Claude Code runs in and the integration is done. Claude reads, greps and edits notes with the same tools it uses on code. No API, no auth, no rate limit.

What makes it work is a CLAUDE.md at the vault root that acts as a router. Mine is mostly two tables, one for where to look and one for where to save. Trimmed:

## Knowledge Routing — Where to Find Things | Question | Look Here | |-----------------------------------|----------------------------------------------| | Context on a person? | `crm/{firstname}-{role}.md` | | What happened today/this week? | `diary/YYYY-MM-DD.md` | | Past decisions and why? | `intelligence/decisions.md` | | How to run a process? | `operational/` — find by cadence or topic | ## Where to SAVE Things | What You Learned | Save To | |-----------------------------------|----------------------------------------------| | Interaction with a person | `crm/{name}.md` → `## Interactions` section | | Strategic decision with reasoning | `intelligence/decision-YYYY-MM-DD-topic.md` | | Reusable prompt or template | `resources/prompt-purpose.md` | Two Claude Code behaviours keep this cheap.

Nested CLAUDE.md files load lazily. The root file loads at startup. A CLAUDE.md inside a subfolder only loads when Claude reads a file in that subfolder. The vault's router costs nothing during a coding session and appears the moment Claude opens a note. Every project folder in the workspace works the same way.

@ imports keep rules in one place. My workspace root CLAUDE.md is nearly empty:

# Dev Workspace — Claude Code > Shared workspace rules live in `AGENTS.md` (imported below) — it is the single source of > truth, and shared rules change there, not here. @AGENTS.md The @AGENTS.md line pulls that whole file in. Codex reads AGENTS.md directly, so both agents get the same rules from one file.

The "Where to SAVE" table is the half most setups skip. Without it, Claude reads the vault and never feeds it. With it, the end of a task routinely includes writing a decision, a CRM line or a lesson back to the right folder. The vault grows from use. It held about 400 notes when I first wrote about this setup in April. It holds just over 1,500 now.

Vault notes skip YAML frontmatter and open with a plain classification block instead:

# LachlanCB Brand OS — Master Index ## Classification **Type:** Operational (Master Index / Map-of-Content) **Brand:** LachlanCB — this file is the hub. The **Brand:** line earns its place. I run two brands with different audiences, and grep -rn '\*\*Brand:\*\*' knowledge/ lists which brand owns every file. That line exists because Claude kept briefing one brand's content from the other brand's notes.

The vault holds what I know. A second store holds how Claude should work with me. Claude Code's auto-memory lives in a per-project folder under ~/.claude/projects/<project>/memory/, and I keep it strict: one file per fact. A real one, trimmed:

--- name: feedback_pathspec-commit description: "Commit with an explicit pathspec in one step — a concurrent session's plain `git commit` sweeps whatever is staged in the shared index" metadata: type: feedback --- Concurrent Claude sessions share ONE git index. Staging explicit paths is not enough: whatever sits staged between your `git add` and your `git commit` belongs to whichever session commits first. **Why:** I staged 38 files; in the seconds before my commit, another session ran a plain `git commit` and swept all 38 into its own commit. **How to apply:** `git commit -m "…" -- path1 path2`, then check `git show --stat HEAD` holds only your files. Related: [[feedback_pre-deploy-concurrent-session-check]] The Why line matters more than the rule.

A bare rule gets applied rigidly or argued away. With the incident attached, Claude can judge whether an edge case is the same situation.

MEMORY.md is the index, and it loads into every session. That makes it the expensive part, so it holds pointers only, one line per memory:

- [Pathspec commit](feedback_pathspec-commit.md) — 🔒 `git commit -- <paths>` - [Don't kill long installs](feedback_dont-kill-long-installs.md) — 🔒 silence ≠ hung - [Never ask for secrets in chat](feedback_never-ask-for-secrets-in-chat.md) — 🔒 DB reset, stdin script or the secrets file instead I cap it at about 17 KB. It's at 19 KB as I write this, so it's due a prune. Memory that only grows turns into noise, and the pruning is the maintenance cost of the whole system.

Hooks are commands Claude Code runs on lifecycle events. Each one receives the event as JSON on stdin. For SessionStart and UserPromptSubmit, anything a hook prints to stdout on exit code 0 is added to Claude's context. That is the entire mechanism for injecting memory.

The wiring in ~/.claude/settings.json, simplified (paths shortened; use absolute paths in yours):

{ "hooks": { "SessionStart": [ { "hooks": [ { "type": "command", "command": "python ~/.claude/scripts/session-handoff.py read" }, { "type": "command", "command": "python ~/.claude/hooks/load-instincts.py" } ] } ], "UserPromptSubmit": [ { "hooks": [ { "type": "command", "command": "python ~/.claude/hooks/skill-suggest.py" } ] } ], "Stop": [ { "hooks": [ { "type": "command", "command": "python ~/.claude/scripts/session-handoff.py write" }, { "type": "command", "command": "python ~/.claude/hooks/extract-instincts.py" } ] } ] } } Hook 1: the session handoff When a session stops, one script writes a snapshot: modified files, commits from the last two hours, the tail of a scratchpad file and the current branch. At the next session start, the same script prints it back if it's under 48 hours old.

HANDOFF_PATH = Path(os.path.expanduser("~/.claude/HANDOFF.md")) WORKSPACE = "C:/Dev" def write_handoff(): """Stop hook: capture session state.""" sections = [f"# Session Handoff — {datetime.now():%Y-%m-%d %H:%M}\n"] try: status = subprocess.run( ["git", "--no-optional-locks", "-C", WORKSPACE, "status", "--porcelain"], capture_output=True, text=True, timeout=5, ).stdout.strip() if status: files = [line.strip() for line in status.splitlines()[:20]] sections += ["## Modified Files", "```", "\n".join(files), "```\n"] except Exception: pass #...same pattern for `git log --since=2.hours.ago`, the scratchpad tail, the branch if len(sections) > 1: HANDOFF_PATH.write_text("\n".join(sections), encoding="utf-8") def read_handoff(): """SessionStart hook: print the last handoff into context.""" if not HANDOFF_PATH.exists(): return age_hours = (datetime.now().timestamp() - HANDOFF_PATH.stat().st_mtime) / 3600 if age_hours > 48: return # stale context is worse than none print(f"## Last Session Handoff ({int(age_hours)}h ago)") print(HANDOFF_PATH.read_text(encoding="utf-8").strip()) The flag --no-optional-locks came from a painful bug. A plain git status takes.git/index.lock while it refreshes the index. My hook ran it with a 5-second timeout.

When the timeout killed git mid-write, the lock file stayed behind and blocked every commit in the workspace, once for 31 hours before I found it. A read-only status check should never take a lock, and with that flag it doesn't.

During a session, failed tool calls get logged to a JSONL file. At Stop, extract-instincts.py turns them into one-line "what failed, what works" rules, capped at 50. At the next session start, a 45-line loader prints them:

INSTINCTS_FILE = Path.home() / ".claude" / "instincts" / "learned-instincts.md" def main(): if not INSTINCTS_FILE.exists(): return content = INSTINCTS_FILE.read_text(encoding="utf-8").strip() count = content.count("\n- ") if count > 0: print(f"\n## Learned Instincts ({count} patterns)\n") print("These are patterns learned from past session failures. Follow them.\n") for line in content.split("\n"): if line.startswith("- "): print(line) Here's part of what landed at the top of a recent session:

## Learned Instincts (11 patterns) These are patterns learned from past session failures. Follow them. - Don't pipe curl output to python/sh — download first, then process (hook blocks it) - When editing docker-compose.yml, insert services inside the services: block, not append to end of file. - Notion REST API cannot duplicate pages with inline databases — use notion-duplicate-page MCP tool instead Each line exists because a session hit that wall once.

With 186 skills I can't remember which one exists for what. Letting Claude auto-fire them on fuzzy matches was worse, because a wrong skill costs more than no skill. So a UserPromptSubmit hook suggests up to three and leaves the decision to Claude.

It runs before every prompt, so it has to be fast and dependency-free. No embeddings, no model download, no API call. It's a TF-IDF retriever over each skill's name and description, standard library only:

MAX_SUGGESTIONS = 3 MIN_SCORE = 7.0 # absolute idf-sum floor; 3.0 let one common word clear it MIN_HITS = 2 # a skill must match at least two distinct prompt terms REL_GAP = 0.5 # drop results scoring under half the top score NAME_BOOST = 2.5 # a hit in the skill's name outweighs one in its description def tokenize(text): words = re.findall(r"[a-z0-9]+", text.lower()) return [w for w in words if len(w) >= 3 and w not in STOPWORDS] def build_index(): docs = [] # one entry per SKILL.md: {"name", "name_tokens", "tokens"} #...parse each SKILL.md's frontmatter into name + description tokens... df = {} for d in docs: for t in d["tokens"]: df[t] = df.get(t, 0) + 1 n = len(docs) idf = {t: math.log((n + 1) / (c + 0.5)) for t, c in df.items()} # cached to skill-index.json; rebuilt only when the skills dir mtime changes return {"n": n, "idf": idf, "docs": docs, "skills_mtime": _skills_mtime()} def score(prompt, index): qtokens = set(tokenize(prompt)) results = [] for d in index["docs"]: hits = qtokens & set(d["tokens"]) if len(hits) < MIN_HITS: continue s = sum(index["idf"].get(t, 0.0) * (NAME_BOOST if t in d["name_tokens"] else 1) for t in hits) results.append((s, d["name"])) return sorted(results, reverse=True) The IDF weighting does the useful work. A distinctive word like "carousel" appears in one or two skill descriptions and scores high. Generic words like "create" or "content" appear in dozens and score close to zero, and the stopword list removes the rest ("please", "thanks", "continue").

The thresholds came from living with it:

The output is one line, and its wording is deliberate:

Possibly relevant skills (suggestion, not auto-fired — invoke only if it fits): /blog-draft, /seo-audit That line lands in Claude's context, so it says outright that it's a hint and not an order.

A hook that hangs or crashes stalls every prompt, so each one carries a hard timeout and swallows its own errors:

# Hard timeout guard: never hang the prompt. _guard = threading.Timer(3.0, lambda: os._exit(0)) _guard.daemon = True # load-bearing, see below _guard.start() #...hook body... if __name__ == "__main__": try: main() except Exception: pass # never block the prompt daemon = True is there because of a real bug. Without it, the timer thread kept the interpreter alive until it fired, so every run took at least 3 seconds even after the hook's work was done. Every prompt paid that delay.

On Windows, every Python hook flashed a console window on screen. Each command in my settings.json actually runs through a small Node wrapper:

node ~/.claude/hooks/hidden-run.js -- python ~/.claude/hooks/skill-suggest.py It spawns the real command with windowsHide: true and passes stdin, stdout and the exit code straight through, so the hook contract is unchanged. It also resolves bare python to a real interpreter. Claude Code can spawn hooks with a reduced PATH where the only python on it is the Microsoft Store redirector, which prints an install prompt and exits with code 9009.

Skip the TF-IDF hook until you have enough skills to forget which ones you have.

I write about running a one-person agency on Claude Code at lachlancb.me.

Quelle: HackerNoon (HackerNoon). Original öffnen