Skip to main content

claude_scriptorium/
transcript.rs

1//! Parsing of Claude Code session JSONL into typed conversation values.
2
3use std::{
4    collections::{HashMap, HashSet},
5    fs,
6    path::{Path, PathBuf},
7};
8
9use anyhow::{Context, Result};
10use jiff::Timestamp;
11use serde::Deserialize;
12use serde_json::Value;
13
14use crate::tools;
15
16/// One rendered session: the unit the tool turns into a single HTML file.
17#[derive(Debug)]
18pub struct Folio {
19    pub source: PathBuf,
20    pub turns: Vec<Turn>,
21}
22
23impl Folio {
24    /// Reads a session JSONL file, keeping only the lines that carry
25    /// conversation.
26    pub fn read(source: &Path) -> Result<Self> {
27        let text = fs::read_to_string(source)
28            .with_context(|| format!("reading session {}", source.display()))?;
29
30        let mut turns = Vec::new();
31        // One API response is written as several lines, one per content block,
32        // each repeating the response's usage. Counting every line would
33        // multiply what the response cost, so a response is counted once, on
34        // the first line that carries its id.
35        let mut counted = HashSet::new();
36        for (index, line) in text.lines().enumerate() {
37            if line.trim().is_empty() {
38                continue;
39            }
40            let entry: Entry = serde_json::from_str(line)
41                .with_context(|| format!("{}:{}", source.display(), index + 1))?;
42            match entry {
43                Entry::User(turn) => turns.push(turn.into_turn(Role::User)),
44                Entry::Assistant(raw) => {
45                    let opens_response = raw
46                        .message
47                        .id
48                        .as_deref()
49                        .is_none_or(|id| counted.insert(id.to_owned()));
50                    let turn = raw.into_turn(Role::Assistant);
51                    turns.push(Turn {
52                        usage: turn.usage.filter(|_| opens_response),
53                        ..turn
54                    });
55                }
56                Entry::Attachment(attachment) => turns.extend(attachment.into_turn()),
57                Entry::Bookkeeping => {}
58            }
59        }
60
61        Ok(Self {
62            source: source.to_path_buf(),
63            turns,
64        })
65    }
66
67    pub fn session_id(&self) -> &str {
68        self.source
69            .file_stem()
70            .and_then(|stem| stem.to_str())
71            .unwrap_or("session")
72    }
73
74    /// Cheaply scans a session's listing metadata (its title and working
75    /// directory) without parsing the conversation, tolerating malformed lines
76    /// so one bad session never breaks a picker that lists every session. This
77    /// is deliberately lenient where [`Folio::read`] is strict: a label is
78    /// best-effort, a render is not.
79    pub fn peek(source: &Path) -> SessionPeek {
80        let Ok(text) = fs::read_to_string(source) else {
81            return SessionPeek::default();
82        };
83
84        let mut cwd = None;
85        let mut ai_title = None;
86        let mut first_prompt = None;
87        for line in text.lines() {
88            let line = line.trim();
89            if line.is_empty() {
90                continue;
91            }
92            let Ok(value) = serde_json::from_str::<Value>(line) else {
93                continue;
94            };
95            if cwd.is_none()
96                && let Some(dir) = value.get("cwd").and_then(Value::as_str)
97            {
98                cwd = Some(PathBuf::from(dir));
99            }
100            match value.get("type").and_then(Value::as_str) {
101                // Claude rewrites the title as the session evolves, so the last
102                // one wins: it is the summary Claude Code shows in terminals.
103                Some("ai-title") => {
104                    if let Some(title) = value.get("aiTitle").and_then(Value::as_str) {
105                        ai_title = Some(title.to_owned());
106                    }
107                }
108                Some("user") if first_prompt.is_none() && !is_meta(&value) => {
109                    first_prompt = user_prompt(&value);
110                }
111                _ => {}
112            }
113        }
114
115        SessionPeek {
116            cwd,
117            title: ai_title.or(first_prompt),
118        }
119    }
120
121    /// The output across the session, or `None` when no turn reports usage.
122    /// Output totals, since each turn produces its own.
123    pub fn output(&self) -> Option<u64> {
124        self.turns
125            .iter()
126            .filter_map(|turn| turn.usage)
127            .map(|usage| usage.output_tokens)
128            .reduce(|total, output| total + output)
129    }
130
131    /// The largest input any one turn took, or `None` when no turn reports
132    /// usage: how big the conversation ever got. A high-water mark rather than
133    /// a sum, since every turn is sent the whole conversation and summing that
134    /// would count the same text once per turn that saw it.
135    pub fn largest_input(&self) -> Option<u64> {
136        self.turns
137            .iter()
138            .filter_map(|turn| turn.usage)
139            .map(|usage| usage.input())
140            .max()
141    }
142
143    /// Folds the raw turns into the display stream: drops `/clear` boundaries
144    /// and merges each tool-result turn back into the assistant turn it
145    /// answers, so a call and its result render as one panel.
146    pub fn panels(&self) -> Vec<Panel> {
147        let calls = self.calls();
148        let mut panels: Vec<Panel> = Vec::new();
149        for (index, turn) in self.turns.iter().enumerate() {
150            if turn.is_clear_command() {
151                continue;
152            }
153            let blocks = answered(turn.blocks(), &calls);
154            // A turn whose every block was dropped has nothing left to show,
155            // and an empty panel is a bordered box with no contents in it.
156            if blocks.is_empty() {
157                continue;
158            }
159            if turn.is_tool_response()
160                && let Some(assistant) = panels.last_mut().filter(|p| p.role == Role::Assistant)
161            {
162                assistant.blocks.extend(blocks);
163                continue;
164            }
165            panels.push(Panel::from_turn(turn, index + 1, blocks));
166        }
167        panels
168    }
169
170    /// Every tool call in the session, by id. The wire format names the tool
171    /// only on the call: a result carries just the id it answers, so a result
172    /// can only be set the way its call is once the two are matched up.
173    fn calls(&self) -> HashMap<&str, Answered> {
174        self.turns
175            .iter()
176            .flat_map(|turn| match &turn.content {
177                Content::Text(_) => [].iter(),
178                Content::Blocks(blocks) => blocks.iter(),
179            })
180            .filter_map(|block| match block {
181                Block::Known(Known::ToolUse {
182                    id: Some(id),
183                    name,
184                    input,
185                }) => Some((id.as_str(), Answered::of(name, input))),
186                _ => None,
187            })
188            .collect()
189    }
190}
191
192/// Names each result in `blocks` with the call it answers, and drops the ones
193/// that say nothing their call doesn't. Naming has to come first: whether a
194/// result is worth showing is a question about the tool that produced it.
195fn answered(mut blocks: Vec<Block>, calls: &HashMap<&str, Answered>) -> Vec<Block> {
196    for block in &mut blocks {
197        if let Block::Known(Known::ToolResult {
198            tool_use_id: Some(id),
199            answers,
200            ..
201        }) = block
202        {
203            *answers = calls.get(id.as_str()).cloned();
204        }
205    }
206    blocks.retain(|block| !is_acknowledgement(block));
207    blocks
208}
209
210/// True for a result that only confirms its call was carried out. A failure is
211/// never one of these: that a call *didn't* work is the whole of what it says.
212fn is_acknowledgement(block: &Block) -> bool {
213    let Block::Known(Known::ToolResult {
214        content,
215        is_error: false,
216        answers: Some(answered),
217        ..
218    }) = block
219    else {
220        return false;
221    };
222    tools::spoken(content).is_ok_and(|text| tools::acknowledges(&answered.tool, &text))
223}
224
225/// What a result needs to know about the call it answers: which tool ran, and
226/// the path it ran on where the tool has one, since a file's contents are set
227/// by its extension and only the call records the name.
228#[derive(Debug, Clone, PartialEq, Eq)]
229pub struct Answered {
230    pub tool: String,
231    pub subject: Option<String>,
232}
233
234impl Answered {
235    fn of(tool: &str, input: &Value) -> Self {
236        Self {
237            tool: tool.to_owned(),
238            subject: input
239                .get("file_path")
240                .and_then(Value::as_str)
241                .map(str::to_owned),
242        }
243    }
244}
245
246/// A session's listing metadata, scanned by [`Folio::peek`] for pickers and
247/// indexes that show sessions without rendering them.
248#[derive(Debug, Default, PartialEq, Eq)]
249pub struct SessionPeek {
250    /// The directory the session ran in, recovered from the transcript because
251    /// the encoded project-dir name flattens separators and can't be decoded
252    /// back to a real path.
253    pub cwd: Option<PathBuf>,
254    /// A human label for the session: Claude's own `ai-title`, falling back to
255    /// the first prose the user typed when the session has no title yet.
256    pub title: Option<String>,
257}
258
259/// True when a turn was injected by the harness rather than typed by the user.
260fn is_meta(entry: &Value) -> bool {
261    entry
262        .get("isMeta")
263        .and_then(Value::as_bool)
264        .unwrap_or(false)
265}
266
267/// The first prose from a user turn, or `None` when it carries only a
268/// harness-injected command wrapper, notification, or reminder: those open with
269/// an XML-ish tag rather than something the user actually wrote.
270fn user_prompt(entry: &Value) -> Option<String> {
271    const WRAPPERS: [&str; 4] = [
272        "<command-",
273        "<local-command-",
274        "<task-notification>",
275        "<system-reminder>",
276    ];
277
278    let content = entry.get("message")?.get("content")?;
279    let text = match content {
280        Value::String(text) => text.trim(),
281        Value::Array(blocks) => blocks
282            .iter()
283            .filter(|block| block.get("type").and_then(Value::as_str) == Some("text"))
284            .find_map(|block| block.get("text").and_then(Value::as_str))?
285            .trim(),
286        _ => return None,
287    };
288
289    let is_wrapper = WRAPPERS.iter().any(|tag| text.starts_with(tag));
290    (!text.is_empty() && !is_wrapper).then(|| text.to_owned())
291}
292
293/// A conversation turn, with the role lifted out of the JSONL's `type` tag.
294#[derive(Debug)]
295pub struct Turn {
296    pub role: Role,
297    pub timestamp: Timestamp,
298    pub model: Option<String>,
299    /// How hard the model was asked to think, where the harness records it: a
300    /// refinement of the model, not a separate fact about the turn.
301    pub effort: Option<String>,
302    pub content: Content,
303    /// What the turn cost, for the assistant turns that report it. A user turn
304    /// has none, and transcripts written before the harness recorded usage
305    /// carry none either.
306    pub usage: Option<Usage>,
307    /// True for turns belonging to a subagent running inside this session.
308    pub is_sidechain: bool,
309    /// True for turns the harness injected rather than the user typing them.
310    pub is_meta: bool,
311}
312
313/// What one turn cost: what the model read, split by where it came from, and
314/// what it wrote.
315#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
316pub struct Usage {
317    pub input_tokens: u64,
318    pub output_tokens: u64,
319    pub cache_creation_input_tokens: u64,
320    pub cache_read_input_tokens: u64,
321}
322
323impl Usage {
324    /// Everything the model was sent for this turn, which is the conversation
325    /// as it stood. The transcript splits it by where it was served from
326    /// (fresh, cached this turn, replayed from an earlier one), a billing
327    /// distinction rather than anything about the conversation, so this
328    /// recombines it.
329    pub fn input(&self) -> u64 {
330        self.input_tokens + self.cache_creation_input_tokens + self.cache_read_input_tokens
331    }
332
333    /// The part of that input the model had not been sent before: what this
334    /// turn added to the conversation, which is what the turn's own output can
335    /// be read against. The cache holds exactly the prefix already sent, so its
336    /// boundary marks what is new, except where a lapsed cache re-sends a
337    /// prefix and counts it new again.
338    pub fn uncached_input(&self) -> u64 {
339        self.input_tokens + self.cache_creation_input_tokens
340    }
341}
342
343impl Turn {
344    /// True when a `user`-role turn carries only tool results: the harness
345    /// returning tool output, not the user typing. These read as a
346    /// continuation of the assistant's turn, not a message of their own.
347    pub fn is_tool_response(&self) -> bool {
348        self.role == Role::User && self.content.is_only_tool_results()
349    }
350
351    /// True when this turn is the `/clear` slash command, which resets the
352    /// context: a session boundary the harness records as a user turn, with
353    /// no conversation of its own worth showing.
354    pub fn is_clear_command(&self) -> bool {
355        matches!(&self.content, Content::Text(text)
356            if text.contains("<command-name>/clear</command-name>"))
357    }
358
359    /// The content as a uniform block list, lifting a plain string into a
360    /// single text block so every panel is a sequence of blocks.
361    fn blocks(&self) -> Vec<Block> {
362        match &self.content {
363            Content::Text(text) => vec![Block::Known(Known::Text { text: text.clone() })],
364            Content::Blocks(blocks) => blocks.clone(),
365        }
366    }
367}
368
369/// One speaker's contribution as displayed. Folding the wire-level turns into
370/// panels is where harness scaffolding is filtered and tool results are
371/// reunited with the assistant that called them, so the renderer walks an
372/// already-clean stream and never re-derives any of it.
373#[derive(Debug)]
374pub struct Panel {
375    /// The 1-based position of this panel's leading turn in the raw stream.
376    /// Gaps between successive panels mark turns that were folded in or
377    /// dropped, so a panel that spans several turns still has one stable label.
378    pub turn_number: usize,
379    pub role: Role,
380    pub timestamp: Timestamp,
381    pub model: Option<String>,
382    /// How hard the model was asked to think, where the harness records it.
383    pub effort: Option<String>,
384    pub blocks: Vec<Block>,
385    /// What the panel's leading turn cost. The tool-result turns folded in
386    /// carry none of their own: the assistant turn that called the tool is
387    /// where the harness records what the exchange cost.
388    pub usage: Option<Usage>,
389    /// True for panels belonging to a subagent running inside this session.
390    pub is_sidechain: bool,
391    /// True for panels the harness injected rather than the user typing them.
392    pub is_meta: bool,
393}
394
395impl Panel {
396    fn from_turn(turn: &Turn, turn_number: usize, blocks: Vec<Block>) -> Self {
397        Self {
398            turn_number,
399            role: turn.role,
400            timestamp: turn.timestamp,
401            model: turn.model.clone(),
402            effort: turn.effort.clone(),
403            blocks,
404            usage: turn.usage,
405            is_sidechain: turn.is_sidechain,
406            is_meta: turn.is_meta,
407        }
408    }
409
410    /// The panel's content kind, preferring the most user-facing thing it
411    /// carries: visible prose reads as the speaker, otherwise a tool exchange,
412    /// otherwise bare reasoning.
413    pub fn kind(&self) -> PanelKind {
414        let speaker = match self.role {
415            Role::User => PanelKind::User,
416            Role::Assistant => PanelKind::Assistant,
417        };
418        if self.blocks.iter().any(Block::is_visible_text) {
419            speaker
420        } else if self.blocks.iter().any(Block::is_tool) {
421            PanelKind::Tool
422        } else if self.blocks.iter().any(Block::is_thinking) {
423            PanelKind::Thinking
424        } else {
425            speaker
426        }
427    }
428}
429
430#[derive(Debug, Clone, Copy, PartialEq, Eq)]
431pub enum Role {
432    User,
433    Assistant,
434}
435
436impl Role {
437    pub fn as_str(self) -> &'static str {
438        match self {
439            Role::User => "user",
440            Role::Assistant => "assistant",
441        }
442    }
443}
444
445/// One line of a session JSONL file.
446#[derive(Debug, Deserialize)]
447#[serde(tag = "type")]
448enum Entry {
449    #[serde(rename = "user")]
450    User(RawTurn),
451    #[serde(rename = "assistant")]
452    Assistant(RawTurn),
453    /// Attachment lines. Most are scaffolding (hook output, task reminders,
454    /// memory), but a `queued_command` carries a message the user typed while
455    /// the assistant was still working: real conversation the harness records
456    /// here rather than as a `user` turn, so it must not be dropped.
457    #[serde(rename = "attachment")]
458    Attachment(RawAttachment),
459    /// Lines that carry no conversation: hook output, mode changes,
460    /// file-history snapshots, and whatever else gets added later.
461    #[serde(other)]
462    Bookkeeping,
463}
464
465#[derive(Debug, Deserialize)]
466struct RawTurn {
467    timestamp: Timestamp,
468    message: Message,
469    /// Recorded beside the message rather than in it, and only by harness
470    /// versions that track it.
471    effort: Option<String>,
472    #[serde(default, rename = "isSidechain")]
473    is_sidechain: bool,
474    #[serde(default, rename = "isMeta")]
475    is_meta: bool,
476}
477
478impl RawTurn {
479    fn into_turn(self, role: Role) -> Turn {
480        Turn {
481            role,
482            timestamp: self.timestamp,
483            model: self.message.model,
484            effort: self.effort,
485            content: self.message.content,
486            usage: self.message.usage,
487            is_sidechain: self.is_sidechain,
488            is_meta: self.is_meta,
489        }
490    }
491}
492
493/// An `attachment` line. Only a `queued_command` body becomes a turn; every
494/// other attachment kind is scaffolding this drops.
495#[derive(Debug, Deserialize)]
496struct RawAttachment {
497    timestamp: Timestamp,
498    attachment: AttachmentBody,
499}
500
501impl RawAttachment {
502    /// A turn for a message the user queued mid-response, or `None` for any
503    /// other attachment kind.
504    fn into_turn(self) -> Option<Turn> {
505        let AttachmentBody::QueuedCommand { prompt } = self.attachment else {
506            return None;
507        };
508        Some(Turn {
509            role: Role::User,
510            timestamp: self.timestamp,
511            model: None,
512            effort: None,
513            content: Content::Text(prompt),
514            usage: None,
515            is_sidechain: false,
516            is_meta: false,
517        })
518    }
519}
520
521#[derive(Debug, Deserialize)]
522#[serde(tag = "type", rename_all = "snake_case")]
523enum AttachmentBody {
524    /// A message the user typed while the assistant was still working, dequeued
525    /// and processed later in the same session.
526    QueuedCommand { prompt: String },
527    /// Every other attachment kind: hook output, task reminders, memory, and
528    /// whatever else gets added later, none of it conversation.
529    #[serde(other)]
530    Other,
531}
532
533#[derive(Debug, Deserialize)]
534struct Message {
535    content: Content,
536    model: Option<String>,
537    usage: Option<Usage>,
538    /// The API response this line belongs to. Several lines share one, since a
539    /// response is written a block at a time.
540    id: Option<String>,
541}
542
543#[derive(Debug, Clone, Deserialize)]
544#[serde(untagged)]
545pub enum Content {
546    Text(String),
547    Blocks(Vec<Block>),
548}
549
550impl Content {
551    fn is_only_tool_results(&self) -> bool {
552        match self {
553            Content::Text(_) => false,
554            Content::Blocks(blocks) => {
555                !blocks.is_empty() && blocks.iter().all(Block::is_tool_result)
556            }
557        }
558    }
559}
560
561impl Block {
562    fn is_tool_result(&self) -> bool {
563        matches!(self, Block::Known(Known::ToolResult { .. }))
564    }
565
566    fn is_tool(&self) -> bool {
567        matches!(
568            self,
569            Block::Known(Known::ToolUse { .. } | Known::ToolResult { .. })
570        )
571    }
572
573    fn is_thinking(&self) -> bool {
574        matches!(self, Block::Known(Known::Thinking { .. }))
575    }
576
577    pub(crate) fn is_visible_text(&self) -> bool {
578        matches!(self, Block::Known(Known::Text { text }) if !text.trim().is_empty())
579    }
580}
581
582/// What a panel actually shows, so a label can say more than "assistant": the
583/// role already has a colour, so the label names the content instead.
584#[derive(Debug, Clone, Copy, PartialEq, Eq)]
585pub enum PanelKind {
586    User,
587    Assistant,
588    Tool,
589    Thinking,
590}
591
592impl PanelKind {
593    pub fn label(self) -> &'static str {
594        match self {
595            PanelKind::User => "user",
596            PanelKind::Assistant => "assistant",
597            PanelKind::Tool => "tool",
598            PanelKind::Thinking => "thinking",
599        }
600    }
601}
602
603/// A content block, or the raw JSON of one this version doesn't recognize.
604///
605/// Claude Code's transcript format grows new block types; encountering one is
606/// a producer adding something optional, not malformed input, so it renders as
607/// JSON instead of aborting the folio.
608#[derive(Debug, Clone, Deserialize)]
609#[serde(untagged)]
610pub enum Block {
611    Known(Known),
612    Unknown(Value),
613}
614
615#[derive(Debug, Clone, Deserialize)]
616#[serde(tag = "type", rename_all = "snake_case")]
617pub enum Known {
618    Text {
619        text: String,
620    },
621    Thinking {
622        thinking: String,
623    },
624    ToolUse {
625        /// What the result answering this call points back to.
626        id: Option<String>,
627        name: String,
628        input: Value,
629    },
630    ToolResult {
631        tool_use_id: Option<String>,
632        content: ToolResultContent,
633        #[serde(default)]
634        is_error: bool,
635        /// The call this answers. Never on the wire: it is resolved when the
636        /// result is folded into the panel, so the renderer walks a stream
637        /// where every result already knows which tool produced it.
638        #[serde(skip)]
639        answers: Option<Answered>,
640    },
641    Image {
642        source: ImageSource,
643    },
644}
645
646#[derive(Debug, Clone, Deserialize)]
647#[serde(untagged)]
648pub enum ToolResultContent {
649    Text(String),
650    Blocks(Vec<Block>),
651}
652
653#[derive(Debug, Clone, Deserialize)]
654pub struct ImageSource {
655    pub media_type: String,
656    pub data: String,
657}