use std::{
collections::{HashMap, HashSet},
fs,
path::{Path, PathBuf},
};
use anyhow::{Context, Result};
use jiff::Timestamp;
use serde::Deserialize;
use serde_json::Value;
use crate::tools;
#[derive(Debug)]
pub struct Folio {
pub source: PathBuf,
pub turns: Vec<Turn>,
}
impl Folio {
pub fn read(source: &Path) -> Result<Self> {
let text = fs::read_to_string(source)
.with_context(|| format!("reading session {}", source.display()))?;
let mut turns = Vec::new();
let mut counted = HashSet::new();
for (index, line) in text.lines().enumerate() {
if line.trim().is_empty() {
continue;
}
let entry: Entry = serde_json::from_str(line)
.with_context(|| format!("{}:{}", source.display(), index + 1))?;
match entry {
Entry::User(turn) => turns.push(turn.into_turn(Role::User)),
Entry::Assistant(raw) => {
let opens_response = raw
.message
.id
.as_deref()
.is_none_or(|id| counted.insert(id.to_owned()));
let turn = raw.into_turn(Role::Assistant);
turns.push(Turn {
usage: turn.usage.filter(|_| opens_response),
..turn
});
}
Entry::Attachment(attachment) => turns.extend(attachment.into_turn()),
Entry::Bookkeeping => {}
}
}
Ok(Self {
source: source.to_path_buf(),
turns,
})
}
pub fn session_id(&self) -> &str {
self.source
.file_stem()
.and_then(|stem| stem.to_str())
.unwrap_or("session")
}
pub fn peek(source: &Path) -> SessionPeek {
let Ok(text) = fs::read_to_string(source) else {
return SessionPeek::default();
};
let mut cwd = None;
let mut ai_title = None;
let mut first_prompt = None;
for line in text.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
let Ok(value) = serde_json::from_str::<Value>(line) else {
continue;
};
if cwd.is_none()
&& let Some(dir) = value.get("cwd").and_then(Value::as_str)
{
cwd = Some(PathBuf::from(dir));
}
match value.get("type").and_then(Value::as_str) {
Some("ai-title") => {
if let Some(title) = value.get("aiTitle").and_then(Value::as_str) {
ai_title = Some(title.to_owned());
}
}
Some("user") if first_prompt.is_none() && !is_meta(&value) => {
first_prompt = user_prompt(&value);
}
_ => {}
}
}
SessionPeek {
cwd,
title: ai_title.or(first_prompt),
}
}
pub fn output(&self) -> Option<u64> {
self.turns
.iter()
.filter_map(|turn| turn.usage)
.map(|usage| usage.output_tokens)
.reduce(|total, output| total + output)
}
pub fn largest_input(&self) -> Option<u64> {
self.turns
.iter()
.filter_map(|turn| turn.usage)
.map(|usage| usage.input())
.max()
}
pub fn panels(&self) -> Vec<Panel> {
let calls = self.calls();
let mut panels: Vec<Panel> = Vec::new();
for (index, turn) in self.turns.iter().enumerate() {
if turn.is_clear_command() {
continue;
}
let blocks = answered(turn.blocks(), &calls);
if blocks.is_empty() {
continue;
}
if turn.is_tool_response()
&& let Some(assistant) = panels.last_mut().filter(|p| p.role == Role::Assistant)
{
assistant.blocks.extend(blocks);
continue;
}
panels.push(Panel::from_turn(turn, index + 1, blocks));
}
panels
}
fn calls(&self) -> HashMap<&str, Answered> {
self.turns
.iter()
.flat_map(|turn| match &turn.content {
Content::Text(_) => [].iter(),
Content::Blocks(blocks) => blocks.iter(),
})
.filter_map(|block| match block {
Block::Known(Known::ToolUse {
id: Some(id),
name,
input,
}) => Some((id.as_str(), Answered::of(name, input))),
_ => None,
})
.collect()
}
}
fn answered(mut blocks: Vec<Block>, calls: &HashMap<&str, Answered>) -> Vec<Block> {
for block in &mut blocks {
if let Block::Known(Known::ToolResult {
tool_use_id: Some(id),
answers,
..
}) = block
{
*answers = calls.get(id.as_str()).cloned();
}
}
blocks.retain(|block| !is_acknowledgement(block));
blocks
}
fn is_acknowledgement(block: &Block) -> bool {
let Block::Known(Known::ToolResult {
content,
is_error: false,
answers: Some(answered),
..
}) = block
else {
return false;
};
tools::spoken(content).is_ok_and(|text| tools::acknowledges(&answered.tool, &text))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Answered {
pub tool: String,
pub subject: Option<String>,
}
impl Answered {
fn of(tool: &str, input: &Value) -> Self {
Self {
tool: tool.to_owned(),
subject: input
.get("file_path")
.and_then(Value::as_str)
.map(str::to_owned),
}
}
}
#[derive(Debug, Default, PartialEq, Eq)]
pub struct SessionPeek {
pub cwd: Option<PathBuf>,
pub title: Option<String>,
}
fn is_meta(entry: &Value) -> bool {
entry
.get("isMeta")
.and_then(Value::as_bool)
.unwrap_or(false)
}
fn user_prompt(entry: &Value) -> Option<String> {
const WRAPPERS: [&str; 4] = [
"<command-",
"<local-command-",
"<task-notification>",
"<system-reminder>",
];
let content = entry.get("message")?.get("content")?;
let text = match content {
Value::String(text) => text.trim(),
Value::Array(blocks) => blocks
.iter()
.filter(|block| block.get("type").and_then(Value::as_str) == Some("text"))
.find_map(|block| block.get("text").and_then(Value::as_str))?
.trim(),
_ => return None,
};
let is_wrapper = WRAPPERS.iter().any(|tag| text.starts_with(tag));
(!text.is_empty() && !is_wrapper).then(|| text.to_owned())
}
#[derive(Debug)]
pub struct Turn {
pub role: Role,
pub timestamp: Timestamp,
pub model: Option<String>,
pub effort: Option<String>,
pub content: Content,
pub usage: Option<Usage>,
pub is_sidechain: bool,
pub is_meta: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
pub struct Usage {
pub input_tokens: u64,
pub output_tokens: u64,
pub cache_creation_input_tokens: u64,
pub cache_read_input_tokens: u64,
}
impl Usage {
pub fn input(&self) -> u64 {
self.input_tokens + self.cache_creation_input_tokens + self.cache_read_input_tokens
}
pub fn uncached_input(&self) -> u64 {
self.input_tokens + self.cache_creation_input_tokens
}
}
impl Turn {
pub fn is_tool_response(&self) -> bool {
self.role == Role::User && self.content.is_only_tool_results()
}
pub fn is_clear_command(&self) -> bool {
matches!(&self.content, Content::Text(text)
if text.contains("<command-name>/clear</command-name>"))
}
fn blocks(&self) -> Vec<Block> {
match &self.content {
Content::Text(text) => vec![Block::Known(Known::Text { text: text.clone() })],
Content::Blocks(blocks) => blocks.clone(),
}
}
}
#[derive(Debug)]
pub struct Panel {
pub turn_number: usize,
pub role: Role,
pub timestamp: Timestamp,
pub model: Option<String>,
pub effort: Option<String>,
pub blocks: Vec<Block>,
pub usage: Option<Usage>,
pub is_sidechain: bool,
pub is_meta: bool,
}
impl Panel {
fn from_turn(turn: &Turn, turn_number: usize, blocks: Vec<Block>) -> Self {
Self {
turn_number,
role: turn.role,
timestamp: turn.timestamp,
model: turn.model.clone(),
effort: turn.effort.clone(),
blocks,
usage: turn.usage,
is_sidechain: turn.is_sidechain,
is_meta: turn.is_meta,
}
}
pub fn kind(&self) -> PanelKind {
let speaker = match self.role {
Role::User => PanelKind::User,
Role::Assistant => PanelKind::Assistant,
};
if self.blocks.iter().any(Block::is_visible_text) {
speaker
} else if self.blocks.iter().any(Block::is_tool) {
PanelKind::Tool
} else if self.blocks.iter().any(Block::is_thinking) {
PanelKind::Thinking
} else {
speaker
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Role {
User,
Assistant,
}
impl Role {
pub fn as_str(self) -> &'static str {
match self {
Role::User => "user",
Role::Assistant => "assistant",
}
}
}
#[derive(Debug, Deserialize)]
#[serde(tag = "type")]
enum Entry {
#[serde(rename = "user")]
User(RawTurn),
#[serde(rename = "assistant")]
Assistant(RawTurn),
#[serde(rename = "attachment")]
Attachment(RawAttachment),
#[serde(other)]
Bookkeeping,
}
#[derive(Debug, Deserialize)]
struct RawTurn {
timestamp: Timestamp,
message: Message,
effort: Option<String>,
#[serde(default, rename = "isSidechain")]
is_sidechain: bool,
#[serde(default, rename = "isMeta")]
is_meta: bool,
}
impl RawTurn {
fn into_turn(self, role: Role) -> Turn {
Turn {
role,
timestamp: self.timestamp,
model: self.message.model,
effort: self.effort,
content: self.message.content,
usage: self.message.usage,
is_sidechain: self.is_sidechain,
is_meta: self.is_meta,
}
}
}
#[derive(Debug, Deserialize)]
struct RawAttachment {
timestamp: Timestamp,
attachment: AttachmentBody,
}
impl RawAttachment {
fn into_turn(self) -> Option<Turn> {
let AttachmentBody::QueuedCommand { prompt } = self.attachment else {
return None;
};
Some(Turn {
role: Role::User,
timestamp: self.timestamp,
model: None,
effort: None,
content: Content::Text(prompt),
usage: None,
is_sidechain: false,
is_meta: false,
})
}
}
#[derive(Debug, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
enum AttachmentBody {
QueuedCommand { prompt: String },
#[serde(other)]
Other,
}
#[derive(Debug, Deserialize)]
struct Message {
content: Content,
model: Option<String>,
usage: Option<Usage>,
id: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(untagged)]
pub enum Content {
Text(String),
Blocks(Vec<Block>),
}
impl Content {
fn is_only_tool_results(&self) -> bool {
match self {
Content::Text(_) => false,
Content::Blocks(blocks) => {
!blocks.is_empty() && blocks.iter().all(Block::is_tool_result)
}
}
}
}
impl Block {
fn is_tool_result(&self) -> bool {
matches!(self, Block::Known(Known::ToolResult { .. }))
}
fn is_tool(&self) -> bool {
matches!(
self,
Block::Known(Known::ToolUse { .. } | Known::ToolResult { .. })
)
}
fn is_thinking(&self) -> bool {
matches!(self, Block::Known(Known::Thinking { .. }))
}
pub(crate) fn is_visible_text(&self) -> bool {
matches!(self, Block::Known(Known::Text { text }) if !text.trim().is_empty())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PanelKind {
User,
Assistant,
Tool,
Thinking,
}
impl PanelKind {
pub fn label(self) -> &'static str {
match self {
PanelKind::User => "user",
PanelKind::Assistant => "assistant",
PanelKind::Tool => "tool",
PanelKind::Thinking => "thinking",
}
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(untagged)]
pub enum Block {
Known(Known),
Unknown(Value),
}
#[derive(Debug, Clone, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum Known {
Text {
text: String,
},
Thinking {
thinking: String,
},
ToolUse {
id: Option<String>,
name: String,
input: Value,
},
ToolResult {
tool_use_id: Option<String>,
content: ToolResultContent,
#[serde(default)]
is_error: bool,
#[serde(skip)]
answers: Option<Answered>,
},
Image {
source: ImageSource,
},
}
#[derive(Debug, Clone, Deserialize)]
#[serde(untagged)]
pub enum ToolResultContent {
Text(String),
Blocks(Vec<Block>),
}
#[derive(Debug, Clone, Deserialize)]
pub struct ImageSource {
pub media_type: String,
pub data: String,
}