Skip to main content

agent_doc_model_tier/
context_transcript_io.rs

1//! File-backed transcript context usage helpers (`#s760a`).
2//!
3//! This module owns the harness transcript locator/read layer. Pure token
4//! parsing, context-window lookup, percentage math, and clear/no-clear policy
5//! stay in [`crate::context_usage`].
6
7use crate::context_usage::{
8    Harness, TranscriptContextPctDiagnostic, UsedTokens, parse_claude_jsonl_used_tokens,
9    parse_codex_jsonl_session_meta_cwd, transcript_context_pct_from_content,
10};
11use std::path::{Path, PathBuf};
12
13/// Read cumulative token usage from a transcript file for the given harness
14/// (`#s760a`). Claude -> parse JSONL; OpenCode -> `None` (unsupported until its
15/// transcript store is confirmed live, so the caller fails safe and never
16/// clears). Codex usage needs the event-provided context window, so callers use
17/// [`transcript_context_pct_from_content`] through [`transcript_context_pct`] instead
18/// of this raw token helper. A missing/unreadable file is `None`.
19pub fn read_used_tokens(harness: Harness, transcript: &Path) -> Option<UsedTokens> {
20    match harness {
21        Harness::Claude => {
22            let content = std::fs::read_to_string(transcript).ok()?;
23            parse_claude_jsonl_used_tokens(&content)
24        }
25        Harness::Codex | Harness::OpenCode => {
26            eprintln!(
27                "[s760] raw transcript token reading unsupported for {harness:?}; ctx% None (never clears)"
28            );
29            None
30        }
31    }
32}
33
34/// Read a transcript and compute ctx% in one call (`#s760a` + `#s760b`). `None`
35/// (never clear) on any failure: unreadable/empty transcript, unsupported
36/// harness, or unknown model.
37pub fn transcript_context_pct(harness: Harness, transcript: &Path, model: &str) -> Option<f64> {
38    let content;
39    let result = match harness {
40        Harness::Claude | Harness::Codex => {
41            content = std::fs::read_to_string(transcript).ok()?;
42            transcript_context_pct_from_content(harness, &content, model)
43        }
44        Harness::OpenCode => transcript_context_pct_from_content(harness, "", model),
45    };
46    match result.diagnostic {
47        Some(TranscriptContextPctDiagnostic::UnknownModel) => {
48            eprintln!(
49                "[s760] WARNING: unknown model {model:?}; context window unknown, ctx% None (never clears)"
50            );
51        }
52        Some(TranscriptContextPctDiagnostic::UnsupportedHarness) => {
53            eprintln!(
54                "[s760] transcript context reading unsupported for {harness:?}; ctx% None (never clears)"
55            );
56        }
57        None => {}
58    }
59    result.pct
60}
61
62/// Locate the active Claude Code session transcript as the most-recently-modified
63/// `*.jsonl` under `projects_subdir` (`#s760c` live locator). The supervisor does
64/// not track the managed harness's session id, so newest-mtime is the live signal
65/// for "the transcript this session is writing". Returns `None` when the directory
66/// is absent/unreadable or holds no `.jsonl` file, so the caller fails safe and
67/// never clears.
68pub fn latest_claude_transcript(projects_subdir: &Path) -> Option<PathBuf> {
69    let mut newest: Option<(std::time::SystemTime, PathBuf)> = None;
70    for entry in std::fs::read_dir(projects_subdir).ok()?.flatten() {
71        let path = entry.path();
72        if path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
73            continue;
74        }
75        let Ok(modified) = entry.metadata().and_then(|m| m.modified()) else {
76            continue;
77        };
78        if newest.as_ref().is_none_or(|(best, _)| modified > *best) {
79            newest = Some((modified, path));
80        }
81    }
82    newest.map(|(_, path)| path)
83}
84
85fn codex_session_meta_cwd_from_file(path: &Path) -> Option<PathBuf> {
86    let content = std::fs::read_to_string(path).ok()?;
87    parse_codex_jsonl_session_meta_cwd(&content)
88}
89
90fn path_matches_project_dir(path: &Path, project_dir: &Path) -> bool {
91    if path == project_dir {
92        return true;
93    }
94    match (path.canonicalize(), project_dir.canonicalize()) {
95        (Ok(a), Ok(b)) => a == b,
96        _ => false,
97    }
98}
99
100/// Locate the newest Codex TUI session transcript for the current project.
101/// Codex stores sessions under `~/.codex/sessions/<year>/<month>/<day>/`; the
102/// first `session_meta` record carries `payload.cwd`, which is the stable
103/// project match key.
104pub fn latest_codex_transcript(home: &Path, project_dir: &Path) -> Option<PathBuf> {
105    let root = home.join(".codex").join("sessions");
106    let mut stack = vec![root];
107    let mut newest: Option<(std::time::SystemTime, PathBuf)> = None;
108    while let Some(dir) = stack.pop() {
109        let Ok(entries) = std::fs::read_dir(dir) else {
110            continue;
111        };
112        for entry in entries.flatten() {
113            let path = entry.path();
114            let Ok(metadata) = entry.metadata() else {
115                continue;
116            };
117            if metadata.is_dir() {
118                stack.push(path);
119                continue;
120            }
121            if path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
122                continue;
123            }
124            let Some(file_name) = path.file_name().and_then(|name| name.to_str()) else {
125                continue;
126            };
127            if !file_name.starts_with("rollout-") {
128                continue;
129            }
130            let Ok(modified) = metadata.modified() else {
131                continue;
132            };
133            if newest.as_ref().is_some_and(|(best, _)| modified <= *best) {
134                continue;
135            }
136            let Some(cwd) = codex_session_meta_cwd_from_file(&path) else {
137                continue;
138            };
139            if path_matches_project_dir(&cwd, project_dir) {
140                newest = Some((modified, path));
141            }
142        }
143    }
144    newest.map(|(_, path)| path)
145}
146
147#[cfg(test)]
148mod tests {
149    use super::*;
150    use std::io::Write;
151    use tempfile::NamedTempFile;
152
153    const FIXTURE: &str = r#"{"type":"user","message":{"role":"user","content":"hi"}}
154{"type":"assistant","message":{"role":"assistant","usage":{"input_tokens":10,"output_tokens":5,"cache_read_input_tokens":100,"cache_creation_input_tokens":20}}}
155{"type":"assistant","message":{"role":"assistant","usage":{"input_tokens":2,"output_tokens":4205,"cache_read_input_tokens":243320,"cache_creation_input_tokens":2232,"server_tool_use":{"web_search_requests":0}}}}
156"#;
157
158    #[test]
159    fn read_used_tokens_unsupported_harness_is_none() {
160        let tmp = NamedTempFile::new().unwrap();
161        assert!(read_used_tokens(Harness::Codex, tmp.path()).is_none());
162        assert!(read_used_tokens(Harness::OpenCode, tmp.path()).is_none());
163        assert!(
164            read_used_tokens(Harness::Claude, Path::new("/no/such/transcript.jsonl")).is_none()
165        );
166    }
167
168    #[test]
169    fn transcript_context_pct_end_to_end() {
170        let mut tmp = NamedTempFile::new().unwrap();
171        tmp.write_all(FIXTURE.as_bytes()).unwrap();
172        let pct = transcript_context_pct(Harness::Claude, tmp.path(), "claude-opus-4-8").unwrap();
173        assert_eq!(pct, 100.0);
174
175        let mut small = NamedTempFile::new().unwrap();
176        small
177            .write_all(
178                b"{\"message\":{\"usage\":{\"input_tokens\":50000,\"output_tokens\":10000}}}\n",
179            )
180            .unwrap();
181        let pct = transcript_context_pct(Harness::Claude, small.path(), "sonnet").unwrap();
182        assert_eq!(pct, 30.0);
183
184        let mut codex = NamedTempFile::new().unwrap();
185        codex
186            .write_all(
187                br#"{"type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":20000,"cached_input_tokens":10000,"output_tokens":0},"model_context_window":100000}}}
188"#,
189            )
190            .unwrap();
191        assert_eq!(
192            transcript_context_pct(Harness::Codex, codex.path(), "gpt-5"),
193            Some(30.0)
194        );
195        assert!(transcript_context_pct(Harness::OpenCode, tmp.path(), "opus").is_none());
196    }
197
198    #[test]
199    fn latest_claude_transcript_picks_newest_jsonl() {
200        let dir = tempfile::tempdir().unwrap();
201        assert!(latest_claude_transcript(dir.path()).is_none());
202
203        let older = dir.path().join("old-session.jsonl");
204        let newer = dir.path().join("new-session.jsonl");
205        std::fs::write(&older, b"{}").unwrap();
206        std::fs::write(&newer, b"{}").unwrap();
207        let later = std::time::SystemTime::now() + std::time::Duration::from_secs(60);
208        filetime::set_file_mtime(&newer, filetime::FileTime::from_system_time(later)).unwrap();
209
210        assert_eq!(latest_claude_transcript(dir.path()), Some(newer));
211
212        let txt = dir.path().join("zzz.txt");
213        std::fs::write(&txt, b"x").unwrap();
214        let even_later = std::time::SystemTime::now() + std::time::Duration::from_secs(120);
215        filetime::set_file_mtime(&txt, filetime::FileTime::from_system_time(even_later)).unwrap();
216        assert_eq!(
217            latest_claude_transcript(dir.path()).unwrap().extension(),
218            Some(std::ffi::OsStr::new("jsonl"))
219        );
220    }
221
222    #[test]
223    fn latest_claude_transcript_missing_dir_is_none() {
224        assert!(latest_claude_transcript(Path::new("/no/such/projects/dir")).is_none());
225    }
226
227    #[test]
228    fn latest_codex_transcript_picks_newest_matching_project() {
229        let home = tempfile::tempdir().unwrap();
230        let day = home
231            .path()
232            .join(".codex")
233            .join("sessions")
234            .join("2026")
235            .join("06")
236            .join("15");
237        std::fs::create_dir_all(&day).unwrap();
238        let project = home.path().join("project");
239        std::fs::create_dir_all(&project).unwrap();
240        let older = day.join("rollout-old.jsonl");
241        let newer = day.join("rollout-new.jsonl");
242        let other = day.join("rollout-other.jsonl");
243        let non_rollout = day.join("notes.jsonl");
244
245        std::fs::write(
246            &older,
247            format!(
248                "{{\"type\":\"session_meta\",\"payload\":{{\"cwd\":\"{}\"}}}}\n",
249                project.display()
250            ),
251        )
252        .unwrap();
253        std::fs::write(
254            &newer,
255            format!(
256                "{{\"type\":\"session_meta\",\"payload\":{{\"cwd\":\"{}\"}}}}\n",
257                project.display()
258            ),
259        )
260        .unwrap();
261        std::fs::write(
262            &other,
263            "{\"type\":\"session_meta\",\"payload\":{\"cwd\":\"/tmp/other\"}}\n",
264        )
265        .unwrap();
266        std::fs::write(
267            &non_rollout,
268            format!(
269                "{{\"type\":\"session_meta\",\"payload\":{{\"cwd\":\"{}\"}}}}\n",
270                project.display()
271            ),
272        )
273        .unwrap();
274
275        let old_time = std::time::SystemTime::now() + std::time::Duration::from_secs(10);
276        let new_time = std::time::SystemTime::now() + std::time::Duration::from_secs(20);
277        let other_time = std::time::SystemTime::now() + std::time::Duration::from_secs(30);
278        let non_rollout_time = std::time::SystemTime::now() + std::time::Duration::from_secs(40);
279        filetime::set_file_mtime(&older, filetime::FileTime::from_system_time(old_time)).unwrap();
280        filetime::set_file_mtime(&newer, filetime::FileTime::from_system_time(new_time)).unwrap();
281        filetime::set_file_mtime(&other, filetime::FileTime::from_system_time(other_time)).unwrap();
282        filetime::set_file_mtime(
283            &non_rollout,
284            filetime::FileTime::from_system_time(non_rollout_time),
285        )
286        .unwrap();
287
288        assert_eq!(latest_codex_transcript(home.path(), &project), Some(newer));
289    }
290}