Streaming parser for Claude Code session JSONL files: token usage, chat events, and activity classification.
See the codePulls 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.
[dependencies]
claude-session-parser = "0.2"
use std::path::Path;
use claude_session_parser::{parse_jsonl_entries, parse_chat_events, reduce_session};
let path = Path::new("/path/to/session.jsonl");
// Usage / billing view: one UsageEntry per assistant turn
let parsed = parse_jsonl_entries(path)?;
let summary = reduce_session(&parsed.entries, "claude-opus-4-7", "session-id");
if let Some(s) = summary {
println!(
"turns={} cost=${:.4} cache_hit={:.1}%",
s.message_count, s.estimated_cost_usd, s.cache_hit_pct
);
}
// Chat view: flat, timestamp-ordered event stream
let events = parse_chat_events(path)?;
for evt in events {
println!("{:?}", evt);
}
# Ok::<(), std::io::Error>(())
| 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 |
"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.
tool_use),
all sharing the same message.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.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/5/5.5 = 2×); usage.server_tool_use.web_search_requests bills $0.01 flat each.
cost_for_entry accounts for all three — pricing only input/output/cache_read
undercounts 1h-cache-heavy turns by ~38%.attachment format and
the v2.1.119+ system.subtype = "*_hook_summary" format.7 commits
Rust
100.0%
Streaming parser for Claude Code session JSONL files: token usage, chat events, and activity classification.
See the codePulls 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.
[dependencies]
claude-session-parser = "0.2"
use std::path::Path;
use claude_session_parser::{parse_jsonl_entries, parse_chat_events, reduce_session};
let path = Path::new("/path/to/session.jsonl");
// Usage / billing view: one UsageEntry per assistant turn
let parsed = parse_jsonl_entries(path)?;
let summary = reduce_session(&parsed.entries, "claude-opus-4-7", "session-id");
if let Some(s) = summary {
println!(
"turns={} cost=${:.4} cache_hit={:.1}%",
s.message_count, s.estimated_cost_usd, s.cache_hit_pct
);
}
// Chat view: flat, timestamp-ordered event stream
let events = parse_chat_events(path)?;
for evt in events {
println!("{:?}", evt);
}
# Ok::<(), std::io::Error>(())
| 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 |
"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.
tool_use),
all sharing the same message.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.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/5/5.5 = 2×); usage.server_tool_use.web_search_requests bills $0.01 flat each.
cost_for_entry accounts for all three — pricing only input/output/cache_read
undercounts 1h-cache-heavy turns by ~38%.attachment format and
the v2.1.119+ system.subtype = "*_hook_summary" format.7 commits
Rust
100.0%