1use serde::{Deserialize, Serialize};
2use serde_json::Value;
3
4#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
5#[serde(rename_all = "snake_case")]
6pub enum TranscriptEventKind {
7 UserMessage,
8 AssistantMessage,
9 AssistantThought,
10 System,
11 ToolUse,
12 ToolResult,
13 Diagnostic,
14}
15
16#[derive(Clone, Serialize, Deserialize, PartialEq)]
17pub struct TranscriptEvent {
18 pub uuid: String,
19 pub session_id: Option<String>,
20 pub kind: TranscriptEventKind,
21 pub text: Option<String>,
22 pub id: Option<String>,
23 pub name: Option<String>,
24 pub model: Option<String>,
25 #[serde(default)]
26 pub is_error: bool,
27 #[serde(skip)]
28 pub raw_input: Option<Value>,
29 #[serde(skip)]
30 pub raw_output: Option<Value>,
31 pub redacted: RedactionSummary,
32}
33
34#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
35pub struct RedactionSummary {
36 pub text_redacted: bool,
37 pub char_count: usize,
38 pub line_count: usize,
39 pub value_kind: Option<String>,
40}
41
42impl std::fmt::Debug for TranscriptEvent {
43 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
44 formatter
45 .debug_struct("TranscriptEvent")
46 .field("uuid", &self.uuid)
47 .field("session_id", &self.session_id)
48 .field("kind", &self.kind)
49 .field("text", &self.text.as_ref().map(|_| "<redacted>"))
50 .field("id", &self.id)
51 .field("name", &self.name)
52 .field("model", &self.model)
53 .field("is_error", &self.is_error)
54 .field("raw_input", &self.raw_input.as_ref().map(|_| "<redacted>"))
55 .field(
56 "raw_output",
57 &self.raw_output.as_ref().map(|_| "<redacted>"),
58 )
59 .field("redacted", &self.redacted)
60 .finish()
61 }
62}
63
64impl TranscriptEvent {
65 pub fn session_id(&self) -> Option<&str> {
66 self.session_id.as_deref()
67 }
68}
69
70pub fn parse_transcript_line(line: &str) -> anyhow::Result<Vec<TranscriptEvent>> {
71 if line.trim().is_empty() {
72 return Ok(Vec::new());
73 }
74
75 let value: Value = serde_json::from_str(line)?;
76 Ok(parse_transcript_record(&value))
77}
78
79pub fn parse_transcript_record(value: &Value) -> Vec<TranscriptEvent> {
80 let session_id = extract_session_id(value);
81 let record_type = string_at(value, &["type"]).unwrap_or("unknown").to_string();
82
83 if record_type == "tool_use" {
84 return vec![tool_use_event(value, session_id, 0)];
85 }
86 if record_type == "tool_result" {
87 return vec![tool_result_event(value, session_id, 0)];
88 }
89
90 let message = value.get("message").unwrap_or(value);
91 let role = string_at(message, &["role"])
92 .or_else(|| string_at(value, &["role"]))
93 .unwrap_or(record_type.as_str());
94 let model = string_at(message, &["model"]).map(str::to_string);
95 let content = message.get("content").or_else(|| value.get("content"));
96
97 match content {
98 Some(Value::Array(items)) => {
99 let mut events = Vec::new();
100 for (index, item) in items.iter().enumerate() {
101 events.extend(parse_content_item(
102 item,
103 role,
104 &record_type,
105 session_id.clone(),
106 model.clone(),
107 index,
108 ));
109 }
110 events
111 }
112 Some(content) => text_events(
113 role,
114 &record_type,
115 session_id,
116 model,
117 text_from_value(content),
118 0,
119 ),
120 None => vec![diagnostic_event(
121 session_id,
122 record_type,
123 text_from_value(value),
124 0,
125 )],
126 }
127}
128
129fn parse_content_item(
130 item: &Value,
131 role: &str,
132 record_type: &str,
133 session_id: Option<String>,
134 model: Option<String>,
135 index: usize,
136) -> Vec<TranscriptEvent> {
137 match string_at(item, &["type"]) {
138 Some("text") => text_events(
139 role,
140 record_type,
141 session_id,
142 model,
143 text_from_value(item.get("text").unwrap_or(item)),
144 index,
145 ),
146 Some("tool_use") => vec![tool_use_event(item, session_id, index)],
147 Some("tool_result") => vec![tool_result_event(item, session_id, index)],
148 Some(kind) => vec![diagnostic_event(
149 session_id,
150 kind.to_string(),
151 text_from_value(item),
152 index,
153 )],
154 None => text_events(
155 role,
156 record_type,
157 session_id,
158 model,
159 text_from_value(item),
160 index,
161 ),
162 }
163}
164
165fn text_events(
166 role: &str,
167 record_type: &str,
168 session_id: Option<String>,
169 model: Option<String>,
170 text: String,
171 index: usize,
172) -> Vec<TranscriptEvent> {
173 let text = if role == "user" {
174 strip_local_command_metadata(&text)
175 } else {
176 text
177 };
178 if role == "user" && text.trim().is_empty() {
179 return Vec::new();
180 }
181 let kind = match role {
182 "user" => TranscriptEventKind::UserMessage,
183 "assistant" => TranscriptEventKind::AssistantMessage,
184 "system" => TranscriptEventKind::System,
185 _ => TranscriptEventKind::Diagnostic,
186 };
187 let uuid = event_uuid(session_id.as_deref(), record_type, index);
188 vec![TranscriptEvent {
189 uuid,
190 session_id,
191 kind,
192 redacted: summarize_text(&text),
193 text: Some(text),
194 id: None,
195 name: None,
196 model,
197 is_error: false,
198 raw_input: None,
199 raw_output: None,
200 }]
201}
202
203fn tool_use_event(value: &Value, session_id: Option<String>, index: usize) -> TranscriptEvent {
204 let id = string_at(value, &["id"]).map(str::to_string);
205 TranscriptEvent {
206 uuid: id
207 .clone()
208 .unwrap_or_else(|| event_uuid(session_id.as_deref(), "tool_use", index)),
209 session_id,
210 kind: TranscriptEventKind::ToolUse,
211 text: string_at(value, &["name"]).map(str::to_string),
212 id,
213 name: string_at(value, &["name"]).map(str::to_string),
214 model: None,
215 is_error: false,
216 raw_input: value.get("input").cloned(),
217 raw_output: None,
218 redacted: summarize_value(value.get("input").unwrap_or(&Value::Null)),
219 }
220}
221
222fn tool_result_event(value: &Value, session_id: Option<String>, index: usize) -> TranscriptEvent {
223 let raw_output = value.get("content").cloned();
224 let text = text_from_value(raw_output.as_ref().unwrap_or(value));
225 let id = string_at(value, &["tool_use_id"]).map(str::to_string);
226 TranscriptEvent {
227 uuid: id
228 .clone()
229 .unwrap_or_else(|| event_uuid(session_id.as_deref(), "tool_result", index)),
230 session_id,
231 kind: TranscriptEventKind::ToolResult,
232 redacted: summarize_text(&text),
233 text: Some(text),
234 id,
235 name: None,
236 model: None,
237 is_error: value
238 .get("is_error")
239 .and_then(Value::as_bool)
240 .unwrap_or(false),
241 raw_input: None,
242 raw_output,
243 }
244}
245
246fn diagnostic_event(
247 session_id: Option<String>,
248 record_type: String,
249 text: String,
250 index: usize,
251) -> TranscriptEvent {
252 TranscriptEvent {
253 uuid: event_uuid(session_id.as_deref(), &record_type, index),
254 session_id,
255 kind: TranscriptEventKind::Diagnostic,
256 redacted: summarize_text(&text),
257 text: Some(text),
258 id: None,
259 name: Some(record_type),
260 model: None,
261 is_error: false,
262 raw_input: None,
263 raw_output: None,
264 }
265}
266
267fn extract_session_id(value: &Value) -> Option<String> {
268 string_at(value, &["sessionId"])
269 .or_else(|| string_at(value, &["session_id"]))
270 .or_else(|| string_at(value, &["sessionID"]))
271 .map(str::to_string)
272}
273
274fn string_at<'a>(value: &'a Value, path: &[&str]) -> Option<&'a str> {
275 let mut current = value;
276 for part in path {
277 current = current.get(part)?;
278 }
279 current.as_str()
280}
281
282fn text_from_value(value: &Value) -> String {
283 match value {
284 Value::String(text) => text.clone(),
285 Value::Array(items) => items
286 .iter()
287 .filter_map(|item| {
288 item.as_str()
289 .or_else(|| item.get("text").and_then(Value::as_str))
290 })
291 .collect::<Vec<_>>()
292 .join("\n"),
293 _ => value.to_string(),
294 }
295}
296
297pub fn strip_local_command_metadata(text: &str) -> String {
298 let mut stripped = text.to_string();
299 for tag in [
300 "command-name",
301 "command-message",
302 "command-args",
303 "local-command-stdout",
304 "local-command-stderr",
305 ] {
306 let start_tag = format!("<{tag}>");
307 let end_tag = format!("</{tag}>");
308 while let Some(start) = stripped.find(&start_tag) {
309 let body_start = start + start_tag.len();
310 let Some(relative_end) = stripped[body_start..].find(&end_tag) else {
311 break;
312 };
313 let end = body_start + relative_end + end_tag.len();
314 stripped.replace_range(start..end, "");
315 }
316 }
317 stripped.trim().to_string()
318}
319
320fn summarize_text(text: &str) -> RedactionSummary {
321 RedactionSummary {
322 text_redacted: true,
323 char_count: text.chars().count(),
324 line_count: text.lines().count().max(usize::from(!text.is_empty())),
325 value_kind: None,
326 }
327}
328
329fn summarize_value(value: &Value) -> RedactionSummary {
330 RedactionSummary {
331 text_redacted: true,
332 char_count: 0,
333 line_count: 0,
334 value_kind: Some(
335 match value {
336 Value::Null => "null",
337 Value::Bool(_) => "bool",
338 Value::Number(_) => "number",
339 Value::String(_) => "string",
340 Value::Array(_) => "array",
341 Value::Object(_) => "object",
342 }
343 .to_string(),
344 ),
345 }
346}
347
348fn event_uuid(session_id: Option<&str>, record_type: &str, index: usize) -> String {
349 format!(
350 "{}:{record_type}:{index}",
351 session_id.unwrap_or("transcript")
352 )
353}