feat(stats): surface first-turn ctx tokens on the per-agent stats page

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<u64>` 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.
This commit is contained in:
iris 2026-06-10 20:06:54 +02:00 committed by mara
commit 8db8bd610f
2 changed files with 43 additions and 0 deletions

View file

@ -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';

View file

@ -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<ReminderStats>,
/// 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<u64>,
}
#[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<Snapshot> {
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<u64> {
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.