Skip to main content

skiagram_core/adapters/
claude_code.rs

1//! Claude Code adapter — the MVP's fully implemented one.
2//!
3//! Reads `~/.claude/projects/<url-encoded-cwd>/*.jsonl` (READ-ONLY). Sub-agent
4//! transcripts live in `<project>/<parent-session-uuid>/subagents/agent-<id>.jsonl`
5//! and are attributed back to the parent via [`Session::parent_session`].
6//!
7//! Schema notes, VERIFIED against real local files (Claude Code v2.1.162, 2026-06-13):
8//! - A single API request is written as 2–10 assistant lines (one per content
9//!   block) that all repeat `message.usage` and share one `requestId`. Observed
10//!   real ratios: 83 lines -> 26 requests; 642 -> 262. Accounting MUST dedup
11//!   (CLAUDE.md §8.1) — that happens in `analysis::dedup`, not here; the adapter
12//!   preserves the raw lines as events.
13//! - `message.usage` keys: `input_tokens`, `output_tokens`,
14//!   `cache_creation_input_tokens`, `cache_read_input_tokens`, plus a
15//!   `cache_creation: { ephemeral_5m_input_tokens, ephemeral_1h_input_tokens }`
16//!   breakdown (the TTLs are priced differently — see `pricing`).
17//! - API-error lines have `model: "<synthetic>"` / `isApiErrorMessage: true` with
18//!   zeroed usage that never hit the API; their usage is dropped.
19//! - Sub-agent spawns are `tool_use` blocks named `Task` (legacy) or `Agent`
20//!   (current), with `input.description` / `input.subagent_type`.
21//! - `output_tokens` ALREADY includes extended-thinking tokens (VERIFIED on real
22//!   v2.1.178 data: thinking-only requests report output far larger than the
23//!   visible text alone) — do NOT add an estimate on top, or `known_total`
24//!   double-counts. Thinking blocks are frequently encrypted (`"thinking": ""` +
25//!   `signature`; ~85% of thinking requests on the sampled machine), so the
26//!   thinking SHARE of output is usually unmeasurable; that is surfaced honestly
27//!   (encrypted-thinking count), never guessed.
28//! - Bookkeeping line types carrying no spend (VERIFIED on real files): `summary`,
29//!   `mode`, `permission-mode`, `last-prompt`, `file-history-snapshot`,
30//!   `ai-title`, `custom-title`, `queue-operation`, `agent-name` — ignored without
31//!   counting as "skipped" so the skip stat means *unexpected*.
32//! - `attachment` lines (deferred-tool/skill listings, MCP instruction blocks,
33//!   IDE/file context, reminders) carry no spend but ARE kept as
34//!   `EventKind::Attachment` events — their category/size feed `analysis::context`
35//!   (verified `attachment.type` ∈ {deferred_tools_delta, skill_listing,
36//!   mcp_instructions_delta, task_reminder, file, …}).
37//! - TODO(verify): compaction markers (`subtype: "compact_boundary"` system lines,
38//!   `isCompactSummary` user lines) were not present in sampled files; the mapping
39//!   below is best-effort from documentation.
40
41use std::collections::BTreeMap;
42use std::fs::File;
43use std::io::{BufRead, BufReader};
44use std::path::{Path, PathBuf};
45
46use serde::Deserialize;
47
48use crate::adapters::Adapter;
49use crate::error::CoreError;
50use crate::model::{Event, EventKind, Session, SessionRef, SubAgent, ToolCall, Usage};
51
52/// Claude Code (`~/.claude`).
53pub struct ClaudeCode;
54
55/// Data root: `$CLAUDE_CONFIG_DIR` when set (Claude Code honors the same
56/// variable; tests use it to point at fixtures), else `~/.claude` resolved via
57/// the `directories` crate — never a hardcoded `~`.
58fn claude_dir() -> Option<PathBuf> {
59    match std::env::var("CLAUDE_CONFIG_DIR") {
60        Ok(dir) if !dir.trim().is_empty() => Some(PathBuf::from(dir)),
61        _ => directories::BaseDirs::new().map(|b| b.home_dir().join(".claude")),
62    }
63}
64
65impl Adapter for ClaudeCode {
66    fn id(&self) -> &'static str {
67        "claude-code"
68    }
69
70    fn detect(&self) -> bool {
71        claude_dir().is_some_and(|d| d.join("projects").is_dir())
72    }
73
74    fn discover(&self) -> anyhow::Result<Vec<SessionRef>> {
75        let root = claude_dir()
76            .ok_or_else(|| anyhow::anyhow!("could not determine the home directory"))?
77            .join("projects");
78        let mut refs = Vec::new();
79        for entry in walkdir::WalkDir::new(&root).follow_links(false) {
80            let entry = match entry {
81                Ok(e) => e,
82                Err(e) => {
83                    tracing::debug!("skipping unreadable directory entry: {e}");
84                    continue;
85                }
86            };
87            if !entry.file_type().is_file()
88                || entry.path().extension().and_then(|x| x.to_str()) != Some("jsonl")
89            {
90                continue;
91            }
92            let meta = entry.metadata().ok();
93            refs.push(SessionRef {
94                path: entry.path().to_path_buf(),
95                agent: self.id().to_string(),
96                project: project_label(entry.path(), &root),
97                size_bytes: meta.as_ref().map_or(0, |m| m.len()),
98                modified: meta
99                    .and_then(|m| m.modified().ok())
100                    .and_then(|t| jiff::Timestamp::try_from(t).ok()),
101            });
102        }
103        refs.sort_by_key(|r| std::cmp::Reverse(r.modified));
104        Ok(refs)
105    }
106
107    fn parse(&self, r: &SessionRef) -> anyhow::Result<Session> {
108        let file = File::open(&r.path).map_err(|source| CoreError::Io {
109            path: r.path.clone(),
110            source,
111        })?;
112
113        let mut session = Session {
114            id: session_id_for(&r.path),
115            agent: self.id().to_string(),
116            project: r.project.clone(),
117            model: None,
118            parent_session: parent_session_for(&r.path),
119            started_at: None,
120            ended_at: None,
121            events: Vec::new(),
122            sub_agents: Vec::new(),
123            skipped_lines: 0,
124        };
125        // BTreeMap so model tie-breaking is deterministic.
126        let mut model_counts: BTreeMap<String, u64> = BTreeMap::new();
127
128        for (idx, line) in BufReader::new(file).lines().enumerate() {
129            let line = match line {
130                Ok(l) => l,
131                Err(e) => {
132                    session.skipped_lines += 1;
133                    tracing::debug!("{}:{}: unreadable line: {e}", r.path.display(), idx + 1);
134                    continue;
135                }
136            };
137            if line.trim().is_empty() {
138                continue;
139            }
140            let raw: RawLine = match serde_json::from_str(&line) {
141                Ok(v) => v,
142                Err(e) => {
143                    session.skipped_lines += 1;
144                    tracing::debug!("{}:{}: unparseable JSON: {e}", r.path.display(), idx + 1);
145                    continue;
146                }
147            };
148            match raw.kind.as_deref() {
149                Some("assistant") => push_assistant(raw, &mut session, &mut model_counts),
150                Some("user") => push_user(raw, &mut session),
151                Some("system") => push_system(raw, &mut session),
152                // Non-message context injected into the window — kept as Attachment
153                // events for context-bloat attribution (deferred-tool/skill listings,
154                // MCP instruction blocks, IDE/file context, reminders).
155                Some("attachment") => push_attachment(raw, &mut session),
156                // Bookkeeping lines that carry no token spend. VERIFIED present in
157                // real v2.1.162 files: ai-title / custom-title (session titles),
158                // queue-operation (message-queue ops), agent-name (sub-agent label),
159                // file-history-snapshot, mode, permission-mode, last-prompt. Kept
160                // `queued-message`/`progress` from older builds defensively.
161                Some(
162                    "summary"
163                    | "mode"
164                    | "permission-mode"
165                    | "last-prompt"
166                    | "file-history-snapshot"
167                    | "ai-title"
168                    | "custom-title"
169                    | "queue-operation"
170                    | "agent-name"
171                    | "queued-message"
172                    | "progress",
173                ) => {}
174                other => {
175                    session.skipped_lines += 1;
176                    tracing::debug!(
177                        "{}:{}: unknown line type {:?}",
178                        r.path.display(),
179                        idx + 1,
180                        other
181                    );
182                }
183            }
184        }
185
186        session.started_at = session.events.iter().filter_map(|e| e.ts).min();
187        session.ended_at = session.events.iter().filter_map(|e| e.ts).max();
188        session.model = model_counts
189            .into_iter()
190            .max_by_key(|(_, count)| *count)
191            .map(|(model, _)| model);
192        Ok(session)
193    }
194}
195
196/// `<root>/<project-dir>/...` -> the project dir name.
197fn project_label(path: &Path, root: &Path) -> Option<String> {
198    path.strip_prefix(root)
199        .ok()?
200        .components()
201        .next()
202        .map(|c| c.as_os_str().to_string_lossy().into_owned())
203}
204
205/// Session id = file stem (`<uuid>.jsonl`, or `agent-<id>.jsonl` for sub-agents).
206fn session_id_for(path: &Path) -> String {
207    path.file_stem()
208        .map(|s| s.to_string_lossy().into_owned())
209        .unwrap_or_else(|| path.to_string_lossy().into_owned())
210}
211
212/// `.../<parent-session-uuid>/subagents/<file>.jsonl` -> parent session uuid.
213fn parent_session_for(path: &Path) -> Option<String> {
214    let comps: Vec<String> = path
215        .components()
216        .map(|c| c.as_os_str().to_string_lossy().into_owned())
217        .collect();
218    let pos = comps.iter().rposition(|c| c == "subagents")?;
219    (pos > 0).then(|| comps[pos - 1].clone())
220}
221
222fn parse_ts(raw: &Option<String>) -> Option<jiff::Timestamp> {
223    raw.as_deref().and_then(|t| t.parse().ok())
224}
225
226/// Display-only first non-empty line, capped at 80 chars.
227fn snippet(text: &str) -> Option<String> {
228    let first = text.lines().find(|l| !l.trim().is_empty())?;
229    let s: String = first.trim().chars().take(80).collect();
230    (!s.is_empty()).then_some(s)
231}
232
233fn push_assistant(raw: RawLine, session: &mut Session, model_counts: &mut BTreeMap<String, u64>) {
234    let Some(msg) = raw.message else {
235        session.skipped_lines += 1;
236        return;
237    };
238    let ts = parse_ts(&raw.timestamp);
239    let model = msg.model.clone();
240    let synthetic = raw.is_api_error || model.as_deref() == Some("<synthetic>");
241    if let Some(m) = &model {
242        if !synthetic {
243            *model_counts.entry(m.clone()).or_default() += 1;
244        }
245    }
246
247    let mut tool_calls = Vec::new();
248    let mut spawns = Vec::new();
249    let mut content_chars = 0u64;
250    let mut thinking_chars = 0u64;
251    let mut has_thinking = false;
252    let mut summary = None;
253
254    if let Some(blocks) = msg.content.as_array() {
255        for block in blocks {
256            match block.get("type").and_then(|t| t.as_str()) {
257                Some("text") => {
258                    let text = block.get("text").and_then(|t| t.as_str()).unwrap_or("");
259                    content_chars += text.chars().count() as u64;
260                    if summary.is_none() {
261                        summary = snippet(text);
262                    }
263                }
264                Some("thinking") => {
265                    has_thinking = true;
266                    let text = block.get("thinking").and_then(|t| t.as_str()).unwrap_or("");
267                    let n = text.chars().count() as u64;
268                    content_chars += n;
269                    thinking_chars += n;
270                }
271                Some("redacted_thinking") => has_thinking = true,
272                Some("tool_use") => {
273                    let id = block
274                        .get("id")
275                        .and_then(|v| v.as_str())
276                        .unwrap_or_default()
277                        .to_string();
278                    let name = block
279                        .get("name")
280                        .and_then(|v| v.as_str())
281                        .unwrap_or("unknown")
282                        .to_string();
283                    let input_bytes = block.get("input").map_or(0, |i| i.to_string().len() as u64);
284                    content_chars += input_bytes;
285                    // `Task` (legacy) / `Agent` (current) tool = sub-agent spawn.
286                    if name == "Task" || name == "Agent" {
287                        spawns.push(SubAgent {
288                            tool_call_id: id.clone(),
289                            agent_type: block
290                                .pointer("/input/subagent_type")
291                                .and_then(|v| v.as_str())
292                                .map(str::to_string),
293                            description: block
294                                .pointer("/input/description")
295                                .and_then(|v| v.as_str())
296                                .map(str::to_string),
297                            ts,
298                        });
299                    }
300                    tool_calls.push(ToolCall {
301                        server: ToolCall::server_from_name(&name),
302                        id,
303                        name,
304                        input_bytes,
305                    });
306                }
307                _ => {}
308            }
309        }
310    } else if let Some(text) = msg.content.as_str() {
311        content_chars += text.chars().count() as u64;
312        summary = snippet(text);
313    }
314
315    // API-error lines report zeroed usage for requests that never completed —
316    // dropping it keeps "absence ≠ zero" honest (the spend is unknown, not 0).
317    let usage = if synthetic {
318        None
319    } else {
320        msg.usage.map(RawUsage::into_usage)
321    };
322
323    session.events.push(Event {
324        kind: EventKind::Assistant,
325        ts,
326        request_id: raw.request_id,
327        model,
328        usage,
329        tool_calls,
330        sidechain: raw.is_sidechain,
331        content_summary: summary,
332        content_chars,
333        thinking_chars,
334        has_thinking,
335        tool_use_id: None,
336        attachment_kind: None,
337        item_count: 0,
338    });
339    for spawn in spawns {
340        session.events.push(Event {
341            kind: EventKind::SubAgentSpawn,
342            ts,
343            request_id: None,
344            model: None,
345            usage: None,
346            tool_calls: Vec::new(),
347            sidechain: raw.is_sidechain,
348            content_summary: spawn.description.clone(),
349            content_chars: 0,
350            thinking_chars: 0,
351            has_thinking: false,
352            tool_use_id: None,
353            attachment_kind: None,
354            item_count: 0,
355        });
356        session.sub_agents.push(spawn);
357    }
358}
359
360fn push_user(raw: RawLine, session: &mut Session) {
361    let ts = parse_ts(&raw.timestamp);
362    let mut kind = if raw.is_compact_summary {
363        EventKind::Compaction // TODO(verify): see module docs
364    } else {
365        EventKind::User
366    };
367    let mut summary = None;
368    let mut content_chars = 0u64;
369    // For tool-result lines: the id of the tool_use this answers, so the result's
370    // weight can be attributed to the originating tool/MCP server (context attr.).
371    let mut tool_use_id = None;
372
373    match raw.message.as_ref().map(|m| &m.content) {
374        Some(serde_json::Value::String(text)) => {
375            content_chars = text.chars().count() as u64;
376            summary = snippet(text);
377        }
378        Some(value @ serde_json::Value::Array(blocks)) => {
379            let tool_result = blocks
380                .iter()
381                .find(|b| b.get("type").and_then(|t| t.as_str()) == Some("tool_result"));
382            if let Some(tr) = tool_result {
383                kind = EventKind::ToolResult;
384                tool_use_id = tr
385                    .get("tool_use_id")
386                    .and_then(|v| v.as_str())
387                    .map(str::to_string);
388                // Serialized result size = what this result weighs in the context
389                // window (incl. base64 images) — input for context-bloat analysis.
390                content_chars = value.to_string().len() as u64;
391            } else {
392                for block in blocks {
393                    if let Some(text) = block.get("text").and_then(|t| t.as_str()) {
394                        content_chars += text.chars().count() as u64;
395                        if summary.is_none() {
396                            summary = snippet(text);
397                        }
398                    }
399                }
400            }
401        }
402        _ => {}
403    }
404
405    session.events.push(Event {
406        kind,
407        ts,
408        request_id: None,
409        model: None,
410        usage: None,
411        tool_calls: Vec::new(),
412        sidechain: raw.is_sidechain,
413        content_summary: summary,
414        content_chars,
415        thinking_chars: 0,
416        has_thinking: false,
417        tool_use_id,
418        attachment_kind: None,
419        item_count: 0,
420    });
421}
422
423fn push_system(raw: RawLine, session: &mut Session) {
424    let kind = if raw.subtype.as_deref() == Some("compact_boundary") {
425        EventKind::Compaction // TODO(verify): see module docs
426    } else {
427        EventKind::System
428    };
429    session.events.push(Event {
430        kind,
431        ts: parse_ts(&raw.timestamp),
432        request_id: None,
433        model: None,
434        usage: None,
435        tool_calls: Vec::new(),
436        sidechain: raw.is_sidechain,
437        content_summary: raw.content.as_deref().and_then(snippet),
438        content_chars: raw
439            .content
440            .as_deref()
441            .map_or(0, |c| c.chars().count() as u64),
442        thinking_chars: 0,
443        has_thinking: false,
444        tool_use_id: None,
445        attachment_kind: None,
446        item_count: 0,
447    });
448}
449
450/// `attachment` lines inject non-message content into the window (deferred-tool
451/// listings, skill listings, MCP-server instruction blocks, IDE/file context,
452/// reminders). They carry no token usage, but their *category*, *item count*, and
453/// *byte weight* feed context-bloat attribution (`analysis::context`).
454///
455/// VERIFIED shapes (v2.1.x): `attachment.type` ∈ {deferred_tools_delta,
456/// skill_listing, mcp_instructions_delta, task_reminder, file, opened_file_in_ide,
457/// selected_lines_in_ide, queued_command, date_change, compact_file_reference, …}.
458/// `addedNames`/`names` list the items; weight lives in `addedLines` (tool names),
459/// `content` (skill listing) or `addedBlocks` (MCP instructions).
460fn push_attachment(raw: RawLine, session: &mut Session) {
461    let Some(att) = raw.attachment else {
462        // Shape we don't recognize — count it so format drift stays visible.
463        session.skipped_lines += 1;
464        return;
465    };
466    // Named-item count: prefer the explicit list, fall back to skill count.
467    let item_count = att
468        .added_names
469        .as_ref()
470        .map(|v| v.len() as u64)
471        .or_else(|| att.names.as_ref().map(|v| v.len() as u64))
472        .or(att.skill_count)
473        .unwrap_or(0);
474    // Byte weight added to the window: whichever payload field is present.
475    let content_chars = att
476        .content
477        .as_deref()
478        .map_or(0, |c| c.chars().count() as u64)
479        + att
480            .added_lines
481            .as_ref()
482            .map_or(0, |v| v.iter().map(|s| s.chars().count() as u64).sum())
483        + att
484            .added_blocks
485            .as_ref()
486            .map_or(0, |b| b.to_string().chars().count() as u64);
487
488    session.events.push(Event {
489        kind: EventKind::Attachment,
490        ts: parse_ts(&raw.timestamp),
491        request_id: None,
492        model: None,
493        usage: None,
494        tool_calls: Vec::new(),
495        sidechain: raw.is_sidechain,
496        content_summary: att.kind.clone(),
497        content_chars,
498        thinking_chars: 0,
499        has_thinking: false,
500        tool_use_id: None,
501        attachment_kind: att.kind,
502        item_count,
503    });
504}
505
506// ---- raw line shapes (lenient: unknown fields ignored everywhere) ----
507
508#[derive(Deserialize)]
509struct RawLine {
510    #[serde(rename = "type")]
511    kind: Option<String>,
512    timestamp: Option<String>,
513    #[serde(rename = "requestId")]
514    request_id: Option<String>,
515    #[serde(rename = "isSidechain", default)]
516    is_sidechain: bool,
517    #[serde(rename = "isCompactSummary", default)]
518    is_compact_summary: bool,
519    #[serde(rename = "isApiErrorMessage", default)]
520    is_api_error: bool,
521    subtype: Option<String>,
522    /// Top-level content on `system` lines.
523    content: Option<String>,
524    message: Option<RawMessage>,
525    /// Payload on `attachment` lines.
526    attachment: Option<RawAttachment>,
527}
528
529/// `attachment` line payload (lenient: fields vary by `type`).
530#[derive(Deserialize)]
531struct RawAttachment {
532    #[serde(rename = "type")]
533    kind: Option<String>,
534    /// Names of items added (deferred tools, MCP servers); also `names` on some.
535    #[serde(rename = "addedNames")]
536    added_names: Option<Vec<serde_json::Value>>,
537    names: Option<Vec<serde_json::Value>>,
538    /// Per-item text (e.g. tool names) added to the window.
539    #[serde(rename = "addedLines")]
540    added_lines: Option<Vec<String>>,
541    /// Skill-listing body text.
542    content: Option<String>,
543    /// Skill-listing item count.
544    #[serde(rename = "skillCount")]
545    skill_count: Option<u64>,
546    /// MCP-server instruction blocks added to the window.
547    #[serde(rename = "addedBlocks")]
548    added_blocks: Option<serde_json::Value>,
549}
550
551#[derive(Deserialize)]
552struct RawMessage {
553    model: Option<String>,
554    #[serde(default)]
555    content: serde_json::Value,
556    usage: Option<RawUsage>,
557}
558
559#[derive(Deserialize)]
560struct RawUsage {
561    input_tokens: Option<u64>,
562    output_tokens: Option<u64>,
563    cache_creation_input_tokens: Option<u64>,
564    cache_read_input_tokens: Option<u64>,
565    cache_creation: Option<RawCacheCreation>,
566}
567
568#[derive(Deserialize)]
569struct RawCacheCreation {
570    ephemeral_5m_input_tokens: Option<u64>,
571    ephemeral_1h_input_tokens: Option<u64>,
572}
573
574impl RawUsage {
575    fn into_usage(self) -> Usage {
576        Usage {
577            input: self.input_tokens,
578            output: self.output_tokens,
579            cache_creation: self.cache_creation_input_tokens,
580            cache_creation_5m: self
581                .cache_creation
582                .as_ref()
583                .and_then(|c| c.ephemeral_5m_input_tokens),
584            cache_creation_1h: self
585                .cache_creation
586                .as_ref()
587                .and_then(|c| c.ephemeral_1h_input_tokens),
588            cache_read: self.cache_read_input_tokens,
589            // Claude Code's `output_tokens` ALREADY includes extended-thinking
590            // tokens (verified v2.1.178) — keep this None so `known_total` does not
591            // double-count; thinking is surfaced as a char-measured attribution of
592            // output in `analysis::dedup` instead (CLAUDE.md §8.2).
593            thinking: None,
594        }
595    }
596}
597
598#[cfg(test)]
599mod tests {
600    use super::*;
601
602    #[test]
603    fn parent_session_is_derived_from_subagents_path() {
604        let p = Path::new("projects/F--demo/1111-2222/subagents/agent-abc.jsonl");
605        assert_eq!(parent_session_for(p).as_deref(), Some("1111-2222"));
606        let normal = Path::new("projects/F--demo/1111-2222.jsonl");
607        assert_eq!(parent_session_for(normal), None);
608    }
609
610    #[test]
611    fn legacy_task_tool_counts_as_spawn() {
612        // Same branch handles "Task" and "Agent"; cover the legacy name here since
613        // fixtures use the current one.
614        let line = r#"{"type":"assistant","timestamp":"2026-06-01T10:00:00Z","requestId":"req_t","message":{"role":"assistant","model":"claude-sonnet-4-5","content":[{"type":"tool_use","id":"t1","name":"Task","input":{"description":"d","prompt":"p","subagent_type":"general-purpose"}}],"usage":{"input_tokens":10,"output_tokens":5}}}"#;
615        let raw: RawLine = serde_json::from_str(line).unwrap();
616        let mut session = Session {
617            id: "s".into(),
618            agent: "claude-code".into(),
619            project: None,
620            model: None,
621            parent_session: None,
622            started_at: None,
623            ended_at: None,
624            events: Vec::new(),
625            sub_agents: Vec::new(),
626            skipped_lines: 0,
627        };
628        push_assistant(raw, &mut session, &mut BTreeMap::new());
629        assert_eq!(session.sub_agents.len(), 1);
630        assert_eq!(
631            session.sub_agents[0].agent_type.as_deref(),
632            Some("general-purpose")
633        );
634        assert_eq!(session.events.len(), 2, "assistant event + spawn event");
635        assert_eq!(session.events[1].kind, EventKind::SubAgentSpawn);
636    }
637}