use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum TranscriptEventKind {
UserMessage,
AssistantMessage,
AssistantThought,
System,
ToolUse,
ToolResult,
Diagnostic,
}
#[derive(Clone, Serialize, Deserialize, PartialEq)]
pub struct TranscriptEvent {
pub uuid: String,
pub session_id: Option<String>,
pub kind: TranscriptEventKind,
pub text: Option<String>,
pub id: Option<String>,
pub name: Option<String>,
pub model: Option<String>,
#[serde(default)]
pub is_error: bool,
#[serde(skip)]
pub raw_input: Option<Value>,
#[serde(skip)]
pub raw_output: Option<Value>,
pub redacted: RedactionSummary,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct RedactionSummary {
pub text_redacted: bool,
pub char_count: usize,
pub line_count: usize,
pub value_kind: Option<String>,
}
impl std::fmt::Debug for TranscriptEvent {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("TranscriptEvent")
.field("uuid", &self.uuid)
.field("session_id", &self.session_id)
.field("kind", &self.kind)
.field("text", &self.text.as_ref().map(|_| "<redacted>"))
.field("id", &self.id)
.field("name", &self.name)
.field("model", &self.model)
.field("is_error", &self.is_error)
.field("raw_input", &self.raw_input.as_ref().map(|_| "<redacted>"))
.field(
"raw_output",
&self.raw_output.as_ref().map(|_| "<redacted>"),
)
.field("redacted", &self.redacted)
.finish()
}
}
impl TranscriptEvent {
pub fn session_id(&self) -> Option<&str> {
self.session_id.as_deref()
}
}
pub fn parse_transcript_line(line: &str) -> anyhow::Result<Vec<TranscriptEvent>> {
if line.trim().is_empty() {
return Ok(Vec::new());
}
let value: Value = serde_json::from_str(line)?;
Ok(parse_transcript_record(&value))
}
pub fn parse_transcript_record(value: &Value) -> Vec<TranscriptEvent> {
let session_id = extract_session_id(value);
let record_type = string_at(value, &["type"]).unwrap_or("unknown").to_string();
if record_type == "tool_use" {
return vec![tool_use_event(value, session_id, 0)];
}
if record_type == "tool_result" {
return vec![tool_result_event(value, session_id, 0)];
}
let message = value.get("message").unwrap_or(value);
let role = string_at(message, &["role"])
.or_else(|| string_at(value, &["role"]))
.unwrap_or(record_type.as_str());
let model = string_at(message, &["model"]).map(str::to_string);
let content = message.get("content").or_else(|| value.get("content"));
match content {
Some(Value::Array(items)) => {
let mut events = Vec::new();
for (index, item) in items.iter().enumerate() {
events.extend(parse_content_item(
item,
role,
&record_type,
session_id.clone(),
model.clone(),
index,
));
}
events
}
Some(content) => text_events(
role,
&record_type,
session_id,
model,
text_from_value(content),
0,
),
None => vec![diagnostic_event(
session_id,
record_type,
text_from_value(value),
0,
)],
}
}
fn parse_content_item(
item: &Value,
role: &str,
record_type: &str,
session_id: Option<String>,
model: Option<String>,
index: usize,
) -> Vec<TranscriptEvent> {
match string_at(item, &["type"]) {
Some("text") => text_events(
role,
record_type,
session_id,
model,
text_from_value(item.get("text").unwrap_or(item)),
index,
),
Some("tool_use") => vec![tool_use_event(item, session_id, index)],
Some("tool_result") => vec![tool_result_event(item, session_id, index)],
Some(kind) => vec![diagnostic_event(
session_id,
kind.to_string(),
text_from_value(item),
index,
)],
None => text_events(
role,
record_type,
session_id,
model,
text_from_value(item),
index,
),
}
}
fn text_events(
role: &str,
record_type: &str,
session_id: Option<String>,
model: Option<String>,
text: String,
index: usize,
) -> Vec<TranscriptEvent> {
let text = if role == "user" {
strip_local_command_metadata(&text)
} else {
text
};
if role == "user" && text.trim().is_empty() {
return Vec::new();
}
let kind = match role {
"user" => TranscriptEventKind::UserMessage,
"assistant" => TranscriptEventKind::AssistantMessage,
"system" => TranscriptEventKind::System,
_ => TranscriptEventKind::Diagnostic,
};
let uuid = event_uuid(session_id.as_deref(), record_type, index);
vec![TranscriptEvent {
uuid,
session_id,
kind,
redacted: summarize_text(&text),
text: Some(text),
id: None,
name: None,
model,
is_error: false,
raw_input: None,
raw_output: None,
}]
}
fn tool_use_event(value: &Value, session_id: Option<String>, index: usize) -> TranscriptEvent {
let id = string_at(value, &["id"]).map(str::to_string);
TranscriptEvent {
uuid: id
.clone()
.unwrap_or_else(|| event_uuid(session_id.as_deref(), "tool_use", index)),
session_id,
kind: TranscriptEventKind::ToolUse,
text: string_at(value, &["name"]).map(str::to_string),
id,
name: string_at(value, &["name"]).map(str::to_string),
model: None,
is_error: false,
raw_input: value.get("input").cloned(),
raw_output: None,
redacted: summarize_value(value.get("input").unwrap_or(&Value::Null)),
}
}
fn tool_result_event(value: &Value, session_id: Option<String>, index: usize) -> TranscriptEvent {
let raw_output = value.get("content").cloned();
let text = text_from_value(raw_output.as_ref().unwrap_or(value));
let id = string_at(value, &["tool_use_id"]).map(str::to_string);
TranscriptEvent {
uuid: id
.clone()
.unwrap_or_else(|| event_uuid(session_id.as_deref(), "tool_result", index)),
session_id,
kind: TranscriptEventKind::ToolResult,
redacted: summarize_text(&text),
text: Some(text),
id,
name: None,
model: None,
is_error: value
.get("is_error")
.and_then(Value::as_bool)
.unwrap_or(false),
raw_input: None,
raw_output,
}
}
fn diagnostic_event(
session_id: Option<String>,
record_type: String,
text: String,
index: usize,
) -> TranscriptEvent {
TranscriptEvent {
uuid: event_uuid(session_id.as_deref(), &record_type, index),
session_id,
kind: TranscriptEventKind::Diagnostic,
redacted: summarize_text(&text),
text: Some(text),
id: None,
name: Some(record_type),
model: None,
is_error: false,
raw_input: None,
raw_output: None,
}
}
fn extract_session_id(value: &Value) -> Option<String> {
string_at(value, &["sessionId"])
.or_else(|| string_at(value, &["session_id"]))
.or_else(|| string_at(value, &["sessionID"]))
.map(str::to_string)
}
fn string_at<'a>(value: &'a Value, path: &[&str]) -> Option<&'a str> {
let mut current = value;
for part in path {
current = current.get(part)?;
}
current.as_str()
}
fn text_from_value(value: &Value) -> String {
match value {
Value::String(text) => text.clone(),
Value::Array(items) => items
.iter()
.filter_map(|item| {
item.as_str()
.or_else(|| item.get("text").and_then(Value::as_str))
})
.collect::<Vec<_>>()
.join("\n"),
_ => value.to_string(),
}
}
pub fn strip_local_command_metadata(text: &str) -> String {
let mut stripped = text.to_string();
for tag in [
"command-name",
"command-message",
"command-args",
"local-command-stdout",
"local-command-stderr",
] {
let start_tag = format!("<{tag}>");
let end_tag = format!("</{tag}>");
while let Some(start) = stripped.find(&start_tag) {
let body_start = start + start_tag.len();
let Some(relative_end) = stripped[body_start..].find(&end_tag) else {
break;
};
let end = body_start + relative_end + end_tag.len();
stripped.replace_range(start..end, "");
}
}
stripped.trim().to_string()
}
fn summarize_text(text: &str) -> RedactionSummary {
RedactionSummary {
text_redacted: true,
char_count: text.chars().count(),
line_count: text.lines().count().max(usize::from(!text.is_empty())),
value_kind: None,
}
}
fn summarize_value(value: &Value) -> RedactionSummary {
RedactionSummary {
text_redacted: true,
char_count: 0,
line_count: 0,
value_kind: Some(
match value {
Value::Null => "null",
Value::Bool(_) => "bool",
Value::Number(_) => "number",
Value::String(_) => "string",
Value::Array(_) => "array",
Value::Object(_) => "object",
}
.to_string(),
),
}
}
fn event_uuid(session_id: Option<&str>, record_type: &str, index: usize) -> String {
format!(
"{}:{record_type}:{index}",
session_id.unwrap_or("transcript")
)
}