diff --git a/frontend/packages/agent/src/stats.html b/frontend/packages/agent/src/stats.html index 52076b78..72fcf32a 100644 --- a/frontend/packages/agent/src/stats.html +++ b/frontend/packages/agent/src/stats.html @@ -33,6 +33,10 @@

token cost per bucket (sum across inferences)

turns by model per bucket — model drives token cost

top tools

+ +

wake source mix

result mix

result trend per bucket — errors / rate-limits / compactions over time

diff --git a/frontend/packages/agent/src/stats.js b/frontend/packages/agent/src/stats.js index 08b2c23e..01c5ee92 100644 --- a/frontend/packages/agent/src/stats.js +++ b/frontend/packages/agent/src/stats.js @@ -340,8 +340,26 @@ window.Chart = Chart; }); } + // "favorite tools" doughnut: most-run shell commands. The capture + // (hive-bash-mcp -> bash_commands table) lands separately, so until + // there's data we hide the whole card rather than show an empty + // doughnut. Runs independently of turn_count (a bash task is tied to + // a turn, but we don't want to couple the two reads). + function renderBashCard(s) { + const card = document.getElementById('card-bash'); + const items = s.bash_breakdown || []; + if (!items.length) { + if (card) card.hidden = true; + destroy('chart-bash'); + return; + } + if (card) card.hidden = false; + renderKeyCount('chart-bash', items, 'no bash commands'); + } + function render(s) { renderSummary(s); + renderBashCard(s); if (s.turn_count === 0) { paintEmpty('chart-turns', 'no turns in window'); paintEmpty('chart-duration', 'no turns in window'); diff --git a/hive-ag3nt/src/stats.rs b/hive-ag3nt/src/stats.rs index f9fdf14e..ff28d916 100644 --- a/hive-ag3nt/src/stats.rs +++ b/hive-ag3nt/src/stats.rs @@ -90,6 +90,13 @@ pub struct Snapshot { pub buckets: Vec, /// Top tools by call count across the window. Capped to 10. pub tool_breakdown: Vec, + /// Top shell commands ("favorite tools") by invocation count across + /// the window, capped to 10. Normalised command heads recorded per + /// bash task into the `bash_commands` table by hive-bash-mcp. Empty + /// until that capture lands (or on any agent that hasn't run a bash + /// task) — the table is created lazily by the writer, so a read + /// before the first insert returns an empty list, not an error. + pub bash_breakdown: Vec, pub wake_mix: Vec, pub result_mix: Vec, /// Distinct models seen in the window, sorted. Each bucket's @@ -176,6 +183,7 @@ fn empty_snapshot(window: Window) -> Snapshot { turn_count: 0, buckets, tool_breakdown: Vec::new(), + bash_breakdown: Vec::new(), wake_mix: Vec::new(), result_mix: Vec::new(), models: Vec::new(), @@ -276,6 +284,7 @@ fn snapshot(path: &Path, window: Window) -> Result { turn_count, buckets, tool_breakdown: top_n(tool_totals, 10), + bash_breakdown: read_bash_breakdown(&conn, from).unwrap_or_default(), wake_mix: top_n(wake_totals, 20), result_mix: top_n(result_totals, 20), models, @@ -284,6 +293,36 @@ fn snapshot(path: &Path, window: Window) -> Result { }) } +/// Aggregate the top shell-command heads ("favorite tools") over +/// `[from, now]` from the `bash_commands` table — one row per bash task +/// (`ts INTEGER NOT NULL, head TEXT NOT NULL`), written by hive-bash-mcp. +/// +/// Returns `Err` (which the caller maps to an empty list) when the +/// table doesn't exist yet — the writer creates it lazily on first +/// insert, so any agent that hasn't run a bash task since the capture +/// shipped simply has no table. Decoupling it this way means the read +/// side is inert-until-data and needs no schema coordination here. +fn read_bash_breakdown(conn: &Connection, from: i64) -> rusqlite::Result> { + let mut stmt = conn.prepare( + "SELECT head, COUNT(*) AS n + FROM bash_commands + WHERE ts >= ?1 + GROUP BY head", + )?; + let rows = stmt.query_map([from], |row| { + Ok(( + row.get::<_, String>(0)?, + u64_from_i64(row.get::<_, i64>(1)?), + )) + })?; + let mut totals: HashMap = HashMap::new(); + for r in rows { + let (head, n) = r?; + *totals.entry(head).or_insert(0) += n; + } + Ok(top_n(totals, 10)) +} + struct Row { started_at: i64, duration_ms: i64, @@ -581,4 +620,60 @@ mod tests { assert_eq!(s.bucket_seconds, 86_400); assert!(s.buckets.len() >= 7); } + + /// `bash_breakdown` degrades gracefully when the `bash_commands` + /// table hasn't been created yet (the capture side hasn't shipped / + /// run on this agent). A `seed_db` DB has no such table, so the read + /// must yield an empty list rather than erroring the whole snapshot. + #[test] + fn bash_breakdown_empty_without_table() { + let db = tmp_db(); + let _ = std::fs::remove_file(&db); + seed_db(&db, &[(now_secs() - 100, 1000, "opus", "recv", "ok", "{}")]); + let s = snapshot(&db, Window::Day).unwrap(); + assert!(s.bash_breakdown.is_empty()); + } + + /// With a populated `bash_commands` table, `bash_breakdown` rolls up + /// per-head counts (busiest first) and respects the window cutoff. + #[test] + fn bash_breakdown_aggregates_heads() { + let db = tmp_db(); + let _ = std::fs::remove_file(&db); + seed_db(&db, &[]); + let now = now_secs(); + let conn = Connection::open(&db).unwrap(); + conn.execute_batch("CREATE TABLE bash_commands (ts INTEGER NOT NULL, head TEXT NOT NULL);") + .unwrap(); + // 3x cargo + 2x git inside the window, 1x rg outside it. + for (ts, head) in [ + (now - 100, "cargo"), + (now - 200, "cargo"), + (now - 300, "cargo"), + (now - 400, "git"), + (now - 500, "git"), + (now - (2 * 24 * 3600), "rg"), // older than the 24h window + ] { + conn.execute( + "INSERT INTO bash_commands (ts, head) VALUES (?1, ?2)", + params![ts, head], + ) + .unwrap(); + } + let s = snapshot(&db, Window::Day).unwrap(); + let map: HashMap<_, _> = s + .bash_breakdown + .iter() + .map(|kc| (kc.key.clone(), kc.count)) + .collect(); + assert_eq!(map.get("cargo").copied(), Some(3)); + assert_eq!(map.get("git").copied(), Some(2)); + // `rg` fell outside the 24h window — excluded. + assert_eq!(map.get("rg").copied(), None); + // top_n orders busiest first. + assert_eq!( + s.bash_breakdown.first().map(|kc| kc.key.as_str()), + Some("cargo") + ); + } }