Usage dashboard — the money, by step

A rebuild of the console's Usage & cost tab into a real dashboard: daily / weekly / monthly views, cost per function in the final vocabulary (speech · fp · tool.* · kit.* · photo.* · persona.* · room.* · app.*), cost by model tier, daily token volume, and a double-click drill-down for any period.

Status: BUILT 2026-08-21 — live on main, not yet on the box. The ledger (db.call_events), the group.leaf rename, the endpoint and the console tab all shipped together; the smoketest gates them. The interactive figure below is the design specimen and still runs on generated data — the real thing is Console ▸ Usage & cost.

1 · The dashboard

The shape, after the owner's rebuild (2026-08-21). The first cut gave the reader three independent axes — a granularity, a lens and a stack — plus a drill-down drawer whose tables repeated the window's own headings at a different scope, so「by function」appeared twice meaning two different things and「by model tier」named no period at all. The verdict was fair:「still very confusing」. There is now ONE control that matters — the time scoper — and everything below it is one server answer for that one window:
  1. the KPI strip — spend · tokens · calls · cache hit
  2. the chart, whose bar follows the span (an hour for a day, a day for a season, a month beyond) — and clicking a bar makes it the window
  3. four standing analyses: ① by model tier · ② by function · ③ top 10 users · ④ top 10 chats
Nothing on the page can disagree with anything else on it, because it all came from the same query. The specimen below is the older three-axis study, kept as the design record.
Admin console · Usage & cost SPECIMEN · GENERATED DATA
hover = every value · click = select · double-click = details (double-tap on touch)
Spend by model tier
By function

Three design notes on what you just used:

2 · The ledger (why it needed one new table)

Before this build the per-function split never reached the database. Room._tally() already sees every provider call with its kind — but only accumulates it in memory, per room, all-time. The DB ledger turn_events books one row per whole turn (speech + FP + prop + act + dispatch summed), with no kind and no cache-token columns. And two functions bill nowhere at all today: the dreamer and the growth sweep only print their cost to stderr.

EVERY provider call persona msg · FP (staging) prop master · act · memory dispatch · translate · title voice · interpreter · ruling authoring · promise · empty + Studio · Seen · Convene · Vibes · STT · dream · growth Room._tally(...) kind · model · usage · $ the ONE seam — smoketest counts its call sites ALSO · session by_kind (memory) per room, all-time, never dated — no daily / weekly / monthly view possible ALSO · db turn_events 1 row per TURN — whole turn's spend, no kind, no cache tokens · keeps caps alive BUILT — one write at the seam db call_events — 1 row per CALL ts · room_id · user_id · kind · model in_tok · out_tok · cw_tok · cr_tok · cost the dashboard reads only this table turn_events stays untouched — spend caps, the leaderboard and per-room totals keep working; the dream and growth sweeps (stderr-only today) start booking rows here too, so nothing bills invisibly again.
one INSERT at a seam that already existed — no call site moved, and the smoketest invariant still guards it
CREATE TABLE call_events ( id INTEGER PRIMARY KEY, ts TEXT NOT NULL, -- ISO UTC, second precision room_id TEXT, -- real room, or a function pseudo-room user_id INTEGER, -- who pays (NULL for system sweeps) kind TEXT NOT NULL, -- speech · staging · prop · act · memory · dream · growth · … model TEXT, in_tok INTEGER DEFAULT 0, -- fresh input out_tok INTEGER DEFAULT 0, -- output cw_tok INTEGER DEFAULT 0, -- cache write cr_tok INTEGER DEFAULT 0, -- cache read cost REAL DEFAULT 0 ); CREATE INDEX ce_ts ON call_events(ts); CREATE INDEX ce_kind ON call_events(kind, ts);

Volume: a busy day is a few thousand calls — SQLite yawns. A year is ~1M rows; if it ever matters, a monthly rollup table is a follow-up, not a prerequisite. The dashboard's aggregates are three GROUP BY substr(ts,1,10) queries.

3 · The vocabulary (final, 2026-08-21)

Every stored kind is group.leaf — lowercase, dot between group and leaf, stable forever (it is a row in the ledger). The group is the stack slot; display labels are a separate map and may change any day. "merged" = the split exists in memory today but lands in the turn row unsplit; those all start writing call_events rows through _tally on day one.

In the By function table the shape carries this on its own: a group holding several leaves names the group and lists them indented beneath it; a group holding one is named by that leaf alone, with nothing indented under it. The table used to say so in a caption under its header — the captions came off the cost page (owner, 2026-08-24), so the rule is written here instead.

A leaf may belong to a FAMILY (2026-09-09, the owner:「app.ink is still a major function under App, and when we click on app.ink, it expands to app.ink_desk, etc」). The rule is the underscore, and it is the one the vocabulary already used: app.ink_writer belongs to the family named by the part before it, app.ink. A family stands where a leaf stands — summed, badged with its seat count, and shut — and opens one indent deeper on a click. Every other kind is a family of one and is drawn as the plain leaf it always was, so nothing outside Ink moves. The bare app.ink — rows booked before the 09-02 split — falls into the Ink family by the same rule and is one of its leaves. It is the same shape Models & APIs took the same day: there Ink is one card headed app.ink, with a plain sub-head per seat; here the seats are where the money is, so here they open.

