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:
parent
62c41baf63
commit
8db8bd610f
2 changed files with 43 additions and 0 deletions
|
|
@ -123,6 +123,13 @@ window.Chart = Chart;
|
||||||
['reminders pending', fmtInt(s.reminder_stats.pending)],
|
['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) {
|
for (const [label, value] of chips) {
|
||||||
const chip = document.createElement('span');
|
const chip = document.createElement('span');
|
||||||
chip.className = 'chip';
|
chip.className = 'chip';
|
||||||
|
|
|
||||||
|
|
@ -112,6 +112,14 @@ pub struct Snapshot {
|
||||||
/// None if the RPC call failed or hasn't been integrated yet.
|
/// None if the RPC call failed or hasn't been integrated yet.
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub reminder_stats: Option<ReminderStats>,
|
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)]
|
#[derive(Debug, Serialize)]
|
||||||
|
|
@ -189,6 +197,7 @@ fn empty_snapshot(window: Window) -> Snapshot {
|
||||||
models: Vec::new(),
|
models: Vec::new(),
|
||||||
duration_summary: DurationSummary::default(),
|
duration_summary: DurationSummary::default(),
|
||||||
reminder_stats: None,
|
reminder_stats: None,
|
||||||
|
first_turn_ctx: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -290,9 +299,36 @@ fn snapshot(path: &Path, window: Window) -> Result<Snapshot> {
|
||||||
models,
|
models,
|
||||||
duration_summary,
|
duration_summary,
|
||||||
reminder_stats: None, // filled in by api_stats in web_ui.rs via fetch_reminder_stats RPC
|
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
|
/// Aggregate the top shell-command heads ("favorite tools") over
|
||||||
/// `[from, now]` from the `bash_commands` table — one row per bash task
|
/// `[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.
|
/// (`ts INTEGER NOT NULL, head TEXT NOT NULL`), written by hive-bash-mcp.
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue