Skip to main content

warden/adapters/
claude_code.rs

1//! The Claude Code adapter: `~/.claude/projects/**/*.jsonl`.
2//!
3//! The transcript schema is undocumented and drifts between releases, so this
4//! parser is deliberately tolerant: it reads through `serde_json::Value`,
5//! ignores unknown keys, skips the record types that carry no usage, and counts
6//! rather than fails on a line it cannot read.
7//!
8//! `~/.claude/projects` is also where Claude Code keeps its own memory. Nothing
9//! here opens a file for anything but reading.
10
11use std::collections::BTreeSet;
12use std::io;
13use std::path::{Path, PathBuf};
14
15use chrono::DateTime;
16use serde_json::Value;
17
18use super::{usage_key, Adapter, Capabilities, Kpi, Parsed, ParsedRecord};
19use crate::config::Config;
20use crate::store::{event_id, Event, EventIdentity, ToolCall};
21
22/// Adapter name, and the `agent` field on every event it produces.
23pub const AGENT: &str = "claude-code";
24/// Every model in these transcripts is an Anthropic one.
25pub const PROVIDER: &str = "anthropic";
26/// `~/.claude/projects`, unless config overrides it.
27pub const DEFAULT_ROOT: &str = "~/.claude/projects";
28
29/// `duration_ms` is deliberately absent: Claude Code does not log per-turn wall
30/// clock time, so warden reports the column as unsupported rather than `0`.
31const CAPABILITIES: Capabilities = Capabilities::new(&[
32    Kpi::Tokens,
33    Kpi::CacheTokens,
34    Kpi::Cost,
35    Kpi::Prompts,
36    Kpi::ToolCalls,
37    Kpi::StopReason,
38    Kpi::Sidechain,
39]);
40
41/// Record types that carry usage or a prompt. Every other `type` in these files
42/// (`attachment`, `mode`, `file-history-snapshot`, ...) is a UI or bookkeeping
43/// record and is skipped, not counted as unparseable.
44const INGESTED_TYPES: [&str; 2] = ["assistant", "user"];
45
46/// Keys on a `tool_use` input that name what the tool acted on. Free-form
47/// arguments (a shell command, a patch body) are deliberately not treated as
48/// targets — attribution only makes sense for a path-like value.
49const TARGET_KEYS: [&str; 6] = [
50    "file_path",
51    "notebook_path",
52    "path",
53    "pattern",
54    "url",
55    "file",
56];
57
58pub struct ClaudeCodeAdapter;
59
60impl Adapter for ClaudeCodeAdapter {
61    fn name(&self) -> &'static str {
62        AGENT
63    }
64
65    fn is_implemented(&self) -> bool {
66        true
67    }
68
69    fn capabilities(&self) -> Capabilities {
70        CAPABILITIES
71    }
72
73    fn root(&self, config: &Config) -> Option<PathBuf> {
74        Some(
75            config
76                .source(AGENT)
77                .path
78                .unwrap_or_else(|| PathBuf::from(DEFAULT_ROOT)),
79        )
80    }
81
82    fn discover(&self, root: &Path) -> io::Result<Vec<PathBuf>> {
83        super::jsonl_files(root)
84    }
85
86    /// One transcript file per session; the file stem is the session id, so
87    /// this needs no reads.
88    fn session_count(&self, root: &Path) -> io::Result<usize> {
89        let sessions: BTreeSet<String> = self
90            .discover(root)?
91            .iter()
92            .filter_map(|path| path.file_stem()?.to_str().map(str::to_string))
93            .collect();
94        Ok(sessions.len())
95    }
96
97    fn parse_line(&self, _source: &Path, line: &str) -> Parsed {
98        parse_line(line)
99    }
100}
101
102fn parse_line(line: &str) -> Parsed {
103    if line.trim().is_empty() {
104        return Parsed::Skipped;
105    }
106    let Ok(value) = serde_json::from_str::<Value>(line) else {
107        return Parsed::Unparseable;
108    };
109    let Some(kind) = value.get("type").and_then(Value::as_str) else {
110        return Parsed::Unparseable;
111    };
112    if !INGESTED_TYPES.contains(&kind) {
113        return Parsed::Skipped;
114    }
115
116    // A record of a type we do ingest but cannot place in time is unusable;
117    // that is schema drift, and worth surfacing as such.
118    let Some(ts) = value
119        .get("timestamp")
120        .and_then(Value::as_str)
121        .and_then(ts_ms)
122    else {
123        return Parsed::Unparseable;
124    };
125
126    let session_id = string(&value, "sessionId");
127    let source_id = value.get("uuid").and_then(Value::as_str);
128    let turn_id = string(&value, "requestId");
129
130    let id = event_id(EventIdentity {
131        agent: AGENT,
132        session_id: session_id.as_deref(),
133        source_id,
134        ts,
135        role: kind,
136        extra: None,
137    });
138
139    let mut event = Event::new(id, ts, AGENT, PROVIDER, kind);
140    event.session_id = session_id.clone();
141    event.turn_id = turn_id.clone();
142    event.project = value
143        .get("cwd")
144        .and_then(Value::as_str)
145        .and_then(project_from_cwd);
146    event.is_sidechain = value.get("isSidechain").and_then(Value::as_bool);
147
148    let message = value.get("message");
149    event.model = message.and_then(|m| string(m, "model"));
150    event.stop_reason = message.and_then(|m| string(m, "stop_reason"));
151    event.tool_calls = tool_calls(message);
152    // `duration_ms` stays None: the source does not record it.
153
154    let mut usage_dedup_key = None;
155    if kind == "assistant" {
156        if let Some(usage) = message.and_then(|m| m.get("usage")) {
157            event.input_tok = count(usage, "input_tokens");
158            event.output_tok = count(usage, "output_tokens");
159            event.cache_read_tok = count(usage, "cache_read_input_tokens");
160            event.cache_write_tok = count(usage, "cache_creation_input_tokens");
161            // Claude Code repeats one request's usage verbatim on every
162            // assistant record it produced (a thinking block and a tool_use
163            // block share a `requestId` and a usage object). Counting each
164            // would roughly double every token figure, so the counts are
165            // attributed to the request, once.
166            usage_dedup_key = turn_id
167                .as_deref()
168                .map(|turn| usage_key(AGENT, session_id.as_deref(), turn));
169        }
170    }
171
172    let prompt_text = if kind == "user" {
173        message.and_then(|m| m.get("content")).and_then(prompt_text)
174    } else {
175        None
176    };
177
178    Parsed::Record(Box::new(ParsedRecord {
179        event,
180        prompt_text,
181        usage_key: usage_dedup_key,
182    }))
183}
184
185/// ISO-8601 to epoch milliseconds.
186fn ts_ms(text: &str) -> Option<i64> {
187    DateTime::parse_from_rfc3339(text)
188        .ok()
189        .map(|dt| dt.timestamp_millis())
190}
191
192/// Project is the basename of `cwd`. A worktree suffix is part of that basename
193/// and is preserved as-is, so two worktrees of one repo stay distinguishable.
194fn project_from_cwd(cwd: &str) -> Option<String> {
195    Path::new(cwd)
196        .file_name()
197        .and_then(|name| name.to_str())
198        .filter(|name| !name.is_empty())
199        .map(str::to_string)
200}
201
202fn string(value: &Value, key: &str) -> Option<String> {
203    value.get(key).and_then(Value::as_str).map(str::to_string)
204}
205
206fn count(usage: &Value, key: &str) -> Option<u64> {
207    usage.get(key).and_then(Value::as_u64)
208}
209
210fn tool_calls(message: Option<&Value>) -> Vec<ToolCall> {
211    let Some(blocks) = message
212        .and_then(|m| m.get("content"))
213        .and_then(Value::as_array)
214    else {
215        return Vec::new();
216    };
217    blocks
218        .iter()
219        .filter(|block| block.get("type").and_then(Value::as_str) == Some("tool_use"))
220        .filter_map(|block| {
221            let name = block.get("name").and_then(Value::as_str)?;
222            Some(ToolCall::new(name, tool_target(block.get("input"))))
223        })
224        .collect()
225}
226
227fn tool_target(input: Option<&Value>) -> Option<String> {
228    let input = input?;
229    TARGET_KEYS
230        .iter()
231        .find_map(|key| input.get(key).and_then(Value::as_str))
232        .filter(|target| !target.is_empty())
233        .map(str::to_string)
234}
235
236/// The human-authored part of a user record. `content` is either a bare string
237/// or a block list; tool results are not prompts and yield nothing.
238fn prompt_text(content: &Value) -> Option<String> {
239    let text = match content {
240        Value::String(text) => text.clone(),
241        Value::Array(blocks) => blocks
242            .iter()
243            .filter(|block| block.get("type").and_then(Value::as_str) == Some("text"))
244            .filter_map(|block| block.get("text").and_then(Value::as_str))
245            .collect::<Vec<_>>()
246            .join("\n"),
247        _ => return None,
248    };
249    (!text.trim().is_empty()).then_some(text)
250}
251
252#[cfg(test)]
253mod tests {
254    use super::*;
255
256    fn record(line: &str) -> ParsedRecord {
257        match parse_line(line) {
258            Parsed::Record(record) => *record,
259            other => panic!("expected a record, got {other:?}"),
260        }
261    }
262
263    const ASSISTANT: &str = r#"{"type":"assistant","uuid":"u1","timestamp":"2026-08-01T10:00:00.000Z",
264      "sessionId":"s1","requestId":"req_1","cwd":"/home/me/code/acme-api","isSidechain":false,
265      "message":{"model":"claude-sonnet-4-6","stop_reason":"tool_use",
266        "usage":{"input_tokens":2,"output_tokens":295,"cache_read_input_tokens":18715,
267                 "cache_creation_input_tokens":18472,"service_tier":"standard"},
268        "content":[{"type":"tool_use","name":"Read","input":{"file_path":"src/lib.rs","limit":10}}]}}"#;
269
270    #[test]
271    fn maps_an_assistant_record_field_for_field() {
272        let parsed = record(ASSISTANT);
273        let event = &parsed.event;
274        assert_eq!(event.agent, "claude-code");
275        assert_eq!(event.provider, "anthropic");
276        assert_eq!(event.role, "assistant");
277        assert_eq!(event.ts, 1_785_578_400_000);
278        assert_eq!(event.model.as_deref(), Some("claude-sonnet-4-6"));
279        assert_eq!(event.session_id.as_deref(), Some("s1"));
280        assert_eq!(event.turn_id.as_deref(), Some("req_1"));
281        assert_eq!(event.project.as_deref(), Some("acme-api"));
282        assert_eq!(event.stop_reason.as_deref(), Some("tool_use"));
283        assert_eq!(event.input_tok, Some(2));
284        assert_eq!(event.output_tok, Some(295));
285        assert_eq!(event.cache_read_tok, Some(18715));
286        assert_eq!(event.cache_write_tok, Some(18472));
287        assert_eq!(event.is_sidechain, Some(false));
288        assert_eq!(
289            event.tool_calls,
290            vec![ToolCall::new("Read", Some("src/lib.rs".into()))]
291        );
292        assert_eq!(
293            parsed.usage_key.as_deref(),
294            Some("claude-code\u{1}s1\u{1}req_1")
295        );
296        assert_eq!(parsed.prompt_text, None);
297    }
298
299    #[test]
300    fn duration_is_never_invented() {
301        assert_eq!(record(ASSISTANT).event.duration_ms, None);
302        assert!(!CAPABILITIES.supports(Kpi::DurationMs));
303        assert!(CAPABILITIES.unsupported().contains(&Kpi::DurationMs));
304    }
305
306    #[test]
307    fn sibling_records_of_one_request_share_a_usage_key() {
308        let sibling = ASSISTANT.replace("\"uuid\":\"u1\"", "\"uuid\":\"u2\"");
309        assert_ne!(record(&sibling).event.id, record(ASSISTANT).event.id);
310        assert_eq!(record(&sibling).usage_key, record(ASSISTANT).usage_key);
311    }
312
313    #[test]
314    fn user_records_yield_prompt_text_in_both_content_shapes() {
315        let bare = r#"{"type":"user","uuid":"u2","timestamp":"2026-08-01T10:00:01Z","sessionId":"s1",
316          "cwd":"/home/me/acme-api","message":{"role":"user","content":"run the tests"}}"#;
317        let parsed = record(bare);
318        assert_eq!(parsed.prompt_text.as_deref(), Some("run the tests"));
319        assert_eq!(parsed.event.role, "user");
320        assert_eq!(parsed.event.input_tok, None, "user records carry no usage");
321        assert_eq!(parsed.usage_key, None);
322
323        let blocks = r#"{"type":"user","uuid":"u3","timestamp":"2026-08-01T10:00:02Z",
324          "message":{"content":[{"type":"text","text":"fix it"}]}}"#;
325        assert_eq!(record(blocks).prompt_text.as_deref(), Some("fix it"));
326    }
327
328    #[test]
329    fn tool_results_are_not_prompts() {
330        let line = r#"{"type":"user","uuid":"u4","timestamp":"2026-08-01T10:00:03Z",
331          "message":{"content":[{"type":"tool_result","content":"ok"}]}}"#;
332        assert_eq!(record(line).prompt_text, None);
333    }
334
335    #[test]
336    fn sidechain_records_are_ingested_and_marked() {
337        let line = ASSISTANT.replace("\"isSidechain\":false", "\"isSidechain\":true");
338        assert_eq!(record(&line).event.is_sidechain, Some(true));
339    }
340
341    #[test]
342    fn worktree_suffix_is_preserved_as_is() {
343        let line = ASSISTANT.replace("/home/me/code/acme-api", "/home/me/code/acme-api--wt-x");
344        assert_eq!(
345            record(&line).event.project.as_deref(),
346            Some("acme-api--wt-x")
347        );
348    }
349
350    #[test]
351    fn unknown_record_types_are_skipped_not_counted_as_unparseable() {
352        for kind in ["attachment", "mode", "file-history-snapshot", "system"] {
353            let line = format!(r#"{{"type":"{kind}","uuid":"x"}}"#);
354            assert_eq!(parse_line(&line), Parsed::Skipped, "{kind}");
355        }
356        assert_eq!(parse_line("   "), Parsed::Skipped);
357    }
358
359    #[test]
360    fn malformed_lines_are_counted_not_fatal() {
361        assert_eq!(parse_line("{not json"), Parsed::Unparseable);
362        assert_eq!(parse_line("{\"no\":\"type\"}"), Parsed::Unparseable);
363        // A known type we cannot place in time is drift worth reporting.
364        assert_eq!(parse_line(r#"{"type":"assistant"}"#), Parsed::Unparseable);
365    }
366
367    #[test]
368    fn tool_target_is_absent_when_the_input_is_not_path_like() {
369        let line = ASSISTANT.replace(
370            r#"{"file_path":"src/lib.rs","limit":10}"#,
371            r#"{"command":"cargo test"}"#,
372        );
373        assert_eq!(record(&line).event.tool_calls[0].tool_target, None);
374    }
375
376    #[test]
377    fn missing_optional_fields_stay_absent() {
378        let line = r#"{"type":"assistant","uuid":"u9","timestamp":"2026-08-01T10:00:00Z"}"#;
379        let event = record(line).event;
380        assert_eq!(event.model, None);
381        assert_eq!(event.project, None);
382        assert_eq!(event.session_id, None);
383        assert_eq!(event.input_tok, None);
384        assert!(event.tool_calls.is_empty());
385    }
386}