1use std::collections::HashSet;
39use std::path::{Path, PathBuf};
40use std::time::SystemTime;
41
42use serde::Deserialize;
43
44use super::{content_text, modified_at, newer_than, walk_files, Source, Transcript, TranscriptRef};
45use crate::conversation::{truncate, Conversation, ConversationEntry};
46use crate::error::RecallError;
47
48const TOOL_INPUT_CHARS: usize = 200;
50const TOOL_RESULT_CHARS: usize = 2000;
53const UUID_LEN: usize = 36;
55
56#[derive(Debug, Clone)]
58pub struct CodexTranscripts {
59 sessions_dir: PathBuf,
60}
61
62impl CodexTranscripts {
63 #[must_use]
65 pub fn new(sessions_dir: PathBuf) -> Self {
66 Self { sessions_dir }
67 }
68
69 #[must_use]
71 pub fn detect() -> Option<Self> {
72 Some(Self::new(dirs::home_dir()?.join(".codex").join("sessions")))
73 }
74}
75
76impl Transcript for CodexTranscripts {
77 fn source(&self) -> Source {
78 Source::Codex
79 }
80
81 fn sessions_root(&self) -> &Path {
82 &self.sessions_dir
83 }
84
85 fn discover(&self, since: Option<SystemTime>) -> Result<Vec<TranscriptRef>, RecallError> {
86 let found = walk_files(&self.sessions_dir, "jsonl", 0)
87 .into_iter()
88 .filter_map(|path| {
89 let stem = path.file_stem()?.to_str()?;
90 Some(TranscriptRef {
91 source: Source::Codex,
92 session_id: session_id_from_stem(stem),
93 modified: modified_at(&path),
94 path,
95 cwd: None,
96 })
97 })
98 .collect();
99 Ok(newer_than(found, since))
100 }
101
102 fn parse(&self, transcript: &TranscriptRef) -> Result<Conversation, RecallError> {
103 let raw = std::fs::read_to_string(&transcript.path)?;
104 Ok(parse_rollout(&raw, &transcript.session_id))
105 }
106}
107
108fn session_id_from_stem(stem: &str) -> String {
114 if stem.len() > UUID_LEN {
115 let tail = &stem[stem.len() - UUID_LEN..];
116 if is_uuid_shaped(tail) {
117 return tail.to_string();
118 }
119 }
120 stem.to_string()
121}
122
123fn is_uuid_shaped(candidate: &str) -> bool {
124 candidate.len() == UUID_LEN
125 && candidate
126 .chars()
127 .enumerate()
128 .all(|(index, ch)| match index {
129 8 | 13 | 18 | 23 => ch == '-',
130 _ => ch.is_ascii_hexdigit(),
131 })
132}
133
134#[derive(Deserialize)]
137struct RolloutLine {
138 #[serde(rename = "type")]
139 kind: String,
140 timestamp: Option<String>,
141 payload: Option<serde_json::Value>,
142}
143
144fn parse_rollout(raw: &str, fallback_session_id: &str) -> Conversation {
146 let mut conv = Conversation::new(fallback_session_id);
147 let mut user_positions: Vec<usize> = Vec::new();
150 let mut real_prompts: HashSet<String> = HashSet::new();
151
152 for line in raw.lines() {
153 if line.trim().is_empty() {
154 continue;
155 }
156 let Ok(entry) = serde_json::from_str::<RolloutLine>(line) else {
157 eprintln!("recall-echo: skipping malformed codex line");
158 continue;
159 };
160
161 if let Some(ref timestamp) = entry.timestamp {
162 if conv.first_timestamp.is_none() {
163 conv.first_timestamp = Some(timestamp.clone());
164 }
165 conv.last_timestamp = Some(timestamp.clone());
166 }
167
168 let Some(payload) = entry.payload else {
169 continue;
170 };
171
172 match entry.kind.as_str() {
173 "session_meta" => {
174 if let Some(id) = payload.get("session_id").and_then(|v| v.as_str()) {
175 conv.session_id = id.to_string();
176 }
177 }
178 "event_msg" => {
179 if payload.get("type").and_then(|v| v.as_str()) == Some("user_message") {
180 if let Some(message) = payload.get("message").and_then(|v| v.as_str()) {
181 real_prompts.insert(message.trim().to_string());
182 }
183 }
184 }
185 "response_item" => push_item(&mut conv, &payload, &mut user_positions),
186 _ => {}
187 }
188 }
189
190 if !real_prompts.is_empty() {
191 retain_real_prompts(&mut conv, &user_positions, &real_prompts);
192 }
193 conv
194}
195
196fn push_item(
198 conv: &mut Conversation,
199 payload: &serde_json::Value,
200 user_positions: &mut Vec<usize>,
201) {
202 let item_type = payload.get("type").and_then(|v| v.as_str()).unwrap_or("");
203 match item_type {
204 "message" => {
205 let role = payload.get("role").and_then(|v| v.as_str()).unwrap_or("");
206 let Some(content) = payload.get("content") else {
207 return;
208 };
209 let text = content_text(content);
210 if text.trim().is_empty() {
211 return;
212 }
213 match role {
214 "user" => {
215 user_positions.push(conv.entries.len());
216 conv.user_message_count += 1;
217 conv.entries.push(ConversationEntry::UserMessage(text));
218 }
219 "assistant" => {
220 conv.assistant_message_count += 1;
221 conv.entries.push(ConversationEntry::AssistantText(text));
222 }
223 _ => {}
225 }
226 }
227 "custom_tool_call" | "function_call" => {
228 let name = payload
229 .get("name")
230 .and_then(|v| v.as_str())
231 .unwrap_or("unknown")
232 .to_string();
233 let raw_input = payload
234 .get("input")
235 .or_else(|| payload.get("arguments"))
236 .map(content_or_json)
237 .unwrap_or_default();
238 conv.entries.push(ConversationEntry::ToolUse {
239 name,
240 input_summary: truncate(raw_input.trim(), TOOL_INPUT_CHARS),
241 });
242 }
243 "custom_tool_call_output" | "function_call_output" => {
244 let content = payload
245 .get("output")
246 .map(content_or_json)
247 .unwrap_or_default();
248 conv.entries.push(ConversationEntry::ToolResult {
249 content: truncate(content.trim(), TOOL_RESULT_CHARS),
250 is_error: false,
251 });
252 }
253 _ => {}
256 }
257}
258
259fn content_or_json(value: &serde_json::Value) -> String {
261 let text = content_text(value);
262 if text.is_empty() && !value.is_string() {
263 return serde_json::to_string(value).unwrap_or_default();
264 }
265 text
266}
267
268fn retain_real_prompts(
270 conv: &mut Conversation,
271 user_positions: &[usize],
272 real_prompts: &HashSet<String>,
273) {
274 let injected: HashSet<usize> = user_positions
275 .iter()
276 .copied()
277 .filter(|position| match conv.entries.get(*position) {
278 Some(ConversationEntry::UserMessage(text)) => !real_prompts.contains(text.trim()),
279 _ => false,
280 })
281 .collect();
282 if injected.is_empty() {
283 return;
284 }
285
286 let mut position = 0;
287 conv.entries.retain(|_| {
288 let keep = !injected.contains(&position);
289 position += 1;
290 keep
291 });
292 conv.user_message_count = conv
293 .user_message_count
294 .saturating_sub(injected.len().try_into().unwrap_or(u32::MAX));
295}
296
297#[cfg(test)]
298mod tests {
299 use super::*;
300
301 const ROLLOUT: &str = concat!(
306 r#"{"timestamp":"2026-08-05T22:29:00.878Z","type":"session_meta","payload":{"session_id":"019fd40b-55d5-7a72-8ecb-611abc36879e","cwd":"/tmp/probe","timestamp":"2026-08-05T22:29:00.107Z","cli_version":"0.146.1"}}"#,
307 "\n",
308 r#"{"timestamp":"2026-08-05T22:29:00.879Z","type":"event_msg","payload":{"type":"task_started","turn_id":"t1"}}"#,
309 "\n",
310 r#"{"timestamp":"2026-08-05T22:29:02.293Z","type":"response_item","payload":{"type":"message","role":"developer","content":[{"type":"input_text","text":"<permissions instructions>sandbox_mode is read-only.</permissions instructions>"}]}}"#,
311 "\n",
312 r#"{"timestamp":"2026-08-05T22:29:02.295Z","type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"<recommended_plugins>\nHere is a list of plugins that are available but not installed.\n</recommended_plugins>"}]}}"#,
313 "\n",
314 r#"{"timestamp":"2026-08-05T22:29:02.296Z","type":"world_state","payload":{"full":true,"state":{}}}"#,
315 "\n",
316 r#"{"timestamp":"2026-08-05T22:29:02.298Z","type":"turn_context","payload":{"turn_id":"t1","cwd":"/tmp/probe"}}"#,
317 "\n",
318 r#"{"timestamp":"2026-08-05T22:29:02.329Z","type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"List the files here, then reply DONE"}]}}"#,
319 "\n",
320 r#"{"timestamp":"2026-08-05T22:29:02.330Z","type":"event_msg","payload":{"type":"user_message","message":"List the files here, then reply DONE","images":null}}"#,
321 "\n",
322 r#"{"timestamp":"2026-08-05T22:29:03.595Z","type":"response_item","payload":{"type":"reasoning","id":"rs_1","summary":[],"encrypted_content":"gAAAAAsecret"}}"#,
323 "\n",
324 r#"{"timestamp":"2026-08-05T22:29:04.028Z","type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"I'll check the directory."}],"phase":"commentary"}}"#,
325 "\n",
326 r#"{"timestamp":"2026-08-05T22:29:04.977Z","type":"response_item","payload":{"type":"custom_tool_call","call_id":"call_1","name":"exec","input":"const r = await tools.exec_command({\"cmd\":\"ls\"});"}}"#,
327 "\n",
328 r#"{"timestamp":"2026-08-05T22:29:05.394Z","type":"response_item","payload":{"type":"custom_tool_call_output","call_id":"call_1","output":[{"type":"input_text","text":"Script completed\nOutput:\nREADME.md"}]}}"#,
329 "\n",
330 r#"{"timestamp":"2026-08-05T22:29:06.921Z","type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"DONE"}],"phase":"final_answer"}}"#,
331 "\n",
332 );
333
334 fn fixture_tree() -> tempfile::TempDir {
335 let tmp = tempfile::tempdir().unwrap();
336 let day = tmp.path().join("2026/08/05");
337 std::fs::create_dir_all(&day).unwrap();
338 std::fs::write(
339 day.join("rollout-2026-08-05T22-29-00-019fd40b-55d5-7a72-8ecb-611abc36879e.jsonl"),
340 ROLLOUT,
341 )
342 .unwrap();
343 tmp
344 }
345
346 fn parsed() -> Conversation {
347 let tmp = fixture_tree();
348 let adapter = CodexTranscripts::new(tmp.path().to_path_buf());
349 let found = adapter.discover(None).unwrap();
350 adapter.parse(&found[0]).unwrap()
351 }
352
353 #[test]
354 fn discovery_walks_the_date_nested_directories() {
355 let tmp = fixture_tree();
356 let adapter = CodexTranscripts::new(tmp.path().to_path_buf());
357
358 let found = adapter.discover(None).unwrap();
359 assert_eq!(found.len(), 1);
360 assert_eq!(found[0].session_id, "019fd40b-55d5-7a72-8ecb-611abc36879e");
361 assert_eq!(found[0].source, Source::Codex);
362 }
363
364 #[test]
365 fn session_ids_come_from_the_trailing_uuid() {
366 assert_eq!(
367 session_id_from_stem(
368 "rollout-2026-08-05T22-29-00-019fd40b-55d5-7a72-8ecb-611abc36879e"
369 ),
370 "019fd40b-55d5-7a72-8ecb-611abc36879e"
371 );
372 assert_eq!(session_id_from_stem("odd-name"), "odd-name");
373 }
374
375 #[test]
377 fn developer_turns_are_not_conversation() {
378 let conv = parsed();
379 for entry in &conv.entries {
380 let text = match entry {
381 ConversationEntry::UserMessage(t) | ConversationEntry::AssistantText(t) => t,
382 _ => continue,
383 };
384 assert!(!text.contains("permissions instructions"), "{text}");
385 }
386 }
387
388 #[test]
389 fn injected_user_blocks_are_dropped_and_the_real_prompt_is_kept() {
390 let conv = parsed();
391 let users: Vec<&String> = conv
392 .entries
393 .iter()
394 .filter_map(|e| match e {
395 ConversationEntry::UserMessage(t) => Some(t),
396 _ => None,
397 })
398 .collect();
399 assert_eq!(users.len(), 1);
400 assert_eq!(users[0], "List the files here, then reply DONE");
401 assert_eq!(conv.user_message_count, 1);
402 }
403
404 #[test]
407 fn without_mirroring_events_every_user_item_is_kept() {
408 let raw = concat!(
409 r#"{"timestamp":"2026-08-05T22:29:02.329Z","type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"first"}]}}"#,
410 "\n",
411 r#"{"timestamp":"2026-08-05T22:29:02.330Z","type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"second"}]}}"#,
412 "\n",
413 );
414 let conv = parse_rollout(raw, "fallback");
415 assert_eq!(conv.user_message_count, 2);
416 assert_eq!(conv.session_id, "fallback");
417 }
418
419 #[test]
420 fn reasoning_never_reaches_the_archive() {
421 let conv = parsed();
422 let markdown = crate::conversation::conversation_to_markdown(&conv, 1);
423 assert!(!markdown.contains("gAAAAAsecret"), "{markdown}");
424 }
425
426 #[test]
427 fn tool_calls_and_results_survive() {
428 let conv = parsed();
429 let tools: Vec<&ConversationEntry> = conv
430 .entries
431 .iter()
432 .filter(|e| {
433 matches!(
434 e,
435 ConversationEntry::ToolUse { .. } | ConversationEntry::ToolResult { .. }
436 )
437 })
438 .collect();
439 assert_eq!(tools.len(), 2);
440 match tools[0] {
441 ConversationEntry::ToolUse {
442 name,
443 input_summary,
444 } => {
445 assert_eq!(name, "exec");
446 assert!(input_summary.contains("exec_command"), "{input_summary}");
447 }
448 other => panic!("expected a tool call, got {other:?}"),
449 }
450 match tools[1] {
451 ConversationEntry::ToolResult { content, is_error } => {
452 assert!(content.contains("README.md"), "{content}");
453 assert!(!is_error);
454 }
455 other => panic!("expected a tool result, got {other:?}"),
456 }
457 }
458
459 #[test]
460 fn metadata_comes_from_the_session_and_the_line_timestamps() {
461 let conv = parsed();
462 assert_eq!(conv.session_id, "019fd40b-55d5-7a72-8ecb-611abc36879e");
463 assert_eq!(conv.assistant_message_count, 2);
464 assert_eq!(
465 conv.first_timestamp.as_deref(),
466 Some("2026-08-05T22:29:00.878Z")
467 );
468 assert_eq!(
469 conv.last_timestamp.as_deref(),
470 Some("2026-08-05T22:29:06.921Z")
471 );
472 }
473
474 #[test]
475 fn a_malformed_line_does_not_lose_the_session() {
476 let raw = concat!(
477 "not json at all\n",
478 r#"{"timestamp":"2026-08-05T22:29:02.329Z","type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"still here"}]}}"#,
479 "\n",
480 );
481 let conv = parse_rollout(raw, "s");
482 assert_eq!(conv.user_message_count, 1);
483 }
484}