1use std::{
4 collections::{HashMap, HashSet},
5 fs,
6 path::{Path, PathBuf},
7};
8
9use anyhow::{Context, Result};
10use jiff::Timestamp;
11use serde::Deserialize;
12use serde_json::Value;
13
14use crate::tools;
15
16#[derive(Debug)]
18pub struct Folio {
19 pub source: PathBuf,
20 pub turns: Vec<Turn>,
21}
22
23impl Folio {
24 pub fn read(source: &Path) -> Result<Self> {
27 let text = fs::read_to_string(source)
28 .with_context(|| format!("reading session {}", source.display()))?;
29
30 let mut turns = Vec::new();
31 let mut counted = HashSet::new();
36 for (index, line) in text.lines().enumerate() {
37 if line.trim().is_empty() {
38 continue;
39 }
40 let entry: Entry = serde_json::from_str(line)
41 .with_context(|| format!("{}:{}", source.display(), index + 1))?;
42 match entry {
43 Entry::User(turn) => turns.push(turn.into_turn(Role::User)),
44 Entry::Assistant(raw) => {
45 let opens_response = raw
46 .message
47 .id
48 .as_deref()
49 .is_none_or(|id| counted.insert(id.to_owned()));
50 let turn = raw.into_turn(Role::Assistant);
51 turns.push(Turn {
52 usage: turn.usage.filter(|_| opens_response),
53 ..turn
54 });
55 }
56 Entry::Attachment(attachment) => turns.extend(attachment.into_turn()),
57 Entry::Bookkeeping => {}
58 }
59 }
60
61 Ok(Self {
62 source: source.to_path_buf(),
63 turns,
64 })
65 }
66
67 pub fn session_id(&self) -> &str {
68 self.source
69 .file_stem()
70 .and_then(|stem| stem.to_str())
71 .unwrap_or("session")
72 }
73
74 pub fn peek(source: &Path) -> SessionPeek {
80 let Ok(text) = fs::read_to_string(source) else {
81 return SessionPeek::default();
82 };
83
84 let mut cwd = None;
85 let mut ai_title = None;
86 let mut first_prompt = None;
87 for line in text.lines() {
88 let line = line.trim();
89 if line.is_empty() {
90 continue;
91 }
92 let Ok(value) = serde_json::from_str::<Value>(line) else {
93 continue;
94 };
95 if cwd.is_none()
96 && let Some(dir) = value.get("cwd").and_then(Value::as_str)
97 {
98 cwd = Some(PathBuf::from(dir));
99 }
100 match value.get("type").and_then(Value::as_str) {
101 Some("ai-title") => {
104 if let Some(title) = value.get("aiTitle").and_then(Value::as_str) {
105 ai_title = Some(title.to_owned());
106 }
107 }
108 Some("user") if first_prompt.is_none() && !is_meta(&value) => {
109 first_prompt = user_prompt(&value);
110 }
111 _ => {}
112 }
113 }
114
115 SessionPeek {
116 cwd,
117 title: ai_title.or(first_prompt),
118 }
119 }
120
121 pub fn output(&self) -> Option<u64> {
124 self.turns
125 .iter()
126 .filter_map(|turn| turn.usage)
127 .map(|usage| usage.output_tokens)
128 .reduce(|total, output| total + output)
129 }
130
131 pub fn largest_input(&self) -> Option<u64> {
136 self.turns
137 .iter()
138 .filter_map(|turn| turn.usage)
139 .map(|usage| usage.input())
140 .max()
141 }
142
143 pub fn panels(&self) -> Vec<Panel> {
147 let calls = self.calls();
148 let mut panels: Vec<Panel> = Vec::new();
149 for (index, turn) in self.turns.iter().enumerate() {
150 if turn.is_clear_command() {
151 continue;
152 }
153 let blocks = answered(turn.blocks(), &calls);
154 if blocks.is_empty() {
157 continue;
158 }
159 if turn.is_tool_response()
160 && let Some(assistant) = panels.last_mut().filter(|p| p.role == Role::Assistant)
161 {
162 assistant.blocks.extend(blocks);
163 continue;
164 }
165 panels.push(Panel::from_turn(turn, index + 1, blocks));
166 }
167 panels
168 }
169
170 fn calls(&self) -> HashMap<&str, Answered> {
174 self.turns
175 .iter()
176 .flat_map(|turn| match &turn.content {
177 Content::Text(_) => [].iter(),
178 Content::Blocks(blocks) => blocks.iter(),
179 })
180 .filter_map(|block| match block {
181 Block::Known(Known::ToolUse {
182 id: Some(id),
183 name,
184 input,
185 }) => Some((id.as_str(), Answered::of(name, input))),
186 _ => None,
187 })
188 .collect()
189 }
190}
191
192fn answered(mut blocks: Vec<Block>, calls: &HashMap<&str, Answered>) -> Vec<Block> {
196 for block in &mut blocks {
197 if let Block::Known(Known::ToolResult {
198 tool_use_id: Some(id),
199 answers,
200 ..
201 }) = block
202 {
203 *answers = calls.get(id.as_str()).cloned();
204 }
205 }
206 blocks.retain(|block| !is_acknowledgement(block));
207 blocks
208}
209
210fn is_acknowledgement(block: &Block) -> bool {
213 let Block::Known(Known::ToolResult {
214 content,
215 is_error: false,
216 answers: Some(answered),
217 ..
218 }) = block
219 else {
220 return false;
221 };
222 tools::spoken(content).is_ok_and(|text| tools::acknowledges(&answered.tool, &text))
223}
224
225#[derive(Debug, Clone, PartialEq, Eq)]
229pub struct Answered {
230 pub tool: String,
231 pub subject: Option<String>,
232}
233
234impl Answered {
235 fn of(tool: &str, input: &Value) -> Self {
236 Self {
237 tool: tool.to_owned(),
238 subject: input
239 .get("file_path")
240 .and_then(Value::as_str)
241 .map(str::to_owned),
242 }
243 }
244}
245
246#[derive(Debug, Default, PartialEq, Eq)]
249pub struct SessionPeek {
250 pub cwd: Option<PathBuf>,
254 pub title: Option<String>,
257}
258
259fn is_meta(entry: &Value) -> bool {
261 entry
262 .get("isMeta")
263 .and_then(Value::as_bool)
264 .unwrap_or(false)
265}
266
267fn user_prompt(entry: &Value) -> Option<String> {
271 const WRAPPERS: [&str; 4] = [
272 "<command-",
273 "<local-command-",
274 "<task-notification>",
275 "<system-reminder>",
276 ];
277
278 let content = entry.get("message")?.get("content")?;
279 let text = match content {
280 Value::String(text) => text.trim(),
281 Value::Array(blocks) => blocks
282 .iter()
283 .filter(|block| block.get("type").and_then(Value::as_str) == Some("text"))
284 .find_map(|block| block.get("text").and_then(Value::as_str))?
285 .trim(),
286 _ => return None,
287 };
288
289 let is_wrapper = WRAPPERS.iter().any(|tag| text.starts_with(tag));
290 (!text.is_empty() && !is_wrapper).then(|| text.to_owned())
291}
292
293#[derive(Debug)]
295pub struct Turn {
296 pub role: Role,
297 pub timestamp: Timestamp,
298 pub model: Option<String>,
299 pub effort: Option<String>,
302 pub content: Content,
303 pub usage: Option<Usage>,
307 pub is_sidechain: bool,
309 pub is_meta: bool,
311}
312
313#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
316pub struct Usage {
317 pub input_tokens: u64,
318 pub output_tokens: u64,
319 pub cache_creation_input_tokens: u64,
320 pub cache_read_input_tokens: u64,
321}
322
323impl Usage {
324 pub fn input(&self) -> u64 {
330 self.input_tokens + self.cache_creation_input_tokens + self.cache_read_input_tokens
331 }
332
333 pub fn uncached_input(&self) -> u64 {
339 self.input_tokens + self.cache_creation_input_tokens
340 }
341}
342
343impl Turn {
344 pub fn is_tool_response(&self) -> bool {
348 self.role == Role::User && self.content.is_only_tool_results()
349 }
350
351 pub fn is_clear_command(&self) -> bool {
355 matches!(&self.content, Content::Text(text)
356 if text.contains("<command-name>/clear</command-name>"))
357 }
358
359 fn blocks(&self) -> Vec<Block> {
362 match &self.content {
363 Content::Text(text) => vec![Block::Known(Known::Text { text: text.clone() })],
364 Content::Blocks(blocks) => blocks.clone(),
365 }
366 }
367}
368
369#[derive(Debug)]
374pub struct Panel {
375 pub turn_number: usize,
379 pub role: Role,
380 pub timestamp: Timestamp,
381 pub model: Option<String>,
382 pub effort: Option<String>,
384 pub blocks: Vec<Block>,
385 pub usage: Option<Usage>,
389 pub is_sidechain: bool,
391 pub is_meta: bool,
393}
394
395impl Panel {
396 fn from_turn(turn: &Turn, turn_number: usize, blocks: Vec<Block>) -> Self {
397 Self {
398 turn_number,
399 role: turn.role,
400 timestamp: turn.timestamp,
401 model: turn.model.clone(),
402 effort: turn.effort.clone(),
403 blocks,
404 usage: turn.usage,
405 is_sidechain: turn.is_sidechain,
406 is_meta: turn.is_meta,
407 }
408 }
409
410 pub fn kind(&self) -> PanelKind {
414 let speaker = match self.role {
415 Role::User => PanelKind::User,
416 Role::Assistant => PanelKind::Assistant,
417 };
418 if self.blocks.iter().any(Block::is_visible_text) {
419 speaker
420 } else if self.blocks.iter().any(Block::is_tool) {
421 PanelKind::Tool
422 } else if self.blocks.iter().any(Block::is_thinking) {
423 PanelKind::Thinking
424 } else {
425 speaker
426 }
427 }
428}
429
430#[derive(Debug, Clone, Copy, PartialEq, Eq)]
431pub enum Role {
432 User,
433 Assistant,
434}
435
436impl Role {
437 pub fn as_str(self) -> &'static str {
438 match self {
439 Role::User => "user",
440 Role::Assistant => "assistant",
441 }
442 }
443}
444
445#[derive(Debug, Deserialize)]
447#[serde(tag = "type")]
448enum Entry {
449 #[serde(rename = "user")]
450 User(RawTurn),
451 #[serde(rename = "assistant")]
452 Assistant(RawTurn),
453 #[serde(rename = "attachment")]
458 Attachment(RawAttachment),
459 #[serde(other)]
462 Bookkeeping,
463}
464
465#[derive(Debug, Deserialize)]
466struct RawTurn {
467 timestamp: Timestamp,
468 message: Message,
469 effort: Option<String>,
472 #[serde(default, rename = "isSidechain")]
473 is_sidechain: bool,
474 #[serde(default, rename = "isMeta")]
475 is_meta: bool,
476}
477
478impl RawTurn {
479 fn into_turn(self, role: Role) -> Turn {
480 Turn {
481 role,
482 timestamp: self.timestamp,
483 model: self.message.model,
484 effort: self.effort,
485 content: self.message.content,
486 usage: self.message.usage,
487 is_sidechain: self.is_sidechain,
488 is_meta: self.is_meta,
489 }
490 }
491}
492
493#[derive(Debug, Deserialize)]
496struct RawAttachment {
497 timestamp: Timestamp,
498 attachment: AttachmentBody,
499}
500
501impl RawAttachment {
502 fn into_turn(self) -> Option<Turn> {
505 let AttachmentBody::QueuedCommand { prompt } = self.attachment else {
506 return None;
507 };
508 Some(Turn {
509 role: Role::User,
510 timestamp: self.timestamp,
511 model: None,
512 effort: None,
513 content: Content::Text(prompt),
514 usage: None,
515 is_sidechain: false,
516 is_meta: false,
517 })
518 }
519}
520
521#[derive(Debug, Deserialize)]
522#[serde(tag = "type", rename_all = "snake_case")]
523enum AttachmentBody {
524 QueuedCommand { prompt: String },
527 #[serde(other)]
530 Other,
531}
532
533#[derive(Debug, Deserialize)]
534struct Message {
535 content: Content,
536 model: Option<String>,
537 usage: Option<Usage>,
538 id: Option<String>,
541}
542
543#[derive(Debug, Clone, Deserialize)]
544#[serde(untagged)]
545pub enum Content {
546 Text(String),
547 Blocks(Vec<Block>),
548}
549
550impl Content {
551 fn is_only_tool_results(&self) -> bool {
552 match self {
553 Content::Text(_) => false,
554 Content::Blocks(blocks) => {
555 !blocks.is_empty() && blocks.iter().all(Block::is_tool_result)
556 }
557 }
558 }
559}
560
561impl Block {
562 fn is_tool_result(&self) -> bool {
563 matches!(self, Block::Known(Known::ToolResult { .. }))
564 }
565
566 fn is_tool(&self) -> bool {
567 matches!(
568 self,
569 Block::Known(Known::ToolUse { .. } | Known::ToolResult { .. })
570 )
571 }
572
573 fn is_thinking(&self) -> bool {
574 matches!(self, Block::Known(Known::Thinking { .. }))
575 }
576
577 pub(crate) fn is_visible_text(&self) -> bool {
578 matches!(self, Block::Known(Known::Text { text }) if !text.trim().is_empty())
579 }
580}
581
582#[derive(Debug, Clone, Copy, PartialEq, Eq)]
585pub enum PanelKind {
586 User,
587 Assistant,
588 Tool,
589 Thinking,
590}
591
592impl PanelKind {
593 pub fn label(self) -> &'static str {
594 match self {
595 PanelKind::User => "user",
596 PanelKind::Assistant => "assistant",
597 PanelKind::Tool => "tool",
598 PanelKind::Thinking => "thinking",
599 }
600 }
601}
602
603#[derive(Debug, Clone, Deserialize)]
609#[serde(untagged)]
610pub enum Block {
611 Known(Known),
612 Unknown(Value),
613}
614
615#[derive(Debug, Clone, Deserialize)]
616#[serde(tag = "type", rename_all = "snake_case")]
617pub enum Known {
618 Text {
619 text: String,
620 },
621 Thinking {
622 thinking: String,
623 },
624 ToolUse {
625 id: Option<String>,
627 name: String,
628 input: Value,
629 },
630 ToolResult {
631 tool_use_id: Option<String>,
632 content: ToolResultContent,
633 #[serde(default)]
634 is_error: bool,
635 #[serde(skip)]
639 answers: Option<Answered>,
640 },
641 Image {
642 source: ImageSource,
643 },
644}
645
646#[derive(Debug, Clone, Deserialize)]
647#[serde(untagged)]
648pub enum ToolResultContent {
649 Text(String),
650 Blocks(Vec<Block>),
651}
652
653#[derive(Debug, Clone, Deserialize)]
654pub struct ImageSource {
655 pub media_type: String,
656 pub data: String,
657}