Skip to main content

nomoreide_core/
agent_transcripts.rs

1use chrono::{DateTime, Utc};
2use serde::Serialize;
3use serde_json::Value;
4use std::collections::HashSet;
5use std::fs::{self, File};
6use std::io::{BufRead, BufReader};
7use std::path::{Path, PathBuf};
8
9const MAX_HEAD_LINES: usize = 500;
10const MAX_CODEX_SCAN: usize = 400;
11const MAX_TITLE: usize = 200;
12pub const DEFAULT_TRANSCRIPT_LIMIT: usize = 100;
13
14#[derive(Debug, Clone, Serialize, PartialEq)]
15#[serde(rename_all = "camelCase")]
16pub struct AgentTranscript {
17    pub id: String,
18    pub provider: String,
19    pub cwd: String,
20    pub title: String,
21    pub started_at: String,
22    pub updated_at: String,
23}
24
25fn path_key(value: &str) -> String {
26    let mut key = String::new();
27    let mut separated = true;
28    for character in value.chars() {
29        if character.is_ascii_alphanumeric() {
30            key.push(character.to_ascii_lowercase());
31            separated = false;
32        } else if !separated && !key.is_empty() {
33            key.push('-');
34            separated = true;
35        }
36    }
37    if key.ends_with('-') {
38        key.pop();
39    }
40    key
41}
42
43fn title(text: &str) -> Option<String> {
44    let trimmed = text.trim();
45    if trimmed.is_empty()
46        || trimmed.starts_with('<')
47        || trimmed.starts_with("# AGENTS.md instructions for ")
48    {
49        return None;
50    }
51    let collapsed = trimmed.split_whitespace().collect::<Vec<_>>().join(" ");
52    if collapsed.chars().count() <= MAX_TITLE {
53        return Some(collapsed);
54    }
55    let shortened = collapsed.chars().take(MAX_TITLE - 1).collect::<String>();
56    Some(format!("{}…", shortened.trim_end()))
57}
58
59fn content_text(content: &Value) -> String {
60    match content {
61        Value::String(text) => text.clone(),
62        Value::Array(blocks) => blocks
63            .iter()
64            .filter_map(|block| block.get("text").and_then(Value::as_str))
65            .collect(),
66        _ => String::new(),
67    }
68}
69
70fn modified_at(path: &Path) -> Option<String> {
71    let modified = fs::metadata(path).ok()?.modified().ok()?;
72    Some(DateTime::<Utc>::from(modified).to_rfc3339())
73}
74
75fn json_lines(path: &Path) -> impl Iterator<Item = Value> {
76    File::open(path)
77        .ok()
78        .into_iter()
79        .flat_map(|file| BufReader::new(file).lines().take(MAX_HEAD_LINES))
80        .filter_map(Result::ok)
81        .filter_map(|line| serde_json::from_str(&line).ok())
82}
83
84fn read_claude(path: &Path, fallback_id: &str) -> Option<AgentTranscript> {
85    let mut id = None;
86    let mut cwd = None;
87    let mut first_prompt = None;
88    let mut started_at = None;
89    for entry in json_lines(path) {
90        if id.is_none() {
91            id = entry
92                .get("sessionId")
93                .and_then(Value::as_str)
94                .map(str::to_owned);
95        }
96        if cwd.is_none() {
97            cwd = entry.get("cwd").and_then(Value::as_str).map(str::to_owned);
98        }
99        if started_at.is_none() {
100            started_at = entry
101                .get("timestamp")
102                .and_then(Value::as_str)
103                .map(str::to_owned);
104        }
105        if first_prompt.is_none()
106            && entry.get("type").and_then(Value::as_str) == Some("user")
107            && entry.get("isSidechain").and_then(Value::as_bool) != Some(true)
108        {
109            first_prompt = entry
110                .get("message")
111                .and_then(|message| message.get("content"))
112                .and_then(|content| title(&content_text(content)));
113        }
114        if cwd.is_some() && first_prompt.is_some() {
115            break;
116        }
117    }
118    let updated_at = modified_at(path)?;
119    Some(AgentTranscript {
120        id: id.unwrap_or_else(|| fallback_id.to_string()),
121        provider: "claude".to_string(),
122        cwd: cwd?,
123        title: first_prompt?,
124        started_at: started_at.unwrap_or_else(|| updated_at.clone()),
125        updated_at,
126    })
127}
128
129/// Codex writes each subagent thread to its own rollout file whose
130/// `session_meta` repeats the *parent's* `session_id`. Listing those would show
131/// one conversation once per subagent it spawned, all sharing a single id — and
132/// resuming any of them just reopens the parent. Claude's reader skips its
133/// sidechain turns for the same reason.
134fn is_codex_subagent_thread(payload: &Value) -> bool {
135    payload.get("thread_source").and_then(Value::as_str) == Some("subagent")
136        || payload
137            .get("forked_from_id")
138            .and_then(Value::as_str)
139            .is_some()
140        || payload
141            .get("parent_thread_id")
142            .and_then(Value::as_str)
143            .is_some()
144}
145
146fn read_codex(path: &Path, expected_cwd: Option<&str>) -> Option<AgentTranscript> {
147    let mut id = None;
148    let mut cwd = None;
149    let mut first_prompt = None;
150    let mut started_at = None;
151    for entry in json_lines(path) {
152        let Some(payload) = entry.get("payload") else {
153            continue;
154        };
155        if entry.get("type").and_then(Value::as_str) == Some("session_meta") {
156            if is_codex_subagent_thread(payload) {
157                return None;
158            }
159            id = payload
160                .get("session_id")
161                .or_else(|| payload.get("id"))
162                .and_then(Value::as_str)
163                .map(str::to_owned);
164            cwd = payload
165                .get("cwd")
166                .and_then(Value::as_str)
167                .map(str::to_owned);
168            started_at = payload
169                .get("timestamp")
170                .and_then(Value::as_str)
171                .map(str::to_owned);
172            if expected_cwd.is_some() && cwd.as_deref() != expected_cwd {
173                return None;
174            }
175        } else if first_prompt.is_none()
176            && payload.get("type").and_then(Value::as_str) == Some("message")
177            && payload.get("role").and_then(Value::as_str) == Some("user")
178        {
179            first_prompt = payload
180                .get("content")
181                .and_then(|content| title(&content_text(content)));
182        }
183        if id.is_some() && first_prompt.is_some() {
184            break;
185        }
186    }
187    let updated_at = modified_at(path)?;
188    Some(AgentTranscript {
189        id: id?,
190        provider: "codex".to_string(),
191        cwd: cwd.filter(|value| expected_cwd.map_or(true, |expected| value == expected))?,
192        title: first_prompt?,
193        started_at: started_at.unwrap_or_else(|| updated_at.clone()),
194        updated_at,
195    })
196}
197
198/// The session id is what `resume` takes, so two rows carrying the same one are
199/// the same conversation however many files it was written to. Only the most
200/// recently written copy is kept — a duplicate id is not just a repeated row,
201/// it collides in any list keyed by it. Expects newest-first input.
202fn dedupe_by_session(transcripts: &mut Vec<AgentTranscript>) {
203    let mut seen = HashSet::new();
204    transcripts.retain(|transcript| seen.insert(transcript.id.clone()));
205}
206
207fn directory_names(path: &Path) -> Vec<String> {
208    fs::read_dir(path)
209        .ok()
210        .into_iter()
211        .flatten()
212        .filter_map(Result::ok)
213        .filter_map(|entry| entry.file_name().into_string().ok())
214        .collect()
215}
216
217fn codex_files(codex_home: &Path) -> Vec<PathBuf> {
218    let root = codex_home.join("sessions");
219    let mut files = Vec::new();
220    let mut years = directory_names(&root);
221    years.sort_by(|a, b| b.cmp(a));
222    for year in years {
223        let year_path = root.join(year);
224        let mut months = directory_names(&year_path);
225        months.sort_by(|a, b| b.cmp(a));
226        for month in months {
227            let month_path = year_path.join(month);
228            let mut days = directory_names(&month_path);
229            days.sort_by(|a, b| b.cmp(a));
230            for day in days {
231                let day_path = month_path.join(day);
232                let mut names = directory_names(&day_path);
233                names.sort_by(|a, b| b.cmp(a));
234                files.extend(
235                    names
236                        .into_iter()
237                        .filter(|name| name.starts_with("rollout-") && name.ends_with(".jsonl"))
238                        .map(|name| day_path.join(name)),
239                );
240                if files.len() >= MAX_CODEX_SCAN {
241                    files.truncate(MAX_CODEX_SCAN);
242                    return files;
243                }
244            }
245        }
246    }
247    files
248}
249
250/// Where the two CLIs keep their transcripts.
251///
252/// `CODEX_HOME` is read from the environment rather than derived, because Codex
253/// itself reads it there and an installation that moved is still the one whose
254/// sessions should be listed.
255pub fn default_transcript_homes() -> (PathBuf, PathBuf) {
256    let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
257    let codex = std::env::var_os("CODEX_HOME")
258        .map(PathBuf::from)
259        .unwrap_or_else(|| home.join(".codex"));
260    (home, codex)
261}
262
263pub fn list_agent_transcripts(
264    home: &Path,
265    codex_home: &Path,
266    repo_path: Option<&str>,
267    limit_per_provider: usize,
268) -> Vec<AgentTranscript> {
269    let mut claude = Vec::new();
270    let claude_root = home.join(".claude").join("projects");
271    let expected_key = repo_path.map(path_key);
272    for directory in directory_names(&claude_root).into_iter().filter(|name| {
273        expected_key
274            .as_ref()
275            .map_or(true, |expected| path_key(name) == *expected)
276    }) {
277        let path = claude_root.join(directory);
278        for name in directory_names(&path)
279            .into_iter()
280            .filter(|name| name.ends_with(".jsonl"))
281        {
282            if let Some(transcript) =
283                read_claude(&path.join(&name), name.trim_end_matches(".jsonl"))
284            {
285                if repo_path.map_or(true, |expected| transcript.cwd == expected) {
286                    claude.push(transcript);
287                }
288            }
289        }
290    }
291    let mut codex = codex_files(codex_home)
292        .iter()
293        .filter_map(|path| read_codex(path, repo_path))
294        .collect::<Vec<_>>();
295    claude.sort_by(|left, right| right.updated_at.cmp(&left.updated_at));
296    codex.sort_by(|left, right| right.updated_at.cmp(&left.updated_at));
297    dedupe_by_session(&mut claude);
298    dedupe_by_session(&mut codex);
299    claude.truncate(limit_per_provider);
300    codex.truncate(limit_per_provider);
301    let mut transcripts = claude;
302    transcripts.extend(codex);
303    transcripts.sort_by(|left, right| right.updated_at.cmp(&left.updated_at));
304    transcripts
305}
306
307#[cfg(test)]
308mod tests {
309    use super::list_agent_transcripts;
310    use std::fs;
311
312    #[test]
313    fn lists_claude_and_codex_sessions_for_the_requested_repository() {
314        let home = std::env::temp_dir().join(format!(
315            "nomoreide-agent-transcripts-{}",
316            uuid::Uuid::new_v4()
317        ));
318        let repo = "/tmp/work/repo";
319        let claude_dir = home.join(".claude/projects/-tmp-work-repo");
320        let codex_dir = home.join(".codex/sessions/2026/07/25");
321        fs::create_dir_all(&claude_dir).unwrap();
322        fs::create_dir_all(&codex_dir).unwrap();
323        fs::write(
324            claude_dir.join("dce2b69c-0fb4-4bd3-b456-b2bef4230c81.jsonl"),
325            concat!(
326                "{\"type\":\"mode\",\"sessionId\":\"dce2b69c-0fb4-4bd3-b456-b2bef4230c81\"}\n",
327                "{\"type\":\"user\",\"cwd\":\"/tmp/work/repo\",\"timestamp\":\"2026-07-25T01:00:00Z\",",
328                "\"message\":{\"content\":\"Resume the dock\"}}\n"
329            ),
330        )
331        .unwrap();
332        fs::write(
333            codex_dir.join(
334                "rollout-2026-07-25T02-00-00-019f7c82-cb32-73d3-9ffd-7425ddb8dbb4.jsonl",
335            ),
336            concat!(
337                "{\"type\":\"session_meta\",\"payload\":{\"session_id\":\"019f7c82-cb32-73d3-9ffd-7425ddb8dbb4\",",
338                "\"cwd\":\"/tmp/work/repo\",\"timestamp\":\"2026-07-25T02:00:00Z\"}}\n",
339                "{\"type\":\"response_item\",\"payload\":{\"type\":\"message\",\"role\":\"user\",",
340                "\"content\":[{\"type\":\"input_text\",\"text\":\"Finish conversation history\"}]}}\n"
341            ),
342        )
343        .unwrap();
344
345        let transcripts = list_agent_transcripts(&home, &home.join(".codex"), Some(repo), 30);
346
347        assert_eq!(transcripts.len(), 2);
348        assert!(transcripts
349            .iter()
350            .any(|row| { row.provider == "claude" && row.title == "Resume the dock" }));
351        assert!(transcripts
352            .iter()
353            .any(|row| { row.provider == "codex" && row.title == "Finish conversation history" }));
354        assert_eq!(
355            list_agent_transcripts(&home, &home.join(".codex"), Some(repo), 1).len(),
356            2,
357        );
358        fs::remove_dir_all(home).unwrap();
359    }
360
361    #[test]
362    fn drops_codex_subagent_threads_that_repeat_their_parents_session_id() {
363        let home = std::env::temp_dir().join(format!(
364            "nomoreide-agent-subagents-{}",
365            uuid::Uuid::new_v4()
366        ));
367        let repo = "/tmp/work/repo";
368        let codex_dir = home.join(".codex/sessions/2026/07/25");
369        fs::create_dir_all(&codex_dir).unwrap();
370        fs::write(
371            codex_dir.join("rollout-2026-07-25T02-00-00-parent.jsonl"),
372            concat!(
373                "{\"type\":\"session_meta\",\"payload\":{\"session_id\":\"parent\",\"id\":\"parent\",",
374                "\"thread_source\":\"user\",\"cwd\":\"/tmp/work/repo\",\"timestamp\":\"2026-07-25T02:00:00Z\"}}\n",
375                "{\"type\":\"response_item\",\"payload\":{\"type\":\"message\",\"role\":\"user\",",
376                "\"content\":[{\"type\":\"input_text\",\"text\":\"the real task\"}]}}\n"
377            ),
378        )
379        .unwrap();
380        // A subagent thread: its own file and id, but the parent's session_id.
381        fs::write(
382            codex_dir.join("rollout-2026-07-25T02-05-00-child.jsonl"),
383            concat!(
384                "{\"type\":\"session_meta\",\"payload\":{\"session_id\":\"parent\",\"id\":\"child\",",
385                "\"forked_from_id\":\"parent\",\"parent_thread_id\":\"parent\",\"thread_source\":\"subagent\",",
386                "\"cwd\":\"/tmp/work/repo\",\"timestamp\":\"2026-07-25T02:05:00Z\"}}\n",
387                "{\"type\":\"response_item\",\"payload\":{\"type\":\"message\",\"role\":\"user\",",
388                "\"content\":[{\"type\":\"input_text\",\"text\":\"subagent instruction\"}]}}\n"
389            ),
390        )
391        .unwrap();
392
393        let transcripts = list_agent_transcripts(&home, &home.join(".codex"), Some(repo), 30);
394
395        assert_eq!(transcripts.len(), 1);
396        assert_eq!(transcripts[0].id, "parent");
397        assert_eq!(transcripts[0].title, "the real task");
398        fs::remove_dir_all(home).unwrap();
399    }
400}