Skip to main content

claude_scriptorium/
transcript.rs

1//! Parsing of Claude Code session JSONL into typed conversation values.
2
3use std::{
4    fs,
5    path::{Path, PathBuf},
6};
7
8use anyhow::{Context, Result};
9use jiff::Timestamp;
10use serde::Deserialize;
11use serde_json::Value;
12
13/// One rendered session: the unit the tool turns into a single HTML file.
14#[derive(Debug)]
15pub struct Folio {
16    pub source: PathBuf,
17    pub turns: Vec<Turn>,
18}
19
20impl Folio {
21    /// Reads a session JSONL file, keeping only the lines that carry
22    /// conversation.
23    pub fn read(source: &Path) -> Result<Self> {
24        let text = fs::read_to_string(source)
25            .with_context(|| format!("reading session {}", source.display()))?;
26
27        let mut turns = Vec::new();
28        for (index, line) in text.lines().enumerate() {
29            if line.trim().is_empty() {
30                continue;
31            }
32            let entry: Entry = serde_json::from_str(line)
33                .with_context(|| format!("{}:{}", source.display(), index + 1))?;
34            match entry {
35                Entry::User(turn) => turns.push(turn.into_turn(Role::User)),
36                Entry::Assistant(turn) => turns.push(turn.into_turn(Role::Assistant)),
37                Entry::Attachment(attachment) => turns.extend(attachment.into_turn()),
38                Entry::Bookkeeping => {}
39            }
40        }
41
42        Ok(Self {
43            source: source.to_path_buf(),
44            turns,
45        })
46    }
47
48    pub fn session_id(&self) -> &str {
49        self.source
50            .file_stem()
51            .and_then(|stem| stem.to_str())
52            .unwrap_or("session")
53    }
54
55    /// Cheaply scans a session's listing metadata (its title and working
56    /// directory) without parsing the conversation, tolerating malformed lines
57    /// so one bad session never breaks a picker that lists every session. This
58    /// is deliberately lenient where [`Folio::read`] is strict: a label is
59    /// best-effort, a render is not.
60    pub fn peek(source: &Path) -> SessionPeek {
61        let Ok(text) = fs::read_to_string(source) else {
62            return SessionPeek::default();
63        };
64
65        let mut cwd = None;
66        let mut ai_title = None;
67        let mut first_prompt = None;
68        for line in text.lines() {
69            let line = line.trim();
70            if line.is_empty() {
71                continue;
72            }
73            let Ok(value) = serde_json::from_str::<Value>(line) else {
74                continue;
75            };
76            if cwd.is_none()
77                && let Some(dir) = value.get("cwd").and_then(Value::as_str)
78            {
79                cwd = Some(PathBuf::from(dir));
80            }
81            match value.get("type").and_then(Value::as_str) {
82                // Claude rewrites the title as the session evolves, so the last
83                // one wins: it is the summary Claude Code shows in terminals.
84                Some("ai-title") => {
85                    if let Some(title) = value.get("aiTitle").and_then(Value::as_str) {
86                        ai_title = Some(title.to_owned());
87                    }
88                }
89                Some("user") if first_prompt.is_none() && !is_meta(&value) => {
90                    first_prompt = user_prompt(&value);
91                }
92                _ => {}
93            }
94        }
95
96        SessionPeek {
97            cwd,
98            title: ai_title.or(first_prompt),
99        }
100    }
101
102    /// Folds the raw turns into the display stream: drops `/clear` boundaries
103    /// and merges each tool-result turn back into the assistant turn it
104    /// answers, so a call and its result render as one panel.
105    pub fn panels(&self) -> Vec<Panel> {
106        let mut panels: Vec<Panel> = Vec::new();
107        for (index, turn) in self.turns.iter().enumerate() {
108            if turn.is_clear_command() {
109                continue;
110            }
111            if turn.is_tool_response()
112                && let Some(assistant) = panels.last_mut().filter(|p| p.role == Role::Assistant)
113            {
114                assistant.blocks.extend(turn.blocks());
115                continue;
116            }
117            panels.push(Panel::from_turn(turn, index + 1));
118        }
119        panels
120    }
121}
122
123/// A session's listing metadata, scanned by [`Folio::peek`] for pickers and
124/// indexes that show sessions without rendering them.
125#[derive(Debug, Default, PartialEq, Eq)]
126pub struct SessionPeek {
127    /// The directory the session ran in, recovered from the transcript because
128    /// the encoded project-dir name flattens separators and can't be decoded
129    /// back to a real path.
130    pub cwd: Option<PathBuf>,
131    /// A human label for the session: Claude's own `ai-title`, falling back to
132    /// the first prose the user typed when the session has no title yet.
133    pub title: Option<String>,
134}
135
136/// True when a turn was injected by the harness rather than typed by the user.
137fn is_meta(entry: &Value) -> bool {
138    entry
139        .get("isMeta")
140        .and_then(Value::as_bool)
141        .unwrap_or(false)
142}
143
144/// The first prose from a user turn, or `None` when it carries only a
145/// harness-injected command wrapper, notification, or reminder: those open with
146/// an XML-ish tag rather than something the user actually wrote.
147fn user_prompt(entry: &Value) -> Option<String> {
148    const WRAPPERS: [&str; 4] = [
149        "<command-",
150        "<local-command-",
151        "<task-notification>",
152        "<system-reminder>",
153    ];
154
155    let content = entry.get("message")?.get("content")?;
156    let text = match content {
157        Value::String(text) => text.trim(),
158        Value::Array(blocks) => blocks
159            .iter()
160            .filter(|block| block.get("type").and_then(Value::as_str) == Some("text"))
161            .find_map(|block| block.get("text").and_then(Value::as_str))?
162            .trim(),
163        _ => return None,
164    };
165
166    let is_wrapper = WRAPPERS.iter().any(|tag| text.starts_with(tag));
167    (!text.is_empty() && !is_wrapper).then(|| text.to_owned())
168}
169
170/// A conversation turn, with the role lifted out of the JSONL's `type` tag.
171#[derive(Debug)]
172pub struct Turn {
173    pub role: Role,
174    pub timestamp: Timestamp,
175    pub model: Option<String>,
176    pub content: Content,
177    /// True for turns belonging to a subagent running inside this session.
178    pub is_sidechain: bool,
179    /// True for turns the harness injected rather than the user typing them.
180    pub is_meta: bool,
181}
182
183impl Turn {
184    /// True when a `user`-role turn carries only tool results: the harness
185    /// returning tool output, not the user typing. These read as a
186    /// continuation of the assistant's turn, not a message of their own.
187    pub fn is_tool_response(&self) -> bool {
188        self.role == Role::User && self.content.is_only_tool_results()
189    }
190
191    /// True when this turn is the `/clear` slash command, which resets the
192    /// context: a session boundary the harness records as a user turn, with
193    /// no conversation of its own worth showing.
194    pub fn is_clear_command(&self) -> bool {
195        matches!(&self.content, Content::Text(text)
196            if text.contains("<command-name>/clear</command-name>"))
197    }
198
199    /// The content as a uniform block list, lifting a plain string into a
200    /// single text block so every panel is a sequence of blocks.
201    fn blocks(&self) -> Vec<Block> {
202        match &self.content {
203            Content::Text(text) => vec![Block::Known(Known::Text { text: text.clone() })],
204            Content::Blocks(blocks) => blocks.clone(),
205        }
206    }
207}
208
209/// One speaker's contribution as displayed. Folding the wire-level turns into
210/// panels is where harness scaffolding is filtered and tool results are
211/// reunited with the assistant that called them, so the renderer walks an
212/// already-clean stream and never re-derives any of it.
213#[derive(Debug)]
214pub struct Panel {
215    /// The 1-based position of this panel's leading turn in the raw stream.
216    /// Gaps between successive panels mark turns that were folded in or
217    /// dropped, so a panel that spans several turns still has one stable label.
218    pub turn_number: usize,
219    pub role: Role,
220    pub timestamp: Timestamp,
221    pub model: Option<String>,
222    pub blocks: Vec<Block>,
223    /// True for panels belonging to a subagent running inside this session.
224    pub is_sidechain: bool,
225    /// True for panels the harness injected rather than the user typing them.
226    pub is_meta: bool,
227}
228
229impl Panel {
230    fn from_turn(turn: &Turn, turn_number: usize) -> Self {
231        Self {
232            turn_number,
233            role: turn.role,
234            timestamp: turn.timestamp,
235            model: turn.model.clone(),
236            blocks: turn.blocks(),
237            is_sidechain: turn.is_sidechain,
238            is_meta: turn.is_meta,
239        }
240    }
241
242    /// The panel's content kind, preferring the most user-facing thing it
243    /// carries: visible prose reads as the speaker, otherwise a tool exchange,
244    /// otherwise bare reasoning.
245    pub fn kind(&self) -> PanelKind {
246        let speaker = match self.role {
247            Role::User => PanelKind::User,
248            Role::Assistant => PanelKind::Assistant,
249        };
250        if self.blocks.iter().any(Block::is_visible_text) {
251            speaker
252        } else if self.blocks.iter().any(Block::is_tool) {
253            PanelKind::Tool
254        } else if self.blocks.iter().any(Block::is_thinking) {
255            PanelKind::Thinking
256        } else {
257            speaker
258        }
259    }
260}
261
262#[derive(Debug, Clone, Copy, PartialEq, Eq)]
263pub enum Role {
264    User,
265    Assistant,
266}
267
268impl Role {
269    pub fn as_str(self) -> &'static str {
270        match self {
271            Role::User => "user",
272            Role::Assistant => "assistant",
273        }
274    }
275}
276
277/// One line of a session JSONL file.
278#[derive(Debug, Deserialize)]
279#[serde(tag = "type")]
280enum Entry {
281    #[serde(rename = "user")]
282    User(RawTurn),
283    #[serde(rename = "assistant")]
284    Assistant(RawTurn),
285    /// Attachment lines. Most are scaffolding (hook output, task reminders,
286    /// memory), but a `queued_command` carries a message the user typed while
287    /// the assistant was still working: real conversation the harness records
288    /// here rather than as a `user` turn, so it must not be dropped.
289    #[serde(rename = "attachment")]
290    Attachment(RawAttachment),
291    /// Lines that carry no conversation: hook output, mode changes,
292    /// file-history snapshots, and whatever else gets added later.
293    #[serde(other)]
294    Bookkeeping,
295}
296
297#[derive(Debug, Deserialize)]
298struct RawTurn {
299    timestamp: Timestamp,
300    message: Message,
301    #[serde(default, rename = "isSidechain")]
302    is_sidechain: bool,
303    #[serde(default, rename = "isMeta")]
304    is_meta: bool,
305}
306
307impl RawTurn {
308    fn into_turn(self, role: Role) -> Turn {
309        Turn {
310            role,
311            timestamp: self.timestamp,
312            model: self.message.model,
313            content: self.message.content,
314            is_sidechain: self.is_sidechain,
315            is_meta: self.is_meta,
316        }
317    }
318}
319
320/// An `attachment` line. Only a `queued_command` body becomes a turn; every
321/// other attachment kind is scaffolding this drops.
322#[derive(Debug, Deserialize)]
323struct RawAttachment {
324    timestamp: Timestamp,
325    attachment: AttachmentBody,
326}
327
328impl RawAttachment {
329    /// A turn for a message the user queued mid-response, or `None` for any
330    /// other attachment kind.
331    fn into_turn(self) -> Option<Turn> {
332        let AttachmentBody::QueuedCommand { prompt } = self.attachment else {
333            return None;
334        };
335        Some(Turn {
336            role: Role::User,
337            timestamp: self.timestamp,
338            model: None,
339            content: Content::Text(prompt),
340            is_sidechain: false,
341            is_meta: false,
342        })
343    }
344}
345
346#[derive(Debug, Deserialize)]
347#[serde(tag = "type", rename_all = "snake_case")]
348enum AttachmentBody {
349    /// A message the user typed while the assistant was still working, dequeued
350    /// and processed later in the same session.
351    QueuedCommand { prompt: String },
352    /// Every other attachment kind: hook output, task reminders, memory, and
353    /// whatever else gets added later, none of it conversation.
354    #[serde(other)]
355    Other,
356}
357
358#[derive(Debug, Deserialize)]
359struct Message {
360    content: Content,
361    model: Option<String>,
362}
363
364#[derive(Debug, Clone, Deserialize)]
365#[serde(untagged)]
366pub enum Content {
367    Text(String),
368    Blocks(Vec<Block>),
369}
370
371impl Content {
372    fn is_only_tool_results(&self) -> bool {
373        match self {
374            Content::Text(_) => false,
375            Content::Blocks(blocks) => {
376                !blocks.is_empty() && blocks.iter().all(Block::is_tool_result)
377            }
378        }
379    }
380}
381
382impl Block {
383    fn is_tool_result(&self) -> bool {
384        matches!(self, Block::Known(Known::ToolResult { .. }))
385    }
386
387    fn is_tool(&self) -> bool {
388        matches!(
389            self,
390            Block::Known(Known::ToolUse { .. } | Known::ToolResult { .. })
391        )
392    }
393
394    fn is_thinking(&self) -> bool {
395        matches!(self, Block::Known(Known::Thinking { .. }))
396    }
397
398    pub(crate) fn is_visible_text(&self) -> bool {
399        matches!(self, Block::Known(Known::Text { text }) if !text.trim().is_empty())
400    }
401}
402
403/// What a panel actually shows, so a label can say more than "assistant": the
404/// role already has a colour, so the label names the content instead.
405#[derive(Debug, Clone, Copy, PartialEq, Eq)]
406pub enum PanelKind {
407    User,
408    Assistant,
409    Tool,
410    Thinking,
411}
412
413impl PanelKind {
414    pub fn label(self) -> &'static str {
415        match self {
416            PanelKind::User => "user",
417            PanelKind::Assistant => "assistant",
418            PanelKind::Tool => "tool",
419            PanelKind::Thinking => "thinking",
420        }
421    }
422}
423
424/// A content block, or the raw JSON of one this version doesn't recognize.
425///
426/// Claude Code's transcript format grows new block types; encountering one is
427/// a producer adding something optional, not malformed input, so it renders as
428/// JSON instead of aborting the folio.
429#[derive(Debug, Clone, Deserialize)]
430#[serde(untagged)]
431pub enum Block {
432    Known(Known),
433    Unknown(Value),
434}
435
436#[derive(Debug, Clone, Deserialize)]
437#[serde(tag = "type", rename_all = "snake_case")]
438pub enum Known {
439    Text {
440        text: String,
441    },
442    Thinking {
443        thinking: String,
444    },
445    ToolUse {
446        name: String,
447        input: Value,
448    },
449    ToolResult {
450        content: ToolResultContent,
451        #[serde(default)]
452        is_error: bool,
453    },
454    Image {
455        source: ImageSource,
456    },
457}
458
459#[derive(Debug, Clone, Deserialize)]
460#[serde(untagged)]
461pub enum ToolResultContent {
462    Text(String),
463    Blocks(Vec<Block>),
464}
465
466#[derive(Debug, Clone, Deserialize)]
467pub struct ImageSource {
468    pub media_type: String,
469    pub data: String,
470}