Skip to main content

sessionwiki/adapters/
codex.rs

1use super::{
2    dedup_paths, ok_or_flag, parse_ts, redacted_truncate, title_from_messages, Adapter, Discovered,
3};
4use crate::model::{Message, Role, Session};
5use crate::util::short_id;
6use anyhow::Result;
7use serde_json::Value;
8use std::collections::HashMap;
9use std::path::{Path, PathBuf};
10use walkdir::WalkDir;
11
12/// Codex CLI stores one JSONL rollout per session under
13/// `~/.codex/sessions/YYYY/MM/DD/rollout-<ts>-<uuid>.jsonl`.
14/// Lines carry a `type` plus a `payload`; the schema has shifted across
15/// versions, so both `response_item` and `event_msg` shapes are handled.
16///
17/// `Codex::default()` reads the stock `~/.codex` install. An embedder that
18/// runs several Codex installs in different homes builds one adapter per
19/// install with [`Codex::in_home`].
20#[derive(Default)]
21pub struct Codex {
22    /// Explicit sessions directory, or `None` for the stock location.
23    root: Option<PathBuf>,
24}
25
26impl Codex {
27    /// An adapter for the Codex install rooted at `home` (e.g. `~/.codex2`).
28    /// The sessions sub-directory layout is the adapter's business, not the
29    /// caller's.
30    pub fn in_home(home: impl Into<PathBuf>) -> Self {
31        Codex {
32            root: Some(home.into().join("sessions")),
33        }
34    }
35}
36
37impl Adapter for Codex {
38    fn name(&self) -> &'static str {
39        "codex"
40    }
41
42    fn root(&self) -> Option<PathBuf> {
43        match &self.root {
44            Some(root) => Some(root.clone()),
45            None => Some(dirs::home_dir()?.join(".codex").join("sessions")),
46        }
47    }
48
49    /// An explicit install speaks only for the rows under its own root; the
50    /// stock adapter speaks for every Codex row, as before.
51    fn reconcile_scope(&self) -> Option<String> {
52        super::root_scope(self.root.as_deref())
53    }
54
55    fn discover(&self) -> Discovered {
56        let Some(root) = self.root() else {
57            return Vec::new().into();
58        };
59        if !root.exists() {
60            return Vec::new().into(); // no store on this machine - normal
61        }
62        let mut had_error = false;
63        let files = WalkDir::new(root)
64            .into_iter()
65            .filter_map(|e| ok_or_flag(e, &mut had_error))
66            .filter(|e| e.file_type().is_file())
67            .filter(|e| {
68                let name = e.file_name().to_string_lossy();
69                name.starts_with("rollout-") && name.ends_with(".jsonl")
70            })
71            .map(|e| e.into_path())
72            .collect();
73        Discovered { files, had_error }
74    }
75
76    fn parse(&self, path: &Path) -> Result<Session> {
77        // Over the byte cap: window (head+tail) instead of dropping the session.
78        let (lines, windowed) = crate::util::session_lines(path)?;
79
80        let mut messages: Vec<Message> = Vec::new();
81        let mut touched: Vec<String> = Vec::new();
82        let mut cwd: Option<String> = None;
83        let mut started = None;
84        let mut ended = None;
85        // Current rollouts carry each user prompt TWICE (event_msg AND
86        // response_item). Counted multisets cancel each pair exactly once, so
87        // a genuinely repeated prompt ("continue" twice) still indexes twice.
88        let mut response_user_texts: HashMap<String, u32> = HashMap::new();
89        let mut event_user_texts: HashMap<String, u32> = HashMap::new();
90
91        for line in &lines {
92            let Ok(v) = serde_json::from_str::<Value>(line) else {
93                continue;
94            };
95
96            let ts = v
97                .get("timestamp")
98                .and_then(Value::as_str)
99                .and_then(parse_ts);
100            if let Some(t) = ts {
101                if started.is_none() {
102                    started = Some(t);
103                }
104                ended = Some(t);
105            }
106
107            match v.get("type").and_then(Value::as_str) {
108                Some("session_meta") => {
109                    if cwd.is_none() {
110                        cwd = v
111                            .pointer("/payload/cwd")
112                            .and_then(Value::as_str)
113                            .map(String::from);
114                    }
115                }
116                Some("response_item") => {
117                    match v.pointer("/payload/type").and_then(Value::as_str) {
118                        Some("message") => {
119                            let role = match v.pointer("/payload/role").and_then(Value::as_str) {
120                                Some("user") => Role::User,
121                                Some("assistant") => Role::Assistant,
122                                _ => continue,
123                            };
124                            let Some(Value::Array(blocks)) = v.pointer("/payload/content") else {
125                                continue;
126                            };
127                            for b in blocks {
128                                let Some(text) = b.get("text").and_then(Value::as_str) else {
129                                    continue;
130                                };
131                                if role == Role::User && is_boilerplate(text) {
132                                    continue;
133                                }
134                                if role == Role::User {
135                                    if let Some(n) =
136                                        event_user_texts.get_mut(text).filter(|n| **n > 0)
137                                    {
138                                        *n -= 1; // pairs with an already-pushed event_msg
139                                        continue;
140                                    }
141                                    *response_user_texts.entry(text.to_string()).or_insert(0) += 1;
142                                }
143                                push(&mut messages, role, text, ts);
144                            }
145                        }
146                        Some("function_call" | "custom_tool_call") => {
147                            let name = v
148                                .pointer("/payload/name")
149                                .and_then(Value::as_str)
150                                .unwrap_or("?");
151                            let args = v
152                                .pointer("/payload/arguments")
153                                .or_else(|| v.pointer("/payload/input"))
154                                .and_then(Value::as_str)
155                                .unwrap_or("");
156                            // Codex applies edits via an apply_patch envelope,
157                            // whether the call is named apply_patch or a shell
158                            // wrapping it. Scan the full args for the file
159                            // markers before the message text is truncated.
160                            collect_patched_paths(args, &mut touched);
161                            let text = format!("{name} {}", redacted_truncate(args, 300));
162                            push(&mut messages, Role::Tool, &text, ts);
163                        }
164                        // function_call_output and reasoning are skipped on
165                        // purpose: they dominate file size and pollute search.
166                        _ => {}
167                    }
168                }
169                Some("event_msg") => match v.pointer("/payload/type").and_then(Value::as_str) {
170                    Some("user_message") => {
171                        if let Some(t) = v.pointer("/payload/message").and_then(Value::as_str) {
172                            if !is_boilerplate(t) {
173                                if let Some(n) = response_user_texts.get_mut(t).filter(|n| **n > 0)
174                                {
175                                    *n -= 1; // pairs with an already-pushed response_item
176                                } else {
177                                    *event_user_texts.entry(t.to_string()).or_insert(0) += 1;
178                                    push(&mut messages, Role::User, t, ts);
179                                }
180                            }
181                        }
182                    }
183                    Some("agent_message") => {
184                        if let Some(t) = v.pointer("/payload/message").and_then(Value::as_str) {
185                            push(&mut messages, Role::Assistant, t, ts);
186                        }
187                    }
188                    _ => {}
189                },
190                Some("message") => {
191                    let role = match v.get("role").and_then(Value::as_str) {
192                        Some("user") => Role::User,
193                        Some("assistant") => Role::Assistant,
194                        _ => continue,
195                    };
196                    let Some(Value::Array(blocks)) = v.get("content") else {
197                        continue;
198                    };
199                    for b in blocks {
200                        let Some(text) = b.get("text").and_then(Value::as_str) else {
201                            continue;
202                        };
203                        if role == Role::User && is_boilerplate(text) {
204                            continue;
205                        }
206                        push(&mut messages, role, text, ts);
207                    }
208                }
209                _ => {}
210            }
211        }
212
213        let project = cwd.unwrap_or_default();
214        let title = if windowed {
215            format!("[large] {}", title_from_messages(&messages))
216        } else {
217            title_from_messages(&messages)
218        };
219
220        Ok(Session {
221            id: short_id(&path.to_string_lossy()),
222            tool: self.name(),
223            path: path.to_path_buf(),
224            project,
225            started,
226            ended,
227            title,
228            subagent: false,
229            messages,
230            touched: dedup_paths(touched),
231            edits: Vec::new(),
232        })
233    }
234}
235
236/// Extract the files an apply_patch touched from the raw call arguments. The
237/// patch format names each file on a header line - `*** Add File: path`,
238/// `*** Update File: path`, `*** Delete File: path`, `*** Move to: path` -
239/// regardless of whether the call arrives as a dedicated apply_patch function
240/// or a shell command wrapping a heredoc. Newlines may be JSON-escaped (\\n)
241/// when the patch is embedded in an arguments string, so handle both.
242fn collect_patched_paths(args: &str, out: &mut Vec<String>) {
243    const MARKERS: [&str; 4] = [
244        "*** Add File: ",
245        "*** Update File: ",
246        "*** Delete File: ",
247        "*** Move to: ",
248    ];
249    let normalized = args.replace("\\n", "\n");
250    for line in normalized.lines() {
251        let line = line.trim();
252        for m in MARKERS {
253            if let Some(rest) = line.strip_prefix(m) {
254                let path = rest.trim().trim_matches('"');
255                if !path.is_empty() {
256                    out.push(path.to_string());
257                }
258            }
259        }
260    }
261}
262
263/// Codex wraps instructions and environment dumps in pseudo-XML tags and
264/// repeats them in every session. Indexing them buries real matches.
265fn is_boilerplate(text: &str) -> bool {
266    let t = text.trim_start();
267    t.starts_with("<user_instructions>")
268        || t.starts_with("<environment_context>")
269        || t.starts_with("<ENVIRONMENT_CONTEXT>")
270        || t.starts_with("<turn_context>")
271        || t.starts_with("# AGENTS.md instructions")
272        || t.starts_with("<INSTRUCTIONS>")
273}
274
275fn push(
276    messages: &mut Vec<Message>,
277    role: Role,
278    text: &str,
279    ts: Option<chrono::DateTime<chrono::Utc>>,
280) {
281    let text = text.trim();
282    if !text.is_empty() {
283        messages.push(Message {
284            role,
285            text: text.to_string(),
286            ts,
287        });
288    }
289}
290
291#[cfg(test)]
292mod tests {
293    use super::*;
294
295    /// An embedder points one adapter at each Codex install. The adapter must
296    /// find that install's rollouts and claim only that install's rows for
297    /// deletion reconciliation.
298    #[test]
299    fn in_home_discovers_that_installs_rollouts_and_scopes_reconciliation() {
300        let home = tempfile::tempdir().unwrap();
301        let day = home
302            .path()
303            .join("sessions")
304            .join("2026")
305            .join("01")
306            .join("01");
307        std::fs::create_dir_all(&day).unwrap();
308        let file = day.join("rollout-2026-01-01T10-00-00-abc.jsonl");
309        std::fs::write(
310            &file,
311            "{\"type\":\"session_meta\",\"payload\":{\"cwd\":\"/repo\"}}\n",
312        )
313        .unwrap();
314
315        let adapter = Codex::in_home(home.path());
316        let found = adapter.discover();
317        assert!(!found.had_error);
318        assert_eq!(found.files, vec![file.clone()]);
319
320        let scope = adapter
321            .reconcile_scope()
322            .expect("an explicit install is scoped");
323        assert!(
324            file.to_string_lossy().starts_with(&scope),
325            "{scope} must be a prefix of the discovered {}",
326            file.display()
327        );
328        assert_eq!(
329            Codex::default().reconcile_scope(),
330            None,
331            "the stock adapter still speaks for every codex row"
332        );
333    }
334}