← Design notes

Function map — how the multi-user room is wired

A map of the running system: what the pieces are, how a message flows through them, and which file owns what. Read it top-to-bottom to understand the whole; use the file map as a lookup when you change something. The server is one Python process; the browser is one page. build 2026-08-16 · rev 23

1 · the shape

Three tiers, one process

Everything is one FastAPI process talking to a browser. State lives in three places: a small SQLite database for the relational facts (who, which rooms, membership, usage), flat files for each conversation, and the LLM API for the actual panel turns. Two LLM endpoints are in play now: a panel model (Sonnet 5 or one of the four DeepSeek v4 ids — the V4.1 Flash pair, and the V4 Pro pair kept only so a saved chat resolves) voices the hosts, and the web dispatch runs on an admin-picked engine — Haiku 4.5 + native web_search, or a search API distilled by DeepSeek v4.1 Flash, each query routed by its language (see §2b).

browser · the SPA

room-ui.html · login.html · admin.html
  • static HTML pages, no framework
  • renders chats, bubbles, the egg, the chat-info panel
  • markdown + math + code + diagrams + emoji via vendored dompurify · /katex · /hljs · /mermaid · /fonts · /emoji (all same-origin)
  • holds two live streams open (see §3)
  • auth is a cookie; gated client-side via /api/me
  • admin console + read-only room peek (reuses the chat UI)

server · one process

run_room.py (FastAPI + uvicorn)
  • RoomManager holds live Room objects in RAM
  • each Room has a lock + a background drain worker
  • routes are sync def on a threadpool
  • two pub/sub hubs bridge threads → SSE
  • off-lock web dispatch (Haiku + web_search, or SERP + Flash) folds into the turn
  • off-lock the eyes (gap 5): a sent photo's text shadow (written at upload — the routing in one table (2026-08-24): a plain photo is read whole by PHOTO_MODEL = V4.1 Flash (was the V4 Flash Vision exp seat until 2026-09-09), a dense one (KIND = plan · document · chart · screenshot · map, or FINE TEXT the ~800² shrink ate) whole by Gemini 3.8 Flash (3.7 until 2026-09-09) (MAD_PHOTO_DENSE_EYES, _gemini_vision) with DeepSeek's tiled read (_photo_tiles) as the fallback; a seeing room takes no Gemini at all, because the mind is the eyes and DeepSeek only writes the record) and, when the look gate says so (SKIP · LOOK · STUDY, a rewritten ask), one look — cold, or thinking on for a STUDY — fold into the turn as <photo> / <photo-detail> blocks. A blind panel never sees pixels; a room on DeepSeek v4.1 Flash — natively multimodal, the Vision exp seat having retired onto it 2026-09-09 — gets the last four photos (PHOTO_NATIVE_MAX) re-attached to their own lines at call time and looks for itself. The picture is uploaded to DeepSeek's Files API once (purpose=user_data, a 30-day expiry) and every later call rides the file_id instead of ~270 KB of base64 — same token bill, a far smaller request; base64 stays the fallback, so a photo is never lost to the Files API

stores

SQLite + flat files + LLM
  • app.db — users, rooms, members, usage, settings, invites, DM pairs, push subscriptions
  • rooms/<id>/ — state, event log, transcript
  • rooms/notebook.json — saved quotes (per user)
  • rooms/<id>/photos/ — a sent photo in two tiers (<pid>.webp 1600 · <pid>-t.webp 480, EXIF/GPS stripped) plus, when it is dense, its close-up tiles (<pid>-t<row><col>.webp, ≤3×3 at 8% overlap) — beside its meta <pid>.json, which carries the text shadow, the kind/dense verdict that routes it, the tile list, which eyes read it, and the DeepSeek file_id + expiry the eyes reference it by
  • personas: two roots — prototype/personas/ (repo) + /var/lib/mad/personas/ (builder)
  • panel: Sonnet 5 / DeepSeek v4 (Pro · Flash · Flash vision, each ± reasoning)
  • dispatch: Haiku 4.5 + web_search, or a search API + DeepSeek Flash (admin pick; en Serper→Brave · zh Bocha→Serper→Brave) · builder: DeepSeek + Gemini + Serper/Brave/Bocha

Rule of thumb: relational + cross-room facts → SQLite; a conversation's content → its flat files; the model's reply → the LLM, then parsed back into the flat files.

1b · the model registry

Five model ids, three providers' worth of behaviour

