use std::path::{Path, PathBuf};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Harness {
Claude,
Codex,
OpenCode,
}
impl Harness {
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,
}
}
}
#[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 {
pub fn total(self) -> u64 {
self.input
.saturating_add(self.output)
.saturating_add(self.cache_read)
.saturating_add(self.cache_creation)
}
}
pub const CLAUDE_CONTEXT_WINDOW: u64 = 200_000;
pub fn claude_project_hash(project_dir: &Path) -> String {
project_dir.to_string_lossy().replace(['/', '.'], "-")
}
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"))
}
pub fn claude_projects_subdir(home: &Path, project_dir: &Path) -> PathBuf {
home.join(".claude")
.join("projects")
.join(claude_project_hash(project_dir))
}
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
}
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
}
pub fn context_pct(used: u64, model: &str) -> Option<f64> {
let window = context_window_for_model(model)?;
context_pct_for_window(used, window)
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum TranscriptContextPctDiagnostic {
UnknownModel,
UnsupportedHarness,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct TranscriptContextPct {
pub pct: Option<f64>,
pub diagnostic: Option<TranscriptContextPctDiagnostic>,
}
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))
}
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)
}
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
}
#[derive(Clone, Debug, PartialEq)]
pub struct ClearDecision {
pub clear: bool,
pub diagnostic: String,
}
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"
);
}
}