sessionwiki/adapters/
codex.rs1use 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#[derive(Default)]
21pub struct Codex {
22 root: Option<PathBuf>,
24}
25
26impl Codex {
27 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 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(); }
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 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 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; 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 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 _ => {}
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; } 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
236fn 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
263fn 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 #[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}