Streaming parser for Claude Code session JSONL files
Pulls structured token-usage and chat-event data out of the .jsonl files
Claude Code writes to
~/.claude/projects/<encoded-path>/<session-id>.jsonl. Streaming,
read-only, no network, no subprocess, no writes.
Install
[]
= "0.2"
Usage
use Path;
use ;
let path = new;
// Usage / billing view: one UsageEntry per assistant turn
let parsed = parse_jsonl_entries?;
let summary = reduce_session;
if let Some = summary
// Chat view: flat, timestamp-ordered event stream
let events = parse_chat_events?;
for evt in events
# Ok::
API
| Function | What it returns |
|---|---|
parse_jsonl_entries(path) |
Vec<UsageEntry> — one per assistant message (tokens, model, tool uses, cache stats, activity class, hooks) |
parse_chat_events(path) |
Vec<ChatEvent> — flat, timestamp-ordered user / assistant / thinking / tool-use / tool-result events |
reduce_session(entries, model, session_id) |
Option<SessionUsage> — totals, cost, context-window %, cache-hit rate |
cost_for_entry(&UsageEntry) |
f64 — canonical per-turn cost (USD); tiered cache writes, fast-mode multiplier, web-search fee |
classify(...) |
Activity — deterministic activity tag (13 categories: Coding, Debugging, Refactor, Testing, Exploration, …). No LLM, no regex. |
encode_project_path(path) |
String — replicates Claude Code's ~/.claude/projects/ directory-name encoding |
find_current_session_jsonl(project_root) |
Active session JSONL for a project from Claude Code's sessions/ registry |
Trust surface
"If a tool uses this crate to read my
~/.claude/JSONL files, what does it touch on my machine?"
The full surface is the functions above plus the data types they return.
Every file read is bounded (MAX_JSONL_BYTES = 200 MB), streaming
(BufReader::lines(), O(1) memory per line), and read-only. No network.
No subprocess. No filesystem writes. ~2600 lines of source.
JSONL format notes (load-bearing for correctness)
- Split content blocks. Claude Code 2.1+ writes one JSONL row per content
block of an assistant message (thinking, then text, then each
tool_use), all sharing the samemessage.id. The usage parser dedupes billing fields by id (they repeat identically) but unions content fields across rows; the chat parser keys dedup on(message_id + serialized_content). Dedup by id alone silently drops every block after the first. <synthetic>model rows are skipped — internal routing entries, not real LLM turns.- Tiered cache / fast mode / web search. Cache writes split into 5-minute
(1.25× input) and 1-hour (2× input) tiers via
usage.cache_creation.ephemeral_{5m,1h}_input_tokens; fast mode (usage.speed == "fast") multiplies the token line (Opus 4.6/4.7 = 6×, 4.8 = 2×);usage.server_tool_use.web_search_requestsbills $0.01 flat each.cost_for_entryaccounts for all three — pricing only input/output/cache_read undercounts 1h-cache-heavy turns by ~38%. - Hook executions are parsed from both the legacy
attachmentformat and the v2.1.119+system.subtype = "*_hook_summary"format.
Scope
- No file watching, no incremental parsing, no cache layer. Layer that on top with e.g. file-parse-cache.
- No I/O beyond reading the JSONL file you point it at.
- No async runtime, no GUI coupling, no build-system coupling.