Skip to main content

borg_trace/
claude.rs

1//! Claude Code transcripts: `~/.claude/projects/<slug>/<session>.jsonl`
2//! (sub-agents under `<session>/subagents/`).
3
4use std::collections::{HashMap, HashSet};
5use std::io::{BufRead, BufReader};
6use std::path::Path;
7
8use serde_json::Value;
9
10use crate::{Event, Harness, Kind, Session, approx_tokens, parse_ts};
11
12struct Pending {
13    ts: i64,
14    kind: Kind,
15}
16
17fn abs(cwd: &str, p: &str) -> String {
18    if p.starts_with('/') || cwd.is_empty() { p.to_string() } else { format!("{}/{}", cwd.trim_end_matches('/'), p) }
19}
20
21fn classify(name: &str, input: &Value, cwd: &str) -> Kind {
22    let s = |k: &str| input.get(k).and_then(Value::as_str);
23    match name {
24        "Read" => s("file_path").map_or(Kind::Other, |p| Kind::Read { path: abs(cwd, p) }),
25        "Edit" | "Write" | "MultiEdit" | "NotebookEdit" => {
26            s("file_path").or_else(|| s("notebook_path")).map_or(Kind::Other, |p| Kind::Edit { path: abs(cwd, p) })
27        }
28        "Bash" => Kind::Bash { cmd: s("command").unwrap_or_default().to_string(), failed: false, error: None, error_line: None },
29        "Grep" | "Glob" | "LS" => Kind::Search,
30        n if n.starts_with("mcp__serena__") || n.starts_with("mcp__codebase-memory-mcp__") => Kind::Search,
31        _ => Kind::Other,
32    }
33}
34
35pub fn parse(path: &Path) -> std::io::Result<Session> {
36    let file = std::fs::File::open(path)?;
37    let id = path.file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default();
38    let mut sess = Session::new(id, Harness::Claude);
39    sess.sidechain = path.components().any(|c| c.as_os_str() == "subagents");
40    let mut pending: HashMap<String, Pending> = HashMap::new();
41    let mut usage_seen: HashSet<String> = HashSet::new();
42    let mut narrated = false;
43
44    for line in BufReader::new(file).lines() {
45        let Ok(line) = line else { continue };
46        let Ok(v) = serde_json::from_str::<Value>(&line) else { continue };
47        let ts = v.get("timestamp").and_then(Value::as_str).map_or(0, parse_ts);
48        if sess.cwd.is_empty()
49            && let Some(c) = v.get("cwd").and_then(Value::as_str)
50        {
51            sess.cwd = c.to_string();
52        }
53        if v.get("isSidechain").and_then(Value::as_bool) == Some(true) {
54            sess.sidechain = true;
55        }
56        let Some(msg) = v.get("message") else { continue };
57        match v.get("type").and_then(Value::as_str) {
58            Some("assistant") => {
59                sess.see(ts);
60                let mid = msg.get("id").and_then(Value::as_str).unwrap_or_default();
61                if let Some(u) = msg.get("usage")
62                    && usage_seen.insert(mid.to_string())
63                {
64                    let n = |k: &str| u.get(k).and_then(Value::as_u64).unwrap_or(0);
65                    sess.input_tokens += n("input_tokens") + n("cache_creation_input_tokens") + n("cache_read_input_tokens");
66                    sess.output_tokens += n("output_tokens");
67                }
68                for block in msg.get("content").and_then(Value::as_array).into_iter().flatten() {
69                    match block.get("type").and_then(Value::as_str) {
70                        Some("thinking") => {
71                            sess.reasoning.thinking_blocks += 1;
72                            if block.get("thinking").and_then(Value::as_str).is_some_and(|t| !t.trim().is_empty()) {
73                                sess.reasoning.thinking_nonempty += 1;
74                            }
75                        }
76                        Some("text") => {
77                            narrated |= block.get("text").and_then(Value::as_str).is_some_and(|t| t.trim().len() > 10);
78                        }
79                        Some("tool_use") => {
80                            let name = block.get("name").and_then(Value::as_str).unwrap_or_default();
81                            let input = block.get("input").unwrap_or(&Value::Null);
82                            let kind = classify(name, input, &sess.cwd);
83                            if matches!(kind, Kind::Edit { .. }) {
84                                sess.reasoning.edits += 1;
85                                sess.reasoning.edits_with_text += narrated as u32;
86                            }
87                            narrated = false;
88                            if let Some(id) = block.get("id").and_then(Value::as_str) {
89                                pending.insert(id.to_string(), Pending { ts, kind });
90                            }
91                        }
92                        _ => {}
93                    }
94                }
95            }
96            Some("user") => {
97                sess.see(ts);
98                let Some(blocks) = msg.get("content").and_then(Value::as_array) else { continue };
99                for block in blocks {
100                    if block.get("type").and_then(Value::as_str) != Some("tool_result") {
101                        continue;
102                    }
103                    let Some(p) = block.get("tool_use_id").and_then(Value::as_str).and_then(|id| pending.remove(id)) else {
104                        continue;
105                    };
106                    let content = block.get("content").unwrap_or(&Value::Null);
107                    let failed = block.get("is_error").and_then(Value::as_bool) == Some(true);
108                    match p.kind {
109                        Kind::Bash { cmd, .. } => sess.push_bash(p.ts.max(ts), cmd, failed, content),
110                        kind => sess.events.push(Event { ts: p.ts.max(ts), kind, result_tokens: approx_tokens(content) }),
111                    }
112                }
113            }
114            _ => {}
115        }
116    }
117    Ok(sess)
118}