Skip to main content

recall_echo/transcript/
codex.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5//! Codex CLI transcripts — `~/.codex/sessions/YYYY/MM/DD/rollout-<iso>-<uuid>.jsonl`.
6//!
7//! Every line is `{"timestamp":…, "type":…, "payload":…}`. Four line types
8//! matter and the rest are the CLI talking to itself:
9//!
10//! ```text
11//! session_meta   the session's id, cwd and start time      (first line)
12//! response_item  the model conversation, one item per line
13//! event_msg      the CLI's own event stream                (see below)
14//! world_state    workspace snapshots — ignored
15//! turn_context   sandbox and approval settings — ignored
16//! ```
17//!
18//! Inside `response_item`, `payload.role` is `user`, `assistant` or
19//! **`developer`** — and `developer` is the harness: permission instructions,
20//! agent-team rules, mode switches. It is dropped, or every Codex archive would
21//! open with the sandbox policy recorded as something the user said.
22//!
23//! # Which user turns are real
24//!
25//! Dropping `developer` is not enough. Codex also injects text under
26//! `role: "user"` — this machine's transcripts all begin with a
27//! `<recommended_plugins>` catalogue nobody typed — and nothing in the record
28//! itself distinguishes it from a prompt.
29//!
30//! The CLI's own event stream does distinguish it: a real prompt is mirrored as
31//! `event_msg` with `payload.type = "user_message"`, whose `message` is byte
32//! identical to the prompt, and the injected blocks are not mirrored. So a
33//! user-role item counts as a turn when the file mirrors it as a user message —
34//! and when a file has no such events at all (a Codex build that does not emit
35//! them), every user-role item is kept, because losing the human's side
36//! entirely is the worse failure.
37
38use 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
48/// Characters of a tool call's arguments kept in the archive.
49const TOOL_INPUT_CHARS: usize = 200;
50/// Characters of a tool result kept in the archive — the same budget the
51/// Claude Code parser uses, so archives from the two CLIs read alike.
52const TOOL_RESULT_CHARS: usize = 2000;
53/// Length of a hyphenated UUID.
54const UUID_LEN: usize = 36;
55
56/// Codex's session records.
57#[derive(Debug, Clone)]
58pub struct CodexTranscripts {
59    sessions_dir: PathBuf,
60}
61
62impl CodexTranscripts {
63    /// Read sessions from an explicit `sessions/` directory.
64    #[must_use]
65    pub fn new(sessions_dir: PathBuf) -> Self {
66        Self { sessions_dir }
67    }
68
69    /// Read sessions from this machine's Codex installation.
70    #[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
108/// A session id from `rollout-<iso8601>-<uuid>`.
109///
110/// The trailing UUID is the id Codex records inside the file; the stem is used
111/// verbatim when it does not end in one, so an unexpected name still gives a
112/// stable, unique key rather than a collision.
113fn 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// ── Line model ───────────────────────────────────────────────────────────
135
136#[derive(Deserialize)]
137struct RolloutLine {
138    #[serde(rename = "type")]
139    kind: String,
140    timestamp: Option<String>,
141    payload: Option<serde_json::Value>,
142}
143
144/// Parse a rollout file's contents into a conversation.
145fn parse_rollout(raw: &str, fallback_session_id: &str) -> Conversation {
146    let mut conv = Conversation::new(fallback_session_id);
147    // Positions of user turns, so the ones the CLI never mirrored as prompts
148    // can be dropped once the whole file has been read.
149    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
196/// Turn one `response_item` into conversation entries, if it is one.
197fn 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                // `developer` is the harness, and `system` would be too.
224                _ => {}
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        // `reasoning` carries the model's private, unasserted thinking (and an
254        // encrypted blob). It is not conversation.
255        _ => {}
256    }
257}
258
259/// Text of a field that is a string, a block list, or something structured.
260fn 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
268/// Drop user turns the CLI never mirrored as prompts — the injected ones.
269fn 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    /// Real shapes from a Codex 0.146 rollout, scrubbed: a session_meta line, a
302    /// developer instruction, an injected `<recommended_plugins>` user block, a
303    /// real prompt with its mirroring event, a reasoning item, an assistant
304    /// message, and one tool round trip.
305    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    /// The trap this adapter exists for: `developer` is the system prompt.
376    #[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    /// No mirroring events at all: keep every user-role item rather than
405    /// capture a session with no human side.
406    #[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}