Hooks
What memem runs automatically, and when. A hook is a command Claude Code fires at a fixed moment, without you asking for it.
> /plugin marketplace add TT-Wang/memem > /plugin install memem@memem-marketplace
Ships with memem. Installing the plugin gets these hooks.
What fires, and when
SessionStart
Fires once when a session begins, and again after a context compaction. It is where a plugin sets up its environment, or restores state the compaction dropped.
bash "${CLAUDE_PLUGIN_ROOT}/hooks/session-start.sh"
UserPromptSubmit
Fires before Claude sees each prompt you send. A plugin can use it to inject context, so the same instruction reaches the model every turn instead of only at session start.
bash "${CLAUDE_PLUGIN_ROOT}/hooks/auto-recall.sh"
PreToolUse
bash "${CLAUDE_PLUGIN_ROOT}/hooks/pre-tool-use.sh"
Stop
bash "${CLAUDE_PLUGIN_ROOT}/hooks/stop-mine.sh"
In the plugin's words
How memem describes its own hook set.
memem — auto-recalls relevant memories on first message, topic-shift detection, and pre-tool-use file-read gating
Where it lives
- hooks/auto-recall.shRunsGitHub
Read the script
#!/usr/bin/env bash # auto-recall.sh — v2.0.0 # UserPromptSubmit hook: read JSON envelope from stdin → extract query → # call memem.retrieve + memem.render directly → return {additionalContext}. # No daemon, no socket, no fallback chain. # Disabled when MEMEM_INJECTION_MODE=tool. set -euo pipefail # Anti-recursion guard: skip if invoked from a memem-spawned headless claude -p call. [ -n "${MEMEM_HOOK_DISABLE:-}" ] && exit 0 # Read envelope INPUT=$(cat) # Honor user opt-out if [ "${MEMEM_INJECTION_MODE:-tool}" = "tool" ]; then # v2.4.0 Phase 4.5 fix: telemetry skip log written directly via bash # (NOT python3 -c) to avoid 80-200ms cold-start subprocess on every # UserPromptSubmit. POSIX guarantees atomic O_APPEND for writes under # PIPE_BUF (4096B); this 160-byte line is well within bounds. { TS=$(date -u +%Y-%m-%dT%H:%M:%S.%6NZ 2>/dev/null || date -u +%Y-%m-%dT%H:%M:%SZ) MEMEM_DIR_RESOLVED="${MEMEM_DIR:-$HOME/.memem}" mkdir -p "$MEMEM_DIR_RESOLVED" 2>/dev/null printf '{"ts":"%s","call_type":"hook_tool_skip","query":"","returned_ids":[],"latency_ms":0,"source":"hook"}\n' \ "$TS" >> "$MEMEM_DIR_RESOLVED/.recall_log.jsonl" 2>/dev/null } 2>/dev/null || true echo '{}' exit 0 fi # Find python — must have memem importable PYTHON="${MEMEM_PYTHON:-python3}" # Pass envelope via tempfile (env var hits OS ARG_MAX on huge prompts; tempfile is safe). ENVELOPE_TMP=$(mktemp -t memem-hook-envelope.XXXXXX.json) trap 'rm -f "$ENVELOPE_TMP"' EXIT printf '%s' "$INPUT" > "$ENVELOPE_TMP" export MEMEM_HOOK_ENVELOPE_PATH="$ENVELOPE_TMP" # Also export plugin root for sys.path insertion export MEMEM_PLUGIN_ROOT="${CLAUDE_PLUGIN_ROOT:-}" "$PYTHON" -c ' import json import os import sys with open(os.environ["MEMEM_HOOK_ENVELOPE_PATH"], "r") as _f: envelope = json.load(_f) query = ( envelope.get("prompt") or envelope.get("user_prompt") or envelope.get("message") or envelope.get("query") or "" ).strip() if not query: print(json.dumps({})) sys.exit(0) plugin_root = os.environ.get("MEMEM_PLUGIN_ROOT", "") if plugin_root: sys.path.insert(0, plugin_root) try: from memem.retrieve import retrieve from memem.render import render_slice except ImportError: # memem not on PYTHONPATH — silent no-op, dont break user prompts print(json.dumps({})) sys.exit(0) session_id = (envelope.get("session_id") or "").strip() paths_context = None try: from memem.transcripts import recent_session_paths as _rsp derived = _rsp(session_id) if session_id else [] paths_context = derived or None except Exception as _exc: import structlog as _structlog _structlog.get_logger("memem-hook").warning( "auto-recall: failed to derive paths_context", session_id=session_id, error=str(_exc), ) paths_context = None try: results = retrieve(query, k=8, paths_context=paths_context) except Exception: print(json.dumps({})) sys.exit(0) working = {} if envelope.get("task_mode"): working["task_mode"] = envelope["task_mode"] if envelope.get("recent_actions"): working["recent_actions"] = list(envelope["recent_actions"])[:3] try: md = render_slice(query, results, working) except Exception: print(json.dumps({})) sys.exit(0) # Optional opt-out: if rendered output has empty Relevant, skip if "## Relevant (0" in md: print(json.dumps({})) sys.exit(0) print(json.dumps({ "hookSpecificOutput": { "hookEventName": "UserPromptSubmit", "additionalContext": md, } })) ' - hooks/post-stop-attribution.shGitHub
Read the script
#!/usr/bin/env bash # TRANSITIONAL NO-OP STUB (v2.6.1) — safe to delete in a future major release. # # The real post-stop-attribution hook was removed in v2.5.0 (it was a # guaranteed no-op: it imported two modules deleted in v2.0.0 and read a file # nothing writes). Its hooks.json Stop registration was removed at the same # time. # # However, Claude Code snapshots a plugin's hook registry at session start. # Any session that was already running when the upgrade landed still tries to # execute this path on every Stop event and logs # "Stop hook error: ... post-stop-attribution.sh: No such file or directory" # until /reload-plugins or a session restart. This stub exists solely to keep # those stale registrations silent during the transition. # # Stop hooks must exit 0 with NO stdout (the Stop protocol rejects # hookSpecificOutput envelopes). exit 0
- hooks/pre-tool-use.shRunsGitHub
Read the script
#!/usr/bin/env bash # memem PreToolUse hook — enriches Read tool calls with relevant memories. # OPT-IN: only runs when MEMEM_PRETOOL_GATING=1 is set. set -euo pipefail # Fast exit if not opted in if [ "${MEMEM_PRETOOL_GATING:-0}" != "1" ]; then echo '{"hookSpecificOutput":{"hookEventName":"PreToolUse","additionalContext":""}}' exit 0 fi PLUGIN_ROOT="${CLAUDE_PLUGIN_ROOT:-}" if [ -z "$PLUGIN_ROOT" ] || [ "$PLUGIN_ROOT" = '${CLAUDE_PLUGIN_ROOT}' ]; then echo '{"hookSpecificOutput":{"hookEventName":"PreToolUse","additionalContext":""}}' exit 0 fi # Read stdin once, write to a temp file, pass path to python helper. # Previous version consumed stdin into $INPUT and then tried to json.load(sys.stdin) # in a heredoc — stdin was already drained so every call silently fell through # to the empty-context fallback. v0.10.1 fix: pass via tempfile argv instead. INPUT_FILE=$(mktemp) trap 'rm -f "$INPUT_FILE"' EXIT cat > "$INPUT_FILE" python3 - "$PLUGIN_ROOT" "$INPUT_FILE" <<'PYEOF' import json, sys, os, subprocess from pathlib import Path plugin_root = sys.argv[1] input_file = sys.argv[2] def emit_empty(): print(json.dumps({"hookSpecificOutput":{"hookEventName":"PreToolUse","additionalContext":""}})) sys.exit(0) try: hook = json.loads(Path(input_file).read_text()) except Exception: emit_empty() tool_name = hook.get("tool_name") or hook.get("toolName") or "" tool_input = hook.get("tool_input") or hook.get("toolInput") or {} # Only gate on Read tool if tool_name != "Read": emit_empty() file_path = tool_input.get("file_path") or tool_input.get("filePath") or "" if not file_path: emit_empty() # Search memories for this file path (use basename as query for broader matches) basename = os.path.basename(file_path) env = os.environ.copy() env["PYTHONPATH"] = plugin_root + os.pathsep + env.get("PYTHONPATH", "") try: # Use --recall (pure FTS) not --recall-smart (Haiku, 5-30s). # Cold-start cost: ~2-4s per call because memem.server imports 1k+ memories # into an index at process start. Timeout is 8s to leave margin. This is # why PreToolUse is opt-in (MEMEM_PRETOOL_GATING=1) and why it's marked # experimental — the per-Read latency cost is noticeable. A future release # will query search.db directly without importing memem.server. result = subprocess.run( [sys.executable, "-m", "memem.server", "--recall", basename], capture_output=True, text=True, timeout=8, env=env, ) brief = result.stdout.strip() if result.returncode == 0 else "" except Exception: brief = "" if not brief or "No memories found" in brief: emit_empty() context = f"memem — relevant memories for `{basename}`:\n\n{brief}" print(json.dumps({"hookSpecificOutput":{"hookEventName":"PreToolUse","additionalContext":context}})) PYEOF - hooks/session-start.shRunsGitHub
Read the script
#!/usr/bin/env bash # memem SessionStart hook — primes slice-first runtime context before first input. # # Flow: # 1. Parse session_id + cwd from hook stdin # 2. Generate a slice-first prompt context for the current project scope # 3. If non-empty, write .last-brief.json with primed=true # 4. Emit the rendered slice as additionalContext # # Silent by default; MEMEM_SHOW_BANNER=1 prepends a short status banner. set -euo pipefail # Anti-recursion guard: skip if invoked from a memem-spawned headless claude -p call. # Without this, every memem mining or tournament Haiku call recursively fires memem hooks → load explosion. [ -n "${MEMEM_HOOK_DISABLE:-}" ] && exit 0 MEMEM_DIR="${MEMEM_DIR:-${CORTEX_DIR:-$HOME/.memem}}" PLUGIN_ROOT="${CLAUDE_PLUGIN_ROOT:-}" emit_empty() { echo '{"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":""}}' exit 0 } if [ -z "$PLUGIN_ROOT" ] || [ "$PLUGIN_ROOT" = '${CLAUDE_PLUGIN_ROOT}' ]; then emit_empty fi mkdir -p "$MEMEM_DIR" 2>/dev/null || true INPUT_FILE=$(mktemp) trap 'rm -f "$INPUT_FILE"' EXIT cat > "$INPUT_FILE" || true # `|| emit_empty`: if the python heredoc itself dies before emitting JSON # (interpreter missing, import explosion), hand the harness a valid empty # envelope instead of silence + non-zero exit under `set -e`. "${MEMEM_PYTHON:-python3}" - "$PLUGIN_ROOT" "$INPUT_FILE" "$MEMEM_DIR" << 'HOOKPY' || emit_empty import json import os import sys from datetime import datetime, timezone from pathlib import Path plugin_root = sys.argv[1] input_file = Path(sys.argv[2]) memem_dir = Path(sys.argv[3]) EMPTY_RESPONSE = json.dumps({ "hookSpecificOutput": { "hookEventName": "SessionStart", "additionalContext": "", } }) def emit_empty() -> None: print(EMPTY_RESPONSE) raise SystemExit(0) def detect_scope(cwd: str) -> str: trimmed = (cwd or "").rstrip("/") home = str(Path.home()).rstrip("/") if not trimmed or trimmed in {"", "/", home}: return "default" return os.path.basename(trimmed) or "default" def parse_budget() -> int: raw = os.environ.get("MEMEM_SESSION_START_PROMPT_BUDGET", "4000").strip() try: value = int(raw) except ValueError: return 4000 return max(1000, min(value, 12000)) def memory_count() -> int: try: from memem.models import OBSIDIAN_MEMORIES_DIR memories_dir = OBSIDIAN_MEMORIES_DIR except ImportError: vault_root = Path(os.environ.get("MEMEM_OBSIDIAN_VAULT", str(Path.home() / "obsidian-brain"))) memories_dir = vault_root / "memem" / "memories" try: return len(list(memories_dir.glob("*.md"))) except OSError: return 0 if not plugin_root or plugin_root == "${CLAUDE_PLUGIN_ROOT}": emit_empty() sys.path.insert(0, plugin_root) try: hook = json.loads(input_file.read_text() or "{}") except Exception: hook = {} session_id = str(hook.get("session_id", "") or "") cwd = str(hook.get("cwd") or os.environ.get("PWD") or os.getcwd()) scope = detect_scope(cwd) # session-start context = three-block budgeted assembly (v2.8): # 1. Profiles block (user + project profile, ~2400 chars) # 2. ## Working rules (procedural memories, ~1200 chars) # 3. ## Episode index (recent episodic memories, ~1600 chars, capped at 25) # All blocks composed by render_session_start; total target ≤ 5200 chars. content = "" try: from memem.session_blocks import render_session_start content = render_session_start(scope) or "" except Exception: # noqa: BLE001 — never break SessionStart pass # v2.0.0 Phase 4.5 fix: write .last-brief.json BEFORE the emit_empty() check. # Otherwise the marker file is never written when content is empty (the v2.0.0 # default for session-start), and recall.py:_get_current_session_id always # returns "", silently killing session-scoped recall telemetry across MCP tools. if session_id: try: memem_dir.mkdir(parents=True, exist_ok=True) (memem_dir / ".last-brief.json").write_text(json.dumps({ "session_id": session_id, "keywords": [], "timestamp": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), "primed": True, })) except OSError: pass if not content: emit_empty() banner = "" if os.environ.get("MEMEM_SHOW_BANNER", "0") == "1": banner = f"[memem] {memory_count()} memories · slice-first runtime active\n\n" print(json.dumps({ "hookSpecificOutput": { "hookEventName": "SessionStart", "additionalContext": banner + content, } })) HOOKPY # v2.1.0 stale-session sweep — catch sessions where Stop hook never fired (Claude crash, kill -9, etc.) # Fire-and-forget detached mine_delta processes for un-mined JSONLs older than 10 min, cap at 3 parallel. MEMEM_STATE_DIR="${MEMEM_DIR:-${CORTEX_DIR:-$HOME/.memem}}" if [ -f "$MEMEM_STATE_DIR/.miner-opted-in" ]; then MINED_LIST="$MEMEM_STATE_DIR/.mined_sessions" touch "$MINED_LIST" 2>/dev/null || true SPAWNED=0 for jsonl in $(find "$HOME/.claude/projects" -maxdepth 3 -name "*.jsonl" -type f -mmin +10 2>/dev/null); do [ "$SPAWNED" -ge 3 ] && break SID=$(basename "$jsonl" .jsonl) # Skip if already mined grep -Fxq "$SID" "$MINED_LIST" 2>/dev/null && continue # Skip mining artifacts: headless `claude -p` transcripts spawned by mine_delta/mining. # Detection = marker anywhere in the first 20 raw JSONL lines AND a small file # (headless calls produce only a handful of JSONL records — even a huge embedded # prompt lives inside ONE user line). The line-count conjunct protects real # conversations that merely QUOTE a marker phrase: a falsely-skipped session would # be PERMANENTLY excluded from auto-mining via the zombie guard below. Residual # risk: a <=30-line real session quoting a marker is skipped — recoverable via # `python3 -m memem.server --mine-session <id>`. - hooks/stop-mine.shRunsGitHub
Read the script
#!/usr/bin/env bash # memem Stop hook — fires mine_delta as a detached fire-and-forget subprocess. # # Flow: # 1. Anti-recursion guard: skip if MEMEM_HOOK_DISABLE is set # 2. Opt-in check: skip if ~/.memem/.miner-opted-in does not exist # 3. Parse session_id and transcript_path from hook stdin JSON # 4. Spawn mine_delta as a detached subprocess (setsid nohup ... disown) # 5. Emit valid Stop hook JSON and exit 0 immediately (<100ms total) # # The mine_delta process runs detached — this hook never waits for it. set -euo pipefail # Anti-recursion guard: skip if invoked from a memem-spawned headless claude -p call. # Without this, every memem mining or tournament Haiku call recursively fires memem hooks. if [ -n "${MEMEM_HOOK_DISABLE:-}" ]; then exit 0 fi MEMEM_DIR="${MEMEM_DIR:-${CORTEX_DIR:-$HOME/.memem}}" # Opt-in check: do nothing unless the user has explicitly enabled the miner. if [ ! -f "$MEMEM_DIR/.miner-opted-in" ]; then exit 0 fi # Read stdin to a temp file to avoid ARG_MAX issues with large transcripts. INPUT_FILE=$(mktemp) trap 'rm -f "$INPUT_FILE"' EXIT cat > "$INPUT_FILE" || true # Extract session_id and transcript_path via python3 for portability. read -r SID TP < <( "${MEMEM_PYTHON:-python3}" - "$INPUT_FILE" << 'PYEOF' import json, sys from pathlib import Path try: data = json.loads(Path(sys.argv[1]).read_text() or "{}") except Exception: data = {} sid = str(data.get("session_id") or "") tp = str(data.get("transcript_path") or "") print(sid, tp) PYEOF ) || true # Spawn mine_delta as a fully detached subprocess — fire and forget. # setsid creates a new session (detaches from current process group). # nohup prevents SIGHUP from reaching the child. # </dev/null >/dev/null 2>&1 ensures no I/O inherits from the hook process. # disown removes the child from the shell's job table. # The subshell `(...)` closes all extra file descriptors before exec so that # no inherited pipes from the calling environment can keep this hook's parent # process alive waiting for the child. if [ -n "$SID" ]; then ( # Close all file descriptors above stderr so no inherited pipes leak. for _fd in $(ls /proc/$$/fd 2>/dev/null | grep -v '^[012]$'); do eval "exec ${_fd}>&-" 2>/dev/null || true done setsid nohup "${MEMEM_PYTHON:-python3}" -m memem.mine_delta \ --session-id "$SID" \ --transcript-path "$TP" \ </dev/null >/dev/null 2>&1 & disown ) fi # Stop hooks should exit 0 with NO stdout (unlike SessionStart, the Stop hook # protocol does not accept `hookSpecificOutput` envelopes; emitting them gets # rejected with "Hook JSON output validation failed — (root): Invalid input"). # The mining work is detached above; the hook itself just returns silently. exit 0
Read the script before you install anything that runs on your machine. This is the one part of a plugin that acts without being asked.
Persistent, self-evolving memory for Claude Code. Stop re-explaining your project every session. <!-- The Glama badge URL below intentionally uses the legacy cortex-plugin slug.

