Skip to main content

agent_doc_model_tier/
context_usage.rs

1//! Harness transcript token and context-window policy.
2//!
3//! This module owns the pure half of pre-emptive context clearing: parse token
4//! usage from harness transcript content, map model names to context windows,
5//! compute context percentage, and decide whether an opted-in caller should
6//! request a destructive context clear. Filesystem transcript discovery and
7//! reads stay in orchestration adapters.
8
9use std::path::{Path, PathBuf};
10
11/// Harness whose session transcript token usage can be interpreted.
12#[derive(Clone, Copy, Debug, PartialEq, Eq)]
13pub enum Harness {
14    Claude,
15    Codex,
16    OpenCode,
17}
18
19impl Harness {
20    /// Parse a harness token. Unknown harnesses return `None` so callers can
21    /// fail safe.
22    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/// Cumulative token usage read from a harness session transcript.
33#[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    /// Total tokens occupying the context window.
43    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
51/// Default context window in tokens for the current Claude model family.
52pub const CLAUDE_CONTEXT_WINDOW: u64 = 200_000;
53
54/// Claude Code encodes a project directory into its transcript project
55/// subdirectory by replacing every `/` and `.` with `-`.
56pub fn claude_project_hash(project_dir: &Path) -> String {
57    project_dir.to_string_lossy().replace(['/', '.'], "-")
58}
59
60/// Locate a Claude Code session transcript by known session id.
61pub 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
68/// Compose the `~/.claude/projects/<project-hash>/` transcript directory.
69pub 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
75/// Parse cumulative token usage from Claude Code JSONL transcript content.
76///
77/// Each line is one JSON record. Assistant records nest the usage block under
78/// `message.usage`; a few record shapes carry top-level `usage`. The latest
79/// nonzero usage record wins. Non-JSON and partial trailing lines are ignored.
80pub 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
115/// Map a resolved model id to its context window in tokens.
116pub 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
129/// Compute context-usage percentage for `used` tokens against `model`'s window,
130/// clamped to `[0, 100]`.
131pub 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/// Diagnostic reason for a transcript context-% miss.
137#[derive(Clone, Copy, Debug, PartialEq, Eq)]
138pub enum TranscriptContextPctDiagnostic {
139    UnknownModel,
140    UnsupportedHarness,
141}
142
143/// Pure transcript context-% policy result.
144#[derive(Clone, Copy, Debug, PartialEq)]
145pub struct TranscriptContextPct {
146    pub pct: Option<f64>,
147    pub diagnostic: Option<TranscriptContextPctDiagnostic>,
148}
149
150/// Parse transcript content and compute context-usage percentage for a harness.
151pub 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
199/// Parse Codex TUI session JSONL and compute context usage from the latest
200/// `token_count` event.
201pub 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
242/// Parse the `payload.cwd` from a Codex TUI `session_meta` record.
243///
244/// Codex writes this near the start of each session transcript. Only the first
245/// 20 JSONL records are scanned so callers can cheaply test candidate transcript
246/// files during recursive filesystem discovery.
247pub 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/// Outcome of the dispatch-time pre-emptive clear gate.
266#[derive(Clone, Debug, PartialEq)]
267pub struct ClearDecision {
268    pub clear: bool,
269    pub diagnostic: String,
270}
271
272/// Decide whether an opted-in caller should pre-emptively clear context before
273/// dispatching a queue head.
274pub 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}