feat(scripts): session-search v2.0 with advanced filtering
- Multi-word AND search (all words must match) - Project filter (-p, --project) - Date filter (--since today/7d/30d/YYYY-MM-DD) - JSON output (--json) for scripting - Improved preview extraction (skips tool results) - 3s search timeout for safety - Updated documentation in observability.md Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
30cbe57d1e
commit
785727d16c
2 changed files with 261 additions and 69 deletions
|
|
@ -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 <N> Show N results (default: 10)
|
||||
-p, --project <P> Filter by project name (substring match)
|
||||
--since <DATE> 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 <session-id>
|
||||
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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue