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-07-22 · rev 16
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 4.6 or one of the four DeepSeek v4 ids) 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 Flash, each query routed by its language (see §2b).
room-ui.html · login.html · admin.htmldompurify · /katex · /hljs · /mermaid · /fonts · /emoji (all same-origin)/api/merun_room.py (FastAPI + uvicorn)def on a threadpoolapp.db — users, rooms, members, usage, settings, invites, DM pairs, push subscriptionsrooms/<id>/ — state, event log, transcriptrooms/notebook.json — saved quotes (per user)prototype/personas/ (repo) + /var/lib/mad/personas/ (builder)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.
MODELS lists five selectable ids. The four DeepSeek entries are really two underlying API models (v4-pro, v4-flash), each exposed twice — the only difference between a pair is a thinking toggle. The default panel model is deepseek-v4-pro. Helpers normalise the id: provider_of, api_model_of (strips -reasoning), thinking_of (default OFF), max_tokens_for (16384 on DeepSeek — the cap must cover reasoning + reply — 2048 on Anthropic).
| id | provider | api model | thinking |
|---|---|---|---|
| claude-sonnet-4-6 | anthropic | claude-sonnet-4-6 | — |
| deepseek-v4-pro | deepseek | deepseek-v4-pro | off (default) |
| deepseek-v4-pro-reasoning | deepseek | deepseek-v4-pro | on |
| deepseek-v4-flash | deepseek | deepseek-v4-flash | off |
| deepseek-v4-flash-reasoning | deepseek | deepseek-v4-flash | on |
DISPATCH_MODEL = Haiku 4.5 · DISPATCH_MAX_SEARCHES = 1 · DISPATCH_ENGINES = anthropic | serp (console → Models; the serp pick → DISPATCH_SERP_MODEL = v4 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-pro.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.
/api/rooms/<id>/saynonce so it can ignore its own echo coming back over the stream._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.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.post event; every other member viewing the room sees your line appear live. Their browser raises the same egg._wake() signals the room's background drain worker (a daemon thread, one per active room). The request is now done.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.@{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.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.extract_panel_blocks makes one pass over the decoded turn — the routing table is rolls · reacts · notes · reveals · boards · 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 rolls (or arms — for one person, the room, or everyone via for="all", the collect-roll it settles itself) the shared die, seals a <note> into the panel's private notebook, opens the oldest on <reveal/>, pins the <board>, opens a <gate> (the sealed circle — a <seal> in the same reply is the host's own submission) or a <vote options="…"> ballot on the same card, and sets the room's <clock>. Each rides its own SSE event (the die pushes dice state then a roll record after a ~1.6 s tumble; sealed capsules and reveal cards ride note; then board, react, clock, gate — and vote for the fired tally). 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), never left in a bubble.gate, kind="text") holds every human message on the server as a sealed bubble and suppresses panel turns until it releases (all-in · the card's Release · <gate away/>), which opens every bubble in place and hands the panel one turn to read the round. A ballot is the same container with kind="vote" and deliberately does not hold the room. 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._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).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 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; v4 Flash + search API = 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 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_fetch_dispatch → {brief, title, usage, cost, searches, engine, model, secs}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.on_dispatch(payload) → SSE dispatch + artifactdispatch event and a kind="dispatch" wildcard artifact, landing the brief for everyone before the egg.on_start() → SSE responding → replysearching → dispatch → responding → reply. A normal turn skips the first two.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 grid; the room then opens normally. The describe box also seeds the room's target language (detected from the text).
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.
kind="dispatch" artifact)removed_by (v530) — model switchesstate.json (LLM history) + event-log.json + transcript.mdreact / redact events in the log, projected at read time (never surgically erased)post, reply, dispatch, member_join, member_leave, member_avatar, history_private, react, redacttyping, responding, searching, activity, scratched (a stopped turn), removed (you were dropped from a room), member_rename (a live relabel)/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./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.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.
| store | holds | why there |
|---|---|---|
| users | account, 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 | SQLite — looked up by name across rooms |
| sessions | cookie token → user, expiry | SQLite — every request checks it |
| rooms | id, title, owner, status, updated, history_private (one-way) | SQLite — the registry + scoped lists |
| room_members | who'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_events | append-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 turns | SQLite — daily/weekly caps + per-model/day/user/room stats |
| settings | admin config: enabled models + default, default caps, feature visibility, TTS | SQLite — one key/value table |
| invite_codes | console-minted codes: label, uses, max, expiry, active | SQLite — revocable, use-limited signups |
| notices | The notification rail: one row per live concern — kind (build_done · build_failed · room_invite), 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 ≡ dot). 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_msgs | Delete-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 replay | SQLite — enforced server-side on every user-facing replay exit (_hide_filter), so a hide holds on every device |
| moment_rooms | Vibes: a moment key (slug + text hash, minted by the client) → the one shared room (Open Circle) every "Join them" joiner lands in | SQLite — one room per moment, re-bindable if retired |
| vibes_posts | Vibes 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 live | SQLite — the feed's stock, served as slates by /api/vibes/feed |
| vibes_seen | Vibes: which posts a user has already been shown, so the slate resurfaces on tier-crossing without repeating | SQLite — per-user feed de-dup + unread bumps |
| vibes_brews | Vibes: 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_likes | Vibes: one heart per real human per post — (moment_key, user_id), idempotent, so it survives a refresh | SQLite — set/cleared by /api/vibes/like |
| dm_pairs | Direct 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 cast | SQLite — one live DM per pair; a zero-persona room |
| push_subs | Web 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_ok | SQLite — the lock-screen mirror's device registry |
| push_events · activity_days | Append-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.json | verbatim LLM history + cast + cost + the floor-producer flag + the feedback log (per-turn ratings + check-in surveys) + the prompt-variant stamp (variant — v1|v2, set at creation and never flipped) | flat file — the panel's resumable memory |
| event-log.json | every event, verbatim prompts, confer, settings-change audits (who / what / when — floor · title · status · history-private) | flat file — the canonical record |
| transcript.md | the clean on-stage dialogue | flat file — human-readable |
| notebook.json | saved quotes, scoped per user | flat file — one cross-room store |
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.
sp_file(variant) picks it by the room's stamp: system-prompt.md (v1 baseline) or system-prompt.v2.md (v2 "manners")roster_intro + dispatch_note + formatting_note (markdown / math / code) + per-host profiles + language / dynamics / intensity directiveslanguage_directive — separates 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 rulepanel_turn tool schema carries an optional self-reported lang per reply — the model's own claim about which language it wroteintensity_directive · kickoff_cue · read_card(confer, replies, artifacts).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 / dispatchresolve_who, _repair_speak — pin a reply to a speaker, fix mangled tags_MARK_RE = @{u|p|a:id} (a = Web Search); resolve_markers renders them human-readablelang. 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.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. open() forces reasoning OFF for a fast kickoff greeting; set_model() mid-chat switch writes a cold prefix.[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 · v1p/v1f the smart f, a DeepSeek Pro/Flash call). The user control is now On/Off only (POST /api/rooms/<id>/floor); the admin sets which engine On uses (default v1p, in the console's Models & APIs tab). 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.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_round → feedback_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).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").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:
| file | owns |
|---|---|
| lib/run_room.py | The 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 · /emoji · /dompurify.min.js static mounts (all vendored, same-origin), 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/avatar[/remove] · /api/user/{uid}/profile[/avatar] — uploads resized to a small square + metadata-stripped, stored on disk, no DB column), 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 dm→group, 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), and every /api/* + /api/admin/* route. ~11.5k lines — the biggest Python file. |
| lib/seen.py | The 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. 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.py | The 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 per-build _job.json/_bill.json journals, and provenance stamping (built_by). run_room imports it alongside its other app modules (db/auth/cost/seen/websearch/vibes_brew); design: Studio. |
| lib/builder-ui.html | The builder page (/builder, gated by the persona_studio visibility flag — admin-on by default, grantable per role; embedded as the landing's Studio tab). A phase-driven select → lock → confirm flow (v2) over the /api/build/* endpoints: a candidate/concept picker (picking = lock), a Your material block (+ Files / + Links + a rights acknowledgment), and the made-from forecast bar (web · yours · AI-fills); then a live SSE progress spine (stage rows, per-facet + per-audit-seat status, spend ticker) and the terminal bill (per-stage + by-API). A built persona is private by default — library_descriptor(viewer_uid) filters the picker to public + own; an admin publishes via POST /api/admin/personas/<slug>/visibility. House look — console topbar, room-ui buttons. |
| lib/personas.html | The 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.py | SQLite. 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), push_subs (Web Push devices) + push_events/activity_days telemetry; admin aggregates (usage_by_model / _day / _user / _room). One connection, lock-serialized, WAL. |
| lib/auth.py | Identity. 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). |
| lib/cost.py | Money. PRICES per family (Opus / Sonnet / Haiku + deepseek-v4-pro / -flash) → 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. |
| lib/websearch.py | The 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/vibes_brew.py | The 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/room-ui.html | The whole app UI. One page: chats pane, message stream, the egg, @-mentions, the chat-info panel, account menu (the Me sheet), both SSE clients. Also the read-only peek view (/?peek=<id>). ~15.6k lines — the largest 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.css | THE 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.js | ONE 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.js | The 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.html | The 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, 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), 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, and the per-role Who-sees-what grid), Status (read-only server health & config — all seven provider keys by role/present-missing, and the floor-producer health row: staging counts by arm + fallback keys + smart-f p50/p95 since restart), 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/login.html | The gate. Login + signup (prefilled from an /invite/<code> link). |
| lib/md-viewer.html | The profile reader. Renders a persona's profile.md, with browser-side read-aloud (TTS straight to the provider — no server bandwidth). |
| lib/smoketest.py | The safety net. python lib/smoketest.py — runs the unit selftests + in-process integration checks (no API). Run it after any change. |
| 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. |
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, Web Push, the persona builder, the persona-roots lint and the i18n catalog. 350+ checks; exit 0 = green.
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.