diff --git a/examples/scripts/session-search.sh b/examples/scripts/session-search.sh index ed6681d..f623a14 100755 --- a/examples/scripts/session-search.sh +++ b/examples/scripts/session-search.sh @@ -1,50 +1,134 @@ #!/bin/bash -# session-search.sh - Fast Claude Code session search & resume +# session-search.sh - Fast Claude Code session search & resume (v2.0) # # Zero-dependency bash script to search past Claude Code conversations # and generate ready-to-use resume commands. # # Usage: -# ./session-search.sh # List 10 most recent sessions -# ./session-search.sh "keyword" # Full-text search across all sessions -# ./session-search.sh -n 20 # Show 20 results -# ./session-search.sh --rebuild # Force index rebuild +# cs # List 10 most recent sessions +# cs "keyword" # Single keyword search +# cs "Prisma migration" # Multi-word AND search (both must match) +# cs -n 20 # Show 20 results +# cs -p myproject "bug" # Filter by project name +# cs --since 7d # Sessions from last 7 days +# cs --json "api" # JSON output for scripting +# cs --rebuild # Force index rebuild # # Smart refresh: # Index auto-rebuilds when any session file is newer than the index. -# No manual rebuild needed - always fresh results. # # Performance: -# - List recent: ~15ms (uses cached index + 10ms freshness check) -# - Keyword search: ~400ms (full-text grep) +# - List recent: ~15ms (cached index) +# - Keyword search: ~400ms (multi-word AND) # - Index rebuild: ~5s for 200 sessions +# - Timeout: 3s max for searches # # Installation: # cp session-search.sh ~/.claude/scripts/cs # chmod +x ~/.claude/scripts/cs # echo "alias cs='~/.claude/scripts/cs'" >> ~/.zshrc -# -# Comparison with alternatives: -# | Tool | List | Search | Deps | Resume cmd | -# |------------------------------|---------|--------|---------|------------| -# | session-search.sh | 15ms | 400ms | None | Yes | -# | claude-conversation-extractor| 230ms | 1.7s | Python | No | -# | claude-code-transcripts | N/A | N/A | Python | No | -# | native `claude --resume` | 500ms+ | No | None | Interactive| set -euo pipefail INDEX="$HOME/.claude/sessions.idx" PROJECTS="$HOME/.claude/projects" -MAX_AGE_MIN=60 LIMIT=10 +PROJECT_FILTER="" +SINCE_DATE="" +OUTPUT_JSON=false +MAX_SEARCH_TIME=3 -# Colors +# Colors (disabled for JSON output) C_CYAN='\033[36m' C_YELLOW='\033[33m' C_DIM='\033[2m' C_RESET='\033[0m' +show_help() { + cat << 'EOF' +Usage: cs [OPTIONS] [KEYWORD] + +Search and resume Claude Code sessions. + +Options: + -n Show N results (default: 10) + -p, --project

