use std::path::{Path, PathBuf};
mod render;
pub use render::render;
const AGENTS_DIR: &str = "agents";
const MESSAGES_DIR: &str = "messages";
const TOOL_ORIGIN: &str = "tool";
const MD_EXT: &str = "md";
const JSON_EXT: &str = "json";
const INPUT_SUMMARY_CAP: usize = 200;
const STREAMING_NAME: &str = "«live»";
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct Transcript {
pub entries: Vec<Entry>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Entry {
pub name: String,
pub raw: Vec<u8>,
pub kind: EntryKind,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum EntryKind {
Delivered { sender: String, body: String },
Model {
model_id: String,
blocks: Vec<Block>,
},
ToolResult {
tool_use_id: String,
content: String,
is_error: bool,
},
Streaming { text: String },
Raw,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Block {
Text(String),
Thinking(String),
ToolUse {
id: String,
name: String,
input_summary: String,
},
}
impl Transcript {
pub fn tool_in_progress(&self, tool_use_id: &str) -> bool {
!self.entries.iter().any(|e| {
matches!(&e.kind, EntryKind::ToolResult { tool_use_id: id, .. } if id == tool_use_id)
})
}
}
pub fn build(workspace: &Path, agent_id: &str, in_flight: bool) -> Transcript {
let dir = workspace.join(AGENTS_DIR).join(agent_id).join(MESSAGES_DIR);
let mut entries = read_messages(&dir);
if in_flight && let Some(text) = crate::git_tree::streaming_text_from_disk(workspace, agent_id)
{
entries.push(Entry {
name: STREAMING_NAME.to_string(),
raw: text.clone().into_bytes(),
kind: EntryKind::Streaming { text },
});
}
Transcript { entries }
}
fn read_messages(dir: &Path) -> Vec<Entry> {
let Ok(read) = std::fs::read_dir(dir) else {
return Vec::new();
};
let mut files: Vec<(String, PathBuf)> = read
.flatten()
.filter_map(|e| {
let path = e.path();
path.is_file()
.then(|| (e.file_name().to_string_lossy().into_owned(), path))
})
.collect();
files.sort_by(|a, b| a.0.cmp(&b.0));
files
.into_iter()
.map(|(name, path)| {
let raw = std::fs::read(&path).unwrap_or_default();
let kind = classify(&name, &raw);
Entry { name, raw, kind }
})
.collect()
}
fn classify(name: &str, raw: &[u8]) -> EntryKind {
let Some((origin, ext)) = parse_name(name) else {
return EntryKind::Raw;
};
match ext {
MD_EXT => EntryKind::Delivered {
sender: origin.to_string(),
body: String::from_utf8_lossy(raw).into_owned(),
},
JSON_EXT if origin == TOOL_ORIGIN => parse_tool_result(raw).unwrap_or(EntryKind::Raw),
JSON_EXT => parse_model(origin, raw),
_ => EntryKind::Raw,
}
}
fn parse_name(name: &str) -> Option<(&str, &str)> {
let (stem, ext) = name.rsplit_once('.')?;
let (num, origin) = stem.split_once('-')?;
if num.is_empty() || !num.bytes().all(|b| b.is_ascii_digit()) || origin.is_empty() {
return None;
}
Some((origin, ext))
}
fn parse_model(model_id: &str, raw: &[u8]) -> EntryKind {
match serde_json::from_slice::<serde_json::Value>(raw) {
Ok(value) => EntryKind::Model {
model_id: model_id.to_string(),
blocks: blocks_from_value(&value),
},
Err(_) => EntryKind::Raw,
}
}
fn blocks_from_value(value: &serde_json::Value) -> Vec<Block> {
if let Some(arr) = value.as_array() {
return arr.iter().filter_map(block_from_value).collect();
}
match value.get("content") {
Some(serde_json::Value::Array(arr)) => arr.iter().filter_map(block_from_value).collect(),
Some(serde_json::Value::String(s)) => vec![Block::Text(s.clone())],
_ => Vec::new(),
}
}
fn block_from_value(v: &serde_json::Value) -> Option<Block> {
match v.get("type").and_then(|t| t.as_str())? {
"text" => Some(Block::Text(str_field(v, "text"))),
"thinking" => Some(Block::Thinking(str_field(v, "thinking"))),
"tool_use" => Some(Block::ToolUse {
id: str_field(v, "id"),
name: str_field(v, "name"),
input_summary: summarize_input(v.get("input")),
}),
_ => None,
}
}
fn summarize_input(input: Option<&serde_json::Value>) -> String {
let Some(v) = input else {
return String::new();
};
let compact = v.to_string();
if compact.chars().count() > INPUT_SUMMARY_CAP {
let head: String = compact.chars().take(INPUT_SUMMARY_CAP).collect();
format!("{head}…")
} else {
compact
}
}
fn parse_tool_result(raw: &[u8]) -> Option<EntryKind> {
let value: serde_json::Value = serde_json::from_slice(raw).ok()?;
let block = find_tool_result(&value)?;
Some(EntryKind::ToolResult {
tool_use_id: str_field(block, "tool_use_id"),
content: tool_result_content(block.get("content")),
is_error: block
.get("is_error")
.and_then(serde_json::Value::as_bool)
.unwrap_or(false),
})
}
fn find_tool_result(value: &serde_json::Value) -> Option<&serde_json::Value> {
if value.get("tool_use_id").is_some() {
return Some(value);
}
value
.get("content")?
.as_array()?
.iter()
.find(|b| b.get("tool_use_id").is_some())
}
fn tool_result_content(content: Option<&serde_json::Value>) -> String {
match content {
None | Some(serde_json::Value::Null) => String::new(),
Some(serde_json::Value::String(s)) => s.clone(),
Some(serde_json::Value::Array(arr)) => arr.iter().map(content_piece).collect(),
Some(other) => other.to_string(),
}
}
fn content_piece(v: &serde_json::Value) -> String {
if v.get("type").and_then(|t| t.as_str()) == Some("text") {
str_field(v, "text")
} else {
v.to_string()
}
}
fn str_field(v: &serde_json::Value, key: &str) -> String {
v.get(key)
.and_then(|x| x.as_str())
.unwrap_or_default()
.to_string()
}
#[cfg(test)]
mod tests;