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")