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:
Florian BRUNIAUX 2026-01-15 09:52:54 +01:00
parent 30cbe57d1e
commit 785727d16c
2 changed files with 261 additions and 69 deletions

View file

@ -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"

View file

@ -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