agent-doc-model-tier 0.34.87

Harness-agnostic model tier resolution for agent-doc
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
//! Harness transcript token and context-window policy.
//!
//! This module owns the pure half of pre-emptive context clearing: parse token
//! usage from harness transcript content, map model names to context windows,
//! compute context percentage, and decide whether an opted-in caller should
//! request a destructive context clear. Filesystem transcript discovery and
//! reads stay in orchestration adapters.

use std::path::{Path, PathBuf};

/// Harness whose session transcript token usage can be interpreted.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Harness {
    Claude,
    Codex,
    OpenCode,
}

impl Harness {
    /// Parse a harness token. Unknown harnesses return `None` so callers can
    /// fail safe.
    pub fn parse(s: &str) -> Option<Harness> {
        match s.trim().to_ascii_lowercase().as_str() {
            "claude" | "claude-code" => Some(Harness::Claude),
            "codex" => Some(Harness::Codex),
            "opencode" => Some(Harness::OpenCode),
            _ => None,
        }
    }
}

/// Cumulative token usage read from a harness session transcript.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct UsedTokens {
    pub input: u64,
    pub output: u64,
    pub cache_read: u64,
    pub cache_creation: u64,
}

impl UsedTokens {
    /// Total tokens occupying the context window.
    pub fn total(self) -> u64 {
        self.input
            .saturating_add(self.output)
            .saturating_add(self.cache_read)
            .saturating_add(self.cache_creation)
    }
}

/// Default context window in tokens for the current Claude model family.
pub const CLAUDE_CONTEXT_WINDOW: u64 = 200_000;

/// Claude Code encodes a project directory into its transcript project
/// subdirectory by replacing every `/` and `.` with `-`.
pub fn claude_project_hash(project_dir: &Path) -> String {
    project_dir.to_string_lossy().replace(['/', '.'], "-")
}

/// Locate a Claude Code session transcript by known session id.
pub fn claude_transcript_path(home: &Path, project_dir: &Path, session_id: &str) -> PathBuf {
    home.join(".claude")
        .join("projects")
        .join(claude_project_hash(project_dir))
        .join(format!("{session_id}.jsonl"))
}

/// Compose the `~/.claude/projects/<project-hash>/` transcript directory.
pub fn claude_projects_subdir(home: &Path, project_dir: &Path) -> PathBuf {
    home.join(".claude")
        .join("projects")
        .join(claude_project_hash(project_dir))
}

/// Parse cumulative token usage from Claude Code JSONL transcript content.
///
/// Each line is one JSON record. Assistant records nest the usage block under
/// `message.usage`; a few record shapes carry top-level `usage`. The latest
/// nonzero usage record wins. Non-JSON and partial trailing lines are ignored.
pub fn parse_claude_jsonl_used_tokens(content: &str) -> Option<UsedTokens> {
    let mut latest: Option<UsedTokens> = None;
    for line in content.lines() {
        let line = line.trim();
        if line.is_empty() {
            continue;
        }
        let value: serde_json::Value = match serde_json::from_str(line) {
            Ok(v) => v,
            Err(_) => continue,
        };
        let usage = value
            .get("message")
            .and_then(|m| m.get("usage"))
            .or_else(|| value.get("usage"));
        let Some(usage) = usage else { continue };
        let field = |k: &str| {
            usage
                .get(k)
                .and_then(serde_json::Value::as_u64)
                .unwrap_or(0)
        };
        let used = UsedTokens {
            input: field("input_tokens"),
            output: field("output_tokens"),
            cache_read: field("cache_read_input_tokens"),
            cache_creation: field("cache_creation_input_tokens"),
        };
        if used.total() > 0 {
            latest = Some(used);
        }
    }
    latest
}

/// Map a resolved model id to its context window in tokens.
pub fn context_window_for_model(model: &str) -> Option<u64> {
    let m = model.to_ascii_lowercase();
    if m.contains("opus")
        || m.contains("sonnet")
        || m.contains("haiku")
        || m.contains("fable")
        || m.starts_with("claude")
    {
        return Some(CLAUDE_CONTEXT_WINDOW);
    }
    None
}

/// Compute context-usage percentage for `used` tokens against `model`'s window,
/// clamped to `[0, 100]`.
pub fn context_pct(used: u64, model: &str) -> Option<f64> {
    let window = context_window_for_model(model)?;
    context_pct_for_window(used, window)
}

