1use 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
13pub 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
34pub 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
62pub 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
100pub 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}