1use crate::{Agent, Message, MetaUpdate, Role};
4use chrono::{DateTime, Utc};
5use serde_json::Value;
6
7#[derive(Debug, Clone, PartialEq, Eq)]
8pub struct ConversationMessage {
9 pub role: Role,
10 pub text: String,
11 pub created_at_epoch: Option<i64>,
12}
13
14pub fn tolerant_timestamp_epoch(v: &Value) -> Option<i64> {
17 let t = v
18 .get("timestamp")
19 .or_else(|| v.get("created_at"))
20 .or_else(|| v.get("createdAt"))
21 .or_else(|| v.pointer("/payload/timestamp"))?;
22 t.as_i64()
23 .or_else(|| t.as_str()?.trim().parse().ok())
24 .or_else(|| {
25 DateTime::parse_from_rfc3339(t.as_str()?.trim())
26 .ok()
27 .map(|d| d.timestamp())
28 })
29}
30
31fn text(
32 content: Option<&Value>,
33 allowed: &[&str],
34 strings: bool,
35) -> (String, Vec<std::ops::Range<usize>>) {
36 let Some(content) = content else {
37 return (String::new(), Vec::new());
38 };
39 let parts: Vec<&str> = if strings && content.is_string() {
40 content.as_str().into_iter().collect()
41 } else {
42 content
43 .as_array()
44 .into_iter()
45 .flatten()
46 .filter_map(|block| {
47 allowed
48 .contains(&block.get("type")?.as_str()?)
49 .then(|| block.get("text")?.as_str())
50 .flatten()
51 })
52 .collect()
53 };
54 let mut offset = 0;
55 let segments = parts
56 .iter()
57 .map(|s| {
58 let range = offset..offset + s.len();
59 offset += s.len() + 1;
60 range
61 })
62 .collect();
63 (parts.join("\n"), segments)
64}
65
66pub fn project_conversation(v: &Value) -> Option<ConversationMessage> {
69 let (role, content) = match v.get("type")?.as_str()? {
70 "user" => (Role::User, v.pointer("/message/content")),
71 "assistant" => (Role::Assistant, v.pointer("/message/content")),
72 "response_item" if v.pointer("/payload/type")?.as_str()? == "message" => {
73 let role = match v.pointer("/payload/role")?.as_str()? {
74 "user" => Role::User,
75 "assistant" => Role::Assistant,
76 _ => return None,
77 };
78 (role, v.pointer("/payload/content"))
79 }
80 _ => return None,
81 };
82 Some(ConversationMessage {
83 role,
84 text: text(content, &["text", "input_text", "output_text"], true).0,
85 created_at_epoch: tolerant_timestamp_epoch(v),
86 })
87}
88
89#[derive(Debug, Clone, Copy, PartialEq)]
92pub enum CodexFunction<'a> {
93 Call {
94 call_id: Option<&'a str>,
95 name: Option<&'a str>,
96 arguments: Option<&'a Value>,
97 },
98 Output {
99 call_id: Option<&'a str>,
100 output: Option<&'a Value>,
101 },
102}
103pub fn project_codex_function(v: &Value) -> Option<CodexFunction<'_>> {
104 if v.get("type")?.as_str()? != "response_item" {
105 return None;
106 }
107 let p = v.get("payload")?;
108 let call_id = p.get("call_id").and_then(Value::as_str);
109 match p.get("type")?.as_str()? {
110 "function_call" => Some(CodexFunction::Call {
111 call_id,
112 name: p.get("name").and_then(Value::as_str),
113 arguments: p.get("arguments"),
114 }),
115 "function_call_output" => Some(CodexFunction::Output {
116 call_id,
117 output: p.get("output"),
118 }),
119 _ => None,
120 }
121}
122
123#[derive(Debug, Clone, PartialEq)]
124pub struct TranscriptProjection {
125 pub message: Option<Message>,
126 pub meta: MetaUpdate,
127 pub at: Option<DateTime<Utc>>,
128}
129
130pub fn project_transcript(agent: Agent, v: &Value) -> TranscriptProjection {
135 let at = [v.get("timestamp"), v.pointer("/payload/timestamp")]
136 .into_iter()
137 .flatten()
138 .filter_map(Value::as_str)
139 .find_map(|s| {
140 DateTime::parse_from_rfc3339(s)
141 .ok()
142 .map(|d| d.with_timezone(&Utc))
143 });
144 let kind = v.get("type").and_then(Value::as_str).unwrap_or("");
145 let mut meta = MetaUpdate::default();
146 let mut candidate = None;
147 match agent {
148 Agent::ClaudeCode => {
149 meta.cwd = crate::parser::string(v, "cwd");
150 if kind == "system" {
151 meta.model = v
152 .pointer("/message/model")
153 .and_then(Value::as_str)
154 .map(str::to_owned);
155 }
156 if matches!(kind, "user" | "assistant") {
157 candidate = Some((
158 if kind == "user" {
159 Role::User
160 } else {
161 Role::Assistant
162 },
163 text(v.pointer("/message/content"), &["text"], true),
164 ));
165 }
166 }
167 Agent::Codex => match kind {
168 "session_meta" | "turn_context" => {
169 let p = v.get("payload").unwrap_or(&Value::Null);
170 meta.model = p
171 .get("model")
172 .or_else(|| (kind == "session_meta").then(|| v.get("model")).flatten())
173 .and_then(Value::as_str)
174 .map(str::to_owned);
175 meta.cwd = crate::parser::string(p, "cwd");
176 meta.origin = Some(crate::codex::origin::classify(p));
177 meta.source = p.get("source").cloned();
178 meta.originator = crate::parser::string(p, "originator");
179 meta.thread_source = crate::parser::string(p, "thread_source");
180 }
181 "user_message" => {
182 if v.get("content").is_some_and(Value::is_string) {
183 candidate = Some((Role::User, text(v.get("content"), &[], true)));
184 }
185 }
186 "response_item" => {
187 let p = v.get("payload").unwrap_or(&Value::Null);
188 let ty = p.get("type").and_then(Value::as_str);
189 let role = p.get("role").and_then(Value::as_str);
190 let selection: Option<(Role, &[&str])> = match (ty, role) {
191 (Some("message"), Some("user")) => Some((Role::User, &["input_text", "text"])),
192 (Some("message"), Some("assistant")) => {
193 Some((Role::Assistant, &["output_text", "text"]))
194 }
195 (None, _) => Some((Role::Assistant, &["text"])),
196 _ => None,
197 };
198 if let Some((role, allowed)) = selection {
199 candidate = Some((role, text(p.get("content"), allowed, false)));
200 }
201 }
202 _ => {}
203 },
204 }
205 let message =
206 candidate
207 .filter(|(_, (body, _))| !body.is_empty())
208 .map(|(role, (text, text_segments))| Message {
209 role,
210 text,
211 text_segments,
212 is_meta: v
213 .get("isMeta")
214 .or_else(|| v.get("is_meta"))
215 .or_else(|| v.pointer("/message/isMeta"))
216 .or_else(|| v.pointer("/message/is_meta"))
217 .and_then(Value::as_bool)
218 .unwrap_or(false),
219 is_sidechain: v
220 .get("isSidechain")
221 .and_then(Value::as_bool)
222 .unwrap_or(false),
223 parent_id: crate::parser::string(v, "parentUuid"),
224 });
225 TranscriptProjection { message, meta, at }
226}