/// Diagnostic reason for a transcript context-% miss.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum TranscriptContextPctDiagnostic {
    UnknownModel,
    UnsupportedHarness,
}

/// Pure transcript context-% policy result.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct TranscriptContextPct {
    pub pct: Option<f64>,
    pub diagnostic: Option<TranscriptContextPctDiagnostic>,
}

/// Parse transcript content and compute context-usage percentage for a harness.
pub fn transcript_context_pct_from_content(
    harness: Harness,
    content: &str,
    model: &str,
) -> TranscriptContextPct {
    match harness {
        Harness::Claude => {
            let Some(used) = parse_claude_jsonl_used_tokens(content) else {
                return TranscriptContextPct {
                    pct: None,
                    diagnostic: None,
                };
            };
            let pct = context_pct(used.total(), model);
            let diagnostic = pct
                .is_none()
                .then_some(TranscriptContextPctDiagnostic::UnknownModel);
            TranscriptContextPct { pct, diagnostic }
        }
        Harness::Codex => TranscriptContextPct {
            pct: parse_codex_jsonl_context_pct(content),
            diagnostic: None,
        },
        Harness::OpenCode => TranscriptContextPct {
            pct: None,
            diagnostic: Some(TranscriptContextPctDiagnostic::UnsupportedHarness),
        },
    }
}

fn json_u64_at(value: &serde_json::Value, path: &[&str]) -> u64 {
    let mut cursor = value;
    for key in path {
        let Some(next) = cursor.get(*key) else {
            return 0;
        };
        cursor = next;
    }
    cursor.as_u64().unwrap_or(0)
}

fn context_pct_for_window(used: u64, window: u64) -> Option<f64> {
    if window == 0 {
        return None;
    }
    Some(((used as f64) / (window as f64) * 100.0).clamp(0.0, 100.0))
}

/// Parse Codex TUI session JSONL and compute context usage from the latest
/// `token_count` event.
pub fn parse_codex_jsonl_context_pct(content: &str) -> Option<f64> {
    let mut latest: Option<(u64, u64)> = None;
    for line in content.lines() {
        let line = line.trim();
        if line.is_empty() {
            continue;
        }
        let value: serde_json::Value = match serde_json::from_str(line) {
            Ok(v) => v,
            Err(_) => continue,
        };
        if value
            .get("payload")
            .and_then(|p| p.get("type"))
            .and_then(serde_json::Value::as_str)
            != Some("token_count")
        {
            continue;
        }
        let window = json_u64_at(&value, &["payload", "info", "model_context_window"]);
        let input = json_u64_at(
            &value,
            &["payload", "info", "last_token_usage", "input_tokens"],
        );
        let cached = json_u64_at(
            &value,
            &["payload", "info", "last_token_usage", "cached_input_tokens"],
        );
        let output = json_u64_at(
            &value,
            &["payload", "info", "last_token_usage", "output_tokens"],
        );
        let used = input.saturating_add(cached).saturating_add(output);
        if window > 0 && used > 0 {
            latest = Some((used, window));
        }
    }
    let (used, window) = latest?;
    context_pct_for_window(used, window)
}

/// Parse the `payload.cwd` from a Codex TUI `session_meta` record.
///
/// Codex writes this near the start of each session transcript. Only the first
/// 20 JSONL records are scanned so callers can cheaply test candidate transcript
/// files during recursive filesystem discovery.
pub fn parse_codex_jsonl_session_meta_cwd(content: &str) -> Option<PathBuf> {
    for line in content.lines().take(20) {
        let value: serde_json::Value = match serde_json::from_str(line.trim()) {
            Ok(value) => value,
            Err(_) => continue,
        };
        if value.get("type").and_then(serde_json::Value::as_str) != Some("session_meta") {
            continue;
        }
        let cwd = value
            .get("payload")
            .and_then(|p| p.get("cwd"))
            .and_then(serde_json::Value::as_str)?;
        return Some(PathBuf::from(cwd));
    }
    None
}

/// Outcome of the dispatch-time pre-emptive clear gate.
#[derive(Clone, Debug, PartialEq)]
pub struct ClearDecision {
    pub clear: bool,
    pub diagnostic: String,
}

/// Decide whether an opted-in caller should pre-emptively clear context before
/// dispatching a queue head.
pub fn clear_decision(opted_in: bool, pct: Option<f64>, threshold: u8) -> ClearDecision {
    let clear = opted_in && pct.is_some_and(|p| p >= f64::from(threshold));
    let pct_field = match pct {
        Some(p) => format!("{p:.1}"),
        None => "none".to_string(),
    };
    let diagnostic = format!(
        "[s760] clear-decision optIn={opted_in} threshold={threshold} pct={pct_field} clear={clear}"
    );
    ClearDecision { clear, diagnostic }
}

