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.
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.Three design notes on what you just used:
group.leaf; the chart colours the group, the drawer
lists every leaf. The spare colour is the trial slot — a mechanism for promoting
one leaf to its own segment while it is being judged, then folding it back with a
one-line commit (USAGE_TRIAL). persona.growth held it for the
day the dashboard shipped and was folded back the same day, which is the point: a
permanent trial slot is just an eighth group wearing a badge. Empty is the resting
state. Eight is the ceiling a stacked bar stays readable at (and the ceiling the
palette is validated at), so there is no "Other" — only the gray pre-ledger era.$ by model lens (owner, 2026-09-10) is the same money split by WHO
ANSWERED rather than by what asked — the chart and the By model tier bar under
it become two views of one number, so a jump in the column has a name the moment you see
it. The buckets carry their own per-model split (periods[].models, folded on
canon_model so an alias is one model — _merge_models' rule
applied per bucket); the bucket's totals are untouched, this reads the same rows a second
way. Colour is the window's own cost rank, which is how that table already colours
its rows, so a model wears one hue down the whole page. ⚠ The slots ARE the palette,
and the palette holds eight. The table can give every model past the eighth one shared
hue because each of its rows carries a name; a stacked column cannot — two segments of one
colour meaning two things is the one thing a chart may not do. So eight slots are eight
models, and only when there are more models than slots does the last slot become
other models. That is the same reason there is no "Other" in the function lens
above: eight is the ceiling, and what exceeds it has to be named honestly, not
colour-collided.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.
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.
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.
| Slot | Leaf (stored kind) | What it is (· its pre-rename name, for reading old commits) | Booked? | |
|---|---|---|---|---|
| speech | speech | the panel reply itself — one call, every persona's lines | yes | |
speech.interrogate | ONE 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 turns | yes | ||
speech.empty | a reply that came back unusable and was retried — still paid for | yes | ||
| fp | fp.open | the Floor Producer's OPENING staging — seats every host for the kickoff (split out 2026-08-24) | yes | |
fp.mid | the per-turn director, one tier below speech · staging | yes | ||
fp | a row booked BEFORE the split — open or mid, unrecorded, so it is never guessed: it reads as its own leaf, beside the two | legacy | ||
| tool | tool.prop | the prop master —「does this moment want an object?」· prop | yes | |
tool.act | the act call — how the instrument is set · act | yes | ||
tool.promised | the wake-door YES/NO check —「did those words announce a tool opening right now?」· promise | yes | ||
| kit | kit.interpreter | the flash reader over a collect window's lines · interpreter | yes | |
kit.ruling | a side ruling asked off the card alone · ruling | yes | ||
kit.voice | a server-computed fact put in the host's words (not TTS) · voice | yes | ||
kit.authoring | the kit's authoring call (the quiz) | yes | ||
kit.route | the flash clerk that decides which thread a loose line touched — not a voice | yes | ||
| persona | persona.memory | the memory writer's A/B sweep calls · memory | yes | |
persona.dream | the nightly consolidation · memory_dream_all() | yes — new | ||
persona.growth | the "since the record" weekly sweep · growth ④ (LLM + the SERP fee, booked as its own row) | yes — new | ||
persona.embed | the 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 usage | yes — new | ||
| room | room.translate | translate-on-divergence (Layer B) · translate | yes | |
room.title | the autotitle · title | yes | ||
room.dispatch | the web-search correspondent · dispatch | yes | ||
| photo | photo.shadow | the eyes read a sent photo ONCE, on upload, into a text shadow the text-only panel can refer to ever after · room.photo | yes | |
photo.gate | one 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 what | yes | ||
photo.look | the re-read the gate authorised — the expensive half, and the reason the gate is worth its own leaf · room.look | yes | ||
| app | app.studio | Persona Studio builds · pseudo-room builder | yes | |
app.vibes | the daily brew · pseudo-room vibes | yes | ||
app.seen | the Seen piece · pseudo-room seen | yes | ||
app.convene | the panel curator · pseudo-room convene | yes | ||
app.search | the 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 minutes | yes | ||
app.stt | iFlytek dictation, one call per mic press · pseudo-room stt | yes | ||
app.ink_editor | Ink (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 groups | yes — new | ||
app.ink_pitch | the 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 prefix | yes — new | ||
app.ink_desk | the 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_archive | the 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 yet | yes — new | ||
app.ink_brief | the background brief (the Source page), one per language, Flash | yes — new | ||
app.ink_writer | every draft, in the persona's voice · the antithesis rewrite | yes — new | ||
app.ink_interview | the 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_copy | the 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_judge | the gate's cold reader — did a light piece land | yes — 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 fix | yes — new | ||
app.ink_score | the 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_rendition | the zh rendition — 意译 with thinking, 润色 only when the lint fails | yes — new | ||
app.ink_director | the art director's briefs, one per chosen piece | yes — new | ||
app.ink_art | the 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 split | yes — new | ||
app.ink_bank | a persona's anchor bank, extracted on its first commission | yes — new | ||
app.ink_voice | the voice harvest — a thin persona's own paragraphs mined once from the open web: five searches (SERP fees) + Flash copying verbatim | yes — new | ||
app.ink_pegs | the news search — the SERP fees as one row per pass, model serper. Never booked before the split | yes — new | ||
app.ink | the 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 ledger | every turn_events row older than the ship — one unsplit sum per turn | yes, 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.
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.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.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.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.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.lib/smoketest.py, incl. the
double-count guard (a function books both books; a chat turn books only the ledger) and
the timezone one.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.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.
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.
USAGE_TRIAL = (). Growth folded back
into persona on ship day; the mechanism stays for the next thing worth
watching, and empty is its resting state.exam/* runs from the dev PC against the
APIs directly and never reaches the box's ledger — that is dev cost, and its own page.