Vibes is the cast's 朋友圈 — a feed where every character posts: the famous masters and user-built personas alike — the user-built ones are high-value posters (creators are invested in their own characters), and every moment is a door into a live room. The feed UI is built on ui/sandbox over a static, hand-curated pool of ~382 posts (the v297 audit). This page records the design that replaces hand-authoring with a production content engine — settled in the design discussion of 2026-07-07 — plus the presentation mechanics the engine feeds. Step 1, the persona extractor, shipped the same day (db653c1).
The current pool was written and curated by hand in CC sessions — eight calibration rounds (v256→v262) set the bar, and a full-pool audit (458→382, v296) enforced it. Production can't do that: new posts must come from API calls alone (DeepSeek writes; Gemini judges), at the same bar. The in-house evidence says the bar is reachable but the process is the hard part: the one large-batch attempt (v284, 300 posts in one marathon) failed on style drift and was fully rolled back — a scale-and-fatigue failure, not an author-model failure. The writing canon lives in prototype/meta/how-to-write-vibes-moments.md (on ui/sandbox; ports with the feature).
The guide reads like a rule engine — 232 lines, three levels of rules. The engine dissolves it: each rule class is a different kind of problem, and only one of them belongs in a prompt.
| Rules that are… | Live in | Why prompting can't replace them |
|---|---|---|
| Taste — the recipe, ending tones, one-joke, the poster loses | the writer's prompt + exemplars | it can — exemplars beat rule lists for style; this is the promptable part |
| Memory — no anchor reuse, arcs, no repeats | the used-anchor ledger, injected as context | statelessness, not stupidity — the audit's #1 failure was same-character-same-joke repeats |
| Distribution — species mix, ~30% quiet, tone rotation | the composer's dice roll | each call is myopic; ask for "funny" 30× and you get uniform brilliance — “74 masks, one comedian” |
| Verification — the blacklist, the 红黑榜 | the standby judge's rubric | drift arrives silently at scale (the v284 lesson) |
One offline call per persona reads the full profile.md + corpus/raw (capped ~60k chars) and emits the writer's reference pack: a voice card sized to how much the writer must be told, and an anchor bank — the enumerable raw material future posts are seeded from. Anchors are consumed (marked used), which makes anchor reuse — the audit's #1 failure — impossible by construction rather than instruction-dependent.
| Mode | For | Sources | Card | Anchors |
|---|---|---|---|---|
| mine | famous figures (most of the cast) | profile + corpus + public biography — model knowledge, mined under a confidence rule (unsure → omit) | small — 2–4 lines who, 3–6 voice; the card selects from what the model knows | 20–30, at least half beyond the profile |
| strict | supplied-corpus · builder-made · invented | only the supplied text; zero outside knowledge | large — voice 6–12 lines + verbatim quotes; the card carries the whole voice | 10–20, every one citing a verbatim fragment |
Validated same day on a stratified sample — every tier, both languages:
| Persona | Tier · mode | Anchors | Verification | |
|---|---|---|---|---|
| Isaac Newton | famous EN · mine | 30 | spot-checked real (sin list, Mint/Chaloner, Flamsteed, 2060-as-lower-bound); two famous apocrypha correctly flagged as not-real | pass |
| 余华 Yu Hua | famous ZH · mine | 29 | all documented lore; Chinese voice card, knows 潦草小狗 | pass |
| Dan Wang | thin-real · mine | 25 | the confidence-guard stress test — letter quotes check out; nothing confabulated | pass |
| 小胖 Xiao Pang | supplied · strict | 15 | 15/15 cites verbatim-matched mechanically (2 were ellipsis-stitched pairs, all fragments found) | pass |
| 陈惠兰 Chen Huilan | built, zero-priors · strict | 16 | 16/16 cites + 5/5 quotes verbatim — the hard case: the card carries a voice the model has never seen | pass |
Pending tweak: forbid ellipsis-stitched cites ("cite ONE contiguous fragment"). Backfill: warming all 86 personas ≈ $1.20, ~45 min — deferred until the writer is proven; the lazy path (extract on first use, re-derive on profile edit) exists regardless.
The generation unit is deliberately small: one API call = one post + its comment thread. This sidesteps the v284 axis entirely — no within-call fatigue, no self-conditioning convergence (by post #30 a batched model imitates its own output). Cross-post variety is enforced by the composer's per-call briefs, not by the model seeing its siblings; batch-level coherence is injected as context, via the guide's two world-weaving devices (§6):
| World-weaving device | What it is | Rule |
|---|---|---|
| Shared event 共享事件 | one small world-happening — a rain, an eclipse, a heatwave — that several characters each pass through in their own posts that day: the physicist complains about it, the chef cooks around it. The illusion of one flowing world | 1–2 per time window · 2–4 posts each, never everyone; the composer dice-rolls today's event and hands it to a few calls as context |
| Arc 弧线 | one character's continuing storyline across posts — counting flowers ch.1→2→3, studying for the bar, a running experiment | each chapter a new development, never a restatement; timestamps ordered; the composer attaches the prior chapters' gists |
| Real-time news 实时新闻built | the real world enters the feed — see the news device section below. Unlike events and arcs, this syncs the fictional world with the reader's world — they saw the same headline | built 2026-07-07: full pipeline (harvest → editor/caster → scrape+distil → share-frame writer → NEWS card + reader popup) |
lib/vibes_troupe.py — the LLM casting director (DeepSeek @ temp 1.1, seedless: groups are stable, topics vary). Two objectives by owner: realism (LLM — who sparks with whom, tension pairs = who has dirt on whom) × product (dice — each party is dealt a combo shape from a 9-shape palette: cross_domain · same_field · rivals · era_clash · hidden_gems · lang_mix · mentor · all_stars · chaos, so one day's feed showcases the app's range of panel forms). Every party carries exactly one wildcard, enforced mechanically; hallucinated slugs get mechanical repair (case-fold → fuzzy → drop), never a blind re-roll. Batch #6 (first directed benches from the 91-cast world) was a clear quality jump: multi-voice comic riffs, four-language threads, the invented persona holding a star bench.
The calibration discovery that shaped it (batch #8 → #9): posts seeded from news drifted off-story 8/18, because private and public seeds are opposite speech acts — an anchor is your own memory (retelling it is weird → transform), a news story is a shared object (pointing at it is the point → react). News posts are therefore quote-tweets: the post is the line ABOVE the headline card the reader also sees. Commentary un-banned (only neutral pundit summary stays banned); four news shapes (take · joke-on-the-story · personal-hook · question); ~10–25 words. Result: 18/18 on-story.
The pipeline (lib/vibes_news.py + lib/vibes_details.py, ≈$0.07/day all-in):
Mini-batches of 3–5 only where the posts form one comedic object — a proposal thread, a shared-event cluster. Cost is a non-factor either way (~$0.03/day for 20–40 posts).
An anchor bank is finite (20–30 per persona; the strict tier drains in days at feed pace — after three calibration batches 小胖 had 3 anchors left). The fix is structural: the anchor is one seed type among several, dice-rolled by the composer. Traits are combinatorial where events are not — 「陈惠兰的克制 × 电梯坏了」 is new, in-character material composed in latent space from the profile's fact/style vectors. The guide already legalizes this (人设锚 — anchor the person, not the event); the seed system industrializes it. The discipline that keeps it honest: non-anchor seeds may invent no biography — the seed is the only new fact.
| Seed | What it is | Supply | Status |
|---|---|---|---|
| anchor | a documented biography event | finite, mined; re-mineable | live |
| situation | an ordinary moment × the persona's traits (power cut, 电梯坏了, wrong parcel) | infinite — a shared situation pool | live — 6/20 in batch #4, quality ≈ anchors |
| arc | the next development of the persona's own earlier post | self-renewing | emerged unprompted in batch #4 (a post continued its own batch-3 storyline) |
| social | react / call back to another character's post | renewable | designed |
| world | shared event · season · real news (a PUBLIC seed — react, don't transform: the share-frame) | renewable | news LIVE (share-frame); events/season designed |
| free | no seed — write from your day | infinite, most latent | designed; judge watches drift |
Round 2 (≈15/20): whole-person comments landed; owner notes → the reply asymmetry (a reply reacts to the post; commenters never cite their own works) and English-only backstage (the half-Chinese labels read awkward and leaked). Round 3 (≈17/20): full-profile posters showed immediately (余华's son, 小胖's audience tension); "lean, not a checklist" + a free tone face (~25%) added. Owner note → same-species adjacency reads cheesy (两个「我输了」). Round 4 (≈18/20): species spread in the plan, G-brief rewritten to show the loss, never announce it (9/10 clean), an anti-echo block (own last 8 posts in-context) killed repeated closers, and the first 6 situation-seeded posts matched anchor quality. Round 5 (≈18–19/20): the unfair dice live (situation 60 · arc 20 · anchor adaptive 5–15 · free 5) — all four seed types produced at quality; arcs continue the storyline the model has material for, so the composer hands a menu, not an assignment. Round 6: first troupe-directed benches from the 91-cast world — clear thread-quality jump. Rounds 8→10 (news): seed-frame drifted 8/18 off-story → the share-frame fixed it to 18/18 → fact sheets made takes bite specifics. Costs: ~$0.04/batch; full profiles largely absorbed by prefix caching (56% hit).
| Decision | Call |
|---|---|
| Who posts | every character — famous masters and user-built personas alike; the user-built ones are high-value (creators are invested in their own characters) and ride the strict extraction tier, which is therefore a first-class path, not an edge case |
| Writer model | DeepSeek v4, reasoning on — A/B'd in calibration batch #1, not assumed. No pre-judgment of DeepSeek's humor: v284's failure was authored by a frontier model; process, not author, was the killer |
| Judge | Gemini 3.5 Flash (already wired: pricing, key, routing) — standby; armed only if the human review keeps catching the same class of miss. Build machinery on observed failure, not inherited scar tissue |
| Prompt language | English megaprompt, Chinese tone-exemplars kept verbatim (「白练。」 doesn't translate); per-persona output language set explicitly |
| Ending tones | the principle (end short and unsaid; never uplift / explain / hook) + tone briefs as descriptions only — NO quotable exemplars: calibration round 1 leaked verbatim stock phrases (「肉输了」, “The motion carried”, the cats vote) into 7/20 endings; anything copyable gets copied. Endings must be the writer's own words |
| Who replies | split by what each side can know: the system proposes a bench of 6–8 (affinity pairs · airtime deficit · language mix · one cross-domain wildcard); the model casts 0–4 — “only those this joke needs, possibly none” |
| Card sizing | tiered by model priors — famous get 5 lines, zero-priors get the full voice; the extractor right-sizes once per persona, not per call |
| Backfill | a derived cache, not a migration — hash-keyed, auto-invalidates on profile edit; “warm 86” is one overnight run ≈ $1.20 |
Batch #1: 20 posts, hit rate ≈ 12/20. The bar is reachable; every defect found was contract-level, not architectural. Owner review named three; the run surfaced three more:
| Finding | Evidence | Fix absorbed |
|---|---|---|
| Stereotyping — characters reduced to their trade (“pre-AI-era code” feel) | 陈惠兰 read watches into 6 of her 7 comments; 小胖 talked 房 in every one. Root cause: the prompt's own “cross-domain gold … reads a physicist through watches” line — over-deployed on every comment, the persona pipeline's over-deployment lesson replayed | the whole-person rule: commenters also eat, gossip, follow football; at most ONE comment per thread may lean on a trade |
| @ noise | plan briefed 30% @-posts (the guide's calibrated real rate is 17%, v296 audit); the model added unbriefed @s on top | @ posts cut to ~15% of the plan; “most posts @ nobody; zero @ when unbriefed” + lint |
| Language pollution | tone few-shots (from guide §1: 「肉输了」 etc.) copied verbatim into 7/20 endings; 「肉输了」 is stale guide-ese, not living Chinese | all quotable exemplars stripped from the prompt — tone briefs are descriptions only; “end in YOUR OWN words”. Guide §1's exemplar list needs the same cleanup when it ports |
| Name drift | 陈惠兰 misspelled 陈慧廉/陈慧兰 across threads | exact-name contract in the bench (display + 中文名) + a misspelled-name lint; extractor to emit name_local at next warm |
| Length overruns (EN) | worst 270 chars vs cap 130; zh posts largely complied | watch in round 2; if it persists → lint-reject-and-retry |
| Reasoning A/B | taste indistinguishable (the best post was non-reasoning); 10–20× slower; didn't even protect the caps | reasoning OFF is the writer default |
| Dark anchors | the composer dealt a traumatic lockdown anchor into a comedy species | manual veto this round; extractor to tag anchors comic / neutral / dark, composer matches tag to species |
The feed looks like WeChat Moments but serves like 今日头条: the pool is ordered with the newest batch at its head, each user carries a reveal cursor (delivered[] momentKeys in localStorage, BATCH=30), and a pull always yields the next 30 unseen — every user experiences a fresh feed until the pool drains. Timestamps are authored fiction (cosmetic), which is what makes generate-ahead-to-inventory possible.
The interaction cap in the writing spec — poster ∪ @-mentioned ∪ commenters ≤ 5 people — exists because the comment block seeds the shared room, and the room cast limit is 5. Content rules and room mechanics are one system.
Scheduled batch production to inventory (e.g. daily), then reveal on pull: freshness is perceived, not real-time. Generation is batched, offline and cheap; serving is instant with zero per-view spend. Everyone draws from one shared inventory; each user's cursor drips their own view, so pull-to-refresh is always productive. A low-watermark check keeps the batch job ahead of the drain.
Two entry classes (news = time-sensitive; everything else = evergreen) + live signals, blended per pull to serve four goals: ① fresh news (≤7 days) · ② persona-combination exploration · ③ expose joinable discussions where real users are/were active · ④ pull-down always yields fresh — while incepting ①–③. The frame that settled it is the BBS: four classic sorts merged into one stream by quota. The bump-sort insight matters at our scale: “recently replied” is scale-adaptive — with few users it means “someone answered yesterday” (the slot is rarely empty); at high concurrency it naturally becomes “live now”. One rule, every scale.
| Feed class | BBS ancestor | Signal | Badge |
|---|---|---|---|
| NEWS | 新帖 | published ≤7d (hard TTL; expired posts survive in scroll-back + profile albums), decay curve day 0→7 | the NEWS card (shipped, v301–302) |
| REPLIED | 最新回复 (bump-sort) | last human reply in the post's room, any recency, newest-bump first — plus personal unread-bumps: “5 new replies since you looked” on threads the user touched (re-engagement, the strongest form of goal ③) | gradient by heat: reply <15 min → ● pulsing “2 inside now” · reply <24h → quiet “回复 · 3h前” |
| HOT | 精华/热帖 | accumulated proof — distinct guests · message volume · joins; no expiry. Tier-crossing resurface: vibes_seen stores the hotness tier at seen-time; a post re-serves ONCE per tier it climbs (seeing it at 0 replies ≠ seeing it hot) | quiet weight: 「热议 · 12 guests · 87 messages」 — no pulse (pulse means now); the preview keeps the seeded character comments, never quotes guests |
| EVERGREEN | 普通帖轮播 | unseen drip, diversity-selected (no poster twice per pull · span the party shapes · bonus for never-seen personas · guaranteed seat for user-built) → cycles when a user exhausts the pool: seen evergreens recycle shuffled, deprioritized under anything new — the feed never goes dry | none — an honest global released_at stamped when the scheduler drips it live (resolves the timestamp question: fiction posts get real release times) |
Free dividend: the HOT/REPLIED signals are the pair-quality term the troupe director's hints wanted — threads that provoked real human discussion teach the caster which combinations work. The feed's applause becomes the casting director's notes. Tuning note: the quota numbers are knobs, not constants — per-pull composition is logged from day one so “feels news-heavy / too samey” is answerable with one query.
t-amy…t-eve, real HTTP on :8011) drove the state machine: a 5-guest/30-msg thread crossed to HOT tier 2 and resurfaced with its badge to a user who'd seen it at tier 0 (the tier-crossing contract, observed live); REPLIED bump-sorted 4 threads by recency with ● pulses; quotas held (8/4/1/17 of 30); the evergreen tail was 17 posts / 17 posters; a second pull was 18/22 unseen-new. Two engine fixes fell out: the drip released expired stock (ORDER BY id, no expiry filter — after an inventory reset it would drip corpses for weeks; f29f54b) and 20-poster troupe casts truncated at max_tokens 4000. All interaction data retained in dev (interact-log.json + turn_events + vibes_seen).lib/vibes_brew.py is the orchestrator: one brew = the whole pipeline the validation run drove by hand — posters picked least-recently-posted first → missing voice cards auto-extracted (capped 10) → troupe casting (≤20/call) → the seed dice (situation pool persisted + consumed; anchor ledger persisted across brews) → writer → assembly (seats·likes·ts) → ingest. Evergreen lands as stock for the drip (bootstrap-releases only when the live pool starves); news lands released-now with the TTL. Three triggers, one code path: the in-app scheduler (a 15-min heartbeat thread asking due() — fires past the daily brew_time, or early under the low_watermark), the console's Generate now, and the CLI. One brew at a time (a lock); every brew logs a vibes_brews row; a daily $ cap makes the brew refuse while serving stays untouched.
All knobs live in ONE JSON setting, vibes_knobs (schema = DEFAULTS in vibes_brew, sanitized + clamped on save), read by both the brew and the serving path (60s cache). The console's Vibes tab edits them in five sections: Master (feed visible · automation · live status strip) · The brew (counts, time, watermark, species mix, Generate now, advanced: seed dice + news casting) · News desk (scrape budget, TTL, freshness window) · The slate (drip/day, pull size, quotas, HOT tiers, pulse window, the guest-count privacy knob) · Budget + the brew HISTORY strip. Content gates (politics/race/religion, concrete-events-only, fact-minimal briefs) are policy in the prompts, deliberately not knobs.
The engine has two lines running at different clocks. Production is a batch: one brew a day drops ~24 evergreen into stock — a reserve the feed hasn't started serving. Consumption is on-demand, per user. Between them sits the drip: the one valve that turns reserve into served, at a deliberately slow, self-limiting rate. Understanding the feed's rhythm is really understanding this valve.
A “feed load” is one GET /api/vibes/feed — a user opening the Vibes tab or pulling to refresh. That request runs the drip once, then composes the slate. So the two cadences are: production ≈ one spike a day (the brew); the drip ≈ at most one release per hour, and only while someone is actually loading the feed; consumption = whatever the users do. A day of it:
So stock is a slow sawtooth: one +24 jump a day, then a gentle stair down as the drip nibbles ~1/hr during active hours only — flat through any idle stretch — staying far above the brew-early watermark, which trips the next batch before the reserve can run dry. Read against the two hard edges:
| Situation | What the drip does | Net effect |
|---|---|---|
| Nobody uses Vibes for hours | Nothing — the valve only ticks on a feed load | Stock is frozen. No wasted releases into a feed no one is reading. |
| …then one person returns after 10 h | Releases one post (stamps it now), not ten | The backlog is not caught up; the feed just reads “≤1 h fresh” again from that moment. |
| One user refreshing constantly | Still ≤1 release / hr — the freshness gate blocks the rest | Frequency can’t accelerate the valve; the cadence is wall-clock-bound. |
| One user reading heavily | Can’t keep up (1/hr); their per-user slate exhausts unseen evergreen | They hit recycled (seen, reshuffled) content. Stock doesn’t rescue a power user — it drips too slow. |
released_at = now makes releasing more than one impossible; the real behaviour is ≤1 per load, ≤1/hr. Left as-is by owner ruling 2026-07-08. If guaranteed 24/day drain ever matters, advance the clock as released_at = newest + gap and loop; if a power-user pattern emerges, the lever is per-user unseen-first serving + a bigger brew, not a faster drip.)main — and only Vibes (2026-07-07). The full ui/sandbox branch merged first (836cefe, v298), then per the owner's ruling Notes v2 and the library-tabs thread were backed out (b7a8644, v299): the Notes pane returns to the v279 original; Notes v2 stays on ui/sandbox. What main carries: the 382 pool · moment backend · Join-them doors, contents behind the vibes_contents admin toggle, default OFF. Smoketest + browser-verified.db653c1 · canon: prototype/meta/how-to-write-vibes-moments.md (ui/sandbox) · see also Roadmap · Studio · Function map