From 8db8bd610f450e45455df70d5ce8978c81bfb373 Mon Sep 17 00:00:00 2001 From: iris Date: Wed, 10 Jun 2026 20:06:54 +0200 Subject: [PATCH] feat(stats): surface first-turn ctx tokens on the per-agent stats page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds the read/surface half of the per-session first-turn-tokens metric (the capture — sessions table + turn_stats.session_id — landed separately). A fresh claude session's first turn pays the full static prefix (system prompt + tools + CLAUDE.md + first wake) as uncached input, so its input_tokens is a clean proxy for prompt / CLAUDE.md sprawl — watching it over time surfaces creep. - stats.rs: add `Snapshot.first_turn_ctx: Option` populated by `read_first_turn_ctx` — the agreed per-session derive (first turn, `ORDER BY started_at LIMIT 1`, of the most recent session that started in the window). Inert-until-capture: `.ok()` maps both "no fresh session yet" and "older db without the sessions table" to None, the same decoupling as read_bash_breakdown; the field is skipped from the JSON when None. Pre-capture rows have a NULL session_id and are excluded. - agent stats.js: add a "first-turn ctx" summary chip, guarded on a numeric value so it stays hidden until capture has data. clippy + cargo fmt clean; agent bundle builds. --- frontend/packages/agent/src/stats.js | 7 ++++++ hive-ag3nt/src/stats.rs | 36 ++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/frontend/packages/agent/src/stats.js b/frontend/packages/agent/src/stats.js index 93eeb897..3c9beac9 100644 --- a/frontend/packages/agent/src/stats.js +++ b/frontend/packages/agent/src/stats.js @@ -123,6 +123,13 @@ window.Chart = Chart; ['reminders pending', fmtInt(s.reminder_stats.pending)], ); } + // First-turn ctx: input tokens of the most recent fresh session's + // first turn — the cold system-prompt + CLAUDE.md cost, a sprawl + // proxy. Omitted from the JSON (and so absent here) until the + // per-session capture has data. + if (typeof s.first_turn_ctx === 'number') { + chips.push(['first-turn ctx', fmtInt(s.first_turn_ctx)]); + } for (const [label, value] of chips) { const chip = document.createElement('span'); chip.className = 'chip'; diff --git a/hive-ag3nt/src/stats.rs b/hive-ag3nt/src/stats.rs index 020dec90..d7c3515d 100644 --- a/hive-ag3nt/src/stats.rs +++ b/hive-ag3nt/src/stats.rs @@ -112,6 +112,14 @@ pub struct Snapshot { /// None if the RPC call failed or hasn't been integrated yet. #[serde(default, skip_serializing_if = "Option::is_none")] pub reminder_stats: Option, + /// First-turn input tokens of the most recent fresh claude session + /// that started in the window — a proxy for system-prompt + CLAUDE.md + /// sprawl (a fresh session's first turn pays the full static prefix + /// uncached, so this is the current "cold context" cost). `None` until + /// the sessions capture (per-session `session_id`) has data; every + /// pre-capture `turn_stats` row has a NULL `session_id` and is excluded. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub first_turn_ctx: Option, } #[derive(Debug, Serialize)] @@ -189,6 +197,7 @@ fn empty_snapshot(window: Window) -> Snapshot { models: Vec::new(), duration_summary: DurationSummary::default(), reminder_stats: None, + first_turn_ctx: None, } } @@ -290,9 +299,36 @@ fn snapshot(path: &Path, window: Window) -> Result { models, duration_summary, reminder_stats: None, // filled in by api_stats in web_ui.rs via fetch_reminder_stats RPC + // Inert-until-capture: `.ok()` maps both "no fresh session in the + // window yet" (QueryReturnedNoRows) and "sessions table absent on + // an older db" (Err) to None, same decoupling as read_bash_breakdown. + first_turn_ctx: read_first_turn_ctx(&conn, from).ok(), }) } +/// First-turn input tokens of the most recent fresh claude session that +/// started in `[from, now]`. Tracks system-prompt + CLAUDE.md sprawl: +/// the first turn of a fresh session (`--continue` suppressed) pays the +/// full static prefix as uncached input, so watching this over time +/// surfaces creep. Uses the agreed per-session derive — the first turn +/// (`ORDER BY started_at LIMIT 1`) of the latest session row. +/// +/// Returns `Err` when the `sessions` table doesn't exist (older db) or no +/// fresh session in the window has a recorded turn yet; the caller maps +/// that to `None` (inert-until-capture), same as `read_bash_breakdown`. +fn read_first_turn_ctx(conn: &Connection, from: i64) -> rusqlite::Result { + conn.query_row( + "SELECT input_tokens FROM turn_stats + WHERE session_id = ( + SELECT id FROM sessions WHERE started_at >= ?1 ORDER BY started_at DESC LIMIT 1 + ) + ORDER BY started_at ASC + LIMIT 1", + [from], + |row| row.get::<_, i64>(0).map(u64_from_i64), + ) +} + /// 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.