1use serde_json::Value;
4use std::collections::BTreeMap;
5
6#[derive(Debug, Clone, PartialEq, Default)]
9pub struct Transcript {
10 pub entries: Vec<Entry>,
12 pub next_before_seq: Option<u64>,
14 pub raw: Value,
16}
17
18impl Transcript {
19 pub(crate) fn from_value(v: Value) -> Self {
20 let entries = v
21 .get("entries")
22 .and_then(Value::as_array)
23 .map(|a| a.iter().map(Entry::from_value).collect())
24 .unwrap_or_default();
25 let next_before_seq = v
26 .get("nextBeforeSeq")
27 .and_then(|x| x.as_u64().or_else(|| x.as_i64().map(|n| n as u64)));
28 Transcript {
29 entries,
30 next_before_seq,
31 raw: v,
32 }
33 }
34}
35
36#[derive(Debug, Clone, PartialEq, Default)]
40pub struct Entry {
41 pub id: Option<String>,
43 pub role: String,
45 pub text: String,
47 pub raw: Value,
49}
50
51fn entry_text(e: &Value) -> String {
52 if !e.is_object() {
53 return String::new();
54 }
55 for k in ["text", "prompt", "message"] {
56 if let Some(s) = e.get(k).and_then(Value::as_str).filter(|s| !s.is_empty()) {
57 return s.to_string();
58 }
59 }
60 match e.get("content") {
61 Some(Value::String(s)) => return s.clone(),
62 Some(Value::Array(parts)) => {
63 return parts
64 .iter()
65 .map(|p| match p {
66 Value::String(s) => s.clone(),
67 Value::Object(_) => p
68 .get("text")
69 .and_then(Value::as_str)
70 .or_else(|| p.get("content").and_then(Value::as_str))
71 .unwrap_or("")
72 .to_string(),
73 _ => String::new(),
74 })
75 .filter(|s| !s.is_empty())
76 .collect::<Vec<_>>()
77 .join("\n");
78 }
79 Some(c @ Value::Object(_)) => {
80 return c
81 .get("text")
82 .and_then(Value::as_str)
83 .map(str::to_string)
84 .unwrap_or_else(|| c.to_string()); }
86 _ => {}
87 }
88 if let Some(s) = e
90 .get("message")
91 .and_then(|m| m.get("content"))
92 .and_then(Value::as_str)
93 {
94 return s.to_string();
95 }
96 e.get("preview")
97 .and_then(Value::as_str)
98 .unwrap_or("")
99 .to_string()
100}
101
102impl Entry {
103 pub fn from_value(v: &Value) -> Entry {
115 let role = ["role", "kind", "sender", "type"]
116 .iter()
117 .find_map(|k| v.get(*k).and_then(Value::as_str).filter(|s| !s.is_empty()))
118 .unwrap_or("msg")
119 .to_string();
120 Entry {
121 id: v.get("id").and_then(Value::as_str).map(str::to_string),
122 role,
123 text: entry_text(v),
124 raw: v.clone(),
125 }
126 }
127
128 pub fn is_from_user(&self) -> bool {
131 let r = self.role.to_lowercase();
132 r.contains("user") || r.contains("human") || r.contains("prompt")
133 }
134
135 pub fn approval(&self) -> Option<Approval> {
137 Approval::from_entry(&self.raw)
138 }
139}
140
141#[derive(Debug, Clone, Copy, PartialEq, Eq)]
143pub enum ApprovalKind {
144 AutoReview,
146 LocalToolPermission,
148}
149
150#[derive(Debug, Clone, PartialEq)]
153pub struct Approval {
154 pub entry_id: String,
156 pub request_id: String,
158 pub kind: ApprovalKind,
160 pub details: BTreeMap<String, String>,
162 pub truncated: bool,
164}
165
166impl Approval {
167 fn from_entry(e: &Value) -> Option<Approval> {
168 if e.get("kind").and_then(Value::as_str) != Some("send-message") {
169 return None;
170 }
171 let msg = e.get("message")?;
172 let (kind, card) = match msg.get("type").and_then(Value::as_str)? {
173 "auto-review-approval" => (ApprovalKind::AutoReview, msg.get("approval")?),
174 "local-tool-permission" => (ApprovalKind::LocalToolPermission, msg.get("ask")?),
175 _ => return None,
176 };
177 if card.get("status").and_then(Value::as_str) != Some("pending") {
178 return None;
179 }
180 let ok = |s: &str| !s.is_empty() && s.len() <= 1024;
181 let request_id = card
182 .get("requestId")
183 .and_then(Value::as_str)
184 .filter(|s| ok(s))?
185 .to_string();
186 let entry_id = e
187 .get("id")
188 .and_then(Value::as_str)
189 .filter(|s| ok(s))?
190 .to_string();
191 let mut details = BTreeMap::new();
192 let mut truncated = false;
193 for k in [
194 "reason",
195 "command",
196 "summary",
197 "action",
198 "description",
199 "target",
200 "machineId",
201 "surface",
202 "workingDirectory",
203 ] {
204 if let Some(t) = card.get(k).and_then(Value::as_str) {
205 truncated |= t.chars().count() > 2048;
206 details.insert(k.to_string(), t.chars().take(2048).collect());
207 }
208 }
209 Some(Approval {
210 entry_id,
211 request_id,
212 kind,
213 details,
214 truncated,
215 })
216 }
217}
218
219pub fn sanitize_text(s: &str) -> String {
226 let mut out = String::with_capacity(s.len());
227 let mut it = s.chars().peekable();
228 while let Some(c) = it.next() {
229 if c == '\u{1b}' {
230 match it.next() {
231 Some('[') => {
232 for d in it.by_ref() {
234 if ('@'..='~').contains(&d) {
235 break;
236 }
237 }
238 }
239 Some(']') => {
240 while let Some(d) = it.next() {
242 if d == '\u{7}' {
243 break;
244 }
245 if d == '\u{1b}' {
246 it.next();
247 break;
248 }
249 }
250 }
251 _ => {}
252 }
253 continue;
254 }
255 if c == '\n' || c == '\t' || !(c.is_control()) {
256 out.push(c);
257 }
258 }
259 out
260}
261
262#[cfg(test)]
263mod tests {
264 use super::*;
265 use serde_json::json;
266
267 #[test]
268 fn entry_text_variants() {
269 let t = |v: Value| Entry::from_value(&v).text;
270 assert_eq!(t(json!({"text": "a", "preview": "p"})), "a");
271 assert_eq!(t(json!({"prompt": "b"})), "b");
272 assert_eq!(
273 t(json!({"content": [ "x", {"text": "y"}, {"content": "z"}, 3 ]})),
274 "x\ny\nz"
275 );
276 assert_eq!(
277 t(json!({"kind": "send-message", "message": {"type": "text", "content": "reply"}})),
278 "reply"
279 );
280 assert_eq!(t(json!({"preview": "only preview"})), "only preview");
281 let e = Entry::from_value(&json!({"id": "e1", "kind": "user-prompt", "prompt": "hi"}));
282 assert_eq!(e.id.as_deref(), Some("e1"));
283 assert!(e.is_from_user());
284 assert!(!Entry::from_value(&json!({"kind": "send-message"})).is_from_user());
285 assert_eq!(Entry::from_value(&json!({})).role, "msg");
286 }
287
288 #[test]
289 fn approvals() {
290 let e = Entry::from_value(&json!({
291 "id": "e9", "kind": "send-message",
292 "message": {"type": "local-tool-permission", "ask": {"status": "pending", "requestId": "r1", "command": "rm -rf /"}}
293 }));
294 let a = e.approval().unwrap();
295 assert_eq!((a.entry_id.as_str(), a.request_id.as_str()), ("e9", "r1"));
296 assert_eq!(a.kind, ApprovalKind::LocalToolPermission);
297 assert_eq!(a.details["command"], "rm -rf /");
298 let done = Entry::from_value(&json!({"id": "e", "kind": "send-message",
299 "message": {"type": "auto-review-approval", "approval": {"status": "approved", "requestId": "r"}}}));
300 assert!(done.approval().is_none());
301 }
302
303 #[test]
304 fn sanitize() {
305 assert_eq!(
306 sanitize_text("a\u{1b}[31mred\u{1b}[0m\u{1b}]0;title\u{7}b\u{0}\n\tc"),
307 "aredb\n\tc"
308 );
309 }
310}