Every room has a target language — detected from the Smart-selector's Describe box, or chosen in the new-chat picker's more settings (which defaults to your account language). The aim: every persona speaks that language, while staying completely themselves. Most do. A few don't — strongly language-anchored figures (Confucius always reaches for Chinese), or any host in a target the model finds hard (Japanese). This is how we fixed it without making the system prompt more fragile.
lang field). Re-running the two reported failures: a Japanese opening went 1/4 → 4/4 (Plato, Confucius and Stan Lee all now speak Japanese), and in a French room Confucius now speaks French while keeping his 正名 ("rectify the names") doctrine. Layer B is now shipped too — the persisted translate net: cross-script slips get a faithful translation under a hairline that survives reopen, is saveable to Notes, and supports go-to-line (validated end-to-end, ZH→FR).We measured every room with a target language — 46 rooms, 710 on-stage host lines — grading each line against the room's target. The raw number is 90% compliant, but that hides two things, both important for the fix.
| Target | Rooms | Lines | In target | Note |
|---|---|---|---|---|
| English | 21 | 124 | 85% | many "misses" are the panel correctly following a guest who wrote another language |
| Chinese | 22 | 564 | 93% | the model's strong language; when guest + target agree, ~100% |
| Japanese | 2 | 8 | 62% | even with a native-Japanese peer present, others default to English — looks like model capability |
| French | 1 | 14 | 50%* | *under-counted by the detector; an Italian test later ran clean for everyone |
First: most "non-compliance" is correct — the spec lets the panel follow the guest, so a host switching to the guest's language is the feature working, not a bug. When target and guest agree, compliance is ~100%. Second: the real defect is small and concentrated:
text = "in their own voice and language", and the verbatim replay of prior turns — versus ~2 toward the room language (one directive block, buried after the profiles). Piling louder room-language commands onto the weak side just makes the prompt contradict itself → more fragile. So we subtract the conflict first, then add a narrow net.Set once, then it tracks the live conversation:
The Smart selector identifies the Describe box's main language at convene time and stores it as room.language — and that can be any language (Italian and French rooms already work), not only the four the manual dropdown offers. On a convene it's pre-filled into the picker's "more settings" Language dropdown (added as an option when it's outside the four), so what you see matches the room you'll get — still overridable. Every persona is graded against effectiveTarget, so a host who correctly followed the guest is never counted — or treated — as a miss.
Three subtractive edits. No persona profiles change; nothing gets louder.
| Where | Pulls | Change |
|---|---|---|
system-prompt.md (rule 7) | own language | +1 principle: voice ≠ language (below) |
language_directive() | room language | extend the override from dialects to whole-language anchors |
panel tool text field | own language | remove the contradiction + add a lang self-report |
lang field is the keystone: it turns the schema from a contradiction into the detector, and gives exact compliance numbers.And language_directive gains a clause for figures whose profile anchors a whole language: "Your profile may pin a native tongue ('quote the Chinese', 'speaks Japanese'). That shapes your voice and what you quote — not the room's language. Quote a canonical line in its original script when your profile calls for it, then carry it in {language}."
Layer A makes most fragile cases land on their own. For the residual — a host who still slips — the server translates that one line, as a last resort:
The panel tool returns each reply with who, text, and a self-reported lang.
The guest's current language if they've switched, else the room's stored language.
lang matches the target — or the guest's language? → keep as-is. Otherwise → translate.norm() folds synonyms (Chinese / 中文 / 简体 → zh) + a CJK-script sanity check
A cheap call renders the line into the target, faithfully: preserving markdown, fenced code, math, and any verbatim original-language quote. Misses are the rare tail, so cost stays bounded.
The bubble shows original ─ hairline ─ translation, and the line is stored with both halves — so it reappears intact every time the room is reopened. The model's working memory keeps only the target version, so the room language self-reinforces.
One bubble: the host's own words on top, a hairline, then the faithful translation into the room language below — all formatting (markdown, code, math) preserved on both sides. The hairline is the only divider; the labels above are for this writeup only.
Because the translation is real, persisted text inside the same bubble — one line, one data-lid — the existing notebook features work on it unchanged:
This is why the translation is generated once and stored, not re-made on load: a re-translation could drift by a word, and a saved note would no longer match. Persist-once keeps the highlight exact.
| File | Change | Layer |
|---|---|---|
prototype/meta/system-prompt.md | +1 "voice ≠ language" principle (rule 7) | A |
lib/run_room.py | language_directive reframe · schema text desc + new lang · panel_tool_desc | A |
lib/run_room.py | new translate_to() (once, server-side) · turn pipeline: effective-target → divergence → translate → attach orig/orig_lang · persist them in the transcript + event log so replay rebuilds the bubble; model history keeps only the translation | B |
lib/room-ui.html | render orig + hairline + text on live turns and replay; .bub-rule CSS · save-line + go-to-line tokenize/flash across both halves | B |
| persona profiles | none — the reframe handles the anchored figures centrally | — |
Two stages, so the lift was attributable and we didn't over-engineer:
lang instead of guessing) → expect the fragile / Romance / anchored cases to climb toward the clean Italian-room result.The compliance probe lives in rooms-dev/_probe/ (gitignored); the lang field removes its blind spot on Latin-script languages, so before/after is measured, not eyeballed.
lang could lie. The CJK-script sanity check backs it up; worst case is a missed translation, never a crash.source records: lib/run_room.py (directive · schema · pipeline) · prototype/meta/system-prompt.md (the megaprompt) · lib/room-ui.html (the bubble render) · rooms-dev/_probe/ (the compliance probe, gitignored)