SlotLeaf (stored kind)What it is (· its pre-rename name, for reading old commits)Booked?
speechspeechthe panel reply itself — one call, every persona's linesyes
speech.interrogateONE seat's own call in the interrogation kit(question · ruling · audit · doc)— that engine speaks one mind at a time, so its price scales with threads answered, not turnsyes
speech.emptya reply that came back unusable and was retried — still paid foryes
fpfp.openthe Floor Producer's OPENING staging — seats every host for the kickoff (split out 2026-08-24)yes
fp.midthe per-turn director, one tier below speech · stagingyes
fpa row booked BEFORE the split — open or mid, unrecorded, so it is never guessed: it reads as its own leaf, beside the twolegacy
tooltool.propthe prop master —「does this moment want an object?」· propyes
tool.actthe act call — how the instrument is set · actyes
tool.promisedthe wake-door YES/NO check —「did those words announce a tool opening right now?」· promiseyes
kitkit.interpreterthe flash reader over a collect window's lines · interpreteryes
kit.rulinga side ruling asked off the card alone · rulingyes
kit.voicea server-computed fact put in the host's words (not TTS) · voiceyes
kit.authoringthe kit's authoring call (the quiz)yes
kit.routethe flash clerk that decides which thread a loose line touched — not a voiceyes
personapersona.memorythe memory writer's A/B sweep calls · memoryyes
persona.dreamthe nightly consolidation · memory_dream_all()yes — new
persona.growththe "since the record" weekly sweep · growth ④ (LLM + the SERP fee, booked as its own row)yes — new
persona.embedthe embedder — the seat's card at room open, the memory router, the dream. Priced input-only on a chars/4 proxy: the API returns no usageyes — new
roomroom.translatetranslate-on-divergence (Layer B) · translateyes
room.titlethe autotitle · titleyes
room.dispatchthe web-search correspondent · dispatchyes
photophoto.shadowthe eyes read a sent photo ONCE, on upload, into a text shadow the text-only panel can refer to ever after · room.photoyes
photo.gateone tiny cold call:「does answering well need the picture again — and as a lookup or a study?」It also rewrites the ask, because「你看一下」says to look but not at whatyes
photo.lookthe re-read the gate authorised — the expensive half, and the reason the gate is worth its own leaf · room.lookyes
appapp.studioPersona Studio builds · pseudo-room builderyes
app.vibesthe daily brew · pseudo-room vibesyes
app.seenthe Seen piece · pseudo-room seenyes
app.convenethe panel curator · pseudo-room conveneyes
app.searchthe persona search's sentence read (Jev, 2026-09-23) — one call per sentence typed in a persona search box, roomless, under the viewer; cached ten minutesyes
app.sttiFlytek dictation, one call per mic press · pseudo-room sttyes
app.ink_editorInk (the persona newsroom, pseudo-room ink) — the editor's budget meeting: the slate, one re-ask. Seventeen task leaves since 2026-09-02/03 (owner:「separate editor, author, artwork…」), an underscore not a third dot: Ink stays inside app because the palette holds eight groupsyes — new
app.ink_pitchthe pitches (2026-09-09) — every persona not resting reads the morning’s pegs as itself through one cheap call and raises a hand, or does not; the pegs are the cached prefixyes — new
app.ink_deskthe research desk — per commission three searches (the SERP fees — since 2026-09-19 one row per query under the engine that answered, serper · brave · bocha; before that a lump named serper whichever engine had answered, which showed a Serper-out-of-credits day as Serper money), the two best pages fetched (free), the reporter's notebook (Flash)yes — new
app.ink_archivethe archive (2026-09-17) — what came before this news: four searches by intent (prior coverage · the precedent · the numbers over years · the study everyone cites; eight in the wide form), the best pages fetched, the SERP fees booked as the desk’s are. ⚠ The workbench reaches it, the nightly brew does not yetyes — new
app.ink_briefthe background brief (the Source page), one per language, Flashyes — new
app.ink_writerevery draft, in the persona's voice · the antithesis rewriteyes — new
app.ink_interviewthe interview — before each draft the persona answers five questions out loud, and the writer works from the answers (item 16, 09-12)yes — new
app.ink_copythe copy editor's passes on a RIVAL model (Gemini 3.8 Flash since 2026-09-09; V4.1 Flash steps in when it fails)yes — new
app.ink_judgethe gate's cold reader — did a light piece landyes — new
app.ink_metaphor隐喻率, the metaphor count (2026-09-07) — live metaphors per 100 sentences with their plain replacements; a meter on every survivor, never a fixyes — new
app.ink_scorethe voice judges — editorial · traits · the imposter test and its decoy rewrite, on the rival family (Gemini 3.8 Flash since 2026-09-09)yes — new
app.ink_renditionthe zh rendition — 意译 with thinking, 润色 only when the lint failsyes — new
app.ink_directorthe art director's briefs, one per chosen pieceyes — new
app.ink_artthe paint — one row per hero at the console's $/image (batch at half price, sync at full); tokens zero, dollars real. Never booked before the splityes — new
app.ink_banka persona's anchor bank, extracted on its first commissionyes — new
app.ink_voicethe voice harvest — a thin persona's own paragraphs mined once from the open web: five searches (SERP fees) + Flash copying verbatimyes — new
app.ink_pegsthe news search — the SERP fees as one row per pass, model serper. Never booked before the splityes — new
app.inkthe pre-split leaf — a row booked before 2026-09-02 named no task, so it keeps the old name (the plain fp precedent: never guessed)yes, unsplit
—before the ledgerevery turn_events row older than the ship — one unsplit sum per turnyes, unsplit

