Compacting old Claude Code sessions without re-ingesting them
Written on
Something that’s been bugging me about Claude Code. I’ll have a long session going one day, hit a stopping point, and come back the next morning wanting to keep working on the same thing. The natural move is /resume to pick up where I left off, then /compact to shrink the history before continuing. The trouble is that /compact works by sending the whole session to the model and asking it to summarize. And by the next morning the prompt cache for that session is long gone (Anthropic’s default cache TTL is 5 minutes). So you eat the full input cost on a 200k token history, all cache misses, just to get a summary back.
What I wanted was to skip the model round-trip entirely and produce the summary myself. Turns out Claude Code already writes the full session to disk as a JSONL file at ~/.claude/projects/<encoded-cwd>/<session-id>.jsonl, so a python script can pull out the bits that actually matter (the opening prompt, the last few turns, the most-edited files, the last tool calls) and the agent can read just that.
So I wrote a skill called /cc-compact. The script reads the session file and prints a small XML-tagged report, and the skill tells the agent to run it once and only look at that output.
The way I actually use it is /clear and then /cc-compact with no arguments. With no selector the script picks the most recently active session in the current project and skips the one it’s running inside, which is the fresh session /clear just made, so it lands on the session I just cleared. You can still point it at a specific session with --id or --title if you want an older one.
Doing that repeatedly means the session you’re compacting was often itself started from a compacted summary, and the useful details are one more step back. So the script follows the chain. A compacted session’s log contains the cc-compact command it ran and the report it printed, both of which name the session file they came from, so the script reads that reference back out and walks to the previous session, then the one before that, up to --max-depth (10 by default). Each older session gets summarized more tightly than the one after it, every limit multiplied by --decay (0.6) per step down with floors so nothing collapses to nothing. The result is one <compacted-session-chain> with a <session depth="N"> block per generation, fading out as you go back.
In practice a single session’s report comes out to about 7k tokens with the defaults. That’s compared to whatever your session was, which for me is usually somewhere between 50k and 200k. And it’s way faster, since there’s no round-trip through the model to produce the summary.
Here’s the python script. Drop it at ~/.claude/skills/cc-compact/compact_session.py:
#!/usr/bin/env python3
"""Compact a Claude Code session log into a small, bounded summary.
This reads a session JSONL file *carefully* — it never dumps the whole
history. It extracts only the few signals needed to understand what the
session was about and what the agent was doing at the end:
- header metadata (project, branch, time span, message counts)
- the first few exchanges (user prompt + the agent's reply, truncated)
- a few exchanges randomly sampled from the middle (non-overlapping)
- the last few exchanges (user prompt + the agent's reply, truncated)
- the most-edited files (top N by edit count)
- the final assistant text (what it was saying last)
- the last few tool calls (what it was doing last)
Session resolution (pick one):
--file PATH use this JSONL file directly
--id UUID find <UUID>.jsonl under the projects dir
--title TEXT find the session whose ai-title contains TEXT
(case-insensitive substring; newest match wins)
--latest the most recently active session in the current project,
excluding the caller's own session — this is the default
when no selector is given, so `cc-compact` right after
`/clear` picks up the session you just cleared
Sessions live at: ~/.claude/projects/<encoded-cwd>/<session-id>.jsonl
"""
import argparse
import glob
import json
import os
import random
import re
import sys
PROJECTS_DIR = os.path.expanduser("~/.claude/projects")
# Tools that change files on disk, and which input field holds the path.
EDIT_TOOLS = {
"Edit": "file_path",
"Write": "file_path",
"MultiEdit": "file_path",
"NotebookEdit": "notebook_path",
}
def truncate(text, n):
"""Collapse whitespace to a single line, then clip to n chars.
Use for compact one-liners like tool arguments."""
text = " ".join(text.split())
return text if len(text) <= n else text[: n - 1] + "…"
def clip(text, n):
"""Clip to n chars while preserving newlines/indentation.
Use for quoted messages where structure matters."""
text = text.strip("\n")
return text if len(text) <= n else text[:n].rstrip() + " …[truncated]"
def esc(text):
"""Escape XML metacharacters for tag values and attributes."""
return str(text).replace("&", "&").replace("<", "<").replace(">", ">")
def content_to_text(content):
"""A message's .content is either a string or a list of typed blocks."""
if isinstance(content, str):
return content
if isinstance(content, list):
parts = [b.get("text", "") for b in content if isinstance(b, dict) and b.get("type") == "text"]
return "\n".join(p for p in parts if p)
return ""
def is_genuine_prompt(rec):
"""A real human-typed prompt: a user message that isn't a tool result,
meta record, slash-command wrapper, or interrupt marker."""
if rec.get("type") != "user" or rec.get("isMeta"):
return False
content = rec.get("message", {}).get("content")
if not isinstance(content, str):
return False
s = content.lstrip()
if not s:
return False
skip_prefixes = ("<", "[Request interrupted", "Caveat:")
return not s.startswith(skip_prefixes)
def current_session():
"""(project_dir, session_id) for the session invoking this script.
Claude Code exports CLAUDE_CODE_SESSION_ID; its log lives at
<project_dir>/<id>.jsonl. Fall back to the encoded cwd when the env var is
missing (script run by hand outside a session). Either value may be None."""
sid = os.environ.get("CLAUDE_CODE_SESSION_ID")
if sid:
matches = glob.glob(os.path.join(PROJECTS_DIR, "**", f"{sid}.jsonl"), recursive=True)
if matches:
return os.path.dirname(matches[0]), sid
# Claude Code encodes the cwd into the project dir name by replacing every
# non-alphanumeric char with a dash (/Users/kaan/Code/Veery -> -Users-kaan-Code-Veery).
encoded = re.sub(r"[^A-Za-z0-9]", "-", os.getcwd())
cand = os.path.join(PROJECTS_DIR, encoded)
return (cand if os.path.isdir(cand) else None), sid
def resolve_latest():
"""Newest session log in the current project, excluding the caller's own
session, so compacting right after `/clear` lands on the just-cleared one."""
project_dir, sid = current_session()
if project_dir:
pool = glob.glob(os.path.join(project_dir, "*.jsonl"))
else:
pool = glob.glob(os.path.join(PROJECTS_DIR, "**", "*.jsonl"), recursive=True)
if sid:
pool = [p for p in pool if os.path.basename(p) != f"{sid}.jsonl"]
if not pool:
sys.exit("No previous session found to compact in this project")
latest = max(pool, key=os.path.getmtime)
sys.stderr.write(f"Auto-selected latest session: {latest}\n")
return latest
# A compacted session's log records the cc-compact run it did on its own
# predecessor: the helper command (carrying --id/--file) and the report it
# printed (whose <file> tag and "Auto-selected" line hold the resolved absolute
# path). Following those references walks the /clear -> cc-compact lineage back.
_ANCESTOR_PATH_RES = [
re.compile(r"Auto-selected latest session:s*(S+.jsonl)"),
re.compile(r"<file>([^<]+.jsonl)</file>"),
re.compile(r"compact_session.py[^\n"]*?--file[= ]+(S+.jsonl)"),
]
_ANCESTOR_ID_RE = re.compile(r"compact_session.py[^\n"]*?--id[= ]+([0-9a-fA-F-]{36})")
def find_ancestor(path, visited):
"""Return the session log `path` was compacted from, or None.
Scans for references to another session and returns the first that resolves
to an existing file not already in `visited` (guards against cycles)."""
self_real = os.path.realpath(path)
try:
fh = open(path, encoding="utf-8", errors="replace")
except OSError:
return None
with fh:
for line in fh:
if "compact_session.py" not in line and "<file>" not in line and "Auto-selected" not in line:
continue
candidates = []
for pat in _ANCESTOR_PATH_RES:
candidates += pat.findall(line)
for sid in _ANCESTOR_ID_RE.findall(line):
candidates += glob.glob(os.path.join(PROJECTS_DIR, "**", f"{sid}.jsonl"), recursive=True)
for cand in candidates:
cand = os.path.expanduser(cand)
if not os.path.isfile(cand):
continue
real = os.path.realpath(cand)
if real == self_real or real in visited:
continue
return cand
return None
def resolve_file(args):
if args.latest:
return resolve_latest()
if args.file:
return os.path.expanduser(args.file)
if args.id:
matches = glob.glob(os.path.join(PROJECTS_DIR, "**", f"{args.id}.jsonl"), recursive=True)
if not matches:
sys.exit(f"No session file found for id {args.id} under {PROJECTS_DIR}")
return matches[0]
if args.title:
needle = args.title.lower()
candidates = [] # (mtime, path, title)
for path in glob.glob(os.path.join(PROJECTS_DIR, "**", "*.jsonl"), recursive=True):
title = None
try:
with open(path, encoding="utf-8") as fh:
for line in fh:
if '"ai-title"' not in line:
continue
try:
rec = json.loads(line)
except json.JSONDecodeError:
continue
if rec.get("type") == "ai-title":
title = rec.get("aiTitle", "")
if needle in title.lower():
break
title = None
except OSError:
continue
if title is not None:
candidates.append((os.path.getmtime(path), path, title))
if not candidates:
sys.exit(f"No session whose ai-title contains {args.title!r}")
candidates.sort(reverse=True)
if len(candidates) > 1:
sys.stderr.write("Multiple matches (using newest):\n")
for _, path, title in candidates:
sys.stderr.write(f" {path} — {title}\n")
return candidates[0][1]
sys.exit("Provide one of --file, --id, or --title")
def main():
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
g = ap.add_mutually_exclusive_group()
g.add_argument("--file", help="path to a session .jsonl")
g.add_argument("--id", help="session UUID")
g.add_argument("--title", help="substring of the session's ai-title")
g.add_argument("--latest", action="store_true",
help="newest session in the current project, excluding the caller's own "
"(the default when no selector is given)")
ap.add_argument("--first", type=int, default=5, help="how many opening exchanges")
ap.add_argument("--last", type=int, default=10, help="how many closing exchanges")
ap.add_argument("--middle", type=int, default=5, help="how many exchanges randomly sampled from the middle")
ap.add_argument("--top-files", type=int, default=10, help="how many most-edited files")
ap.add_argument("--maxlen", type=int, default=800, help="max chars per quoted message")
ap.add_argument("--seed", type=int, default=1, help="seed for the middle random sample")
ap.add_argument("--max-depth", type=int, default=10,
help="how many ancestor sessions to follow back through the "
"/clear -> cc-compact chain (0 = just this session)")
ap.add_argument("--decay", type=float, default=0.6,
help="per-depth shrink factor for every limit (tighter summaries deeper in the chain)")
args = ap.parse_args()
if not (args.file or args.id or args.title):
args.latest = True
path = resolve_file(args)
out = sys.stdout.write
# Walk the lineage: the requested session at depth 0, then each older
# session it was compacted from, summarized ever more tightly.
visited = {os.path.realpath(path)}
chain = [path]
cur = path
for _ in range(max(0, args.max_depth)):
anc = find_ancestor(cur, visited)
if not anc:
break
chain.append(anc)
visited.add(os.path.realpath(anc))
cur = anc
out(f'<compacted-session-chain sessions="{len(chain)}" '
'note="depth 0 is the session you asked for; deeper entries are older '
'sessions it was compacted from, summarized more tightly">\n')
for depth, p in enumerate(chain):
params = scale_params(args, depth)
summarize(p, depth, params, out)
out("</compacted-session-chain>\n")
# Floors keep even the deepest summary useful without letting it grow.
_FLOORS = {"first": 1, "last": 2, "middle": 0, "top_files": 3, "maxlen": 200, "final": 300, "tools": 3}
_BASE_FINAL = 2000 # final-agent-message clip at depth 0
_BASE_TOOLS = 8 # last-tool-calls shown at depth 0
def scale_params(args, depth):
"""Depth-0 uses the CLI limits; each deeper level multiplies every limit by
args.decay ** depth, floored so summaries stay non-empty."""
f = args.decay ** depth
def s(base, floor):
return max(floor, int(round(base * f)))
return {
"first": s(args.first, _FLOORS["first"]),
"last": s(args.last, _FLOORS["last"]),
"middle": s(args.middle, _FLOORS["middle"]),
"top_files": s(args.top_files, _FLOORS["top_files"]),
"maxlen": s(args.maxlen, _FLOORS["maxlen"]),
"final": s(_BASE_FINAL, _FLOORS["final"]),
"tools": s(_BASE_TOOLS, _FLOORS["tools"]),
"seed": args.seed,
}
def summarize(path, depth, p, out):
"""Load one session log and emit its bounded, XML-tagged report under a
<session depth="N"> element. `p` holds the per-depth limits."""
turns = [] # conversational turns: {"prompt": str, "reply": [text,...]}
edit_counts = {} # file path -> edit count
last_assistant_text = [] # final text blocks
recent_tools = [] # (name, short arg)
counts = {} # record type -> count
first_ts = last_ts = None
cwd = branch = None
with open(path, encoding="utf-8") as fh:
for line in fh:
line = line.strip()
if not line:
continue
try:
rec = json.loads(line)
except json.JSONDecodeError:
continue
rtype = rec.get("type")
counts[rtype] = counts.get(rtype, 0) + 1
ts = rec.get("timestamp")
if ts:
first_ts = first_ts or ts
last_ts = ts
cwd = cwd or rec.get("cwd")
branch = branch or rec.get("gitBranch")
if is_genuine_prompt(rec):
turns.append({"prompt": rec["message"]["content"], "reply": []})
if rtype == "assistant":
blocks = rec.get("message", {}).get("content", [])
if isinstance(blocks, list):
text_here = []
for b in blocks:
if not isinstance(b, dict):
continue
if b.get("type") == "text" and b.get("text", "").strip():
text_here.append(b["text"])
elif b.get("type") == "tool_use":
name = b.get("name", "?")
inp = b.get("input", {}) or {}
if name in EDIT_TOOLS:
fp = inp.get(EDIT_TOOLS[name])
if fp:
edit_counts[fp] = edit_counts.get(fp, 0) + 1
arg = inp.get("file_path") or inp.get("command") or inp.get("description") or inp.get("path") or ""
recent_tools.append((name, truncate(str(arg), 300)))
if text_here:
last_assistant_text = text_here # keep only the latest turn's text
if turns: # attach the agent's reply to the current turn
turns[-1]["reply"].extend(text_here)
# Non-overlapping index sets: first wins, then last, then the middle is
# sampled only from what's left between them. If the regions collide
# (short session), the overlap is simply dropped.
n = len(turns)
first_idx = list(range(min(p["first"], n)))
last_start = max(len(first_idx), n - p["last"])
last_idx = list(range(last_start, n))
mid_pool = list(range(len(first_idx), last_start))
if p["seed"] is not None:
random.seed(p["seed"])
mid_idx = sorted(random.sample(mid_pool, min(p["middle"], len(mid_pool))))
# ---- emit a bounded, XML-tagged report (newlines preserved) ----
role = "requested" if depth == 0 else "ancestor"
out(f'\n<session depth="{depth}" role="{role}">\n')
out("<session-summary>\n")
out(f" <file>{esc(path)}</file>\n")
out(f" <project>{esc(cwd)}</project>\n")
out(f" <git-branch>{esc(branch)}</git-branch>\n")
out(f' <time-span start="{esc(first_ts)}" end="{esc(last_ts)}" />\n')
out(f" <records>{esc(', '.join(f'{k}={v}' for k, v in sorted(counts.items())))}</records>\n")
out(f" <user-prompts count="{len(turns)}" />\n")
out("</session-summary>\n")
def emit_exchanges(label, indices):
if not indices:
return
out(f'\n<exchanges section="{label}">\n')
for i in indices:
t = turns[i]
reply = "\n".join(t["reply"]).strip()
out(f' <exchange n="{i + 1}">\n')
out(f" <user>\n{clip(t['prompt'], p['maxlen'])}\n </user>\n")
if reply:
out(f" <agent>\n{clip(reply, p['maxlen'])}\n </agent>\n")
else:
out(" <agent note="no text reply — tool calls only" />\n")
out(" </exchange>\n")
out("</exchanges>\n")
emit_exchanges("first", first_idx)
emit_exchanges("sampled-middle", mid_idx)
emit_exchanges("last", last_idx)
out(f'\n<most-edited-files top="{p["top_files"]}">\n')
for fp, c in sorted(edit_counts.items(), key=lambda kv: -kv[1])[: p["top_files"]]:
out(f' <file edits="{c}">{esc(fp)}</file>\n')
out("</most-edited-files>\n")
out("\n<last-tool-calls>\n")
for name, arg in recent_tools[-p["tools"]:]:
out(f' <call tool="{esc(name)}">{esc(arg)}</call>\n')
out("</last-tool-calls>\n")
out("\n<final-agent-message>\n")
out(clip("\n".join(last_assistant_text), p["final"]) + "\n")
out("</final-agent-message>\n")
out("</session>\n")
if __name__ == "__main__":
main()And here’s the skill file at ~/.claude/skills/cc-compact/SKILL.md:
---
name: cc-compact
description: Summarize ("compact") a past Claude Code session without ingesting its entire history. Resolves the session log, then extracts only the key signals — opening intent, recent prompts, most-edited files, and what the agent was doing last. Use, when asked, to resume long sessions without ingesting entire log.
---
You are reloading the context of a previous Claude Code session so you can
**continue that work in this session** — like `/resume` followed by `/compact`, but pre-compacted. The
bundled helper script extracts the key signals and prints a compact report.
**Hard rule: you may NOT read the session `.jsonl` file by any means** — no
`cat`, `head`, `tail`, `jq`, `grep`, `Read`, nothing. Run the helper script
**once** and work solely from its output. That is the only thing allowed to
touch the file.
The helper script lives at `~/.claude/skills/cc-compact/compact_session.py`.
## Step 1: Resolve the session
The argument is one of three forms:
- **Nothing** — no id or title given. This is the common case: the user ran
`/clear` and then `cc-compact` to reload the session they just cleared. Run
the script with no selector; it auto-picks the most recently active session
in the current project, **excluding this session** (the fresh one `/clear`
created). This emulates Claude Code's built-in `/compact`.
- **A session id** — when invoked like `/resume claude --resume <id>` or
`cc-compact <id>`, the user already gave you the UUID. Pass it as `--id`.
- **A session name / title** — free text. Pass it as `--title`; the script
matches it against the `ai-title` records inside the logs (case-insensitive
substring, newest match wins).
Run the script exactly once with the matching form:
```sh
# Latest session before the /clear (default, no selector):
python3 ~/.claude/skills/cc-compact/compact_session.py
# By id:
python3 ~/.claude/skills/cc-compact/compact_session.py --id <session-uuid>
# By title:
python3 ~/.claude/skills/cc-compact/compact_session.py --title "the session name"
```
## Step 2: Read the output
Everything is wrapped in a `<compacted-session-chain>` element containing one or
more `<session depth="N">` blocks. Each block is a bounded, XML-tagged report:
- header: project, git branch, time span, record/prompt counts
- the first few exchanges (user prompt + the agent's reply) — the original intent
- a few exchanges sampled from the middle (non-overlapping) — the journey
- the last few exchanges (user prompt + the agent's reply) — where things were heading
- the most-edited files, ranked by edit count
- the last several tool calls — what the agent was physically doing last
- the final assistant message — what it was saying / waiting on last
`depth="0"` (`role="requested"`) is the session you asked for. If that session
was itself resumed from an earlier one via `/clear` → cc-compact, the script
follows that lineage backwards and emits each older session at `depth="1"`, `2`,
… (`role="ancestor"`), summarized more tightly at each step so the total stays
bounded. It walks up to `--max-depth` ancestors (default 10) and guards against
cycles. Read the deeper blocks as fading background: the further back, the
terser. You do not need to do anything to trigger this — it happens on its own.
## Step 3: Pick up the work
The output is context for *you* — treat it like a resumed session, not something
to report on. From it, reconstruct what the work was, which files are in play,
and what the agent was in the middle of.
Compaction is lossy — the output may not make the next step unambiguous. Before
diving in, decide whether you actually know how to proceed:
- **If the next step is very clear, last few messages from user confirms what to do**, open with one line confirming what you're
resuming, then continue from where it left off.
- **If it's ambiguous** (unclear what the user wants next, multiple plausible
directions, or the session ended mid-decision), **ask the user how to proceed**
before acting. Surface the few plausible next steps you inferred and let them
pick or correct you. If there is any doubt, opt to ask the user first.
## If the compact report left something unclear
Compaction is lossy. If — and **only if absolutely necessary** — a specific
detail you need to proceed is missing or ambiguous (an exact command that ran,
a file path, what a tool returned, the precise wording of an earlier request),
search the full session with the companion **cc-query-chat** skill instead of
reading the raw log:
```sh
python3 ~/.claude/skills/cc-query-chat/query_session.py --chat '<same id or name>' --keyword 'the detail you need'
```
The compact report is meant to stand on its own — reach for this only to close a
concrete gap, never to re-read the session.Compaction throws things away on purpose, so every so often the agent needs one specific detail the report dropped: the exact command that failed, a path, what some tool actually printed. Reading the log back in to find it would defeat the whole point. So there’s a companion skill, /cc-query-chat, that searches the session instead. Same hard rule, the agent isn’t allowed near the JSONL, it runs the script and works from the results.
The script flattens the log into typed items — user prompts, agent replies, command for Bash calls, file for reads and edits, tool for everything else, output for tool results — and searches those. Three matching schemes: --regexp and --glob are exact filters, --keyword is fuzzy and ranked, scoring each item by how many distinct keywords it matched. --type narrows to some of the item types, and --limit (5) plus --truncate (900 chars, the snippet centred on the match) keep the output at around 2k tokens. Keyword to find the area, regexp to pinpoint.
Here’s that script, at ~/.claude/skills/cc-query-chat/query_session.py:
#!/usr/bin/env python3
"""Search a Claude Code session log without ingesting its entire history.
This is the search companion to cc-compact. Where cc-compact gives a bounded
overview of a session, this lets you *search* one — across everything the
session contains:
- user the human's typed prompts
- agent the assistant's text replies
- command Bash commands (the command + its description)
- file file operations (Read/Write/Edit/NotebookEdit) — path + content
- tool other tool calls (the tool name + its input)
- output tool results — command output, file contents read back, search
hits, etc. (labelled with the tool/arg that produced them)
Three matching schemes (pick one):
--regexp PATTERN Python regular expression, searched anywhere in the item
--glob PATTERN shell-style glob (*, ?, [..]), searched anywhere
--keyword "a b c" space-separated keywords; partial (substring) matches
count. Items are ranked: more distinct keywords matched =
better, ties broken by total hit count. Only items that
match at least one keyword are returned.
regexp/glob are exact filters — an item is either a match or it isn't.
keyword is fuzzy and ranked.
Session resolution (pick one):
--chat TEXT a session id (UUID) OR a substring of its ai-title.
Auto-detected: looks like a UUID -> treated as id.
--file PATH use this JSONL file directly
--id UUID find <UUID>.jsonl under the projects dir
--title TEXT find the session whose ai-title contains TEXT
Other options:
--type LIST restrict to item types (comma-separated): any of
user,agent,command,file,tool,output
--limit N max results to show (default 5)
--truncate N max chars of matched text shown per result; the snippet
is centred on the match so you see the relevant part
(default 900 — tuned for a ~2k token total)
--case-sensitive make regexp/glob matching case-sensitive (default: not)
Sessions live at: ~/.claude/projects/<encoded-cwd>/<session-id>.jsonl
"""
import argparse
import fnmatch # noqa: F401 (kept for reference; we hand-roll glob->regex)
import glob
import json
import os
import re
import sys
PROJECTS_DIR = os.path.expanduser("~/.claude/projects")
UUID_RE = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", re.I)
# Tools that touch files on disk, and which input field holds the path.
FILE_TOOLS = {
"Edit": "file_path",
"Write": "file_path",
"MultiEdit": "file_path",
"NotebookEdit": "notebook_path",
"Read": "file_path",
}
ALL_TYPES = ["user", "agent", "command", "file", "tool", "output"]
# --------------------------------------------------------------------------- #
# text helpers
# --------------------------------------------------------------------------- #
def esc(text):
"""Escape XML metacharacters for tag values and attributes."""
return str(text).replace("&", "&").replace("<", "<").replace(">", ">")
def oneline(text, n):
"""Collapse whitespace to a single line, then clip to n chars."""
text = " ".join(str(text).split())
return text if len(text) <= n else text[: n - 1] + "…"
def content_to_text(content):
"""A message's .content is either a string or a list of typed blocks."""
if isinstance(content, str):
return content
if isinstance(content, list):
parts = []
for b in content:
if isinstance(b, dict) and b.get("type") == "text":
parts.append(b.get("text", ""))
return "\n".join(p for p in parts if p)
return ""
def tool_result_text(content):
"""tool_result .content is a string or a list of {type,text|...} blocks."""
if isinstance(content, str):
return content
if isinstance(content, list):
parts = []
for b in content:
if not isinstance(b, dict):
continue
if b.get("type") == "text":
parts.append(b.get("text", ""))
elif b.get("type") == "image":
parts.append("[image]")
return "\n".join(p for p in parts if p)
return ""
def glob_to_regex(pat):
"""Translate a shell glob to a regex fragment we can re.search() anywhere.
Unlike fnmatch (which anchors and matches the *whole* string), this is
meant to find the pattern somewhere inside long, multi-line text — so
`ab*c` matches any text containing 'ab' ... 'c'.
"""
out = []
i, n = 0, len(pat)
while i < n:
c = pat[i]
if c == "*":
out.append(".*")
elif c == "?":
out.append(".")
elif c == "[":
j = i + 1
if j < n and pat[j] in "!^":
j += 1
if j < n and pat[j] == "]":
j += 1
while j < n and pat[j] != "]":
j += 1
if j >= n:
out.append(r"[") # unterminated class -> literal
else:
stuff = pat[i + 1 : j]
if stuff.startswith("!"):
stuff = "^" + stuff[1:]
out.append("[" + stuff + "]")
i = j
else:
out.append(re.escape(c))
i += 1
return "".join(out)
def snippet(text, start, end, n):
"""Return up to n chars of `text` centred on the span [start, end),
with ellipses marking where it was clipped. Newlines preserved."""
text = text.strip("\n")
if len(text) <= n:
return text
span = end - start
if span >= n:
return text[start : start + n].rstrip() + " …[truncated]"
pad = (n - span) // 2
lo = max(0, start - pad)
hi = min(len(text), lo + n)
lo = max(0, hi - n)
out = text[lo:hi].strip()
if lo > 0:
out = "…" + out
if hi < len(text):
out = out + "…"
return out
# --------------------------------------------------------------------------- #
# session resolution
# --------------------------------------------------------------------------- #
def find_by_title(needle):
needle = needle.lower()
candidates = [] # (mtime, path, title)
for path in glob.glob(os.path.join(PROJECTS_DIR, "**", "*.jsonl"), recursive=True):
title = None
try:
with open(path, encoding="utf-8") as fh:
for line in fh:
if '"ai-title"' not in line:
continue
try:
rec = json.loads(line)
except json.JSONDecodeError:
continue
if rec.get("type") == "ai-title":
t = rec.get("aiTitle", "")
if needle in t.lower():
title = t
break
except OSError:
continue
if title is not None:
candidates.append((os.path.getmtime(path), path, title))
if not candidates:
sys.exit(f"No session whose ai-title contains {needle!r}")
candidates.sort(reverse=True)
if len(candidates) > 1:
sys.stderr.write("Multiple title matches (using newest):\n")
for _, path, title in candidates:
sys.stderr.write(f" {path} — {title}\n")
return candidates[0][1]
def find_by_id(uuid):
matches = glob.glob(os.path.join(PROJECTS_DIR, "**", f"{uuid}.jsonl"), recursive=True)
if not matches:
sys.exit(f"No session file found for id {uuid} under {PROJECTS_DIR}")
return matches[0]
def resolve_file(args):
if args.file:
return os.path.expanduser(args.file)
if args.id:
return find_by_id(args.id)
if args.title:
return find_by_title(args.title)
if args.chat:
chat = args.chat.strip()
if UUID_RE.match(chat):
return find_by_id(chat)
return find_by_title(chat)
sys.exit("Provide one of --chat, --file, --id, or --title")
# --------------------------------------------------------------------------- #
# item extraction
# --------------------------------------------------------------------------- #
def extract_items(path):
"""Walk the log once and produce a flat, ordered list of searchable items.
Each item: {"type", "turn", "ts", "label", "text"}.
"""
items = []
turn = 0
tool_meta = {} # tool_use_id -> (tool_name, short_arg)
def add(itype, label, text, ts):
if not text or not text.strip():
return
items.append({"type": itype, "turn": turn, "ts": ts, "label": label, "text": text})
with open(path, encoding="utf-8") as fh:
for line in fh:
line = line.strip()
if not line:
continue
try:
rec = json.loads(line)
except json.JSONDecodeError:
continue
rtype = rec.get("type")
ts = rec.get("timestamp")
msg = rec.get("message") if isinstance(rec.get("message"), dict) else {}
content = msg.get("content")
if rtype == "user" and not rec.get("isMeta"):
# Genuine prompt = plain string that isn't a wrapper/interrupt.
if isinstance(content, str):
s = content.lstrip()
if s and not s.startswith(("<", "[Request interrupted", "Caveat:")):
turn += 1
add("user", "user prompt", content, ts)
# tool_result blocks live inside user messages
if isinstance(content, list):
for b in content:
if isinstance(b, dict) and b.get("type") == "tool_result":
tid = b.get("tool_use_id")
name, arg = tool_meta.get(tid, ("tool", ""))
label = f"{name} {arg}".strip()
add("output", f"output ← {label}", tool_result_text(b.get("content")), ts)
elif rtype == "assistant" and isinstance(content, list):
texts = []
for b in content:
if not isinstance(b, dict):
continue
bt = b.get("type")
if bt == "text" and b.get("text", "").strip():
texts.append(b["text"])
elif bt == "tool_use":
name = b.get("name", "?")
inp = b.get("input", {}) or {}
tid = b.get("id")
if name == "Bash":
cmd = str(inp.get("command", ""))
desc = str(inp.get("description", ""))
body = cmd if not desc else f"{cmd}\n# {desc}"
add("command", oneline(cmd, 120), body, ts)
tool_meta[tid] = (name, oneline(cmd, 60))
elif name in FILE_TOOLS:
fp = str(inp.get(FILE_TOOLS[name], ""))
pieces = [f"[{name}] {fp}"]
for k in ("content", "old_string", "new_string"):
if inp.get(k):
pieces.append(str(inp[k]))
add("file", f"{name} {fp}", "\n".join(pieces), ts)
tool_meta[tid] = (name, oneline(os.path.basename(fp) or fp, 60))
else:
try:
body = json.dumps(inp, ensure_ascii=False, indent=2)
except (TypeError, ValueError):
body = str(inp)
add("tool", name, f"[{name}]\n{body}", ts)
arg = inp.get("file_path") or inp.get("path") or inp.get("query") or ""
tool_meta[tid] = (name, oneline(str(arg), 60))
if texts:
add("agent", "agent reply", "\n".join(texts), ts)
return items
# --------------------------------------------------------------------------- #
# matching
# --------------------------------------------------------------------------- #
def match_regex(items, pattern, flags):
try:
rx = re.compile(pattern, flags | re.DOTALL)
except re.error as e:
sys.exit(f"Invalid regular expression: {e}")
results = []
for it in items:
m = rx.search(it["text"])
if m:
results.append((it, m.start(), m.end(), None))
return results
def match_keyword(items, query, flags):
words = [w for w in query.split() if w]
if not words:
sys.exit("--keyword needs at least one word")
ci = flags & re.IGNORECASE
scored = []
for it in items:
text = it["text"]
hay = text.lower() if ci else text
distinct = 0
total = 0
first_pos = None
first_len = 0
for w in words:
needle = w.lower() if ci else w
cnt = hay.count(needle)
if cnt:
distinct += 1
total += cnt
pos = hay.find(needle)
if first_pos is None or pos < first_pos:
first_pos = pos
first_len = len(needle)
if distinct:
scored.append((distinct, total, it, first_pos, first_pos + first_len))
# Best first: more distinct keywords, then more total hits.
scored.sort(key=lambda r: (-r[0], -r[1]))
out = []
for distinct, total, it, s, e in scored:
out.append((it, s, e, f"{distinct}/{len(words)} kw, {total} hits"))
return out
# --------------------------------------------------------------------------- #
# main
# --------------------------------------------------------------------------- #
def main():
ap = argparse.ArgumentParser(
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
)
# session resolution
ap.add_argument("--chat", help="session id (UUID) or substring of its ai-title")
ap.add_argument("--file", help="path to a session .jsonl")
ap.add_argument("--id", help="session UUID")
ap.add_argument("--title", help="substring of the session's ai-title")
# match scheme (exactly one)
mode = ap.add_mutually_exclusive_group(required=True)
mode.add_argument("--regexp", metavar="PATTERN", help="regular-expression search")
mode.add_argument("--glob", metavar="PATTERN", help="shell-glob search")
mode.add_argument("--keyword", metavar="WORDS", help="ranked keyword search")
# filters / shaping
ap.add_argument("--type", help="restrict to item types (comma-separated): " + ",".join(ALL_TYPES))
ap.add_argument("--limit", type=int, default=5, help="max results (default 5)")
ap.add_argument("--truncate", type=int, default=900, help="max chars of matched text per result (default 900)")
ap.add_argument("--case-sensitive", action="store_true", help="case-sensitive regexp/glob (default off)")
args = ap.parse_args()
path = resolve_file(args)
wanted = None
if args.type:
wanted = {t.strip().lower() for t in args.type.split(",") if t.strip()}
bad = wanted - set(ALL_TYPES)
if bad:
sys.exit(f"Unknown --type value(s): {', '.join(sorted(bad))}. Choose from {', '.join(ALL_TYPES)}")
items = extract_items(path)
if wanted:
items = [it for it in items if it["type"] in wanted]
flags = 0 if args.case_sensitive else re.IGNORECASE
if args.regexp is not None:
scheme, query = "regexp", args.regexp
results = match_regex(items, args.regexp, flags)
elif args.glob is not None:
scheme, query = "glob", args.glob
results = match_regex(items, glob_to_regex(args.glob), flags)
else:
scheme, query = "keyword", args.keyword
# keyword is always case-insensitive for friendliness
results = match_keyword(items, args.keyword, re.IGNORECASE)
shown = results[: args.limit]
out = sys.stdout.write
out(
f'<chat-query mode="{scheme}" query="{esc(query)}" '
f'searched-items="{len(items)}" matched="{len(results)}" showing="{len(shown)}">\n'
)
out(f" <file>{esc(path)}</file>\n")
if not shown:
out(" <no-matches />\n")
for n, (it, s, e, note) in enumerate(shown, 1):
attrs = f'n="{n}" type="{it["type"]}" turn="{it["turn"]}"'
if note:
attrs += f' score="{esc(note)}"'
if it["ts"]:
attrs += f' ts="{esc(it["ts"])}"'
out(f" <result {attrs}>\n")
out(f" <label>{esc(oneline(it['label'], 200))}</label>\n")
snip = snippet(it["text"], s if s is not None else 0, e if e is not None else 0, args.truncate)
out(f" <match>\n{esc(snip)}\n </match>\n")
out(" </result>\n")
out("</chat-query>\n")
if __name__ == "__main__":
main()And its skill file at ~/.claude/skills/cc-query-chat/SKILL.md:
---
name: cc-query-chat
description: Search a past Claude Code session by id or name — across prompts, replies, commands, files, and tool output — via regexp, glob, or ranked keyword.
---
You are searching a previous Claude Code session for specific information,
without reading its whole history. The bundled helper script resolves the
session, walks it once, and prints only the matching items.
**Hard rule: you may NOT read the session `.jsonl` file by any means** — no
`cat`, `head`, `tail`, `jq`, `grep`, `Read`, nothing. Run the helper script and
work solely from its output. That is the only thing allowed to touch the file.
The helper lives at `~/.claude/skills/cc-query-chat/query_session.py`.
## Step 1: Resolve the session
The session is identified by id or name, passed via `--chat`:
It auto-detects whether the parameter is a UUID or title.
Titles are case insensitive matched.
If you already know it's a title vs an id, `--title`/`--id`/`--file` also work.
## Step 2: Choose a matching scheme
Exactly one of these is required:
- **`--regexp PATTERN`** — Python regex, searched anywhere in each item.
Exact filter: an item either matches or it doesn't.
```sh
... --chat '<id or name>' --regexp 'ab.*c'
```
- **`--glob PATTERN`** — shell glob (`*`, `?`, `[..]`), searched anywhere in
each item. Exact filter.
```sh
... --chat '<id or name>' --glob 'ab*c'
```
- **`--keyword "WORDS"`** — space-separated keywords with partial (substring)
matches. Results are **ranked**: the more distinct keywords an item matches
the higher it scores (ties broken by total hit count), best first.
```sh
... --chat '<id or name>' --keyword 'data pipeline error delivery'
```
regexp and glob are case-insensitive by default (pass `--case-sensitive` to
change that); keyword is always case-insensitive.
## Step 3: Narrow and shape the results
- **`--type LIST`** — restrict to item types (comma-separated). Types:
- `user` — the human's prompts
- `agent` — the assistant's text replies
- `command` — Bash commands (command + description)
- `file` — file operations (Read/Write/Edit/NotebookEdit): path + content
- `tool` — other tool calls (name + input)
- `output` — tool results: command output, file contents read back, etc.
e.g. `--type command,output` to search only what was run and what it printed.
- **`--limit N`** — max results (default 5).
- **`--truncate N`** — max chars of matched text per result; the shown snippet
is centred on the match.
The defaults are tuned so that output is short enough to note pollute context.
Do not raise them without good reason. If raising `truncate`, you might want to lower `limit` and vice versa.
## Step 4: Read the output
It prints an XML-tagged report: a header with how many items were searched and
matched, then one `<result>` per hit with its `type`, `turn` number, timestamp,
keyword score (for keyword mode), a `<label>` (e.g. the command or file path),
and the matched `<match>` snippet.
Iterate if needed — refine the pattern, switch schemes (keyword to discover,
regexp/glob to pinpoint), add a `--type` filter, or raise `--limit`/`--truncate`
— rather than reading the raw log. Avoid over-querying, if you don't find
what you're looking for after a few queries then stop.
If you must dig into queries deeply, start an agent prompted with this tool
to dig into the query without polluting your own context.Obviously this is not resuming the same session. It’s a fresh session that gets a summary of the old one, which means Claude Code tooling is a bit rough here, like you’ll see a new entry in the list if you try to /resume later rather than the two being merged together. I suppose I could write a script to merge them myself but I haven’t needed to. The middle sample is also random with a fixed seed, so it’s deterministic but it might miss the actual pivotal turn where you and the agent changed direction. For me the opening prompt plus the last ten exchanges plus the most-edited files is usually enough. If something’s missing the agent asks.
One caveat: I’m parsing the JSONL format based on what I see Claude Code currently writes, like ai-title records, tool_use blocks, the field names in EDIT_TOOLS, the CLAUDE_CODE_SESSION_ID env var, and the way the cwd gets encoded into the project directory name. If Anthropic changes any of that the script will silently start missing things, so if it ever stops finding sessions by title, that’s the first place to look.
Also, dropping both scripts and both skill files inline in a blog post is not a great way to share them. I should probably stick them in a repo and link it. If I do that I’ll come back and update this post.
