use agent_top_core::Harness;
use agent_top_core::harness::SessionSummary;
use agent_top_core::harness::{self, SpanRetention};
use anyhow::{Result, bail};
use std::collections::BTreeMap;
use std::path::Path;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
pub enum GroupBy {
Harness,
Model,
Project,
Day,
}
impl GroupBy {
fn label(self) -> &'static str {
match self {
GroupBy::Harness => "harness",
GroupBy::Model => "model",
GroupBy::Project => "project",
GroupBy::Day => "day",
}
}
}
pub fn parse_since(s: &str) -> Result<SystemTime> {
let s = s.trim();
if s.eq_ignore_ascii_case("all") {
return Ok(UNIX_EPOCH);
}
if let Some((y, mo, d)) = parse_date(s) {
let days = days_from_civil(y, mo, d);
if days < 0 {
bail!("date {s} is before 1970");
}
return Ok(UNIX_EPOCH + Duration::from_secs(days as u64 * 86_400));
}
if let Some(dur) = s.strip_suffix('h').and_then(|n| n.parse::<u64>().ok()) {
return since_ago(dur * 3600);
}
if let Some(dur) = s.strip_suffix('d').and_then(|n| n.parse::<u64>().ok()) {
return since_ago(dur * 86_400);
}
if let Some(dur) = s.strip_suffix('w').and_then(|n| n.parse::<u64>().ok()) {
return since_ago(dur * 7 * 86_400);
}
bail!("--since wants `all`, a duration like 7d / 12h / 2w, or a date YYYY-MM-DD, not {s:?}");
}
fn since_ago(secs: u64) -> Result<SystemTime> {
Ok(SystemTime::now().checked_sub(Duration::from_secs(secs)).unwrap_or(UNIX_EPOCH))
}
fn parse_date(s: &str) -> Option<(i64, u32, u32)> {
let mut it = s.split('-');
let y = it.next()?.parse().ok()?;
let mo = it.next()?.parse().ok()?;
let d = it.next()?.parse().ok()?;
if it.next().is_some() || !(1..=12).contains(&mo) || !(1..=31).contains(&d) {
return None;
}
Some((y, mo, d))
}
#[derive(Default, Clone)]
struct Bucket {
sessions: u64,
tokens: u64,
cost: f64,
unpriced: u64,
turns: u64,
tool_calls: u64,
prompt: u64,
cache_read: u64,
}
impl Bucket {
fn cache_hit(&self) -> Option<f64> {
(self.prompt >= 5_000).then(|| self.cache_read as f64 / self.prompt as f64)
}
}
impl Bucket {
fn add(&mut self, s: &SessionSummary) {
self.sessions += 1;
self.tokens += s.usage.total();
self.cost += s.cost_usd;
self.unpriced += s.unpriced_tokens;
self.turns += s.turns;
self.tool_calls += s.tool_calls;
self.prompt += s.usage.prompt();
self.cache_read += s.usage.cache_read;
}
}
pub struct Report {
since: SystemTime,
by: GroupBy,
groups: BTreeMap<String, Bucket>,
total: Bucket,
scanned: u64,
}
pub fn build(since: SystemTime, by: GroupBy) -> Report {
let mut groups: BTreeMap<String, Bucket> = BTreeMap::new();
let mut total = Bucket::default();
let mut scanned = 0;
for adapter in harness::adapters() {
let harness = adapter.harness();
for (_id, path) in adapter.transcripts() {
scanned += 1;
if let Ok(md) = std::fs::metadata(&path)
&& let Ok(m) = md.modified()
&& m < since
{
continue;
}
let Some(mut tracker) = harness::open_transcript(&path, harness, SpanRetention::Recent) else { continue };
if tracker.refresh_all().is_err() {
continue;
}
let s = tracker.summary();
if s.last_activity.map(|t| t < since).unwrap_or(true) {
continue;
}
if s.usage.total() == 0 && s.turns == 0 {
continue;
}
let key = group_key(by, harness, s);
groups.entry(key).or_default().add(s);
total.add(s);
}
}
Report { since, by, groups, total, scanned }
}
fn group_key(by: GroupBy, harness: Harness, s: &SessionSummary) -> String {
match by {
GroupBy::Harness => harness.label().to_string(),
GroupBy::Model => s.model.clone().unwrap_or_else(|| "unknown".into()),
GroupBy::Project => s.cwd.as_deref().map(project_name).unwrap_or_else(|| "unknown".into()),
GroupBy::Day => {
let (y, m, d) = date_utc(s.last_activity.unwrap_or(UNIX_EPOCH));
format!("{y:04}-{m:02}-{d:02}")
}
}
}
fn project_name(p: &Path) -> String {
let names: Vec<String> = p
.components()
.filter_map(|c| match c {
std::path::Component::Normal(s) => Some(s.to_string_lossy().into_owned()),
_ => None,
})
.collect();
let tail = names.iter().rev().take(2).rev().cloned().collect::<Vec<_>>().join("/");
if tail.is_empty() { p.to_string_lossy().into_owned() } else { tail }
}
impl Report {
pub fn to_plain(&self) -> String {
let mut out = String::new();
out.push_str(&format!("agent-top report · since {} · by {}\n\n", format_date(self.since), self.by.label()));
out.push_str(&format!(
"{:<22} {:>8} {:>10} {:>10} {:>7} {:>10}\n",
self.by.label().to_uppercase(),
"SESSIONS",
"TOKENS",
"COST",
"CACHE",
"UNPRICED"
));
let mut rows: Vec<(&String, &Bucket)> = self.groups.iter().collect();
rows.sort_by(|a, b| b.1.cost.partial_cmp(&a.1.cost).unwrap_or(std::cmp::Ordering::Equal).then(b.1.tokens.cmp(&a.1.tokens)));
for (k, b) in rows {
out.push_str(&format!(
"{:<22} {:>8} {:>10} {:>10} {:>7} {:>10}\n",
truncate(k, 22),
b.sessions,
tokens(b.tokens),
cost(b.cost, b.unpriced),
b.cache_hit().map(|r| format!("{:.0}%", r * 100.0)).unwrap_or_else(|| "-".into()),
if b.unpriced > 0 { tokens(b.unpriced) } else { "-".into() }
));
}
out.push_str(&format!("{:-<72}\n", ""));
let t = &self.total;
out.push_str(&format!(
"{:<22} {:>8} {:>10} {:>10} {:>7} {:>10}\n",
"total",
t.sessions,
tokens(t.tokens),
cost(t.cost, t.unpriced),
t.cache_hit().map(|r| format!("{:.0}%", r * 100.0)).unwrap_or_else(|| "-".into()),
if t.unpriced > 0 { tokens(t.unpriced) } else { "-".into() }
));
if t.unpriced > 0 {
out.push_str(&format!(
"\n{} tokens ran on models with no price in the table, so their cost is missing from the total.\nAdd those models to ~/.config/agent-top/prices.toml to include them; see `agent-top --prices`.\n",
tokens(t.unpriced)
));
}
out
}
pub fn to_json(&self) -> serde_json::Value {
let group = |b: &Bucket| {
serde_json::json!({
"sessions": b.sessions, "tokens": b.tokens, "cost_usd": b.cost,
"unpriced_tokens": b.unpriced, "turns": b.turns, "tool_calls": b.tool_calls,
"prompt_tokens": b.prompt, "cache_read_tokens": b.cache_read,
"cache_hit_rate": b.cache_hit(),
})
};
serde_json::json!({
"since": format_date(self.since),
"group_by": self.by.label(),
"scanned": self.scanned,
"groups": self.groups.iter().map(|(k, b)| (k.clone(), group(b))).collect::<serde_json::Map<_, _>>(),
"total": group(&self.total),
})
}
}
fn cost(usd: f64, unpriced: u64) -> String {
let floor = if unpriced > 0 { "+" } else { "" };
format!("${usd:.2}{floor}")
}
fn tokens(n: u64) -> String {
crate::format::tokens(n)
}
fn truncate(s: &str, n: usize) -> String {
crate::format::truncate(s, n)
}
fn format_date(t: SystemTime) -> String {
if t == UNIX_EPOCH {
return "the beginning".into();
}
let (y, m, d) = date_utc(t);
format!("{y:04}-{m:02}-{d:02}")
}
fn date_utc(t: SystemTime) -> (i64, u32, u32) {
let secs = t.duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0);
civil_from_days((secs / 86_400) as i64)
}
fn days_from_civil(y: i64, m: u32, d: u32) -> i64 {
let y = if m <= 2 { y - 1 } else { y };
let era = if y >= 0 { y } else { y - 399 } / 400;
let yoe = y - era * 400;
let mp = (m as i64 + 9) % 12;
let doy = (153 * mp + 2) / 5 + d as i64 - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
era * 146_097 + doe - 719_468
}
fn civil_from_days(z: i64) -> (i64, u32, u32) {
let z = z + 719_468;
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
let doe = z - era * 146_097;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
let m = (if mp < 10 { mp + 3 } else { mp - 9 }) as u32;
(if m <= 2 { y + 1 } else { y }, m, d)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_since_forms() {
assert_eq!(parse_since("all").unwrap(), UNIX_EPOCH);
let d = parse_since("2026-08-08").unwrap();
assert_eq!(date_utc(d), (2026, 8, 8));
assert!(parse_since("7d").unwrap() < SystemTime::now());
assert!(parse_since("12h").unwrap() < SystemTime::now());
assert!(parse_since("2w").unwrap() < SystemTime::now());
assert!(parse_since("nonsense").is_err());
assert!(parse_since("2026-13-01").is_err());
}
#[test]
fn civil_dates_round_trip() {
for &(y, m, d) in &[(1970, 1, 1), (2026, 9, 5), (2000, 2, 29), (2026, 12, 31)] {
assert_eq!(civil_from_days(days_from_civil(y, m, d)), (y, m, d));
}
}
#[test]
fn project_name_keeps_two_components() {
assert_eq!(project_name(Path::new("/Users/me/code/app")), "code/app");
assert_eq!(project_name(Path::new("/app")), "app");
}
}