From c684e71b166f301614d752820b1d94ce465ae4d0 Mon Sep 17 00:00:00 2001 From: Nina Doinjashvili Date: Thu, 13 Aug 2026 23:38:56 +0300 Subject: [PATCH] Run memory automatically instead of hoping the model remembers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- README.md | 28 ++- src/agent_memory/cli.py | 63 +++++ src/agent_memory/hooks.py | 448 +++++++++++++++++++++++++++++++++ src/agent_memory/mcp_server.py | 30 ++- tests/test_hooks.py | 323 ++++++++++++++++++++++++ tests/test_mcp_server.py | 11 + 6 files changed, 900 insertions(+), 3 deletions(-) create mode 100644 src/agent_memory/hooks.py create mode 100644 tests/test_hooks.py diff --git a/README.md b/README.md index d9f057a..54ab9ef 100644 --- a/README.md +++ b/README.md @@ -107,6 +107,30 @@ env = { AGENT_MEMORY_AGENT = "codex" } Works with `mcp` 1.x and 2.x. Every memory carries its origin (`[decision · claude-code]`, `[handoff · codex]`), and a handoff written in one tool is picked up by the next. Agents may run at the same time: writes take a lock, merge, and land atomically, and a running server sees another agent's writes on its next read. +### Make it run without being asked + +Every tool above depends on the model *choosing* to call it — and models forget, especially at the end of a session, because the session just ends. Two mechanisms close that gap. + +**Claude Code hooks** (deterministic — the client runs them, not the model): + +```bash +agent-memory install-hooks # SessionStart + SessionEnd +agent-memory install-hooks --with-prompt-recall # also inject on every prompt +agent-memory install-hooks --uninstall +``` + +| Hook | What happens | +| --- | --- | +| `SessionStart` | Injects the previous session's handoff plus a few durable project facts, and records where the repo stood. **No model discipline needed.** | +| `SessionEnd` | Diffs against that marker and saves what actually changed — commits made, files still dirty. Writes nothing if nothing happened. | +| `UserPromptSubmit` *(opt-in)* | Injects memories relevant to what you just asked. Fires on every message, so it costs a model load each time. | + +It merges into `.claude/settings.json` without touching hooks belonging to other tools, and re-running replaces its own entries instead of stacking copies. Use `--user` to install for every project. + +**MCP server instructions** (vendor-neutral): the server tells any connecting client — Claude Code, Codex, Cursor — when to boot, write and hand off. That reaches the agents where hooks don't exist. Nothing to configure. + +**What this honestly does and doesn't do.** The *read* side is now fully automatic: memory arrives without anyone asking for it. The *write* side has a deterministic floor — the session note is derived from git, so it's accurate and always written — but a note saying "committed X, changed Y" is weaker than a handoff explaining *why*. Only the model can write that, so the instructions above push it to. Summarising a session properly would need an LLM call, which this engine deliberately does not make. + ### One store per project Memories are scoped to the repository you're working in: @@ -292,6 +316,7 @@ agent-memory list --type decision # ids, so you can fix mistakes agent-memory update mem_0003 "" # revise a memory agent-memory forget mem_0007 # delete a stale memory agent-memory stats # store path, counts, embedder +agent-memory install-hooks # run memory automatically (Claude Code) ``` Global flags: `--path` (explicit store), `--global` (cross-project store), `--agent` (who is writing). @@ -337,7 +362,7 @@ One memory per `##` section, with long sections split so every ingested memory s ## Limits -- **Nothing writes memories for you.** The agent has to choose to call `memory_write` and `memory_handoff`. If it doesn't, the store stays empty. Prompt instructions or a session hook make this reliable; that isn't built in yet. +- **Automatic writes are deterministic, not insightful.** With hooks installed, every session saves an accurate git-derived note, and reads need no prompting at all. But a handoff explaining *why* something was done still depends on the model choosing to write one — the server's instructions push for it, and this engine makes no LLM call of its own. - **Memories never expire.** `state` and `worklog` entries go stale but keep being recalled as confidently as a fresh decision. Correct them with `memory_update` / `memory_forget` until decay lands. - **Retrieval is lexical unless you install the `real` extra.** See [the comparison](#which-embedder-should-you-use). - **Memories are replayed verbatim into other agents' context.** Anything an agent writes — including text it read from a webpage, an issue tracker or a dependency — later reads as trusted project knowledge. Don't point a shared store at untrusted input, and skim `agent-memory list` occasionally. @@ -355,7 +380,6 @@ pytest -q ## Roadmap -- Session hooks, so writing a handoff doesn't depend on the agent remembering to. - Recency- and type-aware ranking (decay old `state`, never drop `decision`). - LLM-based compaction: summarise and dedup `state`/`worklog`, extract durable facts from a session transcript. - Optional FAISS backend for large stores. diff --git a/src/agent_memory/cli.py b/src/agent_memory/cli.py index 78685ff..bf89188 100644 --- a/src/agent_memory/cli.py +++ b/src/agent_memory/cli.py @@ -27,6 +27,7 @@ MEMORY_TYPES, MemoryStore, default_store_path, + find_project_root, relocation_notice, ) @@ -131,6 +132,36 @@ def cmd_forget(args) -> None: print(f"Forgot {args.id}." if ok else f"No memory with id {args.id}.") +def cmd_hook(args) -> None: + from . import hooks + + raise SystemExit(hooks.run(args._events[args.event])) + + +def cmd_install_hooks(args) -> None: + from . import hooks + + root = Path.home() if args.user else (find_project_root() or Path.cwd()) + settings = root / ".claude" / ("settings.json" if args.user else "settings.json") + + if args.uninstall: + hooks.uninstall(settings) + print(f"Removed agent-memory hooks from {settings}") + return + + events = ["SessionStart", "SessionEnd"] + if args.with_prompt_recall: + events.append("UserPromptSubmit") + for line in hooks.install(settings, events): + print(f" {line}") + print(f"Installed into {settings}") + if not args.with_prompt_recall: + print( + "Add --with-prompt-recall to also surface memories relevant to each " + "prompt (costs a model load per message)." + ) + + def cmd_stats(args) -> None: path = _resolve_path(args) s = MemoryStore(path=path).stats() @@ -206,6 +237,38 @@ def build_parser() -> argparse.ArgumentParser: s = sub.add_parser("stats", help="show store stats") s.set_defaults(func=cmd_stats) + + hook = sub.add_parser( + "hook", help="internal: run a Claude Code hook (reads JSON on stdin)" + ) + hook.add_argument( + "event", choices=["session-start", "session-end", "user-prompt"] + ) + hook.set_defaults( + func=cmd_hook, + _events={ + "session-start": "SessionStart", + "session-end": "SessionEnd", + "user-prompt": "UserPromptSubmit", + }, + ) + + ih = sub.add_parser( + "install-hooks", + help="wire memory into Claude Code so it runs without being asked", + ) + ih.add_argument( + "--with-prompt-recall", + action="store_true", + help="also inject memories relevant to each prompt (adds latency per message)", + ) + ih.add_argument("--uninstall", action="store_true", help="remove the hooks again") + ih.add_argument( + "--user", + action="store_true", + help="install for every project (~/.claude/settings.json)", + ) + ih.set_defaults(func=cmd_install_hooks) return parser diff --git a/src/agent_memory/hooks.py b/src/agent_memory/hooks.py new file mode 100644 index 0000000..011b1aa --- /dev/null +++ b/src/agent_memory/hooks.py @@ -0,0 +1,448 @@ +"""Claude Code hooks: make memory happen without the agent remembering to. + +Every tool in this package depends on the model *choosing* to call it. Models +forget, especially at the end of a session — the session simply ends. Hooks are +the deterministic half: the client runs them itself, so the read side needs no +model discipline at all, and the write side gets a floor of accurate history +even when nothing was written explicitly. + +Three events are used (schemas: https://code.claude.com/docs/en/hooks): + +* ``SessionStart`` — inject the previous session's handoff plus orientation + memories, and record a marker of where the repository stood. +* ``UserPromptSubmit`` — inject memories relevant to what was just asked. + Opt-in: it fires on every message and costs a model load each time. +* ``SessionEnd`` — diff against the marker and save what actually changed. + +Two rules hold everywhere in this module: + +1. **A hook must never break a session.** Every entry point catches everything + and falls back to empty output. +2. **stdout is a protocol channel.** Only the hook's JSON goes there; anything + informational goes to stderr. +""" + +from __future__ import annotations + +import json +import os +import subprocess +import sys +from datetime import datetime, timezone +from pathlib import Path +from typing import Optional + +from .embeddings import default_min_score +from .store import ( + PROJECT_STORE_DIR, + MemoryStore, + default_store_path, + find_project_root, +) + +# What SessionStart injects when there is no task to match against yet. +ORIENTATION_TYPES = ("project", "decision") +SESSION_START_BUDGET = 400 +PROMPT_RECALL_BUDGET = 200 + +HOOK_COMMAND = "agent-memory hook" + + +# ---- shared helpers -------------------------------------------------------- +def _sessions_dir(store_path: Path) -> Path: + return store_path.parent / "sessions" + + +def _git(root: Path, *args: str) -> Optional[str]: + """Run a read-only git command, or return None if git/the repo is unusable.""" + try: + out = subprocess.run( + ["git", *args], + cwd=root, + capture_output=True, + text=True, + timeout=10, + ) + except (OSError, subprocess.SubprocessError): + return None + return out.stdout.strip() if out.returncode == 0 else None + + +def _open_store(payload: dict) -> Optional[MemoryStore]: + """Resolve the store for the directory the session is running in.""" + cwd = payload.get("cwd") or os.getcwd() + try: + path = default_store_path(cwd) + return MemoryStore(path=path) + except Exception: # a broken store must not take the session with it + return None + + +def _context_output(event: str, context: str) -> dict: + """A hook response that injects text into the model's context.""" + if not context: + return {} + return { + "additionalContext": context, + "hookSpecificOutput": { + "hookEventName": event, + "additionalContext": context, + }, + } + + +def _render(hits) -> str: + return "\n".join( + f"- [{h.entry.type}" + + (f" · {h.entry.agent}" if h.entry.agent else "") + + f"] {h.entry.text}" + for h in hits + ) + + +# ---- SessionStart ---------------------------------------------------------- +def session_start(payload: dict) -> dict: + """Inject the last handoff plus orientation memories, and mark the repo state. + + No task is known yet — the user has not typed anything — so there is nothing + to match against. What is always relevant at session start is the previous + agent's handoff and a few durable project facts. + """ + store = _open_store(payload) + if store is None: + return {} + + _write_marker(payload, store) + + budget = SESSION_START_BUDGET + parts: list[str] = [] + + handoff = store.latest("handoff") + if handoff is not None and handoff.tokens <= budget: + parts.append(f"Last handoff [{handoff.agent or 'unknown'}]: {handoff.text}") + budget -= handoff.tokens + + # What the previous session actually did. Complements the handoff rather + # than repeating it: the handoff says why, this says what changed. Without + # it the SessionEnd autosave would be written and never read. + note = store.latest("worklog") + if note is not None and note.tokens <= budget: + parts.append(f"Last session: {note.text}") + budget -= note.tokens + + # Most recent durable facts, newest first, until the budget runs out. + orientation = [] + for entry in reversed(store.all()): + if entry.type not in ORIENTATION_TYPES: + continue + if entry.tokens > budget: + continue + orientation.append(entry) + budget -= entry.tokens + if len(orientation) >= 5: + break + if orientation: + parts.append( + "Project memories:\n" + + "\n".join(f"- [{e.type}] {e.text}" for e in orientation) + ) + + if not parts: + return {} + parts.append( + "(From agent-memory. Save durable facts with memory_write, and call " + "memory_handoff before the session ends.)" + ) + return _context_output("SessionStart", "\n\n".join(parts)) + + +def _write_marker(payload: dict, store: MemoryStore) -> None: + """Record where the repository stood, so SessionEnd can diff against it.""" + session_id = payload.get("session_id") + root = find_project_root(payload.get("cwd") or os.getcwd()) + if not session_id or root is None: + return + marker = { + "head": _git(root, "rev-parse", "HEAD"), + "branch": _git(root, "rev-parse", "--abbrev-ref", "HEAD"), + "started_at": datetime.now(timezone.utc).isoformat(timespec="seconds"), + "root": str(root), + } + try: + directory = _sessions_dir(store.path) + directory.mkdir(parents=True, exist_ok=True) + (directory / f"{session_id}.json").write_text(json.dumps(marker)) + except OSError: + pass # a missing marker only costs us the autosave + + +# ---- UserPromptSubmit ------------------------------------------------------ +def user_prompt(payload: dict) -> dict: + """Inject memories relevant to the prompt the user just submitted.""" + prompt = (payload.get("user_input") or "").strip() + if len(prompt) < 12: # "yes", "continue" — nothing to match on + return {} + store = _open_store(payload) + if store is None: + return {} + hits = store.recall( + prompt, + k=3, + budget_tokens=PROMPT_RECALL_BUDGET, + min_score=default_min_score(store.embedder), + ) + if not hits: + return {} + return _context_output( + "UserPromptSubmit", "Relevant memories:\n" + _render(hits) + ) + + +# ---- SessionEnd ------------------------------------------------------------ +def session_end(payload: dict) -> dict: + """Save what actually happened, derived from git rather than from the model. + + A model can write a better handoff than this, but only if it remembers to. + This is the floor: accurate, boring, and always written. + """ + store = _open_store(payload) + if store is None: + return {} + session_id = payload.get("session_id") + marker_file = _sessions_dir(store.path) / f"{session_id}.json" if session_id else None + + marker = {} + if marker_file is not None and marker_file.exists(): + try: + marker = json.loads(marker_file.read_text()) + except (OSError, ValueError): + marker = {} + + root = Path(marker.get("root") or "") if marker.get("root") else find_project_root( + payload.get("cwd") or os.getcwd() + ) + summary = _describe_session(root, marker) if root else None + + if marker_file is not None: + try: + marker_file.unlink(missing_ok=True) + except OSError: + pass + + if not summary: + return {} # nothing changed; do not pollute the store + try: + store.write(summary, type="worklog", agent=_agent_name()) + except Exception: + return {} + return {"systemMessage": "agent-memory: saved a session note."} + + +def _describe_session(root: Path, marker: dict) -> Optional[str]: + """One sentence about what changed, or None if nothing did.""" + branch = _git(root, "rev-parse", "--abbrev-ref", "HEAD") or marker.get("branch") + start_head = marker.get("head") + head = _git(root, "rev-parse", "HEAD") + + commits: list[str] = [] + if start_head and head and start_head != head: + log = _git(root, "log", "--format=%s", f"{start_head}..{head}") + commits = [line for line in (log or "").splitlines() if line] + + 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) + ) + + if not commits and not dirty: + return None + + bits = [f"Session on branch {branch}." if branch else "Session."] + if commits: + shown = "; ".join(commits[:3]) + more = f" (+{len(commits) - 3} more)" if len(commits) > 3 else "" + bits.append(f"Committed: {shown}{more}.") + if dirty: + shown = ", ".join(dirty[:6]) + more = f" (+{len(dirty) - 6} more)" if len(dirty) > 6 else "" + bits.append(f"Uncommitted changes in: {shown}{more}.") + return " ".join(bits) + + +def _is_store_path(path: str) -> bool: + """Is this git path our own store rather than the user's work? + + The store lives inside the repository, so without this every session would + report its own bookkeeping as changes the user made. Note git reports a + wholly untracked directory as ``.agent_memory/``, with the trailing slash. + """ + cleaned = path.strip('"') + if cleaned.startswith("./"): + cleaned = cleaned[2:] + cleaned = cleaned.rstrip("/") + return cleaned == PROJECT_STORE_DIR or cleaned.startswith(PROJECT_STORE_DIR + "/") + + +def _agent_name() -> str: + return os.environ.get("AGENT_MEMORY_AGENT", "claude-code") + + +# ---- settings.json wiring -------------------------------------------------- +EVENT_HANDLERS = { + "SessionStart": session_start, + "UserPromptSubmit": user_prompt, + "SessionEnd": session_end, +} + +_CLI_NAME = { + "SessionStart": "session-start", + "UserPromptSubmit": "user-prompt", + "SessionEnd": "session-end", +} + + +def _executable() -> str: + """Absolute path to this installation's CLI, falling back to the bare name. + + Hooks are spawned by the client, which may not have the virtualenv that + `agent-memory` lives in on its PATH. Pinning the path we are currently + running from makes the hook work regardless. + """ + import shutil + + found = shutil.which("agent-memory") + if found: + return found + candidate = Path(sys.executable).with_name("agent-memory") + return str(candidate) if candidate.exists() else "agent-memory" + + +def _hook_entry(event: str) -> dict: + entry = { + "type": "command", + "command": f"{_executable()} hook {_CLI_NAME[event]}", + } + if event == "UserPromptSubmit": + entry["timeout"] = 20 # the event's own limit is 30s + return entry + + +def _is_ours(hook: dict) -> bool: + """Match our hooks whether they were written bare or as an absolute path.""" + if not isinstance(hook, dict): + return False + return HOOK_COMMAND in str(hook.get("command", "")) + + +def install(settings_path: Path, events: list[str]) -> list[str]: + """Add our hooks to a settings file, preserving everything already there. + + Idempotent: re-running replaces our own entries rather than stacking copies, + and hooks belonging to other tools are never touched. + """ + settings = _load_settings(settings_path) + hooks = settings.setdefault("hooks", {}) + changes: list[str] = [] + + for event in EVENT_HANDLERS: + groups = hooks.get(event, []) + if not isinstance(groups, list): + continue + # Drop any previous version of our own hook for this event. + cleaned = [] + for group in groups: + if not isinstance(group, dict): + cleaned.append(group) + continue + kept = [h for h in group.get("hooks", []) if not _is_ours(h)] + if kept: + cleaned.append({**group, "hooks": kept}) + elif not group.get("hooks"): + cleaned.append(group) + if event in events: + entry = _hook_entry(event) + cleaned.append({"hooks": [entry]}) + changes.append(f"{event} -> {entry['command']}") + if cleaned: + hooks[event] = cleaned + else: + hooks.pop(event, None) + + if not hooks: + settings.pop("hooks", None) + _save_settings(settings_path, settings) + return changes + + +def uninstall(settings_path: Path) -> list[str]: + """Remove only our hooks, leaving any others in place.""" + return install(settings_path, events=[]) + + +def installed_events(settings_path: Path) -> list[str]: + settings = _load_settings(settings_path) + found = [] + for event, groups in (settings.get("hooks") or {}).items(): + if not isinstance(groups, list): + continue + if any( + _is_ours(h) + for group in groups + if isinstance(group, dict) + for h in group.get("hooks", []) + ): + found.append(event) + return found + + +def _load_settings(path: Path) -> dict: + if not path.exists(): + return {} + try: + data = json.loads(path.read_text()) + except (OSError, ValueError) as exc: + raise SystemExit( + f"{path} is not valid JSON ({exc}). Fix or move it before installing hooks." + ) + return data if isinstance(data, dict) else {} + + +def _save_settings(path: Path, settings: dict) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_name(f"{path.name}.{os.getpid()}.tmp") + tmp.write_text(json.dumps(settings, indent=2) + "\n") + os.replace(tmp, path) + + +# ---- entry point used by the CLI ------------------------------------------ +def run(event: str, stdin=None, stdout=None) -> int: + """Read the hook payload, emit the response, and never fail loudly. + + A raised exception here would surface as a hook error in the user's session + for something as minor as an unreadable store, so everything is swallowed + and reported on stderr. + """ + stdin = stdin or sys.stdin + stdout = stdout or sys.stdout + try: + raw = stdin.read() + payload = json.loads(raw) if raw.strip() else {} + if not isinstance(payload, dict): + payload = {} + except Exception: + payload = {} + + try: + result = EVENT_HANDLERS[event](payload) or {} + except Exception as exc: # never break the session + print(f"[agent-memory] {event} hook failed: {exc!r}", file=sys.stderr) + result = {} + + if result: + json.dump(result, stdout) + stdout.write("\n") + return 0 diff --git a/src/agent_memory/mcp_server.py b/src/agent_memory/mcp_server.py index 890756d..ce403f3 100644 --- a/src/agent_memory/mcp_server.py +++ b/src/agent_memory/mcp_server.py @@ -57,6 +57,31 @@ def _load_server_class(): ) from exc +# Sent to the client at initialize, so the model learns the workflow without +# anyone editing a CLAUDE.md. This is the vendor-neutral half of "memory should +# not depend on the model remembering": Claude Code can enforce the same habits +# with hooks, but Codex and Cursor have no equivalent, and they read this. +SERVER_INSTRUCTIONS = """\ +Durable project memory shared across coding agents and sessions. + +Use it like this: + +1. At the start of a session, call memory_boot with a one-line description of + the task. It returns the previous session's handoff plus relevant memories. + Do this before exploring the codebase — it often answers the question first. +2. While working, call memory_write the moment you learn something that will + still be true next week: an architectural decision, a non-obvious constraint, + a bug's root cause. Write one fact per call, in one or two sentences. Do not + write things that are already obvious from reading the code. +3. Before the session ends, call memory_handoff with what you finished, what + comes next, and anything the next agent should watch out for. + +If a memory turns out to be wrong or stale, fix it with memory_update or delete +it with memory_forget rather than writing a second, contradicting memory — both +would be recalled together. Find ids with memory_list. +""" + + def _tag(entry) -> str: return f"{entry.type} · {entry.agent}" if entry.agent else entry.type @@ -89,7 +114,10 @@ def build_server( store = MemoryStore(path=store_path) if min_score is None: min_score = default_min_score(store.embedder) - server = server_class("agent-memory") + try: + server = server_class("agent-memory", instructions=SERVER_INSTRUCTIONS) + except TypeError: # older mcp releases have no `instructions` parameter + server = server_class("agent-memory") @server.tool() def memory_write(text: str, type: str = "fact") -> str: diff --git a/tests/test_hooks.py b/tests/test_hooks.py new file mode 100644 index 0000000..0008588 --- /dev/null +++ b/tests/test_hooks.py @@ -0,0 +1,323 @@ +"""Session hooks — the half of memory that does not depend on the model. + +Two properties matter most here and are tested hardest: + +* a hook must never break the user's session, whatever it is handed; +* installing must never damage hooks belonging to other tools. +""" + +import io +from pathlib import Path +import json +import subprocess + +import pytest + +from agent_memory import hooks + + +@pytest.fixture(autouse=True) +def offline(monkeypatch): + monkeypatch.setenv("AGENT_MEMORY_EMBEDDER", "hashing") + monkeypatch.setenv("AGENT_MEMORY_AGENT", "claude-code") + + +@pytest.fixture +def repo(tmp_path): + """A real git repository with one commit.""" + root = tmp_path / "project" + root.mkdir() + run = lambda *a: subprocess.run(a, cwd=root, capture_output=True, check=True) + run("git", "init", "-q") + run("git", "config", "user.email", "t@example.com") + run("git", "config", "user.name", "Test") + (root / "app.py").write_text("v1\n") + run("git", "add", "-A") + run("git", "commit", "-qm", "initial commit") + return root + + +def payload(root, event, **extra): + return {"session_id": "sess-1", "cwd": str(root), "hook_event_name": event, **extra} + + +def store_for(root): + from agent_memory import MemoryStore + + return MemoryStore(path=root / ".agent_memory" / "store.json") + + +# ---- SessionStart ---------------------------------------------------------- +def test_session_start_injects_handoff_and_orientation(repo): + store = store_for(repo) + store.write("Bookings are stored in UTC.", type="decision", agent="claude-code") + store.write("Done: fixed emails. Next: add rate limiting.", type="handoff", agent="codex") + + out = hooks.session_start(payload(repo, "SessionStart")) + context = out["additionalContext"] + assert "Last handoff [codex]" in context + assert "rate limiting" in context + assert "Bookings are stored in UTC." in context + assert out["hookSpecificOutput"]["hookEventName"] == "SessionStart" + + +def test_session_start_surfaces_the_previous_session_note(repo): + """The SessionEnd autosave is pointless if the next session never reads it.""" + hooks.session_start(payload(repo, "SessionStart")) + (repo / "app.py").write_text("v2\n") + subprocess.run(["git", "add", "-A"], cwd=repo, check=True, capture_output=True) + subprocess.run(["git", "commit", "-qm", "Add rate limiting"], cwd=repo, check=True, capture_output=True) + hooks.session_end(payload(repo, "SessionEnd")) + + context = hooks.session_start(payload(repo, "SessionStart", session_id="next"))[ + "additionalContext" + ] + assert "Last session:" in context + assert "Add rate limiting" in context + + +def test_session_start_is_silent_on_an_empty_store(repo): + assert hooks.session_start(payload(repo, "SessionStart")) == {} + + +def test_session_start_respects_a_token_budget(repo, monkeypatch): + monkeypatch.setattr(hooks, "SESSION_START_BUDGET", 40) + store = store_for(repo) + for i in range(12): + store.write(f"Durable project fact number {i} about subsystem {i}.", type="project") + + from agent_memory.tokens import count_tokens + + context = hooks.session_start(payload(repo, "SessionStart"))["additionalContext"] + memories = [ln for ln in context.splitlines() if ln.startswith("- [")] + assert memories, "should still inject something" + assert sum(count_tokens(ln) for ln in memories) <= 40 + 10 # + list markers + + +def test_session_start_records_a_marker(repo): + hooks.session_start(payload(repo, "SessionStart")) + marker = repo / ".agent_memory" / "sessions" / "sess-1.json" + assert marker.exists() + assert json.loads(marker.read_text())["branch"] + + +# ---- SessionEnd ------------------------------------------------------------ +def test_session_end_saves_what_actually_changed(repo): + hooks.session_start(payload(repo, "SessionStart")) + + (repo / "app.py").write_text("v2\n") + subprocess.run(["git", "add", "-A"], cwd=repo, check=True, capture_output=True) + subprocess.run(["git", "commit", "-qm", "Add rate limiting"], cwd=repo, check=True, capture_output=True) + (repo / "notes.md").write_text("wip\n") + + out = hooks.session_end(payload(repo, "SessionEnd", end_reason="clear")) + assert "systemMessage" in out + + saved = [e for e in store_for(repo).all() if e.type == "worklog"] + assert len(saved) == 1 + text = saved[0].text + assert "Add rate limiting" in text # the commit made this session + assert "notes.md" in text # still-uncommitted work + assert saved[0].agent == "claude-code" + + +def test_session_end_writes_nothing_when_nothing_happened(repo): + """The store lives inside the repo, so its own files must not read as work.""" + hooks.session_start(payload(repo, "SessionStart")) + store_for(repo).write("A fact written during the session.", type="decision") + + assert hooks.session_end(payload(repo, "SessionEnd")) == {} + assert [e.type for e in store_for(repo).all()] == ["decision"] # no worklog note + + +def test_session_end_removes_the_marker(repo): + hooks.session_start(payload(repo, "SessionStart")) + marker = repo / ".agent_memory" / "sessions" / "sess-1.json" + assert marker.exists() + hooks.session_end(payload(repo, "SessionEnd")) + assert not marker.exists() + + +@pytest.mark.parametrize( + "path, is_ours", + [ + (".agent_memory/", True), # git reports untracked dirs this way + (".agent_memory/store.json", True), + ("./.agent_memory/sessions/a.json", True), + ('".agent_memory/odd name.json"', True), # quoted when it contains a space + ("agent_memory/real_code.py", False), # must not over-match + ("src/app.py", False), + (".agentic/notes.md", False), + ], +) +def test_only_our_own_bookkeeping_is_filtered_out(path, is_ours): + assert hooks._is_store_path(path) is is_ours + + +def test_session_end_outside_a_repository_is_harmless(tmp_path): + loose = tmp_path / "not-a-repo" + loose.mkdir() + assert hooks.session_end(payload(loose, "SessionEnd")) == {} + + +# ---- UserPromptSubmit ------------------------------------------------------ +def test_user_prompt_injects_relevant_memories(repo): + store_for(repo).write( + "Admin routes are guarded by requireAdmin in server/auth.ts.", type="decision" + ) + out = hooks.user_prompt( + payload(repo, "UserPromptSubmit", user_input="how do we protect the admin pages?") + ) + assert "requireAdmin" in out["additionalContext"] + + +def test_user_prompt_ignores_short_prompts(repo): + store_for(repo).write("Bookings are stored in UTC.", type="decision") + assert hooks.user_prompt(payload(repo, "UserPromptSubmit", user_input="yes")) == {} + + +def test_user_prompt_is_silent_when_nothing_is_relevant(repo): + store_for(repo).write("Bookings are stored in UTC.", type="decision") + out = hooks.user_prompt( + payload(repo, "UserPromptSubmit", user_input="what is the best sourdough bread recipe") + ) + assert out == {} + + +# ---- robustness: a hook must never break a session ------------------------- +@pytest.mark.parametrize( + "raw", ["", " ", "not json at all", "[1,2,3]", '{"cwd": null}', '{"cwd": "/nonexistent/xyz"}'] +) +@pytest.mark.parametrize("event", ["SessionStart", "SessionEnd", "UserPromptSubmit"]) +def test_run_survives_any_input(raw, event): + out = io.StringIO() + assert hooks.run(event, stdin=io.StringIO(raw), stdout=out) == 0 + printed = out.getvalue().strip() + if printed: + json.loads(printed) # whatever we emit must be valid JSON + + +def test_run_survives_a_handler_that_raises(monkeypatch, capsys): + monkeypatch.setitem( + hooks.EVENT_HANDLERS, "SessionStart", lambda p: (_ for _ in ()).throw(RuntimeError("boom")) + ) + out = io.StringIO() + assert hooks.run("SessionStart", stdin=io.StringIO("{}"), stdout=out) == 0 + assert out.getvalue() == "" # nothing on the protocol channel + assert "boom" in capsys.readouterr().err # reported on stderr instead + + +def test_run_emits_only_json_on_stdout(repo): + store_for(repo).write("Bookings are stored in UTC.", type="decision") + out = io.StringIO() + hooks.run("SessionStart", stdin=io.StringIO(json.dumps(payload(repo, "SessionStart"))), stdout=out) + json.loads(out.getvalue()) # raises if anything else leaked in + + +# ---- installing into settings.json ---------------------------------------- +@pytest.fixture +def settings(tmp_path): + return tmp_path / ".claude" / "settings.json" + + +def test_install_creates_settings_and_is_idempotent(settings): + hooks.install(settings, ["SessionStart", "SessionEnd"]) + hooks.install(settings, ["SessionStart", "SessionEnd"]) + + data = json.loads(settings.read_text()) + commands = [ + h["command"] + for groups in data["hooks"].values() + for g in groups + for h in g["hooks"] + ] + assert len(commands) == 2, f"re-running must not stack copies: {commands}" + assert sorted(c.rsplit(" hook ", 1)[-1] for c in commands) == [ + "session-end", + "session-start", + ] + + +def test_install_preserves_other_tools_and_settings(settings): + settings.parent.mkdir(parents=True) + settings.write_text(json.dumps({ + "permissions": {"allow": ["Bash(npm test)"]}, + "hooks": { + "SessionStart": [{"hooks": [{"type": "command", "command": "other-tool init"}]}], + "PreToolUse": [{"matcher": "Bash", "hooks": [{"type": "command", "command": "my-linter"}]}], + }, + })) + + hooks.install(settings, ["SessionStart", "SessionEnd"]) + data = json.loads(settings.read_text()) + + start = [h["command"] for g in data["hooks"]["SessionStart"] for h in g["hooks"]] + assert "other-tool init" in start + assert any(c.endswith("hook session-start") for c in start) + assert data["hooks"]["PreToolUse"][0]["hooks"][0]["command"] == "my-linter" + assert data["permissions"] == {"allow": ["Bash(npm test)"]} + + +def test_uninstall_removes_only_our_hooks(settings): + settings.parent.mkdir(parents=True) + settings.write_text(json.dumps({ + "hooks": {"SessionStart": [{"hooks": [{"type": "command", "command": "other-tool init"}]}]} + })) + hooks.install(settings, ["SessionStart", "SessionEnd"]) + hooks.uninstall(settings) + + data = json.loads(settings.read_text()) + remaining = [h["command"] for g in data["hooks"]["SessionStart"] for h in g["hooks"]] + assert remaining == ["other-tool init"] + assert "SessionEnd" not in data["hooks"] + + +def test_uninstall_on_a_clean_file_leaves_no_empty_scaffolding(settings): + hooks.uninstall(settings) + assert json.loads(settings.read_text()) == {} + + +def test_prompt_recall_hook_carries_a_timeout_under_the_event_limit(settings): + hooks.install(settings, ["UserPromptSubmit"]) + data = json.loads(settings.read_text()) + entry = data["hooks"]["UserPromptSubmit"][0]["hooks"][0] + assert entry["timeout"] < 30, "UserPromptSubmit is capped at 30s by the client" + + +def test_installed_command_is_an_absolute_path(settings): + """The client spawns hooks without our virtualenv necessarily on PATH.""" + hooks.install(settings, ["SessionStart"]) + command = json.loads(settings.read_text())["hooks"]["SessionStart"][0]["hooks"][0]["command"] + assert command.endswith("hook session-start") + executable = command.rsplit(" hook ", 1)[0] + assert executable == "agent-memory" or Path(executable).is_absolute() + + +def test_hooks_written_bare_are_still_recognised(settings): + """An entry installed by an older version must still be found and replaced.""" + settings.parent.mkdir(parents=True) + settings.write_text(json.dumps({"hooks": {"SessionStart": [ + {"hooks": [{"type": "command", "command": "agent-memory hook session-start"}]} + ]}})) + hooks.install(settings, ["SessionStart"]) + + commands = [ + h["command"] + for g in json.loads(settings.read_text())["hooks"]["SessionStart"] + for h in g["hooks"] + ] + assert len(commands) == 1, f"the old entry should be replaced, not kept: {commands}" + + +def test_installed_events_reports_what_is_wired(settings): + assert hooks.installed_events(settings) == [] + hooks.install(settings, ["SessionStart", "SessionEnd"]) + assert sorted(hooks.installed_events(settings)) == ["SessionEnd", "SessionStart"] + + +def test_malformed_settings_file_is_reported_not_overwritten(settings): + settings.parent.mkdir(parents=True) + settings.write_text("{ this is not json") + with pytest.raises(SystemExit): + hooks.install(settings, ["SessionStart"]) + assert settings.read_text() == "{ this is not json" # left alone diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index 476bd50..0780758 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -133,6 +133,17 @@ def test_handoff_is_picked_up_by_a_second_agent_on_the_same_store(tmp_path, monk assert "claude-code" in out # provenance survives the handoff +def test_server_ships_usage_instructions(server): + """Clients surface these to the model, so agents without hooks still learn + the workflow. This is the vendor-neutral half of not relying on the model + to remember on its own.""" + instructions = getattr(server, "instructions", None) + if instructions is None: # older mcp releases have no such field + pytest.skip("installed mcp version does not carry server instructions") + for tool in ("memory_boot", "memory_write", "memory_handoff"): + assert tool in instructions + + def test_stats_reports_the_store(server): call(server, "memory_write", text="Bookings are stored in UTC.", type="decision") out = call(server, "memory_stats")