Not in the table because the app never pays for it: read-aloud TTS is the browser calling OpenRouter on the user's own key.

The Ink leaves book one row per provider call (09-04, the owner:「why only 1 call for the writer?」). The brew holds its usage in memory and writes it at the end of a pass; until this fix it summed the pass per (leaf, model) into a single row, so a whole night's writing read in the console as one call. Every booking now appends the call itself and the drain writes a row each — the rule the rest of the table already keeps.

4 · What shipped

  1. The ledger. db.call_events + one write at the _tally seam (best-effort — the books never break a turn) + record_turn(kind=…), an opt-in that lets the five function pseudo-rooms book both books at once. turn_events untouched.
  2. Three spends that had no bill at all now have one: persona.dream and persona.growth (stderr-only until today), and persona.embed — the embedder, which returns no usage object and had no price row, so the card ranking at room open, the memory router and the dream all spent real money that nothing in the app could see. It is priced input-only with a chars/4 token proxy, and metered at one seam (wordpick._post_embed) so a fifth caller cannot forget it.
  3. The rename. Every call site carries the §3 vocabulary; KIND_ALIAS renames a retired kind at read time, so nothing on disk is rewritten and last week's room reads correctly in today's panel. ⚠ At the ship this covered a room's by_kind only — see「the alias has to run where the reading happens」below.
  4. The caps got complete. take_turn_spend() drains what _tally actually saw, so a turn is billed for every call it made. It used to carry speech + fp + dispatch only — tool, game, translate and memory calls were capped by nothing and appeared in no total. The old three-field sum survives as a floor, so no path can bill less than it did before.
  5. The endpoint. GET /api/admin/usage2?start=&end=&tz=±N — ONE scope, ONE answer: the chart's buckets plus all four standing analyses, every one filtered by the same window. start/end are the LOCAL calendar dates the reader picked, because a date typed into a calendar means the date on that calendar.
  6. The tab. Console ▸ Usage & cost — the time scoper (Today · Yesterday · Last 7 · Last 30 · This month · Last month · Custom, with a two-month range calendar), the chart, and the four analyses. The all-time By-user card and the Studio backfill stay below it: they are deliberately unscoped.
  7. The gate. Twelve ledger checks in lib/smoketest.py, incl. the double-count guard (a function books both books; a chat turn books only the ledger) and the timezone one.
Two rules the build rests on. One source per day — after ledger_start both tables describe the same money, so every reader takes call_events at/after it and turn_events strictly before; summing both would double every modern turn and look plausible the whole time. And a day is the reader's day — the browser sends its own UTC offset, because a day counted in UTC cuts the owner's evening in half and files it under two bars.

Since the ship — one model, one row

A room books its turns under the picker's model id and the eyes book theirs under the API's, and for the vision model those differ(deepseek-v4-flash-vision vs -exp)— so the by-model tier and the chat's cost panel showed the same model twice, one of them raw. cost.MODEL_ALIAS + canon_model now write the canonical id at the tally seam and at book_call, and every by-model read merges already-written rows through the same alias. ⚠ The ledger is never rewritten — the fold happens on the way in and on the way out, so a row booked last week still reads correctly today.

Since the ship — the alias has to run where the reading happens

Re-grouping game.*→kit.* and lifting photo work into its own photo.* group changed nothing at all on either surface, and both went on showing room.photo under a room group. KIND_ALIAS was applied at book_call (write) and when a room loads its state.json — but never when reading call_events, where GROUP BY kind returns the raw stored string. New rows carried the new name because the call site passed it; the ~70 rows already booked kept theirs forever.

The vocabulary now lives in db.py, beside the table whose kind column it describes — the reader needs it and db must not import run_room, which re-exports it. usage_window() and room_cost() fold every row through kind_now on the way out (_fold_kinds), and two eras of one kind merge onto one row rather than sitting beside each other — the by-function table, the chat panel and the chart's stack all at once. ⚠ An alias that only runs on the write path renames nothing; it is the read that has to fold, and a silent no-op looks exactly like a working rename.

5 · Still open

-ish · design docs — Usage dashboard · built 2026-08-21 · chart palette validated (dataviz six checks) against both app surfaces