#[cfg(test)]
mod tests {
    use super::*;

    const FIXTURE: &str = r#"{"type":"user","message":{"role":"user","content":"hi"}}
{"type":"assistant","message":{"role":"assistant","usage":{"input_tokens":10,"output_tokens":5,"cache_read_input_tokens":100,"cache_creation_input_tokens":20}}}
{"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}}}}
"#;

    #[test]
    fn parse_jsonl_returns_latest_usage() {
        let used = parse_claude_jsonl_used_tokens(FIXTURE).expect("latest usage");
        assert_eq!(used.input, 2);
        assert_eq!(used.output, 4205);
        assert_eq!(used.cache_read, 243320);
        assert_eq!(used.cache_creation, 2232);
        assert_eq!(used.total(), 2 + 4205 + 243320 + 2232);
    }

    #[test]
    fn parse_jsonl_tolerates_non_json_and_empty() {
        let content = "not json at all\n\n{\"message\":{\"usage\":{\"input_tokens\":7}}}\n{partial";
        let used = parse_claude_jsonl_used_tokens(content).expect("usage from the one valid line");
        assert_eq!(used.input, 7);
        assert_eq!(used.total(), 7);
    }

    #[test]
    fn parse_jsonl_none_when_no_usage() {
        assert!(parse_claude_jsonl_used_tokens("").is_none());
        assert!(
            parse_claude_jsonl_used_tokens("{\"type\":\"user\",\"message\":{\"content\":\"x\"}}")
                .is_none()
        );
        assert!(
            parse_claude_jsonl_used_tokens("{\"message\":{\"usage\":{\"input_tokens\":0}}}")
                .is_none()
        );
    }

    #[test]
    fn claude_project_hash_replaces_slash_and_dot() {
        assert_eq!(
            claude_project_hash(Path::new("/home/brian/work/btakita/agent-loop")),
            "-home-brian-work-btakita-agent-loop"
        );
        assert_eq!(
            claude_project_hash(Path::new("/home/u/.claude-mem")),
            "-home-u--claude-mem"
        );
    }

    #[test]
    fn claude_transcript_path_composes() {
        let p = claude_transcript_path(
            Path::new("/home/brian"),
            Path::new("/home/brian/work/btakita/agent-loop"),
            "74bb0c6d-4f39",
        );
        assert_eq!(
            p,
            Path::new(
                "/home/brian/.claude/projects/-home-brian-work-btakita-agent-loop/74bb0c6d-4f39.jsonl"
            )
        );
    }

    #[test]
    fn claude_projects_subdir_composes() {
        assert_eq!(
            claude_projects_subdir(
                Path::new("/home/brian"),
                Path::new("/home/brian/work/btakita/agent-loop"),
            ),
            Path::new("/home/brian/.claude/projects/-home-brian-work-btakita-agent-loop")
        );
    }

    #[test]
    fn context_window_known_families_200k() {
        for m in [
            "claude-opus-4-8",
            "claude-sonnet-4-6",
            "claude-haiku-4-5-20251001",
            "claude-fable-5",
            "opus",
            "Sonnet",
        ] {
            assert_eq!(
                context_window_for_model(m),
                Some(CLAUDE_CONTEXT_WINDOW),
                "{m}"
            );
        }
    }

    #[test]
    fn context_window_unknown_is_none() {
        assert!(context_window_for_model("gpt-5").is_none());
        assert!(context_window_for_model("llama-3").is_none());
    }

    #[test]
    fn context_pct_computes_and_clamps() {
        assert_eq!(context_pct(100_000, "claude-opus-4-8"), Some(50.0));
        assert_eq!(context_pct(500_000, "opus"), Some(100.0));
        assert_eq!(context_pct(0, "sonnet"), Some(0.0));
    }

    #[test]
    fn context_pct_unknown_model_is_none() {
        assert!(context_pct(100_000, "gpt-5").is_none());
    }

    #[test]
    fn transcript_context_pct_from_content_handles_claude_and_codex() {
        let claude = transcript_context_pct_from_content(Harness::Claude, FIXTURE, "opus");
        assert_eq!(
            claude,
            TranscriptContextPct {
                pct: Some(100.0),
                diagnostic: None
            }
        );

        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}}}
