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
326 skills4 hooks

Claude Code

> /plugin marketplace add TT-Wang/memem
> /plugin install memem@memem-marketplace

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.

@tt-wang
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.

Stars on GitHub

Momentum

stars in total
32
Star history of TT-Wang/memem on GitHub, a chart by star-history.com

More plugins that ship hooks.

superpowers

superpowers

A development methodology for coding agents that makes the agent brainstorm before building, follow plans, run parallel work and finish branches in a disciplined way. You get fewer skipped steps and more consistent results from your coding assistant.

15 skills1 hook311k
@obra@obra
Install Plugin
ponytail

ponytail

A Claude Code plugin with skills for auditing a whole repo, reviewing a change, listing deferred shortcut comments, and showing measured savings and a quick command reference.

6 skills10 hooks188k
@dietrichgebert@dietrichgebert
Install Plugin
agent-skills

agent-skills

Walks a software change from a written spec through design, testing, code review, CI/CD, and release, applying the matching engineering skill at each stage. Useful for developers using Claude Code on full development workflows.

25 skills+3112k
@addyosmani@addyosmani
Install Plugin
planning-with-files

planning-with-files

Persistent file-based planning for AI coding agents on long-running, multi-step tasks. It keeps task_plan.md, findings.md and progress.md on disk, so work can resume after /clear or context compaction.

6 skills+228k
@othmanadi@othmanadi
Install Plugin
gsd-core

gsd-core

A spec-driven development workflow for Claude Code that guides a project through planning, phases, milestone audits and fixes. Includes skills to generate tests from UAT criteria and to audit completed work.

72 skills+311k
@open-gsd@open-gsd
Install Plugin
superpowers-zh

superpowers-zh

Chinese-language skill pack for Claude Code that adds workflow guidance for brainstorming, TDD, debugging, and code review, plus Chinese conventions for commits, documentation, and domestic Git platforms like Gitee and Coding.net.

21 skills1 hook8.5k
@jnmetacode@jnmetacode
Install Plugin
socraticode

socraticode

Gives Claude Code semantic search, dependency graph and architecture context over a codebase, so an AI assistant can explore and understand a project's code. Includes skills for indexing a project and managing its file watching.

2 skills+33.4k
@giancarloerra@giancarloerra
Install Plugin
cocoindex-code

cocoindex-code

A lightweight code search engine that uses AST-based semantic search with local embeddings and incremental indexing. It helps Claude Code users find code faster and more efficiently.

1 skill1 MCP+13k
@cocoindex-io@cocoindex-io
Install Plugin

Questions about the hooks in memem.

Which coding agents can use the hooks in memem?
The hooks in memem install in Claude Code and Cursor: each runs Claude Code hooks from .claude/settings.json, so copy the hooks there.