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.
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.
- Matches
startup|resume|clear|compact${CLAUDE_PLUGIN_ROOT}/hooks/session-start.sh
Stop
${CLAUDE_PLUGIN_ROOT}/hooks/stop.sh
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

