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}