Skip to main content

claudectl_core/
transcript.rs

1use serde_json::Value;
2
3/// The tools that change a file, in Claude Code's vocabulary. One list for
4/// the monitor's `files_modified`, the session focus (#596), the brain's
5/// edit-outcome attribution and role suggestion, so a renamed tool is found
6/// in one place.
7pub const EDIT_TOOLS: &[&str] = &["Edit", "Write", "MultiEdit", "NotebookEdit"];
8
9/// Whether `name` is one of [`EDIT_TOOLS`].
10pub fn is_edit_tool(name: &str) -> bool {
11    EDIT_TOOLS.contains(&name)
12}
13
14/// The file an edit tool's `input` targets: `file_path`, or `notebook_path`
15/// for `NotebookEdit`.
16pub fn edited_path(input: &Value) -> Option<&str> {
17    input
18        .get("file_path")
19        .or_else(|| input.get("notebook_path"))
20        .and_then(|v| v.as_str())
21}
22
23/// The last `cap` bytes of a transcript, lossily decoded. Recent lines carry
24/// the strongest "what is this session doing now" signal, and a bounded
25/// read keeps a multi-hundred-MB transcript off the hot path. The first
26/// line of the window is usually cut and fails to parse; callers skip it.
27pub fn read_tail(path: &std::path::Path, cap: u64) -> std::io::Result<String> {
28    use std::io::{Read, Seek, SeekFrom};
29    let mut f = std::fs::File::open(path)?;
30    let len = f.metadata()?.len();
31    if len > cap {
32        f.seek(SeekFrom::Start(len - cap))?;
33    }
34    let mut buf = Vec::new();
35    f.take(cap).read_to_end(&mut buf)?;
36    Ok(String::from_utf8_lossy(&buf).into_owned())
37}
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub enum TranscriptRole {
41    Assistant,
42    User,
43}
44
45#[derive(Debug, Clone, PartialEq, Eq)]
46pub struct TranscriptUsage {
47    pub input_tokens: u64,
48    pub cache_read_input_tokens: u64,
49    /// Total cache-write tokens, whatever their TTL.
50    pub cache_creation_input_tokens: u64,
51    /// The subset of the above written with a 1-hour TTL, which bills at 2x
52    /// base input instead of 1.25x. Zero when the transcript carries no
53    /// `cache_creation` breakdown, which prices everything at the 5-minute
54    /// rate — the documented default.
55    pub cache_creation_1h_input_tokens: u64,
56    pub output_tokens: u64,
57}
58
59#[derive(Debug, Clone)]
60pub enum TranscriptBlock {
61    Text(String),
62    ToolUse { name: String, input: Value },
63    ToolResult { content: String, is_error: bool },
64}
65
66#[derive(Debug, Clone)]
67pub struct TranscriptMessage {
68    pub role: TranscriptRole,
69    /// The API message id. One assistant turn is written as one JSONL line per
70    /// content block, every line repeating the same `usage`, so this is what
71    /// tells those lines apart from genuinely separate turns.
72    pub id: Option<String>,
73    pub model: Option<String>,
74    pub stop_reason: Option<String>,
75    pub usage: Option<TranscriptUsage>,
76    pub content: Vec<TranscriptBlock>,
77}
78
79#[derive(Debug, Clone)]
80pub enum TranscriptEvent {
81    WaitingForTask,
82    Message(TranscriptMessage),
83}
84
85pub fn parse_line(line: &str) -> Option<TranscriptEvent> {
86    let entry: Value = serde_json::from_str(line).ok()?;
87
88    if is_waiting_for_task(&entry) {
89        return Some(TranscriptEvent::WaitingForTask);
90    }
91
92    let msg = entry.get("message")?;
93    let role = message_role(&entry, msg)?;
94
95    let content = msg
96        .get("content")
97        .and_then(|v| v.as_array())
98        .map(|blocks| blocks.iter().filter_map(parse_block).collect())
99        .unwrap_or_default();
100
101    Some(TranscriptEvent::Message(TranscriptMessage {
102        role,
103        id: msg
104            .get("id")
105            .and_then(|v| v.as_str())
106            .map(|s| s.to_string()),
107        model: msg
108            .get("model")
109            .and_then(|v| v.as_str())
110            .map(|s| s.to_string()),
111        stop_reason: msg
112            .get("stop_reason")
113            .and_then(|v| v.as_str())
114            .map(|s| s.to_string()),
115        usage: msg.get("usage").and_then(parse_usage),
116        content,
117    }))
118}
119
120fn is_waiting_for_task(entry: &Value) -> bool {
121    if entry.get("type").and_then(|v| v.as_str()) != Some("progress") {
122        return false;
123    }
124
125    match entry.get("data") {
126        Some(Value::String(s)) => s.contains("waiting_for_task"),
127        Some(Value::Object(map)) => map.values().any(|v| {
128            v.as_str()
129                .map(|s| s.contains("waiting_for_task"))
130                .unwrap_or(false)
131        }),
132        _ => false,
133    }
134}
135
136fn message_role(entry: &Value, msg: &Value) -> Option<TranscriptRole> {
137    let role = msg
138        .get("role")
139        .and_then(|v| v.as_str())
140        .or_else(|| entry.get("type").and_then(|v| v.as_str()))?;
141
142    match role {
143        "assistant" => Some(TranscriptRole::Assistant),
144        "user" => Some(TranscriptRole::User),
145        _ => None,
146    }
147}
148
149fn parse_usage(value: &Value) -> Option<TranscriptUsage> {
150    Some(TranscriptUsage {
151        input_tokens: value.get("input_tokens")?.as_u64().unwrap_or(0),
152        cache_read_input_tokens: value
153            .get("cache_read_input_tokens")
154            .and_then(|v| v.as_u64())
155            .unwrap_or(0),
156        cache_creation_input_tokens: value
157            .get("cache_creation_input_tokens")
158            .and_then(|v| v.as_u64())
159            .unwrap_or(0),
160        cache_creation_1h_input_tokens: value
161            .get("cache_creation")
162            .and_then(|v| v.get("ephemeral_1h_input_tokens"))
163            .and_then(|v| v.as_u64())
164            .unwrap_or(0),
165        output_tokens: value
166            .get("output_tokens")
167            .and_then(|v| v.as_u64())
168            .unwrap_or(0),
169    })
170}
171
172fn parse_block(block: &Value) -> Option<TranscriptBlock> {
173    match block.get("type").and_then(|v| v.as_str())? {
174        "text" => block
175            .get("text")
176            .and_then(|v| v.as_str())
177            .map(|s| TranscriptBlock::Text(s.to_string())),
178        "tool_use" => Some(TranscriptBlock::ToolUse {
179            name: block
180                .get("name")
181                .and_then(|v| v.as_str())
182                .unwrap_or("unknown")
183                .to_string(),
184            input: block.get("input").cloned().unwrap_or(Value::Null),
185        }),
186        "tool_result" => Some(TranscriptBlock::ToolResult {
187            content: block
188                .get("content")
189                .and_then(extract_text_content)
190                .unwrap_or_default(),
191            is_error: block
192                .get("is_error")
193                .and_then(|v| v.as_bool())
194                .unwrap_or(false),
195        }),
196        _ => None,
197    }
198}
199
200fn extract_text_content(value: &Value) -> Option<String> {
201    if let Some(text) = value.as_str() {
202        return Some(text.to_string());
203    }
204
205    let blocks = value.as_array()?;
206    let mut parts = Vec::new();
207    for block in blocks {
208        if let Some(text) = block.get("text").and_then(|v| v.as_str()) {
209            parts.push(text);
210        }
211    }
212    if parts.is_empty() {
213        None
214    } else {
215        Some(parts.join("\n"))
216    }
217}
218
219#[cfg(test)]
220mod tests {
221    use super::*;
222
223    #[test]
224    fn parse_real_fixture_line() {
225        let line = include_str!("../../../tests/fixtures/real-transcript-line.json");
226        let Some(TranscriptEvent::Message(msg)) = parse_line(line.trim()) else {
227            panic!("expected message event");
228        };
229        assert_eq!(msg.role, TranscriptRole::Assistant);
230        assert_eq!(msg.stop_reason.as_deref(), Some("tool_use"));
231        assert_eq!(msg.model.as_deref(), Some("claude-sonnet-4-6-20260401"));
232        assert_eq!(msg.content.len(), 2);
233    }
234
235    #[test]
236    fn parse_legacy_fixture_line() {
237        // Fixture lives at the workspace root, not in this crate.
238        let line = include_str!("../../../tests/fixtures/legacy-transcript-line.json");
239        let Some(TranscriptEvent::Message(msg)) = parse_line(line.trim()) else {
240            panic!("expected message event");
241        };
242        assert_eq!(msg.role, TranscriptRole::Assistant);
243        assert_eq!(msg.stop_reason.as_deref(), Some("end_turn"));
244        assert!(msg.usage.is_some());
245    }
246
247    #[test]
248    fn parse_waiting_for_task_progress() {
249        let line = r#"{"type":"progress","data":"waiting_for_task"}"#;
250        assert!(matches!(
251            parse_line(line),
252            Some(TranscriptEvent::WaitingForTask)
253        ));
254    }
255}