Skip to content

Run memory automatically instead of hoping the model remembers - #4

Merged
Ninadnj merged 1 commit into
mainfrom
feat/session-hooks
Aug 14, 2026
Merged

Run memory automatically instead of hoping the model remembers#4
Ninadnj merged 1 commit into
mainfrom
feat/session-hooks

Conversation

@Ninadnj

@Ninadnj Ninadnj commented Aug 13, 2026

Copy link
Copy Markdown
Owner

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:

Hook What happens
SessionStart Injects the previous handoff, the previous session's note, and a few durable project facts — under a token budget — and records where the repo stood. Zero model discipline required.
SessionEnd Diffs against that marker: commits made this session, files still dirty. Writes nothing when nothing changed.
UserPromptSubmit (opt-in) Injects memories relevant to what was just asked. Fires per message and costs a model load, hence opt-in; 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; 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 on PATH — and entries written bare by an earlier version are still recognised, so upgrades don't duplicate.

MCP server instructions — vendor-neutral. Delivered in the initialize response, 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

Last handoff [claude-code]: Done: Added the limiter. Next: Add tests for the 429 path.

Last session: Session on branch main. Committed: Add rate limiting to /api/chat.

Project memories:
- [decision] Rate limiting uses a token bucket in server/limiter.ts, 20 req/min per IP.

Why (handoff), what (auto note), and the facts — assembled automatically.

Three bugs found while testing

  1. The store made every session look dirty. It lives inside the repo, so git status reported .agent_memory/ as user work and each session logged its own bookkeeping as a change.
  2. The fix for that was itself 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.
  3. The autosave was written and never read. SessionStart only surfaced project/decision types, so the SessionEnd note 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

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>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread src/agent_memory/hooks.py
Comment on lines +182 to +183
prompt = (payload.get("user_input") or "").strip()
if len(prompt) < 12: # "yes", "continue" — nothing to match on

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Comment thread src/agent_memory/hooks.py
Comment on lines +172 to +174
directory = _sessions_dir(store.path)
directory.mkdir(parents=True, exist_ok=True)
(directory / f"{session_id}.json").write_text(json.dumps(marker))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Comment thread src/agent_memory/hooks.py
Comment on lines +252 to +259
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)
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

@Ninadnj
Ninadnj merged commit 01da488 into main Aug 14, 2026
4 checks passed
@Ninadnj
Ninadnj deleted the feat/session-hooks branch August 14, 2026 18:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant