Skip to content
Development
Hook

Hooks

What memem runs automatically, and when. A hook is a command Claude Code fires at a fixed moment, without you asking for it.

From plugin
memem
316 skills4 hooks
Install
> /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"
Read hooks/hooks.json

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.

Ships withmemem

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.

Get the whole plugin
Stats
31
Stars
2
Forks
Active
Maintenance
Python
Language
MIT
License
20d ago
Last commit
5mo ago
Created

Repo: TT-Wang/memem