Skip to content
Productivity
Hook

Hooks

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

From plugin
claude-recap
394 skills2 hooks
Install
> /plugin marketplace add hatawong/claude-recap
> /plugin install claude-recap@claude-recap-marketplace

Ships with claude-recap. 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.

  • Matchesstartup|resume|clear|compact${CLAUDE_PLUGIN_ROOT}/hooks/session-start.sh

Stop

  • ${CLAUDE_PLUGIN_ROOT}/hooks/stop.sh
Read hooks/hooks.json

Where it lives

  • hooks/session-start.shRunsGitHub
    Read the script
    #!/usr/bin/env bash
    # session-start.sh — SessionStart hook: inject memory into Claude context
    # Everything printed to stdout is injected into Claude's context
    
    set -euo pipefail
    
    INPUT=$(cat)
    
    CWD=$(echo "$INPUT" | jq -r '.cwd')
    SESSION_ID=$(echo "$INPUT" | jq -r '.session_id')
    SOURCE=$(echo "$INPUT" | jq -r '.source // "unknown"')
    
    echo "[SessionStart] session=$SESSION_ID source=$SOURCE"
    PROJECT_ID="${CWD//\//-}"
    
    # MEMORY_HOME allows test isolation; defaults to ~/.memory
    MEMORY_ROOT="${MEMORY_HOME:-$HOME/.memory}"
    PROJECT_DIR="$MEMORY_ROOT/projects/$PROJECT_ID"
    SESSION_DIR="$PROJECT_DIR/$SESSION_ID"
    
    PLUGIN_ROOT="${CLAUDE_PLUGIN_ROOT:-$(cd "$(dirname "$0")/.." && pwd)}"
    
    # Ensure project dir exists
    mkdir -p "$PROJECT_DIR"
    
    # Step: archive pending topics from previous sessions (skip during compact to reduce cognitive load)
    # Runs as background process — self-starts LLM, does not block session start or occupy Agent context
    if [ "$SOURCE" != "compact" ]; then
      "$PLUGIN_ROOT/scripts/archive-pending.sh" "$PROJECT_DIR" "$SESSION_ID" "$PLUGIN_ROOT" &>/dev/null &
    fi
    
    # Push layer 1: inject REMEMBER.md (global + project)
    GLOBAL_REMEMBER="$MEMORY_ROOT/REMEMBER.md"
    PROJECT_REMEMBER="$PROJECT_DIR/REMEMBER.md"
    
    if [ -f "$GLOBAL_REMEMBER" ]; then
      echo "=== Things You Should Remember (Global) ==="
      cat "$GLOBAL_REMEMBER"
      echo ""
    fi
    
    if [ -f "$PROJECT_REMEMBER" ]; then
      echo "=== Things You Should Remember (This Project) ==="
      cat "$PROJECT_REMEMBER"
      echo ""
    fi
    
    # Legacy: inject preferences.md if it exists (will be migrated to REMEMBER.md)
    PREFERENCES="$MEMORY_ROOT/global/preferences.md"
    if [ -f "$PREFERENCES" ]; then
      echo "=== User Preferences & Constraints (auto-injected, please follow) ==="
      cat "$PREFERENCES"
      echo ""
    fi
    
    # Push layer 2: list topic files grouped by session, sorted by recency (max 20 sessions)
    if [ -d "$PROJECT_DIR" ]; then
      # For each session dir: get newest file mtime (epoch), date, topics in seq order
      TOPIC_HISTORY=$(
        for session_dir in "$PROJECT_DIR"/*/; do
          [ ! -d "$session_dir" ] && continue
          sid=$(basename "$session_dir")
          short=$(echo "$sid" | cut -c1-8)
    
          # List topic files (exclude hidden), sorted by name (= seq order)
          topics=$(find "$session_dir" -maxdepth 1 -name "*.md" -not -name ".*" 2>/dev/null | sed 's|.*/||; s|\.md$||' | sort)
          [ -z "$topics" ] && continue
    
          # Get newest file mtime as epoch (macOS stat -f, Linux stat -c fallback)
          newest=$(find "$session_dir" -maxdepth 1 -name "*.md" -not -name ".*" -exec stat -f '%m' {} + 2>/dev/null | sort -rn | head -1)
          [ -z "$newest" ] && newest=$(find "$session_dir" -maxdepth 1 -name "*.md" -not -name ".*" -exec stat -c '%Y' {} + 2>/dev/null | sort -rn | head -1)
          [ -z "$newest" ] && continue
    
          # Format date from epoch
          dt=$(date -r "$newest" "+%m-%d %H:%M" 2>/dev/null || date -d "@$newest" "+%m-%d %H:%M" 2>/dev/null)
    
          # Output: epoch|display_line (epoch for sorting, stripped later)
          echo "${newest}|${short} (${dt}): $(echo "$topics" | paste -sd',' - | sed 's/,/, /g')"
        done | sort -rn | head -20 | cut -d'|' -f2-
      )
      if [ -n "$TOPIC_HISTORY" ]; then
        echo "=== Topic History for This Project (recent 20 sessions) ==="
        echo "$TOPIC_HISTORY"
        echo ""
      fi
    fi
    
    # Pull layer: tell Claude where to find memory files and scripts
    echo "Your persistent memory is stored at $PROJECT_DIR (session directories with topic files)."
    echo "If topic history files are listed above, check the user's first message to decide whether to cat any of them to restore context."
    echo ""
    echo "Plugin scripts path: $PLUGIN_ROOT/scripts"
    echo ""
    
    # Push layer 3: inject topic tracking state
    STATE_FILE="$SESSION_DIR/.current_topic"
    if [ -f "$STATE_FILE" ]; then
      CURRENT_TOPIC=$(cat "$STATE_FILE")
    else
      CURRENT_TOPIC="(none — use your first topic slug in the tag, the Stop hook will register it)"
    fi
    cat <<EOF
    
    === Topic Tag Rule ===
    At the START of every reply, output a topic tag in this exact format:
    › \`your-topic-slug\`
    The slug should be 2-4 words, lowercase, hyphen-separated, describing the current topic.
    If the topic hasn't changed, repeat the same slug. If it has, use the new slug.
    This tag is machine-read by the Stop hook. Always include it.
    
    Current topic: $CURRENT_TOPIC
    Topic archival is automatic — the Stop hook detects topic changes from your tag and guides you through archival. You do not need to call any skill manually.
    EOF
    
    # Feature A: Compact context recovery — extract + cold-reader summary via claude -p
    if [ "$SOURCE" = "compact" ] && [ -f "$STATE_FILE" ]; then
      CURRENT_SLUG=$(cat "$STATE_FILE")
      mkdir -p "$SESSION_DIR"
    
      # Create .compacted marker — signals archive-pending to skip this session
      touch "$SESSION_DIR/.compacted"
    
      JSONL_PATH="$HOME/.claude/projects/$PROJECT_ID/$SESSION_ID.jsonl"
      if [ -f "$JSONL_PATH" ] && [ -n "$CURRENT_SLUG" ]; then
        EXTRACT_SCRIPT="$PLUGIN_ROOT/scripts/extract-topic.js"
        SUMMARY_TEMPLATE=$(cat "$PLUGIN_ROOT/scripts/topic-tmpl.md")
    
        # Extract conversation for current topic
        EXTRACTED=$(node "$EXTRACT_SCRIPT" "$JSONL_PATH" "$CURRENT_SLUG" 2>/dev/null) || true
    
        if [ -n "$EXTRACTED" ]; then
          # Cold-reader summarization via claude -p (blocks session start)
          ARCHIVE_CWD=$(mktemp -d)
          RECOVERY=$(unset CLAUDECODE; cd "$ARCHIVE_CWD" && claude -p --model sonnet --no-session-persistence "You are summarizing a conversation extract for context recovery after compaction. Output the following TWO parts:
    
    PART 1 — Structured summary (section headings in English, content in user's language, skip empty sections):
    
    ${SUMMARY_TEMPLATE}
    
    PART 2 — Copy the last 2 User/Assistant exchanges verbatim from the conversation. Preserve the original language exactly. Use the heading format:
    ### Last exchanges
    ## User
    ...
    ## Assistant
    ...
    ## User
    ...
    ## Assistant
    ...
    
    Rules: State facts only. No AI filler language.
    
    --- CONVERSATION ---
    ${EXTRACTED}
    --- END ---" 2>/dev/null) || true
          rm -rf "$A
  • hooks/stop.shRunsGitHub
    Read the script
    #!/usr/bin/env bash
    # stop.sh — Stop hook: detect topic change and trigger archival
    # Extracts › `slug` from last_assistant_message,
    # compares with .current_topic. If changed, exit 2 with direct bash command for LLM to archive.
    
    set -euo pipefail
    
    INPUT=$(cat)
    
    # Anti-recursion: if already inside a stop hook cycle, pass through
    STOP_HOOK_ACTIVE=$(echo "$INPUT" | jq -r '.stop_hook_active // false')
    if [ "$STOP_HOOK_ACTIVE" = "true" ]; then
      exit 0
    fi
    
    # Extract topic tag from last assistant message
    LAST_MSG=$(echo "$INPUT" | jq -r '.last_assistant_message // ""')
    # Only match a line that is exclusively the topic tag (anchored ^...$)
    # Format: › `slug` — Unicode arrow + backtick-wrapped slug
    # Single quotes intentional: sed regex, not shell expansion
    # shellcheck disable=SC2016
    NEW_TOPIC=$(echo "$LAST_MSG" | head -1 | sed -n 's/^› `\([a-z0-9-]*\)`$/\1/p')
    
    # Fallback: when LLM uses tools, the tag is in an earlier message (not last_assistant_message).
    # Read the JSONL transcript to find the most recent topic tag from any assistant text block.
    if [ -z "$NEW_TOPIC" ]; then
      TRANSCRIPT_PATH=$(echo "$INPUT" | jq -r '.transcript_path // ""')
      if [ -n "$TRANSCRIPT_PATH" ] && [ -f "$TRANSCRIPT_PATH" ]; then
        NEW_TOPIC=$(tail -50 "$TRANSCRIPT_PATH" | \
          jq -r '.message.content[]? | select(.type == "text") | .text' 2>/dev/null | \
          sed -n 's/^› `\([a-z0-9-]*\)`$/\1/p' | tail -1) || true
        if [ -n "$NEW_TOPIC" ]; then
          echo "[stop.sh] extracted topic tag from transcript (fallback): '${NEW_TOPIC}'" >&2
        fi
      fi
    fi
    
    # Debug: log what we got
    echo "[stop.sh] extracted topic tag: '${NEW_TOPIC}'" >&2
    
    # If no tag found, pass through (LLM didn't follow the rule)
    if [ -z "$NEW_TOPIC" ]; then
      echo "[stop.sh] no topic tag found in last_assistant_message or transcript, pass through" >&2
      exit 0
    fi
    
    # Read current topic from per-session state file
    MEMORY_ROOT="${MEMORY_HOME:-$HOME/.memory}"
    CWD=$(echo "$INPUT" | jq -r '.cwd // ""')
    SESSION_ID=$(echo "$INPUT" | jq -r '.session_id // ""')
    PROJECT_ID="${CWD//\//-}"
    SESSION_DIR="$MEMORY_ROOT/projects/${PROJECT_ID}/${SESSION_ID}"
    TOPIC_FILE="$SESSION_DIR/.current_topic"
    OLD_TOPIC=$(cat "$TOPIC_FILE" 2>/dev/null || echo "none")
    
    echo "[stop.sh] old_topic='${OLD_TOPIC}', new_topic='${NEW_TOPIC}'" >&2
    
    # Compare
    if [ "$NEW_TOPIC" = "$OLD_TOPIC" ]; then
      echo "[stop.sh] topic unchanged, pass through" >&2
      exit 0
    fi
    
    # First topic in session — just register, nothing to archive
    if [ "$OLD_TOPIC" = "none" ]; then
      mkdir -p "$SESSION_DIR"
      echo "$NEW_TOPIC" > "$TOPIC_FILE"
      echo "[stop.sh] first topic registered: ${NEW_TOPIC}" >&2
      exit 0
    fi
    
    # Check .ignore — skip archival for ignored topics (before LLM summary)
    PLUGIN_ROOT="${CLAUDE_PLUGIN_ROOT:-$(cd "$(dirname "$0")/.." && pwd)}"
    PROJECT_DIR="$MEMORY_ROOT/projects/$PROJECT_ID"
    source "$PLUGIN_ROOT/scripts/ignore-topic-utils.sh"
    if topic_is_ignored "$OLD_TOPIC" "$MEMORY_ROOT" "$PROJECT_DIR"; then
      echo "[stop.sh] topic '$OLD_TOPIC' matches .ignore, skipping archival" >&2
      echo "$NEW_TOPIC" > "$TOPIC_FILE"
      exit 0
    fi
    
    # Topic changed — archive old topic via direct bash command
    # Resolve transcript_path (may already be set from fallback above, or extract now)
    TRANSCRIPT_PATH="${TRANSCRIPT_PATH:-$(echo "$INPUT" | jq -r '.transcript_path // ""')}"
    
    SUMMARY_TEMPLATE=$(cat "${PLUGIN_ROOT}/scripts/topic-tmpl.md")
    cat >&2 <<TOPIC_EOF
    Topic changed from '${OLD_TOPIC}' to '${NEW_TOPIC}'.
    
    Archive the old topic NOW. Write a factual summary of '${OLD_TOPIC}' and run this command:
    
    bash "${PLUGIN_ROOT}/scripts/set-topic.sh" "${OLD_TOPIC}" "${NEW_TOPIC}" "${SESSION_ID}" "<your_summary>" "${TRANSCRIPT_PATH}"
    
    Replace <your_summary> with a structured summary using this format (section headings in English, content in user's language, skip empty sections):
    
    ${SUMMARY_TEMPLATE}
    
    Rules: State facts only. No AI filler language. The script adds the header and time range automatically.
    TOPIC_EOF
    exit 2
    

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 withclaude-recap

Topic-based automatic memory for Claude Code — never lose context across sessions or compactions.

Get the whole plugin
Stats
39
Stars
3
Forks
Quiet
Maintenance
JavaScript
Language
MIT
License
6mo ago
Last commit
6mo ago
Created

Repo: hatawong/claude-recap