Run memory automatically instead of hoping the model remembers - #4
Conversation
Every tool in this package depended on the model choosing to call it, and
models forget — especially at session end, because the session just ends.
A store that stays empty makes the whole thing look useless.
Claude Code hooks (deterministic — the client runs these, not the model):
- SessionStart injects the previous handoff, the previous session's note
and a few durable project facts, all under a token budget, and records
where the repository stood.
- SessionEnd diffs against that marker and saves what actually happened:
commits made this session, files still dirty. Writes nothing when
nothing changed, so the store is not polluted with empty notes.
- UserPromptSubmit (opt-in) injects memories relevant to what was just
asked. It fires per message and costs a model load, hence opt-in, and
carries a 20s timeout because the client caps this event at 30s.
`agent-memory install-hooks` merges into .claude/settings.json without
touching hooks owned by other tools, and re-running replaces its own
entries rather than stacking copies. The installed command is an absolute
path, since the client spawns hooks without our virtualenv on PATH, and
entries written bare by an earlier version are still recognised.
Two rules hold throughout: a hook may never break a session (every entry
point swallows everything and falls back to empty output), and stdout is
a protocol channel, so only hook JSON goes there.
Vendor-neutral half: the MCP server now ships `instructions`, delivered
in the initialize response, teaching any client — Codex and Cursor
included, where hooks do not exist — when to boot, write and hand off.
Two bugs found while testing:
- The store lives inside the repository, so `git status` reported
.agent_memory/ as user work and every session logged its own
bookkeeping. Filtered out.
- The filter itself was wrong: `lstrip("./")` strips characters, not a
prefix, turning ".agent_memory/" into "agent_memory/" so it never
matched. Replaced with real prefix handling, parametrised over the
quoted and trailing-slash forms git actually emits.
- SessionStart originally ignored worklog entries, so the SessionEnd
autosave was written and never read. It now surfaces the last note.
Honest scope: reads are fully automatic now. Writes have a deterministic
floor derived from git — accurate and always written — but a note saying
"committed X" is weaker than a handoff explaining why. Only the model can
write that, so the instructions push for it; this engine makes no LLM
call of its own. Stated plainly in the README.
Tests 74 -> 123.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: c684e71b16
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| prompt = (payload.get("user_input") or "").strip() | ||
| if len(prompt) < 12: # "yes", "continue" — nothing to match on |
There was a problem hiding this comment.
Read the prompt from Claude's actual payload field
Claude Code's UserPromptSubmit payload supplies the submitted text in prompt, not user_input. Consequently every installed prompt-recall hook sees an empty string, returns at the length check, and never injects memories; the tests conceal this by constructing payloads with the same incorrect field name.
Useful? React with 👍 / 👎.
| directory = _sessions_dir(store.path) | ||
| directory.mkdir(parents=True, exist_ok=True) | ||
| (directory / f"{session_id}.json").write_text(json.dumps(marker)) |
There was a problem hiding this comment.
Preserve the original marker across context compaction
A matcher-less SessionStart hook also runs when Claude compacts an existing session, using the same session ID. Unconditionally rewriting this file then advances the saved HEAD to the compaction point, so commits made earlier in the session disappear from the eventual SessionEnd range; retain an existing marker or restrict marker creation to a genuinely new session.
Useful? React with 👍 / 👎.
| status = _git(root, "status", "--porcelain") or "" | ||
| dirty = sorted( | ||
| path | ||
| for path in { | ||
| line[3:].split(" -> ")[-1] for line in status.splitlines() if len(line) > 3 | ||
| } | ||
| if not _is_store_path(path) | ||
| ) |
There was a problem hiding this comment.
Compare dirty state with the session-start snapshot
When a repository is already dirty at SessionStart, this reads the entire current status at SessionEnd without comparing it to any starting status. Ending the session without touching those files therefore creates a worklog claiming they were session work, and repeated no-op sessions keep producing misleading notes; record and diff the initial porcelain state rather than treating every final dirty path as newly changed.
Useful? React with 👍 / 👎.
The last item from the "what would make this actually work day to day" list.
The problem
Every tool in this package depended on the model choosing to call it. Models forget — especially at the end of a session, because the session just ends. A store that stays empty makes the whole project look useless on day two.
Two mechanisms
Claude Code hooks — deterministic, because the client runs them rather than the model:
SessionStartSessionEndUserPromptSubmit(opt-in)Merges into
.claude/settings.jsonwithout touching hooks owned by other tools; re-running replaces its own entries instead of stacking copies. The installed command is an absolute path, since the client spawns hooks without our virtualenv onPATH— and entries written bare by an earlier version are still recognised, so upgrades don't duplicate.MCP server instructions — vendor-neutral. Delivered in the
initializeresponse, teaching any client (Codex, Cursor — where hooks don't exist) when to boot, write and hand off. Verified over the wire.What a fresh agent now gets, with no tool calls
Why (handoff), what (auto note), and the facts — assembled automatically.
Three bugs found while testing
git statusreported.agent_memory/as user work and each session logged its own bookkeeping as a change.lstrip("./")strips characters, not a prefix, turning.agent_memory/intoagent_memory/so it never matched. Replaced with real prefix handling, parametrised over the quoted and trailing-slash forms git actually emits.SessionStartonly surfacedproject/decisiontypes, so theSessionEndnote was invisible to the next session — making the whole write path pointless. It now leads with it.Robustness
Two rules hold throughout, both tested: a hook may never break a session (every entry point swallows everything and falls back to empty output — parametrised over empty, malformed, wrong-type and nonexistent-path payloads), and stdout is a protocol channel, so only hook JSON goes there.
Honest scope
Reads are now fully automatic. Writes have a deterministic floor from git — accurate and always written — but a note saying "committed X" is weaker than a handoff explaining why. Only the model can write that, so the server instructions push for it. Summarising a session properly needs an LLM call, which this engine deliberately does not make. That trade-off is stated plainly in the README rather than glossed as "automatic memory".
Tests: 74 → 123.
🤖 Generated with Claude Code