Filter by project name (substring match) + --since Filter sessions since DATE + Formats: YYYY-MM-DD, today, yesterday, 7d, 30d + --json Output as JSON (for scripting) + --rebuild Force index rebuild + -h, --help Show this help + +Examples: + cs # 10 most recent sessions + cs "api" # Search for "api" + cs "Prisma migration" # Multi-word AND search (both must match) + cs -p myproject "bug" # Search "bug" in myproject only + cs --since today # Today's sessions + cs --since 7d "fix" # "fix" in last 7 days + cs --json "api" | jq . # JSON output for scripting + +Output: + Each result shows date, project, preview, and a ready-to-copy command: + claude --resume +EOF +} + +# Parse --since date to YYYY-MM-DD format +parse_since() { + local input="$1" + case "$input" in + today) + date +%Y-%m-%d + ;; + yesterday) + date -v-1d +%Y-%m-%d 2>/dev/null || date -d "yesterday" +%Y-%m-%d + ;; + *d) + local days="${input%d}" + date -v-${days}d +%Y-%m-%d 2>/dev/null || date -d "-${days} days" +%Y-%m-%d + ;; + *) + echo "$input" + ;; + esac +} + +# Extract readable preview from session file +extract_preview() { + local file="$1" + local max_len="${2:-60}" + + # Find first user message with simple string content (not array) + # Skip messages that look like tool results or file references + local preview=$(grep '"type":"user"' "$file" 2>/dev/null | \ + grep -v '"content":\[' | \ + grep -v 'file-history-snapshot' | \ + head -1 | \ + sed 's/.*"content":"\([^"]*\).*/\1/' | \ + sed 's/\\n/ /g' | \ + sed 's/@[^ ]*//g' | \ + sed 's/ */ /g' | \ + cut -c1-"$max_len" | \ + tr -d '\n') + + # Fallback: if empty or still looks like JSON, use generic text + if [[ -z "$preview" || "$preview" == "{"* ]]; then + preview="(session)" + fi + + echo "$preview" +} + +# Extract clean project name from path +extract_project() { + local dir="$1" + basename "$dir" | \ + sed 's/^-Users-[^-]*-Sites-perso-//' | \ + sed 's/^-Users-[^-]*-Sites-//' | \ + sed 's/^-Users-[^-]*-//' +} + build_index() { echo "Indexing sessions..." >&2 : > "$INDEX" @@ -56,15 +140,11 @@ build_index() { [[ "$(basename "$f")" == agent* ]] && continue local id=$(basename "$f" .jsonl) - local proj=$(basename "$(dirname "$f")" | sed 's/^-Users-[^-]*-Sites-perso-//' | sed 's/^-Users-[^-]*-Sites-//' | sed 's/^-Users-[^-]*-//') - local mtime=$(stat -f "%Sm" -t "%Y-%m-%d %H:%M" "$f" 2>/dev/null || stat -c "%y" "$f" 2>/dev/null | cut -d: -f1-2) + local proj=$(extract_project "$(dirname "$f")") + local mtime=$(stat -f "%Sm" -t "%Y-%m-%d %H:%M" "$f" 2>/dev/null || \ + stat -c "%y" "$f" 2>/dev/null | cut -d: -f1-2) - # Fast message extraction without jq - local msg=$(grep -m1 '"type":"user"' "$f" 2>/dev/null | \ - sed 's/.*"content":"\([^"]*\).*/\1/' | \ - sed 's/\\n/ /g' | \ - cut -c1-60 | \ - tr -d '\n') + local msg=$(extract_preview "$f" 60) [[ -n "$msg" ]] && { printf '%s\t%s\t%s\t%s\n' "$mtime" "$proj" "$id" "$msg" >> "$INDEX" @@ -77,79 +157,168 @@ build_index() { } needs_refresh() { - # No index = rebuild [[ ! -f "$INDEX" ]] && return 0 - - # Any .jsonl newer than index? (fast find -newer check) local newer=$(find "$PROJECTS" -name "*.jsonl" -newer "$INDEX" 2>/dev/null | head -1) [[ -n "$newer" ]] && return 0 + return 1 +} - return 1 # Index is fresh +# Check if date is after since filter +check_since() { + local mtime="$1" + [[ -z "$SINCE_DATE" ]] && return 0 + + # Extract just the date part (YYYY-MM-DD) + local session_date="${mtime%% *}" + [[ "$session_date" > "$SINCE_DATE" || "$session_date" == "$SINCE_DATE" ]] +} + +# Check if project matches filter +check_project() { + local proj="$1" + [[ -z "$PROJECT_FILTER" ]] && return 0 + [[ "$proj" == *"$PROJECT_FILTER"* ]] +} + +# Multi-word AND search: all words must be present +check_pattern() { + local file="$1" + local pattern="$2" + + [[ -z "$pattern" ]] && return 0 + + # Split pattern into words + local words=($pattern) + + # Check ALL words are present (AND logic) + for word in "${words[@]}"; do + grep -qi "$word" "$file" 2>/dev/null || return 1 + done + + return 0 +} + +# Display a single result +display_result() { + local date="$1" proj="$2" id="$3" msg="$4" + + if [[ "$OUTPUT_JSON" == true ]]; then + printf '{"date":"%s","project":"%s","id":"%s","preview":"%s","resume":"claude --resume %s"}' \ + "$date" "$proj" "$id" "${msg//\"/\\\"}" "$id" + else + printf "${C_CYAN}%s${C_RESET} │ ${C_YELLOW}%-22s${C_RESET} │ %.50s...\n" "$date" "$proj" "$msg" + printf " ${C_DIM}claude --resume %s${C_RESET}\n\n" "$id" + fi } search_fulltext() { local pattern="$1" + local start_time=$(date +%s) local found=0 - echo "" + # Collect results for sorting + declare -a results=() + for f in "$PROJECTS"/*/*.jsonl; do [[ -f "$f" ]] || continue [[ "$(basename "$f")" == agent* ]] && continue - # Check if pattern exists in file - grep -qi "$pattern" "$f" 2>/dev/null || continue + # Timeout check + local now=$(date +%s) + if (( now - start_time > MAX_SEARCH_TIME )); then + [[ "$OUTPUT_JSON" != true ]] && echo "Search timed out after ${MAX_SEARCH_TIME}s" >&2 + break + fi + + # Multi-word AND search + check_pattern "$f" "$pattern" || continue local id=$(basename "$f" .jsonl) - local proj=$(basename "$(dirname "$f")" | sed 's/^-Users-[^-]*-Sites-perso-//' | sed 's/^-Users-[^-]*-Sites-//' | sed 's/^-Users-[^-]*-//') - local mtime=$(stat -f "%Sm" -t "%Y-%m-%d %H:%M" "$f" 2>/dev/null || stat -c "%y" "$f" 2>/dev/null | cut -d: -f1-2) - local msg=$(grep -m1 '"type":"user"' "$f" 2>/dev/null | \ - sed 's/.*"content":"\([^"]*\).*/\1/' | \ - sed 's/\\n/ /g' | \ - cut -c1-50) + local proj=$(extract_project "$(dirname "$f")") + local mtime=$(stat -f "%Sm" -t "%Y-%m-%d %H:%M" "$f" 2>/dev/null || \ + stat -c "%y" "$f" 2>/dev/null | cut -d: -f1-2) - printf "${C_CYAN}%s${C_RESET} │ ${C_YELLOW}%-22s${C_RESET} │ %.50s...\n" "$mtime" "$proj" "$msg" - printf " ${C_DIM}claude --resume %s${C_RESET}\n\n" "$id" + # Apply filters + check_project "$proj" || continue + check_since "$mtime" || continue + local msg=$(extract_preview "$f" 50) + + # Store for sorting (pipe-delimited) + results+=("${mtime}|${proj}|${id}|${msg}") found=$((found + 1)) - [[ $found -ge $LIMIT ]] && break done - if [[ $found -eq 0 ]]; then - echo "No sessions found matching '$pattern'." + # Sort by date descending and display + if [[ ${#results[@]} -gt 0 ]]; then + if [[ "$OUTPUT_JSON" == true ]]; then + echo -n '{"sessions":[' + local first=true + printf '%s\n' "${results[@]}" | sort -rk1 -t'|' | head -"$LIMIT" | while IFS='|' read -r date proj id msg; do + [[ "$first" != true ]] && echo -n ',' + first=false + display_result "$date" "$proj" "$id" "$msg" + done + echo ']}' + else + echo "" + printf '%s\n' "${results[@]}" | sort -rk1 -t'|' | head -"$LIMIT" | while IFS='|' read -r date proj id msg; do + display_result "$date" "$proj" "$id" "$msg" + done + fi + else + if [[ "$OUTPUT_JSON" == true ]]; then + echo '{"sessions":[]}' + else + echo "No sessions found${pattern:+ matching '$pattern'}." + fi fi } search() { local pattern="$1" - if [[ -z "$pattern" ]]; then - # No pattern = use fast index + if [[ -z "$pattern" && -z "$PROJECT_FILTER" && -z "$SINCE_DATE" ]]; then + # No filters = use fast index needs_refresh && build_index + local results=$(head -"$LIMIT" "$INDEX") if [[ -z "$results" ]]; then - echo "No sessions found." + if [[ "$OUTPUT_JSON" == true ]]; then + echo '{"sessions":[]}' + else + echo "No sessions found." + fi return fi - echo "" - echo "$results" | while IFS=$'\t' read -r date proj id msg; do - printf "${C_CYAN}%s${C_RESET} │ ${C_YELLOW}%-22s${C_RESET} │ %.50s...\n" "$date" "$proj" "$msg" - printf " ${C_DIM}claude --resume %s${C_RESET}\n\n" "$id" - done + if [[ "$OUTPUT_JSON" == true ]]; then + echo -n '{"sessions":[' + local first=true + echo "$results" | while IFS=$'\t' read -r date proj id msg; do + [[ "$first" != true ]] && echo -n ',' + first=false + display_result "$date" "$proj" "$id" "$msg" + done + echo ']}' + else + echo "" + echo "$results" | while IFS=$'\t' read -r date proj id msg; do + display_result "$date" "$proj" "$id" "$msg" + done + fi else - # Pattern = full-text search (slower but accurate) + # Filters or pattern = full-text search search_fulltext "$pattern" fi } -# Parse args +# Parse arguments +KEYWORD="" while [[ $# -gt 0 ]]; do case "$1" in -h|--help) - echo "Usage: cs [keyword] Search sessions by keyword" - echo " cs Show 10 most recent sessions" - echo " cs -n 20 Show 20 results" - echo " cs --rebuild Force index rebuild" + show_help exit 0 ;; --rebuild) @@ -160,12 +329,29 @@ while [[ $# -gt 0 ]]; do LIMIT="$2" shift 2 ;; + -p|--project) + PROJECT_FILTER="$2" + shift 2 + ;; + --since) + SINCE_DATE=$(parse_since "$2") + shift 2 + ;; + --json) + OUTPUT_JSON=true + C_CYAN='' C_YELLOW='' C_DIM='' C_RESET='' + shift + ;; + -*) + echo "Unknown option: $1" >&2 + show_help + exit 1 + ;; *) - search "$1" - exit 0 + KEYWORD="$1" + shift ;; esac done -# No args = show recent -search "" +search "$KEYWORD" diff --git a/guide/observability.md b/guide/observability.md index 86bfff7..2747fee 100644 --- a/guide/observability.md +++ b/guide/observability.md @@ -63,10 +63,15 @@ source ~/.zshrc **Usage:** ```bash -cs # List 10 most recent sessions (10ms) -cs "authentication" # Full-text search (400ms) -cs -n 20 # Show 20 results -cs --rebuild # Force index rebuild +cs # List 10 most recent sessions (~15ms) +cs "authentication" # Single keyword search (~400ms) +cs "Prisma migration" # Multi-word AND search (both must match) +cs -n 20 # Show 20 results +cs -p myproject "bug" # Filter by project name +cs --since 7d # Sessions from last 7 days +cs --since today # Today's sessions only +cs --json "api" | jq . # JSON output for scripting +cs --rebuild # Force index rebuild ``` **Output:** @@ -82,9 +87,10 @@ Copy-paste the `claude --resume` command to continue any session. ### How It Works -1. **Index mode** (no keyword): Builds/uses cached TSV index of all sessions. Refreshes every hour. ~10ms lookup. -2. **Search mode** (with keyword): Greps full JSONL content. ~400ms for 200+ sessions. -3. **Filters**: Automatically excludes agent/subagent sessions (internal automation, not user conversations). +1. **Index mode** (no filters): Uses cached TSV index. Auto-refreshes when sessions change. ~15ms lookup. +2. **Search mode** (with keyword/filters): Full-text search with 3s timeout. Multi-word queries use AND logic. +3. **Filters**: `--project` (substring match), `--since` (supports `today`, `yesterday`, `7d`, `YYYY-MM-DD`) +4. **Output**: Human-readable by default, `--json` for scripting. Excludes agent/subagent sessions. ### Alternative: Python Tools