1use std::path::{Path, PathBuf};
10
11#[derive(Clone, Copy, Debug, PartialEq, Eq)]
13pub enum Harness {
14 Claude,
15 Codex,
16 OpenCode,
17}
18
19impl Harness {
20 pub fn parse(s: &str) -> Option<Harness> {
23 match s.trim().to_ascii_lowercase().as_str() {
24 "claude" | "claude-code" => Some(Harness::Claude),
25 "codex" => Some(Harness::Codex),
26 "opencode" => Some(Harness::OpenCode),
27 _ => None,
28 }
29 }
30}
31
32#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
34pub struct UsedTokens {
35 pub input: u64,
36 pub output: u64,
37 pub cache_read: u64,
38 pub cache_creation: u64,
39}
40
41impl UsedTokens {
42 pub fn total(self) -> u64 {
44 self.input
45 .saturating_add(self.output)
46 .saturating_add(self.cache_read)
47 .saturating_add(self.cache_creation)
48 }
49}
50
51pub const CLAUDE_CONTEXT_WINDOW: u64 = 200_000;
53
54pub fn claude_project_hash(project_dir: &Path) -> String {
57 project_dir.to_string_lossy().replace(['/', '.'], "-")
58}
59
60pub fn claude_transcript_path(home: &Path, project_dir: &Path, session_id: &str) -> PathBuf {
62 home.join(".claude")
63 .join("projects")
64 .join(claude_project_hash(project_dir))
65 .join(format!("{session_id}.jsonl"))
66}
67
68pub fn claude_projects_subdir(home: &Path, project_dir: &Path) -> PathBuf {
70 home.join(".claude")
71 .join("projects")
72 .join(claude_project_hash(project_dir))
73}
74
75pub fn parse_claude_jsonl_used_tokens(content: &str) -> Option<UsedTokens> {
81 let mut latest: Option<UsedTokens> = None;
82 for line in content.lines() {
83 let line = line.trim();
84 if line.is_empty() {
85 continue;
86 }
87 let value: serde_json::Value = match serde_json::from_str(line) {
88 Ok(v) => v,
89 Err(_) => continue,
90 };
91 let usage = value
92 .get("message")
93 .and_then(|m| m.get("usage"))
94 .or_else(|| value.get("usage"));
95 let Some(usage) = usage else { continue };
96 let field = |k: &str| {
97 usage
98 .get(k)
99 .and_then(serde_json::Value::as_u64)
100 .unwrap_or(0)
101 };
102 let used = UsedTokens {
103 input: field("input_tokens"),
104 output: field("output_tokens"),
105 cache_read: field("cache_read_input_tokens"),
106 cache_creation: field("cache_creation_input_tokens"),
107 };
108 if used.total() > 0 {
109 latest = Some(used);
110 }
111 }
112 latest
113}
114
115pub fn context_window_for_model(model: &str) -> Option<u64> {
117 let m = model.to_ascii_lowercase();
118 if m.contains("opus")
119 || m.contains("sonnet")
120 || m.contains("haiku")
121 || m.contains("fable")
122 || m.starts_with("claude")
123 {
124 return Some(CLAUDE_CONTEXT_WINDOW);
125 }
126 None
127}
128
129pub fn context_pct(used: u64, model: &str) -> Option<f64> {
132 let window = context_window_for_model(model)?;
133 context_pct_for_window(used, window)
134}
135
136#[derive(Clone, Copy, Debug, PartialEq, Eq)]
138pub enum TranscriptContextPctDiagnostic {
139 UnknownModel,
140 UnsupportedHarness,
141}
142
143#[derive(Clone, Copy, Debug, PartialEq)]
145pub struct TranscriptContextPct {
146 pub pct: Option<f64>,
147 pub diagnostic: Option<TranscriptContextPctDiagnostic>,
148}
149
150pub fn transcript_context_pct_from_content(
152 harness: Harness,
153 content: &str,
154 model: &str,
155) -> TranscriptContextPct {
156 match harness {
157 Harness::Claude => {
158 let Some(used) = parse_claude_jsonl_used_tokens(content) else {
159 return TranscriptContextPct {
160 pct: None,
161 diagnostic: None,
162 };
163 };
164 let pct = context_pct(used.total(), model);
165 let diagnostic = pct
166 .is_none()
167 .then_some(TranscriptContextPctDiagnostic::UnknownModel);
168 TranscriptContextPct { pct, diagnostic }
169 }
170 Harness::Codex => TranscriptContextPct {
171 pct: parse_codex_jsonl_context_pct(content),
172 diagnostic: None,
173 },
174 Harness::OpenCode => TranscriptContextPct {
175 pct: None,
176 diagnostic: Some(TranscriptContextPctDiagnostic::UnsupportedHarness),
177 },
178 }
179}
180
181fn json_u64_at(value: &serde_json::Value, path: &[&str]) -> u64 {
182 let mut cursor = value;
183 for key in path {
184 let Some(next) = cursor.get(*key) else {
185 return 0;
186 };
187 cursor = next;
188 }
189 cursor.as_u64().unwrap_or(0)
190}
191
192fn context_pct_for_window(used: u64, window: u64) -> Option<f64> {
193 if window == 0 {
194 return None;
195 }
196 Some(((used as f64) / (window as f64) * 100.0).clamp(0.0, 100.0))
197}
198
199pub fn parse_codex_jsonl_context_pct(content: &str) -> Option<f64> {
202 let mut latest: Option<(u64, u64)> = None;
203 for line in content.lines() {
204 let line = line.trim();
205 if line.is_empty() {
206 continue;
207 }
208 let value: serde_json::Value = match serde_json::from_str(line) {
209 Ok(v) => v,
210 Err(_) => continue,
211 };
212 if value
213 .get("payload")
214 .and_then(|p| p.get("type"))
215 .and_then(serde_json::Value::as_str)
216 != Some("token_count")
217 {
218 continue;
219 }
220 let window = json_u64_at(&value, &["payload", "info", "model_context_window"]);
221 let input = json_u64_at(
222 &value,
223 &["payload", "info", "last_token_usage", "input_tokens"],
224 );
225 let cached = json_u64_at(
226 &value,
227 &["payload", "info", "last_token_usage", "cached_input_tokens"],
228 );
229 let output = json_u64_at(
230 &value,
231 &["payload", "info", "last_token_usage", "output_tokens"],
232 );
233 let used = input.saturating_add(cached).saturating_add(output);
234 if window > 0 && used > 0 {
235 latest = Some((used, window));
236 }
237 }
238 let (used, window) = latest?;
239 context_pct_for_window(used, window)
240}
241
242pub fn parse_codex_jsonl_session_meta_cwd(content: &str) -> Option<PathBuf> {
248 for line in content.lines().take(20) {
249 let value: serde_json::Value = match serde_json::from_str(line.trim()) {
250 Ok(value) => value,
251 Err(_) => continue,
252 };
253 if value.get("type").and_then(serde_json::Value::as_str) != Some("session_meta") {
254 continue;
255 }
256 let cwd = value
257 .get("payload")
258 .and_then(|p| p.get("cwd"))
259 .and_then(serde_json::Value::as_str)?;
260 return Some(PathBuf::from(cwd));
261 }
262 None
263}
264
265#[derive(Clone, Debug, PartialEq)]
267pub struct ClearDecision {
268 pub clear: bool,
269 pub diagnostic: String,
270}
271
272pub fn clear_decision(opted_in: bool, pct: Option<f64>, threshold: u8) -> ClearDecision {
275 let clear = opted_in && pct.is_some_and(|p| p >= f64::from(threshold));
276 let pct_field = match pct {
277 Some(p) => format!("{p:.1}"),
278 None => "none".to_string(),
279 };
280 let diagnostic = format!(
281 "[s760] clear-decision optIn={opted_in} threshold={threshold} pct={pct_field} clear={clear}"
282 );
283 ClearDecision { clear, diagnostic }
284}
285
286#[cfg(test)]
287mod tests {
288 use super::*;
289
290 const FIXTURE: &str = r#"{"type":"user","message":{"role":"user","content":"hi"}}
291{"type":"assistant","message":{"role":"assistant","usage":{"input_tokens":10,"output_tokens":5,"cache_read_input_tokens":100,"cache_creation_input_tokens":20}}}
292{"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}}}}
293"#;
294
295 #[test]
296 fn parse_jsonl_returns_latest_usage() {
297 let used = parse_claude_jsonl_used_tokens(FIXTURE).expect("latest usage");
298 assert_eq!(used.input, 2);
299 assert_eq!(used.output, 4205);
300 assert_eq!(used.cache_read, 243320);
301 assert_eq!(used.cache_creation, 2232);
302 assert_eq!(used.total(), 2 + 4205 + 243320 + 2232);
303 }
304
305 #[test]
306 fn parse_jsonl_tolerates_non_json_and_empty() {
307 let content = "not json at all\n\n{\"message\":{\"usage\":{\"input_tokens\":7}}}\n{partial";
308 let used = parse_claude_jsonl_used_tokens(content).expect("usage from the one valid line");
309 assert_eq!(used.input, 7);
310 assert_eq!(used.total(), 7);
311 }
312
313 #[test]
314 fn parse_jsonl_none_when_no_usage() {
315 assert!(parse_claude_jsonl_used_tokens("").is_none());
316 assert!(
317 parse_claude_jsonl_used_tokens("{\"type\":\"user\",\"message\":{\"content\":\"x\"}}")
318 .is_none()
319 );
320 assert!(
321 parse_claude_jsonl_used_tokens("{\"message\":{\"usage\":{\"input_tokens\":0}}}")
322 .is_none()
323 );
324 }
325
326 #[test]
327 fn claude_project_hash_replaces_slash_and_dot() {
328 assert_eq!(
329 claude_project_hash(Path::new("/home/brian/work/btakita/agent-loop")),
330 "-home-brian-work-btakita-agent-loop"
331 );
332 assert_eq!(
333 claude_project_hash(Path::new("/home/u/.claude-mem")),
334 "-home-u--claude-mem"
335 );
336 }
337
338 #[test]
339 fn claude_transcript_path_composes() {
340 let p = claude_transcript_path(
341 Path::new("/home/brian"),
342 Path::new("/home/brian/work/btakita/agent-loop"),
343 "74bb0c6d-4f39",
344 );
345 assert_eq!(
346 p,
347 Path::new(
348 "/home/brian/.claude/projects/-home-brian-work-btakita-agent-loop/74bb0c6d-4f39.jsonl"
349 )
350 );
351 }
352
353 #[test]
354 fn claude_projects_subdir_composes() {
355 assert_eq!(
356 claude_projects_subdir(
357 Path::new("/home/brian"),
358 Path::new("/home/brian/work/btakita/agent-loop"),
359 ),
360 Path::new("/home/brian/.claude/projects/-home-brian-work-btakita-agent-loop")
361 );
362 }
363
364 #[test]
365 fn context_window_known_families_200k() {
366 for m in [
367 "claude-opus-4-8",
368 "claude-sonnet-4-6",
369 "claude-haiku-4-5-20251001",
370 "claude-fable-5",
371 "opus",
372 "Sonnet",
373 ] {
374 assert_eq!(
375 context_window_for_model(m),
376 Some(CLAUDE_CONTEXT_WINDOW),
377 "{m}"
378 );
379 }
380 }
381
382 #[test]
383 fn context_window_unknown_is_none() {
384 assert!(context_window_for_model("gpt-5").is_none());
385 assert!(context_window_for_model("llama-3").is_none());
386 }
387
388 #[test]
389 fn context_pct_computes_and_clamps() {
390 assert_eq!(context_pct(100_000, "claude-opus-4-8"), Some(50.0));
391 assert_eq!(context_pct(500_000, "opus"), Some(100.0));
392 assert_eq!(context_pct(0, "sonnet"), Some(0.0));
393 }
394
395 #[test]
396 fn context_pct_unknown_model_is_none() {
397 assert!(context_pct(100_000, "gpt-5").is_none());
398 }
399
400 #[test]
401 fn transcript_context_pct_from_content_handles_claude_and_codex() {
402 let claude = transcript_context_pct_from_content(Harness::Claude, FIXTURE, "opus");
403 assert_eq!(
404 claude,
405 TranscriptContextPct {
406 pct: Some(100.0),
407 diagnostic: None
408 }
409 );
410
411 let codex_content = r#"{"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}}}
412"#;
413 let codex = transcript_context_pct_from_content(Harness::Codex, codex_content, "gpt-5");
414 assert_eq!(
415 codex,
416 TranscriptContextPct {
417 pct: Some(30.0),
418 diagnostic: None
419 }
420 );
421 }
422
423 #[test]
424 fn transcript_context_pct_from_content_reports_safe_misses() {
425 let unknown_model = transcript_context_pct_from_content(Harness::Claude, FIXTURE, "gpt-5");
426 assert_eq!(
427 unknown_model,
428 TranscriptContextPct {
429 pct: None,
430 diagnostic: Some(TranscriptContextPctDiagnostic::UnknownModel)
431 }
432 );
433
434 let empty_claude = transcript_context_pct_from_content(Harness::Claude, "", "opus");
435 assert_eq!(
436 empty_claude,
437 TranscriptContextPct {
438 pct: None,
439 diagnostic: None
440 }
441 );
442
443 let opencode = transcript_context_pct_from_content(Harness::OpenCode, "", "opus");
444 assert_eq!(
445 opencode,
446 TranscriptContextPct {
447 pct: None,
448 diagnostic: Some(TranscriptContextPctDiagnostic::UnsupportedHarness)
449 }
450 );
451 }
452
453 #[test]
454 fn parse_codex_jsonl_context_pct_uses_latest_token_count() {
455 let content = r#"{"type":"session_meta","payload":{"cwd":"/tmp/project"}}
456{"type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":10000,"cached_input_tokens":5000,"output_tokens":1000},"model_context_window":100000}}}
457{"type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":20000,"cached_input_tokens":10000,"output_tokens":5000},"model_context_window":100000}}}
458"#;
459 let pct = parse_codex_jsonl_context_pct(content).expect("codex pct");
460 assert_eq!(pct, 35.0);
461 }
462
463 #[test]
464 fn parse_codex_jsonl_context_pct_clamps_and_ignores_missing_window() {
465 let content = r#"{"type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":1,"cached_input_tokens":1,"output_tokens":1}}}}
466{"type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":90000,"cached_input_tokens":20000,"output_tokens":1000},"model_context_window":100000}}}
467"#;
468 let pct = parse_codex_jsonl_context_pct(content).expect("codex pct");
469 assert_eq!(pct, 100.0);
470 }
471
472 #[test]
473 fn parse_codex_jsonl_session_meta_cwd_scans_early_records_only() {
474 let content = r#"not json
475{"type":"event_msg","payload":{"type":"token_count"}}
476{"type":"session_meta","payload":{"cwd":"/tmp/project"}}
477"#;
478
479 assert_eq!(
480 parse_codex_jsonl_session_meta_cwd(content),
481 Some(PathBuf::from("/tmp/project"))
482 );
483
484 let late = (0..20)
485 .map(|idx| format!(r#"{{"type":"event_msg","payload":{{"idx":{idx}}}}}"#))
486 .chain(std::iter::once(
487 r#"{"type":"session_meta","payload":{"cwd":"/tmp/late"}}"#.to_string(),
488 ))
489 .collect::<Vec<_>>()
490 .join("\n");
491 assert!(parse_codex_jsonl_session_meta_cwd(&late).is_none());
492 }
493
494 #[test]
495 fn harness_parse_known_and_unknown() {
496 assert_eq!(Harness::parse("claude"), Some(Harness::Claude));
497 assert_eq!(Harness::parse("Claude-Code"), Some(Harness::Claude));
498 assert_eq!(Harness::parse("codex"), Some(Harness::Codex));
499 assert_eq!(Harness::parse("opencode"), Some(Harness::OpenCode));
500 assert!(Harness::parse("junie").is_none());
501 }
502
503 #[test]
504 fn clear_decision_clears_only_when_opted_in_and_at_or_above_threshold() {
505 let d = clear_decision(true, Some(50.0), 50);
506 assert!(d.clear);
507 assert_eq!(
508 d.diagnostic,
509 "[s760] clear-decision optIn=true threshold=50 pct=50.0 clear=true"
510 );
511 assert!(clear_decision(true, Some(83.4), 50).clear);
512 let below = clear_decision(true, Some(49.9), 50);
513 assert!(!below.clear);
514 assert_eq!(
515 below.diagnostic,
516 "[s760] clear-decision optIn=true threshold=50 pct=49.9 clear=false"
517 );
518 }
519
520 #[test]
521 fn clear_decision_fails_safe_on_unknown_pct_and_disabled_opt_in() {
522 let unknown = clear_decision(true, None, 50);
523 assert!(!unknown.clear);
524 assert_eq!(
525 unknown.diagnostic,
526 "[s760] clear-decision optIn=true threshold=50 pct=none clear=false"
527 );
528 let off = clear_decision(false, Some(100.0), 50);
529 assert!(!off.clear);
530 assert_eq!(
531 off.diagnostic,
532 "[s760] clear-decision optIn=false threshold=50 pct=100.0 clear=false"
533 );
534 }
535}