
The built-in /resume command in Claude Code only searches conversation titles. That's fine until you remember discussing something three weeks ago and need to find where.
I kept running into this. "Where did I debug that Kubernetes OOM issue?" "Which session had that SQL query for cohort analysis?" The title search wasn't cutting it.
Claude Code stores your conversations locally as JSONL files in ~/.claude/projects/. Each project gets a folder, each session gets a file. The data is right there.
So I wrote a Python script that:
claude --resume <id> command so you can jump back inNow I run /cc-search airflow and get every session where I mentioned Airflow:
═══ 2026-01-12 14:32 ═══
Project: Users/mritchie712/mike3
Session: abc123-def456
Resume: claude --resume abc123-def456
👤 ...the Airflow DAG is failing on the dbt task...
Small friction → small script → big time savings.
Here's the complete search.py:
#!/usr/bin/env python3
"""Full-text search across Claude Code conversation history."""
import json
import re
from datetime import datetime
from pathlib import Path
CLAUDE_PROJECTS = Path.home() / ".claude" / "projects"
def extract_text(content) -> str:
"""Extract searchable text from message content."""
if isinstance(content, str):
return content
if isinstance(content, list):
texts = []
for item in content:
if isinstance(item, dict):
if item.get("type") == "text":
texts.append(item.get("text", ""))
elif item.get("type") == "tool_result":
texts.append(str(item.get("content", "")))
elif isinstance(item, str):
texts.append(item)
return "\n".join(texts)
return ""
def search_session(session_path: Path, pattern: re.Pattern, context_lines: int = 2) -> list:
"""Search a single session file for matches."""
results = []
try:
with open(session_path, "r") as f:
messages = []
for line in f:
try:
entry = json.loads(line)
if entry.get("type") in ("user", "assistant"):
text = extract_text(entry.get("message", {}).get("content", entry.get("message", "")))
messages.append({
"role": entry.get("type"),
"text": text,
})
except json.JSONDecodeError:
continue
for i, msg in enumerate(messages):
if pattern.search(msg["text"]):
# Find match snippet
match = pattern.search(msg["text"])
if match:
start_pos = max(0, match.start() - 100)
end_pos = min(len(msg["text"]), match.end() + 100)
snippet = msg["text"][start_pos:end_pos]
if start_pos > 0:
snippet = "..." + snippet
if end_pos < len(msg["text"]):
snippet = snippet + "..."
results.append({
"message_index": i,
"role": msg["role"],
"snippet": snippet,
})
except Exception:
pass
return results
def search_all(query: str, project_filter: str = None, limit: int = 20) -> None:
"""Search all sessions for a query."""
pattern = re.compile(query, re.IGNORECASE)
sessions_with_matches = []
for project_dir in CLAUDE_PROJECTS.iterdir():
if not project_dir.is_dir():
continue
project_name = project_dir.name.replace("-", "/")
if project_filter and project_filter.lower() not in project_name.lower():
continue
for session_file in project_dir.glob("*.jsonl"):
if session_file.name.startswith("agent-"):
continue # Skip agent sub-sessions
results = search_session(session_file, pattern)
if results:
mtime = datetime.fromtimestamp(session_file.stat().st_mtime)
sessions_with_matches.append({
"project": project_name,
"session_id": session_file.stem,
"modified": mtime,
"matches": results,
})
# Sort by modification time (most recent first)
sessions_with_matches.sort(key=lambda x: x["modified"], reverse=True)
# Print results
print(f"\n🔍 Found {sum(len(s['matches']) for s in sessions_with_matches)} matches in {len(sessions_with_matches)} sessions\n")
shown = 0
for session in sessions_with_matches:
if shown >= limit:
remaining = len(sessions_with_matches) - shown
if remaining > 0:
print(f"\n... and {remaining} more sessions (use --limit to see more)")
break
print(f"═══ {session['modified'].strftime('%Y-%m-%d %H:%M')} ═══")
print(f"Project: {session['project']}")
print(f"Session: {session['session_id']}")
print(f"Resume: claude --resume {session['session_id']}")
print()
for match in session["matches"][:3]: # Show max 3 matches per session
role_icon = "👤" if match["role"] == "user" else "🤖"
print(f" {role_icon} {match['snippet'][:200]}")
print()
if len(session["matches"]) > 3:
print(f" ... and {len(session['matches']) - 3} more matches in this session")
print()
shown += 1
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(description="Search Claude Code conversation history")
parser.add_argument("query", help="Search query (regex supported)")
parser.add_argument("--project", "-p", help="Filter by project path substring")
parser.add_argument("--limit", "-l", type=int, default=20, help="Max sessions to show")
args = parser.parse_args()
search_all(args.query, args.project, args.limit)
To make this a skill Claude can invoke:
.claude/skills/cc-search/
├── SKILL.md
└── scripts/
└── search.py
SKILL.md:---
name: cc-search
description: Full-text search across Claude Code conversation history.
Use when searching past conversations, finding previous discussions,
or looking up what was discussed before.
---
# Claude Code Conversation Search
## Usage
python .claude/skills/cc-search/scripts/search.py "search term"
# Filter by project
python .claude/skills/cc-search/scripts/search.py "query" --project myproject
# Show more results
python .claude/skills/cc-search/scripts/search.py "query" --limit 50
The description in the frontmatter tells Claude when to invoke the skill. The rest is documentation it can reference.
If you use Claude Code regularly, look for the small annoyances. The ones you work around five times a day. Those are your skill candidates.
The investment is minimal. The payoff compounds.
Get the new standard in analytics. Sign up below or get in touch and we'll set you up in under 30 minutes.