MODELS lists five ids in production (more on a dev box with local seats armed — the last note below): Sonnet 5, the V4.1 Flash pair, and the V4 Pro pair, retired (greyed in the console, never offered, kept so a saved room resolves; the rows become V4.1 Pro when it ships). The DeepSeek pairs differ only by a thinking toggle. Since 2026-09-09 (the exam) V4 Flash and V4 Flash Vision are gone — MODEL_RETIRED reads a room saved on either onto V4.1 Flash, which is natively multimodal; the V4.1 wire id rides cost.api_id (the 09-10 beta id by default, MAD_V41_FLASH_API the operator's override; a one-token probe runs hourly and, until the id answers, the seam falls back to the canonical GA id — never to V4 Pro, which is retired, and which five Ink pitch calls had fallen to and been priced at on 09-10 before the probe answered). The default panel model is deepseek-v4.1-flash. Helpers normalise the id: provider_of, api_model_of (strips -reasoning), thinking_of (default OFF), vision_of (an unlisted id is blind), max_tokens_for (16384 on DeepSeek — the cap must cover reasoning + reply — 2048 on Anthropic).

idproviderapi modelthinkingvision
claude-sonnet-5anthropicclaude-sonnet-5
deepseek-v4.1-flashdeepseekcost.api_iddeepseek-flash (the GA name, read off /models 09-10 10:54) · deepseek-v4-flash as the fallback (routed to it, never Pro)off (default)yes
deepseek-v4.1-flash-reasoningdeepseekthe sameonyes
deepseek-v4-pro (retired)deepseekdeepseek-v4-prooff
deepseek-v4-pro-reasoning (retired)deepseekdeepseek-v4-proon
The seeing mind (2026-08-23, gap 5; the seat is V4.1 Flash since 2026-09-09). The vision pair was born as deepseek-v4-flash-vision-exp, DeepSeek's experimental id; V4.1 Flash is natively multimodal, so its rows carry vision and the Vision pair retired onto them. A room on a seeing model is a seeing mind: at call time _call_deepseek re-attaches the pixels of the last PHOTO_NATIVE_MAX = 4 photos to their own user lines, so the panel looks for itself and no look-again runs. Nothing is stored as pixels — history stays the text shadow, so switching back to a blind model mid-chat still works. It also never gets the panel_turn tool schema (the tool path leaked DSML markup into the bubble); plain <speak> text is its steady path. "Exp" = no stability promise, so it is not the default.
Sonnet 5 replaced Sonnet 4.6 (2026-08-24) — better and cheaper at the intro price, so there was no reason to keep the old seat. A retired id keeps its seat: MODEL_RETIRED maps a room saved on claude-sonnet-4-6 onto claude-sonnet-5 wherever a saved model is read (resume · switch), never written back until the room next saves — a Sonnet chat stays a Sonnet chat across the swap. MODELS_ADDED_SINCE_KNOWN is the console's companion rule: a saved models_enabled with no models_known beside it predates the vision pair and Sonnet 5, so those ids are offered rather than read as switched off.
Local seats — the dev PC's own GPU, env-gated (2026-08-24). MAD_LOCAL_LLM_URL (set by the dev launchers only — Ollama's OpenAI-compatible door at http://localhost:11434/v1/chat/completions) appends seven more rows: local-qwen3 (Qwen3 30B), local-magidonia (Magidonia 24B), local-cydonia (Cydonia 24B, ablated), local-goetia (Goetia 24B, dark-finetuned), local-magnum (Magnum v4 22B, Claude-prose tune), local-brokentutu (Broken-Tutu 24B, eager+coherent) and local-huihui (Mistral 3.2 abliterated, the steerable instruct seat); the four Q5 seats are Goetia, Magnum, Broken-Tutu and Huihui, whose api ids are the 32k-pinned derived models — the raw hf.co names load at their native 262k context and spill to CPU. Unset — which is production — the rows do not exist: no picker rows, no routing, nothing to misconfigure, and the smoketest holds that a local room makes no external provider call, ever. Their provider stays deepseek on purpose: Ollama speaks the same OpenAI dialect, so every deepseek-shaped path (panel · act · prop · compose) works unchanged and only the wire address differsds_url_for(api_model) is the one line that knows, with LOCAL_API_IDS the membership test. A local chat runs solo (model_local): what a chat is born with is its localness — the crossing is refused both ways, server-side, while the seat may move freely among the local rows (all seven local rows — Qwen3 ↔ Magidonia ↔ Cydonia ↔ Goetia ↔ Magnum ↔ Broken-Tutu ↔ Huihui; owner, 2026-08-25 — every local seat keeps the same contract, so the room is exactly as solo after as before). ⚠ The local- prefix is the contract where the rows are absent (the ce4b lesson, 2026-08-25): on an env-less server model_local falls back to the prefix and Room resume keeps a saved local- id rather than coercing it onto the cloud default, and ds_url_for raises rather than route such an id to DeepSeek — a no-egress room's megaprompt must not be POSTed to the cloud even to be 400-rejected. ⚠ A seat may decline the tool. model_tools_ok(model) reads a MODELS row’s "tools": False and the panel call then omits the panel_turn schema altogether — Magnum v4 22B (ChatML era, no function-calling training) echoed the schema back as its answer, so for that seat the <speak> fallback is the protocol. Design: Local chats.
Other knobs: DISPATCH_MODEL = Haiku 4.5 · DISPATCH_MAX_SEARCHES = 1 · DISPATCH_ENGINES = anthropic | serp (console → Models; the serp pick → DISPATCH_SERP_MODEL = v4.1 Flash writes the brief from DISPATCH_SERP_RESULTS = 8 results; the query's language picks the chain — en Serper→Brave · zh Bocha→Serper→Brave) · CACHE_TTL = 1h · tool output on · EMPTY_RETRIES = 2 (an empty completion is retried, dropping the tool on the retry) · MODEL_DEFAULT = deepseek-v4.1-flash (V4 Pro until 2026-09-09).
2 · a message's journey

What happens when you press send

A human post never blocks. It's recorded and queued instantly; a background worker turns the whole queue into one panel turn and pushes the reply back over the stream. Steps 1–6 are the request; 7–9 are the worker, off the request thread.

Browser posts to /api/rooms/<id>/say
The SPA renders your own bubble immediately and raises the conferring "egg." It also tags the post with a nonce so it can ignore its own echo coming back over the stream.
The gate + cap check + marker scan
_gate confirms you're logged in and a member of this room (else 401/403). A daily-cap check guards the spend. An @human-only line is detected here as an aside — recorded, but no panel turn. The line is scanned for @{u|p|a:id} markers; an @{a:…} (Web Search) sets dispatch= so this turn pulls live web facts.
Record + queue (no model call)
Room.enqueue_human(dispatch=) writes the line to the transcript + event log and appends it to turn_queue. The line is stamped with the sender's local date/time + UTC offset (the server is the clock; the client supplies the zone) — folded ambiently into the prompt so the panel perceives time. The request returns {queued:true} right away.
Instant echo to everyone
The room hub publishes a post event; every other member viewing the room sees your line appear live. Their browser raises the same egg.
Wake the worker
_wake() signals the room's background drain worker (a daemon thread, one per active room). The request is now done.
Worker drains the WHOLE queue → one turn
Room.drain_turn(on_start, on_searching, on_dispatch) combines every queued line into a single prompt. It releases the room lock around the slow API call, so messages that arrive mid-turn just queue up and get answered together by the next drain. This is busy-state batching.
⚑ The silence door — the turn may not run at all (default ON, 2026-07-30)
The floor producer could always stage everyone silent, but the panel call still ran and could disobey it — an instruction, not a door. _silence_on() (console → The turn; MAD_SILENCE_DOOR pins a box) makes an [MODE] hold structural: no panel call is made, and the batch is held outside turn_queue — a re-queue would spin the worker — with its lines riding the next drain instead of getting an answer of their own. The guards live in code, never in the prompt: ≥2 humans in the room (in a 1-on-1 the only audience is the persona), never over a PROP verdict (a moment that wanted an object on the table is not a moment to sit out), and never on a dispatch turn. The FP's own cost still books, and a coordinator hold event records the decision with its intent, so a hold is auditable rather than a turn that silently didn't happen.
If asked: a web dispatch runs first (off-lock)
When the batch carries an @{a:…}, _fetch_dispatch fires a Haiku + web_search call → a neutral fact-brief. The clients see searching → dispatch before the egg. The brief lands once as a kind="dispatch" artifact every host (and human) can read; billed under DISPATCH_MODEL. No dispatch → straight to the next step.
The model replies — as a tool call
The turn is requested as a typed panel_turn tool call → {confer, replies, artifacts}. Forced on Anthropic (_call_anthropic, two cache breakpoints); tool_choice=auto on DeepSeek (_call_deepseek sends the thinking toggle) — it often answers in plain <speak> tags instead → the regex parser is the fallback. Either way the result is the same shape, and history is stored as canonical <speak> text.
⚑ The turn split — act, then speak (SHIPS ON since 2026-07-30)
A second shape for the same turn, built 2026-07-26/27, and the production path since 2026-07-30 (owner). It is no longer an env flag but a live console setting_turn_split_on(), console → Settings → The turn, read once at the top of the drain so a flip landing mid-turn cannot make a turn that ran half each way, and that liveness is not a convenience: the switch IS the rollback, and a thing that stops a behaviour running on every turn in the app must not need a deploy. It shipped OFF through the whole build so the single-call path stayed production while the act machinery was proved; the lifecycle audit is what closed that out, and its fixes live almost entirely on this path. ⚠ The precedence is not the same shape as the other dials: MAD_TURN_SPLIT set to anything wins outright (the box override), and beyond that the module global TURN_SPLIT is the harness pinexam/run.py and the smoketest set it directly to run the split arm with no DB at all, so a True there must beat a console that was never written. ⚠⚠ A default is not a migration: the True default only decides a box whose console value was never written — one carrying an explicit turn_split=0 keeps it, because a stored setting is somebody's decision. Check the box's own value after deploying. ⚠ A live switch whose effect lives in a cached prefix is only live if something re-stamps it_sync_system rebuilds a room's system block when the mode moves under it, for one cache miss. Design + measurements: The turn split; the 18-room battery: Scenario battery. The premise is that deciding an instrument and writing speech are different jobs sharing one call, and the instrument loses. Split on: a cheap reasoning-free gate (ACT_GATE_MODEL, default deepseek-v4.1-flash) asks only 「is an instrument wanted at all?」 — the expensive call decides HOW — then the act call fills an instrument FORM (every dial a menu, JSON-schema'd, compiled to a tag by _form_to_tag) rather than hand-writing grammar — on a model of its own since v655 (_act_model(), default inherit; the find was that it had been silently taking the room's panel model, the most expensive one in the room, on every split turn, carrying the ~5.6k-token manual, to fill in a form — its tally and its act_call event now name the model that actually ran) — and the speech call runs with with_tools=False so prose is all it can produce. Staging and the act call run concurrently. Two rules the build paid for: an act and its result are two beats, not one (the floor-producer prompts carry it), and the speech call is never handed a secret it must not say. The form is where 「note vs deposit」 stopped being a wording problem — note left the kind enum, because a private note IS a deposit with one participant: you, so there is no kind to get wrong, only a choice of who answers (who="me" = you supply the words; who="all" = you write only the question). The retired <note> name is still honoured by the compiler, and the prose manual on the default path still teaches it. smoketest.py lints the form against the tag grammar.
⚑ Deliberate first — think, explain, set the table, check, hand over (default ON, 2026-07-30/31)
The split's second shape, and the one a person running a game actually uses: a GM deliberates before acting. On a setup-shaped moment the order flips — the speech runs first and publishes, and the act call runs after it, behind the bubble, reading that speech as the spec. The cards land while the room is still reading. The property that clinched it: while the host is explaining, the secret does not exist yet — the words are chosen by the deferred act call, privately, after the reply — so the arm-turn leak loses its object rather than being prompted away. The pieces: a KIND: SETUP|OPERATE word from the prop master, in its own block, 「if both could fit, OPERATE」, unreadable = OPERATE — and every doubt degrades to the classic order (dial off, un-split, tools off, prop dead); a deliberation note on the post-staging slot (say the settings out loud, your words ARE the setup order, never name secret content, claim nothing as done); and the SPEC LAW in the deferred call — values the speech named are fixed and copied verbatim, values it deliberately left unnamed are chosen fresh in phase 2, and the speech outranks everything above it, which is how profile knowledge finally gets direct control of the form. deliberate_finish() makes the API call outside the lock and pushes the tag through the same extractor and appliers as any turn, appends it to the panel's memory, and routes refusals onto the outcome channel; it fails to nothing. _broadcast_turn was split into reply + extras halves so phase 2 lands cards without a second bubble. Two more beats close the loop, each its own dial: the verify tail (deliberate_verify() — ONE cheap look back with the speech and the landed handles in front of it, expected verdict kind=none, exceptional verdict one fix form applied through _apply_deferred_tag, the single door phase 2 and the verify now share; capped at form level, never a line split — a board's tag is itself multi-line and the first cut truncated its own fix — and it never re-arms a live card; every look books an event), and the coda (queue_coda() — one more short beat handing the first move to a named player, only on waits-on-players arms (deal · ballot · dice · collect, never a board or clock alone), only if nobody spoke while phase 2 ran, and with its own act call pinned OFF, because a coda speaks over the table and never sets one). The full turn on a game setup is therefore five beats — prop (think) → speech (explain, publishes) → act (set) → verify (check) → coda (hand the move) — all but the speech behind the bubble, ~12s worst case inside the ~10s-masked budget; ops turns are untouched. Dials: deliberate_first · verify_tail · coda, each a console row in The turn with a MAD_* box override; exam/run.py's _turn mirrors the worker so every gauntlet measures the production shape. ⚠ Two robustness levers ride this path. The second attempt: an act call whose every form refused used to learn that a turn later — a whole round played over a bare table in between — so it gets one bounded retry with the refusal reasons in front of it, converting the turn invisibly; never retried are a clean kind=none, a partial arm, or the quick gate, and both attempts bill. The house's draw (ledger #11) is the structural half of the same leak: draw="words" on a deposit, or draw="pair" with a ?1/?1/?2 placeholder deck on a deal, defers the secret to the server at apply time (CSPRNG, after the speech has published), so neither the act call nor the speech ever knew it and the tag — and so the history — carries only the pool name. lib/wordpools.py holds the two pools (everyday nouns · confusable pairs, zh/en, picked by the room's language); composition stays the author's (the ?1 vs ?2 counts), identity is the house's — a dial's resource like deck="poker54", never a game preset. The host reads the result next turn off surfaces that already exist: the deposit binding and the dealer's map.
Host-move blocks are routed out of the reply
extract_panel_blocks makes one pass over the decoded turn — the routing table is rolls · reacts · notes · reveals · boards · pads · gates · seals · votes · clocks, one row per move — and extract_outside_blocks salvages any tag the model placed outside its <speak>. It runs after hist is frozen, so the panel's memory keeps the grammar it actually used (the v539 lesson), while clients and events read the mutated replies. Each is applied in the drain by an _apply_panel_*: the server opens a roll<roll dice="2d6" for="all" label="定先手"/>, since T9 (v584) a kind="roll" instance card people roll on rather than a die armed on a table (_apply_panel_roll_instances; for= names the rollers, absent = the persona's own roll, which commits at arm and settles the instance at once) — deals a deck (<roll deck="狼人×2, 平民×3" into="safe"/> — the whole assignment shuffled and timestamped at arm, stored in state.json only, never in an event until a <reveal who="uN"/> opens one entry — since D/v625 that store is a kind="deal" gate on self.gates like every other instrument), folds a <note>斑马</note> into that same store — since T11 (v596) the envelope is sugar, not a notebook: it arms a self initiator seal card with the content deposited at arm, and <reveal>斑马</reveal> opens it by content (the open-the-oldest FIFO is gone; a legacy self.notes list migrates into seal cards on load, and the note_sealed/note_reveal capsules still replay). The router therefore splits <reveal> two ways: one carrying who="uN" is the deal's (deal_reveals), every other opens a seal card (seal_reveals). It pins the <board>, opens a <gate [options="石头, 剪刀, 布"] [trigger="…"]>question</gate> — since T10 (v590) a kind="seal" bid box, not a text circle, free-text or (with options=) one-tap choice, with a <seal> in the same reply as the host's own answer, committed at arm (_apply_panel_seal_instances) — or a <vote q="…" options="…" [multi] [anon]> poll on the same card, and sets the room's <clock>. Every collecting move takes an optional cast (for=_tool_who() resolves it: absent = everyone, a stale id drops silently, an empty pick refuses out loud), and each has an away/cancel way back down. ⚠ The <gate> was the hole in that sentence until 2026-07-27 — the human dialog had 谁来回答 chips all along, but the tag path had no participant list, so a persona-armed deposit always asked every human in the room: it could say 「only me」 and never 「only Amy」. <gate for="u1,u3">…</gate> now scopes it through its own _GATE_FOR_RE, exactly as <roll for=> already did, with the form's who compiling straight to it — and degrade-never-delete: a for= naming nobody present falls back to the whole room rather than arming a card no one can answer. Each rides its own SSE event: the three instance cards (poll · roll · bid box) all open, tick and close on gate, each with its own settle channel beside it — vote for the fired tally, rolled for the roll's rows (T9), sealed for the revealed answers (T10), and since T11 (v596) seal_reveal for the reveal message a long-lived card lands at the stream's now (its own replay branch too); the deal keeps the older dice-then-roll pair; the legacy sealed capsules and reveal cards still ride note; then board, react, clock. A reply left empty by its tags drops — a pure action needs no bubble — and a tag never renders as text: an unusable one still strips and is counted (clock_bad · vote_bad · deal_bad · deck_bad), never left in a bubble.
A move can hold the turn — or trigger the next one
Most moves just publish. What changes the room's control flow is now a property, not a kind (T8, v577–v578): the three collecting cards are instances on one store (self.gateskind ∈ {vote, roll, seal}, several coexisting unless unique), and each carries blocking ∈ {none, host, all}. none (the default) lets chat and panel turns flow straight past; host suppresses panel turns until the card closes, then hands the panel one turn to read the whole round; all additionally refuses room_say for everyone until close (answering stays reachable — it is on the card). Two combos are coerced in _clean_props, mirrored as locked buttons in the form: blocking=host ⟹ place=pinned (an inline card is lost the moment chat flows past a silent AI) and blocking=all ⟹ unique=yes (a locked composer cannot open a second one). The pre-T10 circle (kind="text"), which held every human message on the server as a sealed bubble, is retired — the composer never seals a message; a pre-T10 room with one still open releases it with what's in at load. The poll's own rule is the reveal gate (v570): gate_payload(viewer_uid) attaches counts (and, when not anon, by_opt) only for a viewer who has already voted — so a non-voter is never sent the standings, rather than sent them and asked not to look. ⚠ The gate SSE broadcast is viewer-less by construction and can therefore only ever carry the un-revealed shape; a tapper's own standings ride their vote_tap response, and every other voter re-reads GET /api/rooms/<id>/gate when a broadcast lands. vote_tap also refuses a second submission server-side (a vote is final — v571; re-posting the same set stays an idempotent no-op so a client retry never reads as a change) and refuses an empty one (membership of subs is what "has voted" means). Submissions store one shape either way — subs[uid] = {ii: [i, …]} — so single-choice is just the one-element case of multi. Its two siblings read differently, each for a reason: a roll has no reveal gate — a die's result is a server fact the moment it lands, and facts don't anchor, so every row rides gate_payload for everyone; a bid box is stricter than either — it withholds content from everyone until close (there is no tally to be anchored by, only your own answer, which rides the payload's mine filter), then reveals by person rather than by option. A seal_put, like a vote_tap, is final and refused on a second try — and since T11 that binding generalizes off the store: every unrevealed deposit is immutable, whatever card holds it (smoketest-pinned, non-negotiable). T11 (v596) added a second dial to kind="seal" alone — trigger ∈ {all-submitted, initiator, writer, time} (since promoted to every kind and split in two by task A/v620 — see the lifecycle step above; the four words survive as the wire alias), the answer to who or what opens the safe: all-submitted is T10 unchanged (close = reveal, in place, no message); initiator hands the key to the creator alone and never auto-closes — a member's force is refused server-side, the valve that makes an answer key an answer key; writer gives each depositor their own row, so a five-person prediction card opens five times and cues the panel none of them (a record, never a forced turn); time arms a server timer on the room's thread from reveal_at, re-armed from state.json on load so a deadline missed while the box was down fires exactly once (the clock's own pattern, verbatim). Anything unknown degrades to all-submitted, and a time with no legible fuse degrades to initiator (the T1 units law: never refuse, land the safe one). A card whose trigger isn't all-submitted is long-lived (_is_long_seal) and is deliberately kept out of the instrument cap (_active_gates — a standing secret must not crowd out a fresh poll), spending instead its own budget of SEAL_ROW_BUDGET = 12 unrevealed rows room-wide. The clock runs the other way: the countdown is client-side (the server publishes end_ts once and never ticks), but its ring is a server timer on the room's own thread, re-armed from state.json on every load — so a restart neither loses nor doubles a deadline. Ringing publishes clock and injects exactly one cue turn; a loop guard forbids that turn from setting a new clock.
Close and reveal are separate events — one lifecycle under every instrument
Task A (v620) promoted the three-phase machine out of kind="seal" and into every kind (the rule was extract, never copy — two copies agree today and diverge at the first fix). Every instrument now carries props.visibility · close_trigger + close_at · reveal_trigger + reveal_at + revealed{}, read through three module-level readers_visibility() · _close_trigger() · _reveal_trigger() (the old _seal_trigger, promoted). T11's four words were these two axes fused; LEGACY_SEAL_TRIGGER is the ONE place they are translated — on load (the stored trigger splits and retires), on the wire (trigger survives as an alias, so a cached shell still renders) and in the replay of a legacy seal_result. One evaluator, one timer: _maybe_close() / _maybe_reveal() / _all_in() / _reveals_on_close() are the whole state machine — every tap site asks them instead of re-deriving "is everyone in?" in its own words — and _life_arm() / _life_fire() own one server timer per room for every deadline of every kind (the T1 traps verbatim: non-daemon guard, evict_guard membership, re-arm on load, a missed deadline fires exactly once), handing the app layer a LIFECYCLE_HOOK of {action: close|reveal|shut, gate}. Coercions live inside the readers so no caller can hold a different opinion — visibility=all ⟹ reveal=on close (nothing is left to reveal when everything was public as it landed) beside the v578 pair. close_at is layered, never a substitute: _maybe_close asks the clock first, then the trigger, so a card with a deadline still closes when the last person is in. And ruling ① is wired end to end — _blocking_open() / _all_blocking_open() and the client's holdsFloor() all skip a closed instrument, so a closed-but-sealed card releases the panel and the composer at the CLOSE; _shut() is the shared close-without-reveal, recording the fact and queueing a cue naming who acted and never what — and only when the panel was actually held. Sealed content enters history and the transcript at the REVEAL, never before — the read-path law extended along time.
The visibility ladder — four rungs, enforced per row on the wire
Tasks B (v621) and C (v622) made who sees what while collecting an explicit axis, and the per-kind defaults ARE the old hard-coding written down: all (the roll — everything as it lands) · contributors-only (the poll's reveal gate — pay-to-see) · own (the deposit, and the default non-open mode — my row rides the wire, everyone else's is withheld) · none (nobody has looked, the contributor included). _shows_content(g, key, viewer) is the ONE place the axis is enforced — a masked row carries no result key at all, so there is nothing to hide in CSS. The sealed roll is SET, not built: 暗掷/盲掷 is kind="roll" + a visibility rung + a deferred reveal_trigger, with no parallel path. Three helpers joined the shared layer for it: _life_stamp(…, close_trigger=…) (a kind that knows its own close states it — a sealed roll closes at all-in, the deposit does not), and _in_uids / _life_title / _life_words, the one per-kind vocabulary table every shared cue and riding note reads. The reveal and the valve are kind-agnostic routes: gate_reveal / gate_close behind /api/rooms/<id>/gate_reveal — T11's seal_* names and the old /seal_reveal path both still answer, because a cached shell posts to them. Because a persona has no wire payload, the parity law is implemented in the riding note: _one_gate_note() splits per rung, telling a persona its own face and only its own under own, and 「NOBODY has seen a result, you included」 under none. ⚠ The parser's opens are dicts now (_GATE_OPEN_KEYS / _VOTE_OPEN_KEYS + _op_dict) — task A's positional tuples had reached eight slots; a tuple still reads, for the old batteries only.
The deal joins the store — and the dealer's eyes become a setting
Task D (v625) moved the last tool off the old model: kind="deal" on self.gates with cards{} keyed "uN", born closed — there is no input door to shut, so a deal never blocks and never waits (dealt → revealed, two states) — and reading its rung off the same _shows_content() as everything else: 明发牌 = all · 暗发牌 = own (the default; a card is flipped for its owner the instant it lands, which is what retires the pickup ceremony) · 盲发牌 = none. Three per-kind facts joined the shared layer rather than forking a path: _my_row_key / _row_store (the "uN vs str(uid)" difference, in ONE place), a row in _life_words, and _dealer_sees(g) — whose coercion (sees ⟹ holds no card) lives inside the reader, so a dealt-in dealer's eyes are unexpressible rather than policed. That grant is spent in exactly two places: _shows_content (a human dealer's own wire) and _deal_map_note (a persona dealer's, which IS its wire). <reveal who="uN"/> now opens a row on the gate through gate_reveal, and the last one settles the card — the deal is the one kind with a per-row reveal record, which is exactly what ruling ③'s "own row only" needs. The legacy self.deal path stays for a room that already holds one (replay · deal_mine · the pickup) — degrade-never-delete — but its riding note goes through the SAME builder, so there is one mechanism with two callers. v627 added off-the-shelf decks (poker52 · poker54 · tarot78) as card names worth 52, composable with hand-written cards, and raised the deck cap 24 → 216: the spec is built from the PARTS, so the card reads 「Poker 54 ×2」 and never 108 values. v633 then took the floor down to arithmetic and the hand up to a list. One hand is a deal: the two-person floor encoded a reading (a deal DISTRIBUTES) that a solo tarot draw refutes, so the only rule left is at least one hand, and enough cards to fill every hand — the parser refuses only an EMPTY deck, and the arm is what refuses a deck the hands outnumber (counted, never silent). A hand may hold more than one card: each="5" on the tag (_ROLL_ATTR_RE gained each beside readout), a 「Cards each」 stepper in the dialog, capped at 13. The deal is round-robin off ONE shuffle at arm — hand i takes every nth card — which leaves the fairness claim exactly where it was. A stored hand is a list, read through one _hand_cards() / handOf() per side, and the wire carries both (cards for anything new, card as the joined alias a cached shell reads) — degrade-never-delete, and it earned itself immediately when a stale v632 shell rendered a five-card hand correctly. ⚠ The privacy assertion had to be re-stated, not merely re-checked: 「cards lives in state.json and never in the event log」 stopped being true the moment a hand became a list, because a deal_reveal legitimately carries the hand it just made public. The honest invariant is the SHAPE — the assignment is a dict keyed uN, a revealed hand is a list, and no event may ever carry the dict.
The readout — and the panel-blindness bug behind it
v630 made 「what does this roll COUNT?」 a property (props.readout ∈ {sum, faces}, a bare faces flag on the tag for a GM-hosted table), moving the total on the card, the record and the panel's line together — one dial, three surfaces, so a host and the room never read two spellings of one roll. It was found by the bug underneath it: every panel-facing roll surface built its line from result alone, so a 吹牛 host read 「你 12 · Dan 20」 and could not count a six — and on a 盲掷 that line is the only path a number has to a persona at all. The invariant, now written in ONE place (_panel_roll_face / _readout / _face_list): THE FACES REACH THE PANEL IN BOTH READOUT MODES, unconditionally — there is no game in which withholding the individual dice from the host helps. The toggle moves what is emphasised, never what is available. ⚠ Wherever the readout governs (2+ dice) _panel_roll_face is the card's spelling character for character (fmtRoll in room-ui.html) — a shared readout whose two sides disagree is worse than no readout. Faces sort under the faces mode (counting 「几个6」 off an unsorted list is the work the tool exists to remove); a single die has no readout to make and is untouched by either path. v632 is the UI half: the readout row is LOCKED at n=1 with its reason rather than hidden, because the dialog opens on 1d6 and v630's hide-below-2 rule made the new control invisible in its own default state.
One setting sheet — and the three dials the human path could not reach
v634 collapsed the five setup dialogs (poll · dice · slots · deposit · deal) into one sheet generated from one config, so a difference between two sheets can only ever be a difference in their config. Server-side it landed as three dials added to the doors that already existed, not beside them. (1) close_trigger now reaches user_vote / user_seal_open / user_roll_open and the tag grammar (close_by="…"), and 发起人点击 is enforced rather than decorative — with it set only the opener's hand closes the card, and it is the default, so a poll no longer closes by itself at all-in. (2) decks — a named deck's ×N shoe, on the form and in compile_instrument. (3) 各自点开 on a sealed roll is real new machinery: the dice had no per-row reveal record, so _roll_row_reveal + a roll_reveal event were added — a participant opens their own face while every other stays down, the valve being own-row-only; _norm_roll_reveal stops degrading playercreator. Persona parity is now LINTED: smoketest's form↔grammar section only ever looked at the roll's attributes (the ballot's and the collect's were never linted at all) — it now compiles a maximal form per sheet, parses it back with the same extractor the room uses, and reads every dial out of the parsed dict. INSTRUMENT_SCHEMA gained close and decks. Two treatments are load-bearing in the form itself: a phase a tool cannot have is not drawn (发牌 has no 收集/截止 — ruling ⑥), while an option that is real but unavailable is greyed and struck, kept visible — hiding it would destroy the difference between 「not offered here」 and 「never existed」.
The pad — the eighth tool, and the one that is deliberately not an event
v636 added a private, mutable, per-owner text surface (<pad>…</pad> + <pad away/>, routed as pads/pad_aways through extract_panel_pads; POST /api/rooms/<id>/user_pad for a human). It is not a board with a visibility dial — the test that separates them is whether the audience changes what the object is, and a private board does not stop being seen, it stops being posted: furniture the room reads becomes memory the owner keeps. THE STORAGE SHAPE IS THE SECURITY. self.pads lives in state.json and is never an event (the deal's idiom, taken further) — replay(), transcript.md, the export and the Seen digest all walk events or the transcript, so a pad cannot reach any of them and no filter has to remember to exclude it. There is deliberately no SSE channel (a viewer-less fan-out is the exact failure gate_payload(g, viewer_uid) exists to prevent) and no notice (「someone wrote something you cannot see」 is a notice about nothing and a signal that there is a secret). Every read is keyed by owner: pad_payload(viewer) can only ever return that viewer's own and returns None for a viewer-less caller, and user_pad() takes no owner argument, so the route has no parameter through which one person could name another. Capped at PAD_MAX = 2000 chars with a warn at PAD_WARN = 1600 — the pad is a compounding per-turn tax nobody can see — and a write over the cap is refused, never truncated, because trimming deletes the owner's words while reporting success. For a persona the pad rides every turn via _pad_note; in a multi-persona room the panel is one call over the whole cast, so each seat's pad rides labelled and the seat wall is discipline (as [Your sealed notes] already is), while the human wall is structural — a u: key is unreachable from _pad_note. Also proved here by construction: the board has no visibility dial — the form↔grammar lint compiles a form setting visibility/who/place/blocking/reveal/close onto a board and onto a pad and fails unless both land on exactly title + body.
A card you can name — the handle, the act verb, and one table for the panel
Sequence step 1 (2026-07-28) let one beat arm several instruments: the act call returns a list of forms, and describe_instruments is the plural of describe_instrument precisely so the redaction runs per instrument — handing the joined tag string to the singular function would return only a note's 「it is sealed」 and silently swallow the board and the ballot beside it. That made the next failure the interesting one: with three cards out, the room had no way to say which. Step 2 — the address layer answers it. Every card is minted a handle kind#n in _life_stamp (the one choke point every arm path of every kind already passes through), ordinal per canon kind (deposit, not seal; spinner, not wheel+mode — so step 4's renames leave handles alone), monotonic and never recycled: the counter lives on the room and is persisted, because max(open)+1 would re-issue deal#2 the moment the first one closed and every stale reference would start hitting a card it never meant. A stale handle must MISS. A legacy room is numbered by arm order with its counters seeded above everything found, so a mid-version save cannot collide; the migration is idempotent and never renumbers. The lifecycle verb is one new tag — <act op="close|reveal|away" target="vote#1"/> (_ACT_OP_RE · _ACT_TARGET_RE), one extractor, one applier routing into the existing close/reveal machinery — deliberately a new grammar rather than target= on the three legacy verbs, which resolve by guesswork (oldest · newest · content-match) and keep doing so forever for old transcripts. An unknown handle, a wrong state, another seat's card, a close_by that forbids it: each refused, and the refusal names what is on the table. That is the outcome channelcompile_form returns (tag, why-not), and the over-cap arm, the unusable spec, the hollow seal, the row budget and the card-short deal all ride the next turn once and clear (persisted); a refusal that dies in stderr teaches the model the tag worked. And _gate_note()'s eight prose paragraphs became one TABLE — handle · tool · title · state · what it wants, one row per card, the shared disciplines said once beneath it — reading the strip's own _strip_title/_strip_count. ⚠ The panel is not one viewer: the table is the union across seats with per-seat labels, and the own-sealed redaction now rides every turn, scoped to the deposit (a 暗掷's own face is explicitly its to bluff about). GATES_MAX_OPEN 4 → 8.
Two cards of a kind must be tellable apart — and one name per thing
Step 3 · the uniqueness invariant (v646): 「close that one」 only works if 「that one」 picks out exactly one card, and a title you cannot tell from another title is no better than none. title_collision(kind, title, mode) is the single predicate, and it returns the reason a card may not go down rather than a boolean — the refusal is built from that sentence. It compares per canon kind (handle_kind), so a spinner and a die never need to differ; the first card of a kind may stay untitled (kind#1 is its address) and every one after it needs a name, and a name not already on the table (_name_key normalises spacing and case). It is called at both arm doors — the human route and the panel applier — and never in the pure compiler, which has no room to ask. The carve-out is the <note> sugar: it has no title field in either door by design and its only door is its own owner's reveal, so enforcing a name there would retire the tool. Long-lived deposits are not exempt — they are cap-exempt only. Step 4 · the naming batch (v647) then made 「one name per thing」 testable, because a naming convention is a promise and a promise is not a test. One alias map (INSTRUMENT_ALIASES + TOOL_CANON) is the only place a legacy spelling may appear in new code and every reader goes through instrument_concept(). The compiler emits canon<spin> · <deal> · <deposit> · <answer> — while the parsers accept every legacy form forever, not transitionally: a room opened a year ago replays through today's parsers, so <gate> must mean today what it meant when it was written. Closers are back-referenced on the tag name rather than an alternation, so <deposit>…</gate> pairs with nothing and cannot swallow the next card's question. One door per concept: gate/seal/note were three doors onto one deposit, and the spinner, which had none of its own, earns one. The manual and the act brief are rewritten in canon with the displaced words kept as reach-for synonyms (the tags.txt trick applied to tools), and the screens follow — Slots→Spinner, 老虎机→转盘, the 摇 ladder→明转·暗转·盲转, the commit verb 密封→存入, and the 封存笔记 preset loses its name (copy describes, never mints a second one). The whole thing is held by a glossary lint in smoketest.py; ⚠ the one surviving tag debt is <note>, declared in that lint's ratchet rather than pattern-dodged, and lowerable only. ⚠ A manual edit is a regression event (step 2's law, learned twice — twice, adding a capability to the brief quietly subtracted one, and both times the words were correct): the exam's full battery re-runs, not just the renamed scenarios.
Broadcast the reply + record usage
_broadcast_turn() publishes a reply event (confer + replies + cost) to all viewers; _record_turn stamps the turn event with the model + effective reasoning + secs. _record_usage_split writes a turn_events row per contributor (cost split 1/N across a batch; the cost = panel + any dispatch fee + the floor-producer call, so the SQLite totals + per-user caps match the live ledger).
Browser hatches the egg
The reply event arrives on everyone's stream; the egg hatches in place into the confer capsule + reply bubbles (markdown / math / code rendered client-side). If a dispatch ran, its brief showed first as a dispatch card. A separate per-user stream lights the unread dot on any other room that got activity.
⚑ THE PRESENCE GATE — a turn nobody asked for only runs if somebody is there (2026-08-05, someone_to_hear() / quiet_room(kind), window MAD_PRESENCE_WINDOW / setting presence_window, 10 min). Everything above is a turn a human caused. The room has a second population of producers that start themselves — a device's dead man, a clock's ring, an instrument's own deadline, the wake door, the coda, the promise check — and none of them ever asked whether a person was present: merely loading a room re-armed its dead man, so glancing at a chat and leaving started a countdown to a paid turn. The fuse caps that waste at ~12 calls per room and cannot prevent a single one of them; a bubble nobody reads is 100% waste even when it is the first. Two ways to be here, either is enough: somebody is holding the room's live SSE stream open (the PRESENCE_HOOK over the room-view registry), or a human acted inside the window (_human_at). ⚠ It needed no taxonomy of producers. Every human door stamps the clock before the turn it causes is drained, so everything a person's own action set off rides through by construction — what is left over is exactly the set with nobody in particular waiting on it. No cue needed a marker; no producer needed teaching. Dropped, not parked — a parked pile would greet the returning human with a burst of stale 3 a.m. announcements about a room whose current state is already on their screen — and a reload is not a person: an old room wakes up gated and speaks unasked only once somebody arrives. The quiet spell is booked once (a room_quiet event + one stderr line, zero model cost — the fuse's own shape, one layer earlier). ⚠ Fails open in every direction: a broken hook, a window of 0 (the harness pin), or a batch carrying a human's own line never mutes a live room. The world keeps running; only the minds stay quiet — the board still updates, the wheel still spins, the device still folds, all free and all already on the screen. What is skipped is the narration.
2b · the web dispatch

The @Web Search correspondent

The web search belongs to the room, not to any host. A guest writes @Web Search pull the news about X (an @{a:…} marker); the worker runs one off-lock correspondent round that distils a neutral fact-brief. The engine is admin-set (console → Models & APIs → "Dispatch — search engine"), a two-way switch: Claude native = a Haiku + server-side web_search call; Search API + DeepSeek V4.1 Flash = a SERP query (lib/websearch.py) routed by the query's own language — English walks Serper→Brave, Chinese (CJK) walks Bocha→Serper→Brave, keyless engines skipped — whose results DeepSeek v4.1 Flash distils — same brief, ~1/10 the cost, snippets instead of full pages. The brief lands once in the dialogue as a dispatch artifact; the hosts then react to it in their own voices on the same turn. Web Search is not a host — no profile, no voice, no turn of its own.

on_searching(n) → SSE searching
Fires before the fetch — the clients show an "assistant is searching" bubble.
_fetch_dispatch{brief, title, usage, cost, searches, engine, model, secs}
Engine anthropic: a Haiku web_search call, capped at DISPATCH_MAX_SEARCHES=1 — most of the cost is the search results, billed as input tokens. The serp pick (_fetch_dispatch_serp): one Flash call shapes the query, websearch.search() fetches 8 results down the query's language chain (en Serper→Brave · zh Bocha→Serper→Brave), one Flash call writes the brief. Each path bills under its own model in the per-model split. The refusal guard (is_refusal_query, 2026-08-08) sits between the two: Flash declines some topics in the query slot, and its apology used to be spent as the search query — the room then reported "no information available" for a question the engine would have answered. A refusal falls back to the guest's own words; the brief writer, given real results, writes the brief normally.
on_dispatch(payload) → SSE dispatch + artifact
Emits a dispatch event and a kind="dispatch" wildcard artifact, landing the brief for everyone before the egg.
on_start() → SSE respondingreply
The egg rises and the hosts' panel turn proceeds normally — the brief is now part of the prompt.
Live SSE sequence on a dispatch turn: searching → dispatch → responding → reply. A normal turn skips the first two.
2c · how a room gets made

Two doors into a new room

Before the first turn there's the creation step. Hand-pick is the plain path — choose the cast from the library and open. The smart panel selector is the other door: the user describes a situation and a single curator call (a DeepSeek pass over a compacted catalog — slug + one-line role + tags.txt, not full profiles) returns three candidate panels, each seat with a one-line why, plus a streamed auto-title and a topic-aware kickoff. The curated set drops into the same editable run of seats; the room then opens normally. The describe box also seeds the room's target language (detected from the text).

Hand-pick commits both fields (v843). POST /api/rooms has always taken cast and humans — the compose page simply never offered the second, so the only route to a human chat was a hatch that left to create a different room. The page now holds the picked people beside the cast (a person is not a seat, so they are separate sets, and the people picker gained a compose mode that collects and hands back instead of opening a room) and sends both; its gate allows people alone to start a chat. Both doors are sub-pages now rather than one page and one card (v829–v830): the picker's new mode lost its last dialog form, so "new-chat is still a dialog" stopped being a per-ship regression check.

3 · two channels that never cross

Conversation vs. presence

The hardest discipline in the design: the conversation (what the panel remembers) and presence (who's typing, the egg, "is anyone here") are kept completely separate. Presence is RAM-only and is never written to disk or fed to the model.

persisted · the conversation

The panel's memory. Survives restarts.
  • human lines, panel replies, confer, artifacts
  • dispatch briefs (a kind="dispatch" artifact)
  • cast joins/leaves, member notices — "X joined", and a human EXIT (walked or removed, removed_by) — model switches. v1023: the exit event is still written and broadcast as the room's audit + the roster's live drop, but the chat SHOWS nothing for it — the "X left" / "Y removed X" pill is retired.
  • written to state.json (LLM history) + event-log.json + transcript.md
  • reactions + delete-for-everyone — append-only react / redact events in the log, projected at read time (never surgically erased)
  • SSE events: post, reply, dispatch, member_join, member_leave, member_avatar, history_private, react, redact

ephemeral · presence

Live feel. Gone on disconnect — never persisted, never seen by the model.
  • "X is typing…", the conferring egg
  • "panel is responding" + "assistant is searching" signals, unread dots
  • RAM-only via the hubs — touches no file, no LLM context
  • SSE events: typing, responding, searching, activity, scratched (a stopped turn), removed (you were dropped from a room), member_rename (a live relabel)
Two streams carry these. A per-room stream (/api/rooms/<id>/stream) carries everything for the room you're looking at. A single per-user stream (/api/stream) carries one activity ping for any room you belong to — that's what lights an unread dot on a chat you're not currently in, in real time, without polling. It also carries removed (you were dropped from a room — a kick evicts silently, exactly like a self-leave) and notice_sync, the notification rail's live mirror: rows added, revoked, or read elsewhere land on every open client. The rail's truth is the notices table, never this stream — so a closed app loses nothing. A closed app is reached instead by Web Push (gap 1, v489): the same rail events — plus a message-while-away bubble — mirrored to the lock screen via VAPID (_push* → the push_subs devices), suppressed for the room you're actively viewing and coalesced per room. Push is a pure add-on; a device its push service can't reach still has the whole rail on next open.
The top strip is the server's set now (v635 — strip_payload(viewer_uid), behind GET /api/rooms/<id>/strip). v624 computed the pills client-side out of the gate payloads, which was safe only because those happen to be viewer-scoped — and the moment 「a collect you have not answered」 joined the obligations, one careless gatesArr() would have leaked who has not answered yet in exactly the rounds built to hide that. So the set is computed server-side, per viewer, and there is deliberately no broadcast channel: a viewer-less gate event says only 「your strip is stale」 and each client re-reads its own from the route. Three zones behind one rule — 待办 (a collect you have not answered · a reveal only you can fire · your own row to open; never compresses, two then a +N list sheet) > 状态 (board · the room's wall clock · a pinned card you owe nothing on; yields width first, down a four-rung ladder to one merged chip) > 通知 (a timed reveal that just fired · a collect that just opened without you; borrows the 状态 zone, owns no width, retracts by itself) — ordered that way because an obligation is the only thing a person can fail to do. The reframe that makes 375px tractable: 「pinned」 is not a category — pinning is a property of a card, a pill is about your relationship to it. A card appears exactly once, never in both zones, and a pinned card's countdown fuses onto its own chip; per ruling ⑨ the strip's ◷ is the room's wall clock while a card's own deadline stays on the card. Empty is gone, not empty — no reserved band. The ladder is measured, not counted (the same two pills are 249px in English against ~210 in Chinese, and --read-scale 1.3 blows through both): the count picks the starting rung, a tape measure picks the final one, and the merged chip sheds pictures rather than ever being sliced. Ruling ⑫ · persona parity: the closed-card riding note used to say 「nothing is wanted of you」 on a card the panel itself had to open — the one sentence a host would obey — so it now names the act in the pill's own verb, once (owed_told on the gate), and only for a door the grammar actually has.
Perceived-speed endpoints (shipped). Layered over the streams: a one-shot /api/boot snapshot (the whole opening view in a single round-trip), delta sync (pull only what changed since a cursor), and a durable client send outbox (an optimistic local echo + background retry, so a flaky network never loses a line). Together they make the app feel instant on a slow link — see Perceived speed. ⚠ A one-round-trip open buys a consistency problem per resource it folds in, and the third one was the persona library (v908): LIB was written once by applyConfig out of the boot payload and never again, so every library-backed surface was the library as of that device's last cold start. /api/config is a live resource now — revalidated lazily (foreground + any library-backed page opening, throttled 20s) and prompted by a library event on the user stream at the eight verbs that move it (build · hidden · visibility · clone · delete · discard · purge · restore), plus a forced read on every stream reconnect. What makes the cheap triggers affordable is the validator: an ETag over the whole payload with the client sending If-None-Match by hand, so the common answer is a 304. ⚠ The tag hashes the payload, not the persona roots' mtimes — a root's mtime does not move when a profile.md inside it is edited, and half the fields are DB settings that touch no file. ⚠ The SW passes /api/* through, so heuristic caching must never own the library's freshness.
4 · where state lives

The data model

SQLite (app.db) holds the relational, cross-room facts. Each conversation's content stays in its own flat files under rooms/<id>/ — the verbatim LLM history is plain text, so any model can pick a room up.

storeholdswhy there
usersaccount, password hash, role, the per-user spend caps daily_usd/weekly_usd (the old turn-count daily_cap is retired, kept for back-compat), display name + about (the profile one-liner, gap 11), default_lang — the "Default language" that rules both the panel's language in new chats and the UI locale — and gap 13 phase 4/5's four privacy cells: findable (may handle search reach me — ON by default since v680, with a one-shot backfill for accounts that predate the flip, because a boot-time UPDATE would silently re-open the switch for anyone who closed it deliberately), link_code (my rotatable personal add-token, minted lazily — a token and not a user id, because an id is enumerable and a shared link must be revocable without changing who you are), add_policy and profile_vis (NULL/reach | friends; set_policy() stores NULL for anything that is not literally friends, so one spelling means open and a stale client string can never silently narrow a setting). Since 2026-08-18 two more, for persona memory: memory_note (the ≤400-char 「what personas should remember about you」 field, riding a system block verbatim) and memory_on (default 1; 0 stops both halves — no host reads a row about this person and no harvest writes one — and deletes nothing, because a switch that silently destroyed the record is a worse promise than one that stops using it). And since v947 one more, for how the hosts address a person: gender''/NULL unset | female | male | nonbinary | other, set from Edit profile’s own row and never sent to another user. ⚠ The vocabulary is closed and an unknown value stores as UNSET rather than being refused: the field’s whole job is to stop a persona guessing, and a value nobody can interpret is not better than no value — it is a guess with a database row. ⚠ And the cell is not the answeraddress_lines() is: it turns the plain fact into the pronouns plus what to reach for if a voice reaches for a title, one line per person who has said, riding the memory block beside the standing note. Nobody who has not said gets a line, so a room where the field is unset carries not one extra byte. And since 2026-08-20 one more, for growth: tz — the person's clock in minutes east, as their client last reported it. ⚠ Written by the room, read by the writers: the memory dream dates 「no longer true since …」 by it and the date line tells a persona what day it is where the guest is, so both land on the person's own day rather than on UTC's. Since 2026-08-13 one more cell: super_user — the tier above admin (the model). ⚠ A flag beside role, never a third role value: the app asks role == "admin" in ~45 places, and a third string would have turned every one of them into a site where a super silently loses a power. As a flag the tier can only add, and no client gate had to change. The first one is minted out of band (lib/make_super.py) because only a super may grant the tier, and the flag is read per request — no restartSQLite — looked up by name across rooms
sessionscookie token → user, expirySQLite — every request checks it
roomsid, title, owner, status, updated, history_private (one-way)SQLite — the registry + scoped lists
room_memberswho's in a room, role, last_read, see_from (history floor), status (per-user archive / trash, v515 — NULL = active; a shared chat belongs to everyone in it, so one member's housekeeping never edits another's list)SQLite — membership gate + unread + private-history cutoff
turn_eventsappend-only, one row per turn: in/out tokens, cost, model (wall-clock secs + effective reasoning live in event-log.json, not the row). Every API books here — real rooms plus special room_ids builder (Persona Studio), convene (the curator), seen, stt (composer dictation, model iflytek-zh-iat) — so Usage & cost counts all spend, not just room turnsSQLite — daily/weekly caps + per-model/day/user/room stats
call_eventsThe call ledger (v935, the dashboard) — one row per provider call, where turn_events books one row per turn: ts (ISO UTC) · room_id (a real room, a function pseudo-room, or NULL for a global sweep) · user_id (NULL = a system sweep, nobody to charge) · kind — a closed group.leaf vocabulary (speech · fp · tool.act · kit.ruling · persona.dream · …) — · model · the four token classes in_tok/out_tok/cw_tok/cr_tok · cost. The two tables are complements, never substitutes: a turn row answers 「who owes what」 (the spend caps read it), a call row answers 「which step is the money」, which no per-turn sum can answer because one turn is speech + fp + tool + kit + … . ⚠ ONE SOURCE PER DAY — both cover the same money after the ledger’s first row, so a reader picks by date: call_events at/after ledger_start(), turn_events strictly before it. Summing both double-counts. Written at the one seam every provider call already passed through (book_call), which is how it turned up three spends that had no bill anywhere — the nightly memory dream, the growth sweep and the embedder. It is also what the chat’s own Cost panel reads (/api/rooms/{id}/cost), by room, so the panel and the console can never disagreeSQLite — three indexes, one per question: by ts, by (kind, ts), by (room_id, ts)
settingsadmin config: enabled models + default, default caps, feature visibility, TTSSQLite — one key/value table
invite_codesminted codes (the console's Invites tab · Me › Settings › Invite code): label, uses, max, expiry, active, and lang (v1033) — a language the code carries, so /api/invite-check can open the signup door translated and signup stamps it as the new account's default_lang. NULL = the invitee's own device decidesSQLite — revocable, use-limited signups
noticesThe notification rail: one row per live concernkind (build_done · build_failed · room_invite · ink_failed — 2026-09-03, the house notice: an Ink pass that dies in the night names its stage on every admin’s rail, collapsed on the day and revoked by ink_ok when the day publishes after all; it renders on the rail’s generic branch, so no client change rode with it), title/body, kind-specific data JSON (the CTA's target), ckey (an FCM-style collapse key — same (user, ckey) upserts, so it's an inbox, never an event log), read_at (NULL = unread → the badge on the Me tab, and the count on Me's Notifications row; it was the ≡ dot until v679 retired that button). Revoked on the counter-event (notices_revoke by ckey · notices_revoke_for_room when the room a CTA points at is trashed), consumed on arrival (notices_read_ckey)SQLite — durable first; SSE (notice_sync) is only the live mirror, so nothing is lost with the app closed
hidden_msgsDelete-for-me: one row per (room_id, user_id, lid) a user has hidden from their own view. No FK on the room — rooms live on disk, this is a per-user view fact. Its sibling, delete-for-everyone, is not here: that's a redact event in the room log, tombstoned at replaySQLite — enforced server-side on every user-facing replay exit (_hide_filter), so a hide holds on every device
msg_indexOmni search's history half (v753, design): one row per spoken line(room_id, lid, ts, who, text, fold), keyed on (room_id, lid). fold is the v895 CJK-folded copy of the text, stored only when it differs (NULL otherwise — see lib/cjkfold.py in the file map): most lines are Latin or already simplified and fold to themselves, so the common row costs nothing while the traditional/Japanese line pays for the second copy it needs. ⚠ ts and lid are not there to match anything; they are there because this is a SECOND read path and every filter the first one applies must be re-appliable without loading the room — the history floor is enforced by ts per room exactly as replay(floor) does it, and delete-for-me by the caller's own lid set. Written by Room._index_sync() off persist(): one predicate (a speak event) finds every searchable line and only the tail is walked, so an ordinary turn costs one insert. ⚠ Redacts are applied AFTER inserts, always — a line can be spoken and taken back inside one tail, and the other order would delete the lid and immediately put it back, findable forever. The high-water mark lives in RAM deliberately: a room evicted by the LRU re-indexes itself whole on rehydration, and because (room_id, lid) is the key that is a repair, not a duplication. lib/migrate_search_index.py is the one-time backfill — ⚠ a deploy needs it, or history search returns nothing for every conversation that already existsSQLite — searchable without hydrating 1,600 rooms; the rooms on disk stay the truth
moment_roomsVibes: a moment key (slug + text hash, minted by the client) → the one shared room (Open Circle) every "Join them" joiner lands in. ⚠ THE INK CARVE-OUT (2026-09-02): the Ink door (v1054) reuses this table as a reopen index under its own key shape (ink:<uid>:<piece>), which is how a second tap on the same piece returns to the same room — but such a room is a normal private chat owned by the reader, not a moment room, so every reader here excludes the ink: keys (_MOMENT_NOT_INK) while get/set_moment_room stay key-agnostic. Without that split an Ink chat would fold into the「Chats from Vibes」folder, wear the public-group hint, and be locked the way the masters' group is — three wrong answers from one shared indexSQLite — one room per moment, re-bindable if retired; and the Ink door's reopen index beside it
vibes_postsVibes inventory: one row per composed post (moment_key unique, kind, poster, payload JSON, released_at / expires_at) — the ingest CLI fills it, a lazy scheduler drips it liveSQLite — the feed's stock, served as slates by /api/vibes/feed
vibes_seenVibes: which posts a user has already been shown, so the slate resurfaces on tier-crossing without repeatingSQLite — per-user feed de-dup + unread bumps
vibes_brewsVibes: one row per brew run (the daily automation that stocks the feed) — trigger (schedule | watermark — fresh stock fell under the knob | manual), how many ever/news posts it made, estimated engine cost, took_s, status (ok | partial | error | running)SQLite — the console's brew HISTORY strip; lib/vibes_brew.py owns the knobs + the run
vibes_likesVibes: one heart per real human per post — (moment_key, user_id), idempotent, so it survives a refreshSQLite — set/cleared by /api/vibes/like
ink_feedbackInk (v1045): one reader’s verdict on one piece — (piece_id, user_id) unique, carrying edition and verdict (up | down — the article’s 👍/👎). Idempotent and re-tappable to nothing: tapping the standing verdict deletes the row rather than writing a third value, so “no opinion” is the absence of a row and never a stored one. No FK on piece_id — an edition is content-as-files, exactly as personas are, so the house convention holds (persona_follows, vibes_likes)SQLite — set/cleared by POST /api/ink/feedback; read back per user by GET so the article re-opens with the reader’s own mark
ink_notesInk (v1057): a reader’s written verdict — piece_id · user_id · edition · text · ts, appended, never upserted (a second thought is a second note, not an edit). This is the calibration material: the quality score’s judges (lib/ink_score.py’s E and V) are model judgments, and R — what real readers said — is the ground truth they are tuned against, so the notes are kept whole rather than reduced to a number. The console’s edition view lists them per piece — and since v1088 so does the writer of them: db.ink_notes_of(user_id, piece_id) hands one reader their own notes on one piece, newest first (50 at most), and the Feedback page paints them under the box with a date each, nothing sent meaning no block at allSQLite — written by POST /api/ink/note (the article’s ⋯ → Feedback… sub-page), read back by GET /api/ink/note?id= for the reader and by the console for everyone
ink_readsInk (v1070, owner:「the completion rate per article is important data」): the verdict the reader never has to give — how far each one got in each piece. (piece_id, user_id) primary key carrying edition · depth (0–1, the furthest point of the body scrolled into view) · secs (time on the piece with the app visible) · opens · ts. ⚠ The upsert is the whole design: the depth takes MAX and the seconds and opens add, so a reader who comes back to finish a piece deepens their own row rather than overwriting it with a shallow second visit. ink_reads_tally() folds it per piece into {readers, completed, depth, secs}, completed counted at DB.INK_COMPLETE = 0.9 — 90% of the body is the end matter, not the last pixel. No FK on piece_id, the same content-as-files convention as ink_feedback/ink_notes. ink_readers_by_day() (item 7, 09-11) maps an edition date to the set of readers who opened any of that day’s pieces — the date is read off the piece id’s prefix, so older ids without one are left out — and the console’s Ink days turn it into Came back: of the previous day’s readers, how many read this oneSQLite — beaconed by the client to POST /api/ink/read when a piece is left (back · another piece · the app hidden · pagehide, over sendBeacon with a keepalive fetch as the fallback); read by the console’s edition rows and by the quality score’s R
ink_marksInk (v1096, owner 09-11: the highlighter): one reader’s mark on one passage of a piece — piece_id · user_id · kind (hl a highlight | note a highlight with a comment) · field (title | dek | body) · para · s/e (character offsets into that element’s text) · quote · note · lang · ts, indexed on piece_id. ⚠ The quote is the truth, the offsets are the hint: a piece is content-as-files and its renditions are separate texts, so the offsets drift between languages — the verbatim passage is stored beside them and is what the client matches on, which is also how ikExistingMark tells whether a fresh selection is sitting on a mark that already exists (same field and paragraph, and the spans overlap or one quote contains the other). Rows are appended by ink_mark_add, handed back per reader per piece by ink_marks_of (400 at most, oldest first), edited in place by ink_mark_update (v1097 — a second Comment overwrites rather than stacking) and removed by ink_mark_del (v1099 — unhighlight · Delete comment · an emptied box); both writers are scoped AND user_id=?, so another reader’s mark is a miss rather than an edit. ink_marks_all() is the study’s pull — every mark with its writer’s name joined on, for reading what passages readers actually stop at. No FK on piece_id, the same convention as ink_feedback/ink_notes/ink_readsSQLite — written by POST /api/ink/mark and POST /api/ink/mark/delete, read back by GET /api/ink/mark?id= so a piece re-opens wearing the reader’s own marks
dm_pairsDirect messages (gap 9): a human pair (user_lo,user_hi; lo==hi = notes-to-self) → THE one canonical 1:1 room for that pair while it stays AI-free (re-tap returns to it; ON DELETE CASCADE with the room). The binding is releasable (v530): seating a persona converts that room to a group and clear_dm_room drops the row, so the pair's next "Message privately" mints a fresh, AI-free 1:1 — no single member's invite can take the private channel away. dm_open also releases a legacy binding whose room has since gained a castSQLite — one live DM per pair; a zero-persona room
friendshipsThe contact layer (gap 13, design; phases 1–5 shipped 2026-08-09, the QR scan following over v859–v891): one row per pair (user_lo,user_hi — dm_pairs' convention) carrying TWO per-side state cells, lo_state/hi_stateok·pending·gone·blocked, plus asked_by · note (the ~140-char verification message) · via (a machine value, never printed raw — room:<id> | qr | search, with link surviving only on rows written before v878 scrapped that door; read back on the profile's From row as Group chat / QR code / -ish ID search, and ⚠ since v866 the room case stops at「Group chat」 — the id is not read at all now, which retires the "never name a room this viewer cannot see" care with it) · asked (drives the re-ask cooldown) · since · lo_remark/hi_remark (备注, phase 4). _friend_write() is the only writer of a state cell — the whole design in one function: removal is genuinely unilateral (I write only my half), a decline is silent (the asker's cell is untouched, so their screen keeps reading「requested」), block is a value rather than a table, and no message can bounce because a pair stops being live for BOTH sides the moment either cell leaves okSQLite — the pair state behind may_reach()
persona_followsPersonas are the second citizen class — WeChat's Official Accounts: add, never request; instant, one-way, silent unfollow. (user_id, slug, added), and no FK on the slug deliberately — personas have no DB rows by design (disk is the registry), so this mirrors vibes_likes/hidden_msgs. ⚠ Existence is its own check: discoverable() fails open on an unreal slug (right for seating, where valid_cast drops it downstream) — nothing downstream would drop it here, so the follow route tests profile.md too. ⚠ It is no longer the Friends list's only source (v724). It was, and nothing in the app has ever written to it — there is no follow button in the UI, so every user's persona half was empty by construction. A persona cannot accept a request, so the human rule has no analogue; what does is having talked, which the room manager already knows. /api/friends now unions this table with the cast of every room the viewer is in (db.room_ids_for_usermgr.metas_for, first-seen order, a room that will not hydrate costing a name rather than the list), so the table survives as the explicit half should a follow door ever be built. ⚠ discoverable() still rules the union: a private persona somebody else seated beside you keeps working in that room and stays out of the list — seating survives, discovery does notSQLite — the explicit half of the contact list's persona roster
favouritesA starred contact (v847) — (user_id, kind, ref, added), where kind is persona|person and ref is a persona slug or a user id as text. One table for both kinds, because a favourite is one idea and a contact list holds two kinds of contact — two tables would only have to be joined back together on every read. No FK on ref, for the same reason persona_follows has none, and nothing is validated beyond the shape: /api/friends stamps fav by membership of this set, so a row pointing at something gone costs a set lookup and nothing else. ⚠ Where a favourite lives was a real fork — this browser or the account — and the owner chose the account, so it follows you across devicesSQLite — the Friends list's ★ Fav filter. ⚠ Its neighbour pill changed its question in v867: it read Private and filtered on a persona's visibility; it reads Studio (v869's word, after「Built」collided with the builder's final stage) and asks who made it — so the wire now carries the answer (was this you) rather than the owner's id, a contact list having no use for who built each persona in it
persona_memoryThe memory store (2026-08-18, the design) — one row per remembered line: persona (the mind holding it) · subject (u:<id> | self | world) · counterpart · witnessed (the JSON list of user ids present when it formed — the audience) · room_id (where it formed, so the page can jump there) · kind (fact · preference · event · promise · open_loop · relationship · and since 2026-08-28 thread — the agenda's ① — a running concern of the subject's that a mind is following rather than a settled fact about them; like promise/open_loop it is exempt from the importance floor, because a thread's beats are small by nature) · text (ONE standalone third-person sentence, ≤200 chars) · importance 1–10 · valid_from/valid_to/closed_by · evidence (transcript line ids) · last_recalled/recall_count · embedding (an int8 vector on the wordpick pattern, NULL tolerated) · and since v954 holders — the JSON list of persona slugs that hold this row, where persona stays the origin / first holder. And since 2026-08-28 tmeta — the per-row state neither the text nor the model owns: a thread's manners (cadence fast·slow·event · status · asked · deflects) and, on any promise/open_loop/thread, the ping's own state (due · judged · pings_sent · last_ping_at). ⚠ It is state, never prose: every reader parses defensively and '{}' reads as「no opinion」, so a row of any other kind costs nothing and no migration has to touch it. One fact, many minds: hosts that heard the same stretch with the same audience write it once and all hold it, so one Delete is the end of it everywhere and the extraction bill divides by N. persona_memory_open(p) asks it through json_each, and memory_holders_backfill() at startup stamps every pre-v954 row [persona] (idempotent). ⚠ Holding is not seeing: witnessed is still the whole privacy model — holders only says which minds may read a row, never who may be told about it. The overnight fold groups by the exact holders set for the same reason, so a summary can never take a fact from a mind that held it. ⚠⚠ And witnessing is not ownership (2026-08-29, read off the live store): the witness fallback exists only for a row nobody owns — a promise made to the whole table, hearsay whose sayer is unknown — which were the audit's black holes, invisible and undeletable by anyone. A row with an owner is that person's, so db._memory_reaches now asks for no counterpart first, world rows included (their counterpart is the sayer), and the extractor names whom a promise is owed to — a with field validated against the present set exactly like subject. ⚠ The sentence names a USERNAME, never the bare id and never the display name: u:N lives in the fields, the readable-and-immutable login handle lives in the prose (user_handle() resolves and caches it — usernames never change), and the present list hands the model both((username: enovy, u:1)). ⚠ The dream folds stories, not only change-chains: a thread now includes same-story accumulation — five open notes about one flat are one arc — the cat-and-job guard held, and the fingerprint carries a prompt generation ("2:") so every pair that already slept on 「no threads」 under the old prompt re-dreams once rather than sleeping forever on an unchanged set. ⚠ Nothing is ever overwritten: a contradiction closes the row it contradicts (valid_to + closed_by) so the arc stays readable — closing rather than deleting is the entire reason the page can show 「no longer true · 2024-03 → 2026-08」. No FK on persona, the persona_follows convention: personas have no DB rows by design. ⚠ subject is an id, never a display name — a rename must not break a memory, the same rule @-mentions are anchored on. Two indexes, one per read seam: (persona, valid_to) is the room's question, (subject, persona) is the page'sSQLite — cross-room by construction; the whole point is that it outlives one state.json
persona_memory_muteTess forgets me」(user_id, persona), the account switch one host wide. Stops reads and writes alike, and like memory_on it deletes nothingSQLite — asked per present human at the read seam
followup_dialThe ping’s volume, per pair (the agenda’s ⑧ slice 4) — (user_id, persona)dialoff · rare · normal · often, set from the persona page’s Follow-ups row. An absent row reads normal, so the table only ever holds a deliberate choice and the default can be changed in one place rather than in a migration. ⚠ off silences the pair without touching memory — the same promise memory_on makes: a switch that quietly destroyed the record would be a worse bargain than one that stops acting on itSQLite — read by the follow-up loop before it judges a candidate; no FK on persona, the house convention
followup_logThe ping's ledger (2026-08-29, the agenda's ⑦ — the design): one row per judged candidate, sent or notpersona · uid · mem_id · due · verdict (send | hold) · why · sent. It is the feature's honesty and its pacing at once: the console's Initiate tab reads it, and a would-send counts against the caps exactly as a real send does, so a dry week paces identically to a live one and its false-alarm number transfers. No FK on persona, the house conventionSQLite — cross-room, and it must outlive the loop it judged
push_subsWeb Push (gap 1): one row per browser/device install — endpoint (unique push-service URL) + the client keys (p256dh/auth), a UA-derived label (shown in the Me-sheet toggle), last_okSQLite — the lock-screen mirror's device registry
push_events · activity_daysAppend-only telemetry — push sent/click pairs by kind/room (the return-rate loop's two ends) and one row per user per active UTC day (D1/D7 return, computable later)SQLite — no FK, like turn_events
state.jsonverbatim LLM history + cast + cost + the floor-producer flag + the feedback log (per-turn ratings + check-in surveys) + the prompt-variant stamp (variantv1|v2, set at creation and never flipped) + persona memory’s two per-room cellsincognito (set at creation and never afterwards: a room already harvested cannot be made incognito, and one switched out of it would be a promise quietly withdrawn — exactly two lines write it, the constructor and the loader, and a lint counts them) and mem_cursor ({persona: last lid harvested}, so a restart never re-reads a stretch it has already written down) + the toolbox's live stategates (every open instrument — poll · roll · bid box · seal card, and since D/v625 the deal too — submissions, dealt hands and unrevealed deposits included, each carrying its own visibility rung and its close_at/reveal_at deadlines, re-armed on load), clock (end_ts, re-armed the same way), the legacy deal slot (an older room's shuffled assignment, still replayed) and the legacy notes list (the panel's old sealed notebook, migrated into seal cards on load since v596 and then cleared). The read-path law: concealed content lives here and enters the event log only when it opens — so a sealed answer, a ballot in progress and a dealt card leave no trace a replay could leak. ⚠ Stated as a SHAPE since v633, not as a key name: once a hand became a list, 「cards never appears in an event」 stopped being true — a deal_reveal legitimately carries the hand it just made public. The invariant that holds is that the assignment is a dict keyed uN and a revealed hand is a list, and no event may ever carry the dictflat file — the panel's resumable memory + the room's un-opened commitments
meta.jsonThe sidecar (systemic-check #1) — the ~15 header fields the chat-list / boot path needs (title, raw cast, dm, status, language, rounds, calls, cost, created/updated) so listing a room never parses its full state (events + history, avg ~200KB). Derived, never a source: anything it holds is recomputable from state.json. Two gates guard it. Freshness is mtime — it is written after state, so a sidecar older than state (an old server, a crash between the two writes, a hand-edited state) is disbelieved. Shape is the mv stamp (META_V, currently 1024) — ⚠ and that is not decoration: the sidecar is written by the very function that reads it, so it is legitimately newer than state and mtime cannot catch a stale one; sniffing for a field cannot either, since a field can be present and mean something older than the code reading it. Bumping META_V makes every room re-parse once and self-heal. Since v904 it also carries last_last_preview(events), the {who, kind, text} of the newest spoken line, so a chat row can show the last message instead of a descriptor; redacted lines are skipped, since the list is the one surface a withdrawn message must not surface on. ⚠ v1024 — a photo IS a message. A caption has been its own bubble since v961, so a photo with no caption records text:"" and the walker's bare if not text: continue stepped over the newest line and quoted the one before it. A photo-carrying line is now marked (photo: true) rather than skipped, and the client renders 「[Photo]」 through i18n when there are no words — a preview crosses into a UI that has its own language, so the label never rides the wire in English. That fix is the reason META_V moved 904 → 1024: the shape stamp is the only thing that can make every existing room re-parse once, so the bump is load-bearing rather than decoration. ⚠ Known gap in the committed code: Room.persist()'s writer has not been updated since v465 and emits the header without mv or last, so a persisted room's sidecar fails the shape gate on the next read — the reader full-parses and self-heals correctly, but the fast path is spent again on the following persist. Correctness is unaffected (the preview is rebuilt from state); the saving is not being collected.flat file — a derived, disposable cache beside the room
event-log.jsonevery event, verbatim prompts, confer, settings-change audits (who / what / when — floor · title · status · history-private)flat file — the canonical record
transcript.mdthe clean on-stage dialogueflat file — human-readable
notebook.jsonsaved quotes, scoped per user — a note carries its origin room, and since v1082 an optional ink field (the piece id) marking a line clipped from an article rather than a chat, so its jump-back opens the readerflat file — one cross-room store
4b · the prompt and the parse

How the turn is assembled and read back

Two halves bracket the model call: building the prompt (cached) and parsing the result back into the canonical shape. The read-back is the harness parse layer — a P0–P3 degrade-never-delete chain (decode → heal → type → attribute → store): off-spec model output is repaired or downgraded, never dropped, so a malformed turn still renders something faithful rather than vanishing.

assemble · build_system_blocks

A cached two-block system prompt — three in a room that remembers somebody.
  • block 1: the SP (actor megaprompt) — sp_file(variant) picks it by the room's stamp: system-prompt.md (v1 baseline) or system-prompt.v2.md (v2 "manners")
  • block 2 (carries the 1h BP1 breakpoint): roster_intro + dispatch_note + formatting_note (markdown / math / code) + per-host profiles + language / dynamics / intensity directives
  • language_directiveseparates voice from language: a persona keeps its own voice but writes in the room's target language (the room's target, not "follow the guest's script"); topolect → standard rule
  • the panel_turn tool schema carries an optional self-reported lang per reply — the model's own claim about which language it wrote
  • intensity_directive · kickoff_cue · read_card
  • block 3, only when there is one (carries its own BP3 breakpoint): the memory blockmemory_block(cards, present, notes, sections), capped at MEMORY_BLOCK_CHARS 1200. ⚠ Absent, never empty: a room with nothing to remember sends byte-for-byte the two blocks it sent before the feature existed, which is what keeps the profiles above it from ever re-writing because of memory (see the note below)
  • chat_register_note()the last thing in the cached prefix, on purpose (the AI-tone batch, 2026-08-16): the closing reminder that the ~27,000 characters of analyst prose it follows are written about these people, not as them (see the note below)

parse · parse_panel

Tool call or salvaged prose → (confer, replies, artifacts).
  • structured panel_turn tool (panel_tool_anthropic/openai, parse_tool_args, serialize_turn)
  • _stage_split + negative-space artifact capture + _classify_artifact → image / code / table / document / note / dispatch
  • resolve_who, _repair_speak — pin a reply to a speaker, fix mangled tags
  • markers _MARK_RE = @{u|p|a:id} (a = Web Search); resolve_markers renders them human-readable
  • translate-on-divergence net — a per-reply check after parsing (see the note below)
  • de_tell — the deterministic register net, applied to on-stage speech only, inside _stage_split (see the note below)
The translate-on-divergence net. A per-reply guard for the room-language system: detection is script-based (kana vs. Han vs. Latin) — cheap and provider-free — not the model's self-reported lang. When a line lands in the wrong script for the room's target, a DeepSeek-Flash call produces a faithful translation, accepted only if it itself lands in the target script (else dropped — degrade, never corrupt). The accepted translation is appended to the same bubble under a hairline (original ─ translation) and persisted on the speak event + the transcript, so it survives reopen and is saveable to the notebook.
The register net — de_tell, at the bubble seam (the AI-tone batch, 2026-08-16; diagnosis + measurements: AI tone). The panel's meaning was never the problem — its register was an essay. Measured across 40 production rooms, 88.6% of panel turns carry an em dash against 1.6% of the humans' in the same rooms, and the cause is demonstration rather than disobedience: the host profiles the model reads last are dense analyst prose. So the fix runs on two channels at once, and only one of them is a prompt. ① The deterministic net (de_tell, called from _stage_split on on-stage speech only — never <confer>, never an artifact) spends each em-dash run for the mark a thumb would have typed: nothing when the neighbouring punctuation already did the work, / , between words, …… for a dangling trail-off, and nothing at all at a line opening. Fenced blocks pass through untouched — a diagram or a snippet is not prose. ② Layout is conditional, and the condition is read off a verdict the room already has: keep_layout(staging) takes the floor producer's own length ceiling and keeps the Markdown only on the analysis tier (LAYOUT_CAP_ZI 240字 / LAYOUT_CAP_WORDS 100 — just under the generous tier, so a rounded cap still counts); below it _demote_layout removes the furniture and nothing else — bullets, heads, rules and bold go, every word survives, and a numbered point keeps its number as ordinary text. ⚠ With no staging at all the answer is True: the producer being off or its call having failed must never silently strip a guest's structured answer — the same fail-open every producer-derived decision here uses. Together the two remove 64–76% of the excess over human, depending on how many of a room's turns are analysis turns. ③ The prompt arm plateaus at about a tenth, which is why it is a closing reminder rather than a campaign: chat_register_note() is the last block of the cached prefix and names the profiles' prose explicitly — a rule that only says「don't use dashes」fights an example and loses. Two further levers were A/B'd on the bench (exam/tone_ab.py, n=20 replayed production turns, noise ~±0.5): positive framing (MAD_REGISTER_POSITIVE, −10%) is ON; showing the register (MAD_REGISTER_SHOWregister_exemplar, the guest's own last few lines handed back on the append-only channel as a writing sample, never an instruction) measured −4%, inside the noise, and ships OFF — the guest's messages are already twelve turns deep in the context, so the example was never the thing that was missing.
The play kit — a rulebook read into the room (move 4, 2026-07-31). A kit is a game's researched rules as a file (prototype/kits/<slug>.md, frontmatter + two sections), stored on exactly the persona philosophy: in-tree, on main, read live, mtime-memoized (read_kit — the read_card recipe), no DB row. Its two halves ride two different places, which is the whole design. The HOST BRIEF (panel-facing prose) is appended once to self.history as its own user-role block by load_kit()append-only, so §8's cached prefix is untouched and every later turn simply holds it; a second kit's block says it replaces the first, and the old prose stays as the past context it now is. The TABLE SPEC (act-facing — the exact kind/draw/visibility settings per phase) is not in the dialog at all: it is appended to the act call's user message while self.kit is set, below the manual (it is a reference, not a law) and above the deliberate turn's speech block (the host's own words still outrank it). self.kit persists in state.json, so a kit survives a restart. Routes: GET /api/kits (headers only — the rulebook is the room's reading, not the browser's) and POST /api/rooms/<id>/kit, open to any member. The match is deliberately model-free: match_kit(text, exclude=) substring-matches a kit's aliases in an incoming message — exclude skips the kit already on the table, because a switch line names both games(「不玩骰子了,换玩谁是卧底」carries a liar's-dice alias)and the first hit used to be the one already loaded, so the loader saw its own kit and did nothing. It feeds two doors. ① The prop-gated auto-load (2026-08-01, superseding「an offer, never a load」): when a match coincides with the prop master's SETUP verdict, the drain calls load_kit(…, auto=True) off-lock, before any prompt is assembled, so this very turn carries the brief and the first setup reads the spec instead of running bare on the profile; the worker publishes the capsule behind the reply. The prop verdict is the whole precision guard — a NONE blocks the load, so the 骰子-alias metaphors match the substring and summon nothing — and an auto kit_load never cues a wake, since the turn that loaded it has already read it (the double-start's cousin, pre-empted). ② The chip is now the weak-match door only: rate-limited to once per kit, it is stashed at the route and published by the drain worker after the turn broadcasts (an offer must not arrive while the host is already acting on the same words — the owner's race), idle/error paths flush it too, and one made moot by a kit landing meanwhile is dropped. A load publishes kit and books a kit_load system event with its own replay row. ③ THE INERT DISMOUNT — the exit a kit never had (2026-08-05, kit_idle_sweep, window MAD_KIT_IDLE / setting kit_idle, 24h). The only way off a table used to be a cartridge's own game ending: a classic kit had nothing to fold it, a cartridge that declined the table never made a device to end, and a cartridge between games was deliberately left standing with no expiry — 285 inert mounts in the dev tree, 253 of them over a day old. The cost is not tokens (an empty room is silent since the presence gate) but the rulebook riding every turn and steering a host still primed for yesterday's game. It is lazy, with no timer of its own: asked on the two occasions that already exist — a room being hydrated(before _build_system, so a cold kit never reaches that instance's prompt)and a turn about to run. ⚠ Judged on coldness alone, deliberately not on presence — 「cold for a day」is a rule a person can predict. ⚠ And the coldness is the human's_cold_since, _human_at), never the room's own churn, and the two are never max()'d: a room bleeding by itself has a fresh updated and an ancient last human. A running game folds through the lawful door(the pile publishes, the record closes, the board comes down with it). The capsule is its own sentence and names NO actor(「X cleared itself — the table had been quiet for N hours」, plural-aware through tn(), zh + en)on all three surfaces a returning reader can meet it on — the live stream, the replay row, and the host's own context(the v555 law: a change the model is never told about teaches it the wrong thing). ⚠ The age is measured once, before the unmount — the lift moves updated. A hand unmount is untouched: no age, and the person still named.
The model-call path inside Room: _call(reason=) dispatches by provider → _call_anthropic (BP1 + rolling BP2 cache, forces the tool) or _call_deepseek(use_tool, reason) (sends the thinking toggle, tool_choice=auto). _invoke_model wraps the empty-completion retry (drops the tool on retry); _resolve_turn tries tool → <speak> → prose-salvage in order. Caching: _messages_with_cache (rolling BP2), the ledger, _cache_control, _toklen. ⚠ The room's history outlives the persona (2026-08-28, from production). A join is a permanent fact of the event log, so ledger() can walk a seat whose persona has since been deleted from the library — and it read that profile unguarded, so the whole room stopped being servable: every /replay and /state calls ledger(), the client falls back to its cached copy on a failed load, and the chat looked normal while quietly ending at the last turn anybody had cached. self.founders is filtered through library_slugs() on resume; history is filtered by nothing, and must not be — degrade, never delete — so _profile_toklen() scores a vanished segment at 0 tokens instead of raising. Deliberately not fixed inside read(): a silent empty read would hide a missing profile on the paths that build the megaprompt, where it is a real fault; this is the one caller that must survive it, because it answers a read of old history. open() forces reasoning OFF for a fast kickoff greeting; set_model() mid-chat switch writes a cold prefix. ⚠ The act call has its own cache shape (the lever, 2026-08-01 — a game room's spend measured at −38%, behaviour held on both A/B cells). Two breakers were closed. The brief split: the volatile blocks — the table, the prop master's why, the finger signal — used to ride the end of the system message, killing the prefix cache for the whole user message behind them; the system message is now byte-stable (the manual plus _act_brief's static half only, pinned by the smoketest) and the per-turn half moved into _act_live_state at the end of the user message — nearer generation than it ever sat, so the positional law is respected in the strengthening direction. The anchored tail: the transcript window used to slide every call, rewriting the prompt's head; it now starts at a fixed index, grows at its end (free under a prefix cache) and re-anchors ACT_TAIL_LINES 24 → ACT_TAIL_MIN 12 about once every twelve turns instead of invalidating every one — G4's reachability contract holds, since a fresh anchor reads the full 24 and the newest line never drops. Record: World events.
The floor producer. Before a turn a [STAGING] directive is appended to the guest turn — the same append-only channel as a dispatch brief, so it never busts the §8 cache. Two pieces of per-room state (both in state.json): Room.floor (the on/off flag — default-on for new rooms via MAD_FLOOR_V0; pre-feature rooms stay off) and floor_ver (which engine runs when on: v0 deterministic · v1x/v1p the smart f, a DeepSeek call; v1f/v1fr are legacy aliases that run V4.1 Flash, V4 Flash having left the table 2026-09-09). The user control is four-way (v994, owner; the arms relabelled at v1093) — Off · Code · V4.1 Flash · V4 Pro, the Pro arm greyed and inert unless it is the room's own saved pick, the same segment on the new-chat page and in the Geek page (POST /api/rooms/<id>/floor, whose set_floor had always spoken all four; only the control collapsed them). ⚠ The collapse hid a real choice: 「On」 silently meant the console's arm and v0 was unreachable from the room at all, yet in some rooms code staging is worse than none. The admin setting is now the default arm for a new chat (floor_defaultwindow.FLOOR_DEFAULT, default v1p, in the console's Models & APIs tab). floor_ver is sticky across an off→on flip, and a legacy v1 aliases to v1p. ⚠ A local chat's producer is Off or Code, nothing else: the v1l arm (the FP on the room's own local model) lived v995–v997 and is retired — a stale client's v1l lands on v0, and a cloud arm asked of a local room lands on off. v0 (_floor_stage) makes no model call — it reads only public state (the cast + who spoke when, via _floor_recency, magnitudes in FloorV0Params) and applies three levers: silence (_floor_n_speakers — a 5+-host room caps at ~3), a length budget that mirrors the guest, and variance (the lead runs; the rest get one line, via the SHAPE_DECK). When floor_ver is v1p/v1f the smart engine (_f_engine) takes the wheel and v0 catches on any failure (any error → no directive → exactly today's panel). Which FP file the smart engine reads follows the room's prompt-variant stamp (fp_file(variant)) — a v2 room never runs a v1 FP against a v2 SP; the opening producer's file is shared. An independent OPENING producer (_open_stage, armed by the floor_open console setting / MAD_FLOOR_OPEN env — off by default, ships dark) stages the kickoff first beat the same way, falling back to the fixed cue on failure. The directive is debug-visible as an optional in-stream FP-content bubble. ⚠ Two deterministic lines ride the staging bundle after the engine has spoken (the liveliness build, 2026-08-31) — appended in code, not asked of f, so neither can drift and neither touches the cached prefix. ① The voice re-anchor (lever ②): each speaking host's card voice: note, verbatim, as a [VOICES] tail — registers converge within about eight turns and the context tail is the slot that carries. A host counts as speaking when its display name opens a speaker slot (the same Name(-anchored match the mention backstop uses, so a benched bare name never matches); when nothing matches, every voiced host rides — fail open, a register is a licence. ② The typo licence (lever ⑫): the rarity is code's, because a model told 「rarely」 is either never or always — the punch slot's process-stable seed (_floor_seed) arms roughly one turn in thirteen and the model still gates by beat; never on a kit room, never within the first few turns. And a typed name routes like an @-mention: Room._named_cast matches every rendering the cards carry (display · local · short · the display's surname; CJK by substring, Latin on word boundaries) and feeds both arms — v0 seats those names in mentioned, v1's brief gains a NAMED row carrying the rule; the prompt bridges renderings no card holds (班克西 → Banksy).
The prop master — a door in front of the act call (2026-07-29). One cheap no-tool-schema call runs at the front of the turn and asks a single question: does this moment want an object on the table at all? Room._prop_call · PROP_GATE_MODEL (default deepseek-v4.1-flash, thinking off, max_tokens 120, 10s timeout) reads the prompt at lib/prompts/prop_master.txt and answers two lines — VERDICT: PROP|NONE plus one WHY. It reads the signal the act call structurally lacks: register — the transcript tail since tr_floor, what is already on the table (_tool_state_signal), and this turn's words — because the act call gets six history messages and a tool manual, and a manual is not a place to learn that somebody is upset. It is a prefix, not a third sibling: the same verdict rides into the floor producer's brief (_f_engine(…, prop=…)) and gates _act_call, so neither may be left computing 「is an object wanted」 on its own — that is the disagreement failure where the gate reads a metaphor while the FP stages 「execute it literally」. On a NONE the expensive act call does not run at all (not 「runs and answers none」 — that is the forced-form bias it exists to remove, and 1,400 tokens of tool manual bought to be told nothing). It fails open: any error, timeout or unreadable verdict → None → 「no opinion」, never 「no」, and the turn runs exactly as it did before it existed (MAD_PROP_GATE=0 removes it entirely). It fires whenever the floor producer is on or the turn split is armed, and its verdict is recorded off-stage as a prop_call event with its reason. This is not the deleted act gate: that one existed for cost and latency and was judged on them; this one exists for restraint, which is the axis the exam can finally see — where it enforces on the split path (a NONE stops the arm) and can only advise un-split. ⚠ One call cannot hold two opposite defaults, and the clause's position inside f's furniture bullet is itself worth measuring. Design + measurements: The turn split · the exam.
The three tool kill switches — and why they cost three lines, not fifteen routes (v655, owner-specified). 5a a global tools off (_tools_on(), console → Settings) · 5b the composer's tool button alone (_tool_button_on() — the human door shut while the panel keeps arming, for a room shape where the instruments are the hosts' to place and the guests only answer them) · 5c the chat's own (Room.tools_off / Room.set_tools, a Tools pill in chat info beside Mute, mirrored in the ⋯ menu, persisted in state.json, audited like any room setting and pushed over SSE so every member's window follows in the same frame). They are ORed on the client into one class (body.tools-off, painted by paintToolDoor() — and it needs an explicit display:none, because .tool-btn sets display:grid and a bare [hidden] loses to your own rule) but are never merged server-side into one boolean: they answer to different owners, and each surface has to be able to show its own. The human half is three lines in room_tool_access() — the single predicate every human arm route already asked — which is also why no switch touches a card already on a table: the settle paths (vote_tap · roll_tap · seal_put · gate_reveal · roll_go · gate_release) never ask it. The AI half is PANEL_ARM_BLOCKS, the router rows that put a new card down and not one more — so with tools off a reveal, a close, a take-down, an <act> and a <react> still land, and the drop is counted (tools_off_dropped). ⚠ 5c is the chat's, not a person's, and the gate is why that is safe: it answers to the floor producer's gate (an admin · a private chat's owner · never a plain member, never in a live chat), so 「one member must not end another's game」 holds by construction instead of by counting who has opted out — the first cut was an account setting with exactly that arithmetic. ⚠ The speech prompt needed a third state, not a flag: chat-only is not hands-off-minus-the-manual, because the hands-off note tells a host the room has dice and that they are set up before it speaks — a promise nothing can keep in a tool-free room, so the host offers a die that never arrives.
The feedback loop (closed onto the smart f). Guest ratings and surveys now steer the next turn's staging, not just measure it. Room.add_feedback(kind, payload, user) appends per-turn ratings (👍 / 👎 + lever-mapped tags) and check-in surveys to an append-only Room.feedback list, persisted in state.json; the browser posts to POST /api/rooms/<id>/feedback. Each record is tagged with the room's producer arm at the time (no_fp / v0 / v1p / v1f) so ratings stay comparable across arms. Into the producer: when the admin feedback_fp toggle is on (default on; off ⇒ still recorded, never consumed — collection ≠ consumption), Room._feedback_block distils the standing signal (last-wins per user/reply, newest pole per length/tone axis, ×N repetition, notes quoted ≤120 as hostile input, latest survey per user) and folds it into the smart f's brief as a GUEST FEEDBACK block — read as panel-level standing asks (the room's taste, never a verdict on one host; a fresh/repeated "too long" ×2 stages one tier lower on the ladder). Ratings age out of the brief after feedback_fp_turns rounds (admin, default 8, clamp [1,100]); _feedback_block is gated on _feedback_fp_on. v0 and the opening producer never read feedback. The check-in threshold is admin-set — POST /api/admin/settings/feedback-survey-every writes the feedback_survey_every setting (default 20; 0 = off) — and the clock is per-room, per-user: the survey fires only after that many rounds since this user's last qualified feedback (a per-turn rating or a survey, via last_feedback_roundfeedback_at), so a member who is actively rating is never nagged. Both admin knobs live in console → Settings → Feedback (/api/admin/settings/feedback-fp · …/feedback-fp-turns); visibility is gated per-role in the Who-sees-what matrix (feedback in VIS_FEATURES, default on).
Persona memory — two seams, and only two (v910–v931, the design; the「Proven」half of Live personas row ③). THE READ SEAM is one function. visible_memories(persona, present, room) is the only place persona_memory is read into a room’s context — an unnamed second reader would be a second privacy model, and the second one is always the one that leaks. A smoketest lint holds the count exactly: THREE readers, all namedvisible_memories (the block), memory_neighbourhood (the router’s candidate set, bounded to the people being written about) and memory_dream (the overnight fold, whose answer goes back into the store and never into a room). A fourth is a fourth privacy model, so the lint fails on any call site outside those three function bodies — and it proves itself unblind on a planted fourth before its zero means anything. No raw SQL over the table exists outside db.py, by the same lint. ⚠ Code decides what enters the context; the model only decides how to use it. A row about somebody who is not in the room never reaches the payload, so there is nothing there for a model to be discreet about — the number the leak exam measures is only ever about the people who are present. The per-person gate is memory_on ∧ ¬memory_muted, and ⚠ a switch that cannot be read reads as OFF. V1_PROVEN_SCOPE (True) is the one flip: a group room loads world rows only, personal memories riding private chats alone until row ⑥ is measured — the predicate above it is written in full anyway, because a filter written later is a filter written twice. memory_neighbourhood is the second seam and the router's alone. Render: Room.sync_memory_memory_lines (top MEMORY_TOP_N 10 per present human, then the MEMORY_BLOCK_CHARS 1200 cap bites) → memory_block, rebuilt at the doors a human enters and keyed on _memory_key. THE WRITE PATH runs off the floor, never inside a turn. Room.memory_harvest → per host memory_extract then memory_routetwo DeepSeek-Flash calls (_memory_flash), reading at most MEMORY_MAX_SLICE 60 lines since that host's mem_cursor; the router closes a contradicted row rather than editing it. ⚠ A clock that only ticks when somebody knocks is not a clock, which was this feature's first shipped defect: the write originally waited on the presence gate's 「the room went quiet」 booking, which fires only when something else tries to speak into an empty room — and the commonest ending is nothing trying. It now has a timer of its own, memory_arm/_memory_fire, re-armed at fuse_touch (the seam every human door already passes through, so a tap and a reaction push it back exactly as a message does) and firing _memory_quiet()120s, MAD_MEMORY_QUIET, 0 disables — after the last line. ⚠ And the two windows answer different questions: the presence gate asks 「may the room SPEAK to nobody」 and is deliberately 10 minutes because speaking costs money and lands where somebody reads it later; writing a memory says nothing, lands nowhere and interrupts no one, so it wants to happen while the person is still there to see the note and undo it. THE LEAVE DOOR (v915): the client beacons at every exit and the note fires MEMORY_LEAVE_GRACE 8s later. ⚠ The client's word, not the socket's — the first cut armed on the room stream's finally and never fired for a real client, because a boot opens that stream twice and the closed one's generator sits in its 20s wait, a phantom watcher keeping the count off zero. _memory_sweep is the net under both: daily, riding the brew scheduler's existing 15-minute heartbeat rather than a timer of its own, walking cold rooms with unharvested lines (limit 40, newest-first, trashed rooms skipped, ⚠ the disk asked before hydrating). _memory_eligible names every refusal out loud — incognito · a kit is mounted (§7: role-play must never become biography) · no hosts · nobody to remember · no key. memory_since() is stamped once in settings: nothing said before the feature was switched on is ever harvested, the same manners the last_read migration chose, or the first sweep after a deploy mines the whole back catalogue at once. Routes: POST /api/me/memory-note · POST /api/me/memory-on · GET /api/memory (the page — ⚠ the store's third reader and the only one that is not a model's, so it deliberately answers a wider question than the block: closed rows with their arc, and rows formed in chats you are not currently in) · POST /api/memory/forget (⚠ a delete gives back what it closedmemory_reopen_closed_by reopens the rows it had superseded, so nothing vanishes that the person did not strike with their own hand) · POST /api/memory/mute · POST /api/me/memory-note-groups (v930: the standing note’s reachdb.memory_note_groups, default 0 = private chats only; the decision sits in Room.sync_memory, the gatherer, and memory_block stays pure) · GET /api/rooms/<id>/memory (chat info’s what they’ve kept from this chat — the page’s reader with a room_id argument, not a fourth one) · POST /api/memory/dream (dream-now, rate-limited to once an hour per user in RAM) · POST /api/rooms/<id>/left (the beacon). The note’s surface is a MARK on the human’s own bubble (v917, superseding the v915–v916 capsule): a projection by lid — every memory records the line ids it came from — repainted after every replay and on every live note, so a stream rebuild cannot lose it. The sheet is the reaction sheet’s own dress (bottom sheet on touch, popover under the bubble on FINE_POINTER && !NARROW(), on the back-stack). ⚠ The capsule was retired, not restyled — a system bubble per harvest breaks the flow of the chat; the mark adds nothing to it and gains provenance. Its events still replay as marks, falling back to the last human line when they carry no lids. Chat info renders the same store through GET /api/rooms/<id>/memory and hides the section when the chat has nothing kept. ⚠ The rail kind that remains deliberately does not push — every other notice mirrors to the lock screen for free through _push_notice; this one returns early with the reason written beside it, because a private fact about the reader must not appear in a preview anyone holding the phone can read. THE DREAM — consolidation on the same heartbeat (v921, §3.1). Left alone a store about one person becomes twenty rows saying four things, so once a day — right after _memory_sweep, on the brew scheduler’s existing 15-minute tick rather than a timer of its own — memory_dream_all(limit_calls=60) walks the pairs db.persona_memory_pairs(min_open=2) names and runs memory_dreammemory_dream_call per (persona, human): threads fold to one row, the parts absorbed are closed with closed_by pointing at the survivor. ⚠ A fold is not a contradiction — the page shows it as its own group (Folded into a summary), never No longer true, and 「a cat and a job are not a thread」is in the prompt because the tempting failure is folding two unrelated true things into one false one. Reading back the other way, db.persona_memory_arc lets a closed row ride its replacement into the block as 「before that: …」 — called only from inside visible_memories, on rows that have already passed the audience filter, so it widens nothing. HEARSAY — a world fact is somebody’s remark (2026-08-19). A world row now stores who said it (the evidence line’s speaker, in counterpart), and the block files it under you heard — in conversation, unchecked; it may be wrong or out of date, each line ending (heard from Amy on …) — or (heard in an earlier conversation, …) when the speaker is not present, so an absent person’s remark is not named to the others. ⚠ This is the tail rule’s one exception: the manual otherwise forbids saying where a thing came from, and without the carve-out the host was told to hold one guest’s guess as its own knowledge and never attribute it. Hearsay cannot harden — the dream never folds world rows and the router compares same-subject only, so a world candidate can never close a personal fact. Measured A/B on the recall exam: hedged 0/2 → 2/2, flat 2 → 0, recall 9/10 in both arms. THE AUDIT — 28 faults, 28 pins (v929 + a second pass, §the audit). Two reads of every seam — ours, then an independent cold agent over the same code — against don’t forget · don’t confuse · don’t leak. The load-bearing repairs: memory_since() is stamped at app startup, not at the first harvest (a deploy’s first conversation used to fall before the store’s own opening date); any cap now holds the cursor, because a pass that wrote a surviving world fact used to step past the lines it had refused for a capped person; a leave during a running harvest and a backlog longer than one slice both re-arm instead of waiting for the next door; and db._memory_reaches replaced the old subject-or-counterpart test as the one predicate behind delete — a row is yours if it is about you, promised to you, or (a world row, or a promise with no single counterpart) one you witnessed. ⚠ The old test made a mis-filed world row invisible AND undeletable, which is the worst pair those two properties can come in. THE BACKSTOP — the bill is bounded (owner: 「do we have a backstop so that our negligence in design will not burn a hole in our API bill?」). Per-call brakes already existed — MEMORY_MAX_SLICE 60 lines a read · MEMORY_DAILY_CAP 12 new rows per persona per human per day · the sweep’s 40 rooms · the dream’s 60 calls a night — but nothing capped the total. Every memory call (extract · route · dream) goes through _memory_flash, so _memory_call_ticket() is its first line, before the key and before the wire: past MEMORY_DAILY_CALLS (2000/UTC day, MAD_MEMORY_DAILY_CALLS, 0 disables) it raises MemoryBudgetExceeded and every caller degrades the way it already degrades on an outage — a harvest keeps its cursor and reads the stretch tomorrow, a dream is skipped. A brake, not accounting: the counter is in RAM (memory_calls_today()) and a restart resets it. ⚠ The review found a loop of our own making — a person at their daily row cap with a backlog longer than one slice re-read the same sixty lines every couple of seconds, the 「come back in 2s」 retry firing forever against a bookmark that correctly refused to move. Exams: exam/memory_recall.py (with a hearsay probe — one world fact planted beside the five, scored hedged / flat / neither) · exam/memory_leak.py; 134 smoketest checks across seven sections in all.
Persona agenda — the remembering is the platform's, the acting is the character's (2026-08-28/29, the study; phases ①–⑦ of eight). Everything below rides machinery that already ran — the extractor, the router, the dream, the floor producer, the memory block — and adds no new model call until ⑦. The manners have ONE copy of their arithmetic. Room._thread_state(row) returns (days since the last beat, verdict) with verdict ∈ dormant · deflected · brief · recent · ripe · quiet, and both readers go through it: _thread_line(), which annotates the thread in the memory block the persona reads, and _own_ground_signal(), which tells the producer who has ground — so the block and the staging can never disagree about whether a thread is ripe. The block's own section is the trigger for everything downstream: when WHAT YOU REMEMBER grows a「story you're following」section, the block appends ③'s selection clause (act only on the ones you would care about; at most one; raise it by its own details; a sideways answer means let it go) — prompt only, no field — and the same presence test is what _kickoff_return_line() (⑥) reads to turn a reopened room's cue into a reunion. ⚠ The producer's firewall holds unchanged. The OWN GROUND row (④) carries a host's name and a count(「Tess follows 1 part(s) of a guest's story and has fresh news of their own」)and nothing more; the FP prompt is told in as many words that the count is the entire fact and the subject is never its to name, guess or hint at. Its output is a licence appended verbatim to one host's cue — never an assignment, never on a pressing ask, never during a live game, one host and one slot. ⑤ adds the second half of that count from recent_fresh_count(slug) — bullets in the since-the-record file dated within RECENT_FRESH_DAYS = 10 — so news ages out of being news on its own, with no state to keep. Two maintenance passes, no model in either. memory_thread_review() runs nightly beside the dream and marks a thread with no beat for cadence × THREAD_STALE_MULT dormant (the row survives; the block keeps carrying it as「only if they bring it up」, and a fresh beat starts it live again); followup_sweep() rides the brew heartbeat every 15 minutes. ⑦ is gated in code before it is gated in a model. followup_candidates() walks memory_due_loops() and returns (candidates, gate counts) — the counts exist so a gate that silently ate everything can never read as「no loops」 — applying, in order: a resolvable uid · the due date passed in the user's timezone · both memory switches on · outside FOLLOWUP_QUIET = (22, 8) local · under FOLLOWUP_STRIKES = 3 unanswered · past the backoff · not judged today · under FOLLOWUP_CAPS_DAY = (1, 3) and FOLLOWUP_CAP_WEEK = 7 · and not while the human is present. At most one candidate per human per sweep, oldest first. Only then does followup_judge() spend a call, and its prompt's standing instruction is Default NO. Delivery adds four more locks in followup_sweep() itself: _followup_mode()off | dry | send, where MAD_FOLLOWUP=off in a box's .env beats the console outright and the default is dry · _followup_groups(), defaulting to {admin, tester}, so everyone else's loops are still judged into the ledger and never delivered · _followup_ticket(), THE WALLET GATE — one counter a day in settings against FOLLOWUP_DAILY_CALLS = 200, taken before any spend, with cap = 0 meaning spend nothing at all · and FOLLOWUP_DELIVER[0], the closure build_app() injects, so a process with no app cannot deliver by construction — the exam and the smoke test run the whole judgment path and physically cannot send. The delivery itself is not a new writer: _followup_deliver() finds the pair's private room, re-members a cold one from the DB so the memory block builds, and drops a _world_cue(…, must=True, followup=True)the panel writes the line, same megaprompt, same manners, same block, and the producer keeps its veto. The loop id is parked on the room so the human's next line closes it out and reopens the allowance. The push is content-free.
Persona growth — currency without drift (2026-08-20/21, the design; the「Growth」half of Live personas). The law is one line: knowledge moves, personality does not — and exam/growth_drift.py exists to prove the second half, scoring a persona against its own frozen profile after the first half has moved. THE DATE LINE (①) is Room._date_note(), one sentence beside the roster and the clock note. ⚠ It rides the USER turn, never the cached prefix — §8 requires that block to stay byte-identical for the room's life, so a 「today」 in it would re-write the prefix on every turn of every room. The clock is the guests': Room._human_tz (the last human line's offset, the same clock the memory harvest dates by), else the room language when it names one, else UTC — persisted per person in users.tz (db.user_tz / set_user_tz), written whenever a client reports a different one. THE HORIZON CLAUSE (②) is not code at all: one bullet in both system-prompt variants plus the extrapolation-contract's since-the-record clause — known, never lived; cite-only — and a matching question in the persona audit. ⚠ It was written into the prompt kit rather than the app because it is a rule about the character, and a prompt change is baselined, never smoke-tested into place. THE LOADER (③) is read_recent(slug, card) → the materials block, gated on the card's horizon: living; CARD_KEYS gained horizon · origin · anchor to carry the decision. It is called at both profile doors — room open and a joiner's injection — because 「wherever the profile is read」 is the rule, and a room keeps the copy it loaded exactly as it keeps the profile. recent_parse() is the one parser under both it and the job. THE REFRESH JOB (④) is module-level and hook-injected (the MEMORY_NOTIFY pattern), so the smoketest and a mocked-SERP hand test drive the whole path with no server and no key: recent_due() → per persona recent_refresh_one() — ONE SERP round, ONE _recent_flash() read (RECENT_MODEL deepseek-v4.1-flash), recent_route() pure (exact bullet shape via _RECENT_BULLET_RE or nothing; a date below the floor is dropped) — then the verifier, then write + bump as_of + log. RECENT_MAX_BULLETS 12, oldest rolling off logged, never silently; RECENT_REFRESH_DAYS 7 (MAD_RECENT_REFRESH_DAYS), and ⚠ the clock is each file's own as_of, not a timer — so a restart cannot double-run the week. It rides the brew scheduler's existing 15-minute heartbeat, off-peak only (the owner's call-heavy norm), kill-switched by MAD_RECENT_SWEEP=0 — ⚠ which the smoketest sets, after a long test run crossing the scheduler's settle line at an off-peak hour fired a real 55-persona sweep from the test process (.env loads from CWD, so the keys were live). THE VERIFIER — no single mind writes to users (owner, 2026-08-21: 「I can't count on you to be the QA」). _recent_verify() is a second flash call given the same evidence and the opposite stance: DROP unless a result clearly supports the claim with a matching date and a named outlet, DROP unless it is unambiguously this person — ⚠ the split-name rule: a family name alone or a given name alone is a different person, and a product or team wearing the name is not the person. ⚠ It fails CLOSED: an unreadable verdict, a dead call or a missing line keeps nothing — a wrongly dropped bullet returns next week, a wrongly kept one lies to every reader, so the asymmetry is written into the default. THE OWNER GATE. A horizon-changing item — a death, a resignation from the defining role, a conviction — is extracted as a HORIZON: line and is never an add: RECENT_NOTIFY_recent_horizon_notify puts it on every admin's rail (ckey horizon:<slug>, so it coalesces), and the profile's anchor moves only by hand through the pipeline. THE CATCH-ALL (owner, 2026-08-20): a real figure carrying no horizon is an omission, not a decided fixed — the template requires the field, the builder enforces it at build time, the smoketest lints the repo, and GET /api/admin/recent's unclassified list is the net for whatever still slips through, or a persona added tomorrow misses the sweep forever. Routes: GET /api/admin/recent (the console's Personas ▸ Since-the-record card — state, due, unclassified, the diff log) · GET /api/admin/recent/<slug>.md (the raw file) · POST /api/admin/recent/refresh-now (⚠ ignores the off-peak tier — a hand on the button is a decision) · GET /api/personas/<slug>/recent, the one public reader, visibility-gated through discoverable() and feeding the persona page's two knowledge rows (v933); a fixed or invented card, or an empty file, answers empty and the rows stay hidden.
Vibes — the characters' feed & its rooms. The runtime serving side of the Vibes content engine (the offline extractor/writer that authors posts lives in that design). Composed posts sit in vibes_posts as a lazy-released inventory (vibes_release_tick drips them live, skipping expired stock); GET /api/vibes/feed returns a server-composed slate per pull — NEWS / REPLIED / HOT / EVERGREEN quotas, tier-crossing resurface, diversity + cycle guards — and stamps vibes_seen so a user isn't shown the same post twice. Some posts are world-news shares: a real story harvested via the SERP news vertical, distilled to a fact sheet + verbatim clean body by an LLM news editor, quote-tweeted in a master's voice with a NEWS attribution box; the reader page renders that stored article body. Every post is a door — the browser mints a stable moment key (slug + text hash) and POST /api/moments/<key>/join binds (or re-binds) the one shared "Open Circle" room for that moment in moment_rooms; the first joiner creates it, seeded with the post + the masters' comments (no AI spend until someone speaks). All Vibes content is gated behind the admin vibes_contents toggle (default off → "under construction").
5 · the file map

Which file owns what

The whole server is a handful of Python files plus a handful of static HTML pages. When you change a behaviour, this is where it lives:

fileowns
lib/run_room.pyThe engine + the web app. Room (one conversation: history, the turn pipeline, batching, the web dispatch _fetch_dispatch, persistence), the model registry (MODELS + provider_of/api_model_of/thinking_of), RoomManager, RoomHub (SSE pub/sub), the prompt assembly (build_system_blocks), the panel-turn tool schema + parser, the layered auth gate (public · authed · admin-only · room-member, with a read-only admin-peek exception — and peek reads only what the panel reads (v530): admin_room_peek slices the transcript at tr_floor and reports floored/hidden_before, so inviting a persona can never retroactively expose the human-only lines that preceded it), the per-user notebook, the /katex · /hljs · /mermaid · /fonts · /ink · /emoji · /dompurify.min.js static mounts (all same-origin; vendored except /ink, which is ours — lib/ink/edition.json + the hero art, content-as-files like the personas, with edition.json the pointer to the current dated edition, v1039 — served from MAD_INK_DIR when it is set (the box’s /var/lib/mad/ink, so the nightly brew writes outside the repo) and from lib/ink otherwise; /fonts carried the app’s own Chinese faces in v1073–v1088 (reversed v1089: too slow to load on a phone; the system face again) — lib/fonts/noto-sc.css over lib/fonts/noto/*.woff2, three frequency tiers per weight, linked from room-ui.html with a ?v= buster), the PWA pair/manifest.webmanifest and /sw.js, both public because neither sits under /api/; since v1077 the manifest is read and rewritten per request rather than sent as a file: a mad-theme cookie set by the pages swaps its two colours for the dark pair, and it goes out Cache-Control: no-store + Vary: Cookie and has left the service worker’s shell list, because Android 15+ paints an installed app’s status strip from the manifest alone and a cached copy would hand Chrome’s WebAPK update check the old theme, the Ink routes (/api/ink/feedback the 👍/👎 both ways · /api/ink/note the written verdict, both ways since v1088 — the POST files it, the GET hands one reader their own notes on that piece back, newest first, so the Feedback page can show what they already sent under the box (own notes only; never another reader’s) · /api/ink/read — v1070, the completion the reader is never asked for, beaconed when a piece is left · /api/ink/door — v1054, 「Sit down with X」: one private room per reader per piece, idempotent, the essay frozen in as the room’s first linked page · /api/ink/mark — v1096, the reader’s marks: the POST files a highlight or a comment (and, carrying a mark_id, overwrites one — v1097), the GET hands one reader their own marks on that piece back so the article re-opens wearing them, and /api/ink/mark/delete (v1099) takes one off · /api/ink/by/<slug> the persona page’s shelf · /api/ink/save and /api/ink/piece/<id> — v1082, Save line from an article: its own door because /api/rooms/{id}/notes requires membership of a real room and a piece is not a room, so the note is filed under the pseudo-room ink:<pid> with the article’s title as its origin and an ink field the notebook keeps, which is what lets the card’s「go to line」fetch the piece by id and open the article rather than a chat), the Seen routes (/api/seen/* — the request (POST /api/seen) · peek · rank · status · list · feedback · delete — and the /seen/<id> full page) plus the async hero-image job, the Vibes serving path (/api/vibes/feed slate composer, the drip scheduler, /api/moments/<key>/join → the shared Open Circle) and the news device, the composer-dictation signer (GET /api/stt/ws-url — mints a short-lived iFlytek WebSocket URL so the browser streams audio direct to iFlytek's CN endpoint, never through the box; each signed session books a stt turn), the notification rail's server half (notify() inserts + the notice_sync mirror down the per-user stream) including the greenroom (_greenroom — on a build reaching published, auto-creates the owner's private 1:1 seating the new persona and calls r.open() so she speaks first; her opening bubble is reused as the notice preview, so no second call. Fired from _notify_build_end via asyncio.to_thread — it's a blocking model call and would stall the event loop inline; idempotent across re-audit/resume via a _greenroom.json marker, and any failure falls back to the plain build_done notice), the user-profile + avatar routes (/api/me/profile · /api/me/about · /api/me/gender (v947 — a closed vocabulary, an unknown value stored as unset rather than refused, and never carried in another user’s profile payload) · /api/me/avatar[/remove] · /api/user/{uid}/profile[/avatar] — uploads resized to a small square + metadata-stripped, stored on disk, no DB column), the contact-layer routes (GET /api/friends · /ask · /accept · /ignore · DELETE /api/friends/{uid} · /{uid}/block · /{uid}/remark · POST /api/favourite (v847 — one route for both kinds, kind+ref in the body), the add doorsGET /api/friends/link · POST /api/friends/link/rotate · GET /api/friends/by-code/{code}, versus GET /api/friends/search, the only path behind findable. ⚠ The GET /add/{code} landing is deleted (v878, the owner's call): the token is still minted and still rotatable, but what a code carries is now ish:u:<code>ours, and not a URL — so a phone's native camera cannot walk somebody out of the app and into a browser that does not know them, and an old link 404s rather than landing a stranger in the app wondering which contact it was for. It settles provenance for free: arriving through the scanner is the via, where before it had to be smuggled through a URL the app then erased — and the privacy switches GET/POST /api/me/privacy, whose reads back the blocked list too; the two predicates they exist for are may_add_to_chat() (⚠ the policy belongs to the person being added, never the one adding) and sees_full_profile() (below it a viewer gets the thin card — name, handle, face, and none of the about line, presence or shared chats), plus POST/DELETE /api/personas/{slug}/follow; ⚠ the follow route tests profile.md itself rather than trusting discoverable(), which fails open on an unreal slug), direct-message seating (the one canonical 1:1 per human pair via dm_pairs — a zero-persona room, all panel machinery dormant; seating a persona converts that room to a group (v530) — Room.invite flips dmgroup, stamps the persona's name as the title, and returns converted so the route releases the pair binding), the Web Push server half (_push* — a background send worker fanning out per device over push_subs, guarded on pywebpush + VAPID keys like pillow/pypdf; /api/push/vapid · subscribe · unsubscribe · clicked · test; --gen-vapid CLI), the toolbox's server half (the host-move router + each _apply_panel_*, the tap routes roll_tap · vote_tap · gate_release, the kind-agnostic lifecycle routes gate_reveal · gate_close (T11's seal_* paths still answering as aliases), and the touch drawer's parity routes user_roll · user_deal · user_gate · user_vote · user_note[_reveal] · user_board · user_clock · user_pad (v636), plus the viewer-scoped GET …/strip that feeds the top bar (v635) — v565's parity law: USER_TOOLS is the panel's mechanism list, and one predicate room_tool_access(room, user, tool) gates them all, open in a free room and narrowable by T5's card sessions from a single function body), the play-kit shelf's server half (read_kit · list_kits · match_kit + Room.load_kit, behind GET /api/kits, POST …/kit and DELETE …/kit — taking the kit back off the table), THE GAME DEVICE's runner (D1–D4, 2026-08-02 — a kit carrying a ```flow block boots it: _game_boot arms with the living roster, _game_run walks the steps, the _game_do_* verbs deal · board · counter · seal · ballot · eliminate · rule · spin · roll · end-check · reveal, _game_moment asks the persona for a moment's voice only, _game_abort is the one exit every phase reaches (and an aborted game still publishes the pile), and _game_notethe device's screen — rides above the prompt on every turn, not only on moments; _flow_active says a game is running, _flow_kit says the cartridge is still on the table, and the door sits on _act_call itself so no caller can arm around it. Five cartridges run on it now — 谁是卧底 · 二十问 · 真心话大冒险 · 大话骰 · 问答之夜 — and the D3/D4 games are what found the genre assumptions sitting in general code: an idle fold that counted「who spoke」rather than「did the world move」, a winner that had to be majority-or-minority, a board that could only be a roster, 20Q's own row hardcoded in the runner, and a game-deal gate that required a sighted dealer (卧底 has one; 大话骰's blind-dealt cups do not, so the reveal and the abort could not find the pile they owed the audit). Every exit — a ballot's, a 自爆, an abort — books a game_out event, because a world that does not record what it did cannot be checked. Three more runner parts came with 大话骰's second day (2026-08-03). ① The collect window (_game_do_collect + _game_collect_entries): bidding as a ruled window rather than a thing the host emcees — it exits on its until-await (the 开盅 press), every entry is judged by the named validator in gameflow, an accepted bid lands silently with a reaction tick, and a void comes back through the host carrying the validator's exact reason. The verdict at the end is arithmetic on the validated record, not a reading of the transcript. ② Two flash calls, both falling back rather than blocking. _collect_flash_read is the interpreter (the prop-master pattern): it reads a batch of chat lines into strict entries at temperature 0 so the window never pays a full panel turn for a transcription, and returns None — falling through to the whole path — for a line aimed at the host, a reading it doubts, any error, any timeout. _game_flash_line is the flash voice (the owner's ruling: never hardcode what a persona says — a mind speaks every line, and for lines needing little intelligence the mind is deepseek-v4.1-flash): it voices the device's note in the host's own words in ~1s instead of a panel turn, wearing a persona capsule — the profile's head as a constant system prefix, so the provider's prefix cache makes the personality nearly free after the first call. Fidelity is by construction, not by hope: every player name and every number verbatim, bold spans preserved, no invented digit, a checklist-carrying cold retry, and any failure at all falls back to the full moment path — so a flash outage costs seconds, never the line. ③ The language layer (_game_lang / _game_lex): every machine surface the device says or shows renders once through the cartridge's own lexicon in the room's language, and the client owns no words — the bid strip reads a terms slice off the envelope. A room mounted before its kit had a lexicon falls back to the shelf's block. ④ Three doors moved onto the room's own thumb (2026-08-04). The offer card (kit_offer_state · kit_offer_take · _flush_kit_offer · POST …/kit/offer): no keyword ever mounts a kit — the host's <kit name/> raises a stream card and the tap mounts it, in the tapper's name (record_aside lands the CTA line before the mount capsule); one offer per room, 15-minute lapse, cleared by any kit event, replayed off state. Because a host trained never to write markup answers a play-ask in prose and writes no tag, the world raises the card itself on the prop master's judged play-ask, published after the host's reply. Its twin is the hand-build suppressor (_shelf_suppress, sticky): a shelf mention while the room is kitless drops this turn's hand-laid arms at the one door where the speech and act arms merge — the keyword only ever prevents, never mounts, so prevention stays fail-safe. The dial door (extract_panel_dials · dial_choice_state · dial_choice_take · _game_do_dial_ask · POST …/dial): the room can change a table rule by talking — the host relays <dial name pick/>, the world validates by option id against the cartridge's declared options, announces on the cold voice (never the host's — a said-but-unset change would otherwise pass as done) and enforces from the next round, while _game_do_dial_ask puts the choice up as a card before the first deal. The armed end (end_armed): mid-game the first away-table judgment only arms the end, the second closes it, an accepted bid disarms it, and the device's screen carries END PENDING so the host cannot forget it holds one; the exit still routes through _game_abort, so an ended game publishes its pile whole), THE INTERROGATION's room half (2026-08-07 — a kit whose kind is utility boots lib/interrogate.py instead of printing a rulebook, and from there the interrogation is the room: no prop master, no floor producer, no act call, no tool manual, no dispatch, the same construction a cartridge room uses, where the machinery a device replaces is off rather than merely unused. Six seams: the mount hook (a room that cannot seat two declines it and leaves the table exactly as it was), the turn intercept, off-lock, because it makes its own calls; _probe_askone call site, six jobs, with the seat's profile riding the SYSTEM message and the claim first-sealed so it is identical on every call that seat makes, the append-only history opening the user message, and only the board, the thread and the task re-rendered at the end — it is the full profile and not the fact-only world read, because a ruling is「did that satisfy ME」, which is nothing but lens; _probe_emitONE publisher for every door, since a sweep does not open on a message and publishing hung off the drain alone computed five questions nobody ever heard; the quote marker's lid, pinning each ask to its thread so a bubble-reply wakes one seat and pays no routing clerk; and the taps — fold · stop, plus the two cards the world arms through _dial_card_arm — each returning the board so the client repaints from the world's count rather than its guess at one. The length card is gone (2026-08-07, room f9fe): three lengths was a choice nobody had grounds to make before answering anything, so the confirm now calls set_length("short") itself and the sweep opens on that same turn — a sitting is twelve answers, every seat satisfied, or 先到这, whichever comes first. (probe_length / _probe_length_card survive as an unreached path: the engine no longer emits ask_length.) Its replacement sits at the other end — the probe_again card: the record goes out, 「再来一轮?」goes up, and the engine stays on while it waits (phase done, on still true) so the turn intercept holds the room — yes opens a fresh sitting with the still-pressing threads carried over untouched, no takes the kit off in one clean exit. Switching off at the document is what let room 28b4 hand the room back to ordinary panel turns for a thirty-line closing ceremony. The sweep serves one call at a time: armed at once, five seats read an identical empty history and four of five asked the same question), OMNI SEARCH's server half (v753 — Room._index_sync() hanging off persist(), and GET /api/search/history: the caller's rooms closed first and independently, then the floor by ts, then this reader's own hidden lids, the survivors grouped by chat — one row per conversation with its newest hit and a count, never one row per line. Only this group crosses the wire; friends and group chats the client already has. ⚠ The hook reached for the wrong database handle and nothing said so: db is local to build_app() while module-level Room methods must read the _DB alias published for exactly that — and the try/except that exists so a search index can never fail a user's turn swallowed it whole. Every gate passed, the backfill was full, the UI worked, and nothing said after the deploy would ever have been findable. A safety net that hides a defect is a defect; caught only by exercising the live path), the persona purge (DELETE /api/build/{slug}?purge=1, v774 — it empties a persona that is already tombstoned and refuses a live one outright, because one call that either tombstones or destroys depending on a query parameter is one typo away from being the wrong one, and this is the verb with no undo), and every /api/* + /api/admin/* route. ⚠ The super shield lands here too, and its widest read was never /api/admin/* (2026-08-13): it is the room-GET relaxation (/state · /replay · /members · /stream) that the console's peek button actually drives — for a non-member member_see_from() returns None, i.e.「sees everything」, so the raw replay hands over unfloored history including the DMs that the peek route deliberately refuses. Hidden also reads as absent on the user-mutation routes, since a refusal distinguishable from a 404 would be a free enumeration oracle. ~41k lines — the biggest file in the repo.
lib/seen.pyThe Seen pipeline (stdlib-only; its model calls are injected by run_room). Turns a window of a user's chats + saved lines into a personal piece in two LLM calls: a suggest pass (fast DeepSeek) classifies every item + proposes ranked writer candidates, and a write pass (reasoning DeepSeek) has the chosen persona write an HTML fragment in their own form. ⚠ max_tokens covers reasoning AND content together on a thinking call, so a big window provoked a long think, left no room for the essay, and the JSON died mid-body_html as an 「Unterminated string」 parse error — the retry ran identically and cut again. The writer's cap is MAX_TOKENS_REASONING (16384), the house ceiling every panel DeepSeek thinking call already runs at, and a finish_reason=length is now named at the call(「the piece came back cut short … try again, or pick a shorter period」)rather than surfacing as a parse traceback; the tokens were spent, so the cost is still billed before the raise. Owns the harness that makes the model's HTML safe: sanitize_html (strict tag/attribute whitelist — no scripts, styles, links, images, URLs), verbatim-quote verification (every <mark class="her"> checked against the person's real lines or stripped), the code-computed bars/timeline (facts the writer may cite, never alter), and multi-user carving by user_id.
lib/builder.pyThe persona builder (state machine + standalone CLI; imports nothing app-specific). The API-only port of the CC authoring pipeline, in v2 collapsed to one machine for every referent: a classifying probe reads what the figure is and forecasts the made-from mix (web · your material · AI-fills) under class-specific caps and a corpus meter, then identity lock → 5 parallel gather loops (search → fetch → extract → mine → VERIFY, where a quote enters the corpus only as a code-proven substring of the fetched page) → curate → coverage gate → author → the code oracle → a two-seat cold audit → a converging verdict policy → publish. Runs identically headless (python lib/builder.py "<name>" --yes) and as an in-app background task. Owns the shared normalize() both gather-VERIFY and the profile oracle use, the minting of an app-built slug (mint_slug, 2026-08-17 — <name-prefix>-<4 random chars>: the key is an identity, so it is minted rather than derived from the name, which retires the -2/-3 suffix loops and the oracle they leaked, and makes a build → delete → rebuild land on a fresh folder instead of on top of the old one) with adoptable(slug) as its one exception — a caller-named slug is honoured only while its directory holds an unfinished job of ours, because the park doors that begin every new build used to ask only whether a _job.json existed and so adopted a published persona's directory, rewriting the very record build()'s own status == "published" guard reads (⚠ a check downstream of the thing that destroys its evidence is not a check), the per-build _job.json/_bill.json journals, and provenance stamping (built_by). ⚠ It also owns one obligation to the reader: Job.finish() calls _announce_root_change(), which touches PERSONAS_BUILT so that run_room.library_slugs()'s root-mtime memo sees the new persona. A build is not atomic — the directory is made when the job starts (root moves), profile.md is written at the end inside it (root does not), and membership needs the profile — so without the announce a persona that built successfully stays invisible until the next restart, and a room created for it opens with an empty cast (production, 2026-08-15). It fires on every terminal status, not only publish; any future writer that completes a persona in place owes the same call. run_room imports it alongside its other app modules (db/auth/cost/seen/websearch/vibes_brew); design: Studio.
lib/builder-ui.htmlThe console's build report (/builder, gated by the persona_studio visibility flag — admin-on by default, grantable per role). ⚠ It is no longer the app's build UI. Until v800 this document was the Studio: room-ui.html embedded it in an iframe (the landing's Studio tab, and the "New persona" dialog) across a cross-frame message contract. The native port (v801–v804, the plan) moved all four steps into room-ui.html itself and deleted the frame with its whole apparatus — npFrame, the liveness machinery, the theme relay, the back-stack relay, the cache-buster. What survives here is the standalone step-4 report reader the console opens as /builder?report=<slug> (owner-neutral — it drives openRun off the /events + /ledger endpoints, so an admin can read any persona's build); every other direct visit does location.replace("/"). The flow it used to own — select → lock → confirm over /api/build/*, the candidate/concept picker, the Your material block, the made-from forecast bar, the live SSE progress spine and the terminal bill (per-stage + by-API) — is unchanged in behaviour and now lives in the room UI's native #npPage. A built persona is still private by default: library_descriptor(viewer_uid) filters the picker to public + own; an admin publishes via POST /api/admin/personas/<slug>/visibility.
lib/personas.htmlThe persona browser (/personas, any logged-in user). A searchable, sortable library of the public personas plus the caller's own (GET /api/personas/browse for first paint; the Popularity sort pulls GET /api/personas/usage lazily, off the paint path). A card opens a persona's "who they are" profile — the whitelisted intro sections of profile.md via GET /api/persona/<slug>/about — before a private chat. room-ui.html also renders this browser as an in-app overlay (from the library in memory, instant open/close). A ?persona=<slug>&solo=1 deep link opens ONE profile with no back-to-browser — the console’s persona-card click embeds exactly that in a modal iframe.
lib/db.pySQLite. The schema + every query — users (incl. the profile display_name + about), sessions, rooms, members (incl. last_read), turn_events (append-only audit), settings (incl. personas_hidden — the take-off list — and search $/1k prices), invite_codes, dm_pairs (the canonical 1:1 per pair; set_dm_room binds, clear_dm_room releases when the room stops being a duo), friendships + persona_follows (the contact layer) and with them may_reach(a,b) — THE social predicate: friends ∪ chat-mates, minus blocked. Everything that used to ask shares_room() asks this instead (the profile page, the private-chat key, the invite picker's reachable_not_in_room, and the member-add route), and shares_room is demoted to one input of it — joined at phase 4/5 by the remark (friend_remark, written to my own cell), the link token (link_code / rotate_link_code / user_by_link_code) and search_findable(), the one brute-forceable path: findable=1 only, matching the handle (a display name would let you trawl for people by first name), two characters minimum (three hid real handles — @CW is two), the caller's own blocked set subtracted; push_subs (Web Push devices) + push_events/activity_days telemetry; persona_memory + persona_memory_mute and the memory verbs (persona_memory_open — the room's read, whose only caller is visible_memoriesmemories_about the page's wider read, add_memory · close_memory · memory_forget · memory_touch · memory_added_since, and the switch pair memory_on/memory_muted, and since the agenda the thread/ping verbs — memory_tmeta · memory_set_tmeta · memory_live_thread_count (the five-per-mind cap's basis) · memory_open_threads (the nightly review's working set) · memory_due_loops (⑦'s). ⚠ Those last three are maintenance reads, never a model's — their output goes back into tmeta, and the one model that ever sees a loop still reads it through visible_memories, so the three-named read seam is unbroken); followup_log and its verbs (followup_log_add · followup_log_list · followup_send_count, which counts would-sends and real sends alike, so the caps pace a dry run exactly as they pace a live one); msg_index and its four verbs (index_lines · unindex_lines · clear_room_lines · search_lines — the last taking the caller's already-scoped room-id list, so the membership gate is closed before the query is written rather than filtered after it); admin aggregates (usage_by_model / _day / _user / _room); and shielded_user_ids() — THE super shield, in one definition (2026-08-13, the model). ⚠ It is a read-path filter, not a list the console hides. A plain admin is not shown a filtered view but a smaller world: rows, counts, totals, rankings and rooms are all computed as though the shielded accounts had never existed, because hiding rows while leaving the totals whole announces the shield by arithmetic. It folds in every super by definition (a tier a plain admin can see is a tier they know to go looking for), one exclusion predicate (_ev_clauses) covers turn_events by user or by room, and a room with even ONE shielded member is hidden whole — its title, transcript, member count and cost would each leak that member on their own. One connection, lock-serialized, WAL.
lib/auth.pyIdentity. argon2 password hashing, signup (invite-gated, first user = admin), login, sessions, the daily + weekly spend caps (cap_status), and the FastAPI auth dependencies (current_user / require_member / require_admin / require_super). Also is_super(user) — the tier predicate, one definition asked everywhere (2026-08-13): a super is an admin plus a flag, so role stays admin on the wire — it is the effective-privilege field every client gate reads, and the tier rides beside it. ⚠ Probing a super-only route learns "not for you", never "a higher tier exists". Also invite_status(code)(valid, reason) (v897) — the read-only half of an invite check, which spends nothing: the signup page asks it before it draws a form, so a dead link says so at the door instead of after somebody has picked a username. The reason exists for that door's wording only — unknown · revoked · expired · used_up, never a bare "no".
lib/cost.pyMoney. PRICES per family (Opus / Sonnet / Haiku + deepseek-v4-pro / -flash / -v4.1-flash / -flash-vision) → cost() of a turn; usage normalisation usage_to_dict (Anthropic 5m/1h split) + usage_to_dict_openai (DeepSeek hit/miss → read/creation); estimate_from_event_log. The table also carries the Gemini families (the builder's audit seat / the Seen hero painter), and it is the single price surface: apply_overrides/effective_prices let the console's price editor replace any family row, so one edit re-prices every model everywhere. Since 2026-08-16 it also owns the clock: DeepSeek bills two tiers by UTC and peak is exactly 2× off-peak on every column, so the table keeps one row per model (the off-peak rate — which keeps the console editor's 5-column shape intact) and cost() applies PEAK_MULT at billing time. is_peak / peak_info / minutes_to_flip are the readers. Since 2026-09-10 it also owns the calendar: a vendor price change is a dated row, never an edit — FLASH_CUT / FLASH_CUT_AT (12:00 Beijing on 2026-09-10, when the Flash series went to $0.15 miss · $0.003 hit · $0.60 output off-peak) is folded in by _dated() inside effective_prices(when), so cost(…, when=…) bills a call as of the moment it was made and history is never re-priced — since 09-13 the peak tier is read as of when too (is_peak(when)), so a call re-priced later keeps its own hour’s rate; the superseded row joins _RETIRED so a console snapshot saved before the hour cannot shadow the cut. The calendar has a second dated entry since 2026-09-10: PRO_ROUTED_ATV4 Pro is discontinued at 12:00 Beijing on 2026-09-14 (DeepSeek’s own site), and from that hour every request to the Pro id is routed to V4.1 Flash and billed at Flash’s price, so _dated() simply points deepseek-v4-pro at the (already cut) Flash row. Exactly the same shape as the cut, and for the same reason: a Pro-named seat that still exists — the floor producer’s v1p arm, a fixed constant, a room saved on Pro — bills what DeepSeek bills, and nothing before the hour re-prices (the 08-16 Pro row joined _RETIRED beside the pre-08-16 one to hold that). Nobody else on the table is tiered (Anthropic and Gemini are flat). Two consumers sit on it, and neither is handed a boolean to keep: the room page gets the window (rate_tiers on /api/config) and reads the clock itself for the chat menu's Peak ×2 / Off-peak badge, and every spend gate that costs real money (the exam, a Studio build, a word well) prints peak_notice() — one line, and silence when off-peak with room to spare, because a banner on every ordinary run trains people to skip banners. ⚠ The window is the Chinese working day in the owner's own clock (09:00–12:00 · 14:00–18:00 CST), which is exactly when a big run is most likely to be fired off. The alignment is not a coincidence, and it is the whole reason a spend gate is worth printing a line about.
lib/websearch.pyThe SERP engines — the search half of the @Web Search dispatch (and the Vibes news harvest). Three providers (serper · brave · bocha); search() walks the chain picked by the query's own language (en Serper→Brave · zh/CJK Bocha→Serper→Brave), skipping keyless engines. Per-engine $/1k in PRICES; has_key / available gate the SERP option.
lib/unfurl.pyPaste a link (v1032; imported by run_roomguarded, like pillow/pypdf: a box without trafilatura/lxml still boots, the feature simply goes dark). One outbound fetch of a pasted page, done the way you fetch something untrusted: host_is_refused/resolve_pinned refuse private, loopback and link-local addresses and pin the resolved IP so the name cannot be re-resolved between check and fetch, ALLOWED_PORTS holds an explicit port to 80/443, guarded_get re-runs both at every redirect (≤5) with the proxy env ignored and the body capped at MAX_BYTES. Then the reading: og_card (the card the bubble freezes), extract_markdownsplit_sectionsbuild_digest (the ≤DIGEST_BUDGET skeleton the panel is handed — lede whole-ish, a short preview per section, the tail kept), hidden_main (the WeChat shape, where trafilatura sees only the footer), and looks_failed/_WALL_RE, which name a paywall or consent wall as one rather than passing the wall off as the article. ⚠ It only fetches and reads — the freezing is run_room's (rooms/<rid>/links/<lid>.json, served back by lid; the origin is never touched again). Design: Paste a link.
lib/wordpools.pyThe house's entropy (ledger #11, 2026-07-31; imported by run_room). Two word pools — everyday nouns and confusable pairs — in zh and en, drawn from by Room._draw_pool when a tag defers its secret to the server (draw="words" on a deposit, draw="pair" on a deal). The draw happens with a CSPRNG at apply time, after the arming speech has already published, which is what removes the secret from the turn that set the round up. A dial's resource, like a named deck — never a game preset. Split at the seam (2026-08-06): draw() is now a thin resolver over draw_from(entries, …), which is the one copy of the draw arithmetic — the entropy, the avoid filter and the without-replacement rule are the parts that carry the security property, so the ranked shortlist below hands its entries to the same function rather than growing a second implementation. An unknown pool name still answers [] and the seal's arm turns that into a named refusal, so a mistyped pool is never silently served.
lib/wordpick.py
+ lib/wordpool/
The persona's own well — the LIVE path (2026-08-06; imported by run_room). One shipped pool, ranked per seat. lib/wordpool/ holds the pool built once for everybody(~7.8k items, words + pairs, zh + en, each with a cached embedding in a sibling .bin); persona_vector(slug) embeds a persona's own card once(~1s the first time that persona is ever seated, ~1ms forever after, no build step — a character invented thirty seconds ago is playable)and pick() ranks the pool against it by dot product. It replaces per-persona generation: same result, ~$0.00001 instead of ~$0.50. The mount is one seamRoom._word_source, the only line in the app that knows where a secret comes from: it takes the room's host seat_word_seat, the ANSWERER: in 二十问 exactly one seat talks about the word all game), takes a shortlist of that seat's top-ranked entries, and hands it to wordpools.draw_from. Everything above the seam — the CSPRNG, the apply time, the replace rule, the binding, the reveal — is source-agnostic and did not move. ⚠⚠ RANKING IS NOT DRAWING. pick() is deterministic: its top item is identical for every game in every room that persona ever sits in, and derivable from their public card by anyone — taking it would hand away the exact property the draw exists to protect. Rank NARROWS, entropy still PICKS. Fall back, never fail: no embedding key, an unloadable pool, a persona with no usable card, an emptied shortlist — every one lands on the house's generic pool(and a degraded ranking, which returns the pool's file order, is deliberately treated as no ranking at all). Whose card, sharpened 2026-08-06: _word_seat used to guess the host by taking cast[0]; the seat is now threaded down from whoever armed the draw(the deal's slug, the seal note's slug), with cast[0] only the fallback for a caller that names nobody, and a slug not seated in this room falling back rather than ranking against a stranger. On a device room the two are the same seat — everywhere else they need not be, which is the classic path the old reading got wrong. Design: The word pool.
lib/wordwell.pyThe word well — a persona-flavoured word & word-pair generator (2026-08-05; a standalone CLI, not imported by run_room). ⚠ SUPERSEDED as the live path 2026-08-06 by wordpick above — generating a bespoke well per persona costs ~$0.50 and ~20 minutes, which no roster of user-made characters can survive, so retrieval replaced generation at the seam. It is kept as the deepening path: what you run when a persona's world genuinely isn't in the shipped pool. It does not touch the draw machinery above; it replaces the bank's authorship — instead of one hand-curated general list, every persona gets its own well, so a secret drawn in their room lands as of course that's theirs. Built on the house grammar: the world salts, the mind proposes, the world picks — the server rolls one constraint per batch from its own wheels (CSPRNG, alternating facet and scope, because a model asked twice for「100 words from their world」writes nearly the same list twice), the persona call answers that salt off profile.md + tags.txt, and a separate judge call scores every candidate while the code applies the gate. Two kinds: word (a 20-questions secret — concrete, guessable, tiered) and pair (卧底's confusables, built in two stages — the persona supplies frame seeds, a persona-free pair-smith balances the halves, because knowing your world and knowing what makes two words confusable are different jobs). The no-padding law: a persona whose world cannot honestly fill the target fails loudly at its real ceiling (capped at N) rather than shipping filler wearing their name — the judge's freshness axis is anchored on the generic bank as the negative control. ⚠ Read stop_reason, never the survivor count: in the qualification run nearly every apparent「cap」was the stopping rule, not the persona's floor. Design: The word well.
lib/gameflow.pyTHE GAME DEVICE's flow schema + validator (D1–D4, 2026-08-02; imported by run_room). The closed vocabulary a play kit is written in when it is a cartridge: flow_of(body) pulls the fenced ```flow JSON block out of a kit's markdown, validate(flow) refuses anything the runner can't execute — a phase outside setup · round · end, a verb outside the nineteen (deal · board · counter · moment · await · ballot · eliminate · end_check · reveal · seal · rule · spin · roll · compose · score · button · collect · dial_ask · readyseal · rule · spin · roll arrived with the second, third and fourth cartridges, compose · score · button · collect with 大话骰's second day (2026-08-03), and dial_ask · ready the day after (2026-08-04): the first puts a dial the kit declares up as a card for the room to pick before the first deal, the second is button's opposite number — button is first-past-the-post and closes a window, ready is unanimous and opens a beat, and a lapse on it proceeds rather than folds, because readiness is pacing and the consent already happened at the mount. And spin/roll are the wheel and the cup, the latter being the deal wearing pips: a sealed hand whose cards are dice faces, so server entropy, own-eyes visibility and the pile publishing whole all come free), an await outside all_living_spoke · vote_result · a_question · turn_line · cups_full · a_press, an end predicate outside the grammar (faces_identical · living<=N · living>=N · round>=N · round>=dial:<name> · asked>=N · ruled_win · always — the dial: form is a length the room set, so「how many rounds」can be a table's choice rather than a number frozen in the kit), a winner that is neither one of the resolvers the world computes (surviving_face · nobody · last_living · top_score) nor a side the cartridge itself declares, and — the leak door — a mid-game moment quoting an END-only fact (faces · winner · secret: the role sheet is the dealer's, not the table's). The schema widens where a second carrier splits a fused concept, which is the method's own law: the winner enum was one genre's list of sides wearing a general name (a quiz has no majority, it has the guessers and the house), and「a ballot needs an eliminate」was one genre's coupling — a ballot's settle now merely needs a reader (eliminate, score, or an awaited vote_result). THE COLLECT PHASE (2026-08-03) put three more machine parts in this file, all of them pure functions beside the schema so a bench can drive every branch without a room, a server or a persona. ① The validator library (VALIDATORSliars_bid · spy_desc, reached through the one door validate_entry): a collect step names its grammar and sets its dials, and the named function judges one entry against the window's running state — strict turn order, the climb/convert/double/lock ladder, and the table as the ceiling (no claim may exceed the dice actually on it). It answers (ok, reason, state2) and an unknown grammar fails closed. ② Reasons are codes, never prose (_r("double", lc=…, need=…)) — the runner renders them through the kit's lexicon in the room's language, because voice-time translation drifts terms while builder-time translation is done once, and「declined」makes a table retry the same thing where the exact break names the next move. ③ The legal envelope (ENVELOPESliars_envelope) derives what the client may compose — per mode, the minimum count and the face floor at that count — so the bid strip clamps the illegal moves without a second copy of the ladder living in JavaScript; the validator stays the authority, the envelope is a fence. A collect window's EXIT is a registry too (COLLECT_UNTIL) and it grew its second name with 谁是卧底's reshape (2026-08-06): a_press is the challenge button(大话骰), all_living_spoke is the round that ends when everyone owed has been accepted. ⚠ That second one is what turned this from one game's window into furniture — 「everybody has had their turn」cannot be counted in raw lines once the window is ruled, because three lines from one player and a dodge from another is not a played round, so the count is over the ledger of accepted entries, never over who happened to type. spy_desc also moved the seam: a bid is transcription(the mind reads「三个四斋」into three integers), a description has no strict form to read it into, so the mind's read arrives as a closed kinddesc · copy · empty)and everything arithmetic stays the world's — who is living, who already spoke, whether the line contains the speaker's own dealt word, what a void costs. And unlike the bid ladder it fails OPEN: an unknown kind reads as desc, because a void the player did not earn is worse than a slack line getting through — the table blames the player and the machine wrote the sentence. Alongside them, the canonical mode ids go English (plain · zhai · fei, frozen, compared and persisted, never localized) with MODE_NORM as the read-time normalizer for the legacy Chinese tokens and every synonym family, and the lexicon is linted for key parity: a cartridge's lexicon block is {lang: {key: template}} with zh as the reference, and a language that covers it only partly fails the shelf rather than rendering a half-and-half line at a live table (runtime falls back to zh whole-surface). Parse failures are loud and leave flow None, which is the dual-mode hinge: a kit whose flow won't validate runs the classic path whole rather than half a program. Also a standalone linter over the whole shelf — python lib/gameflow.py. Design: The game device.
lib/interrogate.pyTHE UTILITY KIT's engine — the interrogation (2026-08-07; imported by run_room). The same relationship to a utility kit that gameflow has to a cartridge, and the same failure policy: probe_of(body) pulls the fenced ```probe block out of a kit's markdown, validate(probe) refuses what the engine can't run, and a block that will not parse or will not validate is loud and leaves probe None, so the kit falls back to the classic prose path rather than mounting a broken machine. Above that it owns the process itselfInterrogation (the run: the claim, the seats, the threads, the board, the fold budget) with new() / from_state() for the room's store. The split is the constitution's: code owns the process, a mind owns the question — the engine decides which seat is asked what and when a thread is satisfied, folded or 待查, and never writes a word of it. Also a standalone linter over the whole shelf — python lib/interrogate.py, the probe's answer to gameflow's. Design: The interrogation.
lib/vibes_brew.pyThe Vibes automation (the knob schema + the daily brew; imported by run_room). Owns the console-tunable settings for the feed and orchestrates the offline pipeline by subprocessing the vibes_* engine CLIs (extractor → writer → news harvest) that stock vibes_posts; the runtime serving side (the slate composer, the drip scheduler) lives in run_room. Design: Vibes content engine.
lib/ink_brew.pyTHE BREW — how Ink is made, nightly (2026-09-02; a standalone CLI that run_room also reaches — lazily, inside _ink_mod(), never at import time, so a box missing the brew’s dependencies still boots the app). Two passes, and since Phase 0.5 (09-07) they wear the owner’s namestext and publish, with evening / dawn kept as aliases and read in both spellings by the brew-log reader, because the log now speaks the same three times the console does (TEXT · ART CUTOFF · PUBLISH). text runs pegs (the wide harvest, ink_harvest) → the pitches (09-09 — every persona not resting reads the morning’s pegs as itself through one cheap call and raises a hand or not) → the budget meeting (slate) → the writers (the reporter’s notebook inside them) → the briefs → the gate → the renditions → the art director → the art batch; publish (collect the art; at the art cut-off (the console’s knob, 07:15 by default) cancel the batch and paint the stragglers synchronously, then ink_compose, then an atomic pointer flip of edition.json); brew runs both at once for dev, and status · bank · voices · research · briefs · calibrate · thumbs are the stage verbs. THE CLOCK MOVED INTO THE APP (09-04, owner:「let me set the start time in the Ink tab」): the console’s Ink tab holds a Daily brew switch and three times — text starts (20:00) · the art cut-off (07:15) · publish (07:30) — and run_room’s _ink_scheduler thread asks due_pass(now, knobs) every minute whether a pass is owed. ⚠ Three knobs, not four: the dawn collect starts thirty minutes before the cut-off, derived rather than stored, because a fourth time the owner has to keep consistent with the third is a bug waiting for a clock change. Each pass runs once a dayevening_started/dawn_started stamped on that day’s state.json, so a pass that died is not retried every minute — never while the kill switch is on, and behind the same lock as the console’s Brew now. The two systemd timers in deploy/ are superseded (installed on the box, never enabled). And a pass that dies tells the owner: NOTIFY is the one seam — the bare CLI writes the durable rail rows for every admin through db.admin_ids(), while run_room hangs its own callback on the module so an in-app pass also gets the live SSE mirror and the lock-screen push. The collapse key is the day (ink:<date>), so a second failure replaces the first, and an ink_ok event revokes the row when the day publishes after all. Every stage freezes an artefact under <ink_dir>/brew/<date>/ and is skipped on re-run unless --force, so a half-finished night resumes rather than restarts. The constitution again — the LLM proposes, the code enforces: the editor commissions, a validator trims the slate to policy; the writers write, the gate judges — and since 2026-09-08 it ranks the survivors on ink_lint.register’s deterministic flags rather than on a model’s score of them (the floor ranks; it never drops). ⚠ The gate judges and marks; it neither decides nor edits: since v1094 (09-10) every piece written goes to the edition, a rejected one carrying its verdict as fine print, and since 09-11 (owner:「no more AI editing — we try to make the first run right」) nothing rewrites a first draft — no retitle, no cleanse or polish_zh, no antithesis rewrite, no length top-up (those functions stay defined for the bench and are not called). The one change the gate still makes is notation, by code: an English piece’s spelled figures of 11 or more become digits (ink_lint.spelled_to_digits), and digits the reporting lacks are logged, never judged. THE WRITING (09-11 → 09-12, the outside research §7): several drafts a piece — the drafts knob (the console’s drafts a piece, 1–4, default 1): write_drafts writes a commission’s drafts in parallel and keeps each under brew/<date>/drafts/; DRAFT_MODELS = three DeepSeek V4.1 Flash + one Gemini 3.8 Flash (DeepSeek draft i is dealt only the i-th memory of the hand); the pick is codedraft_key: a clean lint, then no disclaimer, then the fewest register flags, then (since 09-13) the over-used phrases (ink_lint.overuse) and the gavel share (ink_lint.gavel_share), both English-only, then the lint score; no model scores a draft. The interview — the interview knob (the console’s interview first, off): before each draft one small call on the interview seat (app.ink_interview) asks the persona five questions written by code, and the writer’s prompt carries the answers; writer_head() is the prefix both calls share byte for byte, so the writer reads the profile from the cache; fail-open. The persona drives (recipe r33, 09-19) — the driven knob (the console’s the persona drives, on by default) sends every English commission through write_driven_piece() instead of the drafts: the persona reacts to the reporting (reaction(), its own questions) → its life store is recalled against the news (life_store() written once from the record to <ink dir>/life/, recall() by a reader, zero is legal) → the desk looks up its questions → the point (the_point()) with its two tests — absence_test() by code (the point said back from the story twice raises NoPiece, logged as NO PIECE, a fair answer, not a failure) and mechanism_test() by a reader → the witness desk (witness(), quotations checked verbatim against a fetched page) → its own outline → the piece written whole → a rival reader marks the tells with the taxonomy (defect_marks(), lib/ink/taxonomy.md) and the writer mends, twice (mend_defects()) → the reread → the two-stage check (fact_flags_two_stage()) and the correction → the point kept as the persona’s prior (<ink dir>/life/<slug>.prior.json). Every step is kept under <brew>/driven/<id>.json, and the gate counts what that look-up and the witness desk found as reporting. A Chinese commission, or the knob off, keeps the old path. Preview — for a Review the desk names the product and who in the reporting used it; needs_preview() + apply_previews() turn a product nobody used into Preview, a code-only format (CODE_ONLY_FORMATS) the editor is never offered. BOUNDARY, the house rules, ends on the owner’s eight never-lines (09-12). The shape menu (SHAPES · shape_of · SHAPE_NEVER) sits behind a shape knob — off, no console control, tested and no better. Algorithm A — the repair pass (09-14, owner’s go; selection by the paragraph): repair_pass() takes a picked draft as the spine, runs para_key() — the lint’s per-paragraph hits, the over-used excess and the gavel, with the names and numbers a paragraph uses from the notebook as the last key — and re-rolls each failing paragraph in its place, N candidates on the same writer prefix, keeping the cleanest or the original; the fault is never named. ⚠ Defined here, not yet called by the brew: only the loop bench reaches it (exam/ink_loop.py recipe r20), and it waits on the owner’s blind read. THE WORKBENCH (09-16 → 09-17, the writer as an agent): the skills of that design, built one at a time and — like the repair pass — defined here, reached only by the bench (exam/ink_loop.py recipes r21–r25, exam/ink_workbench.py), never by a brew stage. ① The archive — what came before this news, and the one part of the workbench that already owns a ledger leaf (app.ink_archive, the console’s Research group): archive() searches by intent rather than by subject (prior coverage · the precedent · the numbers over years · the study everyone cites — and, in the wide form the owner asked for, four more: the people · elsewhere · the mechanism · the critics), fetches the best pages, keeps each page’s text, and gives every found item an archive source number (A1…) that maps back to a page or a search hit, so no item is ever invented; archive_dossier() then reads each cited page again and writes out its notes on the items the plan uses. ⚠ The arithmetic that forced the dossier: a pool item is one line of ~25 words, so twelve of them are ~300 — not enough to fill the 44% background the majors run (measured by reading, MATERIAL_TARGET = news .14 · archive .44 · life .08 · view .35; our own pieces ran 24 / 11 / 14 / 52). ② The planplan() samples N outlines as jotted cards (a format, not a rule: asked for “one plain line” 88–100% of lines carried a fault, asked for a card of at most fourteen words 29–55%), reads every line in code through ink_lint.line_faults, and keeps the best by plan_key()the mix leads, the faults break the tie, because a faulty line can be re-rolled and a wrong mix cannot. ③ The deal, and Algorithm B (r24): plan_material() deals every fact of the reporting and the archive to exactly one card, and write_by_parts() writes the piece part by part off those cards, each candidate part judged by part_key() — length off the want, sentences that repeat the piece so far, numbers from nowhere, the defects, the over-used excess and the gavel, with its own material used as the single positive key. ④ The persona drives (r25, 09-17): the order stops being the code’s and becomes the writer’s — reaction() (what struck it, what it reminds it of, and its own three-to-six questions) → look_up() (the desk answers those questions, one search each, one merged answer per question) → the_point()own_outline() → the whole piece written → reread() (the writer reads its own draft as a reader and writes it again whole — the owner’s 09-17 ruling that rereading and editing its own piece are allowed, where nothing may rewrite it for it) → fact_flags(), the check on a model that is not the writer, mended by correct() against the writer’s own material and nothing else. Design: the workbench · the dossier · part by part · the persona drives · the writing process. At publish edition_sameness() measures how alike the day’s English pieces are, to each other and to the previous seven editions, logs it and saves it in state.json; the console shows it as Alike. THE DESK (Phase 1 of the upstream plan, 09-08) is the same law applied upstream of the writing, because the first read found the bystander’s cause was a thin notebook: _section_url() refuses a peg whose last URL segment is one or two category words with no id, date or hex (a section page commissioned the Hinton essay); notebook_thin() names the cause and floors a still-thin commission to the spares after research (the lead exempt, the slate rewritten); a second desk round searches again on what the first could not establish, and _page_text_ladder() climbs page → reader proxy → archive snapshot → the open wires for the lead and the well. 隐喻率, the metaphor rate, rides every survivor as a meter and never a fix — it is logged and filed on the piece, and it does not touch the ranking. ⚠ A brew never leaves a blank day — a failure anywhere leaves yesterday’s edition.json current. Content lives at MAD_INK_DIR (box /var/lib/mad/ink; dev default lib/ink), voice cards under $MAD_ROOMS_DIR/_vibes/cards. Cost is booked per task, on the app.ink_* leaves of the call ledger. Design: The brew.
lib/ink_harvest.py
+ lib/ink/harvest.json
THE WIDE HARVEST — the morning’s pegs (2026-09-09; reached by ink_brew.stage_pegs, and a standalone CLI: python lib/ink_harvest.py [YYYY-MM-DD] [--out DIR] [--resolve]). The owner’s course after the budget-meeting study: keep the pool wide and balanced — the old harvest asked Google thirty-three beat-name questions and got ~80 pegs from nine outlets, two thirds American, three beats empty. No model is called. It reads the free sources (Wikipedia’s Current events, Deaths in <year> and the day’s Selected anniversaries · Google News top stories, one feed per country edition — 37 editions, 13 regions, 20 languages · topic feeds in five English editions · outlet feeds, all listed in lib/ink/harvest.json), searches the checklist (~150 sub-fields under the eight beats + 17 kinds of event, each one a free Google News RSS search, all of them every day — a search costs nothing, so a rota is unnecessary), then folds one story to one peg on Google’s own clusters and counts what it merged (outlets · countries · editions that ran it top · Wikipedia’s mention). Two of those three signals = FRONT, whatever the beat. Selection ranks by ATTENTION, never by a proportion (the owner’s third correction, 09-09): ~160 seats taken greedily with a diminishing return on sameness — the same running story, outlet, sub-field, beat — no outlet above a tenth, and the last 40 to the long tail (the newest story from each sub-field the list did not touch); the rest of the pool is kept in pegs-all.json with its tags, the wide pool a topic picker or a recommender reads later. Tags from birth, closed vocabulary: beat · region · sub-field · kind · layer, plus the counts; the brew log carries the coverage count (beats, sub-fields, regions, kinds touched — a count, not a score). ⚠ Google News links are redirect stubs — the fold prefers a publisher URL from any layer and the brew decodes the stubs of the commissioned pegs at the desk, one at a time (a burst of 119 tripped Google’s bot wall on 09-09); an undecoded peg keeps its stub, is marked, and the desk’s own search by subject covers it. Everything degrades: a source that fails is logged and the harvest goes on without it, and a harvest under HARVEST_FLOOR lets ink_brew fall back to the old Serper path. Design: the world map · the coverage specimen.
lib/ink_lint.pyThe AI-tell lint — and, since 2026-09-08, the register floor. ① The lint — Ink’s hard gate on machine prose (2026-09-02; owner: “that’s fatal”). Regexes only, no model call, so it is cheap enough to run on every piece, every rendition, and on the same text twice: the antithesis turn (not X — rather Y, zero tolerance), the AI vocabulary, em-dash cadence, the rule of three, the metronome — scored in English and in Chinese. lint(paragraphs, lang) returns pass · score · the per-tell hits · notes. ⚠ It is a gate, not a score — and since 2026-09-11 a gate that marks and never mends: a failing piece used to be rewritten by a rival model and linted again; now nothing rewrites a first draft, so a failure is marked「reads as the machine」and the piece still runs (v1094). Sibling of the room’s own tone work — AI tone is the meter, this is the net. ② The register floor (Phase 2 of the upstream plan, 2026-09-08) now lives in the same module, and it is the thing the brew’s gate ranks on since the model score stopped deciding. register(paragraphs, title, lang, band) returns flags · counts · notesdeterministic counts only (the owner allows stats, not a subjective score): metaphor nouns per thousand words, long words per hundred, first person per hundred (a floor, not a ceiling — 「no one in it」 is the flag), mean sentence length, the Flesch–Kincaid reading grade (readability, a syllable heuristic, no dictionary), and the 「The X of the Y」 title shape. ⚠ Two English bands, and each ceiling is the shelf bank’s 75th percentile — a piece is flagged when it reads harder than three quarters of the real pieces in its band. REGISTER_ALLOW["plain"] (grade 13.3 · 22.9 words a sentence · 7.4 long / 100 · 1.2 metaphor nouns / 1k · first person ≥ 0.3) and ["literary"] (12.1 · 23.8 · 6.5 · 2.3 · ≥ 0.6); ink_brew.band_of(format) picks which. ⚠ The literary band is not the harder one, it is the more personal one — tighter on grade and long words, looser only on images. ③ The Chinese floor (Phase 2.5, same day): readability_zh counts characters a sentence, 四字格 and 文言 markers per hundred; REGISTER_ALLOW_ZH = 28 字 a sentence · 四字格 2.0 · 文言 1.75 · 我 ≥ 0.2 per hundred, taken from the columnists readers finish (六神磊磊读金庸 · 兽楼处) rather than from the periodical — 财新 runs 41 characters a sentence with nobody in it. One band for Chinese until a Chinese shelf exists. The reference corpus the bands were measured from, and whose openings the writer is shown, is lib/ink/register.json (196 openings, tagged cls · band · beat; harvested by scripts/ink_standard_harvest.py, measured by exam/register_study.py). ④ Numbers and sameness (items 12 + 6, 09-11) — code, no model. spelled_to_digits(par) turns an English paragraph’s spelled figures of 11 or more into digits — never at a sentence’s start, inside a quotation, in an ordinal or fraction, or「a hundred」— and returns what it changed; the gate runs it before the lint. unsourced_numbers(body, reporting) lists the digits (10 and up) a piece uses that the reporting does not contain — for the brew log, never a verdict. sameness(today, week) returns the word overlap within a day’s pieces, each piece’s closest match in the week, and the four-word phrases found in three or more pieces. ⑤ The over-use list and the gavel (09-13, owner’s yes) — code, no model, English only. The phrase list is not typed here: exam/ink_profile.py counts the published pieces against the majors’ bank and writes the strong rows to lib/ink/overuse.json, read at import as OVERUSE; overuse(paragraphs) counts each phrase beyond the bank’s own rate at that length (excess + the hits). gavel_share(paragraphs) is the share of multi-sentence paragraphs whose last sentence opens That is / This is / It is… (GAVEL_EN; 10.2% of Ink’s paragraph ends against 2.3% of the majors’). Both feed only the brew’s draft pick (ink_brew.draft_key) — never the prompt, never the gate’s pass/fail. ⑥ The outline’s ruler (09-17, owner: “the outline should be clean, no machine defect”) — line_faults(line) names every fault the code can see in one plan line, and it is stricter than the paragraph ruler because a line is one line and many are sampled: on top of the lint’s own patterns, the over-used list and the gavel, it fails any negation (NEGATION_EN), any dash, and any stage direction (STAGE_EN — “Open with…”, “The turn:”). It is read by the workbench’s ink_brew.plan, never by the brew. Design: The register study · The over-use profile.
lib/ink_score.pyThe Ink quality score — the target function (2026-09-02). Four axes folded into one number: L the lint (code, a pass/fail gate) · E editorial (a judge on Pulitzer’s four, adapted — clarity · reasoning · specificity · a position · what the reader carries away) · V voice, the axis no traditional desk has — the imposter test (pick the persona’s piece among decoys, given only the persona’s real sentences) plus a function-word / sentence-shape profile computed in code · S specificity (dated facts, numbers, names, attributed quotes per 100 words) — IQS = 100 × (0.40·E + 0.40·V + 0.20·S), scored only over pieces that pass the lint. ⚠ The judge is the rival family (Gemini Flash; the writer is DeepSeek), never the writer’s own, and pairwise judgments swap positions and average to cancel position bias. ⚠ Traits are extracted from a persona’s real prose, never from its profile — the PersonalBench circularity warning. R, what readers actually said (ink_feedback + ink_notes), is the ground truth the judges are calibrated against; the number rides the article’s end matter, covertly (v1058), while that calibration runs. ⚠ THE SCORE NO LONGER SELECTS THE DAY (ruled 2026-09-07, owner: 「it makes no sense to have you score each article subjectively; you can do stats — long words, long sentences, pronouns — but you can’t judge overall quality」; Phase 0.3 of the upstream plan). stage_gate writes iqs: null and ranks the survivors on the deterministic register floor instead (ink_lint.register, fewest flags first, ties in the editor’s order) — no model scores a piece in the brew any more. stage_score stays whole as a CLI / calibration stage, which is also why an article that carries a score still prints the covert line. Design: The quality score (⚠ that page still reads as though the score decides — the ruling is recorded on The brew).
lib/ink_compose.pyInk’s layout composer (v1042; a standalone module, not imported by run_room — the daily brew calls it at edition-build time, so nothing is decided at render time). The house constitution applied to layout: the LLM proposes, the code enforces. The budget-meeting editor hands over the slate’s order (and may set well_hint on one piece); compose(date_iso, pieces) then gives every piece exactly one module role the renderer speaks — lead (the ink band + full-bleed hero) · well (THE INK WELL, the one dark showcase) · row (the standard row) · compact (the no-dek row, the pace changer). Five hard harmony constraints, asserted in the module’s own __main__ demo across synthetic weeks: the slate’s first piece is always the lead (editorial order is meaning) · at most one well, never in the two positions after the lead (two rows must breathe between the dark bands) · the well is never last (something must follow the dark) · a compact row only ever appears in a run of ≥ 2 (a lone compact reads as an error) · nothing is hidden (exposure is the law). The variety on top is day-seeded — every decision keyed on sha256(date), so the well rests on ~1 day in 4 when only weak candidates offer, its slot floats, and a long edition’s tail turns into a compact run of 2 or 3: deterministic per date (reproducible, cache-safe) and different across days (the feed reads alive, never templated). Design: The Ink look. ⚠ The shipped 6-piece edition exercises lead/well/row only — the compact run needs ≥ 7 pieces.
lib/room-ui.htmlThe whole app UI. One page: chats pane, message stream, the egg, @-mentions, the chat-info panel, the four roots and their bottom bar (Ink · Chats · Friends · Me since v1038; born Chats · Friends · Discover · Me at v679, Discover retired whole — Studio moved to Friends, Seen to Me — data-root on <body> is which tab is lit, data-tab on the pane is what the pane shows, and keeping those two axes apart is what lets a sub-view hold its parent tab lit and send Back to the right place; the roots register no back layer, one layer stands for "a sub-view is showing"), both SSE clients, and the toolbox's client half (the tool strip, the live cards that ride the stream, the hand, the tools drawer). Since v795–v804 it also owns the Persona Studio whole — the landing (#studioView, painted from /api/build/list) and the four-step build flow (#npPage / #npNative), including the live progress spine over the build's /events SSE; the app loads no iframe at all any more. Also the read-only peek view (/?peek=<id>), the scan page (v860 — an ordinary sub-page dressed dark, ⚠ a scanned code shown and never followed, both halves linted) and the My -ish ID page (v864, sharing one code builder with Add contact — the block owns a rotating secret, so two copies would be two places to forget when the code changes). ~36k lines — the largest HTML file in the repo; links the shared theme.css + i18n.js + fmt.js + persona-search.js (each with a ?v=N buster, bumped alongside BUILD).
lib/theme.cssTHE shared design layer (systemic-check #21–#30). One source for the palette + font stacks, the token scales (radius · space · shadow · the 5-step avatar scale), and the once-defined components: the dialog scaffold (scrim rgba(0,0,0,.40) + the 580 card), .dialog-x/.dialog-back, the search-box chrome styles, the popover base (.popmenu/.rowmenu/.cardmenu), the pill family, the toast. Every app page links it before its own styles, so page-local rules still win; pages keep only page-specific tokens + z-ladders. The md-viewers deliberately keep their own reading palette.
lib/fmt.jsONE display voice (systemic-check #31+#32). Fmt.ago/agoMin/rowTime/date/tok/usd/dur/num — relative times, en-GB absolute dates (“5 Jan 2026”), 1.2k/1.23M tokens, adaptive money (<$1 → 3dp), one duration format, thousands separators. The room, the console, and the Studio all load it, so the same field reads the same everywhere. Locale packs (v382) follow <html lang>: zh reads 刚刚 / 5分钟前 / 昨天 / 2026年7月11日; pages without i18n.js stay English.
lib/i18n.jsThe UI-language layer (v382). One strict-JSON dict per language keyed by the English source string (missing → English fallback); t()/tn() for JS-built copy, I18N.apply() auto-translates the static shell (text nodes + placeholder/aria-label/title/data-ph/alt — nothing needs annotating). “Default language” rules both axes: the panel's language in new chats + the UI locale (mirrored to mad-ui-lang, resolved pre-paint in <head>, one reload on change; brand names never translate). Enforced by lib/i18n_audit.py — missing t()-keys / stale entries fail, static untranslated rides a ratchet budget — via smoketest's “i18n catalog” section. Since v384 the WHOLE room-ui speaks zh (the static ratchet sits at 0, so new English ships with its zh entry or smoketest fails); v385 adds login/share/personas + server errors translating at display sites (t(d.error) — auth + common room strings in the dict, the rest fall back to English); v386 completes the sweep with the Studio (builder-ui — steps, stages, chips, bill; ~500 entries). Every user-facing surface follows “Default language”; the console + md-viewers stay English by design.
lib/admin.htmlThe admin console (/admin, admin role only). Overview, users + roles + caps, rooms (with peek), Seen (every generated piece), Usage & cost (counts every API — rooms, Persona Studio, Seen, Convene, composer dictation — By model split into LLM / image / search / speech groups, each ranked + subtotaled, and By function: Persona Studio / Seen / Convene / dictation + the top rooms), invites, personas, plus four config panels: Models & APIs (every model role in one place — the panel catalogue + the admin one-model roles for FP mid-chat / FP opening / dispatch / panel curator, since v655 three per-call roles beside them (act_model · prop_gate_model · autotitle_model, each defaulting to inherit), and the three Seen roles: other-writer rank + the writer, both DeepSeek, and the hero-image painter, a Gemini Imagen id — plus the priced non-LLM APIs: search $/1k and Speech-to-text $/call, metered per signed dictation session — and the LLM $/M table itself, the one surface cost.py bills every room and Studio call from. ⚠ Since 2026-08-16 DeepSeek prices by the clock: the rows you edit are its off-peak rate and the meter doubles them inside the peak window(01:00–04:00 · 06:00–10:00 UTC), which the card labels live; every other vendor is flat, and a stored console override that still equals a retired default is dropped on load so a vendor's price change can actually reach the meter), Settings (defaults, spend caps, the new-room floor-producer default, the new-room panel-guidance default — v1 baseline / v2 manners, stamped at creation so existing chats keep theirs — feedback, experiments, the per-role Who-sees-what grid, and since v655 The turn: the turn split and the prop master as live switches read at the drain, plus kill switches 5a global tools-off and 5b the composer's tool button alone — everything here was an env var read at import, which made 「turn it off」 mean 「edit the box and restart it」; the 2026-07-30/31 turn shape added four more rows on the same pattern, each defaulting ON with a MAD_* pin that beats the console and says so in the row: deliberate first · the silence door · the coda · the verify tail), Status (read-only server health & config — all seven provider keys by role/present-missing, the floor-producer health row: staging counts by arm + fallback keys + smart-f p50/p95 since restart, and since v655 two more cards: cost per call type — a turn is up to six provider round trips and one total is the number that hides which one grew — and toolbox health (toolbox_health(): arms by kind AI-hand vs human-hand, arms ÷ panel turns as live restraint, the *_bad refusal-by-reason ledger read straight out of PARSE_STATS rather than copied into a second counter, and the prop master's PROP/NONE split with its fail-open count), deliberately in the exam's vocabulary so the two are comparable), and a Backup tab (export / restore every console setting as one portable JSON snapshot, on this box or another). The Personas tab now carries a SYS/BUILT provenance badge + slug per card, a ⋮ take-off / put-on control (hides a persona from the user picker without removing it), and publish / unpublish (flip a built persona between private and public in place).
lib/qr.js
+ lib/jsqr.js
The two halves of a code (v859 · v860). qr.js is the encoder — hand-written, byte mode, level Q, versions 1–10, which is five times the longest payload this app can mint; a general 40-version library is most of a dependency and the page may fetch nothing at runtime. ⚠ A QR is a machine target, not decoration: flat black on flat white in every theme, with the spec's 4-module quiet zone inside the SVG — clay on cream reads as ours and reads badly to a phone in poor light, and an inverted code in dark mode is unscannable to many readers outright. Hand-written means it is gated by proof rather than by reading it: bit-for-bit against segno across all eight masks, a round trip that inverts placement and mask and reads the message back, and zero Reed–Solomon syndromes per block (what a real decoder checks, involving no reference implementation at all) — and the smoketest says so out loud when node or segno is missing, because a check that skips in silence reads exactly like a check that passed. jsqr.js is the vendored decoder (Apache-2.0, verbatim), fetched the first time somebody scans and never in the shell — a lint holds that line in both directions, served and not precached. ⚠ Since v890 both readers — the phone's built-in one where it exists, and this — are handed the same crop, so there is no coordinate space left to get wrong and a mute or throwing built-in reader still falls through to a working one.
lib/cjkfold.py
+ lib/cjkfold.js
The CJK variant fold (v895) — one answer to「小岛秀夫 should find 小島秀夫」, simplified ⇄ traditional ⇄ Japanese kanji. A GENERATED table (scripts/build_cjkfold.py, from OpenCC), deliberately one-way to simplified and single characters only, shipped as two copies of the same map because the browser needs the identical answer and cannot import a Python module — fold() in each. Consumed by db.search_lines (the chat-history index) on the server and by the persona / contact search in the page. ⚠ Never a runtime dependency: the Python side is imported behind a try that falls back to identity — a search that folds nothing is the old behaviour, and a search that cannot start is not. The index pays for it only where it earns: msg_index.fold is written only when the fold differs from the text (_fold_or_noneNULL otherwise), so the common Latin-or-already-simplified line costs nothing and the traditional/Japanese line — the one that needs a second copy — is the one that buys it. ⚠ Both the query and the column are folded: doing either alone finds nothing new. Served to the browser from /cjkfold.js, on the immutable-cache list with the other generated assets.
lib/make_super.pyThe out-of-band grant (2026-08-13). python lib/make_super.py <username> — grant, --revoke, --list. ⚠ The first super cannot be made from the console, on purpose: the tier's whole point is that a plain admin cannot reach it, and a console door that minted the first one would be exactly that reach — so the first grant is made by whoever has shell access to the box and the database file, the same trust level as owning the server. Granting also raises the account to admin, since a super who is not an admin is refused by every one of the app's admin checks. No restart — the flag is read per request. Design: Super.
lib/login.htmlThe gate. Login + signup (prefilled from an /invite/<code> link). The two-screen signup ends on the identity step — display name, and (v1036) an optional Gender dropdown carrying run_room.GENDERS's closed vocabulary, asked here because the hosts address a new user from their very first message; it rides /api/signup and an unrecognised value stores as unset, never a refusal. A code that carries a language (v1033) opens this whole page translated before the form is drawn. ⚠ An invite link is also the way back (v1037): people keep the message it arrived in and tap it again long after joining, so /invite/<code> checks for a live session first and sends anyone who already has one to the app — never to a signup form asking for the one thing they have already answered. The bounce is only on the invite door; /login stays reachable while signed in, because going there is a deliberate act (sign in as somebody else). ⚠ It honours ?next= (v875) — validated to same-origin paths only, or the hand-off is an open redirect. Before that fix the server had always preserved a code across the sign-in round trip and the page had never read it back, so every link shared with somebody not yet signed in on that browser dropped its code at the door.
lib/md-viewer.htmlThe profile reader. Renders a persona's profile.md, with browser-side read-aloud (TTS straight to the provider — no server bandwidth).
lib/prompts/The prompts run_room reads off disk — the only model instructions that are not Python string literals. kickoff_cue*.txt / kickoff_staged*.txt (the opening beat, topic and staged variants) · dispatch_brief.txt (the @Web Search fact-brief writer) · floor_producer.txt + floor_producer.v2.txt (the smart f, picked by the room's prompt-variant stamp via fp_file(variant)) + floor_producer_open.txt (the opening producer, shared across variants) · prop_master.txt (the restraint door in front of the act call). Editing one of these is a regression event — re-run the exam, not just the scenario you had in mind.
lib/smoketest.pyThe safety net. python lib/smoketest.py — runs the unit selftests + in-process integration checks (no API). Run it after any change.
exam/The exam — the toolbox scenario battery as one repeatable, scored command (run.py · scenarios.py · score.py · leakscan.py; artifacts in the gitignored runs/). It spends real API money, so it is a gate you run deliberately, not part of the smoke test — though the leak scanner's five planted leaks and the scenario set's well-formedness are checked on every smoke run. Beside it live the free benches, which cost nothing and are gated in the smoke test: device_dryrun.py plays a whole cartridge game per scenario with no server, no API and no persona, and interrogate_dryrun.py (2026-08-07) does the same for the interrogation engine — each prints its own checks when run alone, so「did the process break」is answerable before any spend. kit_contract.py (2026-08-08) joined them: the five axes asserted in ONE place with BOTH engines driven through it, 28 checks, also gated in the smoke test — the measurement behind it being that the two flow vocabularies share exactly two terms, so the lifecycle merged and the language deliberately did not (a cartridge's safety is its vocabulary being closed; union them and the validator can no longer reject anything). scan_decode_test.js (v889–v890) joined them for the QR reader: it runs the real decode handler with exactly the names the real module has in scope, so a name it does not genuinely have throws there instead of on somebody's phone, and drives both geometry functions against a real phone geometry — 13 checks that fail against the revision which was deployed. ⚠ It was written after a feature that had never worked once shipped through ten versions, because one catch covered both the decode (which fails harmlessly on every blurry frame) and the handler (where a throw means the feature is broken), so a programming error looked exactly like a frame with no code in it. memory_recall.py and memory_leak.py (2026-08-18/19) are the memory store's two, and both spend real money: recall tells a host five checkable things in a private chat, lets the room go quiet so the write path runs, then opens a brand-new chat with the same host and asks — the rate, plus the two things that spoil recall even when it works (attribution, 「you told me」, which matters most here because the fact came from a different room, and recitation, the host listing what it knows unprompted, counted on the open turn where nobody asked). ⚠ It plants and cleans up: every row it writes carries its run tag and is deleted, so an exam never leaves furniture on a real person's memory page. leak is the scaffold for row ⑥: plant a confidence in a 1:1, fish for it in a group, and count mentions with the subject present (the model's discretion — the real number), with them absent (⚠ must be 0 by construction; a non-zero there is a build break, not a tuning problem) and by the other host (the cross-seat leak a single panel writer risks). Both arms run today; the number is what licenses the one New. Its other half, kit_lifecycle_live.py, needs the dev server up on :8011 and drives mount · freeze · drawer · floor · dismount · thaw over the same HTTP routes a phone hits, every kit on the shelf, three real humans — 64 checks. It exists because 590-odd scripted checks were green with not one cartridge ever mounted live, and it immediately found two defects nothing else could reach. Design + results: the exam.
prototype/personas/<slug>/
+ /var/lib/mad/personas/<slug>/
The cast (pure data), in TWO roots. Each character's profile.md (+ corpus). Repo tree = CC-authored (ships with git); runtime root (MAD_PERSONAS_DIR) = builder-made (outside the repo so git pull stays clean; repo wins slug collisions). run_room.persona_dir(slug) is the only resolver — a smoketest source-lint keeps every reader routed through it. No DB rows; disk is the registry. Adding one is dropping a folder — no code change. ⚠ The two roots name their slugs differently, on purpose (2026-08-17): a repo slug is curated (hand-authored, unique by convention, python lib/check_slug.py at authoring — those are the ones a human types), while a runtime-root slug is minted by builder.mint_slug and is not a function of the persona's name at all. Nothing user-facing shows the key except the build view's mono sub-line, where a build id is what it should read as.
personas-recent/<slug>.md
+ /var/lib/mad/personas-recent/
Since the record (pure data, RUNTIME). A living real figure's dated news file — frontmatter (slug · as_of · horizon) then ≤12 sourced bullets, newest last — loaded after the profile by read_recent() for cards carrying horizon: living only. ⚠ A SIBLING root, never inside a persona's directory (MAD_PERSONAS_RECENT, gitignored): the weekly refresh job writes here, and a job must not dirty the checkout. recent_parse() is the one parser — the renderer and the job both read through it — and a corrupt file degrades to nothing, never to a broken prompt. Absent field, absent file and empty file are all the same answer, which is why a fixed-horizon persona needs no file at all. Design: Growth §1.4.
prototype/kits/<slug>.mdThe play kits (pure data). One file per kit: frontmatter (name · en · kind · aliases · players · tools · blurb · blurb_en — the shelf row, in either language) then == HOST BRIEF == (what the panel reads) and == TABLE SPEC == (what the act call reads while the kit is on the table). kind rides the header (2026-08-07) so the shelf can split without opening a kit: play is a game, utility is not. Seven shipped, rules researched rather than remembered — six games (谁是卧底 · 大话骰 · 二十问 · 问答之夜 · 两真一假 · 真心话大冒险) and one utility kit, 诘问 / the interrogation (the header name settled on 诘问 rather than 拷问 on 2026-08-07 — the owner's word; 拷问 stays in aliases, because a search word is display and not identity), which carries a fenced ```probe block instead of a ```flow one and boots lib/interrogate.py rather than the game device (design: The interrogation). Of the six games five are cartridges (谁是卧底 · 二十问 · 真心话大冒险 · 大话骰 · 问答之夜, the last joining 2026-08-04), carrying a ```flow block the server runs; 两真一假 alone never grew one, and it is the classic path whole, which is what dual mode is for. shelf: off — a WITHDRAWAL, not a deletion (2026-08-05, 真心话大冒险; 两真一假 joined it 2026-08-07, leaving five kits listed — four games and the utility one): the file, its cartridge and its tests all stay and the kit simply stops being reachable, through four doorslist_kits (the menu and the megaprompt's shelf note) and match_kit (the offer chip, the auto-load) skip it, and load_kit refuses it by name at the last door, because a slug travels(a stale client, a saved offer card, a rematch's memory). read_kit deliberately still works, so a room that already has it mounted can name it and lift it off. MAD_SHELF_ALL=1 puts the whole shelf back for a harness — a withdrawn kit is still tested, which is what lets it come back — and the shelf pin no longer asserts a count(a fact about a directory, not an invariant): it counts what is on disk, subtracts the withdrawn, and asserts that everything listed is whole. The smoketest's classic-path fixture is chosen, never named — the first kit with neither machine (no flow and no probe) — because naming one meant that the day that kit grew a flow block, ten unrelated checks went red. And since 2026-08-07 it reads the whole shelf, withdrawn kits included: the classic path is a code path rather than a menu entry, so when 两真一假 was withdrawn the last un-withdrawn prose kit went with it and five checks lost their fixture. That block now opens MAD_SHELF_ALL and closes it again, so every check below it still sees the shelf as a room sees it. Repo-authored on main and read live like a persona — no DB row, no restart; adding a game is dropping a file. Where variants legitimately differ the file marks a DIAL the host must declare out loud, rather than the kit choosing for the table. A cartridge also carries its own lexicon (2026-08-03): per-language display text for everything the machine says or shows — the mode words, the board's columns, the round separator, every void reason — so the words a game speaks are the game's data rather than strings in the server or the browser. zh is the reference language and a declared language must cover its keys whole, or the shelf lint fails; the canonical ids underneath stay English and frozen.
6 · how to know it still works

Checking the system

One command, no API cost, fully isolated:

python lib/smoketest.py — runs the three unit selftests (db / auth / run_room) and in-process HTTP checks over the gate, ownership, membership + the join notice, the unread lifecycle, account edits, owner-only permissions, fork/archive, invite validation, per-user notebook isolation, the admin peek gate (admin GET → 200, write → 403) + tts-config, plus the batching engine (tool path + fallback + retry-on-empty), the notices rail, user profiles, direct messages, the contact layer (gap 13 — the pair's two per-side cells and every verb that writes one, with the load-bearing checks being refusals: someone who is neither a friend nor a chat-mate cannot appear in the invite picker, be added by username, open a private chat or read a profile), Web Push, the persona builder, the toolbox (the host-move router, the deck + deal, the roll card, the poll's reveal gate, the bid box, the clock, the touch drawer's parity routes, and T11's reveal trigger — the valve, the writer settle, the time fuse + a reveal missed while down, the row budget, the seal-is-final binding as a grep, and the legacy migrations of both the text circle and the notebook — plus the shared lifecycle since v620: the split of close from reveal on every kind, the blocking release at CLOSE, the four-rung visibility ladder checked on the wire row by row (a masked row carries no result at all), the layered close_at, the sealed roll's 暗/盲 rungs and their per-rung riding notes, and the deal-on-the-store with its dealer's-eyes coercion refused server-side; since v633 the solo deal, five-each with disjoint hands and an exact partition, the refusal when a deck cannot fill every hand, the round-robin, the each= grammar and the re-stated privacy shape; and the instrument form linted against the tag grammar — since v634 that lint compiles a maximal form for all five sheets, not just the roll's, and reads every dial back out through the room's own extractor; since v635 12 strip checks + 7 persona-parity checks, with two seats in one room asserted to diverge; and since v636 the pad's leak test — two humans and two personas each holding a pad, asserting every other party's payload, plus the board-and-pad proof that neither grows a visibility dial by accident; and since the naming sequence, the glossary lint (step 4 — grep-shaped and ratcheted, so the one surviving <note> tag debt is declared rather than dodged, and lowerable only), many instruments in one beat (step 1), the address layer (step 2 — the handle minted per canon kind, never recycled, and every <act> refusal naming what is on the table), the uniqueness invariant (step 3 — a second card of a kind refused without a distinct name, at both arm doors), and the prop master (the two-line verdict, the fail-open, the NONE that skips the act call entirely, and both floor-producer prompts deferring to the verdict while keeping their recall half)), the persona-roots lint and the i18n catalog, the library memo's survival of a non-atomic build (2026-08-15 — six checks replay the exact production sequence: directory made · a room list run to poison the memo · profile.md written · the announce; ⚠ one of them asserts the bug itself, so the pair has teeth — if it goes green the fix is unnecessary, if the ones after it go red the fix has been lost), plus the freebenches — a whole cartridge game per scenario, a whole interrogation, and (2026-08-08) the kit contract's five axes driven through both engines, each with no server, no API and no persona. 1,650+ checks; exit 0 = green.

It also runs the exam's leak scanner against five planted leaks and asserts the scenario set is well-formed — a probe must fail on a known failure before its 0 counts. The exam itself is the second gate and spends real API money, so it is run deliberately rather than on every commit: python exam/run.py --quick after any manual, brief or toolbox change (and --split 1 when what you changed was the act brief, because --quick runs un-split and the act call does not run on that path at all).

It runs against a throwaway temp database + rooms dir, so your real data is never touched. The one thing it can't cover for free — a real panel turn — is exercised by the live end-to-end checks instead.

For the design rationale behind these pieces — the batching reply model, the SSE choice, the phased build — see the build plan. For how traffic reaches the box from China, the deployment topology.