mirror of
https://github.com/kuhyx/testsAndMisc.git
synced 2026-11-10 18:07:06 +01:00
token_dashboard: ingester core + detail tables (calls, marks, turn_detail, ab_rows) for the control-panel dashboard
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
Pre-commit checks / pre-commit (${{ matrix.group }}) (mypy, mypy) (push) Waiting to run
Pre-commit checks / pre-commit (${{ matrix.group }}) (pylint, pylint) (push) Waiting to run
Pre-commit checks / pre-commit (${{ matrix.group }}) (rest, ) (push) Waiting to run
Pre-commit checks / pre-commit (${{ matrix.group }}) (shellcheck, shellcheck) (push) Waiting to run
Python tests / test (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
Shell tests / shell-tests (push) Waiting to run
Shell tests / lib-tests (push) Waiting to run
Shell tests / arch-tests (push) Waiting to run
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
Pre-commit checks / pre-commit (${{ matrix.group }}) (mypy, mypy) (push) Waiting to run
Pre-commit checks / pre-commit (${{ matrix.group }}) (pylint, pylint) (push) Waiting to run
Pre-commit checks / pre-commit (${{ matrix.group }}) (rest, ) (push) Waiting to run
Pre-commit checks / pre-commit (${{ matrix.group }}) (shellcheck, shellcheck) (push) Waiting to run
Python tests / test (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
Shell tests / shell-tests (push) Waiting to run
Shell tests / lib-tests (push) Waiting to run
Shell tests / arch-tests (push) Waiting to run
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JThjboXTeZ3yJPBzxMx5wS
This commit is contained in:
parent
6e63b66ff4
commit
1ba202c567
@ -0,0 +1,15 @@
|
||||
{
|
||||
"title": "token_dashboard: durable token store + ingester (dashboard core)",
|
||||
"objective": "Ingest Claude Code transcripts and aux sources into a durable, migrated SQLite store that the control-panel /tokens page reads, with an incremental pass under 60 s and 0 LLM tokens.",
|
||||
"acceptance_criteria": [
|
||||
"python_pkg/token_dashboard and python_pkg/token_audit stay at 100% line and branch coverage; every file <= 250 lines",
|
||||
"python3 -m python_pkg.token_dashboard.ingest --budget 40 finishes in under 60 s and is resumable",
|
||||
"schema.connect migrates a v1/v2 store forward without deleting rows",
|
||||
"calls/marks/turn_detail rows are replaced, never duplicated, when a transcript is re-walked"
|
||||
],
|
||||
"out_of_scope": [
|
||||
"Remaining /tokens panel groups A-Q",
|
||||
"Deleting or rebuilding ~/data/token-dashboard/tokens.sqlite"
|
||||
],
|
||||
"verifier": "pytest --cov=python_pkg/token_dashboard --cov=python_pkg/token_audit --cov-branch; the ingest command; pre-commit hooks on the staged files"
|
||||
}
|
||||
@ -0,0 +1,40 @@
|
||||
{
|
||||
"intent": "A durable SQLite token store (~/data/token-dashboard/tokens.sqlite) that outlives Claude Code's transcript cleanup and feeds the control-panel /tokens cost dashboard, with 0 LLM tokens spent.",
|
||||
"scope": [
|
||||
"python_pkg/token_dashboard/ (new): walk, rows, store, schema v3 with ordered additive migrations, finalize, carry, efficiency, ab, levers, context_files, aux, commits, survival, blocks, ingest; detail layer: classify, detail, detail_rows, detail_store, marks, hook_join (per-call, per-mark and per-turn rows with creation + carry cost); ab_rows (per-assignment A/B outcomes)",
|
||||
"python_pkg/token_audit: persist structured lever candidates (Lever.metric join key) and expose the record primitives the walker shares",
|
||||
"Non-goal: the remaining dashboard panel groups A-Q (tracked in ~/src/control-panel/TODO-token-dashboard-panels.md)"
|
||||
],
|
||||
"changes": [
|
||||
"Resumable incremental ingest: walks changed transcripts until its --budget is spent, commits, exits pending if any remain",
|
||||
"Schema migrations never drop the store; rows of vanished transcripts are kept",
|
||||
"token_audit --recommend stores the lever candidates as data in the snapshot (snap['levers'])",
|
||||
"New raw tables calls / marks / turn_detail (no schema version bump: CREATE IF NOT EXISTS) with key, cwd, model, ts, day, is_sub so the dashboard's filter clause applies; carry.Tracker settles creation (next call's cache-write multiplier x input price) and carry_calls too",
|
||||
"ab.rebuild also stores per-assignment rows in ab_rows so the A/B panel can recount arms per cwd/model/scope"
|
||||
],
|
||||
"verification": [
|
||||
{
|
||||
"command": "python3 -m pytest -q python_pkg/token_dashboard python_pkg/token_audit --cov=python_pkg/token_dashboard --cov=python_pkg/token_audit --cov-branch",
|
||||
"result": "pass",
|
||||
"evidence": "token_dashboard + token_audit: 100.00% line+branch coverage (pytest --cov, --cov-branch)"
|
||||
},
|
||||
{
|
||||
"command": "python3 -m python_pkg.token_dashboard.ingest --budget 40",
|
||||
"result": "pass",
|
||||
"evidence": "token_dashboard: parsed 7 files, refreshed 2 sessions in 0.7s; exit 0"
|
||||
},
|
||||
{
|
||||
"command": "sqlite3 tokens.sqlite 'DELETE FROM files' && python3 -m python_pkg.token_dashboard.ingest --budget 40",
|
||||
"result": "pass",
|
||||
"evidence": "full re-walk of 1616 transcripts in one 32.5 s pass; 114,599 calls, 103,713 turn_detail rows; store kept (backup tokens.v3-2026-10-18.sqlite)"
|
||||
}
|
||||
],
|
||||
"risks": [
|
||||
"A schema change without a MIGRATIONS entry would leave old stores without the new column",
|
||||
"Transcript format drift in Claude Code could make the walker skip new record types"
|
||||
],
|
||||
"rollback": [
|
||||
"git revert this commit; the store file is left untouched and stays readable by the previous code",
|
||||
"Validate: control-panel /api/tokens/overview still answers from the existing store"
|
||||
]
|
||||
}
|
||||
@ -9,6 +9,7 @@ is worse than no number, because it gets acted on.
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from dataclasses import asdict
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
@ -141,13 +142,18 @@ def main(argv: list[str] | None = None) -> int:
|
||||
totals, axes, priced.days
|
||||
)
|
||||
previous = history.last_snapshot(args.out)
|
||||
found = review.candidates(snap, previous) if args.recommend else None
|
||||
if found is not None:
|
||||
# Persisted so the dashboard reads proposed levers as data instead of
|
||||
# re-parsing this run's markdown table.
|
||||
snap["levers"] = [asdict(lever) for lever in found]
|
||||
markdown = report.render(totals, axes, window, sessions, snap, None)
|
||||
markdown += "\n".join(
|
||||
[
|
||||
"",
|
||||
*breakdown.render(priced),
|
||||
"",
|
||||
*review.render(snap, previous, levers=args.recommend),
|
||||
*review.render(snap, previous, levers=args.recommend, found=found),
|
||||
"",
|
||||
],
|
||||
)
|
||||
|
||||
@ -203,3 +203,10 @@ def load_session(path: Path, window: records.Window | None = None) -> Session:
|
||||
elif isinstance(event, ToolCall):
|
||||
session.tools.append(event)
|
||||
return session
|
||||
|
||||
|
||||
# Public names for the record primitives, shared with token_dashboard's walker
|
||||
# (which needs prompt boundaries and timestamps that iter_events drops).
|
||||
load_line = _load_line
|
||||
content_blocks = _blocks
|
||||
pair_result = _pair_result
|
||||
|
||||
@ -9,7 +9,7 @@ an ``if`` statement and lives here.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, replace
|
||||
from typing import Any
|
||||
|
||||
# Rule thresholds. Each is the point past which the lever is worth a look,
|
||||
@ -33,6 +33,22 @@ class Lever:
|
||||
evidence: str
|
||||
action: str
|
||||
est_usd_per_day: float | None = None
|
||||
# The snapshot metric the lever is meant to move: the join key to ledger
|
||||
# lines, whose free-text titles never match these.
|
||||
metric: str = ""
|
||||
|
||||
|
||||
# Snapshot metric each rule's lever targets, by title (titles are fixed).
|
||||
TITLE_METRIC: dict[str, str] = {
|
||||
"Opus runs most subagent work": "subagent_opus_usd_share",
|
||||
"Calls over the context budget": "over_budget_usd_share",
|
||||
"Sessions started in ~": "home_cwd_usd_share",
|
||||
"Independent tool calls not batched": "batching_calls_per_msg",
|
||||
"Large Bash results": "bash_big_result_share",
|
||||
"Always-loaded context grew": "standing_tokens_est",
|
||||
"Images held in context": "image_usd_share",
|
||||
"Unused MCP servers / skills": "standing_tokens_est",
|
||||
}
|
||||
|
||||
|
||||
def _num(metrics: dict[str, Any], key: str) -> float:
|
||||
@ -173,7 +189,8 @@ def candidates(
|
||||
+ _mechanics(metrics, previous)
|
||||
+ _dead(dead_mcp, dead_skills)
|
||||
)
|
||||
return sorted(levers, key=lambda lv: -(lv.est_usd_per_day or 0.0))
|
||||
tagged = [replace(lv, metric=TITLE_METRIC.get(lv.title, "")) for lv in levers]
|
||||
return sorted(tagged, key=lambda lv: -(lv.est_usd_per_day or 0.0))
|
||||
|
||||
|
||||
def render(levers: list[Lever]) -> list[str]:
|
||||
|
||||
@ -101,25 +101,37 @@ def _ledger(snap: dict[str, Any]) -> list[str]:
|
||||
return out
|
||||
|
||||
|
||||
def candidates(
|
||||
snap: dict[str, Any], prev: dict[str, Any] | None
|
||||
) -> list[recommend.Lever]:
|
||||
"""Every rule-based lever that fires for this snapshot."""
|
||||
dead_mcp = [u.name for u in unused.mcp_usage() if u.dead]
|
||||
dead_skills = [u.name for u in unused.skill_usage() if u.dead]
|
||||
prev_metrics = (
|
||||
prev.get("metrics") if prev and prev.get("v", 1) >= SNAPSHOT_VERSION else None
|
||||
)
|
||||
return recommend.candidates(snap["metrics"], prev_metrics, dead_mcp, dead_skills)
|
||||
|
||||
|
||||
def render(
|
||||
snap: dict[str, Any], prev: dict[str, Any] | None, *, levers: bool
|
||||
snap: dict[str, Any],
|
||||
prev: dict[str, Any] | None,
|
||||
*,
|
||||
levers: bool,
|
||||
found: list[recommend.Lever] | None = None,
|
||||
) -> list[str]:
|
||||
"""Markdown for comparison, ledger and (optionally) candidate levers."""
|
||||
"""Markdown for comparison, ledger and (optionally) candidate levers.
|
||||
|
||||
``found`` is the already-computed candidate list when the caller also
|
||||
persists it in the snapshot, so the unused-surface scan runs once.
|
||||
"""
|
||||
lines = ["## Since the previous analysis", ""]
|
||||
lines += _comparison(snap["metrics"], prev)
|
||||
lines += ["", "## What helped (ledger)", ""]
|
||||
lines += _ledger(snap)
|
||||
if levers:
|
||||
dead_mcp = [u.name for u in unused.mcp_usage() if u.dead]
|
||||
dead_skills = [u.name for u in unused.skill_usage() if u.dead]
|
||||
prev_metrics = (
|
||||
prev.get("metrics")
|
||||
if prev and prev.get("v", 1) >= SNAPSHOT_VERSION
|
||||
else None
|
||||
)
|
||||
found = recommend.candidates(
|
||||
snap["metrics"], prev_metrics, dead_mcp, dead_skills
|
||||
)
|
||||
if found is None:
|
||||
found = candidates(snap, prev)
|
||||
lines += ["", "## Candidate levers (rule-based)", ""]
|
||||
lines += recommend.render(found)
|
||||
return lines
|
||||
|
||||
@ -205,3 +205,28 @@ def test_rerun_starts_where_previous_window_ended(tmp_path: Path) -> None:
|
||||
assert cli.main(args) == 0
|
||||
# The first run's window end is now the default start: nothing newer.
|
||||
assert cli.main(args) == cli.EXIT_NO_DATA
|
||||
|
||||
|
||||
def test_recommend_persists_levers_as_structured_snapshot_data(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
root = tmp_path / "projects"
|
||||
root.mkdir()
|
||||
_transcript(root, turns=3)
|
||||
out = tmp_path / "out"
|
||||
assert cli.main(["--root", str(root), "--out", str(out), "--recommend"]) == 0
|
||||
(snap_path,) = (out / "history").glob("*.json")
|
||||
snap = json.loads(snap_path.read_text(encoding="utf-8"))
|
||||
assert isinstance(snap["levers"], list)
|
||||
keys = {"title", "metric", "evidence", "action"}
|
||||
assert all(keys <= set(lever) for lever in snap["levers"])
|
||||
|
||||
|
||||
def test_without_recommend_no_levers_key(tmp_path: Path) -> None:
|
||||
root = tmp_path / "projects"
|
||||
root.mkdir()
|
||||
_transcript(root, turns=3)
|
||||
out = tmp_path / "out"
|
||||
assert cli.main(["--root", str(root), "--out", str(out)]) == 0
|
||||
(snap_path,) = (out / "history").glob("*.json")
|
||||
assert "levers" not in json.loads(snap_path.read_text(encoding="utf-8"))
|
||||
|
||||
@ -88,3 +88,21 @@ def test_render_table_formats_estimates() -> None:
|
||||
assert lines[0].startswith("| category")
|
||||
assert "| $1.50 |" in lines[2]
|
||||
assert "| - |" in lines[3]
|
||||
|
||||
|
||||
def test_every_rule_title_maps_to_a_snapshot_metric() -> None:
|
||||
metrics = {
|
||||
"subagent_usd_share": 0.9,
|
||||
"subagent_opus_usd_share": 0.9,
|
||||
"over_budget_usd_share": 0.9,
|
||||
"home_cwd_usd_share": 0.9,
|
||||
"batching_calls_per_msg": 1.0,
|
||||
"bash_big_result_share": 0.9,
|
||||
"image_usd_share": 0.9,
|
||||
"standing_tokens_est": 200,
|
||||
"usd_total": 10,
|
||||
"usd_per_day": 1,
|
||||
}
|
||||
found = recommend.candidates(metrics, {"standing_tokens_est": 100}, ["m"], [])
|
||||
assert len(found) == len(recommend.TITLE_METRIC)
|
||||
assert all(lv.metric == recommend.TITLE_METRIC[lv.title] for lv in found)
|
||||
|
||||
5
python_pkg/token_dashboard/__init__.py
Normal file
5
python_pkg/token_dashboard/__init__.py
Normal file
@ -0,0 +1,5 @@
|
||||
"""Durable, zero-token store of Claude Code spend for the control-panel /tokens page.
|
||||
|
||||
``ingest`` walks transcripts (reusing token_audit's parser, dedup and prices)
|
||||
into SQLite under ``~/data/token-dashboard/``; the dashboard only reads it.
|
||||
"""
|
||||
243
python_pkg/token_dashboard/ab.py
Normal file
243
python_pkg/token_dashboard/ab.py
Normal file
@ -0,0 +1,243 @@
|
||||
"""A/B test of judgment subagents: Sonnet arm vs Opus arm, 0 tokens.
|
||||
|
||||
Assignments come from ``~/.claude/logs/agent-ab.jsonl`` (one line per
|
||||
dispatched review/debug/design/plan agent: ``tool_use_id``, ``arm``, ...).
|
||||
Each is joined on ``tool_use_id`` to the ``Agent`` call the ingester stored,
|
||||
and judged on outcomes visible in the transcript after the agent returned:
|
||||
|
||||
* **redo** -- the parent dispatched another Agent in the same session whose
|
||||
description shares >= :data:`REDO_JACCARD` of its word set;
|
||||
* **follow-up** -- the parent sent that agent a ``SendMessage``;
|
||||
* **error** -- the call errored or the agent was aborted;
|
||||
* **correction** (noisy) -- the next user prompt opens with a correction
|
||||
phrase (:data:`CORRECTION`);
|
||||
* the parent session's end tag.
|
||||
|
||||
Rates get 95% Wilson intervals and per-arm differences Newcombe's hybrid
|
||||
interval. The banner turns RED only when, with >= :data:`MIN_N` per arm, the
|
||||
Sonnet arm's redo or correction rate is higher and the two Wilson intervals do
|
||||
not overlap.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
import re
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from python_pkg.token_dashboard import ab_rows
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import sqlite3
|
||||
|
||||
LOG = Path.home() / ".claude" / "logs" / "agent-ab.jsonl"
|
||||
Z = 1.96
|
||||
MIN_N = 20
|
||||
REDO_JACCARD = 0.6
|
||||
ERRORS = frozenset({"error", "aborted", "killed", "failed", "cancelled"})
|
||||
CORRECTION = re.compile(
|
||||
r"^\W*no\b|\bwrong\b|\bthat'?s not\b|\bthat is not\b|\bincorrect\b"
|
||||
r"|\brevert\b|\byou missed\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
WORD = re.compile(r"[a-z0-9]+")
|
||||
RATES = ("redo", "followup", "error", "correction")
|
||||
|
||||
|
||||
def wilson(k: int, n: int, z: float = Z) -> tuple[float, float]:
|
||||
"""95% Wilson score interval for ``k`` successes in ``n`` trials."""
|
||||
if n == 0:
|
||||
return 0.0, 1.0
|
||||
p = k / n
|
||||
centre = p + z * z / (2 * n)
|
||||
spread = z * math.sqrt(p * (1 - p) / n + z * z / (4 * n * n))
|
||||
denom = 1 + z * z / n
|
||||
return max(0.0, (centre - spread) / denom), min(1.0, (centre + spread) / denom)
|
||||
|
||||
|
||||
def diff_ci(k1: int, n1: int, k2: int, n2: int) -> tuple[float, float, float]:
|
||||
"""``p1 - p2`` with Newcombe's hybrid-score 95% interval."""
|
||||
p1, p2 = (k1 / n1 if n1 else 0.0), (k2 / n2 if n2 else 0.0)
|
||||
l1, u1 = wilson(k1, n1)
|
||||
l2, u2 = wilson(k2, n2)
|
||||
diff = p1 - p2
|
||||
low = diff - math.sqrt((p1 - l1) ** 2 + (u2 - p2) ** 2)
|
||||
high = diff + math.sqrt((u1 - p1) ** 2 + (p2 - l2) ** 2)
|
||||
return diff, low, high
|
||||
|
||||
|
||||
def jaccard(a: str, b: str) -> float:
|
||||
"""Word-set Jaccard similarity of two descriptions."""
|
||||
left, right = set(WORD.findall(a.lower())), set(WORD.findall(b.lower()))
|
||||
union = left | right
|
||||
return len(left & right) / len(union) if union else 0.0
|
||||
|
||||
|
||||
def is_correction(text: str) -> bool:
|
||||
"""Whether a user prompt reads as correcting the previous answer."""
|
||||
return CORRECTION.search(text) is not None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ArmCounts:
|
||||
"""What the banner needs from one arm."""
|
||||
|
||||
n: int
|
||||
redo: int
|
||||
correction: int
|
||||
|
||||
|
||||
def banner(sonnet: ArmCounts, opus: ArmCounts) -> dict[str, str]:
|
||||
"""RED / GREEN / GREY verdict for the top of every /tokens view."""
|
||||
n = min(sonnet.n, opus.n)
|
||||
if n < MIN_N:
|
||||
return {"level": "grey", "text": f"A/B judgment agents: collecting (n={n})"}
|
||||
for name in ("redo", "correction"):
|
||||
ks, ko = getattr(sonnet, name), getattr(opus, name)
|
||||
low_s, _ = wilson(ks, sonnet.n)
|
||||
_, high_o = wilson(ko, opus.n)
|
||||
if ks / sonnet.n > ko / opus.n and low_s > high_o:
|
||||
return {
|
||||
"level": "red",
|
||||
"text": f"QUALITY DROP: Sonnet {name} rate {ks}/{sonnet.n} vs Opus "
|
||||
f"{ko}/{opus.n} (95% CIs do not overlap)",
|
||||
}
|
||||
return {
|
||||
"level": "green",
|
||||
"text": f"A/B judgment agents: no detectable drop (n={n})",
|
||||
}
|
||||
|
||||
|
||||
def _assignments(log: Path) -> list[dict[str, Any]]:
|
||||
"""Well-formed assignment lines."""
|
||||
if not log.is_file():
|
||||
return []
|
||||
rows = []
|
||||
for line in log.read_text(encoding="utf-8", errors="replace").splitlines():
|
||||
try:
|
||||
row = json.loads(line)
|
||||
except ValueError:
|
||||
continue
|
||||
if isinstance(row, dict) and row.get("tool_use_id") and row.get("arm"):
|
||||
rows.append(row)
|
||||
return rows
|
||||
|
||||
|
||||
def _outcome(conn: sqlite3.Connection, tool_use_id: str) -> dict[str, Any] | None:
|
||||
"""The joined call: cost, duration and the four flags, or ``None``."""
|
||||
call = conn.execute(
|
||||
"SELECT key, agent_id, ts, description, result_ts, status FROM agent_calls"
|
||||
" WHERE tool_use_id = ?",
|
||||
(tool_use_id,),
|
||||
).fetchone()
|
||||
if call is None:
|
||||
return None
|
||||
key, agent_id, ts, desc, result_ts, status = call
|
||||
main = key.split("/", 1)[0]
|
||||
sub = conn.execute(
|
||||
"SELECT usd, wall_s, last_ts FROM subagent_link WHERE sub_key = ?",
|
||||
(f"{main}/agent-{agent_id}",),
|
||||
).fetchone() or (0.0, (result_ts or ts) - ts, result_ts or ts)
|
||||
returned = max(result_ts or ts, sub[2] or 0)
|
||||
later = conn.execute(
|
||||
"SELECT description FROM agent_calls WHERE key = ? AND ts > ?",
|
||||
(key, returned),
|
||||
).fetchall()
|
||||
followup = conn.execute(
|
||||
"SELECT COUNT(*) FROM sent_messages WHERE key = ? AND to_agent = ? AND ts > ?",
|
||||
(key, agent_id, returned),
|
||||
).fetchone()[0]
|
||||
nxt = conn.execute(
|
||||
"SELECT text FROM prompts WHERE key = ? AND ts > ? ORDER BY ts LIMIT 1",
|
||||
(main, returned),
|
||||
).fetchone()
|
||||
tag = conn.execute(
|
||||
"SELECT final_tag FROM session_summary WHERE key = ?", (main,)
|
||||
).fetchone()
|
||||
return {
|
||||
"key": key,
|
||||
"ts": ts,
|
||||
"usd": sub[0] or 0.0,
|
||||
"duration_s": sub[1] or 0.0,
|
||||
"redo": any(jaccard(desc, d) >= REDO_JACCARD for (d,) in later),
|
||||
"followup": followup > 0,
|
||||
"error": (status or "").lower() in ERRORS,
|
||||
"correction": nxt is not None and is_correction(nxt[0]),
|
||||
"tag": (tag[0] if tag else None) or "untagged",
|
||||
}
|
||||
|
||||
|
||||
def _mean_ci(values: list[float]) -> dict[str, float]:
|
||||
"""Mean with a normal-approximation 95% interval."""
|
||||
n = len(values)
|
||||
if n == 0:
|
||||
return {"mean": 0.0, "low": 0.0, "high": 0.0}
|
||||
mean = sum(values) / n
|
||||
var = sum((v - mean) ** 2 for v in values) / (n - 1) if n > 1 else 0.0
|
||||
half = Z * math.sqrt(var / n)
|
||||
return {"mean": mean, "low": mean - half, "high": mean + half}
|
||||
|
||||
|
||||
def _arm(rows: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
"""Per-arm summary."""
|
||||
n = len(rows)
|
||||
out: dict[str, Any] = {"n": n}
|
||||
for name in RATES:
|
||||
k = sum(1 for r in rows if r[name])
|
||||
low, high = wilson(k, n)
|
||||
out[name] = {"k": k, "rate": k / n if n else None, "low": low, "high": high}
|
||||
out["usd"] = _mean_ci([r["usd"] for r in rows])
|
||||
out["duration_s"] = _mean_ci([r["duration_s"] for r in rows])
|
||||
tags: dict[str, int] = {}
|
||||
for r in rows:
|
||||
tags[r["tag"]] = tags.get(r["tag"], 0) + 1
|
||||
out["tags"] = tags
|
||||
return out
|
||||
|
||||
|
||||
def rebuild(conn: sqlite3.Connection, log: Path | None = None) -> dict[str, Any]:
|
||||
"""Join every assignment, summarise both arms, store in ``meta['ab']``."""
|
||||
joined: dict[str, list[dict[str, Any]]] = {"sonnet": [], "opus": []}
|
||||
kept: list[tuple[str, str, dict[str, Any]]] = []
|
||||
unjoined = 0
|
||||
for row in _assignments(log or LOG):
|
||||
found = _outcome(conn, str(row["tool_use_id"]))
|
||||
if found is None or row["arm"] not in joined:
|
||||
unjoined += 1
|
||||
continue
|
||||
joined[row["arm"]].append(found)
|
||||
kept.append((row["arm"], str(row["tool_use_id"]), found))
|
||||
ab_rows.store(conn, kept)
|
||||
arms = {name: _arm(rows) for name, rows in joined.items()}
|
||||
diffs = {
|
||||
name: dict(
|
||||
zip(
|
||||
("diff", "low", "high"),
|
||||
diff_ci(
|
||||
arms["sonnet"][name]["k"],
|
||||
arms["sonnet"]["n"],
|
||||
arms["opus"][name]["k"],
|
||||
arms["opus"]["n"],
|
||||
),
|
||||
strict=True,
|
||||
)
|
||||
)
|
||||
for name in RATES
|
||||
}
|
||||
counts = {
|
||||
name: ArmCounts(
|
||||
arms[name]["n"], arms[name]["redo"]["k"], arms[name]["correction"]["k"]
|
||||
)
|
||||
for name in joined
|
||||
}
|
||||
payload = {
|
||||
"arms": arms,
|
||||
"diffs": diffs,
|
||||
"unjoined": unjoined,
|
||||
"banner": banner(counts["sonnet"], counts["opus"]),
|
||||
}
|
||||
conn.execute("INSERT OR REPLACE INTO meta VALUES ('ab', ?)", (json.dumps(payload),))
|
||||
return payload
|
||||
56
python_pkg/token_dashboard/ab_rows.py
Normal file
56
python_pkg/token_dashboard/ab_rows.py
Normal file
@ -0,0 +1,56 @@
|
||||
"""Per-assignment A/B outcomes, so the dashboard can filter the A/B panel.
|
||||
|
||||
:func:`ab.rebuild` summarises all assignments into ``meta['ab']`` (the
|
||||
all-time verdict). This module also keeps one row per joined assignment, with
|
||||
the parent call's session, cwd, model and scope, so a cwd / model / scope
|
||||
filter can recount the arms. Derived: replaced wholesale on every rebuild.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import sqlite3
|
||||
|
||||
SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS ab_rows(
|
||||
tool_use_id TEXT PRIMARY KEY, arm TEXT, key TEXT, main_key TEXT, is_sub INT,
|
||||
cwd TEXT, model TEXT, ts REAL, usd REAL, duration_s REAL, redo INT,
|
||||
followup INT, error INT, correction INT, tag TEXT);
|
||||
"""
|
||||
|
||||
|
||||
def store(
|
||||
conn: sqlite3.Connection, joined: list[tuple[str, str, dict[str, Any]]]
|
||||
) -> None:
|
||||
"""Replace every row with ``(arm, tool_use_id, outcome)`` triples."""
|
||||
conn.executescript(SCHEMA)
|
||||
conn.execute("DELETE FROM ab_rows")
|
||||
for arm, tool_use_id, found in joined:
|
||||
key = str(found["key"])
|
||||
where = conn.execute(
|
||||
"SELECT cwd, model FROM turns WHERE key = ? AND ts <= ?"
|
||||
" ORDER BY ts DESC LIMIT 1",
|
||||
(key, found["ts"]),
|
||||
).fetchone() or (None, None)
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO ab_rows VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
(
|
||||
tool_use_id,
|
||||
arm,
|
||||
key,
|
||||
key.split("/", 1)[0],
|
||||
int("/" in key),
|
||||
where[0],
|
||||
where[1],
|
||||
found["ts"],
|
||||
found["usd"],
|
||||
found["duration_s"],
|
||||
int(found["redo"]),
|
||||
int(found["followup"]),
|
||||
int(found["error"]),
|
||||
int(found["correction"]),
|
||||
found["tag"],
|
||||
),
|
||||
)
|
||||
56
python_pkg/token_dashboard/active.py
Normal file
56
python_pkg/token_dashboard/active.py
Normal file
@ -0,0 +1,56 @@
|
||||
"""Active time: the sum of gaps between consecutive records, ignoring idle gaps.
|
||||
|
||||
A gap longer than :data:`IDLE_GAP` contributes **zero** (not the cap): it means
|
||||
nobody and nothing was working. Storing each file as maximal runs of records
|
||||
spaced at most ``IDLE_GAP`` apart is lossless for that rule -- the active time
|
||||
of several files' merged timeline equals the measure of the union of their runs
|
||||
with gaps of up to ``IDLE_GAP`` between runs bridged. That is what lets a main
|
||||
session and the subagents it spawned, ingested in different runs, combine
|
||||
exactly afterwards.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterable
|
||||
|
||||
IDLE_GAP = 300.0
|
||||
Span = tuple[float, float]
|
||||
|
||||
|
||||
def intervals(stamps: Iterable[float], gap: float = IDLE_GAP) -> list[Span]:
|
||||
"""Collapse sorted-or-not timestamps into maximal runs."""
|
||||
out: list[Span] = []
|
||||
for stamp in sorted(stamps):
|
||||
if out and stamp - out[-1][1] <= gap:
|
||||
out[-1] = (out[-1][0], stamp)
|
||||
else:
|
||||
out.append((stamp, stamp))
|
||||
return out
|
||||
|
||||
|
||||
def measure(
|
||||
spans: Iterable[Span],
|
||||
low: float = float("-inf"),
|
||||
high: float = float("inf"),
|
||||
gap: float = IDLE_GAP,
|
||||
) -> float:
|
||||
"""Active seconds of the union of runs, clipped to ``[low, high)``."""
|
||||
merged = intervals_union(spans, gap)
|
||||
total = 0.0
|
||||
for start, end in merged:
|
||||
total += max(0.0, min(end, high) - max(start, low))
|
||||
return total
|
||||
|
||||
|
||||
def intervals_union(spans: Iterable[Span], gap: float = IDLE_GAP) -> list[Span]:
|
||||
"""Union of runs, bridging gaps no longer than ``gap``."""
|
||||
out: list[Span] = []
|
||||
for start, end in sorted(spans):
|
||||
if out and start - out[-1][1] <= gap:
|
||||
out[-1] = (out[-1][0], max(out[-1][1], end))
|
||||
else:
|
||||
out.append((start, end))
|
||||
return out
|
||||
200
python_pkg/token_dashboard/aux.py
Normal file
200
python_pkg/token_dashboard/aux.py
Normal file
@ -0,0 +1,200 @@
|
||||
"""Non-transcript sources: snapshots, levers, autopsy aggregates, activity logs.
|
||||
|
||||
Each loader is idempotent (it replaces its own rows) and cheap, so every
|
||||
complete ingest pass reruns all of them.
|
||||
|
||||
The session-autopsy cache (``~/.claude/autopsy/sessions.jsonl``) is the only
|
||||
source of per-session numbers for transcripts already deleted. It is
|
||||
**aggregate only**: tokens are summed per transcript record, not per API
|
||||
message (about 2x), there is no model split and no subagents. It is therefore
|
||||
scaled by ``api_turns / assistant_msgs``, priced as Opus 5.5, and then
|
||||
calibrated against sessions present in both sources (``meta`` key
|
||||
``autopsy_calibration``). It is never mixed into the per-session tables.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import Counter
|
||||
from datetime import datetime
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from python_pkg.token_audit import pricing
|
||||
from python_pkg.token_dashboard import levers, store
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterator
|
||||
import sqlite3
|
||||
|
||||
CLAUDE = Path.home() / ".claude"
|
||||
AUTOPSY = CLAUDE / "autopsy" / "sessions.jsonl"
|
||||
PROMPT_HISTORY = CLAUDE / "history.jsonl"
|
||||
TIME_CAP = CLAUDE / "logs" / "time-cap.jsonl"
|
||||
_MS = 1000.0
|
||||
_MS_EPOCH = 1e11 # anything larger is milliseconds, not seconds
|
||||
|
||||
|
||||
def _jsonl(path: Path) -> Iterator[dict[str, Any]]:
|
||||
"""Well-formed object lines of a JSONL file (none if it is missing)."""
|
||||
if not path.is_file():
|
||||
return
|
||||
with path.open(encoding="utf-8", errors="replace") as handle:
|
||||
for line in handle:
|
||||
try:
|
||||
row = json.loads(line)
|
||||
except ValueError:
|
||||
continue
|
||||
if isinstance(row, dict):
|
||||
yield row
|
||||
|
||||
|
||||
def _stamp(raw: object) -> float | None:
|
||||
"""ISO string or epoch (s or ms) to unix seconds."""
|
||||
if isinstance(raw, (int, float)):
|
||||
return raw / _MS if raw > _MS_EPOCH else float(raw)
|
||||
if isinstance(raw, str):
|
||||
try:
|
||||
return datetime.fromisoformat(raw).timestamp()
|
||||
except ValueError:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def snapshots(conn: sqlite3.Connection, report: Path) -> None:
|
||||
"""Copy every history snapshot's window and metrics."""
|
||||
for path in sorted((report / "history").glob("*.json")):
|
||||
try:
|
||||
snap = json.loads(path.read_text(encoding="utf-8"))
|
||||
except ValueError:
|
||||
continue
|
||||
window = snap.get("window", {})
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO snapshots VALUES (?,?,?,?,?)",
|
||||
(
|
||||
path.stem,
|
||||
snap.get("generated"),
|
||||
window.get("since"),
|
||||
window.get("until"),
|
||||
json.dumps(snap.get("metrics", {})),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _nearest(generated: list[tuple[float, str]], stamp: float) -> str:
|
||||
"""The snapshot day generated closest to ``stamp``."""
|
||||
return min(generated, key=lambda g: abs(g[0] - stamp))[1]
|
||||
|
||||
|
||||
def lever_rows(conn: sqlite3.Connection, report: Path) -> None:
|
||||
"""Rebuild the ``levers`` table from every source."""
|
||||
generated = [
|
||||
(_stamp(g) or 0.0, name[:10])
|
||||
for name, g in conn.execute("SELECT name, generated FROM snapshots")
|
||||
]
|
||||
tables: dict[str, str] = {}
|
||||
found = conn.execute("SELECT ts, markdown FROM lever_tables ORDER BY ts")
|
||||
weekly = report / "WEEKLY.md"
|
||||
extra = []
|
||||
if weekly.is_file():
|
||||
extra = [(weekly.stat().st_mtime, weekly.read_text(encoding="utf-8"))]
|
||||
for ts, markdown in [*found, *extra]:
|
||||
# A table belongs to the snapshot generated closest to it.
|
||||
if generated:
|
||||
tables.setdefault(_nearest(generated, ts), markdown)
|
||||
conn.execute("DELETE FROM levers")
|
||||
conn.executemany(
|
||||
"INSERT INTO levers VALUES (?,?,?,?,?,?,?,?,?,?,?)",
|
||||
[tuple(row.values()) for row in levers.build(report, tables)],
|
||||
)
|
||||
|
||||
|
||||
def autopsy(conn: sqlite3.Connection) -> None:
|
||||
"""Load autopsy aggregates and calibrate their dollar estimate."""
|
||||
price = pricing.load().for_model("claude-opus-5-5")
|
||||
for row in _jsonl(AUTOPSY):
|
||||
meta, tok, turns = (
|
||||
row.get("meta", {}),
|
||||
row.get("tokens", {}),
|
||||
row.get("turns", {}),
|
||||
)
|
||||
started = _stamp(meta.get("started_at"))
|
||||
records = row.get("counts", {}).get("assistant_msgs") or 0
|
||||
api = turns.get("api_turns") or records
|
||||
if started is None or not records:
|
||||
continue
|
||||
scale = api / records
|
||||
raw = (
|
||||
tok.get("input", 0) * price.input
|
||||
+ tok.get("cache_write", 0) * price.input * pricing.WRITE_1H
|
||||
+ tok.get("cache_read", 0) * price.cache_read
|
||||
+ tok.get("output", 0) * price.output
|
||||
) / pricing.PER_MILLION
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO aggregate_sessions VALUES"
|
||||
" (?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
(
|
||||
row.get("session_id"),
|
||||
"autopsy",
|
||||
store.day_of(started),
|
||||
started,
|
||||
meta.get("cwd"),
|
||||
meta.get("title"),
|
||||
tok.get("input", 0),
|
||||
tok.get("cache_write", 0),
|
||||
tok.get("cache_read", 0),
|
||||
tok.get("output", 0),
|
||||
api,
|
||||
records,
|
||||
raw * scale,
|
||||
),
|
||||
)
|
||||
est, real = conn.execute(
|
||||
"SELECT SUM(a.usd_est), SUM(s.usd_main) FROM aggregate_sessions a"
|
||||
" JOIN session_summary s ON s.key = a.session_id"
|
||||
).fetchone()
|
||||
factor = (real / est) if est and real else 1.0
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO meta VALUES ('autopsy_calibration', ?)", (str(factor),)
|
||||
)
|
||||
|
||||
|
||||
def activity_logs(conn: sqlite3.Connection, report: Path) -> None:
|
||||
"""Prompt counts per day, time-cap events, task-size misses and estimates."""
|
||||
days: Counter[str] = Counter()
|
||||
for row in _jsonl(PROMPT_HISTORY):
|
||||
stamp = _stamp(row.get("timestamp"))
|
||||
if stamp is not None:
|
||||
days[store.day_of(stamp)] += 1
|
||||
# Upserts, never a wipe: if history.jsonl is trimmed, the days it lost stay.
|
||||
conn.executemany(
|
||||
"INSERT INTO prompt_activity VALUES (?,?) ON CONFLICT(day) DO UPDATE"
|
||||
" SET prompts = MAX(prompts, excluded.prompts)",
|
||||
days.items(),
|
||||
)
|
||||
sources = (
|
||||
("time-cap", TIME_CAP, "event"),
|
||||
("task-size-miss", report / "task-size" / "misses.jsonl", "kind"),
|
||||
)
|
||||
for name, path, kind_key in sources:
|
||||
conn.executemany(
|
||||
"INSERT OR IGNORE INTO events VALUES (?,?,?,?)",
|
||||
[
|
||||
(
|
||||
name,
|
||||
_stamp(r.get("ts")),
|
||||
str(r.get(kind_key)),
|
||||
str(r.get("command") or r.get("first_prompt") or "")[:200],
|
||||
)
|
||||
for r in _jsonl(path)
|
||||
],
|
||||
)
|
||||
for row in _jsonl(report / "task-size" / "verdicts.jsonl"):
|
||||
verdict = row.get("verdict")
|
||||
calls = verdict.get("est_calls") if isinstance(verdict, dict) else None
|
||||
if row.get("status") == "ok" and isinstance(calls, int):
|
||||
# The first sized prompt of a session is its pre-work estimate.
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO estimates VALUES (?,?)",
|
||||
(row.get("session"), calls),
|
||||
)
|
||||
104
python_pkg/token_dashboard/blocks.py
Normal file
104
python_pkg/token_dashboard/blocks.py
Normal file
@ -0,0 +1,104 @@
|
||||
"""Pure readers for individual transcript records and content blocks.
|
||||
|
||||
Split from :mod:`walk` so the record-level rules (what counts as a typed
|
||||
prompt, how a status tag or a commit hash is recognised) are testable without a
|
||||
file and the walker stays under the file-length cap.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from python_pkg.token_audit.discover import WRAPPER
|
||||
|
||||
PROMPT_CHARS = 120
|
||||
LEVER_HEADING = "Candidate levers (rule-based)"
|
||||
# kuhy's convention: every final reply ends on one of these status tags.
|
||||
TAG = re.compile(r"^\W*(DONE|RUNNING|QUESTION|NEEDS ACTION)\W*:", re.MULTILINE)
|
||||
COMMAND = re.compile(r"<command-name>\s*(\S+?)\s*</command-name>")
|
||||
# `git commit` prints `[branch sha] subject`, also through finish_auto.sh.
|
||||
COMMIT_LINE = re.compile(
|
||||
r"^\[[^\]\s]+(?: \(root-commit\))? ([0-9a-f]{7,40})\]", re.MULTILINE
|
||||
)
|
||||
REPO_HINT = re.compile(r"(?:git\s+-C|\bcd)\s+(\"[^\"]+\"|'[^']+'|[^\s;&|]+)")
|
||||
_SKIP_PREFIXES = ("[Request interrupted", "Caveat:")
|
||||
|
||||
|
||||
def text_of(content: object) -> str:
|
||||
"""Flatten a message/tool-result ``content`` into plain text."""
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
if not isinstance(content, list):
|
||||
return ""
|
||||
parts = [
|
||||
str(block.get("text") or "")
|
||||
for block in content
|
||||
if isinstance(block, dict) and block.get("type") == "text"
|
||||
]
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
def prompt_text(record: dict[str, Any]) -> str | None:
|
||||
"""The typed text of a real user prompt, or ``None`` for anything else.
|
||||
|
||||
Excludes tool results, ``isMeta`` / compact-summary records, interrupts,
|
||||
and harness-injected ``<tag>`` blocks. A slash command keeps its name.
|
||||
"""
|
||||
if record.get("type") != "user" or record.get("isMeta"):
|
||||
return None
|
||||
if record.get("isCompactSummary") or record.get("isVisibleInTranscriptOnly"):
|
||||
return None
|
||||
message = record.get("message")
|
||||
content = message.get("content") if isinstance(message, dict) else None
|
||||
if isinstance(content, list) and any(
|
||||
isinstance(b, dict) and b.get("type") == "tool_result" for b in content
|
||||
):
|
||||
return None
|
||||
raw = text_of(content)
|
||||
command = COMMAND.search(raw)
|
||||
text = WRAPPER.sub("", raw).strip()
|
||||
if command is not None and not text:
|
||||
text = command.group(1)
|
||||
if not text or text.startswith(_SKIP_PREFIXES):
|
||||
return None
|
||||
return " ".join(text.split())[:PROMPT_CHARS]
|
||||
|
||||
|
||||
def final_tag(text: str) -> str | None:
|
||||
"""The last status tag in a reply, or ``None`` when it carries none."""
|
||||
found = TAG.findall(text)
|
||||
return found[-1] if found else None
|
||||
|
||||
|
||||
def commit_shas(text: str) -> list[str]:
|
||||
"""Commit hashes printed by ``git commit`` in a tool result."""
|
||||
return COMMIT_LINE.findall(text)
|
||||
|
||||
|
||||
def repo_hint(command: str, cwd: str | None) -> str:
|
||||
"""The repository a commit command ran in: ``-C``/``cd`` target, else cwd."""
|
||||
match = REPO_HINT.search(command)
|
||||
target = match.group(1).strip("\"'") if match else (cwd or "")
|
||||
if not target:
|
||||
return ""
|
||||
path = Path(target).expanduser()
|
||||
if cwd and not path.is_absolute():
|
||||
path = Path(cwd) / path
|
||||
return os.path.normpath(path)
|
||||
|
||||
|
||||
def lever_table(text: str) -> str | None:
|
||||
"""The markdown table following the candidate-levers heading, if any."""
|
||||
at = text.find(LEVER_HEADING)
|
||||
if at < 0:
|
||||
return None
|
||||
rows: list[str] = []
|
||||
for line in text[at:].splitlines()[1:]:
|
||||
if line.startswith("|"):
|
||||
rows.append(line)
|
||||
elif rows:
|
||||
break
|
||||
return "\n".join(rows) if rows else None
|
||||
79
python_pkg/token_dashboard/carry.py
Normal file
79
python_pkg/token_dashboard/carry.py
Normal file
@ -0,0 +1,79 @@
|
||||
"""Carry cost: what a tool result costs by staying in context afterwards.
|
||||
|
||||
A result of N tokens is cache-read again on every later API call of the same
|
||||
file until a compaction drops it (or the session ends), so its real price is
|
||||
N x (cache-read price of each later call) -- usually far more than the call
|
||||
that fetched it. A compaction shows up as the context shrinking below
|
||||
``COMPACTION_DROP_RATIO`` of the previous call's, the same rule token_audit's
|
||||
image-cost model uses.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Protocol
|
||||
|
||||
from python_pkg.token_audit.model import COMPACTION_DROP_RATIO
|
||||
from python_pkg.token_audit.pricing import PER_MILLION, WRITE_5M
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from python_pkg.token_audit.pricing import PriceBook
|
||||
|
||||
|
||||
class _Row(Protocol):
|
||||
"""Anything with creation / carry accumulators."""
|
||||
|
||||
create_usd: float
|
||||
carry_usd: float
|
||||
carry_calls: int
|
||||
|
||||
|
||||
class Tracker:
|
||||
"""Collects calls and results in order, settles carry at the end."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Start with no calls and no results."""
|
||||
self.calls: list[tuple[str, int]] = []
|
||||
self.writes: list[float] = []
|
||||
self.results: list[tuple[int, int, _Row]] = []
|
||||
|
||||
def turn(self, model: str, context: int, write: float = WRITE_5M) -> None:
|
||||
"""Note one API call, in order, with its cache-write price multiplier."""
|
||||
self.calls.append((model, context))
|
||||
self.writes.append(write)
|
||||
|
||||
def result(self, row: _Row, tokens: int) -> None:
|
||||
"""Note a result arriving before call number ``len(self.calls)``."""
|
||||
self.results.append((len(self.calls), tokens, row))
|
||||
|
||||
def spans(self) -> list[tuple[int, int, int, _Row]]:
|
||||
"""``(first, end, tokens, row)``: the calls ``first..end-1`` carry it."""
|
||||
# cut[i]: index of the first call at or after i that starts a new
|
||||
# context (after a compaction), else len(calls).
|
||||
size = len(self.calls)
|
||||
cut = [size] * (size + 1)
|
||||
for index in range(size - 1, -1, -1):
|
||||
dropped = index > 0 and self.calls[index][1] < (
|
||||
COMPACTION_DROP_RATIO * self.calls[index - 1][1]
|
||||
)
|
||||
cut[index] = index if dropped else cut[index + 1]
|
||||
return [
|
||||
(start, cut[start + 1], tokens, row)
|
||||
for start, tokens, row in self.results
|
||||
if start < size
|
||||
]
|
||||
|
||||
def settle(self, book: PriceBook) -> None:
|
||||
"""Add each result's creation and carry to its row.
|
||||
|
||||
Creation: the first call after the result writes it to cache, at that
|
||||
call's write multiplier of input price. Carry: every call from there
|
||||
to the next compaction re-reads it at the cache-read price.
|
||||
"""
|
||||
prefix = [0.0]
|
||||
for model, _ in self.calls:
|
||||
prefix.append(prefix[-1] + book.for_model(model).cache_read)
|
||||
for start, end, tokens, row in self.spans():
|
||||
price = book.for_model(self.calls[start][0]).input
|
||||
row.create_usd += tokens * self.writes[start] * price / PER_MILLION
|
||||
row.carry_usd += tokens * (prefix[end] - prefix[start]) / PER_MILLION
|
||||
row.carry_calls += end - start
|
||||
78
python_pkg/token_dashboard/check_window.py
Normal file
78
python_pkg/token_dashboard/check_window.py
Normal file
@ -0,0 +1,78 @@
|
||||
"""Reconcile the store against a token_audit snapshot: within 1% or exit 1.
|
||||
|
||||
The snapshot's ``usd_per_day`` is ``usd_total`` over the span between the
|
||||
earliest and latest priced record inside its window (not the window length),
|
||||
so the same definition is applied to the store's turns here.
|
||||
|
||||
python3 -m python_pkg.token_dashboard.check_window # newest
|
||||
python3 -m python_pkg.token_dashboard.check_window --snapshot 2026-10-17
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from python_pkg.token_audit import history
|
||||
from python_pkg.token_dashboard import schema
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import sqlite3
|
||||
|
||||
TOLERANCE = 0.01
|
||||
DAY_S = 86400.0
|
||||
MIN_SPAN_S = 60.0
|
||||
|
||||
|
||||
def measure(conn: sqlite3.Connection, since: float, until: float) -> dict[str, float]:
|
||||
"""``usd_total``, ``usd_per_day`` and ``calls`` from the store's turns."""
|
||||
usd, calls, first, last = conn.execute(
|
||||
"SELECT SUM(usd), COUNT(*), MIN(ts), MAX(ts) FROM turns"
|
||||
" WHERE ts BETWEEN ? AND ?",
|
||||
(since, until),
|
||||
).fetchone()
|
||||
usd = usd or 0.0
|
||||
days = max((last or 0) - (first or 0), MIN_SPAN_S) / DAY_S
|
||||
return {"usd_total": usd, "usd_per_day": usd / days, "calls": float(calls)}
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
"""Compare and print one line per metric; 0 when every one is within 1%."""
|
||||
parser = argparse.ArgumentParser(prog="token_dashboard.check_window")
|
||||
parser.add_argument("--db", type=Path, default=schema.DEFAULT_DB)
|
||||
parser.add_argument("--report-dir", type=Path, default=history.REPORT_DIR)
|
||||
parser.add_argument("--snapshot", default=None, help="history file stem")
|
||||
args = parser.parse_args(argv)
|
||||
folder = args.report_dir / history.HISTORY_NAME
|
||||
path = (
|
||||
folder / f"{args.snapshot}.json"
|
||||
if args.snapshot
|
||||
else max(folder.glob("*.json"), default=None)
|
||||
)
|
||||
if path is None or not path.is_file() or not args.db.is_file():
|
||||
sys.stderr.write(f"missing snapshot or store ({path}, {args.db})\n")
|
||||
return 1
|
||||
snap = json.loads(path.read_text(encoding="utf-8"))
|
||||
window = snap["window"]
|
||||
conn = schema.connect(args.db, readonly=True)
|
||||
try:
|
||||
got = measure(conn, float(window["since"]), float(window["until"]))
|
||||
finally:
|
||||
conn.close()
|
||||
ok = True
|
||||
for key, value in got.items():
|
||||
want = float(snap["metrics"][key])
|
||||
drift = abs(value - want) / want if want else 0.0
|
||||
ok &= drift <= TOLERANCE
|
||||
sys.stdout.write(
|
||||
f"{key}: store {value:,.2f} vs snapshot {want:,.2f}"
|
||||
f" ({drift:.2%}) {'OK' if drift <= TOLERANCE else 'FAIL'}\n"
|
||||
)
|
||||
return 0 if ok else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
216
python_pkg/token_dashboard/classify.py
Normal file
216
python_pkg/token_dashboard/classify.py
Normal file
@ -0,0 +1,216 @@
|
||||
"""Name what a tool call was, for the carry-cost and tool panels.
|
||||
|
||||
``family`` is the bucket a panel groups by (``git status``, ``mcp:<server>``,
|
||||
``Read:memory``); ``target`` is the one thing it touched (the command, path,
|
||||
host or query), capped so the store stays small; ``signature`` identifies
|
||||
identical repeated calls (loops) without storing the full input.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from python_pkg.token_audit.model import IMAGE_SUFFIXES
|
||||
|
||||
TARGET_CHARS = 160
|
||||
SIG_CHARS = 16
|
||||
EMPTY = "(empty)"
|
||||
|
||||
# Words that only wrap the real command (and the flags/numbers they take).
|
||||
_WRAPPERS = frozenset(
|
||||
{
|
||||
"sudo",
|
||||
"env",
|
||||
"nice",
|
||||
"ionice",
|
||||
"timeout",
|
||||
"time",
|
||||
"nohup",
|
||||
"exec",
|
||||
"command",
|
||||
"capped.sh",
|
||||
"stdbuf",
|
||||
"xargs",
|
||||
}
|
||||
)
|
||||
# Tools whose first non-flag argument is what the call really did.
|
||||
_SUBCOMMANDS = frozenset(
|
||||
{
|
||||
"git",
|
||||
"pnpm",
|
||||
"npm",
|
||||
"npx",
|
||||
"flutter",
|
||||
"dart",
|
||||
"cargo",
|
||||
"go",
|
||||
"docker",
|
||||
"systemctl",
|
||||
"gh",
|
||||
"pre-commit",
|
||||
"uv",
|
||||
"pip",
|
||||
"yay",
|
||||
"pacman",
|
||||
"adb",
|
||||
"make",
|
||||
"fvm",
|
||||
"journalctl",
|
||||
}
|
||||
)
|
||||
_TAKES_VALUE = frozenset({"-C", "-c", "-n", "--dir", "--prefix"})
|
||||
_SHELLS = frozenset({"bash", "sh", "zsh"})
|
||||
_ASSIGN = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=")
|
||||
_SEGMENTS = re.compile(r"\s*(?:&&|\|\||;|\||\n)\s*")
|
||||
_NUMBERISH = re.compile(r"^\d+[smhd]?$")
|
||||
_ERRORS = (
|
||||
("Exit code", "exit_code"),
|
||||
("<tool_use_error>", "tool_error"),
|
||||
("doesn't want to proceed", "rejected"),
|
||||
("Permission for this action was denied", "denied"),
|
||||
("hook error", "hook"),
|
||||
("TIME CAP", "time_cap"),
|
||||
("[Request interrupted", "interrupted"),
|
||||
)
|
||||
|
||||
|
||||
def _base(word: str) -> str:
|
||||
"""Last path component of a command word."""
|
||||
return word.rsplit("/", 1)[-1]
|
||||
|
||||
|
||||
def _command_words(words: list[str]) -> list[str]:
|
||||
"""Drop leading assignments, wrappers and the flags/values they take."""
|
||||
index = 0
|
||||
while index < len(words):
|
||||
word = words[index]
|
||||
if not (
|
||||
_ASSIGN.match(word)
|
||||
or _base(word) in _WRAPPERS
|
||||
or word.startswith("-")
|
||||
or _NUMBERISH.match(word)
|
||||
):
|
||||
break
|
||||
index += 1
|
||||
return words[index:]
|
||||
|
||||
|
||||
def _first_argument(words: list[str]) -> str:
|
||||
"""The first non-flag word, skipping flags that take a value."""
|
||||
skip = False
|
||||
for word in words:
|
||||
if skip:
|
||||
skip = False
|
||||
elif word in _TAKES_VALUE:
|
||||
skip = True
|
||||
elif not word.startswith(("-", "<", ">", "2>")):
|
||||
return word
|
||||
return ""
|
||||
|
||||
|
||||
def _name_command(words: list[str]) -> str:
|
||||
"""``git status``, ``python3 -m pytest``, ``bash x.sh`` or the bare name."""
|
||||
head, rest = _base(words[0]), words[1:]
|
||||
if head.startswith("python"):
|
||||
if "-m" in rest:
|
||||
after = rest[rest.index("-m") + 1 :]
|
||||
return f"{head} -m {after[0]}" if after else f"{head} -m"
|
||||
script = _first_argument(rest)
|
||||
return f"{head} {_base(script)}" if script else head
|
||||
if head in _SHELLS and "-c" in rest:
|
||||
return f"{head} -c"
|
||||
if head in _SHELLS or head in _SUBCOMMANDS:
|
||||
arg = _first_argument(rest)
|
||||
return f"{head} {_base(arg) if head in _SHELLS else arg}".strip()
|
||||
return head
|
||||
|
||||
|
||||
def bash_family(command: str) -> str:
|
||||
"""The command a Bash call really ran, ignoring wrappers and ``cd``."""
|
||||
last = ""
|
||||
for segment in _SEGMENTS.split(command.strip()):
|
||||
words = _command_words(segment.split())
|
||||
if not words:
|
||||
continue
|
||||
last = _name_command(words)
|
||||
if last != "cd":
|
||||
return last
|
||||
return last or EMPTY
|
||||
|
||||
|
||||
_PATH_KINDS = (
|
||||
("memory", ("/.claude/", "/memory/"), ("/.claude/", "/memories/")),
|
||||
("rules", ("/.claude/rules",), ("rules-ondemand",)),
|
||||
("skill", ("/skills/",)),
|
||||
("claude", ("/.claude/",)),
|
||||
)
|
||||
|
||||
|
||||
def _path_kind(path: str) -> str:
|
||||
"""What a file path is, as far as standing context goes."""
|
||||
name = _base(path)
|
||||
if name.startswith("CLAUDE") and name.endswith(".md"):
|
||||
return "claude-md"
|
||||
for kind, *needle_sets in _PATH_KINDS:
|
||||
if any(all(n in path for n in needles) for needles in needle_sets):
|
||||
return kind
|
||||
lowered = path.lower()
|
||||
return "image" if lowered.endswith(tuple(IMAGE_SUFFIXES)) else "file"
|
||||
|
||||
|
||||
def _text(tool_input: dict[str, object], key: str) -> str:
|
||||
"""One string input field, empty when absent."""
|
||||
value = tool_input.get(key)
|
||||
return value if isinstance(value, str) else ""
|
||||
|
||||
|
||||
def family(name: str, tool_input: dict[str, object]) -> str:
|
||||
"""The bucket a call is grouped under."""
|
||||
if name == "Bash":
|
||||
return bash_family(_text(tool_input, "command"))
|
||||
if name.startswith("mcp__"):
|
||||
return f"mcp:{name.split('__')[1]}"
|
||||
if name == "Skill":
|
||||
return "skill"
|
||||
if name in {"Read", "Write", "Edit", "MultiEdit", "NotebookEdit"}:
|
||||
return f"{name}:{_path_kind(_text(tool_input, 'file_path'))}"
|
||||
return name
|
||||
|
||||
|
||||
def target(name: str, tool_input: dict[str, object]) -> str:
|
||||
"""The one thing a call touched, whitespace-collapsed and capped."""
|
||||
if name == "Bash":
|
||||
found = _text(tool_input, "command")
|
||||
elif name.startswith("mcp__"):
|
||||
found = name.split("__", 2)[-1]
|
||||
elif name == "Skill":
|
||||
found = _text(tool_input, "skill")
|
||||
elif name == "WebFetch":
|
||||
found = urlsplit(_text(tool_input, "url")).netloc
|
||||
elif name in {"WebSearch", "Grep", "Glob"}:
|
||||
found = _text(tool_input, "query") or _text(tool_input, "pattern")
|
||||
elif name in {"Agent", "Task"}:
|
||||
kind = _text(tool_input, "subagent_type") or "general-purpose"
|
||||
worktree = _text(tool_input, "isolation") == "worktree"
|
||||
found = f"{kind} [worktree]" if worktree else kind
|
||||
else:
|
||||
found = _text(tool_input, "file_path")
|
||||
return " ".join(found.split())[:TARGET_CHARS]
|
||||
|
||||
|
||||
def signature(name: str, tool_input: dict[str, object]) -> str:
|
||||
"""A short stable hash of the full call, for spotting identical repeats."""
|
||||
raw = json.dumps([name, tool_input], sort_keys=True, default=str)
|
||||
return hashlib.sha1(raw.encode(), usedforsecurity=False).hexdigest()[:SIG_CHARS]
|
||||
|
||||
|
||||
def error_kind(text: str) -> str:
|
||||
"""Why a tool call failed, from its result text."""
|
||||
head = text[:400]
|
||||
for needle, kind in _ERRORS:
|
||||
if needle in head:
|
||||
return kind
|
||||
return "error"
|
||||
105
python_pkg/token_dashboard/commits.py
Normal file
105
python_pkg/token_dashboard/commits.py
Normal file
@ -0,0 +1,105 @@
|
||||
"""Find the commits each session made, from git rather than from tool output.
|
||||
|
||||
Measured 2026-10-17: of 601 recent commit commands only 35 printed a parseable
|
||||
``[branch sha]`` line -- most run through ``finish_auto.sh`` with a tailed or
|
||||
backgrounded output. So every commit command is stored with its time window
|
||||
and repository, and the hashes come from ``git log`` instead:
|
||||
|
||||
* a commit whose hash a command printed belongs to that command's session;
|
||||
* otherwise a commit carrying a ``Co-Authored-By: Claude`` trailer belongs to
|
||||
the commit command in the same repository whose window covers its commit
|
||||
time (from :data:`LEAD_S` before the call to :data:`TAIL_S` after its
|
||||
result -- a backgrounded finish_auto.sh commits after the call returns),
|
||||
the nearest one when several do.
|
||||
|
||||
Rows are only ever added, so a repository deleted later keeps its history.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
import re
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from python_pkg.token_dashboard import gitcmd
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import sqlite3
|
||||
|
||||
LEAD_S = 60.0
|
||||
TAIL_S = 1800.0
|
||||
TRAILER = re.compile(r"^Co-Authored-By:\s*Claude", re.IGNORECASE | re.MULTILINE)
|
||||
_FIELD, _RECORD = "\x1f", "\x1e"
|
||||
_LOG_FIELDS = 3
|
||||
|
||||
|
||||
def _git(repo: str, *args: str) -> str | None:
|
||||
"""Run git in ``repo``; ``None`` on any failure."""
|
||||
return gitcmd.run(repo, *args)
|
||||
|
||||
|
||||
def _toplevel(hint: str, cache: dict[str, str | None]) -> str | None:
|
||||
"""The work tree containing ``hint``, cached per hint."""
|
||||
if hint not in cache:
|
||||
out = _git(hint, "rev-parse", "--show-toplevel") if hint else None
|
||||
cache[hint] = out.strip() if out else None
|
||||
return cache[hint]
|
||||
|
||||
|
||||
def _log(repo: str, since: float) -> list[tuple[str, float, bool]]:
|
||||
"""``(sha, commit time, has Claude trailer)`` on HEAD since ``since``."""
|
||||
out = _git(
|
||||
repo,
|
||||
"log",
|
||||
"HEAD",
|
||||
f"--since=@{int(since)}",
|
||||
f"--format=%H{_FIELD}%ct{_FIELD}%B{_RECORD}",
|
||||
)
|
||||
rows = []
|
||||
for record in (out or "").split(_RECORD):
|
||||
parts = record.strip("\n").split(_FIELD, 2)
|
||||
if len(parts) == _LOG_FIELDS:
|
||||
rows.append((parts[0], float(parts[1]), bool(TRAILER.search(parts[2]))))
|
||||
return rows
|
||||
|
||||
|
||||
Command = tuple[str, int, float, float, str] # key, prompt, start, end, sha
|
||||
|
||||
|
||||
def _match(
|
||||
sha: str, ts: float, cmds: list[Command], *, trailer: bool
|
||||
) -> Command | None:
|
||||
"""The command that made this commit, if any."""
|
||||
for cmd in cmds:
|
||||
if cmd[4] and sha.startswith(cmd[4]):
|
||||
return cmd
|
||||
if not trailer:
|
||||
return None
|
||||
covering = [c for c in cmds if c[2] - LEAD_S <= ts <= max(c[2], c[3]) + TAIL_S]
|
||||
if not covering:
|
||||
return None
|
||||
return min(covering, key=lambda c: abs(ts - c[2]))
|
||||
|
||||
|
||||
def discover(conn: sqlite3.Connection) -> int:
|
||||
"""Attribute git commits to sessions; returns how many rows were added."""
|
||||
cache: dict[str, str | None] = {}
|
||||
by_repo: dict[str, list[Command]] = defaultdict(list)
|
||||
for key, prompt, start, end, sha, hint in conn.execute(
|
||||
"SELECT key, prompt_idx, ts, end_ts, sha, repo FROM commits"
|
||||
):
|
||||
top = _toplevel(hint, cache)
|
||||
if top is not None:
|
||||
by_repo[top].append((key, prompt, start, end, sha))
|
||||
added = 0
|
||||
for repo, cmds in by_repo.items():
|
||||
since = min(c[2] for c in cmds) - LEAD_S
|
||||
for sha, ts, trailer in _log(repo, since):
|
||||
cmd = _match(sha, ts, cmds, trailer=trailer)
|
||||
if cmd is None:
|
||||
continue
|
||||
added += conn.execute(
|
||||
"INSERT OR IGNORE INTO session_commits VALUES (?,?,?,?,?)",
|
||||
(sha, repo, ts, cmd[0], cmd[1]),
|
||||
).rowcount
|
||||
return added
|
||||
162
python_pkg/token_dashboard/context_files.py
Normal file
162
python_pkg/token_dashboard/context_files.py
Normal file
@ -0,0 +1,162 @@
|
||||
"""Which files enter Claude's context, and how big each has been over time.
|
||||
|
||||
:func:`find` lists them per file (token_audit's :mod:`surfaces` sums them per
|
||||
surface, which cannot say which rule grew). Only the list and the size history
|
||||
are stored -- the dashboard reads each file live when asked, so it never shows
|
||||
a stale copy.
|
||||
|
||||
Size history comes from two places, both cheap: ``~/.claude``'s own git
|
||||
history (one ``git log --raw`` plus one ``git cat-file --batch-check`` for
|
||||
every blob, incremental from the last commit seen) and the weekly prune's
|
||||
``prune/<date>/sizes*.json`` snapshots.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from python_pkg.token_audit import pricing
|
||||
from python_pkg.token_audit.surfaces import CLAUDE_DIR
|
||||
from python_pkg.token_dashboard import gitcmd
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import sqlite3
|
||||
|
||||
SRC_DIR = Path.home() / "src"
|
||||
MEMORY_LIMIT = 24_400 # MEMORY.md is only read up to ~24.4 KB
|
||||
CLAUDE_MD_LIMIT = 40_000 # Claude Code warns about a CLAUDE.md over 40k chars
|
||||
DESCRIPTION_LIMIT = 1024 # a skill description's maximum length
|
||||
_BATCH_FIELDS = 3 # cat-file --batch-check: <sha> <type> <size>
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ContextFile:
|
||||
"""One file and how it reaches the context."""
|
||||
|
||||
path: str
|
||||
kind: str
|
||||
standing: str # always | in repo | description
|
||||
limit: int | None
|
||||
|
||||
|
||||
def find(claude: Path | None = None, src: Path | None = None) -> list[ContextFile]:
|
||||
"""Every context file on disk (on-demand backups excluded)."""
|
||||
claude = claude or CLAUDE_DIR
|
||||
src = src or SRC_DIR
|
||||
out = [
|
||||
ContextFile(
|
||||
str(claude / "CLAUDE.md"), "global CLAUDE.md", "always", CLAUDE_MD_LIMIT
|
||||
)
|
||||
]
|
||||
groups = (
|
||||
("memories", "*.md", "memories (@-imported)", "always", None),
|
||||
("rules", "**/*.md", "rules (loaded recursively)", "always", None),
|
||||
("skills", "*/SKILL.md", "skill", "description", DESCRIPTION_LIMIT),
|
||||
("agents", "*.md", "agent definition", "description", DESCRIPTION_LIMIT),
|
||||
("output-styles", "*.md", "output style", "always", None),
|
||||
)
|
||||
for folder, pattern, kind, standing, limit in groups:
|
||||
out += [
|
||||
ContextFile(str(p), kind, standing, limit)
|
||||
for p in sorted((claude / folder).glob(pattern))
|
||||
if p.is_file()
|
||||
]
|
||||
memory = claude / "projects" / "-home-kuhy" / "memory" / "MEMORY.md"
|
||||
out.append(ContextFile(str(memory), "auto-memory index", "always", MEMORY_LIMIT))
|
||||
for name in ("CLAUDE.md", "AGENTS.md"):
|
||||
out += [
|
||||
ContextFile(str(p), f"repo {name}", "in repo", CLAUDE_MD_LIMIT)
|
||||
for p in sorted(src.glob(f"*/{name}"))
|
||||
]
|
||||
return [f for f in out if Path(f.path).is_file()]
|
||||
|
||||
|
||||
def _git(repo: Path, *args: str, stdin: str | None = None) -> str:
|
||||
"""Run git, returning stdout ('' on failure)."""
|
||||
return gitcmd.run(repo, *args, stdin=stdin) or ""
|
||||
|
||||
|
||||
def _git_sizes(claude: Path, since: str) -> tuple[list[tuple[str, float, str]], str]:
|
||||
"""``(relpath, commit time, blob)`` for commits after ``since``; new head."""
|
||||
head = _git(claude, "rev-parse", "HEAD").strip()
|
||||
if not head or head == since:
|
||||
return [], since
|
||||
span = f"{since}..HEAD" if since else "HEAD"
|
||||
out = _git(claude, "log", "--raw", "--no-abbrev", "--format=@%ct", span)
|
||||
rows: list[tuple[str, float, str]] = []
|
||||
stamp = 0.0
|
||||
for line in out.splitlines():
|
||||
if line.startswith("@"):
|
||||
stamp = float(line[1:])
|
||||
elif line.startswith(":") and line.endswith(".md"):
|
||||
meta, _, rel = line.partition("\t")
|
||||
blob = meta.split()[3]
|
||||
rows.append((rel, stamp, blob))
|
||||
return rows, head
|
||||
|
||||
|
||||
def refresh(
|
||||
conn: sqlite3.Connection, claude: Path | None = None, src: Path | None = None
|
||||
) -> None:
|
||||
"""Re-list context files and append any new size-history points."""
|
||||
claude = claude or CLAUDE_DIR
|
||||
files = find(claude, src)
|
||||
conn.execute("DELETE FROM context_files")
|
||||
conn.executemany(
|
||||
"INSERT INTO context_files VALUES (?,?,?,?)",
|
||||
[(f.path, f.kind, f.standing, f.limit) for f in files],
|
||||
)
|
||||
row = conn.execute("SELECT value FROM meta WHERE key='context_git_head'").fetchone()
|
||||
rows, head = _git_sizes(claude, row[0] if row else "")
|
||||
zero = "0" * 40
|
||||
blobs = sorted({b for _, _, b in rows if b != zero})
|
||||
sizes: dict[str, int] = {}
|
||||
batch = _git(claude, "cat-file", "--batch-check", stdin="\n".join(blobs) + "\n")
|
||||
for line in batch.splitlines():
|
||||
parts = line.split()
|
||||
if len(parts) == _BATCH_FIELDS and parts[2].isdigit():
|
||||
sizes[parts[0]] = int(parts[2])
|
||||
conn.executemany(
|
||||
"INSERT OR IGNORE INTO context_sizes VALUES (?,?,?,?)",
|
||||
[(str(claude / rel), ts, sizes.get(b, 0), "git") for rel, ts, b in rows],
|
||||
)
|
||||
conn.execute("INSERT OR REPLACE INTO meta VALUES ('context_git_head', ?)", (head,))
|
||||
for path in sorted((claude / "token-report" / "prune").glob("*/sizes*.json")):
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8")).get("file_bytes", {})
|
||||
except ValueError, OSError, AttributeError:
|
||||
continue
|
||||
stamp = path.stat().st_mtime
|
||||
conn.executemany(
|
||||
"INSERT OR IGNORE INTO context_sizes VALUES (?,?,?,?)",
|
||||
[
|
||||
(str(claude / rel), stamp, size, f"prune {path.parent.name}")
|
||||
for rel, size in data.items()
|
||||
if isinstance(size, int)
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def price_mix(conn: sqlite3.Connection, now: float, days: float = 7.0) -> float:
|
||||
"""Call-weighted cache-read USD/MTok over the last ``days``, into ``meta``.
|
||||
|
||||
A standing file costs its tokens x calls/day x this price: every call
|
||||
re-reads the whole prefix from cache.
|
||||
"""
|
||||
book = pricing.load()
|
||||
rows = conn.execute(
|
||||
"SELECT model, COUNT(*) FROM turns WHERE ts > ? GROUP BY model",
|
||||
(now - days * 86400,),
|
||||
).fetchall()
|
||||
calls = sum(n for _, n in rows)
|
||||
price = (
|
||||
sum(book.for_model(m).cache_read * n for m, n in rows) / calls if calls else 0.0
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO meta VALUES ('cache_read_usd_per_mtok', ?)",
|
||||
(str(price),),
|
||||
)
|
||||
return price
|
||||
207
python_pkg/token_dashboard/detail.py
Normal file
207
python_pkg/token_dashboard/detail.py
Normal file
@ -0,0 +1,207 @@
|
||||
"""Collect the detail rows (calls, marks, turn facts) during a file walk.
|
||||
|
||||
The walker feeds every record, tool call, tool result and priced turn here;
|
||||
:meth:`Detail.finish` settles creation and carry cost on each row. Marks come
|
||||
from what Claude Code writes next to the conversation (see :mod:`marks`).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from python_pkg.token_audit import parse, records
|
||||
from python_pkg.token_audit.pricing import WRITE_1H, WRITE_5M
|
||||
from python_pkg.token_dashboard import blocks, carry, classify, hook_join, marks
|
||||
from python_pkg.token_dashboard.detail_rows import (
|
||||
CallRow,
|
||||
DetailRows,
|
||||
MarkRow,
|
||||
TurnDetail,
|
||||
Where,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from python_pkg.token_audit.model import Turn
|
||||
from python_pkg.token_audit.pricing import PriceBook
|
||||
|
||||
CHARS = parse.CHARS_PER_TOKEN
|
||||
|
||||
|
||||
def write_multiplier(usage: dict[str, int]) -> float:
|
||||
"""Average cache-write price of one call, as a multiple of input."""
|
||||
total = usage.get("cache_creation_input_tokens", 0)
|
||||
if not total:
|
||||
return WRITE_5M
|
||||
one_hour = min(usage.get(records.CACHE_1H, 0), total)
|
||||
return (one_hour * WRITE_1H + (total - one_hour) * WRITE_5M) / total
|
||||
|
||||
|
||||
def _message(rec: dict[str, Any]) -> dict[str, Any]:
|
||||
"""A record's ``message`` dict, empty when absent."""
|
||||
message = rec.get("message")
|
||||
return message if isinstance(message, dict) else {}
|
||||
|
||||
|
||||
class _Output:
|
||||
"""Visible output of one API message, accumulated over its records."""
|
||||
|
||||
def __init__(self, latency: float) -> None:
|
||||
"""Start empty; ``latency`` is the gap before the first record."""
|
||||
self.latency = latency
|
||||
self.text = 0
|
||||
self.tool = 0
|
||||
self.thinking = False
|
||||
|
||||
def add(self, block: dict[str, Any]) -> None:
|
||||
"""Count one content block."""
|
||||
kind = block.get("type")
|
||||
if kind == "text":
|
||||
self.text += len(str(block.get("text") or ""))
|
||||
elif kind == "tool_use":
|
||||
self.tool += len(json.dumps(block.get("input") or {}))
|
||||
elif kind in {"thinking", "redacted_thinking"}:
|
||||
self.thinking = True
|
||||
|
||||
def split(self, output: int) -> tuple[int, int, int]:
|
||||
"""``(text, tool, thinking)`` tokens summing to ``output``.
|
||||
|
||||
Visible blocks are estimated by length; billed output left over is
|
||||
thinking (whose text Claude Code does not keep). With no thinking
|
||||
block, or more visible text than output, the visible estimates are
|
||||
scaled to the billed total instead.
|
||||
"""
|
||||
text, tool = self.text // CHARS, self.tool // CHARS
|
||||
visible = text + tool
|
||||
if not visible:
|
||||
return (0, 0, output) if self.thinking else (output, 0, 0)
|
||||
if visible > output or not self.thinking:
|
||||
text = round(output * text / visible)
|
||||
return text, output - text, 0
|
||||
return text, tool, output - visible
|
||||
|
||||
|
||||
class Detail:
|
||||
"""Detail state for one file."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Nothing seen yet."""
|
||||
self.rows = DetailRows()
|
||||
self.tracker = carry.Tracker()
|
||||
self.calls: dict[str, CallRow] = {}
|
||||
self.outputs: dict[str, _Output] = {}
|
||||
self.last_ts = 0.0
|
||||
self.model = ""
|
||||
self.hooks = hook_join.Hooks()
|
||||
|
||||
def mark(self, found: marks.Found, ts: float, prompt: int) -> MarkRow:
|
||||
"""Turn a found mark into a row; carry it if it sits in context."""
|
||||
row = MarkRow(
|
||||
Where(prompt, ts, self.model),
|
||||
found.kind,
|
||||
found.name,
|
||||
detail=found.detail,
|
||||
count=found.count,
|
||||
tokens=found.tokens,
|
||||
extra=found.extra,
|
||||
duration_ms=found.duration_ms,
|
||||
)
|
||||
self.rows.marks.append(row)
|
||||
if row.tokens and found.kind not in marks.NOT_IN_CONTEXT:
|
||||
self.tracker.result(row.cost, row.tokens)
|
||||
self.hooks.track(row, found)
|
||||
return row
|
||||
|
||||
def record(self, rec: dict[str, Any], ts: float, prompt: int) -> None:
|
||||
"""Account one record (after the walker opened any new prompt)."""
|
||||
kind = rec.get("type")
|
||||
if kind == "assistant":
|
||||
self.assistant(rec, ts, prompt)
|
||||
elif kind == "user" and blocks.prompt_text(rec) is not None:
|
||||
self.hooks.prompt_opened()
|
||||
self.last_ts = ts
|
||||
for found in marks.of_record(rec):
|
||||
self.mark(found, ts, prompt)
|
||||
|
||||
def assistant(self, rec: dict[str, Any], ts: float, prompt: int) -> None:
|
||||
"""Error placeholders become marks; real messages add visible output."""
|
||||
message = _message(rec)
|
||||
if rec.get("isApiErrorMessage"):
|
||||
text = blocks.text_of(message.get("content"))
|
||||
detail = str(rec.get("apiError") or text)[: classify.TARGET_CHARS]
|
||||
name = str(rec.get("error") or "unknown")
|
||||
self.mark(marks.Found("api_error", name, detail=detail), ts, prompt)
|
||||
return
|
||||
mid = str(message.get("id") or "")
|
||||
if mid not in self.outputs:
|
||||
self.outputs[mid] = _Output(max(0.0, ts - (self.last_ts or ts)))
|
||||
for block in parse.content_blocks(rec):
|
||||
self.outputs[mid].add(block)
|
||||
|
||||
def use(
|
||||
self, rec: dict[str, Any], block: dict[str, Any], ts: float, prompt: int
|
||||
) -> None:
|
||||
"""Open a call row for a ``tool_use`` block."""
|
||||
message = _message(rec)
|
||||
raw = block.get("input")
|
||||
tool_input: dict[str, object] = raw if isinstance(raw, dict) else {}
|
||||
name = str(block.get("name") or "unknown")
|
||||
row = CallRow(
|
||||
Where(prompt, ts, str(message.get("model") or self.model)),
|
||||
name=name,
|
||||
family=classify.family(name, tool_input),
|
||||
target=classify.target(name, tool_input),
|
||||
sig=classify.signature(name, tool_input),
|
||||
message_id=str(message.get("id") or ""),
|
||||
)
|
||||
self.calls[str(block.get("id") or "")] = row
|
||||
self.rows.calls.append(row)
|
||||
|
||||
def result(self, block: dict[str, Any], ts: float) -> None:
|
||||
"""Close the call a ``tool_result`` answers."""
|
||||
row = self.calls.pop(str(block.get("tool_use_id") or ""), None)
|
||||
if row is None:
|
||||
return
|
||||
content = block.get("content")
|
||||
images, text = marks.split_images(content)
|
||||
row.end_ts = ts
|
||||
row.images = images
|
||||
row.result_tokens = images * parse.IMAGE_TOKENS + len(text) // CHARS
|
||||
if block.get("is_error"):
|
||||
row.error_kind = classify.error_kind(blocks.text_of(content))
|
||||
self.tracker.result(row.cost, row.result_tokens)
|
||||
|
||||
def turn(self, turn: Turn, cost: float, prompt: int) -> None:
|
||||
"""Note a priced API call."""
|
||||
usage = turn.usage
|
||||
self.model = turn.model
|
||||
self.tracker.turn(turn.model, turn.context, write_multiplier(usage))
|
||||
self.hooks.turn(cost, turn.timestamp)
|
||||
output = self.outputs.pop(turn.message_id, None) or _Output(0.0)
|
||||
text, tool, think = output.split(usage.get("output_tokens", 0))
|
||||
one_hour = usage.get(records.CACHE_1H, 0)
|
||||
self.rows.turns.append(
|
||||
TurnDetail(
|
||||
Where(prompt, turn.timestamp, turn.model),
|
||||
latency_s=output.latency,
|
||||
cw_1h=one_hour,
|
||||
cw_5m=usage.get("cache_creation_input_tokens", 0) - one_hour,
|
||||
out_text=text,
|
||||
out_tool=tool,
|
||||
out_think=think,
|
||||
tool_calls=turn.tool_calls,
|
||||
)
|
||||
)
|
||||
|
||||
def finish(self, book: PriceBook) -> DetailRows:
|
||||
"""Settle costs, fill batch sizes and models, merge repeated marks."""
|
||||
self.tracker.settle(book)
|
||||
per_message: dict[str, int] = {}
|
||||
for call in self.rows.calls:
|
||||
per_message[call.message_id] = per_message.get(call.message_id, 0) + 1
|
||||
for call in self.rows.calls:
|
||||
call.batch = per_message[call.message_id]
|
||||
first_model = self.rows.turns[0].where.model if self.rows.turns else ""
|
||||
self.hooks.resolve()
|
||||
self.rows.marks = hook_join.merge(self.rows.marks, first_model)
|
||||
return self.rows
|
||||
91
python_pkg/token_dashboard/detail_rows.py
Normal file
91
python_pkg/token_dashboard/detail_rows.py
Normal file
@ -0,0 +1,91 @@
|
||||
"""Row types for the detail tables: one tool call, one context mark, one turn.
|
||||
|
||||
Each call and mark carries a :class:`Cost` (see :mod:`carry`): what putting
|
||||
the chunk into the prompt cache cost, and what re-reading it on every later
|
||||
call of the same file cost until a compaction dropped it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
|
||||
@dataclass
|
||||
class Cost:
|
||||
"""Creation + carry of one chunk of context, and the calls that carried it."""
|
||||
|
||||
create_usd: float = 0.0
|
||||
carry_usd: float = 0.0
|
||||
carry_calls: int = 0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Where:
|
||||
"""When something happened: prompt index, timestamp, serving model."""
|
||||
|
||||
prompt_idx: int
|
||||
ts: float
|
||||
model: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class CallRow:
|
||||
"""One tool call, paired with its result (``error_kind`` empty when ok)."""
|
||||
|
||||
where: Where
|
||||
name: str
|
||||
family: str
|
||||
target: str
|
||||
sig: str
|
||||
message_id: str
|
||||
end_ts: float = 0.0
|
||||
batch: int = 1
|
||||
result_tokens: int = 0
|
||||
images: int = 0
|
||||
error_kind: str = ""
|
||||
cost: Cost = field(default_factory=Cost)
|
||||
|
||||
|
||||
@dataclass
|
||||
class MarkRow:
|
||||
"""Something that entered (or touched) context without being a tool call.
|
||||
|
||||
``kind`` is one of ``hook_run``, ``hook_context``, ``hook_block``,
|
||||
``hook_error``, ``hook_msg``, ``attach``, ``compaction``, ``api_error``,
|
||||
``interrupt``, ``image``. Rows with the same prompt, kind, name, detail
|
||||
and model are summed into one, with ``count`` occurrences.
|
||||
"""
|
||||
|
||||
where: Where
|
||||
kind: str
|
||||
name: str
|
||||
detail: str = ""
|
||||
count: int = 1
|
||||
tokens: int = 0
|
||||
extra: int = 0
|
||||
duration_ms: int = 0
|
||||
retry_usd: float = 0.0
|
||||
cost: Cost = field(default_factory=Cost)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TurnDetail:
|
||||
"""Per-API-call facts the ``turns`` table does not hold."""
|
||||
|
||||
where: Where
|
||||
latency_s: float
|
||||
cw_1h: int
|
||||
cw_5m: int
|
||||
out_text: int
|
||||
out_tool: int
|
||||
out_think: int
|
||||
tool_calls: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class DetailRows:
|
||||
"""Everything one file contributes to the detail tables."""
|
||||
|
||||
calls: list[CallRow] = field(default_factory=list)
|
||||
marks: list[MarkRow] = field(default_factory=list)
|
||||
turns: list[TurnDetail] = field(default_factory=list)
|
||||
110
python_pkg/token_dashboard/detail_store.py
Normal file
110
python_pkg/token_dashboard/detail_store.py
Normal file
@ -0,0 +1,110 @@
|
||||
"""Write one file's detail rows (calls, marks, turn facts) into the store.
|
||||
|
||||
Every row repeats the owning file's key, main session key, subagent flag and
|
||||
cwd, plus its own model, timestamp and local day, so the dashboard's shared
|
||||
filter clause (date range, cwd, model, scope) applies to these tables as-is.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import sqlite3
|
||||
|
||||
from python_pkg.token_dashboard.detail_rows import Cost, Where
|
||||
from python_pkg.token_dashboard.rows import FileFacts
|
||||
|
||||
CLEAR = (
|
||||
"DELETE FROM calls WHERE key = ?",
|
||||
"DELETE FROM marks WHERE key = ?",
|
||||
"DELETE FROM turn_detail WHERE key = ?",
|
||||
)
|
||||
|
||||
Owner = tuple[str, str, int, str | None]
|
||||
|
||||
|
||||
def day_of(stamp: float) -> str:
|
||||
"""Local calendar day of a unix timestamp."""
|
||||
return datetime.fromtimestamp(stamp).astimezone().strftime("%Y-%m-%d")
|
||||
|
||||
|
||||
def _where(where: Where) -> tuple[str, float, str, int]:
|
||||
"""``model, ts, day, prompt_idx`` columns."""
|
||||
return (where.model, where.ts, day_of(where.ts), where.prompt_idx)
|
||||
|
||||
|
||||
def _cost(cost: Cost) -> tuple[float, float, int]:
|
||||
"""``create_usd, carry_usd, carry_calls`` columns."""
|
||||
return (cost.create_usd, cost.carry_usd, cost.carry_calls)
|
||||
|
||||
|
||||
def write(conn: sqlite3.Connection, facts: FileFacts, owner: Owner) -> None:
|
||||
"""Insert the detail rows; :func:`store.write` already cleared old ones."""
|
||||
detail = facts.rows.detail
|
||||
conn.executemany(
|
||||
"INSERT INTO calls (key, main_key, is_sub, cwd, model, ts, day,"
|
||||
" prompt_idx, end_ts, name, family, target, sig, batch, result_tokens,"
|
||||
" images, is_error, error_kind, create_usd, carry_usd, carry_calls)"
|
||||
" VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
[
|
||||
(
|
||||
*owner,
|
||||
*_where(c.where),
|
||||
c.end_ts,
|
||||
c.name,
|
||||
c.family,
|
||||
c.target,
|
||||
c.sig,
|
||||
c.batch,
|
||||
c.result_tokens,
|
||||
c.images,
|
||||
int(bool(c.error_kind)),
|
||||
c.error_kind,
|
||||
*_cost(c.cost),
|
||||
)
|
||||
for c in detail.calls
|
||||
],
|
||||
)
|
||||
conn.executemany(
|
||||
"INSERT INTO marks (key, main_key, is_sub, cwd, model, ts, day,"
|
||||
" prompt_idx, kind, name, detail, count, tokens, extra, duration_ms,"
|
||||
" retry_usd, create_usd, carry_usd, carry_calls)"
|
||||
" VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
[
|
||||
(
|
||||
*owner,
|
||||
*_where(m.where),
|
||||
m.kind,
|
||||
m.name,
|
||||
m.detail,
|
||||
m.count,
|
||||
m.tokens,
|
||||
m.extra,
|
||||
m.duration_ms,
|
||||
m.retry_usd,
|
||||
*_cost(m.cost),
|
||||
)
|
||||
for m in detail.marks
|
||||
],
|
||||
)
|
||||
conn.executemany(
|
||||
"INSERT INTO turn_detail (key, main_key, is_sub, cwd, model, ts, day,"
|
||||
" prompt_idx, latency_s, cw_1h, cw_5m, out_text, out_tool, out_think,"
|
||||
" tool_calls) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
[
|
||||
(
|
||||
*owner,
|
||||
*_where(t.where),
|
||||
t.latency_s,
|
||||
t.cw_1h,
|
||||
t.cw_5m,
|
||||
t.out_text,
|
||||
t.out_tool,
|
||||
t.out_think,
|
||||
t.tool_calls,
|
||||
)
|
||||
for t in detail.turns
|
||||
],
|
||||
)
|
||||
153
python_pkg/token_dashboard/efficiency.py
Normal file
153
python_pkg/token_dashboard/efficiency.py
Normal file
@ -0,0 +1,153 @@
|
||||
"""Three 0-token efficiency trends, weekly.
|
||||
|
||||
1. **USD per DONE session** -- all spend of status-tagged sessions in a week
|
||||
divided by how many of them ended on ``DONE:``. Failed and parked sessions
|
||||
count against the done ones, which is the point. Sessions with no tag at all
|
||||
(before the convention) are counted but kept out of the ratio.
|
||||
2. **USD per surviving commit** -- the week's total spend divided by commits
|
||||
made that week that survived :mod:`survival`'s 14-day rule.
|
||||
3. **USD per estimated size unit** -- spend of sessions the task-size estimator
|
||||
sized, divided by the sum of its ``est_calls`` for them.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta
|
||||
import json
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from python_pkg.token_dashboard import survival
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterable
|
||||
import sqlite3
|
||||
|
||||
Week = dict[str, Any]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SessionCost:
|
||||
"""What one main session cost and how it ended."""
|
||||
|
||||
key: str
|
||||
first_ts: float
|
||||
usd: float
|
||||
tag: str | None
|
||||
|
||||
|
||||
def week_of(stamp: float) -> str:
|
||||
"""Monday (local time) of the week containing ``stamp``."""
|
||||
day = datetime.fromtimestamp(stamp).astimezone().date()
|
||||
return (day - timedelta(days=day.weekday())).isoformat()
|
||||
|
||||
|
||||
def _ratio(part: float, whole: float) -> float | None:
|
||||
"""``part / whole``, ``None`` when ``whole`` is zero."""
|
||||
return part / whole if whole else None
|
||||
|
||||
|
||||
def done_weekly(rows: Iterable[SessionCost]) -> list[Week]:
|
||||
"""USD per DONE session and the DONE share, per week."""
|
||||
weeks: dict[str, Week] = defaultdict(
|
||||
lambda: {"done": 0, "other": 0, "untagged": 0, "usd_tagged": 0.0}
|
||||
)
|
||||
for row in rows:
|
||||
week = weeks[week_of(row.first_ts)]
|
||||
if row.tag is None:
|
||||
week["untagged"] += 1
|
||||
continue
|
||||
week["usd_tagged"] += row.usd
|
||||
week["done" if row.tag == "DONE" else "other"] += 1
|
||||
out = []
|
||||
for name in sorted(weeks):
|
||||
week = weeks[name]
|
||||
tagged = week["done"] + week["other"]
|
||||
week |= {
|
||||
"week": name,
|
||||
"usd_per_done": _ratio(week["usd_tagged"], week["done"]),
|
||||
"done_share": _ratio(week["done"], tagged),
|
||||
}
|
||||
out.append(week)
|
||||
return out
|
||||
|
||||
|
||||
def commits_weekly(
|
||||
verdicts: Iterable[tuple[float, str]], week_usd: dict[str, float]
|
||||
) -> list[Week]:
|
||||
"""USD per surviving commit, with lost / pending / unknown counts."""
|
||||
weeks: dict[str, Week] = defaultdict(
|
||||
lambda: {"survived": 0, "lost": 0, "pending": 0, "unknown": 0}
|
||||
)
|
||||
for stamp, verdict in verdicts:
|
||||
week = weeks[week_of(stamp)]
|
||||
if verdict == "survived":
|
||||
week["survived"] += 1
|
||||
elif verdict in survival.FINAL:
|
||||
week["lost"] += 1
|
||||
elif verdict == "pending":
|
||||
week["pending"] += 1
|
||||
else:
|
||||
week["unknown"] += 1
|
||||
out = []
|
||||
for name in sorted(weeks):
|
||||
usd = week_usd.get(name, 0.0)
|
||||
week = weeks[name]
|
||||
week |= {
|
||||
"week": name,
|
||||
"usd": usd,
|
||||
"usd_per_survivor": _ratio(usd, week["survived"]),
|
||||
}
|
||||
out.append(week)
|
||||
return out
|
||||
|
||||
|
||||
def estimate_weekly(rows: Iterable[SessionCost], est: dict[str, int]) -> list[Week]:
|
||||
"""USD per estimated call, over sessions the estimator sized."""
|
||||
weeks: dict[str, Week] = defaultdict(lambda: {"usd": 0.0, "est_calls": 0, "n": 0})
|
||||
for row in rows:
|
||||
if row.key not in est:
|
||||
continue
|
||||
week = weeks[week_of(row.first_ts)]
|
||||
week["usd"] += row.usd
|
||||
week["est_calls"] += est[row.key]
|
||||
week["n"] += 1
|
||||
return [
|
||||
weeks[name]
|
||||
| {
|
||||
"week": name,
|
||||
"usd_per_unit": _ratio(weeks[name]["usd"], weeks[name]["est_calls"]),
|
||||
}
|
||||
for name in sorted(weeks)
|
||||
]
|
||||
|
||||
|
||||
def rebuild(conn: sqlite3.Connection, now: float) -> None:
|
||||
"""Recompute the three weekly series into ``meta['efficiency']``."""
|
||||
rows = [
|
||||
SessionCost(key, first, usd, tag)
|
||||
for key, first, usd, tag in conn.execute(
|
||||
"SELECT key, first_ts, usd, final_tag FROM session_summary"
|
||||
)
|
||||
]
|
||||
week_usd: dict[str, float] = defaultdict(float)
|
||||
for row in rows:
|
||||
week_usd[week_of(row.first_ts)] += row.usd
|
||||
judged = conn.execute(
|
||||
"SELECT c.ts, v.verdict FROM session_commits c"
|
||||
" LEFT JOIN commit_verdicts v ON v.sha = c.sha"
|
||||
).fetchall()
|
||||
verdicts = [
|
||||
(ts, verdict or ("pending" if now - ts < survival.WINDOW_S else "unchecked"))
|
||||
for ts, verdict in judged
|
||||
]
|
||||
est = dict(conn.execute("SELECT session_id, est_calls FROM estimates"))
|
||||
payload = {
|
||||
"done": done_weekly(rows),
|
||||
"commits": commits_weekly(verdicts, week_usd),
|
||||
"estimate": estimate_weekly(rows, est),
|
||||
}
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO meta VALUES ('efficiency', ?)", (json.dumps(payload),)
|
||||
)
|
||||
217
python_pkg/token_dashboard/finalize.py
Normal file
217
python_pkg/token_dashboard/finalize.py
Normal file
@ -0,0 +1,217 @@
|
||||
"""Derive per-session, per-prompt and per-subagent summaries from raw rows.
|
||||
|
||||
Run for every main session touched by an ingest pass. A subagent file may be
|
||||
ingested in a different pass than its parent, so everything here is computed
|
||||
from the store, never from the in-memory walk.
|
||||
|
||||
Linking a subagent to the prompt that spawned it: the ``toolUseId`` in its
|
||||
``.meta.json`` (or the ``agentId`` the ``Agent`` result reported) names an
|
||||
``Agent`` call; that call's prompt is the answer. A depth-2 agent's call sits
|
||||
in its parent agent's file, so the chain is followed upward. An agent with no
|
||||
resolvable spawn -- one that outlived ``/clear`` -- stays ``prompt_idx NULL``
|
||||
("unattributed") rather than being dropped, so session cost always reconciles.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
import json
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from python_pkg.token_dashboard import active
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import sqlite3
|
||||
|
||||
_MAX_DEPTH = 8
|
||||
|
||||
|
||||
def _rows(conn: sqlite3.Connection, sql: str, *args: object) -> list[Any]:
|
||||
"""Run a query and return all rows."""
|
||||
return conn.execute(sql, args).fetchall()
|
||||
|
||||
|
||||
class _Session:
|
||||
"""Everything stored for one main session and its subagents."""
|
||||
|
||||
def __init__(self, conn: sqlite3.Connection, main: str) -> None:
|
||||
self.main = main
|
||||
self.sessions = {
|
||||
r[0]: r
|
||||
for r in _rows(
|
||||
conn,
|
||||
"SELECT key, cwd, agent_type, description, spawn_tool_use_id,"
|
||||
" agent_model, first_ts, last_ts, final_tag"
|
||||
" FROM sessions WHERE main_key = ?",
|
||||
main,
|
||||
)
|
||||
}
|
||||
self.turns = _rows(
|
||||
conn,
|
||||
"SELECT key, prompt_idx, usd, model FROM turns WHERE main_key = ?",
|
||||
main,
|
||||
)
|
||||
keys = json.dumps(list(self.sessions))
|
||||
self.calls = _rows(
|
||||
conn,
|
||||
"SELECT key, tool_use_id, agent_id, prompt_idx, description,"
|
||||
" subagent_type FROM agent_calls WHERE key IN"
|
||||
" (SELECT value FROM json_each(?))",
|
||||
keys,
|
||||
)
|
||||
self.spans_by_key: dict[str, list[active.Span]] = defaultdict(list)
|
||||
for key, start, end in _rows(
|
||||
conn,
|
||||
"SELECT key, start, end FROM activity WHERE key IN"
|
||||
" (SELECT value FROM json_each(?))",
|
||||
keys,
|
||||
):
|
||||
self.spans_by_key[key].append((start, end))
|
||||
self.spans = [s for spans in self.spans_by_key.values() for s in spans]
|
||||
self.prompts = _rows(
|
||||
conn,
|
||||
"SELECT idx, ts, text FROM prompts WHERE key = ? ORDER BY idx",
|
||||
main,
|
||||
)
|
||||
self.usd: dict[str, float] = defaultdict(float)
|
||||
self.ncalls: dict[str, int] = defaultdict(int)
|
||||
self.models: dict[str, dict[str, float]] = defaultdict(
|
||||
lambda: defaultdict(float)
|
||||
)
|
||||
for key, _, usd, model in self.turns:
|
||||
self.usd[key] += usd
|
||||
self.ncalls[key] += 1
|
||||
self.models[key][model] += usd
|
||||
|
||||
def spawn(self, sub: str) -> tuple[Any, ...] | None:
|
||||
"""The ``Agent`` call that spawned ``sub``, if any is stored."""
|
||||
tool_use_id = self.sessions[sub][4]
|
||||
agent_id = sub.rsplit("/agent-", 1)[-1]
|
||||
for call in self.calls:
|
||||
if (tool_use_id and call[1] == tool_use_id) or call[2] == agent_id:
|
||||
return tuple(call)
|
||||
return None
|
||||
|
||||
def resolve(self, sub: str) -> tuple[int | None, int, tuple[Any, ...] | None]:
|
||||
"""``(prompt_idx, depth, spawning call)`` for a subagent."""
|
||||
first = self.spawn(sub)
|
||||
call, depth = first, 1
|
||||
while call is not None and call[0] != self.main and depth < _MAX_DEPTH:
|
||||
call = self.spawn(call[0]) if call[0] in self.sessions else None
|
||||
depth += 1
|
||||
return (call[3] if call is not None else None), depth, first
|
||||
|
||||
|
||||
def _sub_rows(state: _Session) -> list[tuple[Any, ...]]:
|
||||
"""One ``subagent_link`` row per subagent of the session."""
|
||||
out = []
|
||||
for key, row in state.sessions.items():
|
||||
if key == state.main:
|
||||
continue
|
||||
prompt_idx, depth, call = state.resolve(key)
|
||||
models = state.models.get(key) or {}
|
||||
model = max(models, key=models.__getitem__) if models else row[5]
|
||||
spans = state.spans_by_key.get(key, [])
|
||||
out.append(
|
||||
(
|
||||
key,
|
||||
state.main,
|
||||
prompt_idx,
|
||||
depth,
|
||||
row[3] or (call[4] if call else "") or key,
|
||||
row[2] or (call[5] if call else "") or "unknown",
|
||||
model,
|
||||
state.usd.get(key, 0.0),
|
||||
state.ncalls.get(key, 0),
|
||||
row[6],
|
||||
row[7],
|
||||
row[7] - row[6],
|
||||
active.measure(spans),
|
||||
)
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def refresh(conn: sqlite3.Connection, main: str) -> None:
|
||||
"""Rebuild every summary row for one main session."""
|
||||
state = _Session(conn, main)
|
||||
conn.execute("DELETE FROM subagent_link WHERE main_key = ?", (main,))
|
||||
conn.execute("DELETE FROM prompt_summary WHERE key = ?", (main,))
|
||||
conn.execute("DELETE FROM session_summary WHERE key = ?", (main,))
|
||||
if not state.sessions:
|
||||
return
|
||||
subs = _sub_rows(state)
|
||||
conn.executemany(
|
||||
"INSERT INTO subagent_link VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)", subs
|
||||
)
|
||||
first = min((r[6] for r in state.sessions.values() if r[6]), default=0.0)
|
||||
last = max(r[7] for r in state.sessions.values())
|
||||
_prompt_rows(conn, state, subs, last)
|
||||
own = state.sessions.get(main)
|
||||
usd_main = state.usd.get(main, 0.0)
|
||||
usd_sub = sum(r[7] for r in subs)
|
||||
merged: dict[str, float] = defaultdict(float)
|
||||
for models in state.models.values():
|
||||
for model, usd in models.items():
|
||||
merged[model] += usd
|
||||
conn.execute(
|
||||
"INSERT INTO session_summary VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
(
|
||||
main,
|
||||
own[1] if own else next(iter(state.sessions.values()))[1],
|
||||
state.prompts[0][2] if state.prompts else (subs[0][4] if subs else ""),
|
||||
first,
|
||||
last,
|
||||
last - first,
|
||||
active.measure(state.spans),
|
||||
usd_main + usd_sub,
|
||||
usd_main,
|
||||
usd_sub,
|
||||
sum(state.ncalls.values()),
|
||||
len(state.prompts),
|
||||
len(subs),
|
||||
json.dumps(dict(merged)),
|
||||
own[8] if own else None,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _prompt_rows(
|
||||
conn: sqlite3.Connection,
|
||||
state: _Session,
|
||||
subs: list[tuple[Any, ...]],
|
||||
last: float,
|
||||
) -> None:
|
||||
"""One ``prompt_summary`` row per prompt, plus one for pre-prompt turns."""
|
||||
own: dict[int, float] = defaultdict(float)
|
||||
calls: dict[int, int] = defaultdict(int)
|
||||
for key, idx, usd, _ in state.turns:
|
||||
if key == state.main:
|
||||
own[idx] += usd
|
||||
calls[idx] += 1
|
||||
sub_usd: dict[int, float] = defaultdict(float)
|
||||
for row in subs:
|
||||
if row[2] is not None:
|
||||
sub_usd[row[2]] += row[7]
|
||||
prompts = list(state.prompts)
|
||||
if own.get(-1) or sub_usd.get(-1):
|
||||
start = min((s for s, _ in state.spans), default=0.0)
|
||||
prompts.insert(0, (-1, start, "(before the first prompt)"))
|
||||
bounds = [ts for _, ts, _ in prompts[1:]] + ([last + 1] if prompts else [])
|
||||
conn.executemany(
|
||||
"INSERT INTO prompt_summary VALUES (?,?,?,?,?,?,?,?,?)",
|
||||
[
|
||||
(
|
||||
state.main,
|
||||
idx,
|
||||
ts,
|
||||
text,
|
||||
own.get(idx, 0.0),
|
||||
sub_usd.get(idx, 0.0),
|
||||
active.measure(state.spans, ts, end),
|
||||
min(end, last) - ts,
|
||||
calls.get(idx, 0),
|
||||
)
|
||||
for (idx, ts, text), end in zip(prompts, bounds, strict=True)
|
||||
],
|
||||
)
|
||||
29
python_pkg/token_dashboard/gitcmd.py
Normal file
29
python_pkg/token_dashboard/gitcmd.py
Normal file
@ -0,0 +1,29 @@
|
||||
"""One way to run git for the dashboard: absolute binary, bounded, never raises."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import subprocess
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
GIT = shutil.which("git") or "/usr/bin/git"
|
||||
TIMEOUT_S = 30
|
||||
|
||||
|
||||
def run(repo: str | Path, *args: str, stdin: str | None = None) -> str | None:
|
||||
"""``git -C repo args``: stdout, or ``None`` on any failure."""
|
||||
try:
|
||||
done = subprocess.run(
|
||||
[GIT, "-C", str(repo), *args],
|
||||
input=stdin,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
timeout=TIMEOUT_S,
|
||||
)
|
||||
except OSError, subprocess.TimeoutExpired:
|
||||
return None
|
||||
return done.stdout if done.returncode == 0 else None
|
||||
91
python_pkg/token_dashboard/hook_join.py
Normal file
91
python_pkg/token_dashboard/hook_join.py
Normal file
@ -0,0 +1,91 @@
|
||||
"""Join hook context to the run that produced it; price forced retries; merge.
|
||||
|
||||
``hook_additional_context`` names no command, so it is matched to a
|
||||
``hook_success`` run of the same tool-use id and hook name whose stdout
|
||||
contains the context text. A ``hook_blocking_error`` costs the calls it
|
||||
forces: a Stop bounce every call until the next prompt, any other block the
|
||||
one call that reacts to it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import replace
|
||||
import json
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from python_pkg.token_dashboard.marks import SNIPPET
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from python_pkg.token_dashboard.detail_rows import MarkRow
|
||||
from python_pkg.token_dashboard.marks import Found
|
||||
|
||||
_STOPS = frozenset({"Stop", "SubagentStop"})
|
||||
|
||||
|
||||
class Hooks:
|
||||
"""Pending joins and open retry windows for one file."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Nothing seen yet."""
|
||||
self.runs: dict[tuple[str, str], list[tuple[str, str]]] = {}
|
||||
self.pending: list[tuple[MarkRow, tuple[str, str], str]] = []
|
||||
self.until_prompt: list[MarkRow] = []
|
||||
self.next_turn: list[MarkRow] = []
|
||||
|
||||
def track(self, row: MarkRow, found: Found) -> None:
|
||||
"""Remember what later records or turns need to complete ``row``."""
|
||||
if found.kind == "hook_run":
|
||||
self.runs.setdefault(found.key, []).append((row.detail, found.text))
|
||||
elif found.kind == "hook_context":
|
||||
self.pending.append((row, found.key, found.text))
|
||||
elif found.kind == "hook_block":
|
||||
stop = row.name in _STOPS
|
||||
(self.until_prompt if stop else self.next_turn).append(row)
|
||||
|
||||
def prompt_opened(self) -> None:
|
||||
"""A new prompt ends every Stop-bounce retry."""
|
||||
self.until_prompt.clear()
|
||||
|
||||
def turn(self, cost: float, ts: float) -> None:
|
||||
"""Charge an API call made after a block to that block's retry.
|
||||
|
||||
Turns are released one message late, so a turn released after the
|
||||
block record may still predate it: the timestamp decides.
|
||||
"""
|
||||
for row in [*self.until_prompt, *self.next_turn]:
|
||||
if ts > row.where.ts:
|
||||
row.retry_usd += cost
|
||||
self.next_turn = [row for row in self.next_turn if ts <= row.where.ts]
|
||||
|
||||
def resolve(self) -> None:
|
||||
"""Name the script behind each injected hook context."""
|
||||
for row, key, text in self.pending:
|
||||
snippet = text[:SNIPPET]
|
||||
escaped = json.dumps(snippet)[1:-1]
|
||||
for detail, stdout in self.runs.get(key, []):
|
||||
if snippet and (snippet in stdout or escaped in stdout):
|
||||
row.detail = detail
|
||||
break
|
||||
|
||||
|
||||
def merge(rows: list[MarkRow], first_model: str) -> list[MarkRow]:
|
||||
"""Sum marks sharing prompt, kind, name, detail and model; oldest first."""
|
||||
merged: dict[tuple[int, str, str, str, str], MarkRow] = {}
|
||||
for row in rows:
|
||||
row.where = replace(row.where, model=row.where.model or first_model)
|
||||
where = row.where
|
||||
key = (where.prompt_idx, row.kind, row.name, row.detail, where.model)
|
||||
held = merged.get(key)
|
||||
if held is None:
|
||||
merged[key] = row
|
||||
continue
|
||||
held.where = replace(held.where, ts=min(held.where.ts, where.ts))
|
||||
held.count += row.count
|
||||
held.tokens += row.tokens
|
||||
held.extra += row.extra
|
||||
held.duration_ms += row.duration_ms
|
||||
held.retry_usd += row.retry_usd
|
||||
held.cost.create_usd += row.cost.create_usd
|
||||
held.cost.carry_usd += row.cost.carry_usd
|
||||
held.cost.carry_calls += row.cost.carry_calls
|
||||
return sorted(merged.values(), key=lambda r: r.where.ts)
|
||||
137
python_pkg/token_dashboard/ingest.py
Normal file
137
python_pkg/token_dashboard/ingest.py
Normal file
@ -0,0 +1,137 @@
|
||||
"""Refresh the token store: changed transcripts first, then every aux source.
|
||||
|
||||
Resumable by construction. Each pass walks changed files until its time budget
|
||||
is spent (always at least one), commits, and exits :data:`EXIT_PENDING` if any
|
||||
are left -- so a first backfill of months of transcripts is a handful of
|
||||
sub-minute runs, and the systemd timer simply picks up where the last ended.
|
||||
|
||||
python3 -m python_pkg.token_dashboard.ingest # one pass
|
||||
python3 -m python_pkg.token_dashboard.ingest --budget 40
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import time
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from python_pkg.token_audit import discover, history
|
||||
from python_pkg.token_dashboard import (
|
||||
ab,
|
||||
aux,
|
||||
commits,
|
||||
context_files,
|
||||
efficiency,
|
||||
finalize,
|
||||
schema,
|
||||
store,
|
||||
survival,
|
||||
walk,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import sqlite3
|
||||
|
||||
ROOT = Path.home() / ".claude" / "projects"
|
||||
DEFAULT_BUDGET = 40.0
|
||||
EXIT_PENDING = 75 # EX_TEMPFAIL: run again to continue
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Outcome:
|
||||
"""What one pass did."""
|
||||
|
||||
parsed: int
|
||||
finalized: int
|
||||
complete: bool
|
||||
|
||||
|
||||
def _changed(
|
||||
conn: sqlite3.Connection, root: Path
|
||||
) -> list[tuple[Path, tuple[int, float]]]:
|
||||
"""Transcripts whose size or mtime differ from what the store last saw."""
|
||||
seen = {
|
||||
path: (size, mtime)
|
||||
for path, size, mtime in conn.execute("SELECT path, size, mtime FROM files")
|
||||
}
|
||||
out = []
|
||||
for pattern in discover.PATTERNS:
|
||||
for path in root.glob(pattern):
|
||||
stat = path.stat()
|
||||
now = (stat.st_size, stat.st_mtime)
|
||||
if seen.get(str(path)) != now:
|
||||
out.append((path, now))
|
||||
# Oldest first: a backfill lands history in order, and a live session
|
||||
# (newest mtime) is re-read last, closest to the pass's end.
|
||||
return sorted(out, key=lambda item: item[1][1])
|
||||
|
||||
|
||||
def run(
|
||||
root: Path = ROOT,
|
||||
db_path: Path = schema.DEFAULT_DB,
|
||||
report_dir: Path = history.REPORT_DIR,
|
||||
budget_s: float = DEFAULT_BUDGET,
|
||||
) -> Outcome:
|
||||
"""One resumable pass."""
|
||||
started = time.monotonic()
|
||||
deadline = started + budget_s
|
||||
conn = schema.connect(db_path)
|
||||
try:
|
||||
todo = _changed(conn, root)
|
||||
mains: set[str] = set()
|
||||
parsed = 0
|
||||
for path, stat in todo:
|
||||
if parsed and time.monotonic() > deadline:
|
||||
break
|
||||
facts = walk.walk_file(path, root)
|
||||
store.write(conn, facts, str(path), stat)
|
||||
mains.add(facts.parent_session or facts.key)
|
||||
parsed += 1
|
||||
for main_key in sorted(mains):
|
||||
finalize.refresh(conn, main_key)
|
||||
conn.commit()
|
||||
complete = parsed == len(todo)
|
||||
if complete:
|
||||
aux.snapshots(conn, report_dir)
|
||||
aux.lever_rows(conn, report_dir)
|
||||
aux.autopsy(conn)
|
||||
aux.activity_logs(conn, report_dir)
|
||||
commits.discover(conn)
|
||||
now = time.time()
|
||||
complete = survival.update(conn, now, deadline, time.monotonic)
|
||||
efficiency.rebuild(conn, now)
|
||||
ab.rebuild(conn)
|
||||
context_files.refresh(conn)
|
||||
context_files.price_mix(conn, now)
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO meta VALUES ('refreshed_at', ?)", (str(now),)
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
return Outcome(parsed, len(mains), complete)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
"""CLI: 0 when the store is current, :data:`EXIT_PENDING` when not."""
|
||||
parser = argparse.ArgumentParser(prog="token_dashboard.ingest")
|
||||
parser.add_argument("--root", type=Path, default=ROOT)
|
||||
parser.add_argument("--db", type=Path, default=schema.DEFAULT_DB)
|
||||
parser.add_argument("--report-dir", type=Path, default=history.REPORT_DIR)
|
||||
parser.add_argument("--budget", type=float, default=DEFAULT_BUDGET)
|
||||
args = parser.parse_args(argv)
|
||||
started = time.monotonic()
|
||||
outcome = run(args.root, args.db, args.report_dir, args.budget)
|
||||
sys.stderr.write(
|
||||
f"token_dashboard: parsed {outcome.parsed} files, refreshed "
|
||||
f"{outcome.finalized} sessions in {time.monotonic() - started:.1f}s"
|
||||
f"{'' if outcome.complete else ' (more pending: run again)'}\n"
|
||||
)
|
||||
return 0 if outcome.complete else EXIT_PENDING
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
173
python_pkg/token_dashboard/levers.py
Normal file
173
python_pkg/token_dashboard/levers.py
Normal file
@ -0,0 +1,173 @@
|
||||
"""Every lever ever applied, proposed or passed over, with its outcome.
|
||||
|
||||
Sources, newest form first:
|
||||
|
||||
* ``history/*.json`` ``levers`` -- structured candidates (snapshots from
|
||||
2026-10-18 on; see ``token_audit.review``).
|
||||
* Markdown "Candidate levers" tables -- for older snapshots. WEEKLY.md is
|
||||
overwritten every run, so older tables survive only in transcript tool
|
||||
results; :mod:`walk` keeps those as ``lever_tables`` rows.
|
||||
* ``ledger.jsonl`` -- applied levers, scored by ``token_audit.history.score``.
|
||||
* ``prune/<date>/PROPOSAL.md`` -- weekly standing-prefix prune proposals.
|
||||
|
||||
Status of a candidate: ``applied`` if a ledger line on the same metric is dated
|
||||
on or after the snapshot, ``not applied`` if a newer snapshot came and went
|
||||
without one, else ``proposed``. Titles never match ledger text, so the join is
|
||||
on the metric key.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from python_pkg.token_audit import history
|
||||
from python_pkg.token_audit.recommend import TITLE_METRIC
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
Row = dict[str, Any]
|
||||
DATE = re.compile(r"\d{4}-\d{2}-\d{2}")
|
||||
_COLUMNS = 5
|
||||
|
||||
|
||||
def parse_candidates(markdown: str) -> list[Row]:
|
||||
"""Rows of a ``| category | lever | evidence | est $/day | action |`` table."""
|
||||
rows = []
|
||||
for line in markdown.splitlines():
|
||||
cells = [c.strip() for c in line.strip().strip("|").split("|")]
|
||||
if len(cells) != _COLUMNS or cells[0] in {"category", ""}:
|
||||
continue
|
||||
if set(cells[0]) <= {"-"}:
|
||||
continue
|
||||
est = cells[3].lstrip("$").replace(",", "")
|
||||
rows.append(
|
||||
{
|
||||
"category": cells[0],
|
||||
"title": cells[1],
|
||||
"evidence": cells[2],
|
||||
"est_usd_per_day": float(est)
|
||||
if est.replace(".", "").isdigit()
|
||||
else None,
|
||||
"action": cells[4],
|
||||
"metric": TITLE_METRIC.get(cells[1], ""),
|
||||
}
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def ledger_date(entry: Row) -> str | None:
|
||||
"""A ledger line's date, else one named in its text (prune lines)."""
|
||||
if isinstance(entry.get("date"), str):
|
||||
return str(entry["date"])
|
||||
found = DATE.search(str(entry.get("lever", "")))
|
||||
return found.group(0) if found else None
|
||||
|
||||
|
||||
def _snapshots(report: Path) -> list[tuple[str, Row]]:
|
||||
"""``(date, snapshot)`` oldest first."""
|
||||
out = []
|
||||
for path in sorted((report / "history").glob("*.json")):
|
||||
try:
|
||||
snap = json.loads(path.read_text(encoding="utf-8"))
|
||||
except ValueError:
|
||||
continue
|
||||
if isinstance(snap, dict):
|
||||
out.append((path.stem[:10], snap))
|
||||
return out
|
||||
|
||||
|
||||
def _applied(report: Path, latest: Row) -> list[Row]:
|
||||
"""Ledger lines, scored against the newest snapshot."""
|
||||
entries = history.read_ledger(report)
|
||||
rows = []
|
||||
for entry, verdict in zip(entries, history.score(entries, latest), strict=True):
|
||||
rows.append(
|
||||
{
|
||||
"source": "ledger",
|
||||
"date": ledger_date(entry),
|
||||
"lever": verdict.lever,
|
||||
"metric": verdict.metric,
|
||||
"baseline": verdict.baseline,
|
||||
"predicted": verdict.predicted,
|
||||
"actual": verdict.actual,
|
||||
"lower_is_better": int(bool(entry.get("lower_is_better", True))),
|
||||
"status": "applied",
|
||||
"outcome": verdict.outcome,
|
||||
"detail": f"{verdict.note}; undo: {entry.get('undo', '-')}",
|
||||
}
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def _candidates(
|
||||
snaps: list[tuple[str, Row]], tables: dict[str, str], applied: list[Row]
|
||||
) -> list[Row]:
|
||||
"""Candidate rows per snapshot, structured first, markdown second."""
|
||||
rows = []
|
||||
for position, (day, snap) in enumerate(snaps):
|
||||
found = snap.get("levers")
|
||||
if not isinstance(found, list):
|
||||
found = parse_candidates(tables.get(day, ""))
|
||||
newer = position + 1 < len(snaps)
|
||||
for lever in found:
|
||||
metric = str(lever.get("metric") or "")
|
||||
hit = any(
|
||||
a["metric"] == metric and (a["date"] or "") >= day for a in applied
|
||||
)
|
||||
status = "applied" if hit else ("not applied" if newer else "proposed")
|
||||
rows.append(
|
||||
{
|
||||
"source": f"snapshot {day}",
|
||||
"date": day,
|
||||
"lever": lever.get("title"),
|
||||
"metric": metric,
|
||||
"baseline": snap.get("metrics", {}).get(metric),
|
||||
"predicted": None,
|
||||
"actual": None,
|
||||
"lower_is_better": 1,
|
||||
"status": status,
|
||||
"outcome": None,
|
||||
"detail": f"{lever.get('evidence')} -> {lever.get('action')}"
|
||||
f" (est ${lever.get('est_usd_per_day') or '-'}/day)",
|
||||
}
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def _prunes(report: Path, applied: list[Row]) -> list[Row]:
|
||||
"""One row per prune proposal."""
|
||||
paths = sorted((report / "prune").glob("*/PROPOSAL.md"))
|
||||
rows = []
|
||||
for position, path in enumerate(paths):
|
||||
day = path.parent.name
|
||||
lines = [x for x in path.read_text(encoding="utf-8").splitlines() if x.strip()]
|
||||
hit = any(f"prune {day}" in str(a["lever"]) for a in applied)
|
||||
latest = position == len(paths) - 1
|
||||
status = "applied" if hit else ("proposed" if latest else "not applied")
|
||||
rows.append(
|
||||
{
|
||||
"source": f"prune {day}",
|
||||
"date": day,
|
||||
"lever": lines[0].lstrip("# ") if lines else f"prune {day}",
|
||||
"metric": "standing_tokens_est",
|
||||
"baseline": None,
|
||||
"predicted": None,
|
||||
"actual": None,
|
||||
"lower_is_better": 1,
|
||||
"status": status,
|
||||
"outcome": None,
|
||||
"detail": lines[1] if len(lines) > 1 else "",
|
||||
}
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def build(report: Path, tables: dict[str, str]) -> list[Row]:
|
||||
"""Every lever row, applied ones first."""
|
||||
snaps = _snapshots(report)
|
||||
latest = snaps[-1][1] if snaps else {}
|
||||
applied = _applied(report, latest)
|
||||
return applied + _candidates(snaps, tables, applied) + _prunes(report, applied)
|
||||
177
python_pkg/token_dashboard/marks.py
Normal file
177
python_pkg/token_dashboard/marks.py
Normal file
@ -0,0 +1,177 @@
|
||||
"""Read context marks out of the records that are not the conversation.
|
||||
|
||||
Hooks write ``attachment`` records: ``hook_success`` (every run, with its
|
||||
command and duration; SessionStart / UserPromptSubmit stdout is injected as
|
||||
context), ``hook_additional_context`` (injected text, without the command --
|
||||
joined back to its run by :mod:`hook_join`), ``hook_blocking_error`` (a block
|
||||
or a Stop-hook bounce) and ``hook_system_message`` (shown to the user only).
|
||||
Other attachment types in :data:`INJECTED` are rendered into the prompt;
|
||||
the rest (``prompt_snapshot``, ``environment`` ...) only record what was sent.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import json
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from python_pkg.token_audit import parse
|
||||
from python_pkg.token_dashboard import blocks, classify
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterator
|
||||
|
||||
CHARS = parse.CHARS_PER_TOKEN
|
||||
# Marks that never sit in the model's context, so carry nothing.
|
||||
NOT_IN_CONTEXT = frozenset({"hook_msg", "compaction", "api_error", "interrupt"})
|
||||
# Hook events whose plain stdout Claude Code adds to the context.
|
||||
INJECTING = frozenset({"SessionStart", "UserPromptSubmit"})
|
||||
INJECTED = frozenset(
|
||||
{
|
||||
"nested_memory",
|
||||
"edited_text_file",
|
||||
"total_tokens_reminder",
|
||||
"skill_listing",
|
||||
"deferred_tools_delta",
|
||||
"mcp_instructions_delta",
|
||||
"agent_listing_delta",
|
||||
"queued_command",
|
||||
"task_reminder",
|
||||
"silent_turn_reminder",
|
||||
"file",
|
||||
"todo",
|
||||
"command_permissions",
|
||||
}
|
||||
)
|
||||
_SCRIPTS = (".sh", ".py", ".js", ".ts", ".mjs")
|
||||
SNIPPET = 40
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Found:
|
||||
"""One mark read from a record, before it becomes a row.
|
||||
|
||||
``key`` (tool-use id, hook name) and ``text`` only matter to hook runs
|
||||
and hook context, which :mod:`hook_join` pairs up.
|
||||
"""
|
||||
|
||||
kind: str
|
||||
name: str
|
||||
detail: str = ""
|
||||
count: int = 1
|
||||
tokens: int = 0
|
||||
extra: int = 0
|
||||
duration_ms: int = 0
|
||||
key: tuple[str, str] = ("", "")
|
||||
text: str = ""
|
||||
|
||||
|
||||
def short_command(command: str) -> str:
|
||||
"""A hook command's script name, or its first 40 characters."""
|
||||
words = command.split()
|
||||
scripts = [w for w in words if w.endswith(_SCRIPTS)]
|
||||
if scripts:
|
||||
return scripts[0].rsplit("/", 1)[-1]
|
||||
return " ".join(words)[:SNIPPET]
|
||||
|
||||
|
||||
def _int(value: object) -> int:
|
||||
"""A number that may arrive as a string; 0 when it is neither."""
|
||||
try:
|
||||
return int(float(str(value)))
|
||||
except ValueError:
|
||||
return 0
|
||||
|
||||
|
||||
def split_images(content: object) -> tuple[int, str]:
|
||||
"""Image block count and the JSON text of everything else."""
|
||||
if not isinstance(content, list):
|
||||
return 0, "" if content is None else json.dumps(content)
|
||||
images = [b for b in content if isinstance(b, dict) and b.get("type") == "image"]
|
||||
rest = [b for b in content if b not in images]
|
||||
return len(images), json.dumps(rest)
|
||||
|
||||
|
||||
def _tokens(text: object) -> int:
|
||||
"""Estimated tokens of a text value."""
|
||||
return len(str(text or "")) // CHARS
|
||||
|
||||
|
||||
def _hook(att: dict[str, Any]) -> Iterator[Found]:
|
||||
"""Marks of one hook attachment."""
|
||||
kind = att.get("type")
|
||||
name = str(att.get("hookName") or "")
|
||||
key = (str(att.get("toolUseID") or ""), name)
|
||||
content = att.get("content")
|
||||
if kind == "hook_success":
|
||||
text = str(content or "")
|
||||
injected = att.get("hookEvent") in INJECTING
|
||||
injected = injected and not text.lstrip().startswith("{")
|
||||
yield Found(
|
||||
"hook_run",
|
||||
name,
|
||||
detail=short_command(str(att.get("command") or "")),
|
||||
duration_ms=_int(att.get("durationMs")),
|
||||
tokens=_tokens(text) if injected else 0,
|
||||
key=key,
|
||||
text=str(att.get("stdout") or ""),
|
||||
)
|
||||
elif kind == "hook_additional_context":
|
||||
for item in content if isinstance(content, list) else [content]:
|
||||
text = str(item or "")
|
||||
yield Found("hook_context", name, tokens=_tokens(text), key=key, text=text)
|
||||
elif kind == "hook_blocking_error":
|
||||
raw = att.get("blockingError")
|
||||
error = raw if isinstance(raw, dict) else {"blockingError": raw}
|
||||
command = short_command(str(error.get("command") or ""))
|
||||
yield Found(
|
||||
"hook_block", name, detail=command, tokens=_tokens(error["blockingError"])
|
||||
)
|
||||
elif kind == "hook_non_blocking_error":
|
||||
yield Found("hook_error", name, tokens=_tokens(content))
|
||||
elif kind == "hook_system_message":
|
||||
yield Found("hook_msg", name)
|
||||
|
||||
|
||||
def _attachment(att: dict[str, Any]) -> Iterator[Found]:
|
||||
"""Marks of one attachment record."""
|
||||
kind = str(att.get("type") or "")
|
||||
if kind.startswith("hook_"):
|
||||
yield from _hook(att)
|
||||
elif kind in INJECTED:
|
||||
rest = {k: v for k, v in att.items() if k != "type"}
|
||||
path = str(att.get("path") or "")[: classify.TARGET_CHARS]
|
||||
yield Found("attach", kind, detail=path, tokens=_tokens(json.dumps(rest)))
|
||||
|
||||
|
||||
def _user(rec: dict[str, Any]) -> Iterator[Found]:
|
||||
"""Interruptions and pasted images."""
|
||||
message = rec.get("message")
|
||||
content = message.get("content") if isinstance(message, dict) else None
|
||||
if blocks.text_of(content).startswith("[Request interrupted"):
|
||||
yield Found("interrupt", "user")
|
||||
images, _ = split_images(content)
|
||||
if images:
|
||||
yield Found("image", "user", count=images, tokens=images * parse.IMAGE_TOKENS)
|
||||
|
||||
|
||||
def of_record(rec: dict[str, Any]) -> list[Found]:
|
||||
"""Every mark one non-assistant record carries."""
|
||||
kind = rec.get("type")
|
||||
att = rec.get("attachment")
|
||||
if kind == "attachment" and isinstance(att, dict):
|
||||
return list(_attachment(att))
|
||||
if kind == "system" and rec.get("subtype") == "compact_boundary":
|
||||
meta = rec.get("compactMetadata")
|
||||
meta = meta if isinstance(meta, dict) else {}
|
||||
found = Found(
|
||||
"compaction",
|
||||
str(meta.get("trigger") or "manual"),
|
||||
tokens=_int(meta.get("preTokens")),
|
||||
extra=_int(meta.get("postTokens")),
|
||||
duration_ms=_int(meta.get("durationMs")),
|
||||
)
|
||||
return [found]
|
||||
if kind == "user":
|
||||
return list(_user(rec))
|
||||
return []
|
||||
104
python_pkg/token_dashboard/rows.py
Normal file
104
python_pkg/token_dashboard/rows.py
Normal file
@ -0,0 +1,104 @@
|
||||
"""Row types one transcript file contributes to the store (see :mod:`walk`)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from python_pkg.token_dashboard.detail_rows import DetailRows
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from python_pkg.token_dashboard import active
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TurnRow:
|
||||
"""One deduplicated API call, priced."""
|
||||
|
||||
ts: float
|
||||
model: str
|
||||
usd: float
|
||||
context: int
|
||||
prompt_idx: int
|
||||
usage: dict[str, int]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PromptRow:
|
||||
"""One typed user prompt."""
|
||||
|
||||
idx: int
|
||||
ts: float
|
||||
prompt_id: str
|
||||
text: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class ToolRow:
|
||||
"""Calls of one tool inside one prompt."""
|
||||
|
||||
prompt_idx: int
|
||||
name: str
|
||||
calls: int = 0
|
||||
result_tokens: int = 0
|
||||
usd: float = 0.0
|
||||
carry_usd: float = 0.0
|
||||
# Settled by the carry tracker too, but not stored per tool (the calls
|
||||
# table holds creation per call).
|
||||
create_usd: float = 0.0
|
||||
carry_calls: int = 0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AgentCall:
|
||||
"""An ``Agent`` tool call: the spawn point of a subagent."""
|
||||
|
||||
tool_use_id: str
|
||||
agent_id: str
|
||||
prompt_idx: int
|
||||
ts: float
|
||||
description: str
|
||||
subagent_type: str
|
||||
model: str
|
||||
result_ts: float
|
||||
status: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Commit:
|
||||
"""A commit hash printed by a Bash ``git commit`` in this file."""
|
||||
|
||||
prompt_idx: int
|
||||
ts: float
|
||||
end_ts: float
|
||||
sha: str
|
||||
repo: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class FileRows:
|
||||
"""The row lists one transcript file contributes."""
|
||||
|
||||
turns: list[TurnRow] = field(default_factory=list)
|
||||
prompts: list[PromptRow] = field(default_factory=list)
|
||||
tools: list[ToolRow] = field(default_factory=list)
|
||||
agent_calls: list[AgentCall] = field(default_factory=list)
|
||||
intervals: list[active.Span] = field(default_factory=list)
|
||||
lever_tables: list[tuple[float, str]] = field(default_factory=list)
|
||||
commits: list[Commit] = field(default_factory=list)
|
||||
messages: list[tuple[float, str]] = field(default_factory=list)
|
||||
detail: DetailRows = field(default_factory=DetailRows)
|
||||
|
||||
|
||||
@dataclass
|
||||
class FileFacts:
|
||||
"""Everything one transcript file contributes to the store."""
|
||||
|
||||
key: str
|
||||
session_id: str
|
||||
parent_session: str | None
|
||||
cwd: str | None
|
||||
meta: dict[str, Any]
|
||||
rows: FileRows = field(default_factory=FileRows)
|
||||
final_tag: str | None = None
|
||||
span: tuple[float, float] = (0.0, 0.0)
|
||||
192
python_pkg/token_dashboard/schema.py
Normal file
192
python_pkg/token_dashboard/schema.py
Normal file
@ -0,0 +1,192 @@
|
||||
"""SQLite layout of the durable token store.
|
||||
|
||||
Raw tables (``files`` .. ``commits``) hold what one transcript file said and
|
||||
are replaced wholesale when that file changes. They are **never** deleted when
|
||||
the file disappears: Claude Code removes transcripts after
|
||||
``cleanupPeriodDays``, and outliving that is the store's whole purpose.
|
||||
|
||||
Summary tables (``session_summary`` .. ``subagent_link``) are derived from the
|
||||
raw ones by :mod:`finalize` and may be rebuilt at any time. Aux tables hold the
|
||||
sources that are not transcripts (snapshots, ledger, levers, autopsy rows).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import sqlite3
|
||||
|
||||
DEFAULT_DB = Path.home() / "data" / "token-dashboard" / "tokens.sqlite"
|
||||
VERSION = "3"
|
||||
|
||||
_RAW = """
|
||||
CREATE TABLE IF NOT EXISTS meta(key TEXT PRIMARY KEY, value TEXT);
|
||||
CREATE TABLE IF NOT EXISTS files(
|
||||
path TEXT PRIMARY KEY, key TEXT, size INT, mtime REAL);
|
||||
CREATE TABLE IF NOT EXISTS sessions(
|
||||
key TEXT PRIMARY KEY, session_id TEXT, main_key TEXT, cwd TEXT,
|
||||
agent_type TEXT, description TEXT, spawn_tool_use_id TEXT,
|
||||
parent_agent_id TEXT, agent_model TEXT, first_ts REAL, last_ts REAL,
|
||||
final_tag TEXT);
|
||||
CREATE TABLE IF NOT EXISTS turns(
|
||||
key TEXT, main_key TEXT, is_sub INT, cwd TEXT, ts REAL, day TEXT,
|
||||
model TEXT, usd REAL, context INT, prompt_idx INT, input INT,
|
||||
cache_write INT, cache_read INT, output INT);
|
||||
CREATE INDEX IF NOT EXISTS turns_key ON turns(key);
|
||||
CREATE INDEX IF NOT EXISTS turns_day ON turns(day);
|
||||
CREATE TABLE IF NOT EXISTS prompts(
|
||||
key TEXT, idx INT, ts REAL, prompt_id TEXT, text TEXT);
|
||||
CREATE INDEX IF NOT EXISTS prompts_key ON prompts(key);
|
||||
CREATE TABLE IF NOT EXISTS tool_use(
|
||||
key TEXT, prompt_idx INT, name TEXT, calls INT, result_tokens INT,
|
||||
usd REAL, carry_usd REAL);
|
||||
CREATE INDEX IF NOT EXISTS tool_use_key ON tool_use(key);
|
||||
CREATE TABLE IF NOT EXISTS agent_calls(
|
||||
key TEXT, tool_use_id TEXT, agent_id TEXT, prompt_idx INT, ts REAL,
|
||||
description TEXT, subagent_type TEXT, model TEXT, result_ts REAL,
|
||||
status TEXT);
|
||||
CREATE INDEX IF NOT EXISTS agent_calls_key ON agent_calls(key);
|
||||
CREATE TABLE IF NOT EXISTS activity(key TEXT, start REAL, end REAL);
|
||||
CREATE INDEX IF NOT EXISTS activity_key ON activity(key);
|
||||
CREATE TABLE IF NOT EXISTS sent_messages(key TEXT, ts REAL, to_agent TEXT);
|
||||
CREATE TABLE IF NOT EXISTS lever_tables(key TEXT, ts REAL, markdown TEXT);
|
||||
CREATE TABLE IF NOT EXISTS commits(
|
||||
key TEXT, prompt_idx INT, ts REAL, end_ts REAL, sha TEXT, repo TEXT);
|
||||
CREATE TABLE IF NOT EXISTS calls(
|
||||
key TEXT, main_key TEXT, is_sub INT, cwd TEXT, model TEXT, ts REAL,
|
||||
day TEXT, prompt_idx INT, end_ts REAL, name TEXT, family TEXT,
|
||||
target TEXT, sig TEXT, batch INT, result_tokens INT, images INT,
|
||||
is_error INT, error_kind TEXT, create_usd REAL, carry_usd REAL,
|
||||
carry_calls INT);
|
||||
CREATE INDEX IF NOT EXISTS calls_key ON calls(key);
|
||||
CREATE INDEX IF NOT EXISTS calls_ts ON calls(ts);
|
||||
CREATE TABLE IF NOT EXISTS marks(
|
||||
key TEXT, main_key TEXT, is_sub INT, cwd TEXT, model TEXT, ts REAL,
|
||||
day TEXT, prompt_idx INT, kind TEXT, name TEXT, detail TEXT, count INT,
|
||||
tokens INT, extra INT, duration_ms INT, retry_usd REAL, create_usd REAL,
|
||||
carry_usd REAL, carry_calls INT);
|
||||
CREATE INDEX IF NOT EXISTS marks_key ON marks(key);
|
||||
CREATE INDEX IF NOT EXISTS marks_ts ON marks(ts);
|
||||
CREATE TABLE IF NOT EXISTS turn_detail(
|
||||
key TEXT, main_key TEXT, is_sub INT, cwd TEXT, model TEXT, ts REAL,
|
||||
day TEXT, prompt_idx INT, latency_s REAL, cw_1h INT, cw_5m INT,
|
||||
out_text INT, out_tool INT, out_think INT, tool_calls INT);
|
||||
CREATE INDEX IF NOT EXISTS turn_detail_key ON turn_detail(key);
|
||||
CREATE INDEX IF NOT EXISTS turn_detail_ts ON turn_detail(ts);
|
||||
CREATE TABLE IF NOT EXISTS session_commits(
|
||||
sha TEXT PRIMARY KEY, repo TEXT, ts REAL, key TEXT, prompt_idx INT);
|
||||
"""
|
||||
|
||||
_DERIVED = """
|
||||
CREATE TABLE IF NOT EXISTS session_summary(
|
||||
key TEXT PRIMARY KEY, cwd TEXT, title TEXT, first_ts REAL, last_ts REAL,
|
||||
wall_s REAL, active_s REAL, usd REAL, usd_main REAL, usd_sub REAL,
|
||||
calls INT, prompts INT, subagents INT, models TEXT, final_tag TEXT);
|
||||
CREATE TABLE IF NOT EXISTS prompt_summary(
|
||||
key TEXT, idx INT, ts REAL, text TEXT, usd_own REAL, usd_sub REAL,
|
||||
active_s REAL, wall_s REAL, calls INT);
|
||||
CREATE INDEX IF NOT EXISTS prompt_summary_key ON prompt_summary(key);
|
||||
CREATE TABLE IF NOT EXISTS subagent_link(
|
||||
sub_key TEXT PRIMARY KEY, main_key TEXT, prompt_idx INT, depth INT,
|
||||
description TEXT, agent_type TEXT, model TEXT, usd REAL, calls INT,
|
||||
first_ts REAL, last_ts REAL, wall_s REAL, active_s REAL);
|
||||
CREATE INDEX IF NOT EXISTS subagent_link_main ON subagent_link(main_key);
|
||||
CREATE TABLE IF NOT EXISTS commit_verdicts(
|
||||
sha TEXT PRIMARY KEY, repo TEXT, ts REAL, added INT, kept INT,
|
||||
verdict TEXT);
|
||||
CREATE VIEW IF NOT EXISTS daily AS
|
||||
SELECT day, SUM(usd) AS usd, COUNT(*) AS calls,
|
||||
SUM(CASE WHEN is_sub THEN usd ELSE 0 END) AS usd_sub,
|
||||
SUM(CASE WHEN model LIKE '%opus%' THEN usd ELSE 0 END) AS usd_opus,
|
||||
SUM(CASE WHEN context > 300000 THEN usd ELSE 0 END) AS usd_over
|
||||
FROM turns GROUP BY day;
|
||||
"""
|
||||
|
||||
_AUX = """
|
||||
CREATE TABLE IF NOT EXISTS snapshots(
|
||||
name TEXT PRIMARY KEY, generated TEXT, since REAL, until REAL,
|
||||
metrics TEXT);
|
||||
CREATE TABLE IF NOT EXISTS levers(
|
||||
source TEXT, date TEXT, lever TEXT, metric TEXT, baseline REAL,
|
||||
predicted REAL, actual REAL, lower_is_better INT, status TEXT,
|
||||
outcome TEXT, detail TEXT);
|
||||
CREATE TABLE IF NOT EXISTS aggregate_sessions(
|
||||
session_id TEXT PRIMARY KEY, source TEXT, day TEXT, started REAL,
|
||||
cwd TEXT, title TEXT, input INT, cache_write INT, cache_read INT,
|
||||
output INT, api_turns INT, records INT, usd_est REAL);
|
||||
CREATE TABLE IF NOT EXISTS prompt_activity(day TEXT PRIMARY KEY, prompts INT);
|
||||
CREATE TABLE IF NOT EXISTS events(source TEXT, ts REAL, kind TEXT, detail TEXT);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS events_once ON events(source, ts, kind, detail);
|
||||
CREATE TABLE IF NOT EXISTS context_files(
|
||||
path TEXT PRIMARY KEY, kind TEXT, standing TEXT, size_limit INT);
|
||||
CREATE TABLE IF NOT EXISTS context_sizes(
|
||||
path TEXT, ts REAL, bytes INT, source TEXT, PRIMARY KEY (path, ts, source));
|
||||
CREATE TABLE IF NOT EXISTS estimates(session_id TEXT PRIMARY KEY, est_calls INT);
|
||||
"""
|
||||
|
||||
|
||||
# Ordered, additive migrations: MIGRATIONS[n] takes a store from n-1 to n.
|
||||
# The store must never be deleted to change its schema -- rows for expired
|
||||
# transcripts cannot be regenerated -- so every change lands here instead.
|
||||
# New tables need no entry: CREATE TABLE IF NOT EXISTS adds them.
|
||||
MIGRATIONS: dict[int, tuple[str, ...]] = {
|
||||
2: ("ALTER TABLE commits ADD COLUMN end_ts REAL",),
|
||||
3: (
|
||||
"ALTER TABLE agent_calls ADD COLUMN result_ts REAL",
|
||||
"ALTER TABLE agent_calls ADD COLUMN status TEXT",
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
class SchemaError(RuntimeError):
|
||||
"""The store was written by a newer (or unknown) schema."""
|
||||
|
||||
def __init__(self, found: object) -> None:
|
||||
"""Name the version found and the one this code knows."""
|
||||
super().__init__(f"store schema {found!r}; this code knows {VERSION}")
|
||||
|
||||
|
||||
def _stored_version(conn: sqlite3.Connection) -> int | None:
|
||||
"""The schema the store was written with, ``None`` for a fresh file."""
|
||||
has_meta = conn.execute(
|
||||
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='meta'"
|
||||
).fetchone()
|
||||
if has_meta is None:
|
||||
return None
|
||||
row = conn.execute("SELECT value FROM meta WHERE key='schema'").fetchone()
|
||||
if row is None or not str(row[0]).isdigit():
|
||||
raise SchemaError(row)
|
||||
return int(row[0])
|
||||
|
||||
|
||||
def _migrate(conn: sqlite3.Connection, stored: int) -> None:
|
||||
"""Apply every migration newer than ``stored``, in order."""
|
||||
current = int(VERSION)
|
||||
if stored > current:
|
||||
raise SchemaError(stored)
|
||||
for step in range(stored + 1, current + 1):
|
||||
for statement in MIGRATIONS.get(step, ()):
|
||||
table, column = statement.split()[2], statement.split()[5]
|
||||
present = {r[1] for r in conn.execute(f"PRAGMA table_info({table})")}
|
||||
if column not in present:
|
||||
conn.execute(statement)
|
||||
|
||||
|
||||
def connect(path: Path, *, readonly: bool = False) -> sqlite3.Connection:
|
||||
"""Open the store; create missing tables and migrate when writing."""
|
||||
if readonly:
|
||||
return sqlite3.connect(f"file:{path}?mode=ro", uri=True)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
conn = sqlite3.connect(path)
|
||||
try:
|
||||
stored = _stored_version(conn)
|
||||
# Missing tables first, at today's shape; then columns old tables lack.
|
||||
conn.executescript(_RAW + _DERIVED + _AUX)
|
||||
if stored is not None:
|
||||
_migrate(conn, stored)
|
||||
except SchemaError:
|
||||
conn.close()
|
||||
raise
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute("INSERT OR REPLACE INTO meta VALUES ('schema', ?)", (VERSION,))
|
||||
conn.commit()
|
||||
return conn
|
||||
135
python_pkg/token_dashboard/store.py
Normal file
135
python_pkg/token_dashboard/store.py
Normal file
@ -0,0 +1,135 @@
|
||||
"""Write one file's :class:`FileFacts` into the raw tables, replacing old rows.
|
||||
|
||||
The unit of change is the whole file: a changed transcript is re-walked and
|
||||
its rows replaced in one transaction. Resuming from a byte offset would need
|
||||
the dedup state (:class:`TurnMerger`) persisted across runs, and re-walking
|
||||
even the largest transcript costs a fraction of a second.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from python_pkg.token_dashboard import detail_store
|
||||
from python_pkg.token_dashboard.detail_store import day_of
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import sqlite3
|
||||
|
||||
from python_pkg.token_dashboard.walk import FileFacts
|
||||
|
||||
# One literal statement per raw table: no SQL is assembled from strings.
|
||||
CLEAR = (
|
||||
"DELETE FROM turns WHERE key = ?",
|
||||
"DELETE FROM prompts WHERE key = ?",
|
||||
"DELETE FROM tool_use WHERE key = ?",
|
||||
"DELETE FROM agent_calls WHERE key = ?",
|
||||
"DELETE FROM activity WHERE key = ?",
|
||||
"DELETE FROM lever_tables WHERE key = ?",
|
||||
"DELETE FROM commits WHERE key = ?",
|
||||
"DELETE FROM sent_messages WHERE key = ?",
|
||||
*detail_store.CLEAR,
|
||||
)
|
||||
|
||||
|
||||
def write(
|
||||
conn: sqlite3.Connection, facts: FileFacts, path: str, stat: tuple[int, float]
|
||||
) -> None:
|
||||
"""Replace every row ``facts.key`` owns. Caller commits."""
|
||||
key = facts.key
|
||||
for statement in CLEAR:
|
||||
conn.execute(statement, (key,))
|
||||
main_key = facts.parent_session or key
|
||||
meta = facts.meta
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO sessions VALUES (?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
(
|
||||
key,
|
||||
facts.session_id,
|
||||
main_key,
|
||||
facts.cwd,
|
||||
meta.get("agentType"),
|
||||
meta.get("description"),
|
||||
meta.get("toolUseId"),
|
||||
meta.get("parentAgentId"),
|
||||
meta.get("model"),
|
||||
*facts.span,
|
||||
facts.final_tag,
|
||||
),
|
||||
)
|
||||
is_sub = int(facts.parent_session is not None)
|
||||
conn.executemany(
|
||||
"INSERT INTO turns VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
[
|
||||
(
|
||||
key,
|
||||
main_key,
|
||||
is_sub,
|
||||
facts.cwd,
|
||||
t.ts,
|
||||
day_of(t.ts),
|
||||
t.model,
|
||||
t.usd,
|
||||
t.context,
|
||||
t.prompt_idx,
|
||||
t.usage.get("input_tokens", 0),
|
||||
t.usage.get("cache_creation_input_tokens", 0),
|
||||
t.usage.get("cache_read_input_tokens", 0),
|
||||
t.usage.get("output_tokens", 0),
|
||||
)
|
||||
for t in facts.rows.turns
|
||||
],
|
||||
)
|
||||
conn.executemany(
|
||||
"INSERT INTO prompts VALUES (?,?,?,?,?)",
|
||||
[(key, p.idx, p.ts, p.prompt_id, p.text) for p in facts.rows.prompts],
|
||||
)
|
||||
conn.executemany(
|
||||
"INSERT INTO tool_use VALUES (?,?,?,?,?,?,?)",
|
||||
[
|
||||
(key, t.prompt_idx, t.name, t.calls, t.result_tokens, t.usd, t.carry_usd)
|
||||
for t in facts.rows.tools
|
||||
],
|
||||
)
|
||||
conn.executemany(
|
||||
"INSERT INTO agent_calls (key, tool_use_id, agent_id, prompt_idx, ts,"
|
||||
" description, subagent_type, model, result_ts, status)"
|
||||
" VALUES (?,?,?,?,?,?,?,?,?,?)",
|
||||
[
|
||||
(
|
||||
key,
|
||||
a.tool_use_id,
|
||||
a.agent_id,
|
||||
a.prompt_idx,
|
||||
a.ts,
|
||||
a.description,
|
||||
a.subagent_type,
|
||||
a.model,
|
||||
a.result_ts,
|
||||
a.status,
|
||||
)
|
||||
for a in facts.rows.agent_calls
|
||||
],
|
||||
)
|
||||
conn.executemany(
|
||||
"INSERT INTO activity VALUES (?,?,?)",
|
||||
[(key, s, e) for s, e in facts.rows.intervals],
|
||||
)
|
||||
conn.executemany(
|
||||
"INSERT INTO lever_tables VALUES (?,?,?)",
|
||||
[(key, ts, md) for ts, md in facts.rows.lever_tables],
|
||||
)
|
||||
conn.executemany(
|
||||
"INSERT INTO commits (key, prompt_idx, ts, end_ts, sha, repo)"
|
||||
" VALUES (?,?,?,?,?,?)",
|
||||
[
|
||||
(key, c.prompt_idx, c.ts, c.end_ts, c.sha, c.repo)
|
||||
for c in facts.rows.commits
|
||||
],
|
||||
)
|
||||
conn.executemany(
|
||||
"INSERT INTO sent_messages VALUES (?,?,?)",
|
||||
[(key, ts, to) for ts, to in facts.rows.messages],
|
||||
)
|
||||
detail_store.write(conn, facts, (key, main_key, is_sub, facts.cwd))
|
||||
conn.execute("INSERT OR REPLACE INTO files VALUES (?,?,?,?)", (path, key, *stat))
|
||||
126
python_pkg/token_dashboard/survival.py
Normal file
126
python_pkg/token_dashboard/survival.py
Normal file
@ -0,0 +1,126 @@
|
||||
"""Did a commit made in a session survive? Decided by git, never by a model.
|
||||
|
||||
Rule (fixed here, shown in the dashboard caption): a commit is judged once it
|
||||
is :data:`WINDOW_DAYS` old, at the last commit of its repository's ``HEAD``
|
||||
history that is no more than that old relative to it.
|
||||
|
||||
* ``reverted`` -- a commit inside the window says ``This reverts commit <sha>``.
|
||||
* ``churned`` -- under :data:`KEEP_SHARE` of the lines it added (counted over
|
||||
its :data:`MAX_FILES` largest files) still blame to it.
|
||||
* ``survived`` -- otherwise, including a commit that added no lines.
|
||||
* ``pending`` -- younger than the window.
|
||||
* ``unknown`` -- the repo or hash is gone, or the commit is not on ``HEAD``
|
||||
(rebased, squashed or on another branch): excluded from the ratio.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from python_pkg.token_dashboard import gitcmd
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
import sqlite3
|
||||
|
||||
WINDOW_DAYS = 14
|
||||
WINDOW_S = WINDOW_DAYS * 86400
|
||||
KEEP_SHARE = 0.5
|
||||
MAX_FILES = 10
|
||||
_NUMSTAT_FIELDS = 3
|
||||
FINAL = frozenset({"survived", "churned", "reverted"})
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Verdict:
|
||||
"""The outcome for one commit."""
|
||||
|
||||
verdict: str
|
||||
sha: str = ""
|
||||
ts: float = 0.0
|
||||
added: int = 0
|
||||
kept: int = 0
|
||||
|
||||
|
||||
def judge(added: int, kept: int, *, reverted: bool) -> str:
|
||||
"""Apply the survival rule to measured numbers."""
|
||||
if reverted:
|
||||
return "reverted"
|
||||
if added and kept < KEEP_SHARE * added:
|
||||
return "churned"
|
||||
return "survived"
|
||||
|
||||
|
||||
def _git(repo: str, *args: str) -> str | None:
|
||||
"""Run git in ``repo``; ``None`` on any failure."""
|
||||
return gitcmd.run(repo, *args)
|
||||
|
||||
|
||||
def _added(repo: str, sha: str) -> list[tuple[int, str]]:
|
||||
"""``(added lines, path)`` for the commit's largest text files."""
|
||||
out = _git(repo, "show", "--numstat", "--format=", sha) or ""
|
||||
rows = []
|
||||
for line in out.splitlines():
|
||||
parts = line.split("\t", 2)
|
||||
if len(parts) == _NUMSTAT_FIELDS and parts[0].isdigit() and int(parts[0]):
|
||||
rows.append((int(parts[0]), parts[2]))
|
||||
return sorted(rows, reverse=True)[:MAX_FILES]
|
||||
|
||||
|
||||
def _kept(repo: str, rev: str, sha: str, path: str) -> int:
|
||||
"""Lines of ``path`` at ``rev`` that still blame to ``sha``."""
|
||||
out = _git(repo, "blame", "--porcelain", rev, "--", path) or ""
|
||||
prefix = sha + " "
|
||||
return sum(1 for line in out.splitlines() if line.startswith(prefix))
|
||||
|
||||
|
||||
def evaluate(repo: str, sha: str, now: float) -> Verdict:
|
||||
"""Judge one commit in one repository."""
|
||||
if not Path(repo).is_dir():
|
||||
return Verdict("unknown")
|
||||
full = (
|
||||
_git(repo, "rev-parse", "--verify", "-q", f"{sha}^{{commit}}") or ""
|
||||
).strip()
|
||||
if not full:
|
||||
return Verdict("unknown")
|
||||
ts = float((_git(repo, "show", "-s", "--format=%ct", full) or "0").strip() or 0)
|
||||
if now - ts < WINDOW_S:
|
||||
return Verdict("pending", full, ts)
|
||||
if _git(repo, "merge-base", "--is-ancestor", full, "HEAD") is None:
|
||||
return Verdict("unknown", full, ts)
|
||||
until = f"@{int(ts + WINDOW_S)}"
|
||||
end = (_git(repo, "rev-list", "-1", f"--before={until}", "HEAD") or "").strip()
|
||||
log = _git(repo, "log", "--format=%B", f"--since=@{int(ts)}", f"--until={until}")
|
||||
reverted = f"This reverts commit {full}" in (log or "")
|
||||
files = _added(repo, full)
|
||||
added = sum(n for n, _ in files)
|
||||
kept = sum(_kept(repo, end or full, full, path) for _, path in files)
|
||||
return Verdict(judge(added, kept, reverted=reverted), full, ts, added, kept)
|
||||
|
||||
|
||||
def update(
|
||||
conn: sqlite3.Connection, now: float, deadline: float, clock: Callable[[], float]
|
||||
) -> bool:
|
||||
"""Judge every session commit without a final verdict, within the deadline.
|
||||
|
||||
Returns whether the backlog was cleared. A verdict once final is never
|
||||
recomputed, so the backlog is only commits that turned 14 days old.
|
||||
"""
|
||||
rows = conn.execute(
|
||||
"SELECT c.sha, c.repo, c.ts FROM session_commits c LEFT JOIN"
|
||||
" commit_verdicts v ON v.sha = c.sha WHERE v.verdict IS NULL"
|
||||
" OR v.verdict = 'pending'"
|
||||
).fetchall()
|
||||
for sha, repo, ts in rows:
|
||||
if now - ts < WINDOW_S:
|
||||
continue # cannot be final yet; no git call needed
|
||||
if clock() > deadline:
|
||||
return False
|
||||
found = evaluate(repo, sha, now)
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO commit_verdicts VALUES (?,?,?,?,?,?)",
|
||||
(sha, repo, found.ts or ts, found.added, found.kept, found.verdict),
|
||||
)
|
||||
return True
|
||||
0
python_pkg/token_dashboard/tests/__init__.py
Normal file
0
python_pkg/token_dashboard/tests/__init__.py
Normal file
123
python_pkg/token_dashboard/tests/conftest.py
Normal file
123
python_pkg/token_dashboard/tests/conftest.py
Normal file
@ -0,0 +1,123 @@
|
||||
"""Shared dashboard test setup: source isolation and transcript builders.
|
||||
|
||||
The autouse fixture keeps every test away from the real ``~/.claude`` state;
|
||||
the builders below make synthetic transcript records.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
import json
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import pytest
|
||||
|
||||
from python_pkg.token_dashboard import ab, aux, context_files, walk
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
from python_pkg.token_dashboard.detail_rows import DetailRows
|
||||
|
||||
BASE = 1_791_600_000.0 # 2026-10-09, a fixed anchor so day buckets are stable
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolated_sources(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Point every aux source at an empty tmp directory."""
|
||||
claude = tmp_path / "claude-home"
|
||||
monkeypatch.setattr(aux, "AUTOPSY", claude / "autopsy.jsonl")
|
||||
monkeypatch.setattr(aux, "PROMPT_HISTORY", claude / "history.jsonl")
|
||||
monkeypatch.setattr(aux, "TIME_CAP", claude / "time-cap.jsonl")
|
||||
monkeypatch.setattr(ab, "LOG", claude / "agent-ab.jsonl")
|
||||
monkeypatch.setattr(context_files, "CLAUDE_DIR", claude)
|
||||
monkeypatch.setattr(context_files, "SRC_DIR", claude / "src")
|
||||
|
||||
|
||||
def iso(offset: float) -> str:
|
||||
"""ISO timestamp ``offset`` seconds after :data:`BASE`."""
|
||||
return datetime.fromtimestamp(BASE + offset, tz=UTC).isoformat()
|
||||
|
||||
|
||||
def prompt(text: str, at: float, **extra: object) -> dict[str, Any]:
|
||||
"""A typed user prompt."""
|
||||
return {
|
||||
"type": "user",
|
||||
"timestamp": iso(at),
|
||||
"cwd": "/repo",
|
||||
"message": {"role": "user", "content": text},
|
||||
**extra,
|
||||
}
|
||||
|
||||
|
||||
def assistant(
|
||||
mid: str,
|
||||
at: float,
|
||||
*,
|
||||
output: int = 1000,
|
||||
model: str = "claude-opus-5-5",
|
||||
blocks: list[dict[str, Any]] | None = None,
|
||||
context: int = 0,
|
||||
**usage: object,
|
||||
) -> dict[str, Any]:
|
||||
"""One assistant record; ``context`` becomes cache-read tokens.
|
||||
|
||||
``usage`` adds raw usage fields, e.g. a cache-write split.
|
||||
"""
|
||||
return {
|
||||
"type": "assistant",
|
||||
"timestamp": iso(at),
|
||||
"message": {
|
||||
"id": mid,
|
||||
"model": model,
|
||||
"usage": {
|
||||
"output_tokens": output,
|
||||
"cache_read_input_tokens": context,
|
||||
**usage,
|
||||
},
|
||||
"content": blocks or [{"type": "text", "text": "ok"}],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def use(tid: str, name: str, **tool_input: object) -> dict[str, Any]:
|
||||
"""A ``tool_use`` content block."""
|
||||
return {"type": "tool_use", "id": tid, "name": name, "input": tool_input}
|
||||
|
||||
|
||||
def result(
|
||||
tid: str, at: float, text: str = "x" * 40, **extra: object
|
||||
) -> dict[str, Any]:
|
||||
"""A user record carrying one ``tool_result``."""
|
||||
return {
|
||||
"type": "user",
|
||||
"timestamp": iso(at),
|
||||
"message": {
|
||||
"role": "user",
|
||||
"content": [{"type": "tool_result", "tool_use_id": tid, "content": text}],
|
||||
},
|
||||
**extra,
|
||||
}
|
||||
|
||||
|
||||
def write(path: Path, records: list[dict[str, Any]]) -> Path:
|
||||
"""Write records as JSONL, creating parent directories."""
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text("\n".join(json.dumps(r) for r in records) + "\n", encoding="utf-8")
|
||||
return path
|
||||
|
||||
|
||||
def usd_opus_output(tokens: int) -> float:
|
||||
"""List price of ``tokens`` Opus 5.5 output tokens (USD 20 / MTok)."""
|
||||
return tokens * 20 / 1_000_000
|
||||
|
||||
|
||||
def attach(at: float, **attachment: object) -> dict[str, Any]:
|
||||
"""An ``attachment`` record."""
|
||||
return {"type": "attachment", "timestamp": iso(at), "attachment": attachment}
|
||||
|
||||
|
||||
def walk_detail(tmp_path: Path, records: list[dict[str, Any]]) -> DetailRows:
|
||||
"""Walk one main-session file and return its detail rows."""
|
||||
path = write(tmp_path / "proj" / "s.jsonl", records)
|
||||
return walk.walk_file(path, tmp_path).rows.detail
|
||||
110
python_pkg/token_dashboard/tests/test_ab.py
Normal file
110
python_pkg/token_dashboard/tests/test_ab.py
Normal file
@ -0,0 +1,110 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pytest
|
||||
|
||||
from python_pkg.token_dashboard import ab, ingest, schema
|
||||
from python_pkg.token_dashboard.tests.conftest import (
|
||||
assistant,
|
||||
prompt,
|
||||
result,
|
||||
use,
|
||||
write,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def test_wilson_matches_reference_values() -> None:
|
||||
# Reference: Wilson score interval, z = 1.96 (Newcombe 1998, table I).
|
||||
low, high = ab.wilson(81, 263)
|
||||
assert (round(low, 4), round(high, 4)) == (0.2553, 0.3662)
|
||||
assert ab.wilson(0, 10)[0] == 0.0
|
||||
assert ab.wilson(0, 0) == (0.0, 1.0)
|
||||
|
||||
|
||||
def test_newcombe_difference_contains_zero_for_equal_rates() -> None:
|
||||
diff, low, high = ab.diff_ci(5, 50, 5, 50)
|
||||
assert diff == 0.0
|
||||
assert low < 0 < high
|
||||
|
||||
|
||||
def test_jaccard_and_correction_phrases() -> None:
|
||||
assert ab.jaccard("Find RTDB rules for tutor", "find rtdb rules tutor log") >= 0.6
|
||||
assert ab.jaccard("Find RTDB rules", "Write the README") == 0.0
|
||||
assert ab.is_correction("No, that's wrong")
|
||||
assert ab.is_correction("you missed the second file")
|
||||
assert not ab.is_correction("nothing else, thanks")
|
||||
|
||||
|
||||
def _arm(n: int, redo: int, corr: int = 0) -> ab.ArmCounts:
|
||||
return ab.ArmCounts(n=n, redo=redo, correction=corr)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("sonnet", "opus", "level"),
|
||||
[
|
||||
(_arm(10, 9), _arm(10, 0), "grey"),
|
||||
(_arm(40, 20), _arm(40, 2), "red"),
|
||||
(_arm(40, 2, 20), _arm(40, 2, 1), "red"),
|
||||
(_arm(40, 6), _arm(40, 4), "green"),
|
||||
(_arm(40, 0), _arm(40, 20), "green"), # Sonnet better is not a drop
|
||||
],
|
||||
)
|
||||
def test_banner(sonnet: ab.ArmCounts, opus: ab.ArmCounts, level: str) -> None:
|
||||
found = ab.banner(sonnet, opus)
|
||||
assert found["level"] == level
|
||||
assert f"n={min(sonnet.n, opus.n)}" in found["text"] or level == "red"
|
||||
|
||||
|
||||
def test_rebuild_joins_assignments_to_transcripts(tmp_path: Path) -> None:
|
||||
root = tmp_path / "projects"
|
||||
first = use("t1", "Agent", description="Review the parser change")
|
||||
again = use("t2", "Agent", description="Review the parser change again")
|
||||
msg = use("t3", "SendMessage", to="abc", message="more")
|
||||
write(
|
||||
root / "p" / "s1.jsonl",
|
||||
[
|
||||
prompt("go", 0),
|
||||
assistant("m1", 1, blocks=[first]),
|
||||
result("t1", 50, "done", toolUseResult={"agentId": "abc"}),
|
||||
assistant("m2", 60, blocks=[msg]),
|
||||
result("t3", 61),
|
||||
prompt("no, that's wrong", 100),
|
||||
assistant("m3", 101, blocks=[again]),
|
||||
result(
|
||||
"t2", 150, "err", toolUseResult={"agentId": "def", "status": "error"}
|
||||
),
|
||||
],
|
||||
)
|
||||
log = tmp_path / "agent-ab.jsonl"
|
||||
rows = [
|
||||
{"tool_use_id": "t1", "arm": "sonnet", "class": "review", "session_id": "s1"},
|
||||
{"tool_use_id": "t2", "arm": "opus", "class": "review", "session_id": "s1"},
|
||||
]
|
||||
log.write_text("\n".join(json.dumps(r) for r in rows), encoding="utf-8")
|
||||
ingest.run(root, tmp_path / "t.sqlite", tmp_path / "report", 30.0)
|
||||
conn = schema.connect(tmp_path / "t.sqlite")
|
||||
payload = ab.rebuild(conn, log)
|
||||
stored = conn.execute(
|
||||
"SELECT tool_use_id, arm, key, main_key, is_sub, cwd, redo, followup,"
|
||||
" error, correction, tag FROM ab_rows ORDER BY tool_use_id"
|
||||
).fetchall()
|
||||
payload = ab.rebuild(conn, log) # a rebuild replaces, never appends
|
||||
count = conn.execute("SELECT COUNT(*) FROM ab_rows").fetchone()[0]
|
||||
conn.close()
|
||||
assert stored == [
|
||||
("t1", "sonnet", "s1", "s1", 0, "/repo", 1, 1, 0, 1, "untagged"),
|
||||
("t2", "opus", "s1", "s1", 0, "/repo", 0, 0, 1, 0, "untagged"),
|
||||
]
|
||||
assert count == 2
|
||||
sonnet, opus = payload["arms"]["sonnet"], payload["arms"]["opus"]
|
||||
assert (sonnet["n"], opus["n"]) == (1, 1)
|
||||
assert sonnet["redo"]["k"] == 1
|
||||
assert sonnet["followup"]["k"] == 1
|
||||
assert sonnet["correction"]["k"] == 1
|
||||
assert opus["error"]["k"] == 1
|
||||
assert payload["banner"]["level"] == "grey"
|
||||
31
python_pkg/token_dashboard/tests/test_active.py
Normal file
31
python_pkg/token_dashboard/tests/test_active.py
Normal file
@ -0,0 +1,31 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from python_pkg.token_dashboard import active
|
||||
|
||||
|
||||
def test_gap_over_limit_contributes_zero_not_the_limit() -> None:
|
||||
spans = active.intervals([0, 60, 120, 1000, 1030])
|
||||
assert spans == [(0, 120), (1000, 1030)]
|
||||
assert active.measure(spans) == 150
|
||||
|
||||
|
||||
def test_union_bridges_short_gaps_between_files() -> None:
|
||||
main = [(0.0, 100.0)]
|
||||
sub = [(250.0, 400.0)] # 150 s after main went quiet: still active
|
||||
far = [(2000.0, 2010.0)]
|
||||
assert active.measure(main + sub + far) == 410
|
||||
|
||||
|
||||
def test_overlapping_intervals_count_once() -> None:
|
||||
assert active.measure([(0.0, 100.0), (50.0, 150.0)]) == 150
|
||||
|
||||
|
||||
def test_clip_to_a_prompt_window() -> None:
|
||||
spans = [(0.0, 100.0), (1000.0, 1100.0)]
|
||||
assert active.measure(spans, 50.0, 1050.0) == 100
|
||||
assert active.measure([], 0.0, 10.0) == 0
|
||||
|
||||
|
||||
def test_empty_and_single_stamp() -> None:
|
||||
assert active.intervals([]) == []
|
||||
assert active.intervals([5.0]) == [(5.0, 5.0)]
|
||||
173
python_pkg/token_dashboard/tests/test_aux.py
Normal file
173
python_pkg/token_dashboard/tests/test_aux.py
Normal file
@ -0,0 +1,173 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
import json
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pytest
|
||||
|
||||
from python_pkg.token_dashboard import aux, schema
|
||||
from python_pkg.token_dashboard.tests.test_levers import TABLE
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterator, Sequence
|
||||
from pathlib import Path
|
||||
import sqlite3
|
||||
|
||||
NOON = datetime(2026, 10, 9, 12, tzinfo=UTC)
|
||||
SECONDS = NOON.timestamp()
|
||||
|
||||
|
||||
def _lines(path: Path, rows: Sequence[object], *junk: str) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
body = [json.dumps(r) for r in rows] + list(junk)
|
||||
path.write_text("\n".join(body) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def conn(tmp_path: Path) -> Iterator[sqlite3.Connection]:
|
||||
opened = schema.connect(tmp_path / "t.sqlite")
|
||||
yield opened
|
||||
opened.close()
|
||||
|
||||
|
||||
def test_jsonl_skips_blank_malformed_and_non_object_lines(tmp_path: Path) -> None:
|
||||
path = tmp_path / "x.jsonl"
|
||||
_lines(path, [{"a": 1}, [1, 2], 3], "", "{broken")
|
||||
assert list(aux._jsonl(path)) == [{"a": 1}]
|
||||
assert list(aux._jsonl(tmp_path / "missing.jsonl")) == []
|
||||
|
||||
|
||||
def test_stamp_reads_iso_seconds_milliseconds_and_rejects_the_rest() -> None:
|
||||
assert aux._stamp(NOON.isoformat()) == SECONDS
|
||||
assert aux._stamp(SECONDS) == SECONDS
|
||||
assert aux._stamp(int(SECONDS * 1000)) == pytest.approx(int(SECONDS))
|
||||
assert aux._stamp("yesterday") is None
|
||||
assert aux._stamp(["x"]) is None
|
||||
assert aux._stamp(None) is None
|
||||
|
||||
|
||||
def test_snapshots_copy_window_and_metrics_and_skip_unreadable(
|
||||
tmp_path: Path, conn: sqlite3.Connection
|
||||
) -> None:
|
||||
history = tmp_path / "report" / "history"
|
||||
history.mkdir(parents=True)
|
||||
snap = {
|
||||
"generated": NOON.isoformat(),
|
||||
"window": {"since": 1.0, "until": 2.0},
|
||||
"metrics": {"usd_total": 5},
|
||||
}
|
||||
(history / "2026-10-09.json").write_text(json.dumps(snap))
|
||||
(history / "2026-10-10.json").write_text("{not json")
|
||||
aux.snapshots(conn, tmp_path / "report")
|
||||
rows = conn.execute("SELECT name, since, until, metrics FROM snapshots").fetchall()
|
||||
assert rows == [("2026-10-09", 1.0, 2.0, '{"usd_total": 5}')]
|
||||
|
||||
|
||||
def test_lever_rows_assign_each_table_to_the_nearest_snapshot(
|
||||
tmp_path: Path, conn: sqlite3.Connection
|
||||
) -> None:
|
||||
report = tmp_path / "report"
|
||||
(report / "history").mkdir(parents=True)
|
||||
for day in ("2026-10-02", "2026-10-09"):
|
||||
stamp = datetime.fromisoformat(f"{day}T12:00:00+00:00").isoformat()
|
||||
(report / "history" / f"{day}.json").write_text(
|
||||
json.dumps({"generated": stamp})
|
||||
)
|
||||
(report / "WEEKLY.md").write_text(TABLE, encoding="utf-8")
|
||||
aux.snapshots(conn, report)
|
||||
conn.execute("INSERT INTO lever_tables VALUES ('s', ?, ?)", (SECONDS, TABLE))
|
||||
aux.lever_rows(conn, report)
|
||||
sources = {r[0] for r in conn.execute("SELECT source FROM levers")}
|
||||
assert "snapshot 2026-10-09" in sources
|
||||
|
||||
|
||||
def test_lever_rows_with_no_snapshots_store_nothing(
|
||||
tmp_path: Path, conn: sqlite3.Connection
|
||||
) -> None:
|
||||
conn.execute("INSERT INTO lever_tables VALUES ('s', ?, ?)", (SECONDS, TABLE))
|
||||
aux.lever_rows(conn, tmp_path / "report")
|
||||
assert conn.execute("SELECT COUNT(*) FROM levers").fetchone() == (0,)
|
||||
|
||||
|
||||
def _autopsy_row(
|
||||
sid: str, started: object, msgs: int, api: int | None
|
||||
) -> dict[str, object]:
|
||||
return {
|
||||
"session_id": sid,
|
||||
"meta": {"started_at": started, "cwd": "/r", "title": sid},
|
||||
"tokens": {"input": 100, "cache_write": 200, "cache_read": 300, "output": 400},
|
||||
"turns": {"api_turns": api},
|
||||
"counts": {"assistant_msgs": msgs},
|
||||
}
|
||||
|
||||
|
||||
def test_autopsy_scales_by_api_turns_and_calibrates_against_transcripts(
|
||||
conn: sqlite3.Connection,
|
||||
) -> None:
|
||||
rows = [
|
||||
_autopsy_row("both", NOON.isoformat(), 10, 5),
|
||||
_autopsy_row("alone", SECONDS, 4, None),
|
||||
_autopsy_row("nostart", None, 4, 2),
|
||||
_autopsy_row("nomsgs", SECONDS, 0, 0),
|
||||
]
|
||||
_lines(aux.AUTOPSY, rows)
|
||||
conn.execute("INSERT INTO session_summary(key, usd_main) VALUES ('both', 3.0)")
|
||||
aux.autopsy(conn)
|
||||
got = dict(conn.execute("SELECT session_id, usd_est FROM aggregate_sessions"))
|
||||
assert set(got) == {"both", "alone"}
|
||||
# Same tokens: 5 API turns over 10 records scales the cost by one half.
|
||||
assert got["both"] == pytest.approx(got["alone"] / 2)
|
||||
factor = conn.execute("SELECT value FROM meta WHERE key='autopsy_calibration'")
|
||||
assert float(factor.fetchone()[0]) == pytest.approx(3.0 / got["both"])
|
||||
|
||||
|
||||
def test_autopsy_without_overlap_keeps_factor_one(conn: sqlite3.Connection) -> None:
|
||||
aux.autopsy(conn)
|
||||
factor = conn.execute("SELECT value FROM meta WHERE key='autopsy_calibration'")
|
||||
assert factor.fetchone() == ("1.0",)
|
||||
|
||||
|
||||
def test_activity_logs_count_prompts_events_and_first_estimate(
|
||||
tmp_path: Path, conn: sqlite3.Connection
|
||||
) -> None:
|
||||
report = tmp_path / "report"
|
||||
_lines(
|
||||
aux.PROMPT_HISTORY,
|
||||
[{"timestamp": int(SECONDS * 1000)}, {"timestamp": SECONDS}, {"x": 1}],
|
||||
)
|
||||
_lines(aux.TIME_CAP, [{"ts": SECONDS, "event": "kill", "command": "sleep 99"}])
|
||||
misses = report / "task-size" / "misses.jsonl"
|
||||
_lines(misses, [{"ts": NOON.isoformat(), "kind": "under", "first_prompt": "p"}])
|
||||
verdicts = [
|
||||
{"status": "ok", "session": "a", "verdict": {"est_calls": 12}},
|
||||
{"status": "ok", "session": "a", "verdict": {"est_calls": 99}},
|
||||
{"status": "ok", "session": "b", "verdict": "none"},
|
||||
{"status": "error", "session": "c", "verdict": {"est_calls": 3}},
|
||||
]
|
||||
_lines(report / "task-size" / "verdicts.jsonl", verdicts)
|
||||
aux.activity_logs(conn, report)
|
||||
days = conn.execute("SELECT prompts FROM prompt_activity").fetchall()
|
||||
events = conn.execute("SELECT source, kind, detail FROM events").fetchall()
|
||||
estimates = conn.execute("SELECT session_id, est_calls FROM estimates").fetchall()
|
||||
assert days == [(2,)]
|
||||
assert events == [
|
||||
("time-cap", "kill", "sleep 99"),
|
||||
("task-size-miss", "under", "p"),
|
||||
]
|
||||
assert estimates == [("a", 12)]
|
||||
|
||||
|
||||
def test_reloading_keeps_rows_a_trimmed_source_no_longer_has(tmp_path: Path) -> None:
|
||||
conn = schema.connect(tmp_path / "t.sqlite")
|
||||
_lines(
|
||||
aux.PROMPT_HISTORY,
|
||||
[{"timestamp": 1_700_000_000_000}, {"timestamp": 1_800_000_000}],
|
||||
)
|
||||
aux.activity_logs(conn, tmp_path / "report")
|
||||
aux.PROMPT_HISTORY.write_text("", encoding="utf-8") # rotated away
|
||||
aux.activity_logs(conn, tmp_path / "report")
|
||||
aux.activity_logs(conn, tmp_path / "report")
|
||||
days = conn.execute("SELECT COUNT(*), SUM(prompts) FROM prompt_activity").fetchone()
|
||||
conn.close()
|
||||
assert days == (2, 2)
|
||||
82
python_pkg/token_dashboard/tests/test_check_window.py
Normal file
82
python_pkg/token_dashboard/tests/test_check_window.py
Normal file
@ -0,0 +1,82 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pytest
|
||||
|
||||
from python_pkg.token_dashboard import check_window, schema
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Mapping
|
||||
from pathlib import Path
|
||||
|
||||
DAY = 86400.0
|
||||
|
||||
|
||||
def _store(tmp_path: Path, stamps: list[float]) -> Path:
|
||||
db = tmp_path / "t.sqlite"
|
||||
conn = schema.connect(db)
|
||||
conn.executemany(
|
||||
"INSERT INTO turns(key, ts, usd) VALUES ('k', ?, 1.0)", [(s,) for s in stamps]
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return db
|
||||
|
||||
|
||||
def _snapshot(report: Path, name: str, metrics: Mapping[str, float]) -> None:
|
||||
folder = report / "history"
|
||||
folder.mkdir(parents=True, exist_ok=True)
|
||||
snap = {"window": {"since": 0, "until": 10 * DAY}, "metrics": metrics}
|
||||
(folder / f"{name}.json").write_text(json.dumps(snap), encoding="utf-8")
|
||||
|
||||
|
||||
def _run(tmp_path: Path, db: Path, *extra: str) -> int:
|
||||
return check_window.main(
|
||||
["--db", str(db), "--report-dir", str(tmp_path / "report"), *extra]
|
||||
)
|
||||
|
||||
|
||||
def test_matching_snapshot_exits_zero(
|
||||
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
db = _store(tmp_path, [100.0, 100.0 + DAY])
|
||||
good = {"usd_total": 2.0, "usd_per_day": 2.0, "calls": 2}
|
||||
_snapshot(tmp_path / "report", "2026-10-16", {**good, "calls": 99})
|
||||
_snapshot(tmp_path / "report", "2026-10-17", good)
|
||||
assert _run(tmp_path, db, "--snapshot", "2026-10-17") == 0
|
||||
out = capsys.readouterr().out
|
||||
assert out.count("OK") == 3
|
||||
assert "FAIL" not in out
|
||||
|
||||
|
||||
def test_drift_beyond_one_percent_in_the_newest_snapshot_fails(
|
||||
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
db = _store(tmp_path, [100.0, 100.0 + DAY])
|
||||
_snapshot(tmp_path / "report", "2026-10-16", {"usd_total": 2.0})
|
||||
bad = {"usd_total": 2.5, "usd_per_day": 2.0, "calls": 2}
|
||||
_snapshot(tmp_path / "report", "2026-10-17", bad)
|
||||
assert _run(tmp_path, db) == 1
|
||||
assert "usd_total: store 2.00 vs snapshot 2.50 (20.00%) FAIL" in (
|
||||
capsys.readouterr().out
|
||||
)
|
||||
|
||||
|
||||
def test_empty_window_against_zero_metrics_is_a_match(tmp_path: Path) -> None:
|
||||
db = _store(tmp_path, [])
|
||||
zero = {"usd_total": 0, "usd_per_day": 0, "calls": 0}
|
||||
_snapshot(tmp_path / "report", "2026-10-17", zero)
|
||||
assert _run(tmp_path, db) == 0
|
||||
|
||||
|
||||
def test_missing_snapshot_or_store_exits_one(
|
||||
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
db = _store(tmp_path, [1.0])
|
||||
assert _run(tmp_path, db) == 1 # no snapshot directory at all
|
||||
_snapshot(tmp_path / "report", "2026-10-17", {"usd_total": 1})
|
||||
assert _run(tmp_path, db, "--snapshot", "1999-01-01") == 1
|
||||
assert _run(tmp_path, tmp_path / "gone.sqlite") == 1
|
||||
assert capsys.readouterr().err.count("missing snapshot or store") == 3
|
||||
124
python_pkg/token_dashboard/tests/test_classify.py
Normal file
124
python_pkg/token_dashboard/tests/test_classify.py
Normal file
@ -0,0 +1,124 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from python_pkg.token_dashboard import classify
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("command", "family"),
|
||||
[
|
||||
("ls -la", "ls"),
|
||||
("git -C /repo status --short | head", "git status"),
|
||||
("cd /x && git log --oneline -3", "git log"),
|
||||
("~/.claude/scripts/capped.sh python3 -m pytest -q", "python3 -m pytest"),
|
||||
(
|
||||
"CAP_MEM=4G CAP_CPU_PCT=20 ~/.claude/scripts/capped.sh pnpm -s lint",
|
||||
"pnpm lint",
|
||||
),
|
||||
("timeout 40 nice -n 19 grep -r x .", "grep"),
|
||||
("python3 scripts/check.py --all", "python3 check.py"),
|
||||
("bash ~/src/utils/scripts/gate_stamp.sh step x", "bash gate_stamp.sh"),
|
||||
("/usr/bin/sqlite3 db 'SELECT 1'", "sqlite3"),
|
||||
("cd /x", "cd"),
|
||||
("python3 -m", "python3 -m"),
|
||||
("python3 <<'EOF'\nprint(1)\nEOF", "python3"),
|
||||
("bash -c 'x' > /dev/null", "bash -c"),
|
||||
("git", "git"),
|
||||
("FOO=1", "(empty)"),
|
||||
("", "(empty)"),
|
||||
],
|
||||
)
|
||||
def test_bash_family(command: str, family: str) -> None:
|
||||
assert classify.bash_family(command) == family
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("name", "tool_input", "family", "target"),
|
||||
[
|
||||
("Bash", {"command": "git status\n"}, "git status", "git status"),
|
||||
("Bash", {}, "(empty)", ""),
|
||||
(
|
||||
"mcp__claude-in-chrome__navigate",
|
||||
{"url": "x"},
|
||||
"mcp:claude-in-chrome",
|
||||
"navigate",
|
||||
),
|
||||
("Skill", {"skill": "dataviz"}, "skill", "dataviz"),
|
||||
(
|
||||
"Read",
|
||||
{"file_path": "/h/.claude/rules/a.md"},
|
||||
"Read:rules",
|
||||
"/h/.claude/rules/a.md",
|
||||
),
|
||||
(
|
||||
"Read",
|
||||
{"file_path": "/h/.claude/projects/p/memory/m.md"},
|
||||
"Read:memory",
|
||||
"/h/.claude/projects/p/memory/m.md",
|
||||
),
|
||||
("Read", {"file_path": "/r/CLAUDE.md"}, "Read:claude-md", "/r/CLAUDE.md"),
|
||||
(
|
||||
"Edit",
|
||||
{"file_path": "/h/.claude/skills/x/SKILL.md"},
|
||||
"Edit:skill",
|
||||
"/h/.claude/skills/x/SKILL.md",
|
||||
),
|
||||
(
|
||||
"Write",
|
||||
{"file_path": "/h/.claude/settings.json"},
|
||||
"Write:claude",
|
||||
"/h/.claude/settings.json",
|
||||
),
|
||||
("Read", {"file_path": "/r/shot.PNG"}, "Read:image", "/r/shot.PNG"),
|
||||
("Read", {"file_path": "/r/a.py"}, "Read:file", "/r/a.py"),
|
||||
("Read", {}, "Read:file", ""),
|
||||
("Grep", {"pattern": "def x", "path": "/r"}, "Grep", "def x"),
|
||||
(
|
||||
"WebFetch",
|
||||
{"url": "https://docs.example.com/a?b"},
|
||||
"WebFetch",
|
||||
"docs.example.com",
|
||||
),
|
||||
("WebSearch", {"query": "sqlite window"}, "WebSearch", "sqlite window"),
|
||||
("Agent", {"subagent_type": "Explore"}, "Agent", "Explore"),
|
||||
("Agent", {"isolation": "worktree"}, "Agent", "general-purpose [worktree]"),
|
||||
("TaskStop", {"id": 1}, "TaskStop", ""),
|
||||
],
|
||||
)
|
||||
def test_family_and_target(
|
||||
name: str, tool_input: dict[str, object], family: str, target: str
|
||||
) -> None:
|
||||
assert classify.family(name, tool_input) == family
|
||||
assert classify.target(name, tool_input) == target
|
||||
|
||||
|
||||
def test_target_is_capped() -> None:
|
||||
long = {"command": "echo " + "x" * 1000}
|
||||
assert len(classify.target("Bash", long)) == classify.TARGET_CHARS
|
||||
|
||||
|
||||
def test_signature_ignores_key_order_but_not_values() -> None:
|
||||
one = classify.signature("Bash", {"command": "ls", "timeout": 5})
|
||||
same = classify.signature("Bash", {"timeout": 5, "command": "ls"})
|
||||
other = classify.signature("Bash", {"command": "ls -a", "timeout": 5})
|
||||
assert one == same
|
||||
assert one != other
|
||||
assert len(one) == classify.SIG_CHARS
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("text", "kind"),
|
||||
[
|
||||
("Exit code 1\nboom", "exit_code"),
|
||||
("<tool_use_error>File has not been read yet</tool_use_error>", "tool_error"),
|
||||
("The user doesn't want to proceed with this tool use.", "rejected"),
|
||||
("Permission for this action was denied by the classifier", "denied"),
|
||||
("PreToolUse:Bash hook error: blocked by policy", "hook"),
|
||||
("TIME CAP: this command was killed at 60s", "time_cap"),
|
||||
("[Request interrupted by user for tool use]", "interrupted"),
|
||||
("something else", "error"),
|
||||
],
|
||||
)
|
||||
def test_error_kind(text: str, kind: str) -> None:
|
||||
assert classify.error_kind(text) == kind
|
||||
61
python_pkg/token_dashboard/tests/test_commits.py
Normal file
61
python_pkg/token_dashboard/tests/test_commits.py
Normal file
@ -0,0 +1,61 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from python_pkg.token_dashboard import commits, schema
|
||||
from python_pkg.token_dashboard.tests.test_efficiency import MONDAY, _commit, _git
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
TRAILER = "\n\nCo-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
|
||||
|
||||
|
||||
def test_git_log_attributes_trailer_commits_to_the_commit_command(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
repo = tmp_path / "r"
|
||||
repo.mkdir()
|
||||
_git(repo, "init", "-q", when=MONDAY)
|
||||
(repo / "a").write_text("1")
|
||||
_git(repo, "add", "a", when=MONDAY)
|
||||
_git(repo, "commit", "-qm", "ours" + TRAILER, when=MONDAY + 30)
|
||||
ours = _git(repo, "rev-parse", "HEAD", when=MONDAY)
|
||||
_commit(repo, "b", "2", MONDAY + 40) # no trailer: a human commit
|
||||
_git(
|
||||
repo,
|
||||
"commit",
|
||||
"-q",
|
||||
"--allow-empty",
|
||||
"-m",
|
||||
"late" + TRAILER,
|
||||
when=MONDAY + 9000,
|
||||
)
|
||||
conn = schema.connect(tmp_path / "t.sqlite")
|
||||
rows = [
|
||||
("s1", 0, MONDAY, MONDAY + 60, "", str(repo)),
|
||||
("s2", 3, MONDAY - 5000, MONDAY - 4000, "", str(repo / "sub")),
|
||||
("s3", 1, MONDAY, MONDAY + 1, "", str(tmp_path / "missing")),
|
||||
]
|
||||
conn.executemany("INSERT INTO commits VALUES (?,?,?,?,?,?)", rows)
|
||||
commits.discover(conn)
|
||||
found = conn.execute("SELECT sha, key, prompt_idx FROM session_commits").fetchall()
|
||||
conn.close()
|
||||
assert found == [(ours, "s1", 0)]
|
||||
|
||||
|
||||
def test_a_printed_hash_is_attributed_even_without_trailer(tmp_path: Path) -> None:
|
||||
repo = tmp_path / "r"
|
||||
repo.mkdir()
|
||||
_git(repo, "init", "-q", when=MONDAY)
|
||||
sha = _commit(repo, "a", "1", MONDAY + 5)
|
||||
conn = schema.connect(tmp_path / "t.sqlite")
|
||||
conn.execute(
|
||||
"INSERT INTO commits VALUES (?,?,?,?,?,?)",
|
||||
("s1", 2, MONDAY, MONDAY + 9, sha[:7], str(repo)),
|
||||
)
|
||||
commits.discover(conn)
|
||||
commits.discover(conn) # idempotent
|
||||
found = conn.execute("SELECT sha, key FROM session_commits").fetchall()
|
||||
conn.close()
|
||||
assert found == [(sha, "s1")]
|
||||
103
python_pkg/token_dashboard/tests/test_context_files.py
Normal file
103
python_pkg/token_dashboard/tests/test_context_files.py
Normal file
@ -0,0 +1,103 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from python_pkg.token_dashboard import context_files, gitcmd, schema
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _tree(tmp_path: Path) -> tuple[Path, Path]:
|
||||
claude, src = tmp_path / ".claude", tmp_path / "src"
|
||||
for rel in (
|
||||
"CLAUDE.md",
|
||||
"rules/a.instructions.md",
|
||||
"rules/deep/b.md",
|
||||
"memories/mistakes.md",
|
||||
"skills/x/SKILL.md",
|
||||
"agents/reviewer.md",
|
||||
"projects/-home-kuhy/memory/MEMORY.md",
|
||||
"backups/rules-ondemand/not-loaded.md",
|
||||
):
|
||||
(claude / rel).parent.mkdir(parents=True, exist_ok=True)
|
||||
(claude / rel).write_text("# x\n" * 10, encoding="utf-8")
|
||||
(src / "repo").mkdir(parents=True)
|
||||
(src / "repo" / "CLAUDE.md").write_text("## Commands\n", encoding="utf-8")
|
||||
(src / "repo" / "AGENTS.md").write_text("agents\n", encoding="utf-8")
|
||||
return claude, src
|
||||
|
||||
|
||||
def test_enumerates_every_loaded_file_and_nothing_on_demand(tmp_path: Path) -> None:
|
||||
claude, src = _tree(tmp_path)
|
||||
found = {
|
||||
f.path.replace(str(tmp_path), ""): f for f in context_files.find(claude, src)
|
||||
}
|
||||
assert set(found) == {
|
||||
"/.claude/CLAUDE.md",
|
||||
"/.claude/rules/a.instructions.md",
|
||||
"/.claude/rules/deep/b.md",
|
||||
"/.claude/memories/mistakes.md",
|
||||
"/.claude/skills/x/SKILL.md",
|
||||
"/.claude/agents/reviewer.md",
|
||||
"/.claude/projects/-home-kuhy/memory/MEMORY.md",
|
||||
"/src/repo/CLAUDE.md",
|
||||
"/src/repo/AGENTS.md",
|
||||
}
|
||||
assert found["/.claude/rules/deep/b.md"].standing == "always"
|
||||
assert found["/src/repo/CLAUDE.md"].standing == "in repo"
|
||||
assert found["/.claude/skills/x/SKILL.md"].standing == "description"
|
||||
assert found["/.claude/projects/-home-kuhy/memory/MEMORY.md"].limit == 24_400
|
||||
|
||||
|
||||
def _git(repo: Path, *args: str, when: int) -> None:
|
||||
env = {**os.environ, "GIT_AUTHOR_DATE": f"@{when} +0000"}
|
||||
env |= {"GIT_COMMITTER_DATE": f"@{when} +0000", "HOME": str(repo)}
|
||||
env |= {"GIT_AUTHOR_NAME": "t", "GIT_AUTHOR_EMAIL": "t@t"}
|
||||
env |= {"GIT_COMMITTER_NAME": "t", "GIT_COMMITTER_EMAIL": "t@t"}
|
||||
subprocess.run(
|
||||
[gitcmd.GIT, "-C", str(repo), *args], env=env, check=True, capture_output=True
|
||||
)
|
||||
|
||||
|
||||
def test_size_history_from_git_and_prune_sizes(tmp_path: Path) -> None:
|
||||
claude, src = _tree(tmp_path)
|
||||
_git(claude, "init", "-q", when=1000)
|
||||
_git(claude, "add", "CLAUDE.md", when=1000)
|
||||
_git(claude, "commit", "-qm", "a", when=1000)
|
||||
(claude / "CLAUDE.md").write_text("longer\n" * 50, encoding="utf-8")
|
||||
_git(claude, "commit", "-qam", "b", when=2000)
|
||||
prune = claude / "token-report" / "prune" / "2026-10-12"
|
||||
prune.mkdir(parents=True)
|
||||
(prune / "sizes.json").write_text(json.dumps({"file_bytes": {"CLAUDE.md": 77}}))
|
||||
conn = schema.connect(tmp_path / "t.sqlite")
|
||||
context_files.refresh(conn, claude, src)
|
||||
rows = conn.execute(
|
||||
"SELECT ts, bytes, source FROM context_sizes WHERE path LIKE '%/CLAUDE.md'"
|
||||
" AND path NOT LIKE '%/src/%' ORDER BY ts"
|
||||
).fetchall()
|
||||
listed = conn.execute("SELECT COUNT(*) FROM context_files").fetchone()[0]
|
||||
context_files.refresh(conn, claude, src) # incremental: no duplicates
|
||||
again = conn.execute("SELECT COUNT(*) FROM context_sizes").fetchone()[0]
|
||||
conn.close()
|
||||
assert [(r[1], r[2]) for r in rows[:2]] == [(40, "git"), (350, "git")]
|
||||
assert (77, "prune 2026-10-12") in [(r[1], r[2]) for r in rows]
|
||||
assert listed == 9
|
||||
assert again == len(rows)
|
||||
|
||||
|
||||
def test_price_mix_weights_cache_read_price_by_calls(tmp_path: Path) -> None:
|
||||
conn = schema.connect(tmp_path / "t.sqlite")
|
||||
rows = [
|
||||
("claude-opus-5-5", 100.0),
|
||||
("claude-opus-5-5", 101.0),
|
||||
("claude-haiku-4-5", 102.0),
|
||||
("claude-haiku-4-5", 1.0),
|
||||
]
|
||||
conn.executemany("INSERT INTO turns (model, ts) VALUES (?, ?)", rows)
|
||||
price = context_files.price_mix(conn, now=200.0, days=150 / 86400)
|
||||
conn.close()
|
||||
assert round(price, 4) == round((0.2 * 2 + 0.1) / 3, 4)
|
||||
164
python_pkg/token_dashboard/tests/test_detail.py
Normal file
164
python_pkg/token_dashboard/tests/test_detail.py
Normal file
@ -0,0 +1,164 @@
|
||||
"""Per-call and per-turn detail rows the walker keeps."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pytest
|
||||
|
||||
from python_pkg.token_dashboard import detail
|
||||
from python_pkg.token_dashboard.tests.conftest import (
|
||||
assistant,
|
||||
iso,
|
||||
prompt,
|
||||
result,
|
||||
use,
|
||||
walk_detail,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
ONE_HOUR_WRITE = {
|
||||
"cache_creation_input_tokens": 1000,
|
||||
"cache_creation": {"ephemeral_1h_input_tokens": 1000},
|
||||
}
|
||||
|
||||
|
||||
def test_result_creation_and_carry_are_priced_by_hand(tmp_path: Path) -> None:
|
||||
rows = walk_detail(
|
||||
tmp_path,
|
||||
[
|
||||
prompt("go", 0),
|
||||
assistant("m1", 2, blocks=[use("t1", "Bash", command="git status")]),
|
||||
result("t1", 5, "x" * 398), # json.dumps -> 400 chars -> 100 tokens
|
||||
assistant("m2", 9, **ONE_HOUR_WRITE),
|
||||
assistant("m3", 12, context=2000),
|
||||
],
|
||||
)
|
||||
(call,) = rows.calls
|
||||
assert (call.family, call.target, call.batch) == ("git status", "git status", 1)
|
||||
assert call.result_tokens == 100
|
||||
assert call.end_ts - call.where.ts == pytest.approx(3)
|
||||
# Written once by m2 at the 1h rate (2x input $4/MTok) ...
|
||||
assert call.cost.create_usd == pytest.approx(100 * 2.0 * 4 / 1e6)
|
||||
# ... then cache-read by m2 and m3 at $0.20/MTok.
|
||||
assert call.cost.carry_usd == pytest.approx(100 * 2 * 0.2 / 1e6)
|
||||
assert call.cost.carry_calls == 2
|
||||
assert call.where.model == "claude-opus-5-5"
|
||||
|
||||
|
||||
def test_compaction_stops_the_carry(tmp_path: Path) -> None:
|
||||
boundary = {
|
||||
"type": "system",
|
||||
"subtype": "compact_boundary",
|
||||
"timestamp": iso(4),
|
||||
"compactMetadata": {
|
||||
"trigger": "auto",
|
||||
"preTokens": 100000,
|
||||
"postTokens": 9000,
|
||||
"durationMs": 5000,
|
||||
},
|
||||
}
|
||||
read = use("t1", "Read", file_path="/a")
|
||||
rows = walk_detail(
|
||||
tmp_path,
|
||||
[
|
||||
prompt("go", 0),
|
||||
assistant("m1", 1, blocks=[read], context=100000),
|
||||
result("t1", 2, "x" * 398),
|
||||
assistant("m2", 3, context=100000),
|
||||
boundary,
|
||||
assistant("m3", 5, context=9000),
|
||||
],
|
||||
)
|
||||
assert rows.calls[0].cost.carry_calls == 1
|
||||
(mark,) = [m for m in rows.marks if m.kind == "compaction"]
|
||||
assert (mark.name, mark.tokens, mark.extra) == ("auto", 100000, 9000)
|
||||
assert mark.duration_ms == 5000
|
||||
assert mark.cost.carry_usd == 0 # the summary call is not context
|
||||
|
||||
|
||||
def test_batch_errors_images_and_signatures(tmp_path: Path) -> None:
|
||||
image = {"type": "image", "source": {"type": "base64", "data": "A" * 9000}}
|
||||
blocks = [use("a", "Bash", command="ls"), use("b", "mcp__chrome__shot")]
|
||||
failed = {
|
||||
"type": "tool_result",
|
||||
"tool_use_id": "a",
|
||||
"content": "Exit code 2",
|
||||
"is_error": True,
|
||||
}
|
||||
shot = {
|
||||
"type": "tool_result",
|
||||
"tool_use_id": "b",
|
||||
"content": [image, {"type": "text", "text": "ok"}],
|
||||
}
|
||||
rows = walk_detail(
|
||||
tmp_path,
|
||||
[
|
||||
prompt("go", 0),
|
||||
assistant("m1", 1, blocks=blocks[:1]),
|
||||
assistant("m1", 1, blocks=blocks[1:]),
|
||||
{"type": "user", "timestamp": iso(2), "message": {"content": [failed]}},
|
||||
{"type": "user", "timestamp": iso(3), "message": {"content": [shot]}},
|
||||
assistant("m2", 4),
|
||||
],
|
||||
)
|
||||
by_name = {c.name: c for c in rows.calls}
|
||||
assert {c.batch for c in rows.calls} == {2}
|
||||
assert by_name["Bash"].error_kind == "exit_code"
|
||||
found = by_name["mcp__chrome__shot"]
|
||||
assert (found.family, found.target, found.images) == ("mcp:chrome", "shot", 1)
|
||||
assert found.error_kind == ""
|
||||
assert found.result_tokens < 3000 # an image is priced by area, not base64
|
||||
assert by_name["Bash"].sig != found.sig
|
||||
|
||||
|
||||
def test_turn_detail_latency_cache_split_and_output_split(tmp_path: Path) -> None:
|
||||
text = {"type": "text", "text": "y" * 400} # ~100 tokens of reply
|
||||
thinking = {"type": "thinking", "thinking": ""}
|
||||
rows = walk_detail(
|
||||
tmp_path,
|
||||
[
|
||||
prompt("go", 0),
|
||||
assistant("m1", 7, **ONE_HOUR_WRITE, blocks=[thinking]),
|
||||
assistant("m1", 8, blocks=[text]),
|
||||
assistant("m2", 9, output=10, blocks=[text]),
|
||||
assistant("m3", 10, output=50, blocks=[{"type": "server_tool_use"}]),
|
||||
assistant("m4", 11, output=40, blocks=[thinking]),
|
||||
],
|
||||
)
|
||||
first, second, third, fourth = rows.turns
|
||||
assert first.latency_s == pytest.approx(7)
|
||||
assert (first.cw_1h, first.cw_5m) == (1000, 0)
|
||||
assert (first.out_text, first.out_tool, first.out_think) == (100, 0, 900)
|
||||
# More visible text than billed output: scaled down, no thinking left.
|
||||
assert (second.out_text, second.out_think) == (10, 0)
|
||||
assert second.latency_s == pytest.approx(1)
|
||||
# Nothing visible: all of it is text, or thinking when a block says so.
|
||||
assert (third.out_text, third.out_think) == (50, 0)
|
||||
assert (fourth.out_text, fourth.out_think) == (0, 40)
|
||||
|
||||
|
||||
def test_text_and_tool_output_without_thinking_split_by_length(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
blocks = [{"type": "text", "text": "y" * 400}, use("t", "Bash", command="z" * 385)]
|
||||
rows = walk_detail(
|
||||
tmp_path, [prompt("go", 0), assistant("m1", 1, output=1000, blocks=blocks)]
|
||||
)
|
||||
(turn,) = rows.turns
|
||||
assert (turn.out_text, turn.out_tool, turn.out_think) == (500, 500, 0)
|
||||
assert turn.tool_calls == 1
|
||||
|
||||
|
||||
def test_write_multiplier_blends_the_split() -> None:
|
||||
assert detail.write_multiplier({}) == pytest.approx(1.25)
|
||||
half = {"cache_creation_input_tokens": 100, "cache_creation_1h": 50}
|
||||
assert detail.write_multiplier(half) == pytest.approx((50 * 2 + 50 * 1.25) / 100)
|
||||
|
||||
|
||||
def test_result_for_an_unknown_call_is_ignored() -> None:
|
||||
found = detail.Detail()
|
||||
found.result({"tool_use_id": "nope", "content": "x"}, 1.0)
|
||||
assert found.rows.calls == []
|
||||
198
python_pkg/token_dashboard/tests/test_detail_marks.py
Normal file
198
python_pkg/token_dashboard/tests/test_detail_marks.py
Normal file
@ -0,0 +1,198 @@
|
||||
"""Context marks: hooks, injected attachments, API errors, interruptions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pytest
|
||||
|
||||
from python_pkg.token_dashboard.tests.conftest import (
|
||||
BASE,
|
||||
assistant,
|
||||
attach,
|
||||
iso,
|
||||
prompt,
|
||||
result,
|
||||
use,
|
||||
walk_detail,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
CTX = "cd chain wrapped in a subshell"
|
||||
|
||||
|
||||
def _hook_records() -> list[dict[str, object]]:
|
||||
run = {"type": "hook_success", "hookName": "PreToolUse:Bash"}
|
||||
run |= {"hookEvent": "PreToolUse", "toolUseID": "t1", "content": ""}
|
||||
stop = {"blockingError": "no status tag", "command": "python3 /h/tag_stop.py"}
|
||||
return [
|
||||
attach(
|
||||
0,
|
||||
type="hook_success",
|
||||
hookName="SessionStart:startup",
|
||||
hookEvent="SessionStart",
|
||||
toolUseID="s",
|
||||
content="x" * 400,
|
||||
stdout="x" * 400,
|
||||
command="bash /h/.claude/hooks/start.sh",
|
||||
durationMs=30,
|
||||
),
|
||||
prompt("go", 1),
|
||||
assistant("m1", 2, blocks=[use("t1", "Bash", command="cd /x && ls")]),
|
||||
attach(
|
||||
3,
|
||||
type="hook_additional_context",
|
||||
hookName="PreToolUse:Bash",
|
||||
hookEvent="PreToolUse",
|
||||
toolUseID="t1",
|
||||
content=[CTX],
|
||||
),
|
||||
attach(
|
||||
3,
|
||||
**run,
|
||||
stdout='{"additionalContext":"' + CTX + '"}',
|
||||
command="bash /h/.claude/hooks/cd_chain.sh",
|
||||
durationMs=15,
|
||||
),
|
||||
attach(3, **run, stdout="{}", command="bash /h/other.sh", durationMs="7"),
|
||||
result("t1", 4),
|
||||
assistant("m2", 5),
|
||||
attach(
|
||||
6,
|
||||
type="hook_blocking_error",
|
||||
hookName="Stop",
|
||||
toolUseID="z",
|
||||
blockingError=stop,
|
||||
),
|
||||
assistant("m3", 7, output=500),
|
||||
assistant("m4", 8, output=500),
|
||||
prompt("next", 20),
|
||||
assistant("m5", 21),
|
||||
attach(22, type="hook_system_message", hookName="UserPromptSubmit"),
|
||||
attach(
|
||||
22, type="hook_non_blocking_error", hookName="PostToolUse", content="oops"
|
||||
),
|
||||
attach(22, type="nested_memory", path="/r/CLAUDE.md", content={"c": "z"}),
|
||||
attach(22, type="prompt_snapshot", systemPrompt=["huge"]),
|
||||
]
|
||||
|
||||
|
||||
def test_hooks_are_marked_matched_and_their_retry_priced(tmp_path: Path) -> None:
|
||||
rows = walk_detail(tmp_path, _hook_records())
|
||||
marks = {(m.kind, m.detail): m for m in rows.marks}
|
||||
start = marks[("hook_run", "start.sh")]
|
||||
assert (start.tokens, start.duration_ms, start.where.prompt_idx) == (100, 30, -1)
|
||||
assert start.cost.carry_usd > 0
|
||||
assert start.where.model == "claude-opus-5-5" # filled from the first turn
|
||||
assert marks[("hook_context", "cd_chain.sh")].tokens == len(CTX) // 4
|
||||
assert marks[("hook_run", "other.sh")].duration_ms == 7
|
||||
assert marks[("hook_run", "cd_chain.sh")].tokens == 0 # JSON is not injected
|
||||
bounce = marks[("hook_block", "tag_stop.py")]
|
||||
assert bounce.name == "Stop"
|
||||
assert bounce.retry_usd == pytest.approx(2 * 500 * 20 / 1e6) # m3 + m4 only
|
||||
assert ("hook_msg", "") in marks
|
||||
assert marks[("hook_error", "")].tokens == 1
|
||||
assert marks[("attach", "/r/CLAUDE.md")].name == "nested_memory"
|
||||
assert not [m for m in rows.marks if m.name == "prompt_snapshot"]
|
||||
|
||||
|
||||
def test_pretool_block_retry_is_only_the_next_call(tmp_path: Path) -> None:
|
||||
rows = walk_detail(
|
||||
tmp_path,
|
||||
[
|
||||
prompt("go", 0),
|
||||
assistant("m1", 1),
|
||||
attach(
|
||||
2,
|
||||
type="hook_blocking_error",
|
||||
hookName="PreToolUse:Bash",
|
||||
toolUseID="q",
|
||||
blockingError="denied",
|
||||
),
|
||||
assistant("m2", 3, output=100),
|
||||
assistant("m3", 4, output=100),
|
||||
],
|
||||
)
|
||||
(block,) = rows.marks
|
||||
assert (block.detail, block.tokens) == ("", 1)
|
||||
assert block.retry_usd == pytest.approx(100 * 20 / 1e6)
|
||||
|
||||
|
||||
def test_api_errors_interruptions_and_pasted_images(tmp_path: Path) -> None:
|
||||
pasted = {"type": "image", "source": {"type": "base64", "data": "A"}}
|
||||
limit = {"isApiErrorMessage": True, "error": "rate_limit"}
|
||||
limit |= {"apiError": "usage_limit_reached"}
|
||||
text = [{"type": "text", "text": "Overloaded"}]
|
||||
rows = walk_detail(
|
||||
tmp_path,
|
||||
[
|
||||
{
|
||||
"type": "user",
|
||||
"timestamp": iso(0),
|
||||
"message": {"content": [{"type": "text", "text": "look"}, pasted]},
|
||||
},
|
||||
{"type": "assistant", "timestamp": iso(1), **limit, "message": {}},
|
||||
{
|
||||
"type": "assistant",
|
||||
"timestamp": iso(2),
|
||||
"isApiErrorMessage": True,
|
||||
"message": {"content": text},
|
||||
},
|
||||
{
|
||||
"type": "user",
|
||||
"timestamp": iso(3),
|
||||
"message": {
|
||||
"content": [
|
||||
{"type": "text", "text": "[Request interrupted by user]"}
|
||||
]
|
||||
},
|
||||
},
|
||||
assistant("m1", 4),
|
||||
],
|
||||
)
|
||||
kinds = sorted((m.kind, m.name, m.detail) for m in rows.marks)
|
||||
assert kinds == [
|
||||
("api_error", "rate_limit", "usage_limit_reached"),
|
||||
("api_error", "unknown", "Overloaded"),
|
||||
("image", "user", ""),
|
||||
("interrupt", "user", ""),
|
||||
]
|
||||
assert rows.marks[0].where.ts == pytest.approx(BASE)
|
||||
assert rows.marks[0].tokens > 0
|
||||
|
||||
|
||||
def test_repeats_merge_and_odd_records_are_tolerated(tmp_path: Path) -> None:
|
||||
reminder = {"type": "total_tokens_reminder", "left": 5}
|
||||
odd = {"hookName": "PostToolUse", "toolUseID": "u"}
|
||||
rows = walk_detail(
|
||||
tmp_path,
|
||||
[
|
||||
prompt("go", 0),
|
||||
attach(1, **reminder),
|
||||
attach(2, **reminder),
|
||||
attach(
|
||||
2,
|
||||
type="hook_success",
|
||||
**odd,
|
||||
content="",
|
||||
stdout="",
|
||||
command="",
|
||||
durationMs="n/a",
|
||||
),
|
||||
attach(2, type="hook_additional_context", **odd, content=""),
|
||||
attach(2, type="hook_additional_context", **odd, content="unmatched"),
|
||||
attach(2, type="hook_cancelled", **odd),
|
||||
{"type": "system", "subtype": "turn_duration", "timestamp": iso(2)},
|
||||
assistant("m1", 3, blocks=[{"type": "server_tool_use", "id": "w"}]),
|
||||
],
|
||||
)
|
||||
(merged,) = [m for m in rows.marks if m.name == "total_tokens_reminder"]
|
||||
assert merged.count == 2
|
||||
assert merged.where.ts == pytest.approx(BASE + 1)
|
||||
assert merged.cost.carry_calls == 2
|
||||
run = next(m for m in rows.marks if m.kind == "hook_run")
|
||||
assert (run.duration_ms, run.detail) == (0, "")
|
||||
contexts = [m for m in rows.marks if m.kind == "hook_context"]
|
||||
assert {m.detail for m in contexts} == {""}
|
||||
79
python_pkg/token_dashboard/tests/test_detail_store.py
Normal file
79
python_pkg/token_dashboard/tests/test_detail_store.py
Normal file
@ -0,0 +1,79 @@
|
||||
"""The detail tables are written per file, replaced on re-walk, filterable."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import closing
|
||||
import sqlite3
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from python_pkg.token_dashboard import ingest
|
||||
from python_pkg.token_dashboard.tests.conftest import (
|
||||
assistant,
|
||||
iso,
|
||||
prompt,
|
||||
result,
|
||||
use,
|
||||
write,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
COUNTS = (
|
||||
"SELECT (SELECT COUNT(*) FROM calls), (SELECT COUNT(*) FROM marks),"
|
||||
" (SELECT COUNT(*) FROM turn_detail)"
|
||||
)
|
||||
|
||||
|
||||
def _records(extra: int) -> list[dict[str, object]]:
|
||||
hook = {
|
||||
"type": "attachment",
|
||||
"timestamp": iso(1),
|
||||
"attachment": {"type": "hook_system_message", "hookName": "Stop"},
|
||||
}
|
||||
return [
|
||||
prompt("go", 0),
|
||||
hook,
|
||||
assistant("m1", 2, blocks=[use("t1", "Bash", command="ls")]),
|
||||
result("t1", 3),
|
||||
*[assistant(f"x{i}", 4 + i) for i in range(extra)],
|
||||
]
|
||||
|
||||
|
||||
def _ingest(tmp_path: Path) -> closing[sqlite3.Connection]:
|
||||
ingest.run(
|
||||
root=tmp_path / "projects",
|
||||
db_path=tmp_path / "t.sqlite",
|
||||
report_dir=tmp_path / "reports",
|
||||
budget_s=30.0,
|
||||
)
|
||||
return closing(sqlite3.connect(tmp_path / "t.sqlite"))
|
||||
|
||||
|
||||
def test_rewalk_replaces_detail_rows(tmp_path: Path) -> None:
|
||||
path = tmp_path / "projects" / "proj" / "s1.jsonl"
|
||||
write(path, _records(1))
|
||||
with _ingest(tmp_path) as conn:
|
||||
assert conn.execute(COUNTS).fetchone() == (1, 1, 2)
|
||||
write(path, _records(2))
|
||||
with _ingest(tmp_path) as conn:
|
||||
assert conn.execute(COUNTS).fetchone() == (1, 1, 3)
|
||||
|
||||
|
||||
def test_detail_rows_carry_the_shared_filter_columns(tmp_path: Path) -> None:
|
||||
write(tmp_path / "projects" / "proj" / "s1.jsonl", _records(1))
|
||||
with _ingest(tmp_path) as conn:
|
||||
call = conn.execute(
|
||||
"SELECT key, main_key, is_sub, cwd, model, day, family, target, batch,"
|
||||
" carry_calls FROM calls"
|
||||
).fetchone()
|
||||
mark = conn.execute(
|
||||
"SELECT key, main_key, is_sub, cwd, model, kind, name, count FROM marks"
|
||||
).fetchone()
|
||||
turn = conn.execute(
|
||||
"SELECT key, is_sub, cwd, model, latency_s FROM turn_detail ORDER BY ts"
|
||||
).fetchone()
|
||||
assert call[:5] == ("s1", "s1", 0, "/repo", "claude-opus-5-5")
|
||||
assert call[6:] == ("ls", "ls", 1, 1)
|
||||
assert mark == ("s1", "s1", 0, "/repo", "claude-opus-5-5", "hook_msg", "Stop", 1)
|
||||
assert turn == ("s1", 0, "/repo", "claude-opus-5-5", 1.0)
|
||||
146
python_pkg/token_dashboard/tests/test_efficiency.py
Normal file
146
python_pkg/token_dashboard/tests/test_efficiency.py
Normal file
@ -0,0 +1,146 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
|
||||
from python_pkg.token_dashboard import efficiency, gitcmd, survival
|
||||
|
||||
DAY = 86400.0
|
||||
MONDAY = datetime(2026, 10, 5, 12, tzinfo=UTC).timestamp()
|
||||
|
||||
|
||||
def _s(ts: float, usd: float, tag: str | None) -> efficiency.SessionCost:
|
||||
return efficiency.SessionCost(key=f"k{ts}{tag}", first_ts=ts, usd=usd, tag=tag)
|
||||
|
||||
|
||||
def test_cost_per_done_counts_failed_sessions_against_done_ones() -> None:
|
||||
rows = [
|
||||
_s(MONDAY, 10.0, "DONE"),
|
||||
_s(MONDAY + 1, 6.0, "QUESTION"),
|
||||
_s(MONDAY + 2, 4.0, "DONE"),
|
||||
_s(MONDAY + 3, 99.0, None), # pre-convention: excluded from the ratio
|
||||
]
|
||||
(week,) = efficiency.done_weekly(rows)
|
||||
assert week["done"] == 2
|
||||
assert week["other"] == 1
|
||||
assert week["untagged"] == 1
|
||||
assert week["usd_per_done"] == pytest.approx(10.0)
|
||||
assert week["done_share"] == pytest.approx(2 / 3)
|
||||
|
||||
|
||||
def test_week_with_no_done_has_no_ratio() -> None:
|
||||
(week,) = efficiency.done_weekly([_s(MONDAY, 5.0, "RUNNING")])
|
||||
assert week["usd_per_done"] is None
|
||||
|
||||
|
||||
def test_cost_per_surviving_commit_and_pending() -> None:
|
||||
week_usd = {efficiency.week_of(MONDAY): 30.0}
|
||||
verdicts = [
|
||||
(MONDAY, "survived"),
|
||||
(MONDAY, "survived"),
|
||||
(MONDAY, "churned"),
|
||||
(MONDAY, "pending"),
|
||||
]
|
||||
(week,) = efficiency.commits_weekly(verdicts, week_usd)
|
||||
assert (week["survived"], week["lost"], week["pending"]) == (2, 1, 1)
|
||||
assert week["usd_per_survivor"] == pytest.approx(15.0)
|
||||
|
||||
|
||||
def test_cost_per_estimated_unit() -> None:
|
||||
rows = [_s(MONDAY, 8.0, None), _s(MONDAY + 5, 2.0, "DONE")]
|
||||
estimates = {rows[0].key: 30, rows[1].key: 10}
|
||||
(week,) = efficiency.estimate_weekly(rows, estimates)
|
||||
assert week["est_calls"] == 40
|
||||
assert week["usd_per_unit"] == pytest.approx(0.25)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("added", "kept", "reverted", "expected"),
|
||||
[
|
||||
(10, 10, False, "survived"),
|
||||
(10, 5, False, "survived"),
|
||||
(10, 4, False, "churned"),
|
||||
(10, 10, True, "reverted"),
|
||||
(0, 0, False, "survived"),
|
||||
],
|
||||
)
|
||||
def test_survival_rule(added: int, kept: int, *, reverted: bool, expected: str) -> None:
|
||||
assert survival.judge(added, kept, reverted=reverted) == expected
|
||||
|
||||
|
||||
def _git(repo: Path, *args: str, when: float) -> str:
|
||||
stamp = f"@{int(when)} +0000"
|
||||
env = {**os.environ, "GIT_AUTHOR_DATE": stamp, "GIT_COMMITTER_DATE": stamp}
|
||||
env |= {"GIT_AUTHOR_NAME": "t", "GIT_AUTHOR_EMAIL": "t@t", "HOME": str(repo)}
|
||||
env |= {"GIT_COMMITTER_NAME": "t", "GIT_COMMITTER_EMAIL": "t@t"}
|
||||
done = subprocess.run(
|
||||
[gitcmd.GIT, "-C", str(repo), *args],
|
||||
env=env,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
return done.stdout.strip()
|
||||
|
||||
|
||||
def _commit(repo: Path, name: str, text: str, when: float) -> str:
|
||||
(repo / name).write_text(text, encoding="utf-8")
|
||||
_git(repo, "add", name, when=when)
|
||||
_git(repo, "commit", "-qm", name, when=when)
|
||||
return _git(repo, "rev-parse", "HEAD", when=when)
|
||||
|
||||
|
||||
def test_git_survival_against_a_real_repo(tmp_path: Path) -> None:
|
||||
repo = tmp_path / "r"
|
||||
repo.mkdir()
|
||||
_git(repo, "init", "-q", when=MONDAY)
|
||||
t0 = MONDAY - 40 * DAY
|
||||
kept = _commit(repo, "a.txt", "1\n2\n3\n4\n", t0)
|
||||
churn = _commit(repo, "b.txt", "1\n2\n3\n4\n", t0 + 1)
|
||||
_commit(repo, "b.txt", "x\ny\nz\n4\n", t0 + 2 * DAY)
|
||||
young = _commit(repo, "c.txt", "1\n", MONDAY)
|
||||
now = MONDAY + 1
|
||||
assert survival.evaluate(str(repo), kept[:8], now).verdict == "survived"
|
||||
churned = survival.evaluate(str(repo), churn, now)
|
||||
assert (churned.verdict, churned.added, churned.kept) == ("churned", 4, 1)
|
||||
assert survival.evaluate(str(repo), young, now).verdict == "pending"
|
||||
assert survival.evaluate(str(repo), "deadbeef", now).verdict == "unknown"
|
||||
assert survival.evaluate(str(tmp_path / "nope"), kept, now).verdict == "unknown"
|
||||
|
||||
|
||||
def test_git_revert_within_window_is_reverted(tmp_path: Path) -> None:
|
||||
repo = tmp_path / "r"
|
||||
repo.mkdir()
|
||||
_git(repo, "init", "-q", when=MONDAY)
|
||||
t0 = MONDAY - 40 * DAY
|
||||
_commit(repo, "a.txt", "base\n", t0 - 10)
|
||||
bad = _commit(repo, "b.txt", "1\n", t0)
|
||||
_git(repo, "revert", "--no-edit", bad, when=t0 + DAY)
|
||||
assert survival.evaluate(str(repo), bad, MONDAY).verdict == "reverted"
|
||||
|
||||
|
||||
def test_update_judges_old_commits_and_stops_at_the_deadline(tmp_path: Path) -> None:
|
||||
from python_pkg.token_dashboard import schema
|
||||
|
||||
repo = tmp_path / "r"
|
||||
repo.mkdir()
|
||||
_git(repo, "init", "-q", when=MONDAY)
|
||||
old = _commit(repo, "a.txt", "1\n", MONDAY - 40 * DAY)
|
||||
conn = schema.connect(tmp_path / "t.sqlite")
|
||||
rows = [
|
||||
(old, str(repo), MONDAY - 40 * DAY, "s", 0),
|
||||
("f" * 40, "x", MONDAY, "s", 0),
|
||||
]
|
||||
conn.executemany("INSERT INTO session_commits VALUES (?,?,?,?,?)", rows)
|
||||
assert not survival.update(conn, MONDAY, deadline=0.0, clock=lambda: 1.0)
|
||||
assert survival.update(conn, MONDAY, deadline=2.0, clock=lambda: 1.0)
|
||||
efficiency.rebuild(conn, MONDAY)
|
||||
verdicts = conn.execute("SELECT verdict FROM commit_verdicts").fetchall()
|
||||
payload = conn.execute("SELECT value FROM meta WHERE key='efficiency'").fetchone()
|
||||
conn.close()
|
||||
assert verdicts == [("survived",)]
|
||||
assert '"pending": 1' in payload[0]
|
||||
54
python_pkg/token_dashboard/tests/test_gaps_git.py
Normal file
54
python_pkg/token_dashboard/tests/test_gaps_git.py
Normal file
@ -0,0 +1,54 @@
|
||||
"""Git-backed edge cases: caching, binary files, commits outside HEAD."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from python_pkg.token_dashboard import commits, survival
|
||||
from python_pkg.token_dashboard.tests.test_efficiency import DAY, MONDAY, _commit, _git
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _repo(tmp_path: Path) -> Path:
|
||||
repo = tmp_path / "r"
|
||||
repo.mkdir()
|
||||
_git(repo, "init", "-q", when=MONDAY)
|
||||
return repo
|
||||
|
||||
|
||||
def test_toplevel_is_resolved_once_per_hint(tmp_path: Path) -> None:
|
||||
repo = _repo(tmp_path)
|
||||
_commit(repo, "a", "1", MONDAY)
|
||||
cache: dict[str, str | None] = {}
|
||||
first = commits._toplevel(str(repo), cache)
|
||||
assert first == str(repo.resolve())
|
||||
cache[str(repo)] = "cached"
|
||||
assert commits._toplevel(str(repo), cache) == "cached"
|
||||
assert commits._toplevel("", cache) is None
|
||||
|
||||
|
||||
def test_binary_and_empty_files_do_not_count_as_added_lines(tmp_path: Path) -> None:
|
||||
repo = _repo(tmp_path)
|
||||
old = MONDAY - 40 * DAY
|
||||
(repo / "blob.bin").write_bytes(b"\x00\x01\x02")
|
||||
(repo / "empty.txt").write_text("", encoding="utf-8")
|
||||
(repo / "t.txt").write_text("1\n2\n", encoding="utf-8")
|
||||
_git(repo, "add", ".", when=old)
|
||||
_git(repo, "commit", "-qm", "mixed", when=old)
|
||||
sha = _git(repo, "rev-parse", "HEAD", when=old)
|
||||
found = survival.evaluate(str(repo), sha, MONDAY)
|
||||
assert (found.verdict, found.added, found.kept) == ("survived", 2, 2)
|
||||
|
||||
|
||||
def test_a_commit_that_is_not_an_ancestor_of_head_is_unknown(tmp_path: Path) -> None:
|
||||
repo = _repo(tmp_path)
|
||||
old = MONDAY - 40 * DAY
|
||||
_commit(repo, "a", "1", old)
|
||||
trunk = _git(repo, "branch", "--show-current", when=old)
|
||||
_git(repo, "checkout", "-qb", "side", when=old)
|
||||
side = _commit(repo, "b", "2", old + 1)
|
||||
_git(repo, "checkout", "-q", trunk, when=old)
|
||||
found = survival.evaluate(str(repo), side, MONDAY)
|
||||
assert (found.verdict, found.sha) == ("unknown", side)
|
||||
116
python_pkg/token_dashboard/tests/test_gaps_ingest.py
Normal file
116
python_pkg/token_dashboard/tests/test_gaps_ingest.py
Normal file
@ -0,0 +1,116 @@
|
||||
"""Edge cases of the walker, the summaries and the aux readers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from python_pkg.token_dashboard import ab, context_files, finalize, ingest, schema, walk
|
||||
from python_pkg.token_dashboard.tests.conftest import (
|
||||
assistant,
|
||||
prompt,
|
||||
result,
|
||||
use,
|
||||
write,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def test_timestampless_orphans_and_garbage_lines_are_tolerated(tmp_path: Path) -> None:
|
||||
path = tmp_path / "proj" / "s.jsonl"
|
||||
path.parent.mkdir()
|
||||
lines = ['{"type": "summary", "summary": "x"}', "{broken", ""]
|
||||
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
facts = walk.walk_file(path, tmp_path)
|
||||
assert facts.span == (0.0, 0.0)
|
||||
assert facts.rows.turns == []
|
||||
assert facts.final_tag is None
|
||||
|
||||
|
||||
def test_a_result_without_its_call_is_ignored(tmp_path: Path) -> None:
|
||||
path = write(tmp_path / "proj" / "s.jsonl", [prompt("go", 0), result("nope", 1)])
|
||||
facts = walk.walk_file(path, tmp_path)
|
||||
assert facts.rows.tools == []
|
||||
|
||||
|
||||
def test_a_nested_subagent_resolves_to_the_prompt_that_started_the_chain(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
root = tmp_path / "projects"
|
||||
outer = use("ta", "Agent", description="outer")
|
||||
inner = use("tb", "Agent", description="inner")
|
||||
write(
|
||||
root / "p" / "s1.jsonl",
|
||||
[
|
||||
assistant("m0", 0), # a turn before any prompt
|
||||
prompt("go", 5),
|
||||
assistant("m1", 10, blocks=[outer]),
|
||||
result("ta", 11, "ok", toolUseResult={"agentId": "aaa"}),
|
||||
],
|
||||
)
|
||||
subs = root / "p" / "s1" / "subagents"
|
||||
write(
|
||||
subs / "agent-aaa.jsonl",
|
||||
[
|
||||
assistant("a1", 20, blocks=[inner]),
|
||||
result("tb", 21, toolUseResult={"agentId": "bbb"}),
|
||||
],
|
||||
)
|
||||
write(subs / "agent-bbb.jsonl", [assistant("b1", 30)])
|
||||
ingest.run(root, tmp_path / "t.sqlite", tmp_path / "report", 30.0)
|
||||
conn = schema.connect(tmp_path / "t.sqlite")
|
||||
links = conn.execute(
|
||||
"SELECT sub_key, prompt_idx, depth FROM subagent_link ORDER BY depth"
|
||||
).fetchall()
|
||||
first = conn.execute(
|
||||
"SELECT idx, text FROM prompt_summary WHERE key='s1' ORDER BY idx"
|
||||
).fetchone()
|
||||
conn.close()
|
||||
assert links == [("s1/agent-aaa", 0, 1), ("s1/agent-bbb", 0, 2)]
|
||||
assert first == (-1, "(before the first prompt)")
|
||||
|
||||
|
||||
def test_refreshing_an_unknown_session_changes_nothing(tmp_path: Path) -> None:
|
||||
conn = schema.connect(tmp_path / "t.sqlite")
|
||||
finalize.refresh(conn, "ghost")
|
||||
assert conn.execute("SELECT COUNT(*) FROM session_summary").fetchone() == (0,)
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_unreadable_prune_sizes_are_skipped(tmp_path: Path) -> None:
|
||||
claude, src = tmp_path / "claude", tmp_path / "src"
|
||||
prune = claude / "token-report" / "prune"
|
||||
for name, body in (
|
||||
("a", "{broken"),
|
||||
("b", "[1]"),
|
||||
("c", '{"file_bytes": {"X.md": 9}}'),
|
||||
):
|
||||
(prune / name).mkdir(parents=True)
|
||||
(prune / name / "sizes.json").write_text(body, encoding="utf-8")
|
||||
conn = schema.connect(tmp_path / "t.sqlite")
|
||||
context_files.refresh(conn, claude, src)
|
||||
sources = conn.execute("SELECT source FROM context_sizes").fetchall()
|
||||
conn.close()
|
||||
assert sources == [("prune c",)]
|
||||
|
||||
|
||||
def test_unmatched_and_malformed_assignments_are_counted_unjoined(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
log = tmp_path / "agent-ab.jsonl"
|
||||
rows = [
|
||||
{"tool_use_id": "ghost", "arm": "sonnet"},
|
||||
{"tool_use_id": "t1", "arm": "haiku"},
|
||||
{"tool_use_id": "t2"},
|
||||
[1],
|
||||
]
|
||||
log.write_text("\n".join(json.dumps(r) for r in rows) + "\n{broken\n")
|
||||
conn = schema.connect(tmp_path / "t.sqlite")
|
||||
payload = ab.rebuild(conn, log)
|
||||
default = ab.rebuild(conn) # the isolated, absent default log
|
||||
conn.close()
|
||||
assert payload["unjoined"] == 2
|
||||
assert payload["arms"]["sonnet"]["n"] == 0
|
||||
assert default["unjoined"] == 0
|
||||
94
python_pkg/token_dashboard/tests/test_gaps_units.py
Normal file
94
python_pkg/token_dashboard/tests/test_gaps_units.py
Normal file
@ -0,0 +1,94 @@
|
||||
"""Edge cases of the small pure helpers: blocks, gitcmd, levers, schema, efficiency."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
import subprocess
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pytest
|
||||
|
||||
from python_pkg.token_dashboard import blocks, efficiency, gitcmd, levers, schema
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def test_text_of_joins_text_blocks_and_ignores_the_rest() -> None:
|
||||
content = [
|
||||
{"type": "text", "text": "one"},
|
||||
{"type": "image"},
|
||||
"stray",
|
||||
{"type": "text", "text": "two"},
|
||||
]
|
||||
assert blocks.text_of(content) == "one\ntwo"
|
||||
assert blocks.text_of("plain") == "plain"
|
||||
assert blocks.text_of(None) == ""
|
||||
assert blocks.text_of({"type": "text", "text": "dict"}) == ""
|
||||
|
||||
|
||||
def test_compact_summaries_and_tool_results_are_not_prompts() -> None:
|
||||
message = {"role": "user", "content": "summary of the chat"}
|
||||
summary = {"type": "user", "isCompactSummary": True, "message": message}
|
||||
assert blocks.prompt_text(summary) is None
|
||||
visible = {"type": "user", "isVisibleInTranscriptOnly": True, "message": message}
|
||||
assert blocks.prompt_text(visible) is None
|
||||
tool = [{"type": "tool_result", "tool_use_id": "t", "content": "x"}]
|
||||
reply = {"type": "user", "message": {"content": tool}}
|
||||
assert blocks.prompt_text(reply) is None
|
||||
|
||||
|
||||
def test_repo_hint_falls_back_to_cwd_and_resolves_relative_targets() -> None:
|
||||
assert blocks.repo_hint("git commit -m x", None) == ""
|
||||
assert blocks.repo_hint("git commit -m x", "/work/r") == "/work/r"
|
||||
assert blocks.repo_hint("cd sub && git commit", "/work/r") == "/work/r/sub"
|
||||
assert blocks.repo_hint("git -C /abs/repo commit", "/work/r") == "/abs/repo"
|
||||
|
||||
|
||||
def test_lever_table_needs_the_heading_and_at_least_one_row() -> None:
|
||||
heading = f"## {blocks.LEVER_HEADING}\n\n"
|
||||
assert blocks.lever_table("nothing here") is None
|
||||
assert blocks.lever_table(heading + "just prose") is None
|
||||
assert (
|
||||
blocks.lever_table(heading + "| a | b |\n| c | d |") == "| a | b |\n| c | d |"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"error", [OSError("no git"), subprocess.TimeoutExpired("git", 1)]
|
||||
)
|
||||
def test_git_run_never_raises(
|
||||
error: Exception, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
def boom(*_args: object, **_kwargs: object) -> None:
|
||||
raise error
|
||||
|
||||
monkeypatch.setattr("python_pkg.token_dashboard.gitcmd.subprocess.run", boom)
|
||||
assert gitcmd.run(tmp_path, "status") is None
|
||||
|
||||
|
||||
def test_unreadable_or_non_object_snapshots_are_skipped(tmp_path: Path) -> None:
|
||||
history = tmp_path / "history"
|
||||
history.mkdir()
|
||||
(history / "2026-10-01.json").write_text("{broken")
|
||||
(history / "2026-10-02.json").write_text(json.dumps([1, 2]))
|
||||
(history / "2026-10-03.json").write_text(json.dumps({"metrics": {}}))
|
||||
assert levers._snapshots(tmp_path) == [("2026-10-03", {"metrics": {}})]
|
||||
|
||||
|
||||
def test_readonly_store_refuses_writes(tmp_path: Path) -> None:
|
||||
db = tmp_path / "t.sqlite"
|
||||
schema.connect(db).close()
|
||||
conn = schema.connect(db, readonly=True)
|
||||
try:
|
||||
assert conn.execute("SELECT COUNT(*) FROM turns").fetchone() == (0,)
|
||||
with pytest.raises(sqlite3.OperationalError, match="readonly"):
|
||||
conn.execute("INSERT INTO meta VALUES ('k', 'v')")
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_commits_with_an_unrecognised_verdict_count_as_unknown() -> None:
|
||||
(week,) = efficiency.commits_weekly([(1.0, "mystery")], {})
|
||||
assert (week["unknown"], week["survived"], week["lost"]) == (1, 0, 0)
|
||||
181
python_pkg/token_dashboard/tests/test_ingest.py
Normal file
181
python_pkg/token_dashboard/tests/test_ingest.py
Normal file
@ -0,0 +1,181 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pytest
|
||||
|
||||
from python_pkg.token_dashboard import ingest
|
||||
from python_pkg.token_dashboard.tests.conftest import (
|
||||
assistant,
|
||||
prompt,
|
||||
result,
|
||||
usd_opus_output,
|
||||
use,
|
||||
write,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable, Iterator
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _tree(root: Path) -> None:
|
||||
agent = use("ta", "Agent", description="Find x", subagent_type="Explore")
|
||||
write(
|
||||
root / "proj" / "s1.jsonl",
|
||||
[
|
||||
prompt("first", 0),
|
||||
assistant("m1", 10, blocks=[agent]),
|
||||
result("ta", 11, "launched", toolUseResult={"agentId": "abc"}),
|
||||
assistant("m2", 20),
|
||||
prompt("second", 1000),
|
||||
assistant("m3", 1010, blocks=[{"type": "text", "text": "DONE: x"}]),
|
||||
],
|
||||
)
|
||||
subdir = root / "proj" / "s1" / "subagents"
|
||||
write(subdir / "agent-abc.jsonl", [prompt("task", 12), assistant("a1", 200)])
|
||||
(subdir / "agent-abc.meta.json").write_text(
|
||||
json.dumps({"agentType": "Explore", "description": "Find x"}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
# An agent that outlived /clear: nothing in s1 spawned it.
|
||||
write(subdir / "agent-zzz.jsonl", [prompt("orphan", 30), assistant("z1", 40)])
|
||||
|
||||
|
||||
def _run(tmp_path: Path, budget: float = 30.0) -> ingest.Outcome:
|
||||
return ingest.run(
|
||||
root=tmp_path / "projects",
|
||||
db_path=tmp_path / "t.sqlite",
|
||||
report_dir=tmp_path / "report",
|
||||
budget_s=budget,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db(tmp_path: Path) -> Iterator[Callable[[], sqlite3.Connection]]:
|
||||
opened: list[sqlite3.Connection] = []
|
||||
|
||||
def _open() -> sqlite3.Connection:
|
||||
opened.append(sqlite3.connect(tmp_path / "t.sqlite"))
|
||||
return opened[-1]
|
||||
|
||||
yield _open
|
||||
for conn in opened:
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_session_cost_reconciles_to_prompts_plus_unattributed(
|
||||
tmp_path: Path, db: Callable[[], sqlite3.Connection]
|
||||
) -> None:
|
||||
_tree(tmp_path / "projects")
|
||||
outcome = _run(tmp_path)
|
||||
assert outcome.complete
|
||||
conn = db()
|
||||
usd, active_s, wall_s, tag = conn.execute(
|
||||
"SELECT usd, active_s, wall_s, final_tag FROM session_summary WHERE key='s1'"
|
||||
).fetchone()
|
||||
assert usd == pytest.approx(usd_opus_output(1000) * 5)
|
||||
prompts = conn.execute(
|
||||
"SELECT idx, usd_own + usd_sub FROM prompt_summary WHERE key='s1' ORDER BY idx"
|
||||
).fetchall()
|
||||
unattributed = conn.execute(
|
||||
"SELECT SUM(usd) FROM subagent_link WHERE main_key='s1' AND prompt_idx IS NULL"
|
||||
).fetchone()[0]
|
||||
assert sum(v for _, v in prompts) + unattributed == pytest.approx(usd)
|
||||
# Prompt 0 carries its own two turns plus the Explore agent it spawned.
|
||||
assert prompts[0][1] == pytest.approx(usd_opus_output(1000) * 3)
|
||||
assert wall_s == 1010
|
||||
# Union of main + subagent activity: 0..200 bridged, then 1000..1010.
|
||||
assert active_s == pytest.approx(210)
|
||||
assert tag == "DONE"
|
||||
|
||||
|
||||
def test_subagent_links_to_its_prompt_via_agent_id(
|
||||
tmp_path: Path, db: Callable[[], sqlite3.Connection]
|
||||
) -> None:
|
||||
_tree(tmp_path / "projects")
|
||||
_run(tmp_path)
|
||||
rows = dict(
|
||||
db().execute("SELECT sub_key, prompt_idx FROM subagent_link").fetchall()
|
||||
)
|
||||
assert rows == {"s1/agent-abc": 0, "s1/agent-zzz": None}
|
||||
|
||||
|
||||
def test_rerun_skips_unchanged_and_rows_outlive_deleted_files(
|
||||
tmp_path: Path, db: Callable[[], sqlite3.Connection]
|
||||
) -> None:
|
||||
_tree(tmp_path / "projects")
|
||||
_run(tmp_path)
|
||||
assert _run(tmp_path).parsed == 0
|
||||
(tmp_path / "projects" / "proj" / "s1.jsonl").unlink()
|
||||
_run(tmp_path)
|
||||
count = db().execute("SELECT COUNT(*) FROM turns").fetchone()[0]
|
||||
assert count == 5
|
||||
|
||||
|
||||
def test_changed_file_is_reparsed_without_duplicating_rows(
|
||||
tmp_path: Path, db: Callable[[], sqlite3.Connection]
|
||||
) -> None:
|
||||
_tree(tmp_path / "projects")
|
||||
_run(tmp_path)
|
||||
path = tmp_path / "projects" / "proj" / "s1.jsonl"
|
||||
with path.open("a", encoding="utf-8") as handle:
|
||||
handle.write(json.dumps(assistant("m4", 1020)) + "\n")
|
||||
assert _run(tmp_path).parsed == 1
|
||||
count = db().execute("SELECT COUNT(*) FROM turns").fetchone()[0]
|
||||
assert count == 6
|
||||
|
||||
|
||||
def test_zero_budget_leaves_work_pending_and_resumes(tmp_path: Path) -> None:
|
||||
_tree(tmp_path / "projects")
|
||||
first = _run(tmp_path, budget=0.0)
|
||||
assert not first.complete
|
||||
assert first.parsed == 1 # always makes progress
|
||||
rest = _run(tmp_path)
|
||||
assert rest.complete
|
||||
assert first.parsed + rest.parsed == 3
|
||||
|
||||
|
||||
def test_daily_rollup_matches_turns(
|
||||
tmp_path: Path, db: Callable[[], sqlite3.Connection]
|
||||
) -> None:
|
||||
_tree(tmp_path / "projects")
|
||||
_run(tmp_path)
|
||||
conn = db()
|
||||
total, calls, sub = conn.execute(
|
||||
"SELECT SUM(usd), SUM(calls), SUM(usd_sub) FROM daily"
|
||||
).fetchone()
|
||||
assert total == pytest.approx(usd_opus_output(1000) * 5)
|
||||
assert calls == 5
|
||||
assert sub == pytest.approx(usd_opus_output(1000) * 2)
|
||||
|
||||
|
||||
def test_cli_exit_codes(tmp_path: Path) -> None:
|
||||
_tree(tmp_path / "projects")
|
||||
args = [
|
||||
"--root",
|
||||
str(tmp_path / "projects"),
|
||||
"--db",
|
||||
str(tmp_path / "t.sqlite"),
|
||||
"--report-dir",
|
||||
str(tmp_path / "report"),
|
||||
]
|
||||
assert ingest.main([*args, "--budget", "0"]) == ingest.EXIT_PENDING
|
||||
assert ingest.main(args) == 0
|
||||
|
||||
|
||||
def test_session_with_no_prompts_and_no_main_file(
|
||||
tmp_path: Path, db: Callable[[], sqlite3.Connection]
|
||||
) -> None:
|
||||
sub = tmp_path / "projects" / "proj" / "gone" / "subagents" / "agent-q.jsonl"
|
||||
write(sub, [assistant("q1", 5), assistant("q2", 6)])
|
||||
assert _run(tmp_path).complete
|
||||
usd, prompts = (
|
||||
db()
|
||||
.execute("SELECT usd, prompts FROM session_summary WHERE key='gone'")
|
||||
.fetchone()
|
||||
)
|
||||
assert usd == pytest.approx(usd_opus_output(1000) * 2)
|
||||
assert prompts == 0
|
||||
114
python_pkg/token_dashboard/tests/test_levers.py
Normal file
114
python_pkg/token_dashboard/tests/test_levers.py
Normal file
@ -0,0 +1,114 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from python_pkg.token_dashboard import levers
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
TABLE = """## Candidate levers (rule-based)
|
||||
|
||||
| category | lever | evidence | est $/day | action |
|
||||
|---|---|---|---|---|
|
||||
| cheaper agents | Opus runs most subagent work | subagents 64% | $14.47 | route |
|
||||
| agent mechanics | Large Bash results | 40% of Bash | - | read ranges |
|
||||
|
||||
## Next
|
||||
"""
|
||||
|
||||
|
||||
def test_parse_candidate_table() -> None:
|
||||
rows = levers.parse_candidates(TABLE)
|
||||
assert [r["title"] for r in rows] == [
|
||||
"Opus runs most subagent work",
|
||||
"Large Bash results",
|
||||
]
|
||||
assert rows[0]["est_usd_per_day"] == 14.47
|
||||
assert rows[1]["est_usd_per_day"] is None
|
||||
assert rows[0]["metric"] == "subagent_opus_usd_share"
|
||||
|
||||
|
||||
def test_ledger_date_falls_back_to_text_then_unknown() -> None:
|
||||
assert levers.ledger_date({"date": "2026-10-04", "lever": "x"}) == "2026-10-04"
|
||||
assert levers.ledger_date({"lever": "weekly prefix prune 2026-10-05"}) == (
|
||||
"2026-10-05"
|
||||
)
|
||||
assert levers.ledger_date({"lever": "no date"}) is None
|
||||
|
||||
|
||||
def _report(tmp_path: Path) -> Path:
|
||||
report = tmp_path / "report"
|
||||
(report / "history").mkdir(parents=True)
|
||||
ledger = [
|
||||
{
|
||||
"date": "2026-10-04",
|
||||
"lever": "routing",
|
||||
"metric": "subagent_opus_usd_share",
|
||||
"baseline": 0.98,
|
||||
"predicted": 0.55,
|
||||
},
|
||||
{
|
||||
"lever": "weekly prefix prune 2026-10-05",
|
||||
"metric": "standing_tokens_est",
|
||||
"baseline": 22187,
|
||||
"predicted": 19620,
|
||||
},
|
||||
]
|
||||
(report / "ledger.jsonl").write_text(
|
||||
"\n".join(json.dumps(e) for e in ledger), encoding="utf-8"
|
||||
)
|
||||
for day, value in (("2026-10-10", 0.8), ("2026-10-17", 0.84)):
|
||||
snap = {
|
||||
"generated": f"{day}T10:00:00+00:00",
|
||||
"metrics": {"subagent_opus_usd_share": value, "standing_tokens_est": 23670},
|
||||
}
|
||||
(report / "history" / f"{day}.json").write_text(json.dumps(snap))
|
||||
for day in ("2026-10-05", "2026-10-12"):
|
||||
(report / "prune" / day).mkdir(parents=True)
|
||||
(report / "prune" / day / "PROPOSAL.md").write_text(
|
||||
f"# Prefix prune proposal {day}\n\nModest week. Estimated ~ -0.35k.\n"
|
||||
)
|
||||
return report
|
||||
|
||||
|
||||
def test_build_rows_status_and_outcome(tmp_path: Path) -> None:
|
||||
report = _report(tmp_path)
|
||||
tables = {"2026-10-10": TABLE}
|
||||
rows = levers.build(report, tables)
|
||||
applied = [r for r in rows if r["source"] == "ledger"]
|
||||
assert [r["lever"] for r in applied] == [
|
||||
"routing",
|
||||
"weekly prefix prune 2026-10-05",
|
||||
]
|
||||
assert applied[0]["outcome"] == "helped"
|
||||
assert applied[1]["outcome"] == "regressed"
|
||||
assert applied[1]["date"] == "2026-10-05"
|
||||
status = {r["source"]: r["status"] for r in rows if r["source"].startswith("prune")}
|
||||
assert status == {"prune 2026-10-05": "applied", "prune 2026-10-12": "proposed"}
|
||||
# The 10-10 Opus candidate was followed by no ledger line on its metric
|
||||
# and a newer snapshot exists, so it was not applied.
|
||||
opus = next(r for r in rows if r["lever"] == "Opus runs most subagent work")
|
||||
assert opus["status"] == "not applied"
|
||||
|
||||
|
||||
def test_structured_levers_in_a_snapshot_win_over_markdown(tmp_path: Path) -> None:
|
||||
report = _report(tmp_path)
|
||||
snap_path = report / "history" / "2026-10-17.json"
|
||||
snap = json.loads(snap_path.read_text())
|
||||
snap["levers"] = [
|
||||
{
|
||||
"category": "c",
|
||||
"title": "Structured one",
|
||||
"evidence": "e",
|
||||
"action": "a",
|
||||
"est_usd_per_day": 1.5,
|
||||
"metric": "usd_per_day",
|
||||
},
|
||||
]
|
||||
snap_path.write_text(json.dumps(snap))
|
||||
rows = levers.build(report, {})
|
||||
found = next(r for r in rows if r["lever"] == "Structured one")
|
||||
assert found["status"] == "proposed"
|
||||
assert found["source"] == "snapshot 2026-10-17"
|
||||
83
python_pkg/token_dashboard/tests/test_schema_migrate.py
Normal file
83
python_pkg/token_dashboard/tests/test_schema_migrate.py
Normal file
@ -0,0 +1,83 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pytest
|
||||
|
||||
from python_pkg.token_dashboard import schema
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _v1_store(path: Path) -> None:
|
||||
"""A store as schema v1 wrote it: commits without end_ts, no A/B columns."""
|
||||
conn = sqlite3.connect(path)
|
||||
conn.executescript(
|
||||
"CREATE TABLE meta(key TEXT PRIMARY KEY, value TEXT);"
|
||||
"INSERT INTO meta VALUES ('schema', '1');"
|
||||
"CREATE TABLE commits(key TEXT, prompt_idx INT, ts REAL, sha TEXT,"
|
||||
" repo TEXT);"
|
||||
"INSERT INTO commits VALUES ('s1', 0, 5.0, 'abc', '/r');"
|
||||
"CREATE TABLE agent_calls(key TEXT, tool_use_id TEXT, agent_id TEXT,"
|
||||
" prompt_idx INT, ts REAL, description TEXT, subagent_type TEXT,"
|
||||
" model TEXT);"
|
||||
"INSERT INTO agent_calls VALUES ('s1','t','a',0,1.0,'d','x','m');"
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_old_store_is_migrated_in_place_and_keeps_its_rows(tmp_path: Path) -> None:
|
||||
path = tmp_path / "t.sqlite"
|
||||
_v1_store(path)
|
||||
conn = schema.connect(path)
|
||||
kept = conn.execute("SELECT key, sha, end_ts FROM commits").fetchall()
|
||||
calls = conn.execute("SELECT tool_use_id, result_ts, status FROM agent_calls")
|
||||
version = conn.execute("SELECT value FROM meta WHERE key='schema'").fetchone()
|
||||
assert kept == [("s1", "abc", None)]
|
||||
assert calls.fetchall() == [("t", None, None)]
|
||||
assert version == (schema.VERSION,)
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_newer_store_is_refused_not_relabelled(tmp_path: Path) -> None:
|
||||
path = tmp_path / "t.sqlite"
|
||||
conn = sqlite3.connect(path)
|
||||
conn.executescript(
|
||||
"CREATE TABLE meta(key TEXT PRIMARY KEY, value TEXT);"
|
||||
"INSERT INTO meta VALUES ('schema', '999');"
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
with pytest.raises(schema.SchemaError):
|
||||
schema.connect(path)
|
||||
check = sqlite3.connect(path)
|
||||
assert check.execute("SELECT value FROM meta").fetchone() == ("999",)
|
||||
check.close()
|
||||
|
||||
|
||||
def _meta_only(path: Path, version: str | None) -> None:
|
||||
conn = sqlite3.connect(path)
|
||||
conn.execute("CREATE TABLE meta(key TEXT PRIMARY KEY, value TEXT)")
|
||||
if version is not None:
|
||||
conn.execute("INSERT INTO meta VALUES ('schema', ?)", (version,))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_meta_without_a_version_is_refused(tmp_path: Path) -> None:
|
||||
_meta_only(tmp_path / "t.sqlite", None)
|
||||
with pytest.raises(schema.SchemaError):
|
||||
schema.connect(tmp_path / "t.sqlite")
|
||||
|
||||
|
||||
def test_table_missing_from_an_old_store_is_created_at_todays_shape(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
_meta_only(tmp_path / "t.sqlite", "1")
|
||||
conn = schema.connect(tmp_path / "t.sqlite")
|
||||
columns = {r[1] for r in conn.execute("PRAGMA table_info(commits)")}
|
||||
conn.close()
|
||||
assert "end_ts" in columns
|
||||
166
python_pkg/token_dashboard/tests/test_walk.py
Normal file
166
python_pkg/token_dashboard/tests/test_walk.py
Normal file
@ -0,0 +1,166 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pytest
|
||||
|
||||
from python_pkg.token_dashboard import walk
|
||||
from python_pkg.token_dashboard.tests.conftest import (
|
||||
assistant,
|
||||
prompt,
|
||||
result,
|
||||
usd_opus_output,
|
||||
use,
|
||||
write,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _main(tmp_path: Path, records: list[dict[str, object]]) -> walk.FileFacts:
|
||||
path = write(tmp_path / "proj" / "sess-1.jsonl", records)
|
||||
return walk.walk_file(path, tmp_path)
|
||||
|
||||
|
||||
def test_one_api_message_written_as_three_records_is_priced_once(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
blocks = [use("t1", "Bash", command="ls"), use("t2", "Read", file_path="/a")]
|
||||
facts = _main(
|
||||
tmp_path,
|
||||
[
|
||||
prompt("do it", 0),
|
||||
assistant("m1", 1, blocks=[{"type": "thinking", "thinking": ""}]),
|
||||
assistant("m1", 1, blocks=blocks[:1]),
|
||||
assistant("m1", 1, blocks=blocks[1:]),
|
||||
result("t1", 2),
|
||||
result("t2", 2),
|
||||
],
|
||||
)
|
||||
assert len(facts.rows.turns) == 1
|
||||
assert facts.rows.turns[0].usd == pytest.approx(usd_opus_output(1000))
|
||||
# The one turn's dollars split evenly across the two tools it issued.
|
||||
by_name = {t.name: t for t in facts.rows.tools}
|
||||
assert by_name["Bash"].usd == pytest.approx(usd_opus_output(500))
|
||||
assert by_name["Bash"].calls == 1
|
||||
assert by_name["Read"].calls == 1
|
||||
assert by_name["Read"].result_tokens > 0
|
||||
|
||||
|
||||
def test_prompts_split_turns_and_text_only_turns_go_to_no_tool(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
facts = _main(
|
||||
tmp_path,
|
||||
[
|
||||
prompt("first " + "a" * 300, 0, promptId="p1"),
|
||||
assistant("m1", 10),
|
||||
prompt("second", 100),
|
||||
assistant("m2", 110),
|
||||
assistant("m3", 120),
|
||||
],
|
||||
)
|
||||
assert [p.idx for p in facts.rows.prompts] == [0, 1]
|
||||
assert len(facts.rows.prompts[0].text) <= walk.PROMPT_CHARS
|
||||
assert facts.rows.prompts[0].prompt_id == "p1"
|
||||
assert [t.prompt_idx for t in facts.rows.turns] == [0, 1, 1]
|
||||
assert {t.name for t in facts.rows.tools} == {walk.NO_TOOL}
|
||||
|
||||
|
||||
def test_tool_results_and_meta_records_are_not_prompts(tmp_path: Path) -> None:
|
||||
facts = _main(
|
||||
tmp_path,
|
||||
[
|
||||
prompt("real", 0),
|
||||
prompt("<system-reminder>injected</system-reminder>", 1),
|
||||
prompt("hidden", 2, isMeta=True),
|
||||
prompt("[Request interrupted by user]", 3),
|
||||
prompt("<command-name>/clear</command-name>", 4),
|
||||
assistant("m1", 5, blocks=[use("t1", "Bash", command="ls")]),
|
||||
result("t1", 6),
|
||||
],
|
||||
)
|
||||
assert [p.text for p in facts.rows.prompts] == ["real", "/clear"]
|
||||
|
||||
|
||||
def test_agent_call_and_commit_and_final_tag_are_captured(tmp_path: Path) -> None:
|
||||
agent = use("ta", "Agent", description="Find x", subagent_type="Explore")
|
||||
commit = use("tc", "Bash", command="git -C ~/src/foo commit -m x")
|
||||
facts = _main(
|
||||
tmp_path,
|
||||
[
|
||||
prompt("go", 0),
|
||||
assistant("m1", 1, blocks=[agent]),
|
||||
result("ta", 2, "launched", toolUseResult={"agentId": "abc"}),
|
||||
assistant("m2", 3, blocks=[commit]),
|
||||
result("tc", 4, "[main 1a2b3c4d] x\n 1 file changed"),
|
||||
assistant(
|
||||
"m3", 5, blocks=[{"type": "text", "text": "x\nDONE: ok\nNOTE: y"}]
|
||||
),
|
||||
],
|
||||
)
|
||||
call = facts.rows.agent_calls[0]
|
||||
assert (call.tool_use_id, call.agent_id, call.description) == (
|
||||
"ta",
|
||||
"abc",
|
||||
"Find x",
|
||||
)
|
||||
assert call.subagent_type == "Explore"
|
||||
assert facts.rows.commits[0].sha == "1a2b3c4d"
|
||||
assert facts.rows.commits[0].repo.endswith("/src/foo")
|
||||
assert facts.final_tag == "DONE"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("text", "tag"),
|
||||
[
|
||||
("all good", None),
|
||||
("**NEEDS ACTION:** run it", "NEEDS ACTION"),
|
||||
("RUNNING: tests", "RUNNING"),
|
||||
],
|
||||
)
|
||||
def test_final_tag_variants(tmp_path: Path, text: str, tag: str | None) -> None:
|
||||
blocks = [{"type": "text", "text": text}]
|
||||
facts = _main(tmp_path, [prompt("go", 0), assistant("m1", 1, blocks=blocks)])
|
||||
assert facts.final_tag == tag
|
||||
|
||||
|
||||
def test_lever_table_in_a_tool_result_is_kept(tmp_path: Path) -> None:
|
||||
table = (
|
||||
"## Candidate levers (rule-based)\n\n| category | lever |\n|---|---|\n"
|
||||
"| a | b |\n\nafter"
|
||||
)
|
||||
facts = _main(
|
||||
tmp_path,
|
||||
[
|
||||
prompt("go", 0),
|
||||
assistant("m1", 1, blocks=[use("t1", "Bash", command="x")]),
|
||||
result("t1", 2, table),
|
||||
],
|
||||
)
|
||||
assert facts.rows.lever_tables[0][1].splitlines()[-1] == "| a | b |"
|
||||
|
||||
|
||||
def test_activity_intervals_break_on_gaps_over_five_minutes(tmp_path: Path) -> None:
|
||||
facts = _main(
|
||||
tmp_path,
|
||||
[prompt("a", 0), assistant("m1", 60), prompt("b", 1000), assistant("m2", 1100)],
|
||||
)
|
||||
spans = [(round(e - s)) for s, e in facts.rows.intervals]
|
||||
assert spans == [60, 100]
|
||||
assert facts.span[1] - facts.span[0] == 1100
|
||||
|
||||
|
||||
def test_subagent_file_reads_meta_and_parent(tmp_path: Path) -> None:
|
||||
sub = tmp_path / "proj" / "sess-1" / "subagents" / "agent-abc.jsonl"
|
||||
write(sub, [prompt("task", 0), assistant("m1", 1, model="claude-sonnet-5-5")])
|
||||
sub.with_suffix(".meta.json").write_text(
|
||||
'{"agentType": "Explore", "description": "Find", "toolUseId": "ta"}',
|
||||
encoding="utf-8",
|
||||
)
|
||||
facts = walk.walk_file(sub, tmp_path)
|
||||
assert facts.key == "sess-1/agent-abc"
|
||||
assert facts.parent_session == "sess-1"
|
||||
assert facts.meta["toolUseId"] == "ta"
|
||||
assert facts.rows.prompts == []
|
||||
226
python_pkg/token_dashboard/walk.py
Normal file
226
python_pkg/token_dashboard/walk.py
Normal file
@ -0,0 +1,226 @@
|
||||
"""Walk one transcript file into the rows the store keeps for it.
|
||||
|
||||
Reuses token_audit's record primitives -- line loading, usage extraction,
|
||||
per-API-message dedup (:class:`TurnMerger`) and list-price pricing -- rather
|
||||
than :func:`parse.iter_events`, because the dashboard also needs what that
|
||||
stream drops: every record's timestamp (active time), prompt boundaries, the
|
||||
``Agent`` calls that spawned subagents, and commit hashes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from python_pkg.token_audit import discover, parse, pricing, records, turns
|
||||
from python_pkg.token_dashboard import active, blocks, carry, detail
|
||||
from python_pkg.token_dashboard.blocks import PROMPT_CHARS
|
||||
from python_pkg.token_dashboard.rows import (
|
||||
AgentCall,
|
||||
Commit,
|
||||
FileFacts,
|
||||
PromptRow,
|
||||
ToolRow,
|
||||
TurnRow,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
from python_pkg.token_audit.model import Turn
|
||||
|
||||
__all__ = ["NO_TOOL", "PROMPT_CHARS", "FileFacts", "walk_file"]
|
||||
|
||||
NO_TOOL = "(no tool)"
|
||||
AGENT_TOOLS = frozenset({"Agent", "Task"})
|
||||
_BOOK = pricing.load()
|
||||
|
||||
|
||||
class _Walker:
|
||||
"""Single pass over a file's records, holding the cross-record state."""
|
||||
|
||||
def __init__(self, facts: FileFacts) -> None:
|
||||
self.facts = facts
|
||||
self.prompt = -1
|
||||
self.ts = 0.0
|
||||
self.stamps: list[float] = []
|
||||
self.pending: dict[str, dict[str, Any]] = {}
|
||||
self.names: dict[str, list[str]] = {}
|
||||
self.use_ts: dict[str, float] = {}
|
||||
self.tools: dict[tuple[int, str], ToolRow] = {}
|
||||
self.carry = carry.Tracker()
|
||||
self.detail = detail.Detail()
|
||||
self.merger = records.TurnMerger()
|
||||
self.last_text = ""
|
||||
|
||||
def tool(self, name: str) -> ToolRow:
|
||||
"""The accumulator for ``name`` in the current prompt."""
|
||||
return self.tools.setdefault((self.prompt, name), ToolRow(self.prompt, name))
|
||||
|
||||
def release(self, turn: Turn | None) -> None:
|
||||
"""Price a completed API call and split it across its tools."""
|
||||
if turn is None:
|
||||
return
|
||||
cost = pricing.usd(turn, _BOOK)
|
||||
usage = turn.usage
|
||||
self.facts.rows.turns.append(
|
||||
TurnRow(
|
||||
turn.timestamp or self.ts,
|
||||
turn.model,
|
||||
cost,
|
||||
turn.context,
|
||||
self.prompt,
|
||||
usage,
|
||||
)
|
||||
)
|
||||
self.carry.turn(turn.model, turn.context)
|
||||
self.detail.turn(turn, cost, self.prompt)
|
||||
names = self.names.pop(turn.message_id, []) or [NO_TOOL]
|
||||
for name in names:
|
||||
self.tool(name).usd += cost / len(names)
|
||||
|
||||
def record(self, rec: dict[str, Any]) -> None:
|
||||
"""Account one record."""
|
||||
stamp = records.timestamp(rec)
|
||||
if stamp is not None:
|
||||
self.ts = stamp
|
||||
self.stamps.append(stamp)
|
||||
if self.facts.cwd is None and isinstance(rec.get("cwd"), str):
|
||||
self.facts.cwd = rec["cwd"]
|
||||
if rec.get("type") == "user":
|
||||
self.release(self.merger.flush())
|
||||
self.user(rec)
|
||||
self.detail.record(rec, self.ts, self.prompt)
|
||||
for block in parse.content_blocks(rec):
|
||||
self.block(rec, block)
|
||||
found = turns.usage_of(rec)
|
||||
if found is not None:
|
||||
self.release(self.merger.push(turns.build_turn(rec, *found)))
|
||||
|
||||
def user(self, rec: dict[str, Any]) -> None:
|
||||
"""Open a new prompt when this user record is one."""
|
||||
text = blocks.prompt_text(rec)
|
||||
if text is None or self.facts.parent_session is not None:
|
||||
return
|
||||
self.prompt += 1
|
||||
prompt_id = rec.get("promptId")
|
||||
self.facts.rows.prompts.append(
|
||||
PromptRow(self.prompt, self.ts, str(prompt_id or ""), text)
|
||||
)
|
||||
|
||||
def block(self, rec: dict[str, Any], block: dict[str, Any]) -> None:
|
||||
"""Remember tool calls, pair results, keep the last reply text."""
|
||||
kind = block.get("type")
|
||||
if kind == "tool_use":
|
||||
self.tool_use(rec, block)
|
||||
elif kind == "tool_result":
|
||||
self.tool_result(rec, block)
|
||||
elif kind == "text" and rec.get("type") == "assistant":
|
||||
self.last_text = str(block.get("text") or "")
|
||||
|
||||
def tool_use(self, rec: dict[str, Any], block: dict[str, Any]) -> None:
|
||||
"""Hold a call until its result arrives; note which message issued it."""
|
||||
call_id = str(block.get("id") or "")
|
||||
self.pending[call_id] = block
|
||||
self.use_ts[call_id] = self.ts
|
||||
message = rec.get("message")
|
||||
mid = message.get("id") if isinstance(message, dict) else None
|
||||
name = str(block.get("name") or "unknown")
|
||||
self.names.setdefault(str(mid or ""), []).append(name)
|
||||
self.detail.use(rec, block, self.ts, self.prompt)
|
||||
|
||||
def tool_result(self, rec: dict[str, Any], block: dict[str, Any]) -> None:
|
||||
"""Pair a result with its call and record what it tells us."""
|
||||
call_id = str(block.get("tool_use_id") or "")
|
||||
use = self.pending.get(call_id)
|
||||
paired = parse.pair_result(block, self.pending)
|
||||
if use is None or paired is None:
|
||||
return
|
||||
self.detail.result(block, self.ts)
|
||||
row = self.tool(paired.name)
|
||||
row.calls += 1
|
||||
row.result_tokens += paired.result_tokens
|
||||
self.carry.result(row, paired.result_tokens)
|
||||
text = blocks.text_of(block.get("content"))
|
||||
raw_input = use.get("input")
|
||||
tool_input: dict[str, Any] = raw_input if isinstance(raw_input, dict) else {}
|
||||
if paired.name in AGENT_TOOLS:
|
||||
failed = bool(block.get("is_error"))
|
||||
self.agent_call(rec, call_id, tool_input, failed=failed)
|
||||
elif paired.name == "SendMessage":
|
||||
self.facts.rows.messages.append((self.ts, str(tool_input.get("to") or "")))
|
||||
if paired.commit:
|
||||
# Every commit command is kept, hash or not: most run through
|
||||
# finish_auto.sh with a tailed or backgrounded output, so the hash
|
||||
# is usually found later by :mod:`commits` from git log instead.
|
||||
hint = blocks.repo_hint(str(tool_input.get("command")), self.facts.cwd)
|
||||
started = self.use_ts.pop(call_id, self.ts)
|
||||
self.facts.rows.commits += [
|
||||
Commit(self.prompt, started, self.ts, sha, hint)
|
||||
for sha in blocks.commit_shas(text) or [""]
|
||||
]
|
||||
table = blocks.lever_table(text)
|
||||
if table is not None:
|
||||
self.facts.rows.lever_tables.append((self.ts, table))
|
||||
|
||||
def agent_call(
|
||||
self,
|
||||
rec: dict[str, Any],
|
||||
call_id: str,
|
||||
tool_input: dict[str, Any],
|
||||
*,
|
||||
failed: bool,
|
||||
) -> None:
|
||||
"""Record a subagent spawn with the ``agentId`` its result names."""
|
||||
result = rec.get("toolUseResult")
|
||||
result = result if isinstance(result, dict) else {}
|
||||
agent_id = result.get("agentId")
|
||||
status = "error" if failed else str(result.get("status") or "")
|
||||
self.facts.rows.agent_calls.append(
|
||||
AgentCall(
|
||||
tool_use_id=call_id,
|
||||
agent_id=str(agent_id or ""),
|
||||
prompt_idx=self.prompt,
|
||||
ts=self.use_ts.get(call_id, self.ts),
|
||||
description=str(tool_input.get("description") or ""),
|
||||
subagent_type=str(tool_input.get("subagent_type") or ""),
|
||||
model=str(tool_input.get("model") or ""),
|
||||
result_ts=self.ts,
|
||||
status=status,
|
||||
)
|
||||
)
|
||||
|
||||
def finish(self) -> FileFacts:
|
||||
"""Flush state into the facts."""
|
||||
self.release(self.merger.flush())
|
||||
facts = self.facts
|
||||
facts.rows.tools = list(self.tools.values())
|
||||
self.carry.settle(_BOOK)
|
||||
facts.rows.detail = self.detail.finish(_BOOK)
|
||||
facts.rows.intervals = active.intervals(self.stamps)
|
||||
if self.stamps:
|
||||
facts.span = (min(self.stamps), max(self.stamps))
|
||||
facts.final_tag = blocks.final_tag(self.last_text)
|
||||
return facts
|
||||
|
||||
|
||||
def walk_file(path: Path, root: Path) -> FileFacts:
|
||||
"""Read one transcript (main or subagent) into :class:`FileFacts`."""
|
||||
del root # keys are derived from the path's own layout
|
||||
if path.parent.name == "subagents":
|
||||
parent = path.parent.parent.name
|
||||
facts = FileFacts(
|
||||
key=f"{parent}/{path.stem}",
|
||||
session_id=parent,
|
||||
parent_session=parent,
|
||||
cwd=None,
|
||||
meta=discover.agent_meta(path),
|
||||
)
|
||||
else:
|
||||
facts = FileFacts(path.stem, path.stem, None, None, {})
|
||||
walker = _Walker(facts)
|
||||
with path.open(encoding="utf-8", errors="replace") as handle:
|
||||
for line in handle:
|
||||
rec = parse.load_line(line)
|
||||
if rec is not None:
|
||||
walker.record(rec)
|
||||
return walker.finish()
|
||||
Loading…
Reference in New Issue
Block a user