Skip to main content

claude_scriptorium/
transcript.rs

1//! Parsing of Claude Code session JSONL into typed conversation values.
2
3use std::{
4    borrow::Cow,
5    collections::{HashMap, HashSet},
6    fs,
7    path::{Path, PathBuf},
8};
9
10use anyhow::{Context, Result};
11use jiff::Timestamp;
12use serde::Deserialize;
13use serde_json::Value;
14
15use crate::{
16    gloss::{self, Gloss, GlossKind, Wrapped},
17    tools,
18};
19
20/// One line of a session as the folio keeps it: a turn of the conversation, or
21/// a note the harness wrote into the session around it.
22#[derive(Debug)]
23pub enum Recorded {
24    Turn(Turn),
25    Gloss(Glossed),
26}
27
28/// A harness note where it sits in the session.
29#[derive(Debug)]
30pub struct Glossed {
31    pub timestamp: Timestamp,
32    pub is_sidechain: bool,
33    pub gloss: Gloss,
34    /// The session's own name for this line.
35    pub uuid: Option<String>,
36    /// The line this note was written about, where it names one. A slash
37    /// command's output is a `system` line naming the command's own line as its
38    /// parent, which is the exact key that gathers the two into one panel.
39    pub answers: Option<String>,
40}
41
42/// One rendered session: the unit the tool turns into a single HTML file.
43#[derive(Debug)]
44pub struct Folio {
45    pub source: PathBuf,
46    /// The session as read, in file order, so a harness note keeps its place
47    /// among the turns it stands between.
48    pub recorded: Vec<Recorded>,
49}
50
51impl Folio {
52    /// Reads a session JSONL file, keeping the lines that carry conversation
53    /// and the notes the harness wrote around them.
54    pub fn read(source: &Path) -> Result<Self> {
55        let text = fs::read_to_string(source)
56            .with_context(|| format!("reading session {}", source.display()))?;
57
58        let mut recorded = Vec::new();
59        // One API response is written as several lines, one per content block,
60        // each repeating the response's usage. Counting every line would
61        // multiply what the response cost, so a response is counted once, on
62        // the first line that carries its id.
63        let mut counted = HashSet::new();
64        for (index, line) in text.lines().enumerate() {
65            if line.trim().is_empty() {
66                continue;
67            }
68            let entry: Entry = serde_json::from_str(line)
69                .with_context(|| format!("{}:{}", source.display(), index + 1))?;
70            match entry {
71                Entry::User(turn) => recorded.push(Recorded::Turn(turn.into_turn(Role::User))),
72                Entry::Assistant(raw) => {
73                    let opens_response = raw
74                        .message
75                        .id
76                        .as_deref()
77                        .is_none_or(|id| counted.insert(id.to_owned()));
78                    let turn = raw.into_turn(Role::Assistant);
79                    recorded.push(Recorded::Turn(Turn {
80                        usage: turn.usage.filter(|_| opens_response),
81                        ..turn
82                    }));
83                }
84                Entry::Attachment(attachment) => recorded.extend(attachment.into_recorded()),
85                Entry::System(line) => recorded.extend(glossed_system(&line)),
86                Entry::Bookkeeping => {}
87            }
88        }
89
90        Ok(Self {
91            source: source.to_path_buf(),
92            recorded,
93        })
94    }
95
96    /// The conversation alone, for the figures that are about what the model
97    /// was sent and what it wrote.
98    pub fn turns(&self) -> impl Iterator<Item = &Turn> {
99        self.recorded.iter().filter_map(|recorded| match recorded {
100            Recorded::Turn(turn) => Some(turn),
101            Recorded::Gloss(_) => None,
102        })
103    }
104
105    pub fn session_id(&self) -> &str {
106        self.source
107            .file_stem()
108            .and_then(|stem| stem.to_str())
109            .unwrap_or("session")
110    }
111
112    /// Cheaply scans a session's listing metadata (its title and working
113    /// directory) without parsing the conversation, tolerating malformed lines
114    /// so one bad session never breaks a picker that lists every session. This
115    /// is deliberately lenient where [`Folio::read`] is strict: a label is
116    /// best-effort, a render is not.
117    pub fn peek(source: &Path) -> SessionPeek {
118        let Ok(text) = fs::read_to_string(source) else {
119            return SessionPeek::default();
120        };
121
122        let mut cwd = None;
123        let mut ai_title = None;
124        let mut first_prompt = None;
125        for line in text.lines() {
126            let line = line.trim();
127            if line.is_empty() {
128                continue;
129            }
130            let Ok(value) = serde_json::from_str::<Value>(line) else {
131                continue;
132            };
133            if cwd.is_none()
134                && let Some(dir) = value.get("cwd").and_then(Value::as_str)
135            {
136                cwd = Some(PathBuf::from(dir));
137            }
138            match value.get("type").and_then(Value::as_str) {
139                // Claude rewrites the title as the session evolves, so the last
140                // one wins: it is the summary Claude Code shows in terminals.
141                Some("ai-title") => {
142                    if let Some(title) = value.get("aiTitle").and_then(Value::as_str) {
143                        ai_title = Some(title.to_owned());
144                    }
145                }
146                Some("user") if first_prompt.is_none() && !is_meta(&value) => {
147                    first_prompt = user_prompt(&value);
148                }
149                _ => {}
150            }
151        }
152
153        SessionPeek {
154            cwd,
155            title: ai_title.or(first_prompt),
156        }
157    }
158
159    /// The output across the session, or `None` when no turn reports usage.
160    /// Output totals, since each turn produces its own.
161    pub fn output(&self) -> Option<u64> {
162        self.turns()
163            .filter_map(|turn| turn.usage)
164            .map(|usage| usage.output_tokens)
165            .reduce(|total, output| total + output)
166    }
167
168    /// The largest input any one turn took, or `None` when no turn reports
169    /// usage: how big the conversation ever got. A high-water mark rather than
170    /// a sum, since every turn is sent the whole conversation and summing that
171    /// would count the same text once per turn that saw it.
172    pub fn largest_input(&self) -> Option<u64> {
173        self.turns()
174            .filter_map(|turn| turn.usage)
175            .map(|usage| usage.input())
176            .max()
177    }
178
179    /// Folds the raw stream into the display one: drops `/clear` boundaries,
180    /// merges each tool-result turn back into the assistant turn it answers so
181    /// a call and its result render as one panel, and lifts the turns the
182    /// harness wrote for itself out of the conversation and into glosses.
183    pub fn panels(&self) -> Vec<Panel> {
184        let calls = self.calls();
185        let mut panels: Vec<Panel> = Vec::new();
186        // Where each call ended up, so the result answering it can be put in
187        // the same panel however many panels later it comes back.
188        let mut homes: HashMap<&str, usize> = HashMap::new();
189        // The lines the folio left unset, so a note written *about* one goes the
190        // same way. A slash command that only works the harness is dropped, and
191        // what it printed is recorded as a line of its own: keeping that would
192        // set the output of a command the folio deliberately never mentions.
193        let mut unset: HashSet<&str> = HashSet::new();
194        for (index, recorded) in self.recorded.iter().enumerate() {
195            let turn_number = index + 1;
196            let turn = match recorded {
197                Recorded::Gloss(glossed) => {
198                    if glossed
199                        .answers
200                        .as_deref()
201                        .is_some_and(|parent| unset.contains(parent))
202                    {
203                        continue;
204                    }
205                    // One hook writes several lines (what it decided, what it
206                    // injected, what it printed) and a slash command's output is
207                    // written as a line naming the command's own. Either way the
208                    // note belongs in the panel already open, the way a tool
209                    // result belongs in the panel holding its call.
210                    if let Some(open) = panels
211                        .last_mut()
212                        .and_then(Panel::as_gloss_mut)
213                        .filter(|panel| panel.gathers(glossed))
214                    {
215                        open.gloss.absorb(glossed.gloss.clone());
216                        continue;
217                    }
218                    panels.push(Panel::Gloss(GlossPanel::of(glossed, turn_number)));
219                    continue;
220                }
221                Recorded::Turn(turn) => turn,
222            };
223            if turn.is_clear_command() {
224                // A boundary is dropped like any other command the folio leaves
225                // unset, so what it printed goes with it rather than orphaning
226                // into a panel of its own with nothing to say which command it
227                // came from.
228                unset.extend(turn.uuid.as_deref());
229                continue;
230            }
231            match turn.wrapped() {
232                Some(Wrapped::Note(mut gloss)) => {
233                    // A skill names the directory it was loaded from, which is
234                    // how `gloss::meta` knows one. A built-in has no directory
235                    // on disk, so its instructions arrive as bare prose and read
236                    // as a passing note; the command standing directly in front
237                    // of them is what says what they are. Relabelling here is
238                    // what gives a skill one shape however it was loaded, since
239                    // the model reaches for one with no command at all.
240                    if gloss.kind == GlossKind::Note
241                        && let Some(command) = panels
242                            .last()
243                            .and_then(Panel::as_gloss)
244                            .filter(|panel| panel.gloss.kind == GlossKind::Command)
245                            .and_then(|panel| panel.gloss.gist.as_deref())
246                    {
247                        gloss.ran_by(command);
248                    }
249                    panels.push(Panel::Gloss(GlossPanel {
250                        turn_number,
251                        timestamp: turn.timestamp,
252                        is_sidechain: turn.is_sidechain,
253                        gloss,
254                        uuid: turn.uuid.clone(),
255                    }));
256                    continue;
257                }
258                Some(Wrapped::Nothing) => {
259                    unset.extend(turn.uuid.as_deref());
260                    continue;
261                }
262                None => {}
263            }
264            let blocks = answered(turn.blocks(), &calls);
265            // A turn whose every block was dropped has nothing left to show,
266            // and an empty panel is a bordered box with no contents in it.
267            if blocks.is_empty() {
268                continue;
269            }
270            if turn.is_tool_response() {
271                // Each result joins the panel holding the call it answers, not
272                // whichever panel is newest. Calls issued together are written
273                // as one assistant line each, so they are several panels, and
274                // taking the last one piles every result onto the last call
275                // while its siblings show none.
276                let mut homeless = Vec::new();
277                for block in blocks {
278                    let home = block.answering().and_then(|id| homes.get(id)).copied();
279                    match home
280                        .and_then(|at| panels.get_mut(at))
281                        .and_then(Panel::answering_assistant)
282                    {
283                        Some(speech) => speech.blocks.push(block),
284                        None => homeless.push(block),
285                    }
286                }
287                if homeless.is_empty() {
288                    continue;
289                }
290                // A result whose call this session never recorded still belongs
291                // with the assistant that was speaking.
292                if let Some(speech) = panels.last_mut().and_then(Panel::answering_assistant) {
293                    speech.blocks.extend(homeless);
294                    continue;
295                }
296                panels.push(Panel::Speech(Speech::of(turn, turn_number, homeless)));
297                continue;
298            }
299            for calling in turn.calling() {
300                homes.insert(calling, panels.len());
301            }
302            panels.push(Panel::Speech(Speech::of(turn, turn_number, blocks)));
303        }
304        panels
305    }
306
307    /// Every tool call in the session, by id. The wire format names the tool
308    /// only on the call: a result carries just the id it answers, so a result
309    /// can only be set the way its call is once the two are matched up.
310    fn calls(&self) -> HashMap<&str, Answered> {
311        self.turns()
312            .flat_map(|turn| match &turn.content {
313                Content::Text(_) => [].iter(),
314                Content::Blocks(blocks) => blocks.iter(),
315            })
316            .filter_map(|block| match block {
317                Block::Known(Known::ToolUse {
318                    id: Some(id),
319                    name,
320                    input,
321                }) => Some((id.as_str(), Answered::of(name, input))),
322                _ => None,
323            })
324            .collect()
325    }
326}
327
328/// Names each result in `blocks` with the call it answers, and drops the ones
329/// that say nothing their call doesn't. Naming has to come first: whether a
330/// result is worth showing is a question about the tool that produced it.
331fn answered(mut blocks: Vec<Block>, calls: &HashMap<&str, Answered>) -> Vec<Block> {
332    for block in &mut blocks {
333        if let Block::Known(Known::ToolResult {
334            tool_use_id: Some(id),
335            answers,
336            ..
337        }) = block
338        {
339            *answers = calls.get(id.as_str()).cloned();
340        }
341    }
342    blocks.retain(|block| !is_acknowledgement(block));
343    blocks
344}
345
346/// True for a result that only confirms its call was carried out. A failure is
347/// never one of these: that a call *didn't* work is the whole of what it says.
348fn is_acknowledgement(block: &Block) -> bool {
349    let Block::Known(Known::ToolResult {
350        content,
351        is_error: false,
352        answers: Some(answered),
353        ..
354    }) = block
355    else {
356        return false;
357    };
358    tools::spoken(content).is_ok_and(|text| tools::acknowledges(&answered.tool, &text))
359}
360
361/// What a result needs to know about the call it answers: which tool ran, and
362/// the path it ran on where the tool has one, since a file's contents are set
363/// by its extension and only the call records the name.
364#[derive(Debug, Clone, PartialEq, Eq)]
365pub struct Answered {
366    pub tool: String,
367    pub subject: Option<String>,
368}
369
370impl Answered {
371    fn of(tool: &str, input: &Value) -> Self {
372        Self {
373            tool: tool.to_owned(),
374            subject: input
375                .get("file_path")
376                .and_then(Value::as_str)
377                .map(str::to_owned),
378        }
379    }
380}
381
382/// A session's listing metadata, scanned by [`Folio::peek`] for pickers and
383/// indexes that show sessions without rendering them.
384#[derive(Debug, Default, PartialEq, Eq)]
385pub struct SessionPeek {
386    /// The directory the session ran in, recovered from the transcript because
387    /// the encoded project-dir name flattens separators and can't be decoded
388    /// back to a real path.
389    pub cwd: Option<PathBuf>,
390    /// A human label for the session: Claude's own `ai-title`, falling back to
391    /// the first prose the user typed when the session has no title yet.
392    pub title: Option<String>,
393}
394
395/// True when a turn was injected by the harness rather than typed by the user.
396fn is_meta(entry: &Value) -> bool {
397    entry
398        .get("isMeta")
399        .and_then(Value::as_bool)
400        .unwrap_or(false)
401}
402
403/// The first prose from a user turn, or `None` when it carries only a
404/// harness-injected command wrapper, notification, or reminder: those open with
405/// an XML-ish tag rather than something the user actually wrote.
406fn user_prompt(entry: &Value) -> Option<String> {
407    const WRAPPERS: [&str; 4] = [
408        "<command-",
409        "<local-command-",
410        "<task-notification>",
411        "<system-reminder>",
412    ];
413
414    let content = entry.get("message")?.get("content")?;
415    let text = match content {
416        Value::String(text) => text.trim(),
417        Value::Array(blocks) => blocks
418            .iter()
419            .filter(|block| block.get("type").and_then(Value::as_str) == Some("text"))
420            .find_map(|block| block.get("text").and_then(Value::as_str))?
421            .trim(),
422        _ => return None,
423    };
424
425    let is_wrapper = WRAPPERS.iter().any(|tag| text.starts_with(tag));
426    (!text.is_empty() && !is_wrapper).then(|| text.to_owned())
427}
428
429/// A conversation turn, with the role lifted out of the JSONL's `type` tag.
430#[derive(Debug)]
431pub struct Turn {
432    pub role: Role,
433    pub timestamp: Timestamp,
434    pub model: Option<String>,
435    /// How hard the model was asked to think, where the harness records it: a
436    /// refinement of the model, not a separate fact about the turn.
437    pub effort: Option<String>,
438    pub content: Content,
439    /// What the turn cost, for the assistant turns that report it. A user turn
440    /// has none, and transcripts written before the harness recorded usage
441    /// carry none either.
442    pub usage: Option<Usage>,
443    /// True for turns belonging to a subagent running inside this session.
444    pub is_sidechain: bool,
445    /// True for turns the harness injected rather than the user typing them.
446    pub is_meta: bool,
447    /// The session's own name for this line, which is what a note written about
448    /// it names as its parent. A slash command's output is recorded as its own
449    /// `system` line pointing back here, so this is what lets the two be set as
450    /// one panel.
451    pub uuid: Option<String>,
452}
453
454/// What one turn cost: what the model read, split by where it came from, and
455/// what it wrote.
456#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
457pub struct Usage {
458    pub input_tokens: u64,
459    pub output_tokens: u64,
460    pub cache_creation_input_tokens: u64,
461    pub cache_read_input_tokens: u64,
462}
463
464impl Usage {
465    /// Everything the model was sent for this turn, which is the conversation
466    /// as it stood. The transcript splits it by where it was served from
467    /// (fresh, cached this turn, replayed from an earlier one), a billing
468    /// distinction rather than anything about the conversation, so this
469    /// recombines it.
470    pub fn input(&self) -> u64 {
471        self.input_tokens + self.cache_creation_input_tokens + self.cache_read_input_tokens
472    }
473
474    /// The part of that input the model had not been sent before: what this
475    /// turn added to the conversation, which is what the turn's own output can
476    /// be read against. The cache holds exactly the prefix already sent, so its
477    /// boundary marks what is new, except where a lapsed cache re-sends a
478    /// prefix and counts it new again.
479    pub fn uncached_input(&self) -> u64 {
480        self.input_tokens + self.cache_creation_input_tokens
481    }
482}
483
484impl Turn {
485    /// True when a `user`-role turn carries only tool results: the harness
486    /// returning tool output, not the user typing. These read as a
487    /// continuation of the assistant's turn, not a message of their own.
488    pub fn is_tool_response(&self) -> bool {
489        self.role == Role::User && self.content.is_only_tool_results()
490    }
491
492    /// True when this turn is the `/clear` slash command, which resets the
493    /// context: a session boundary the harness records as a user turn, with
494    /// no conversation of its own worth showing.
495    pub fn is_clear_command(&self) -> bool {
496        matches!(&self.content, Content::Text(text)
497            if text.contains("<command-name>/clear</command-name>"))
498    }
499
500    /// What this turn really is, for the turns in the user's role that are not
501    /// the user: a note the harness wrote for itself (a skill's instructions),
502    /// a slash command the user ran, or a wrapper standing in front of one.
503    /// All three are recorded in the user's role because that is where they
504    /// enter the conversation, and none of them is the user speaking.
505    fn wrapped(&self) -> Option<Wrapped> {
506        if self.role != Role::User {
507            return None;
508        }
509        let said = self.content.spoken()?;
510        gloss::wrapped(&said, self.is_meta)
511    }
512
513    /// The ids of the calls this turn issues, so a result coming back later can
514    /// be put in the panel that holds its call.
515    fn calling(&self) -> impl Iterator<Item = &str> {
516        match &self.content {
517            Content::Text(_) => [].iter(),
518            Content::Blocks(blocks) => blocks.iter(),
519        }
520        .filter_map(|block| match block {
521            Block::Known(Known::ToolUse { id: Some(id), .. }) => Some(id.as_str()),
522            _ => None,
523        })
524    }
525
526    /// The content as a uniform block list, lifting a plain string into a
527    /// single text block so every panel is a sequence of blocks.
528    fn blocks(&self) -> Vec<Block> {
529        match &self.content {
530            Content::Text(text) => vec![Block::Known(Known::Text { text: text.clone() })],
531            Content::Blocks(blocks) => blocks.clone(),
532        }
533    }
534}
535
536/// One article of the folio: a speaker's contribution, or a note the harness
537/// wrote into the session. Folding the wire-level stream into panels is where
538/// scaffolding is filtered, tool results are reunited with the assistant that
539/// called them, and injected context is lifted out of the conversation, so the
540/// renderer walks an already-clean stream and never re-derives any of it.
541#[derive(Debug)]
542pub enum Panel {
543    Speech(Speech),
544    Gloss(GlossPanel),
545}
546
547impl Panel {
548    /// The assistant speech this panel is, for a tool result looking for the
549    /// call it answers. A gloss never absorbs one: a hook firing between a call
550    /// and its result does not make the result the hook's.
551    fn answering_assistant(&mut self) -> Option<&mut Speech> {
552        match self {
553            Panel::Speech(speech) if speech.role == Role::Assistant => Some(speech),
554            _ => None,
555        }
556    }
557
558    fn as_gloss(&self) -> Option<&GlossPanel> {
559        match self {
560            Panel::Gloss(gloss) => Some(gloss),
561            Panel::Speech(_) => None,
562        }
563    }
564
565    fn as_gloss_mut(&mut self) -> Option<&mut GlossPanel> {
566        match self {
567            Panel::Gloss(gloss) => Some(gloss),
568            Panel::Speech(_) => None,
569        }
570    }
571
572    pub fn is_sidechain(&self) -> bool {
573        match self {
574            Panel::Speech(speech) => speech.is_sidechain,
575            Panel::Gloss(gloss) => gloss.is_sidechain,
576        }
577    }
578
579    pub fn kind(&self) -> PanelKind {
580        match self {
581            Panel::Speech(speech) => speech.kind(),
582            Panel::Gloss(gloss) => PanelKind::Gloss(gloss.gloss.kind),
583        }
584    }
585
586    pub fn timestamp(&self) -> Timestamp {
587        match self {
588            Panel::Speech(speech) => speech.timestamp,
589            Panel::Gloss(gloss) => gloss.timestamp,
590        }
591    }
592
593    pub fn turn_number(&self) -> usize {
594        match self {
595            Panel::Speech(speech) => speech.turn_number,
596            Panel::Gloss(gloss) => gloss.turn_number,
597        }
598    }
599}
600
601/// One speaker's contribution as displayed.
602#[derive(Debug)]
603pub struct Speech {
604    /// The 1-based position of this panel's leading turn in the raw stream.
605    /// Gaps between successive panels mark turns that were folded in or
606    /// dropped, so a panel that spans several turns still has one stable label.
607    pub turn_number: usize,
608    pub role: Role,
609    pub timestamp: Timestamp,
610    pub model: Option<String>,
611    /// How hard the model was asked to think, where the harness records it.
612    pub effort: Option<String>,
613    pub blocks: Vec<Block>,
614    /// What the panel's leading turn cost. The tool-result turns folded in
615    /// carry none of their own: the assistant turn that called the tool is
616    /// where the harness records what the exchange cost.
617    pub usage: Option<Usage>,
618    /// True for panels belonging to a subagent running inside this session.
619    pub is_sidechain: bool,
620}
621
622impl Speech {
623    fn of(turn: &Turn, turn_number: usize, blocks: Vec<Block>) -> Self {
624        Self {
625            turn_number,
626            role: turn.role,
627            timestamp: turn.timestamp,
628            model: turn.model.clone(),
629            effort: turn.effort.clone(),
630            blocks,
631            usage: turn.usage,
632            is_sidechain: turn.is_sidechain,
633        }
634    }
635
636    /// The panel's content kind, preferring the most user-facing thing it
637    /// carries: visible prose reads as the speaker, otherwise a tool exchange,
638    /// otherwise bare reasoning.
639    pub fn kind(&self) -> PanelKind {
640        let speaker = match self.role {
641            Role::User => PanelKind::User,
642            Role::Assistant => PanelKind::Assistant,
643        };
644        if self.blocks.iter().any(Block::is_visible_text) {
645            speaker
646        } else if self.blocks.iter().any(Block::is_tool) {
647            PanelKind::Tool
648        } else if self.blocks.iter().any(Block::is_thinking) {
649            PanelKind::Thinking
650        } else {
651            speaker
652        }
653    }
654}
655
656/// One harness note as displayed.
657#[derive(Debug)]
658pub struct GlossPanel {
659    pub turn_number: usize,
660    pub timestamp: Timestamp,
661    pub is_sidechain: bool,
662    pub gloss: Gloss,
663    /// What this panel's leading line was called, so a note written about that
664    /// line can be gathered into it.
665    uuid: Option<String>,
666}
667
668impl GlossPanel {
669    fn of(glossed: &Glossed, turn_number: usize) -> Self {
670        Self {
671            turn_number,
672            timestamp: glossed.timestamp,
673            is_sidechain: glossed.is_sidechain,
674            gloss: glossed.gloss.clone(),
675            uuid: glossed.uuid.clone(),
676        }
677    }
678
679    /// True when the note belongs in this panel rather than one of its own: one
680    /// firing of a hook writes several lines, and a slash command's output is
681    /// written as a line naming the command's own.
682    fn gathers(&self, glossed: &Glossed) -> bool {
683        if self.gloss.same_firing_as(&glossed.gloss) {
684            return true;
685        }
686        matches!((&self.uuid, &glossed.answers), (Some(mine), Some(theirs)) if mine == theirs)
687    }
688}
689
690#[derive(Debug, Clone, Copy, PartialEq, Eq)]
691pub enum Role {
692    User,
693    Assistant,
694}
695
696impl Role {
697    pub fn as_str(self) -> &'static str {
698        match self {
699            Role::User => "user",
700            Role::Assistant => "assistant",
701        }
702    }
703}
704
705/// One line of a session JSONL file.
706#[derive(Debug, Deserialize)]
707#[serde(tag = "type")]
708enum Entry {
709    #[serde(rename = "user")]
710    User(RawTurn),
711    #[serde(rename = "assistant")]
712    Assistant(RawTurn),
713    /// Attachment lines: a message the user queued mid-response, the notes the
714    /// harness wrote into the session (hook output, the memory files it pulled
715    /// in, plan-mode boundaries), and the scaffolding [`gloss::attachment`]
716    /// leaves unset.
717    #[serde(rename = "attachment")]
718    Attachment(RawAttachment),
719    /// What the harness said in its own voice. Most of it is bookkeeping it
720    /// keeps for itself; [`gloss::system`] picks out the rest.
721    #[serde(rename = "system")]
722    System(Value),
723    /// Lines that carry no conversation: mode changes, file-history snapshots,
724    /// and whatever else gets added later.
725    #[serde(other)]
726    Bookkeeping,
727}
728
729/// The note a `system` line carries, where it carries one and records when it
730/// was said. A line with no timestamp has no place in the stream to sit at.
731fn glossed_system(line: &Value) -> Option<Recorded> {
732    let timestamp = line.get("timestamp")?.as_str()?.parse().ok()?;
733    let named = |field| line.get(field).and_then(Value::as_str).map(str::to_owned);
734    Some(Recorded::Gloss(Glossed {
735        timestamp,
736        is_sidechain: line
737            .get("isSidechain")
738            .and_then(Value::as_bool)
739            .unwrap_or(false),
740        gloss: gloss::system(line)?,
741        uuid: named("uuid"),
742        answers: named("parentUuid"),
743    }))
744}
745
746#[derive(Debug, Deserialize)]
747struct RawTurn {
748    timestamp: Timestamp,
749    message: Message,
750    /// Recorded beside the message rather than in it, and only by harness
751    /// versions that track it.
752    effort: Option<String>,
753    #[serde(default, rename = "isSidechain")]
754    is_sidechain: bool,
755    #[serde(default, rename = "isMeta")]
756    is_meta: bool,
757    #[serde(default)]
758    uuid: Option<String>,
759}
760
761impl RawTurn {
762    fn into_turn(self, role: Role) -> Turn {
763        Turn {
764            role,
765            timestamp: self.timestamp,
766            model: self.message.model,
767            effort: self.effort,
768            content: self.message.content,
769            usage: self.message.usage,
770            is_sidechain: self.is_sidechain,
771            is_meta: self.is_meta,
772            uuid: self.uuid,
773        }
774    }
775}
776
777/// An `attachment` line. The body stays raw JSON because its shapes are the
778/// harness's rather than a contract: [`gloss::attachment`] reads each leniently
779/// and leaves unset what it doesn't recognise, so an attachment that grows a
780/// field can never abort a render.
781#[derive(Debug, Deserialize)]
782struct RawAttachment {
783    timestamp: Timestamp,
784    #[serde(default, rename = "isSidechain")]
785    is_sidechain: bool,
786    #[serde(default)]
787    uuid: Option<String>,
788    attachment: Value,
789}
790
791impl RawAttachment {
792    /// What this attachment contributes to the session: a user turn for the
793    /// message the user queued while the assistant was still working (real
794    /// conversation the harness records here rather than as a `user` line), a
795    /// gloss for the notes it wrote into the session, and nothing for the
796    /// scaffolding.
797    fn into_recorded(self) -> Option<Recorded> {
798        if self.attachment.get("type").and_then(Value::as_str) == Some("queued_command")
799            && let Some(prompt) = self.attachment.get("prompt").and_then(Value::as_str)
800        {
801            return Some(Recorded::Turn(Turn {
802                role: Role::User,
803                timestamp: self.timestamp,
804                model: None,
805                effort: None,
806                content: Content::Text(prompt.to_owned()),
807                usage: None,
808                is_sidechain: self.is_sidechain,
809                is_meta: false,
810                uuid: self.uuid,
811            }));
812        }
813        Some(Recorded::Gloss(Glossed {
814            timestamp: self.timestamp,
815            is_sidechain: self.is_sidechain,
816            gloss: gloss::attachment(&self.attachment)?,
817            uuid: self.uuid,
818            // A hook's notes are gathered by the firing they name rather than
819            // by the line they were written about.
820            answers: None,
821        }))
822    }
823}
824
825#[derive(Debug, Deserialize)]
826struct Message {
827    content: Content,
828    model: Option<String>,
829    usage: Option<Usage>,
830    /// The API response this line belongs to. Several lines share one, since a
831    /// response is written a block at a time.
832    id: Option<String>,
833}
834
835#[derive(Debug, Clone, Deserialize)]
836#[serde(untagged)]
837pub enum Content {
838    Text(String),
839    Blocks(Vec<Block>),
840}
841
842impl Content {
843    /// What this turn says in words, or `None` when it carries anything that
844    /// isn't text. The harness writes a turn's text as a bare string or as text
845    /// blocks depending on how it arrived, which is a fact about the recording
846    /// rather than about what was said.
847    fn spoken(&self) -> Option<Cow<'_, str>> {
848        match self {
849            Content::Text(text) => Some(Cow::Borrowed(text)),
850            Content::Blocks(blocks) => blocks
851                .iter()
852                .map(|block| match block {
853                    Block::Known(Known::Text { text }) => Some(text.as_str()),
854                    _ => None,
855                })
856                .collect::<Option<Vec<&str>>>()
857                .filter(|spoken| !spoken.is_empty())
858                .map(|spoken| Cow::Owned(spoken.join("\n"))),
859        }
860    }
861
862    fn is_only_tool_results(&self) -> bool {
863        match self {
864            Content::Text(_) => false,
865            Content::Blocks(blocks) => !blocks.is_empty() && blocks.iter().all(Block::is_result),
866        }
867    }
868}
869
870impl Block {
871    /// The two halves of a tool exchange, paired so a reader of the code (and a
872    /// test) can weigh a panel's calls against the results that reached it.
873    pub fn is_call(&self) -> bool {
874        matches!(self, Block::Known(Known::ToolUse { .. }))
875    }
876
877    pub fn is_result(&self) -> bool {
878        matches!(self, Block::Known(Known::ToolResult { .. }))
879    }
880
881    /// The call this block answers, for a result that names one.
882    fn answering(&self) -> Option<&str> {
883        match self {
884            Block::Known(Known::ToolResult {
885                tool_use_id: Some(id),
886                ..
887            }) => Some(id.as_str()),
888            _ => None,
889        }
890    }
891
892    fn is_tool(&self) -> bool {
893        matches!(
894            self,
895            Block::Known(Known::ToolUse { .. } | Known::ToolResult { .. })
896        )
897    }
898
899    fn is_thinking(&self) -> bool {
900        matches!(self, Block::Known(Known::Thinking { .. }))
901    }
902
903    pub(crate) fn is_visible_text(&self) -> bool {
904        matches!(self, Block::Known(Known::Text { text }) if !text.trim().is_empty())
905    }
906}
907
908/// What a panel actually shows, so a label can say more than "assistant": the
909/// role already has a colour, so the label names the content instead. A gloss
910/// is labelled by what wrote it rather than by what it holds.
911#[derive(Debug, Clone, Copy, PartialEq, Eq)]
912pub enum PanelKind {
913    User,
914    Assistant,
915    Tool,
916    Thinking,
917    Gloss(GlossKind),
918}
919
920/// Which side of the exchange a panel is on.
921///
922/// This is the folio's one organising axis, and it is declared here so nothing
923/// else has to restate it: the stylesheet pitches a kind's pigment warm or cool
924/// by it, the dock steps along it, and the label a reader sees is the same
925/// classification the code holds. A new [`PanelKind`] answers this question
926/// before it is given a colour or a name.
927#[derive(Debug, Clone, Copy, PartialEq, Eq)]
928pub enum Side {
929    /// What the model produced: what it said, its reasoning, the tools it
930    /// reached for. Set in the warm hues.
931    Model,
932    /// What reached the model from outside it: what the user said, the commands
933    /// they typed, the skills they wrote, the hooks that answer for them. Set in
934    /// the cool hues.
935    Entered,
936    /// Neither, deliberately. A plan boundary marks a division in the text
937    /// rather than anything said in it, and a rule or a passing note is ambient:
938    /// colouring every kind would leave nothing quiet, and stepping to every
939    /// kind would make the dock no faster than scrolling.
940    Aside,
941}
942
943impl Side {
944    pub fn label(self) -> &'static str {
945        match self {
946            Side::Model => "model",
947            Side::Entered => "entered",
948            Side::Aside => "aside",
949        }
950    }
951}
952
953impl PanelKind {
954    /// Every kind a panel can be, in the order the key reads them: down the cool
955    /// column, then down the warm one. The key builds its chips from this rather
956    /// than restating the list, so a new kind reaches it by being declared here
957    /// rather than by being remembered in the markup as well.
958    ///
959    /// The two halves are five and five so the key is a clean grid, which is why
960    /// `note` sits at the foot of the warm column despite being [`Side::Aside`].
961    /// Nothing reads the *order* as a classification: every chip carries its own
962    /// side, so the dock still steps past `note` and its neutral ink still keeps
963    /// it from reading as the model's.
964    pub const EVERY: [PanelKind; 10] = [
965        PanelKind::User,
966        PanelKind::Gloss(GlossKind::Command),
967        PanelKind::Gloss(GlossKind::Skill),
968        PanelKind::Gloss(GlossKind::Hook),
969        PanelKind::Gloss(GlossKind::Rule),
970        PanelKind::Assistant,
971        PanelKind::Thinking,
972        PanelKind::Tool,
973        PanelKind::Gloss(GlossKind::Plan),
974        PanelKind::Gloss(GlossKind::Note),
975    ];
976
977    pub fn label(self) -> &'static str {
978        match self {
979            PanelKind::User => "user",
980            PanelKind::Assistant => "assistant",
981            PanelKind::Tool => "tool",
982            PanelKind::Thinking => "thinking",
983            PanelKind::Gloss(kind) => kind.label(),
984        }
985    }
986
987    /// Which side of the exchange this kind belongs to. Exhaustive on purpose:
988    /// adding a kind will not compile until its side is decided.
989    ///
990    /// A rule is the user's own writing pulled into the conversation, so it is
991    /// theirs however the harness fetched it. A plan boundary belongs to the
992    /// model: the mode is the user's to ask for, but entering and leaving it is
993    /// the model reporting on its own working, which is why it reads beside the
994    /// reasoning rather than beside the asking.
995    pub fn side(self) -> Side {
996        match self {
997            PanelKind::Assistant | PanelKind::Tool | PanelKind::Thinking => Side::Model,
998            PanelKind::Gloss(GlossKind::Plan) => Side::Model,
999            PanelKind::User => Side::Entered,
1000            PanelKind::Gloss(
1001                GlossKind::Command | GlossKind::Skill | GlossKind::Hook | GlossKind::Rule,
1002            ) => Side::Entered,
1003            PanelKind::Gloss(GlossKind::Note) => Side::Aside,
1004        }
1005    }
1006}
1007
1008/// A content block, or the raw JSON of one this version doesn't recognize.
1009///
1010/// Claude Code's transcript format grows new block types; encountering one is
1011/// a producer adding something optional, not malformed input, so it renders as
1012/// JSON instead of aborting the folio.
1013#[derive(Debug, Clone, Deserialize)]
1014#[serde(untagged)]
1015pub enum Block {
1016    Known(Known),
1017    Unknown(Value),
1018}
1019
1020#[derive(Debug, Clone, Deserialize)]
1021#[serde(tag = "type", rename_all = "snake_case")]
1022pub enum Known {
1023    Text {
1024        text: String,
1025    },
1026    Thinking {
1027        thinking: String,
1028    },
1029    ToolUse {
1030        /// What the result answering this call points back to.
1031        id: Option<String>,
1032        name: String,
1033        input: Value,
1034    },
1035    ToolResult {
1036        tool_use_id: Option<String>,
1037        content: ToolResultContent,
1038        #[serde(default)]
1039        is_error: bool,
1040        /// The call this answers. Never on the wire: it is resolved when the
1041        /// result is folded into the panel, so the renderer walks a stream
1042        /// where every result already knows which tool produced it.
1043        #[serde(skip)]
1044        answers: Option<Answered>,
1045    },
1046    Image {
1047        source: ImageSource,
1048    },
1049}
1050
1051#[derive(Debug, Clone, Deserialize)]
1052#[serde(untagged)]
1053pub enum ToolResultContent {
1054    Text(String),
1055    Blocks(Vec<Block>),
1056}
1057
1058#[derive(Debug, Clone, Deserialize)]
1059pub struct ImageSource {
1060    pub media_type: String,
1061    pub data: String,
1062}