"#;
        let codex = transcript_context_pct_from_content(Harness::Codex, codex_content, "gpt-5");
        assert_eq!(
            codex,
            TranscriptContextPct {
                pct: Some(30.0),
                diagnostic: None
            }
        );
    }

    #[test]
    fn transcript_context_pct_from_content_reports_safe_misses() {
        let unknown_model = transcript_context_pct_from_content(Harness::Claude, FIXTURE, "gpt-5");
        assert_eq!(
            unknown_model,
            TranscriptContextPct {
                pct: None,
                diagnostic: Some(TranscriptContextPctDiagnostic::UnknownModel)
            }
        );

        let empty_claude = transcript_context_pct_from_content(Harness::Claude, "", "opus");
        assert_eq!(
            empty_claude,
            TranscriptContextPct {
                pct: None,
                diagnostic: None
            }
        );

        let opencode = transcript_context_pct_from_content(Harness::OpenCode, "", "opus");
        assert_eq!(
            opencode,
            TranscriptContextPct {
                pct: None,
                diagnostic: Some(TranscriptContextPctDiagnostic::UnsupportedHarness)
            }
        );
    }

    #[test]
    fn parse_codex_jsonl_context_pct_uses_latest_token_count() {
        let content = r#"{"type":"session_meta","payload":{"cwd":"/tmp/project"}}
{"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}}}
{"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}}}
"#;
        let pct = parse_codex_jsonl_context_pct(content).expect("codex pct");
        assert_eq!(pct, 35.0);
    }

    #[test]
    fn parse_codex_jsonl_context_pct_clamps_and_ignores_missing_window() {
        let content = r#"{"type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":1,"cached_input_tokens":1,"output_tokens":1}}}}
{"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}}}
"#;
        let pct = parse_codex_jsonl_context_pct(content).expect("codex pct");
        assert_eq!(pct, 100.0);
    }

    #[test]
    fn parse_codex_jsonl_session_meta_cwd_scans_early_records_only() {
        let content = r#"not json
{"type":"event_msg","payload":{"type":"token_count"}}
{"type":"session_meta","payload":{"cwd":"/tmp/project"}}
"#;

        assert_eq!(
            parse_codex_jsonl_session_meta_cwd(content),
            Some(PathBuf::from("/tmp/project"))
        );

        let late = (0..20)
            .map(|idx| format!(r#"{{"type":"event_msg","payload":{{"idx":{idx}}}}}"#))
            .chain(std::iter::once(
                r#"{"type":"session_meta","payload":{"cwd":"/tmp/late"}}"#.to_string(),
            ))
            .collect::<Vec<_>>()
            .join("\n");
        assert!(parse_codex_jsonl_session_meta_cwd(&late).is_none());
    }

    #[test]
    fn harness_parse_known_and_unknown() {
        assert_eq!(Harness::parse("claude"), Some(Harness::Claude));
        assert_eq!(Harness::parse("Claude-Code"), Some(Harness::Claude));
        assert_eq!(Harness::parse("codex"), Some(Harness::Codex));
        assert_eq!(Harness::parse("opencode"), Some(Harness::OpenCode));
        assert!(Harness::parse("junie").is_none());
    }

    #[test]
    fn clear_decision_clears_only_when_opted_in_and_at_or_above_threshold() {
        let d = clear_decision(true, Some(50.0), 50);
        assert!(d.clear);
        assert_eq!(
            d.diagnostic,
            "[s760] clear-decision optIn=true threshold=50 pct=50.0 clear=true"
        );
        assert!(clear_decision(true, Some(83.4), 50).clear);
        let below = clear_decision(true, Some(49.9), 50);
        assert!(!below.clear);
        assert_eq!(
            below.diagnostic,
            "[s760] clear-decision optIn=true threshold=50 pct=49.9 clear=false"
        );
    }

    #[test]
    fn clear_decision_fails_safe_on_unknown_pct_and_disabled_opt_in() {
        let unknown = clear_decision(true, None, 50);
        assert!(!unknown.clear);
        assert_eq!(
            unknown.diagnostic,
            "[s760] clear-decision optIn=true threshold=50 pct=none clear=false"
        );
        let off = clear_decision(false, Some(100.0), 50);
        assert!(!off.clear);
        assert_eq!(
            off.diagnostic,
            "[s760] clear-decision optIn=false threshold=50 pct=100.0 clear=false"
        );
    }
}