What this PR is

One new file, no code: a design-exploration note for #93, which asks not for an integration but for a recommendation — what of the game's generated prose to voice, when (on-demand vs pre-generated), which vendor, and the cost/latency/storage tradeoffs. The note lands on a lean v1: voice the epilogue first, on-demand then cached, using OpenAI gpt-4o-mini-tts.

What makes the note worth reading top to bottom is that it's grounded in the code — and in grounding it, it corrects four of the issue's own claims. This guide walks those four, each next to the source that settles it. Read it and you can vouch that the recommendation rests on what the code actually does.

The TL;DR table and the first load-bearing decision.

docs/design/text-to-speech-narration.md · 342 lines
docs/design/text-to-speech-narration.md342 lines · Markdown
⋯ 12 lines hidden (lines 1–12)
1# Text-to-Speech Narration — giving the Chronicle a voice, epilogue-first
2 
3> **Design exploration for [#93](https://github.com/mseeks/revisionist/issues/93).** This is
4> a recommendation, not an implementation. No TTS integration, no audio pipeline, no vendor
5> commitment — just the design space, a recommended v1, and the implementation issues this
6> note should spawn. Vendor pricing and model names were **re-verified against live official
7> sources on 2026-06-17** (see [Vendor landscape](#vendor-landscape-re-verified-2026-06-17));
8> they drift, so re-check before building. For what is true in the code today, read
9> [`../../CLAUDE.md`](../../CLAUDE.md); for forward intent, [`../ROADMAP.md`](../ROADMAP.md).
10> This is the **voice** half of giving the game a soundtrack; [#92](https://github.com/mseeks/revisionist/issues/92)
11> (soundscape SFX) is the **sound** half, a separate exploration.
12 
13## TL;DR — the recommendation
14 
15Voice the **epilogue first**: generate it on demand at the verdict screen, then cache it by
16a content hash so a re-listen or a shared run costs nothing. Use **OpenAI `gpt-4o-mini-tts`**
17— it reuses the `openai` SDK and key already in the repo (zero new dependency, zero new
18credential), it's steerable (you can prompt a grave, measured historian's voice without
19commissioning one), and its commercial terms are clean. Defer the rest.
20 
21| v1 (ship) | v2 (next) | Deferred / never |
22|---|---|---|
23| **Voice the epilogue** (on-demand → cache) | **Opt-in "listen"** on the per-turn Chronicle | Auto-voicing the Chronicle every turn (the cost + latency trap) |
24| **A TTS lane** through the AI seam (cost on the same `[ai]` line) | **Pre-generate** the 10 curated objective briefs | Per-figure character voices (charming, but multiplies calls + complexity) |
25| **Content-hash audio cache** (Supabase Storage bucket) | User-selectable narrator voice (ties to [#90](https://github.com/mseeks/revisionist/issues/90)) | Voicing timeline headlines/details (too granular for the value) |
26| **Moderate the epilogue text before voicing** (closes an existing gap) | Carry cached audio with a **shared run** ([#88](https://github.com/mseeks/revisionist/issues/88)/[#89](https://github.com/mseeks/revisionist/issues/89)) | A commissioned/cloned bespoke Chronicler voice |
27 
28Five load-bearing decisions, each argued below:
29 
301. **The epilogue is the sweet spot, and the code already isolates it.** It's produced once
31 per run by its *own* AI task (`epilogue`, distinct from `chronicler`), flagged in-code as
⋯ 311 lines hidden (lines 32–342)
32 "the share artifact and the thing people pay for." On-demand-then-cache fits it perfectly:
33 highest value, lowest pressure.
342. **TTS does not ride the existing seam — it sits beside it.** The gateway's transport is
35 text-in / JSON-out; TTS is text-in / audio-out on a *separate* OpenAI endpoint. A TTS lane
36 is a small sibling transport that **reuses the same cost telemetry**, so TTS spend shows up
37 on the same `[ai]` line and counts against the same cap.
383. **Caching is the biggest lever, bigger than vendor choice.** Key by a content hash of
39 `text + voice + model + format`; an identical re-render costs zero. This needs a Supabase
40 **Storage bucket**, which is **not wired today** (only auth + Postgres are).
414. **Voicing the epilogue voices currently-unmoderated prose.** The Chronicle and epilogue
42 bypass the moderation seam today (only the figure's reply and the timeline headline/detail
43 pass it). Routing the epilogue text through `moderateAction` before voicing is a v1
44 prerequisite, not an afterthought.
455. **For a cached surface, latency barely matters — quality and ops do.** You generate once
46 and replay from cache forever, so the deciding factors are voice character, commercial
47 cleanliness, and operational simplicity. That tilts the choice toward OpenAI (already in
48 the stack) and reframes the fallback as a *quality* upgrade, not a latency one.
49 
50## Today: where the prose comes from
51 
52The game is dense with generated prose, and the [ROADMAP](../ROADMAP.md)'s four-layer AI is
53the source of all of it. Five surfaces render it; voicing is a question of *which* and *when*,
54and the answer differs per surface because the code does.
55 
56| Surface | Component | AI task | Store field | Volume | Moderated today? |
57|---|---|---|---|---|---|
58| **Epilogue** (final telling) | [`EndGameScreen.vue`](../../components/EndGameScreen.vue) | **`epilogue`** | `gameStore.chronicle` | ~once / run, ~400–800 chars | **no** |
59| Chronicle (per-turn retelling) | [`Chronicle.vue`](../../components/Chronicle.vue) | **`chronicler`** | `gameStore.chronicle` | every turn, regenerated | **no** |
60| Figure replies | [`MessageHistory.vue`](../../components/MessageHistory.vue) | `character` | `messageHistory[].text` (+ `figureName`) | per turn, ≤160 chars | yes (`reply.message`) |
61| Objective brief | [`MissionSelect.vue`](../../components/MissionSelect.vue) | curated + `objective` | `objectives.ts` / fetched | 10 curated (fixed) + composed | curated: human-authored |
62| Timeline headline/detail | [`TimelineLedger.vue`](../../components/TimelineLedger.vue) | `timeline` | `timelineEvents[]` | per turn, short | yes (`ripple.headline/detail`) |
63 
64Three facts here matter more than the issue's framing implied, and they sharpen the plan:
65 
66- **The epilogue is its own AI task, not a re-skin of the Chronicle.** Both write to the same
67 `gameStore.chronicle` slot (a `ChronicleEntry` of `title` + `paragraphs[]`), and the
68 *distinction is the task*: `callChroniclerAI` routes `status === 'playing'` to `chronicler`
69 and victory/defeat to **`epilogue`** ([`openai.ts:535`](../../server/utils/openai.ts)). The
70 routing table even annotates `epilogue` as *"the share artifact and the thing people pay
71 for"* ([`ai-routing.ts:105`](../../server/utils/ai-routing.ts)). So "voice the epilogue"
72 maps cleanly onto a seam the code already draws — you voice the output of one named task,
73 fired once, at a natural pause.
74- **The epilogue is short** — ~400–800 chars (two to four tight paragraphs), not the issue's
75 ~1–3K-char guess. That makes it *cheaper* to voice than the issue assumed (about a penny a
76 run; see [Costs](#costs)).
77- **The "closing movement" is a prompt instruction, not a separable field.** Victory prose is
78 asked to "END on a distinct closing movement" ([`prompt-builder.ts:314`](../../server/utils/prompt-builder.ts)),
79 but it arrives as one undivided `paragraphs[]`. So you can't cheaply voice "just the new
80 movement" of a Chronicle — you voice the whole current telling. This is exactly why the
81 per-turn Chronicle is the surface to make **opt-in**, not automatic.
82 
83## The crux: what to voice, and when
84 
85This was never one decision. It's per surface, and the game's own structure makes most of the
86answers obvious.
87 
88| Surface | When to voice | Why |
89|---|---|---|
90| **Epilogue** | ⭐ **on-demand → cache** | Once per run, read at the verdict pause. The share artifact and the paid surface. Highest value, lowest pressure. **The MVP.** |
91| Chronicle (per-turn) | **opt-in "listen" button** | Re-narrates the *whole* timeline every turn and is regenerated each turn — voicing it blindly is expensive *and* slow on every turn, and it can't be voiced "newest-only." Make it a button on the current telling. |
92| Objective brief | **pre-generate the 10 curated; on-demand for composed** | The curated pool is fixed and enumerable ([`objectives.ts`](../../server/utils/objectives.ts), 10 entries) — render them once at build/seed time and ship the audio. Live-composed briefs voice on demand, then cache. |
93| Figure replies | **defer past v1** | Ambitious (a distinct voice per figure — `figureName` is attached, so it's *possible*), but it multiplies calls and cost and adds real complexity. |
94| Timeline headline/detail | **skip v1** | Short, frequent, low marginal charm for the call volume. |
95 
96**The Chronicle is the cost/latency trap, by design.** The Chronicler "narrates the WHOLE
97altered timeline" and "is rewritten every turn"
98([`prompt-builder.ts:273`](../../server/utils/prompt-builder.ts)). Voicing that on every turn means
99multi-paragraph audio regenerated five times a run, each with a latency hit — the textbook
100"on-demand, regenerated often" case to avoid. A **"listen" button** turns it into a
101deliberate, cacheable request.
102 
103## Architecture fit: a TTS lane beside the AI seam
104 
105The issue says TTS "fits as a new task/lane through the same seam, instrumented the same way."
106That's *almost* right, and the gap is worth stating precisely, because it changes the v1
107scope.
108 
109**What's true:** every model call already routes through a per-task seam
110([`ai-gateway.ts`](../../server/utils/ai-gateway.ts) / [`ai-routing.ts`](../../server/utils/ai-routing.ts))
111that stamps **one `[ai]` cost line per call** — task, lane, model, tokens, latency, run id,
112and a dollar cost from the rate card — and records that cost to a spend cap. The rate card's
113own header calls itself "the GTM cost instrument's price half"
114([`cost.ts:2-4`](../../server/utils/cost.ts)). Per-call cost is a deliberate product lever,
115and TTS cost should join it.
116 
117**What's not true:** TTS can't ride the *transport*. `completeStructured` is hardwired
118text-in / JSON-schema-out (`turns` + `response_format`), and OpenAI's TTS is a *different*
119endpoint (`audio.speech.create`) that returns audio bytes. So a TTS lane is a **small sibling
120transport** next to `completeStructured`, not a new row that flows through it. Concretely, v1
121needs:
122 
1231. **A `synthesizeSpeech` transport** that calls `audio.speech.create` on the **same**
124 `getOpenAIClient()` ([`ai-gateway.ts:34`](../../server/utils/ai-gateway.ts)), and emits the
125 **same** `[ai]` telemetry via the existing `emit()` + `spendStore().record()`. Reuse the
126 plumbing; only the call shape differs.
1272. **A `tts` task in the routing table** — but **single-lane**. The table assumes every task
128 has both an `anthropic` and an `openai` route so `REVISIONIST_AI_LANE` can force-flip them;
129 Claude has no TTS, so a `tts` task can't honor a flip to Anthropic. Either pin `tts` to its
130 provider or let the flip skip tasks with no alternate lane.
1313. **A per-character rate-card entry.** `costUsd` today multiplies input/output/cached *text*
132 tokens by per-million rates ([`cost.ts:39-51`](../../server/utils/cost.ts)). TTS is billed
133 per character (`tts-1`/`tts-1-hd`) or per audio token (`gpt-4o-mini-tts`), so the rate card
134 gains a TTS entry and the telemetry's `usage` carries characters (or audio tokens). Small,
135 self-contained — and it keeps every TTS render visible on the same `[ai]` line and inside
136 the same spend cap.
137 
138None of this is a large refactor, but it is **more than "add a row to a table."** The v1
139foundation issue is exactly this lane.
140 
141## Vendor landscape (re-verified 2026-06-17)
142 
143Re-checked against each vendor's live pricing/docs. **The issue's headline figures held up;
144the corrections are in the model *names* and a few framing points.** Full sources in the
145spawned issues.
146 
147| Dimension | **OpenAI** `gpt-4o-mini-tts` | **ElevenLabs** Flash v2.5 / v3 | **Google** Chirp 3 HD |
148|---|---|---|---|
149| Price | **$0.60/1M input + $12/1M audio-output tokens** (≈ a penny per epilogue) | Flash **$0.05/1K chars**; v3/Multilingual **$0.10/1K chars** (paid plan) | **$30/1M chars** ($0.03/1K) + **1M chars/mo free, indefinitely** |
150| Steerable delivery | **yes** — free-text `instructions` (tone, pace, gravity) | no | Gemini TTS is steerable; Chirp 3 HD is not |
151| Quality | strong; 13 voices | top-tier; **10,000+ voice library** + consent-gated cloning | strong; large catalog |
152| Streaming | yes (HTTP chunked) | yes (HTTP + WebSocket) | REST + partial streaming |
153| Commercial use | **unrestricted**, you own output, no attribution, caching OK | **paid plan required** (free = non-commercial + attribution) | standard GCP terms, you own output |
154| In the repo already? | **yes — `openai` SDK + `OPENAI_API_KEY` are wired** | no (new SDK + new paid account + new key) | no (new GCP dep + service account) |
155| Best for | the steerable narrator, **least ops** | a bespoke premium Chronicler voice | a free tier + clean ownership |
156 
157Verification notes (corrections to the issue):
158 
159- **OpenAI prices confirmed exactly.** `tts-1` = $0.015/1K chars, `tts-1-hd` = $0.03/1K chars,
160 `gpt-4o-mini-tts` = $0.60/1M input + $12/1M audio-output tokens. Only `gpt-4o-mini-tts` is
161 steerable (the `instructions` param); `tts-1`/`tts-1-hd` are not. The "~$0.015/min" is
162 OpenAI's *own* convenience estimate, not purely third-party — and note it numerically equals
163 `tts-1`'s per-1K-char price by coincidence; different units, different models, don't
164 conflate.
165- **ElevenLabs prices confirmed.** Flash v2.5 ≈ $0.05/1K chars (0.5 credit/char), v3 /
166 Multilingual v2 ≈ $0.10/1K chars (1 credit/char) on a paid plan. The headline unit is
167 *credits*; the $/1K is plan-dependent. Flash's "~75 ms" is **inference-only** (the docs'
168 own footnote excludes app + network latency) — not a real player-experienced TTFB. **v3 is
169 not real-time** (its WebSocket stream-input endpoint doesn't even support v3), which is fine
170 for a pre-rendered, cached epilogue. Commercial use **requires a paid plan** (the entry
171 Starter tier, a few dollars a month).
172- **Challengers' model names are stale in the issue.** Cartesia's live model is **Sonic-3.5**
173 (not "Sonic 2/Turbo"), ~$0.037–0.039/1K chars, ~188 ms P50 in production — and its
174 commercial use is **tier-gated** (Pro+). Deepgram **Aura-2** is confirmed at $0.03/1K chars,
175 sub-200 ms, but English-centric. **Google Chirp 3 HD** is the strongest cost play: $30/1M
176 chars with an **indefinite 1M-char/month free tier** and clean GCP ownership — its only
177 cost against KISS here is a new GCP dependency.
178 
179## Costs
180 
181The epilogue is short — call it ~600 chars (estimated from its two-to-four-short-paragraph
182schema; there's no hard char cap in code, so treat the figures below as estimates), ≈ 30–50 s
183of speech:
184 
185- **Epilogue, once per run:****$0.01** (OpenAI), ≈ $0.03 (ElevenLabs Flash). Trivial — and
186 *cached*, so a re-listen or a shared run is **$0**. For reference, that's a rounding error
187 against the run's existing per-turn Claude spend (five turns, several Haiku/Sonnet calls
188 each), which the same `[ai]` line already tracks.
189- **Chronicle, every turn, on-demand:** ~5–10K chars/run ≈ **$0.15–0.50/run** *plus* a latency
190 hit each turn — the case to make opt-in and cache, never automatic.
191- **Curated objective briefs:** 10 × ~400 chars, rendered **once** at build/seed time and
192 shipped as static audio — effectively $0 amortized.
193 
194At this volume, **cost is not the deciding factor** — latency and quality are, and for a
195*cached* surface even latency falls away. Cost only bites at scale (the issue estimates
196~100K turns/mo at roughly $1.5K OpenAI vs $5–10K ElevenLabs if the Chronicle were voiced
197every turn — the strongest argument for caching and for keeping the Chronicle opt-in), and
198there the same `[ai]` instrument that prices the text lanes will price TTS too.
199 
200## Latency & streaming (only the on-demand surfaces)
201 
202The issue rightly stresses that **streaming is essential for on-demand voicing** — start
203playback on the first chunk, not the whole clip — and that TTFB should be benchmarked before
204committing. For the **cached epilogue, this mostly dissolves**: it's generated once and
205replayed from cache, so first-render latency is paid once and a re-listen is instant. But the
206two surfaces the plan keeps *on demand* still live under the issue's concern:
207 
208- The **opt-in Chronicle "listen"** and **live-composed objective briefs** are uncached on
209 first hit. Both should **stream** (both OpenAI and ElevenLabs support it — OpenAI via HTTP
210 chunked transfer, ElevenLabs via `convert-as-stream` / WebSocket), and their **TTFB is worth
211 a quick benchmark** before shipping. This is the one place ElevenLabs Flash v2.5's
212 lower-latency profile could matter — though for the cached MVP it does not.
213- **Concurrency caps** bind only if many players voice on demand at the same moment
214 (ElevenLabs is 4–40 concurrent by plan; OpenAI's audio calls draw on a rate-limit pool). A
215 non-issue for a once-per-run cached epilogue; worth a glance before the opt-in surfaces ship
216 to many players at once.
217 
218## Storage & caching
219 
220**Cache generated audio — this is the single biggest lever, bigger than vendor choice.** Key
221the cache by a **content hash of `text + voice + model + format`** (Node's
222`crypto.createHash('sha256')`; **no hashing utility exists in the repo today**, so the cache
223adds one). Then a regenerated-but-identical epilogue, a re-listen, or a shared run's narration
224costs **zero**.
225 
226Two grounded corrections to the issue's storage assumptions:
227 
228- **"Supabase Storage (already in the stack)" is not accurate.** Supabase is wired for **auth
229 + Postgres only** ([`nuxt.config.ts:28`](../../nuxt.config.ts) configures `url`/`key`/
230 `redirect`, no storage; the run/balance/spend stores talk to Postgres tables). Supabase
231 **Storage buckets are not configured or used anywhere.** Wiring a bucket is the audio
232 cache's natural home, but it's **greenfield work**, not a free given. It's still the right
233 home — same backend, same project — just not "already there."
234- **Run state is ephemeral today, so durable caching leans on [#83](https://github.com/mseeks/revisionist/issues/83).**
235 A run persists server-side as a single `runs` row that carries **no board state** (no
236 messages, no timeline, no prose); the board lives in the Pinia store and is **gone on
237 refresh**. A content-hash cache
238 works *within* a session immediately, and the content hash makes it durable across sessions
239 *regardless* of run persistence (same text → same key → same file). But carrying audio with
240 a **shared run** ([#88](https://github.com/mseeks/revisionist/issues/88)) wants #83's run
241 snapshot to exist first.
242 
243**Format: Opus** keeps the cache tiny (~hundreds of KB/min vs ~1 MB/min for MP3); both OpenAI
244and ElevenLabs emit it. Both vendors grant output ownership + storage on paid/commercial use,
245so caching is allowed.
246 
247## Voice selection
248 
249A **single "narrator of history" voice** is a brand choice. `gpt-4o-mini-tts` is **steerable**
250— prompt it `"Narrate gravely, measured, with the calm certainty of a historian for whom this
251is simply how things went"` (which echoes the epilogue prompt's own stance,
252[`prompt-builder.ts:314`](../../server/utils/prompt-builder.ts)) — so it fits a
253historian-narrator **without** commissioning a custom voice. That's the v1 pick.
254 
255- **Premium-voice upgrade:** ElevenLabs' 10,000+ library or a consent-gated cloned Chronicler
256 voice — the path if the stock narrator underwhelms. This is a *quality* decision, not a
257 latency one, because the surface is cached.
258- **Per-figure character voices** (each figure in a distinct voice) — high charm, more
259 calls/cost/complexity. Defer past v1.
260- **User-selectable voice** is a natural setting later, ties to [#90](https://github.com/mseeks/revisionist/issues/90).
261 
262## The moderation gap (handle in v1)
263 
264Worth its own heading, because it inverts one of the issue's claims. The issue says voicing
265"adds no new moderation surface" since the prose "already passes the moderation seam." **For
266the epilogue — the MVP — that's false.** Today `moderateAction` screens only the figure's
267reply, their action, and the timeline headline/detail
268([`send-message.post.ts:300`](../../server/api/send-message.post.ts)); the **Chronicle and
269epilogue prose bypass moderation entirely** ([`chronicle.post.ts`](../../server/api/chronicle.post.ts)
270fires the chronicler and returns the prose untouched).
271 
272So voicing the epilogue would voice **unmoderated** text. The right move is to **route the
273epilogue text through `moderateAction` before synthesizing** — which both makes the voiced
274surface safe and **closes a pre-existing gap** in the text path (a gap the moderation-audit
275loop would flag anyway). v1 should own this, not inherit it.
276 
277## Safety & scope
278 
279- **Cost stays visible.** Every TTS render emits an `[ai]` line and counts against the spend
280 cap — same instrument as the text lanes. No off-the-books audio spend.
281- **Secrets stay server-side.** A vendor key follows the existing pattern (a private
282 `runtimeConfig` entry + env fallback via `configuredKey`,
283 [`ai-gateway.ts:24`](../../server/utils/ai-gateway.ts)). OpenAI needs **no new key** — it
284 reuses `OPENAI_API_KEY`.
285- **Monetization is noted, not decided.** Voiced narration could be a paid perk (the epilogue
286 is already "the thing people pay for"); the accounts/paywall seam exists. Flag the question,
287 leave the call to the monetization track.
288 
289## Recommended v1, restated
290 
291- **Surface:** voice the **epilogue** on-demand at the verdict screen, then cache it. Make the
292 per-turn Chronicle an **opt-in "listen"** button. Pre-generate the 10 curated objective
293 briefs. Defer per-figure voices and timeline-string voicing.
294- **Vendor:** **OpenAI `gpt-4o-mini-tts`** — reuses the in-repo `openai` SDK + key, steerable
295 for a historian's voice, commercially clean. **Fallback is a quality decision** (ElevenLabs
296 v3/Multilingual for a bespoke Chronicler voice), or **Google Chirp 3 HD** if a free tier
297 matters — *not* a latency decision, because the surface is cached.
298- **Seam:** a `synthesizeSpeech` transport beside `completeStructured`, a **single-lane `tts`
299 task**, and a **per-character rate-card entry** so TTS cost rides the same `[ai]` line and
300 spend cap.
301- **Cache:** content hash of `text + voice + model + format` → a **Supabase Storage bucket**
302 (greenfield; Opus format). Durable cross-session via the hash; carry-with-shared-run waits
303 on [#83](https://github.com/mseeks/revisionist/issues/83).
304- **Safety:** **moderate the epilogue text before voicing**, closing the existing
305 Chronicle/epilogue moderation gap.
306 
307## Implementation issues this note spawns
308 
309Ready-to-file, in dependency order:
310 
3111. **A TTS lane through the AI seam.** A `synthesizeSpeech` transport calling
312 `audio.speech.create` on the existing `getOpenAIClient()`, emitting the same `[ai]`
313 telemetry + spend record; a single-lane `tts` task in `AI_ROUTES`; a per-character TTS
314 entry in the rate card so cost stays visible. No UI. *Foundation for the rest.*
3152. **Voice the epilogue (the MVP).** On the verdict screen
316 ([`EndGameScreen.vue`](../../components/EndGameScreen.vue)), synthesize the `epilogue`
317 task's prose on demand with a steered historian voice, play it, and **route the text
318 through `moderateAction` first** (closing the moderation gap). *Depends on 1 and 3.*
3193. **The audio cache + a Supabase Storage bucket.** Wire a Storage bucket, a
320 content-hash key (`sha256(text+voice+model+format)`), Opus output, serve-from-cache.
321 *Depends on 1; the durable-across-session win is immediate; carry-with-shared-run ties to
322 [#83](https://github.com/mseeks/revisionist/issues/83).*
3234. **Pre-generate + cache the curated objective briefs.** Render the 10 curated objectives
324 ([`objectives.ts`](../../server/utils/objectives.ts)) once at build/seed time; ship the
325 audio. *Depends on 1 and 3.*
3265. **Opt-in "listen" on the per-turn Chronicle.** A button that voices the current telling
327 on request (never automatic), cached by the same hash. *Depends on 1–3.*
328 
329## Explicitly out of scope (per #93)
330 
331No implementation, no vendor integration, no audio pipeline. No commitment to a vendor —
332divergent research plus a recommendation. This note identifies *where* and *how* TTS would
333fit; it does not build it.
334 
335---
336 
337*Related:* [#92](https://github.com/mseeks/revisionist/issues/92) (soundscape SFX — the sound
338half to this voice half) · [#83](https://github.com/mseeks/revisionist/issues/83) (run
339persistence — the cache's durable home) · [#88](https://github.com/mseeks/revisionist/issues/88)
340/ [#89](https://github.com/mseeks/revisionist/issues/89) (sharing & replay — why cached audio
341should travel with a run) · [#90](https://github.com/mseeks/revisionist/issues/90) (settings —
342a user-selectable voice).

The epilogue is already its own task

The note's central move — "voice the epilogue first" — works because the code already draws the seam. The Chronicler and the epilogue are two distinct AI tasks in the routing table, not one prose layer in two moods. The epilogue's route even carries an in-code annotation that's the whole argument for voicing it first: it's the share artifact and the paid surface.

epilogue is a member of the task enum, with its own route and that telling comment.

server/utils/ai-routing.ts · 178 lines
server/utils/ai-routing.ts178 lines · TypeScript
⋯ 22 lines hidden (lines 1–22)
1/**
2 * The per-task model routing table — the provider seam the go-to-market plan
3 * calls for (dispatch 1's bake-off lives behind this file). Every AI task names
4 * its lane and its full parameter payload here, because valid parameter sets
5 * differ by model family:
6 *
7 * - Haiku 4.5 rejects `effort` and has no adaptive thinking — omit both.
8 * - Sonnet 4.6 defaults `effort` to HIGH — always set it explicitly (this game
9 * wants reflexes, not deliberation). `temperature` is accepted.
10 * - Opus 4.8 rejects `temperature`/`top_p`/`top_k` — steer tone via prompts.
11 * Thinking is adaptive-only; omitting `thinking` runs without thinking.
12 *
13 * Lane defaults were decided by the two-lane bake-off plus the prompt-tuning
14 * campaign (evals/bakeoff.eval.ts, evals/prompt-tune.eval.ts; snapshots +
15 * verdict in evals/results/) — quality-first, with cost a close second. All
16 * ten tasks run Anthropic: the prose tellings initially lost the blind
17 * pairwise 19-0 under the shared prompt, and the Claude-voice rewrite
18 * (buildChroniclePromptClaude) won them back on Sonnet. The OpenAI lane stays
19 * fully alive behind REVISIONIST_AI_LANE so a forced reverse migration is a
20 * config change, not a rewrite (the GTM's platform-risk counter).
21 */
22 
23export type AiTask =
24 | 'judge'
25 | 'character'
26 | 'timeline'
27 | 'chronicler'
28 | 'epilogue'
⋯ 75 lines hidden (lines 29–103)
29 | 'archivist-study'
30 | 'archive-lookup'
31 | 'objective'
32 | 'objective-check'
33 | 'suggester'
34 | 'lifespan'
35 | 'sentinel'
36 
37export type AiLane = 'anthropic' | 'openai'
38 
39export interface AnthropicParams {
40 model: string
41 maxTokens: number
42 /** Omit on Opus-tier models — the API rejects sampling params there. */
43 temperature?: number
44 /** Omit on Haiku 4.5 — the API rejects the effort parameter there.
45 * 'xhigh' exists on Opus 4.7+ and Fable 5 only. */
46 effort?: 'low' | 'medium' | 'high' | 'xhigh'
47 /** 'adaptive' turns thinking on (billed as output); omitted = no thinking. */
48 thinking?: 'adaptive'
50 
51export interface OpenAiParams {
52 model: string
53 /** gpt-5.5 bills hidden reasoning inside this cap — keep generous headroom. */
54 maxTokens: number
55 reasoningEffort?: 'low' | 'medium' | 'high'
57 
58export interface TaskRoute {
59 lane: AiLane
60 anthropic: AnthropicParams
61 openai: OpenAiParams
63 
64/** The OpenAI lane's flagship — one place, like the old exported MODEL constant. */
65const GPT = 'gpt-5.5'
66const OPENAI_DEFAULTS: OpenAiParams = { model: GPT, maxTokens: 1200, reasoningEffort: 'low' }
67 
68/**
69 * With thinking off, Anthropic's max_tokens is TRUE output budget — no hidden
70 * reasoning billed inside it — so caps are sized to real output shapes instead
71 * of the old padded 1200.
72 */
73export const AI_ROUTES: Record<AiTask, TaskRoute> = {
74 // A rubric classifier in the critical path of every dispatch (it runs before
75 // the die). Haiku for speed; the calibration eval is its gate.
76 judge: {
77 lane: 'anthropic',
78 anthropic: { model: 'claude-haiku-4-5', maxTokens: 300, temperature: 0 },
79 openai: OPENAI_DEFAULTS
80 },
81 // The player converses with this output every turn — prose is product.
82 character: {
83 lane: 'anthropic',
84 anthropic: { model: 'claude-sonnet-4-6', maxTokens: 700, temperature: 0.9, effort: 'low' },
85 openai: OPENAI_DEFAULTS
86 },
87 // The rules-critical judgment layer; code clamps swings into the rolled band,
88 // the model places within band and rates anachronism.
89 timeline: {
90 lane: 'anthropic',
91 anthropic: { model: 'claude-sonnet-4-6', maxTokens: 600, temperature: 0.3, effort: 'medium' },
92 openai: OPENAI_DEFAULTS
93 },
94 // Re-narrates the whole timeline each turn. The original bake-off lost this
95 // slot 9-0 under the shared prompt; the Claude-voice rewrite (V2's
96 // transmission-chain bar, buildChroniclePromptClaude) won it back 8-5 in
97 // blind pairwise on Sonnet at ~1/4 the prior cost-per-telling — and the
98 // incumbent lane's quota outage made the flip availability-driven too.
99 // Higher-N confirmation pending (evals/results/2026-06-11-verdict.md).
100 chronicler: {
101 lane: 'anthropic',
102 anthropic: { model: 'claude-sonnet-4-6', maxTokens: 2000, temperature: 1, effort: 'high' },
103 openai: OPENAI_DEFAULTS
104 },
105 // The final telling — the share artifact and the thing people pay for. The
106 // Claude-voice V1 prompt on SONNET beat the incumbent 16-3 across two
107 // dual-graded rounds (7-3, then 9-0 at N=12) — decisively confirmed, and
108 // cheaper than the Opus/Fable candidates it outscored. Sonnet over Opus is
109 // what the data said, twice; Fable won too but at 4x Sonnet's price for no
110 // measured gain over it.
111 epilogue: {
112 lane: 'anthropic',
113 anthropic: { model: 'claude-sonnet-4-6', maxTokens: 2000, temperature: 1, effort: 'high' },
114 openai: OPENAI_DEFAULTS
115 },
⋯ 63 lines hidden (lines 116–178)
116 // Feeds the player's wager calibration — accuracy matters.
117 'archivist-study': {
118 lane: 'anthropic',
119 anthropic: { model: 'claude-sonnet-4-6', maxTokens: 800, temperature: 0.5, effort: 'low' },
120 openai: OPENAI_DEFAULTS
121 },
122 // Factual micro-answers with a known-since stamp.
123 'archive-lookup': {
124 lane: 'anthropic',
125 anthropic: { model: 'claude-haiku-4-5', maxTokens: 500, temperature: 0.3 },
126 openai: OPENAI_DEFAULTS
127 },
128 // 0-1 calls per run; the objective steers every layer's valence.
129 objective: {
130 lane: 'anthropic',
131 anthropic: { model: 'claude-sonnet-4-6', maxTokens: 600, temperature: 1, effort: 'low' },
132 openai: OPENAI_DEFAULTS
133 },
134 // Gates a freshly composed objective on the safely-historical bounds (#71): a
135 // cheap, temp-0 classifier (the figure-floor pattern) that catches the
136 // living-people / current-events framings a year bound alone can't. Haiku,
137 // off the critical generation path — at most twice per composition.
138 'objective-check': {
139 lane: 'anthropic',
140 anthropic: { model: 'claude-haiku-4-5', maxTokens: 200, temperature: 0 },
141 openai: OPENAI_DEFAULTS
142 },
143 // Tool-using agent verified against free grounding, curated floor beneath it.
144 // maxTokens here is per ROUND of the agent loop.
145 suggester: {
146 lane: 'anthropic',
147 anthropic: { model: 'claude-haiku-4-5', maxTokens: 1500, temperature: 0.7 },
148 openai: { model: GPT, maxTokens: 2500 }
149 },
150 // A dates classifier with hard validation guards behind it (issue #31 bridge).
151 lifespan: {
152 lane: 'anthropic',
153 anthropic: { model: 'claude-haiku-4-5', maxTokens: 300, temperature: 0 },
154 openai: OPENAI_DEFAULTS
155 },
156 // The safety Sentinel — classifies a whole exchange against the verbatim
157 // Usage Policy (server/utils/usage-policy.ts). Haiku at temp 0 for a
158 // consistent, cheap classifier, exactly the tier Anthropic's content-
159 // moderation guide recommends. Runs once per user action, never on the
160 // critical generation path's tokens.
161 sentinel: {
162 lane: 'anthropic',
163 anthropic: { model: 'claude-haiku-4-5', maxTokens: 400, temperature: 0 },
164 openai: OPENAI_DEFAULTS
165 }
167 
168/**
169 * Resolves a task to its lane + parameters. REVISIONIST_AI_LANE overrides the
170 * whole table ('openai' | 'anthropic') — the emergency lever, and how the eval
171 * matrix runs identical fixtures down each lane.
172 */
173export function routeFor(task: AiTask): { lane: AiLane; route: TaskRoute } {
174 const route = AI_ROUTES[task]
175 const override = process.env.REVISIONIST_AI_LANE
176 const lane = override === 'openai' || override === 'anthropic' ? override : route.lane
177 return { lane, route }

One call routes by status: mid-run prose is chronicler, the final telling is epilogue.

server/utils/openai.ts · 577 lines
server/utils/openai.ts577 lines · TypeScript
⋯ 532 lines hidden (lines 1–532)
1/**
2 * The game's AI layers. Historically OpenAI-only (hence the filename, kept to
3 * avoid churning every reference); today each layer is routed per task through
4 * the AI gateway (ai-gateway.ts) to its bake-off-chosen lane — Anthropic tiers
5 * by default, the OpenAI lane alive behind REVISIONIST_AI_LANE.
6 *
7 * Every layer keeps the same contract it always had: build prompt → structured
8 * call → parse → guard → never-throw envelope. The catch-block console.errors
9 * are intentional (ops signal, not debug cruft).
10 */
11import {
12 buildCharacterPrompt,
13 buildTimelinePrompt,
14 buildChroniclePrompt,
15 buildChroniclePromptClaude,
16 buildResearchPrompt,
17 buildLookupPrompt,
18 buildJudgePrompt,
19 type ObjectiveContext,
20 type TimelineContextEntry,
21 type CharacterGrounding,
22 type ChronicleStatus,
23 type FigureStudy,
24 type ArchiveLookup
25} from './prompt-builder'
26import { completeStructured, ModelRefusalError, type ChatTurn } from './ai-gateway'
27import { routeFor } from './ai-routing'
28import { amplifyForAnachronism, toAnachronism, type Anachronism } from './anachronism'
29import { chainStatus, decayForChain, type ChainStatus } from '~/utils/causal-chain'
30import { toCraft, CRAFT_LEVELS, CRAFT_MODIFIER, CRAFT_GAIN_FACTOR, type Craft } from '~/utils/craft'
31import { toContinuity, CONTINUITY_LEVELS, type Continuity } from '~/utils/continuity'
32import type { DiceOutcome } from '~/utils/dice'
33import { BAND_CEILING, SWING_BANDS, VALENCE_SIGN_GUARD_MIN } from '~/utils/swing-bands'
34import { MAX_PROGRESS_SWING } from '~/utils/game-config'
35import { momentumFactor } from '~/utils/momentum'
36 
37/**
38 * What a figure returns each turn: an in-character reply and action, plus factual
39 * metadata (era, persona) the UI uses to label them.
40 */
41export interface StructuredCharacterResponse {
42 message: string
43 action: string
44 era: string
45 persona: string
47 
48export interface CharacterAIResponse {
49 success: boolean
50 characterResponse?: StructuredCharacterResponse
51 error?: string
52 /** Claude's own safety classifier declined this turn (stop_reason refusal) —
53 * the caller surfaces it as a moderation block, not an infra refund. */
54 refused?: boolean
56 
57/**
58 * The Timeline Engine's verdict for a turn: how history bent.
59 */
60export interface TimelineRipple {
61 headline: string
62 detail: string
63 progressChange: number
64 /** The engine's swing BEFORE the anachronism amplifier — kept so the UI can
65 * show the wager's work ("+8 ⚡ far-ahead → +11%") instead of an opaque total. */
66 baseProgressChange: number
67 valence: 'positive' | 'negative' | 'neutral'
68 /** How far beyond the figure's era the player reached — widens the swing. */
69 anachronism: Anachronism
70 /** How far this moment lands from the nearest foothold (objective anchor or a
71 * prior change) — DECAYS the swing toward zero. Absent for ungrounded turns
72 * or anchorless objectives (the dial no-ops). The opposite of anachronism. */
73 causalChain?: ChainStatus
75 
76export interface TimelineAIResponse {
77 success: boolean
78 ripple?: TimelineRipple
79 error?: string
80 /** Model-side safety refusal — surfaced as a block, not an infra refund. */
81 refused?: boolean
83 
84/**
85 * The Chronicler's telling: a titled, multi-paragraph prose account of the altered
86 * timeline as it currently stands. Rewritten each turn; the final one is the epilogue.
87 */
88export interface ChronicleEntry {
89 title: string
90 paragraphs: string[]
92 
93export interface ChronicleAIResponse {
94 success: boolean
95 chronicle?: ChronicleEntry
96 error?: string
98 
99interface HistoryItem { text: string; sender: string }
100 
101/**
102 * Maps the stored conversation thread into chat turns. The thread is expected to
103 * end with the current user message; if it doesn't (e.g. a direct call with no
104 * history), the current message is appended so the figure has it.
105 */
106function toChatMessages(conversationHistory: HistoryItem[], currentUserMessage: string): ChatTurn[] {
107 const mapped: ChatTurn[] = (conversationHistory || [])
108 .filter(m => m && typeof m.text === 'string' && m.sender !== 'system')
109 .map(m => ({
110 role: (m.sender === 'user' ? 'user' : 'assistant') as 'user' | 'assistant',
111 content: m.text
112 }))
113 
114 const last = mapped[mapped.length - 1]
115 if (!last || last.role !== 'user' || last.content !== currentUserMessage) {
116 mapped.push({ role: 'user', content: currentUserMessage })
117 }
118 return mapped
120 
121/**
122 * Layer 1 — role-plays the chosen figure (any name, any era), objective-blind,
123 * returning a structured reply + action + factual era/persona metadata.
124 */
125export async function callCharacterAI(
126 figureName: string,
127 userMessage: string,
128 conversationHistory: HistoryItem[] = [],
129 diceOutcome: DiceOutcome,
130 grounding?: CharacterGrounding,
131 worldBrief?: string[],
132 /** True when the dispatch carries no actionable substance — keeps the figure
133 * honest so a favorable roll on noise yields confusion, not a fabricated deed. */
134 contentless?: boolean
135): Promise<CharacterAIResponse> {
136 try {
137 const content = await completeStructured({
138 task: 'character',
139 system: buildCharacterPrompt(figureName, diceOutcome, grounding, worldBrief, contentless),
140 turns: toChatMessages(conversationHistory, userMessage),
141 schemaName: 'character_response',
142 schema: {
143 type: 'object',
144 properties: {
145 message: { type: 'string', description: `${figureName}'s in-character reply, 160 chars max` },
146 action: { type: 'string', description: `The concrete thing ${figureName} decides to do` },
147 era: { type: 'string', description: 'Short factual when/where tag, e.g. "Vienna, 1914"' },
148 persona: { type: 'string', description: 'A 3-6 word factual descriptor of the figure' }
149 },
150 required: ['message', 'action', 'era', 'persona'],
151 additionalProperties: false
152 }
153 })
154 
155 if (!content) {
156 return { success: false, error: 'Character AI generated an empty response' }
157 }
158 
159 const parsed = JSON.parse(content) as StructuredCharacterResponse
160 if (!parsed.message || !parsed.action) {
161 return { success: false, error: 'Character AI response missing required fields' }
162 }
163 
164 return { success: true, characterResponse: parsed }
165 } catch (error) {
166 if (error instanceof ModelRefusalError) {
167 return { success: false, refused: true, error: 'This dispatch was declined by content moderation and cannot be sent.' }
168 }
169 console.error('Character AI error:', error)
170 return { success: false, error: 'Character AI could not process the request' }
171 }
173 
174/**
175 * Layer 2 — judges how the figure's action ripples through the already-altered
176 * world toward the live objective, returning a structured timeline ripple.
177 */
178export async function callTimelineAI(args: {
179 objective: ObjectiveContext
180 figureName: string
181 era: string
182 userMessage: string
183 characterMessage: string
184 characterAction: string
185 diceRoll: number
186 diceOutcome: DiceOutcome
187 timeline?: TimelineContextEntry[]
188 figureYear?: number
189 figureMoment?: string
190 /** The objective's far anchor year — a foothold for the causal-chain dial. */
191 objectiveAnchorYear?: number
192 /** The Judge's craft grade for this dispatch — drives the gains-only craft
193 * amplifier (issue #62). Absent → no amplification (factor 1). */
194 craft?: Craft
195 /** The run's PRE-turn momentum (0..4) — the run-level gains amplifier that rides
196 * alongside craft. Absent → no amplification (factor 1). */
197 momentum?: number
198}): Promise<TimelineAIResponse> {
199 try {
200 const content = await completeStructured({
201 task: 'timeline',
202 system: buildTimelinePrompt({ ...args, timeline: args.timeline ?? [] }),
203 schemaName: 'timeline_ripple',
204 schema: {
205 type: 'object',
206 properties: {
207 headline: { type: 'string', description: 'Punchy headline, ~9 words max' },
208 detail: { type: 'string', description: '1-2 sentences describing the ripple through history' },
209 progressChange: { type: 'integer', description: `Integer from -${BAND_CEILING} to ${BAND_CEILING}, within the rolled band` },
210 valence: { type: 'string', enum: ['positive', 'negative', 'neutral'] },
211 anachronism: { type: 'string', enum: ['in-period', 'ahead', 'far-ahead', 'impossible'], description: 'How far beyond the figure’s era the message reached' }
212 },
213 required: ['headline', 'detail', 'progressChange', 'valence', 'anachronism'],
214 additionalProperties: false
215 }
216 })
217 
218 if (!content) {
219 return { success: false, error: 'Timeline AI generated an empty response' }
220 }
221 
222 const parsed = JSON.parse(content)
223 if (typeof parsed.headline !== 'string' || typeof parsed.detail !== 'string' || typeof parsed.progressChange !== 'number') {
224 return { success: false, error: 'Timeline AI response missing required fields' }
225 }
226 
227 // The band table is law — in code, not just prompt: the model's base swing
228 // is clamped into the ROLLED band before the amplifier touches it, so the
229 // disclosed legend and the engine can never disagree about a roll's range.
230 const band = SWING_BANDS[args.diceOutcome]
231 const baseProgress = Math.max(band.min, Math.min(band.max, Math.round(parsed.progressChange)))
232 const anachronism = toAnachronism(parsed.anachronism)
233 const amplified = amplifyForAnachronism(baseProgress, anachronism)
234 // Causal-chain decay (the opposite dial): a reach far from any foothold is
235 // diffused toward zero. Footholds are the objective's anchor year plus every
236 // dated change already on the ledger — so landing a change plants a new
237 // foothold and chaining forward overcomes the distance. No-ops (factor 1)
238 // for ungrounded turns or anchorless objectives.
239 const footholds = [args.objectiveAnchorYear, ...(args.timeline ?? []).map(e => e.whenSigned)]
240 const causalChain = chainStatus(args.figureYear, footholds) ?? undefined
241 const decayed = causalChain ? decayForChain(amplified, causalChain.factor) : amplified
242 // Gains-only amplifiers (issue #62): a sharper dispatch (craft) and a more
243 // coherent arc (momentum) each bank more of the band the roll earned — but ONLY
244 // on the upside. Losses keep the rolled band's verdict untouched (mirrors
245 // amplifyForAnachronism's sign branch), so skill buys reach, never immunity.
246 // After this the value can exceed the per-turn fuse, and nothing downstream
247 // re-clamps an UPWARD push before the (post-stake) ±100 cap — so re-clamp to
248 // ±MAX_PROGRESS_SWING here.
249 const craftFactor = args.craft ? CRAFT_GAIN_FACTOR[args.craft] : 1
250 const momoFactor = momentumFactor(args.momentum ?? 0)
251 const gained = decayed > 0 ? decayed * craftFactor * momoFactor : decayed
252 const progressChange = Math.max(-MAX_PROGRESS_SWING, Math.min(MAX_PROGRESS_SWING, Math.round(gained)))
253 const modelValence: TimelineRipple['valence'] =
254 parsed.valence === 'positive' || parsed.valence === 'negative' || parsed.valence === 'neutral'
255 ? parsed.valence
256 : (progressChange > 0 ? 'positive' : progressChange < 0 ? 'negative' : 'neutral')
257 // Sign guard: a swing beyond the neutral window (±3, the eval's own line)
258 // must wear the color of its sign — a "positive" badge on a -12 turn is a
259 // straight "this game is arbitrary" signal. Small swings keep the model's
260 // shading, where "neutral" or a soft contradiction is fair judgment.
261 const signValence: TimelineRipple['valence'] = progressChange > 0 ? 'positive' : 'negative'
262 const valence = Math.abs(progressChange) > VALENCE_SIGN_GUARD_MIN ? signValence : modelValence
263 
264 return {
265 success: true,
266 ripple: { headline: parsed.headline, detail: parsed.detail, progressChange, baseProgressChange: baseProgress, valence, anachronism, causalChain }
267 }
268 } catch (error) {
269 if (error instanceof ModelRefusalError) {
270 return { success: false, refused: true, error: 'This dispatch was declined by content moderation and cannot be sent.' }
271 }
272 console.error('Timeline AI error:', error)
273 return { success: false, error: 'Timeline AI could not process the request' }
274 }
276 
277/**
278 * The Message Judge (roadmap slice 5) — grades the craft of the player's dispatch
279 * BEFORE the dice are thrown; the grade becomes a small roll modifier. Objective-
280 * aware (it judges leverage toward the goal) but outcome-blind: it never sees the
281 * roll. A failure here must never punish the player — callers fall back to the
282 * neutral 'sound' (modifier 0), so a Judge outage just means an untilted die.
283 */
284export interface JudgeVerdict {
285 craft: Craft
286 /** Whether the dispatch builds on the run's thread — feeds the momentum meter
287 * (issue #62). 'neutral' on turn 1 or a Judge outage (never moves momentum). */
288 continuity: Continuity
289 reason: string
291 
292export interface JudgeAIResponse {
293 success: boolean
294 judge?: JudgeVerdict
295 error?: string
297 
298export async function callJudgeAI(args: {
299 objective: ObjectiveContext
300 figureName: string
301 when?: string
302 momentRefined?: boolean
303 userMessage: string
304 /** The figure's reply on the PRIOR turn — their last revealed condition/price.
305 * Empty on turn 1. The Judge reads it to see if THIS dispatch meets it. */
306 lastFigureReply?: string
307 /** A short digest of recent ledger headlines so the Judge can spot a dispatch
308 * that deliberately exploits a change already on the record. */
309 ledgerDigest?: string[]
310}): Promise<JudgeAIResponse> {
311 try {
312 // With a pinned moment the schema gains two leading fields: "event"
313 // makes the model WRITE OUT the real event and its real-world date, so
314 // the "timing" enum right after it compares two dates sitting on the
315 // page instead of inferring silently inside one token. Probed 24/24 on
316 // Haiku and Sonnet where a bare timing enum failed 0/12 — date retrieval
317 // must be a generation step, not an implication. The CAP is still
318 // enforced in code below (legend-is-law, like the band clamp).
319 const timingFields = args.momentRefined
320 ? {
321 event: {
322 type: 'string',
323 description: 'The real recorded historical event this dispatch is trying to influence, and the exact real-world date it occurred (e.g. "the Hindenburg disaster at Lakehurst — May 6, 1937")'
324 },
325 timing: {
326 type: 'string',
327 enum: ['in-time', 'too-late'],
328 description: '"too-late" if the event\'s real date above is earlier than the stated moment the dispatch arrives — its chance already spent; otherwise "in-time"'
329 }
330 }
331 : {}
332 // Continuity is read only when there's a thread to build on. On turn 1 (no
333 // prior reply, empty ledger) the prompt + schema stay byte-identical to the
334 // pre-feature build and continuity defaults to 'neutral' — momentum can't
335 // build out of nothing anyway.
336 const hasThread = !!(args.lastFigureReply || args.ledgerDigest?.length)
337 const continuityField = hasThread
338 ? {
339 continuity: {
340 type: 'string',
341 enum: [...CONTINUITY_LEVELS],
342 description: '"builds" if this dispatch meets a condition/price the figure just revealed, deliberately exploits a change already on the record, or coheres with and escalates the run\'s strategy; "resets" if it abandons that thread and starts cold; "neutral" otherwise'
343 }
344 }
345 : {}
346 const content = await completeStructured({
347 task: 'judge',
348 system: buildJudgePrompt(args),
349 schemaName: 'craft_verdict',
350 schema: {
351 type: 'object',
352 properties: {
353 ...timingFields,
354 craft: { type: 'string', enum: [...CRAFT_LEVELS], description: 'The dispatch’s craft grade' },
355 ...continuityField,
356 reason: { type: 'string', description: 'One terse player-facing sentence, under 90 characters' }
357 },
358 required: [...(args.momentRefined ? ['event', 'timing'] : []), 'craft', ...(hasThread ? ['continuity'] : []), 'reason'],
359 additionalProperties: false
360 }
361 })
362 
363 if (!content) {
364 return { success: false, error: 'Judge generated an empty response' }
365 }
366 
367 const parsed = JSON.parse(content) as { timing?: unknown; craft?: unknown; continuity?: unknown; reason?: unknown }
368 let craft = toCraft(parsed.craft)
369 // The closed-door cap, in code: a too-late pin grades vague at best.
370 if (args.momentRefined && parsed.timing === 'too-late' && CRAFT_MODIFIER[craft] > CRAFT_MODIFIER.vague) {
371 craft = 'vague'
372 }
373 return {
374 success: true,
375 judge: {
376 craft,
377 continuity: toContinuity(parsed.continuity),
378 reason: typeof parsed.reason === 'string' ? parsed.reason.trim().slice(0, 120) : ''
379 }
380 }
381 } catch (error) {
382 console.error('Judge AI error:', error)
383 return { success: false, error: 'Judge could not assess the dispatch' }
384 }
386 
387/**
388 * The Archivist (prototype) — a grounded, OBJECTIVE-BLIND brief on a real figure at
389 * a chosen point in their life: who they are, what they grasp, what they're reaching
390 * for, and what lies beyond their era. In-game research so the player needn't break
391 * out to a search engine — it enriches the world, never makes their move.
392 */
393export interface ArchivistAIResponse {
394 success: boolean
395 study?: FigureStudy
396 error?: string
397 /** Claude's own safety classifier declined the study — surfaced as a moderation
398 * block (the study-blocked banner), not an infra failure. */
399 refused?: boolean
401 
402export async function callArchivistAI(args: {
403 figureName: string
404 when?: string
405 description?: string
406 extract?: string
407}): Promise<ArchivistAIResponse> {
408 try {
409 const content = await completeStructured({
410 task: 'archivist-study',
411 system: buildResearchPrompt(args),
412 schemaName: 'figure_study',
413 schema: {
414 type: 'object',
415 properties: {
416 atThisMoment: { type: 'string', description: 'Where the figure stands in life and work at the chosen moment' },
417 grasp: { type: 'array', items: { type: 'string' }, description: '2-4 things they genuinely understand' },
418 reaching: { type: 'array', items: { type: 'string' }, description: '2-4 things they pursue or are stuck on' },
419 cannotYetKnow: { type: 'string', description: 'One sentence on what lies beyond their era' }
420 },
421 required: ['atThisMoment', 'grasp', 'reaching', 'cannotYetKnow'],
422 additionalProperties: false
423 }
424 })
425 
426 if (!content) {
427 return { success: false, error: 'Archivist generated an empty response' }
428 }
429 
430 const parsed = JSON.parse(content) as { atThisMoment?: unknown; grasp?: unknown; reaching?: unknown; cannotYetKnow?: unknown }
431 const asStrings = (v: unknown): string[] =>
432 Array.isArray(v) ? v.filter((s): s is string => typeof s === 'string' && s.trim().length > 0) : []
433 const grasp = asStrings(parsed.grasp)
434 const reaching = asStrings(parsed.reaching)
435 if (typeof parsed.atThisMoment !== 'string' || !parsed.atThisMoment.trim() || (!grasp.length && !reaching.length)) {
436 return { success: false, error: 'Archivist response missing required fields' }
437 }
438 
439 return {
440 success: true,
441 study: {
442 atThisMoment: parsed.atThisMoment.trim(),
443 grasp,
444 reaching,
445 cannotYetKnow: typeof parsed.cannotYetKnow === 'string' ? parsed.cannotYetKnow.trim() : ''
446 }
447 }
448 } catch (error) {
449 if (error instanceof ModelRefusalError) {
450 return { success: false, refused: true, error: 'This study was declined by content moderation.' }
451 }
452 console.error('Archivist AI error:', error)
453 return { success: false, error: 'Archivist could not process the request' }
454 }
456 
457/**
458 * The Archive lookup (prototype, "yellow") — a concise, factual answer to a freeform
459 * topic query, stamped with when the knowledge first emerged so the player can read
460 * its anachronism. Pure domain facts (what/ingredients/when), never strategy.
461 */
462export interface ArchiveLookupResponse {
463 success: boolean
464 lookup?: ArchiveLookup
465 error?: string
466 /** Claude's own safety classifier declined the lookup — surfaced as a moderation
467 * block (the lookup-blocked banner), not an infra failure. */
468 refused?: boolean
470 
471export async function callArchiveLookupAI(query: string): Promise<ArchiveLookupResponse> {
472 try {
473 const content = await completeStructured({
474 task: 'archive-lookup',
475 system: buildLookupPrompt(query),
476 schemaName: 'archive_lookup',
477 schema: {
478 type: 'object',
479 properties: {
480 topic: { type: 'string', description: 'Short title for what was asked' },
481 summary: { type: 'string', description: 'One or two plain sentences — the essential fact' },
482 requires: { type: 'array', items: { type: 'string' }, description: 'Concrete ingredients / prerequisites, or empty' },
483 knownSince: { type: 'string', description: 'When/where this knowledge first emerged' }
484 },
485 required: ['topic', 'summary', 'requires', 'knownSince'],
486 additionalProperties: false
487 }
488 })
489 
490 if (!content) {
491 return { success: false, error: 'Archive generated an empty response' }
492 }
493 
494 const parsed = JSON.parse(content) as { topic?: unknown; summary?: unknown; requires?: unknown; knownSince?: unknown }
495 if (typeof parsed.topic !== 'string' || typeof parsed.summary !== 'string' || !parsed.summary.trim()) {
496 return { success: false, error: 'Archive response missing required fields' }
497 }
498 const requires = Array.isArray(parsed.requires)
499 ? parsed.requires.filter((s): s is string => typeof s === 'string' && s.trim().length > 0)
500 : []
501 
502 return {
503 success: true,
504 lookup: {
505 topic: parsed.topic.trim() || query,
506 summary: parsed.summary.trim(),
507 requires,
508 knownSince: typeof parsed.knownSince === 'string' ? parsed.knownSince.trim() : ''
509 }
510 }
511 } catch (error) {
512 if (error instanceof ModelRefusalError) {
513 return { success: false, refused: true, error: 'That lookup was declined by content moderation.' }
514 }
515 console.error('Archive lookup error:', error)
516 return { success: false, error: 'Archive could not process the request' }
517 }
519 
520/**
521 * Layer 3 — the Chronicler. Narrates the whole altered timeline as it now stands,
522 * rewritten each turn from the running ledger. It judges nothing, so a failure is
523 * non-fatal: the caller simply keeps the prior telling rather than blanking the panel.
524 *
525 * The FINAL telling (victory/defeat) is the run's epilogue — the share artifact —
526 * and routes as its own task so the flagship model can carry that one moment.
527 */
528export async function callChroniclerAI(args: {
529 objective: ObjectiveContext
530 timeline: TimelineContextEntry[]
531 status: ChronicleStatus
532 progress: number
533}): Promise<ChronicleAIResponse> {
534 try {
535 const task = args.status === 'playing' ? 'chronicler' : 'epilogue'
536 // Prompts are per-model artifacts: the Claude voice won its lane in
⋯ 41 lines hidden (lines 537–577)
537 // blind pairwise (see buildChroniclePromptClaude); the incumbent voice
538 // stays exactly as-is for the OpenAI lane, so the fallback lever keeps
539 // its tuned prompt too.
540 const { lane } = routeFor(task)
541 const content = await completeStructured({
542 task,
543 system: lane === 'anthropic' ? buildChroniclePromptClaude(args) : buildChroniclePrompt(args),
544 schemaName: 'chronicle',
545 schema: {
546 type: 'object',
547 properties: {
548 title: { type: 'string', description: 'Short, evocative title for this version of history (3-6 words)' },
549 paragraphs: {
550 type: 'array',
551 items: { type: 'string' },
552 description: '2-4 short paragraphs of prose, in reading order'
553 }
554 },
555 required: ['title', 'paragraphs'],
556 additionalProperties: false
557 }
558 })
559 
560 if (!content) {
561 return { success: false, error: 'Chronicle AI generated an empty response' }
562 }
563 
564 const parsed = JSON.parse(content) as { title?: unknown; paragraphs?: unknown }
565 const paragraphs = Array.isArray(parsed.paragraphs)
566 ? parsed.paragraphs.filter((p): p is string => typeof p === 'string' && p.trim().length > 0)
567 : []
568 if (typeof parsed.title !== 'string' || !parsed.title.trim() || paragraphs.length === 0) {
569 return { success: false, error: 'Chronicle AI response missing required fields' }
570 }
571 
572 return { success: true, chronicle: { title: parsed.title.trim(), paragraphs } }
573 } catch (error) {
574 console.error('Chronicle AI error:', error)
575 return { success: false, error: 'Chronicle AI could not process the request' }
576 }

A TTS lane sits beside the seam, not through it

The issue says TTS "fits as a new task/lane through the same seam, instrumented the same way." Almost. Every model call does route through a per-task gateway that stamps one [ai] cost line and records the spend — that part is true, and it's the reason the note insists TTS cost must show up there too.

emit writes the one [ai] line per call and records its dollar cost to the spend cap.

server/utils/ai-gateway.ts · 245 lines
server/utils/ai-gateway.ts245 lines · TypeScript
⋯ 92 lines hidden (lines 1–92)
1/**
2 * The AI gateway — one transport for every structured model call, dispatched
3 * per task by the routing table (ai-routing.ts). Callers keep their own prompts,
4 * schemas, parsing, guards, and never-throw envelopes; the gateway only decides
5 * WHICH provider runs the request and HOW (model + parameters), and emits the
6 * cost telemetry the go-to-market plan's dispatch 1 requires.
7 *
8 * Telemetry: every call logs one structured line (task, lane, model, tokens
9 * in/out, latency, ok) — intentional production output, suppressed under vitest.
10 * The eval harness subscribes via setAiCallObserver to price bake-off lanes.
11 */
12import OpenAI from 'openai'
13import Anthropic from '@anthropic-ai/sdk'
14import { routeFor, type AiTask, type AiLane } from './ai-routing'
15import { currentRunId } from './run-context'
16import { costUsd } from './cost'
17import { spendStore } from './spend-store'
18 
19let openaiClient: OpenAI | null = null
20let anthropicClient: Anthropic | null = null
21 
22/** Reads a runtime-config key, falling back to raw env outside a Nuxt context
23 * (the eval harness exercises these utils directly in node). */
24function configuredKey(name: 'openaiApiKey' | 'anthropicApiKey', envName: string): string | undefined {
25 let key: string | undefined
26 try {
27 key = useRuntimeConfig()[name] as string | undefined
28 } catch {
29 key = undefined
30 }
31 return key || process.env[envName]
33 
34export function getOpenAIClient(): OpenAI {
35 if (!openaiClient) {
36 const apiKey = configuredKey('openaiApiKey', 'OPENAI_API_KEY')
37 if (!apiKey) throw new Error('OpenAI API key is not configured')
38 openaiClient = new OpenAI({
39 apiKey,
40 // Fail fast rather than hang on the SDK's 10-minute default if a
41 // call stalls (e.g. a bad key or network hiccup).
42 timeout: 30_000,
43 maxRetries: 1
44 })
45 }
46 return openaiClient
48 
49export function getAnthropicClient(): Anthropic {
50 if (!anthropicClient) {
51 const apiKey = configuredKey('anthropicApiKey', 'ANTHROPIC_API_KEY')
52 if (!apiKey) throw new Error('Anthropic API key is not configured')
53 anthropicClient = new Anthropic({
54 apiKey,
55 // The epilogue's adaptive thinking can run long; everything else is
56 // seconds. 60s bounds the worst case without hanging a turn forever.
57 timeout: 60_000,
58 maxRetries: 1
59 })
60 }
61 return anthropicClient
63 
64export interface AiUsage {
65 inputTokens: number
66 outputTokens: number
67 cacheReadTokens: number
69 
70export interface AiCallTelemetry {
71 task: AiTask
72 lane: AiLane
73 model: string
74 ms: number
75 ok: boolean
76 usage?: AiUsage
77 /** The game run this call belongs to (from the request's x-run-id); absent
78 * outside a run-scoped request. The key that groups calls into a run. */
79 runId?: string
80 /** The call's dollar cost from the rate card; absent when usage is missing
81 * (a failed call) or the model has no listed rate. */
82 usd?: number
84 
85type AiCallObserver = (t: AiCallTelemetry) => void
86let observer: AiCallObserver | null = null
87 
88/** The eval harness (and future dashboards) subscribe here; null to clear. */
89export function setAiCallObserver(fn: AiCallObserver | null): void {
90 observer = fn
92 
93function emit(t: AiCallTelemetry): void {
94 // Enrich once, here, so both the success and failure call sites get the run
95 // id, and so the observer (the eval harness) sees the same priced line the
96 // log does. usd needs usage, so it rides only on calls that reported tokens.
97 const enriched: AiCallTelemetry = {
98 ...t,
99 runId: t.runId ?? currentRunId(),
100 usd: t.usd ?? (t.usage ? costUsd(t.model, t.usage, t.lane) : undefined)
101 }
102 observer?.(enriched)
103 // One greppable line per call — the GTM's "structured log line is enough to
104 // start". Intentional production telemetry; quiet under the test runner.
105 if (!process.env.VITEST) {
106 console.info(`[ai] ${JSON.stringify(enriched)}`)
107 }
⋯ 138 lines hidden (lines 108–245)
108 // Feed the spend cap: record this call's cost (best-effort, non-blocking).
109 // Outside the VITEST guard — unlike the log line, recording must happen under
110 // test (so the wiring is exercised) and for eval traffic (which spends real
111 // money), not just in production.
112 if (enriched.usd) void spendStore().record(enriched.usd)
114 
115export interface ChatTurn {
116 role: 'user' | 'assistant'
117 content: string
119 
120/**
121 * Thrown when Claude's own safety classifiers decline a request mid-flight
122 * (Claude 4 returns stop_reason "refusal"). It is NOT an infra error and NOT an
123 * empty answer — it's a model-side block, so callers surface it as a moderation
124 * block, not a "try again" refund. (See Anthropic's streaming-refusals guide.)
125 */
126export class ModelRefusalError extends Error {
127 constructor() {
128 super('The model declined this request (safety refusal).')
129 this.name = 'ModelRefusalError'
130 }
132 
133export interface StructuredRequest {
134 task: AiTask
135 /** The built prompt. Single-shot tasks send it as the whole request; with
136 * `turns` it becomes the system prompt over the conversation. */
137 system: string
138 turns?: ChatTurn[]
139 /** OpenAI requires a schema name; Anthropic ignores it. */
140 schemaName: string
141 schema: Record<string, unknown>
143 
144/**
145 * Runs one structured completion on the task's routed lane and returns the raw
146 * JSON text (trimmed), or null on an empty answer. Throws on transport errors —
147 * callers own their never-throw envelopes, exactly as before the seam.
148 */
149export async function completeStructured(req: StructuredRequest): Promise<string | null> {
150 const { lane, route } = routeFor(req.task)
151 const model = lane === 'anthropic' ? route.anthropic.model : route.openai.model
152 const started = Date.now()
153 try {
154 const result = lane === 'anthropic' ? await viaAnthropic(req) : await viaOpenAI(req)
155 emit({ task: req.task, lane, model, ms: Date.now() - started, ok: true, usage: result.usage })
156 return result.content
157 } catch (error) {
158 emit({ task: req.task, lane, model, ms: Date.now() - started, ok: false })
159 throw error
160 }
162 
163interface LaneResult {
164 content: string | null
165 usage?: AiUsage
167 
168async function viaOpenAI(req: StructuredRequest): Promise<LaneResult> {
169 const { route } = routeFor(req.task)
170 const params = route.openai
171 const client = getOpenAIClient()
172 const completion = await client.chat.completions.create({
173 model: params.model,
174 messages: [
175 { role: 'system' as const, content: req.system },
176 ...(req.turns ?? [])
177 ],
178 // gpt-5.5 is a reasoning model: omit temperature, keep effort low, leave
179 // headroom for hidden reasoning tokens inside the output cap.
180 ...(params.reasoningEffort ? { reasoning_effort: params.reasoningEffort } : {}),
181 max_completion_tokens: params.maxTokens,
182 response_format: {
183 type: 'json_schema',
184 json_schema: { name: req.schemaName, strict: true, schema: req.schema }
185 }
186 })
187 const usage = completion.usage
188 ? {
189 inputTokens: completion.usage.prompt_tokens ?? 0,
190 outputTokens: completion.usage.completion_tokens ?? 0,
191 cacheReadTokens: completion.usage.prompt_tokens_details?.cached_tokens ?? 0
192 }
193 : undefined
194 return { content: completion.choices?.[0]?.message?.content?.trim() || null, usage }
196 
197async function viaAnthropic(req: StructuredRequest): Promise<LaneResult> {
198 const { route } = routeFor(req.task)
199 const params = route.anthropic
200 const client = getAnthropicClient()
201 
202 // Anthropic requires the first message to be a user turn. Single-shot tasks
203 // send the whole built prompt AS the user message (no system param) — for
204 // one-off structured generation the two are equivalent, and it avoids a
205 // contentless "Proceed." turn. Conversational tasks keep system + turns.
206 const turns = req.turns ?? []
207 const system = turns.length ? req.system : undefined
208 const messages: Anthropic.MessageParam[] = turns.length
209 ? (turns[0].role === 'user' ? turns : [{ role: 'user' as const, content: '(The conversation resumes.)' }, ...turns])
210 : [{ role: 'user' as const, content: req.system }]
211 
212 const response = await client.messages.create({
213 model: params.model,
214 max_tokens: params.maxTokens,
215 ...(system ? { system } : {}),
216 messages,
217 // Thinking rejects non-default sampling params — temperature only rides
218 // on thinking-off calls (the family rule the routing table can't express).
219 ...(params.temperature !== undefined && params.thinking !== 'adaptive' ? { temperature: params.temperature } : {}),
220 ...(params.thinking === 'adaptive' ? { thinking: { type: 'adaptive' as const } } : {}),
221 output_config: {
222 ...(params.effort ? { effort: params.effort } : {}),
223 format: { type: 'json_schema' as const, schema: req.schema }
224 }
225 })
226 
227 // Claude's streaming classifiers can decline mid-flight (stop_reason
228 // "refusal"); that's a model-side block, surfaced distinctly so callers
229 // don't mistake it for an empty/infra response. Cast: the union may predate
230 // this SDK's type for the value.
231 if ((response.stop_reason as string) === 'refusal') {
232 throw new ModelRefusalError()
233 }
234 
235 // Thinking blocks (epilogue) precede the text block; take the text.
236 const text = response.content.find(
237 (b): b is Anthropic.TextBlock => b.type === 'text'
238 )?.text?.trim()
239 const usage: AiUsage = {
240 inputTokens: response.usage?.input_tokens ?? 0,
241 outputTokens: response.usage?.output_tokens ?? 0,
242 cacheReadTokens: response.usage?.cache_read_input_tokens ?? 0
243 }
244 return { content: text || null, usage }

But the transport is text-in / JSON-schema-out. TTS is text-in / audio-out on a different OpenAI endpoint, reached through the same openai client the repo already builds. So a TTS lane is a small sibling transport that reuses this plumbing, plus a per-character rate-card entry — because today's cost function is token-only.

getOpenAIClient — already wired from OPENAI_API_KEY; OpenAI TTS reuses it, no new credential.

server/utils/ai-gateway.ts · 245 lines
server/utils/ai-gateway.ts245 lines · TypeScript
⋯ 33 lines hidden (lines 1–33)
1/**
2 * The AI gateway — one transport for every structured model call, dispatched
3 * per task by the routing table (ai-routing.ts). Callers keep their own prompts,
4 * schemas, parsing, guards, and never-throw envelopes; the gateway only decides
5 * WHICH provider runs the request and HOW (model + parameters), and emits the
6 * cost telemetry the go-to-market plan's dispatch 1 requires.
7 *
8 * Telemetry: every call logs one structured line (task, lane, model, tokens
9 * in/out, latency, ok) — intentional production output, suppressed under vitest.
10 * The eval harness subscribes via setAiCallObserver to price bake-off lanes.
11 */
12import OpenAI from 'openai'
13import Anthropic from '@anthropic-ai/sdk'
14import { routeFor, type AiTask, type AiLane } from './ai-routing'
15import { currentRunId } from './run-context'
16import { costUsd } from './cost'
17import { spendStore } from './spend-store'
18 
19let openaiClient: OpenAI | null = null
20let anthropicClient: Anthropic | null = null
21 
22/** Reads a runtime-config key, falling back to raw env outside a Nuxt context
23 * (the eval harness exercises these utils directly in node). */
24function configuredKey(name: 'openaiApiKey' | 'anthropicApiKey', envName: string): string | undefined {
25 let key: string | undefined
26 try {
27 key = useRuntimeConfig()[name] as string | undefined
28 } catch {
29 key = undefined
30 }
31 return key || process.env[envName]
33 
34export function getOpenAIClient(): OpenAI {
35 if (!openaiClient) {
36 const apiKey = configuredKey('openaiApiKey', 'OPENAI_API_KEY')
37 if (!apiKey) throw new Error('OpenAI API key is not configured')
38 openaiClient = new OpenAI({
39 apiKey,
40 // Fail fast rather than hang on the SDK's 10-minute default if a
41 // call stalls (e.g. a bad key or network hiccup).
42 timeout: 30_000,
43 maxRetries: 1
44 })
45 }
46 return openaiClient
⋯ 199 lines hidden (lines 47–245)
48 
49export function getAnthropicClient(): Anthropic {
50 if (!anthropicClient) {
51 const apiKey = configuredKey('anthropicApiKey', 'ANTHROPIC_API_KEY')
52 if (!apiKey) throw new Error('Anthropic API key is not configured')
53 anthropicClient = new Anthropic({
54 apiKey,
55 // The epilogue's adaptive thinking can run long; everything else is
56 // seconds. 60s bounds the worst case without hanging a turn forever.
57 timeout: 60_000,
58 maxRetries: 1
59 })
60 }
61 return anthropicClient
63 
64export interface AiUsage {
65 inputTokens: number
66 outputTokens: number
67 cacheReadTokens: number
69 
70export interface AiCallTelemetry {
71 task: AiTask
72 lane: AiLane
73 model: string
74 ms: number
75 ok: boolean
76 usage?: AiUsage
77 /** The game run this call belongs to (from the request's x-run-id); absent
78 * outside a run-scoped request. The key that groups calls into a run. */
79 runId?: string
80 /** The call's dollar cost from the rate card; absent when usage is missing
81 * (a failed call) or the model has no listed rate. */
82 usd?: number
84 
85type AiCallObserver = (t: AiCallTelemetry) => void
86let observer: AiCallObserver | null = null
87 
88/** The eval harness (and future dashboards) subscribe here; null to clear. */
89export function setAiCallObserver(fn: AiCallObserver | null): void {
90 observer = fn
92 
93function emit(t: AiCallTelemetry): void {
94 // Enrich once, here, so both the success and failure call sites get the run
95 // id, and so the observer (the eval harness) sees the same priced line the
96 // log does. usd needs usage, so it rides only on calls that reported tokens.
97 const enriched: AiCallTelemetry = {
98 ...t,
99 runId: t.runId ?? currentRunId(),
100 usd: t.usd ?? (t.usage ? costUsd(t.model, t.usage, t.lane) : undefined)
101 }
102 observer?.(enriched)
103 // One greppable line per call — the GTM's "structured log line is enough to
104 // start". Intentional production telemetry; quiet under the test runner.
105 if (!process.env.VITEST) {
106 console.info(`[ai] ${JSON.stringify(enriched)}`)
107 }
108 // Feed the spend cap: record this call's cost (best-effort, non-blocking).
109 // Outside the VITEST guard — unlike the log line, recording must happen under
110 // test (so the wiring is exercised) and for eval traffic (which spends real
111 // money), not just in production.
112 if (enriched.usd) void spendStore().record(enriched.usd)
114 
115export interface ChatTurn {
116 role: 'user' | 'assistant'
117 content: string
119 
120/**
121 * Thrown when Claude's own safety classifiers decline a request mid-flight
122 * (Claude 4 returns stop_reason "refusal"). It is NOT an infra error and NOT an
123 * empty answer — it's a model-side block, so callers surface it as a moderation
124 * block, not a "try again" refund. (See Anthropic's streaming-refusals guide.)
125 */
126export class ModelRefusalError extends Error {
127 constructor() {
128 super('The model declined this request (safety refusal).')
129 this.name = 'ModelRefusalError'
130 }
132 
133export interface StructuredRequest {
134 task: AiTask
135 /** The built prompt. Single-shot tasks send it as the whole request; with
136 * `turns` it becomes the system prompt over the conversation. */
137 system: string
138 turns?: ChatTurn[]
139 /** OpenAI requires a schema name; Anthropic ignores it. */
140 schemaName: string
141 schema: Record<string, unknown>
143 
144/**
145 * Runs one structured completion on the task's routed lane and returns the raw
146 * JSON text (trimmed), or null on an empty answer. Throws on transport errors —
147 * callers own their never-throw envelopes, exactly as before the seam.
148 */
149export async function completeStructured(req: StructuredRequest): Promise<string | null> {
150 const { lane, route } = routeFor(req.task)
151 const model = lane === 'anthropic' ? route.anthropic.model : route.openai.model
152 const started = Date.now()
153 try {
154 const result = lane === 'anthropic' ? await viaAnthropic(req) : await viaOpenAI(req)
155 emit({ task: req.task, lane, model, ms: Date.now() - started, ok: true, usage: result.usage })
156 return result.content
157 } catch (error) {
158 emit({ task: req.task, lane, model, ms: Date.now() - started, ok: false })
159 throw error
160 }
162 
163interface LaneResult {
164 content: string | null
165 usage?: AiUsage
167 
168async function viaOpenAI(req: StructuredRequest): Promise<LaneResult> {
169 const { route } = routeFor(req.task)
170 const params = route.openai
171 const client = getOpenAIClient()
172 const completion = await client.chat.completions.create({
173 model: params.model,
174 messages: [
175 { role: 'system' as const, content: req.system },
176 ...(req.turns ?? [])
177 ],
178 // gpt-5.5 is a reasoning model: omit temperature, keep effort low, leave
179 // headroom for hidden reasoning tokens inside the output cap.
180 ...(params.reasoningEffort ? { reasoning_effort: params.reasoningEffort } : {}),
181 max_completion_tokens: params.maxTokens,
182 response_format: {
183 type: 'json_schema',
184 json_schema: { name: req.schemaName, strict: true, schema: req.schema }
185 }
186 })
187 const usage = completion.usage
188 ? {
189 inputTokens: completion.usage.prompt_tokens ?? 0,
190 outputTokens: completion.usage.completion_tokens ?? 0,
191 cacheReadTokens: completion.usage.prompt_tokens_details?.cached_tokens ?? 0
192 }
193 : undefined
194 return { content: completion.choices?.[0]?.message?.content?.trim() || null, usage }
196 
197async function viaAnthropic(req: StructuredRequest): Promise<LaneResult> {
198 const { route } = routeFor(req.task)
199 const params = route.anthropic
200 const client = getAnthropicClient()
201 
202 // Anthropic requires the first message to be a user turn. Single-shot tasks
203 // send the whole built prompt AS the user message (no system param) — for
204 // one-off structured generation the two are equivalent, and it avoids a
205 // contentless "Proceed." turn. Conversational tasks keep system + turns.
206 const turns = req.turns ?? []
207 const system = turns.length ? req.system : undefined
208 const messages: Anthropic.MessageParam[] = turns.length
209 ? (turns[0].role === 'user' ? turns : [{ role: 'user' as const, content: '(The conversation resumes.)' }, ...turns])
210 : [{ role: 'user' as const, content: req.system }]
211 
212 const response = await client.messages.create({
213 model: params.model,
214 max_tokens: params.maxTokens,
215 ...(system ? { system } : {}),
216 messages,
217 // Thinking rejects non-default sampling params — temperature only rides
218 // on thinking-off calls (the family rule the routing table can't express).
219 ...(params.temperature !== undefined && params.thinking !== 'adaptive' ? { temperature: params.temperature } : {}),
220 ...(params.thinking === 'adaptive' ? { thinking: { type: 'adaptive' as const } } : {}),
221 output_config: {
222 ...(params.effort ? { effort: params.effort } : {}),
223 format: { type: 'json_schema' as const, schema: req.schema }
224 }
225 })
226 
227 // Claude's streaming classifiers can decline mid-flight (stop_reason
228 // "refusal"); that's a model-side block, surfaced distinctly so callers
229 // don't mistake it for an empty/infra response. Cast: the union may predate
230 // this SDK's type for the value.
231 if ((response.stop_reason as string) === 'refusal') {
232 throw new ModelRefusalError()
233 }
234 
235 // Thinking blocks (epilogue) precede the text block; take the text.
236 const text = response.content.find(
237 (b): b is Anthropic.TextBlock => b.type === 'text'
238 )?.text?.trim()
239 const usage: AiUsage = {
240 inputTokens: response.usage?.input_tokens ?? 0,
241 outputTokens: response.usage?.output_tokens ?? 0,
242 cacheReadTokens: response.usage?.cache_read_input_tokens ?? 0
243 }
244 return { content: text || null, usage }

The rate card and costUsd multiply token counts. TTS is per-character — a new entry, not a new model.

server/utils/cost.ts · 51 lines
server/utils/cost.ts51 lines · TypeScript
⋯ 25 lines hidden (lines 1–25)
1/**
2 * The rate card — USD per million tokens, per model — and the per-call cost it
3 * implies. This is the GTM cost instrument's price half: ai-gateway stamps every
4 * `[ai]` line with the dollar cost computed here, so grouping by run id yields
5 * cost-per-run (the p95 that dispatch 1 prices packs at 3-5x against).
6 *
7 * Rates are list prices as of 2026-06 — the ONE number to keep fresh; verify
8 * against current provider pricing before pricing packs. Cache reads bill at
9 * ~0.1x input on Anthropic. The token conventions differ by lane: Anthropic's
10 * input_tokens EXCLUDES cached tokens, OpenAI's prompt_tokens INCLUDES them, so
11 * on the OpenAI lane the cached subset is subtracted out of input to avoid
12 * billing it twice (see ai-gateway's usage mapping).
13 */
14import type { AiLane } from './ai-routing'
15import type { AiUsage } from './ai-gateway'
16 
17/** USD per 1,000,000 tokens. */
18interface Rate {
19 input: number
20 output: number
21 cacheRead: number
23 
24const PER_MILLION = 1_000_000
25 
26const RATES: Record<string, Rate> = {
27 'claude-haiku-4-5': { input: 1, output: 5, cacheRead: 0.1 },
28 'claude-sonnet-4-6': { input: 3, output: 15, cacheRead: 0.3 },
29 'claude-opus-4-8': { input: 15, output: 75, cacheRead: 1.5 },
30 'gpt-5.5': { input: 1.25, output: 10, cacheRead: 0.125 }
⋯ 7 lines hidden (lines 32–38)
32 
33/**
34 * The dollar cost of one call, or undefined for a model with no rate — better an
35 * absent number than a guessed one (a missing line shows up as uncounted rather
36 * than silently low). Rounded to the microdollar; per-call costs are fractions
37 * of a cent and the run total is summed downstream.
38 */
39export function costUsd(model: string, usage: AiUsage, lane: AiLane): number | undefined {
40 const rate = RATES[model]
41 if (!rate) return undefined
42 // OpenAI reports cached tokens inside input; Anthropic reports them apart.
43 const uncachedInput =
44 lane === 'openai' ? Math.max(0, usage.inputTokens - usage.cacheReadTokens) : usage.inputTokens
45 const usd =
46 (uncachedInput * rate.input +
47 usage.cacheReadTokens * rate.cacheRead +
48 usage.outputTokens * rate.output) /
49 PER_MILLION
50 return Math.round(usd * PER_MILLION) / PER_MILLION

Voicing the epilogue voices unmoderated prose

The most consequential correction. The issue claims voicing "adds no new moderation surface" because the prose "already passes the moderation seam." For the epilogue — the MVP — that's false. A turn moderates exactly four model outputs: the figure's reply, their action, and the timeline headline + detail.

moderateAction screens those four fields — and only those.

server/api/send-message.post.ts · 365 lines
server/api/send-message.post.ts365 lines · TypeScript
⋯ 292 lines hidden (lines 1–292)
1import { MAX_MESSAGE_CHARS } from '~/utils/game-config'
2import { toContactMoment, formatContactMoment } from '~/utils/contact-moment'
3import { isContentless } from '~/utils/substance'
4import { nextMomentum, MOMENTUM_MAX } from '~/utils/momentum'
5import {
6 cleanArray,
7 cleanString,
8 clampInt,
9 MAX_ERA_CHARS,
10 MAX_FIGURE_NAME_CHARS,
11 MAX_GROUNDING_CHARS,
12 MAX_HISTORY_ENTRIES,
13 MAX_LEDGER_DETAIL_CHARS,
14 MAX_LEDGER_SWING,
15 MAX_OBJECTIVE_DESC_CHARS,
16 MAX_OBJECTIVE_TITLE_CHARS,
17 MAX_TIMELINE_ENTRIES
18} from '~/server/utils/validate'
19 
20export default defineEventHandler(async (event) => {
21 // Only allow POST requests
22 if (getMethod(event) !== 'POST') {
23 throw createError({
24 statusCode: 405,
25 statusMessage: 'Method Not Allowed'
26 })
27 }
28 
29 const body = await readBody(event)
30 
31 // The client UI bounds these, but a direct POST is untrusted and every field
32 // below feeds a gpt-5.5 prompt — so validate the boundary here, not just in the
33 // store. (Surfaced by the input-validation-gap loop.)
34 if (!body || typeof body.message !== 'string' || !body.message.trim()) {
35 throw createError({
36 statusCode: 400,
37 statusMessage: 'Bad Request: message parameter is required'
38 })
39 }
40 if (body.message.trim().length > MAX_MESSAGE_CHARS) {
41 throw createError({
42 statusCode: 400,
43 statusMessage: `Bad Request: message exceeds ${MAX_MESSAGE_CHARS} characters`
44 })
45 }
46 if (typeof body.figureName !== 'string' || !body.figureName.trim()) {
47 throw createError({
48 statusCode: 400,
49 statusMessage: 'Bad Request: figureName parameter is required'
50 })
51 }
52 
53 const { when = null, figureContext = null, objective = null } = body
54 const message = body.message.trim()
55 // A name is interpolated into every prompt (and the Wikipedia lookup upstream),
56 // so it gets a hard cap like every other prompt-feeding string.
57 const figure = cleanString(body.figureName, MAX_FIGURE_NAME_CHARS)
58 // The grounded contact year (signed: AD positive / BC negative) — anchors the
59 // causal-distance read and the character's world-brief. Optional + untrusted.
60 const figureYear = typeof body.whenSigned === 'number' && Number.isFinite(body.whenSigned)
61 ? clampInt(body.whenSigned, 12000)
62 : undefined
63 // The pinned moment (issue #32) arrives as untrusted integers and is
64 // re-validated here; the display string the prompts see is DERIVED, never
65 // the client's. Sub-year detail is prompt flavor only — every mechanic
66 // below (world-brief, liveness, the wager) still computes on figureYear.
67 const figureMoment = figureYear !== undefined ? toContactMoment(body.whenMonth, body.whenDay) : null
68 const whenLabel = figureYear !== undefined ? formatContactMoment(figureYear, figureMoment) : undefined
69 // The last-stand wager: the client offers it only on the final dispatch; the
70 // doubling is applied after amplification, and the response echoes it.
71 const staked = body.stake === true
72 // The run's PRE-turn momentum (0..4), client-authoritative between turns (no
73 // server run-state today). The server reads it to compute the gains factor and
74 // returns the advanced value; untrusted, so coerce + clamp to the meter (#62).
75 const momentum = Math.max(0, Math.min(MOMENTUM_MAX, Math.floor(Number(body.momentum) || 0)))
76 
77 // Normalize the objective into the context the Timeline Engine needs (coerced +
78 // bounded — a non-string or megabyte field would otherwise reach the prompt raw).
79 const objectiveContext = {
80 title: cleanString(objective?.title, MAX_OBJECTIVE_TITLE_CHARS) || 'Alter the course of history',
81 description: cleanString(objective?.description, MAX_OBJECTIVE_DESC_CHARS) || 'Bend the chain of events toward a better world.',
82 era: cleanString(objective?.era, MAX_ERA_CHARS)
83 }
84 // The objective's far anchor year (signed: AD+/BC−) — the foothold the
85 // causal-chain dial decays toward. Optional + untrusted; absent → no-op.
86 const objectiveAnchorYear = typeof objective?.anchorYear === 'number' && Number.isFinite(objective.anchorYear)
87 ? clampInt(objective.anchorYear, 12000)
88 : undefined
89 
90 // Sanitize the client-supplied ledger + thread: cap the counts and coerce each
91 // field, so a crafted body can't crash a `.map`, forge an unbounded prior
92 // history, or amplify token cost.
93 const timeline = cleanArray(body.timeline, MAX_TIMELINE_ENTRIES).map((raw) => {
94 const e = (raw ?? {}) as Record<string, unknown>
95 return {
96 era: cleanString(e.era, MAX_ERA_CHARS),
97 headline: cleanString(e.headline, MAX_OBJECTIVE_TITLE_CHARS),
98 detail: cleanString(e.detail, MAX_LEDGER_DETAIL_CHARS),
99 progressChange: clampInt(e.progressChange, MAX_LEDGER_SWING),
100 whenSigned: typeof e.whenSigned === 'number' && Number.isFinite(e.whenSigned)
101 ? clampInt(e.whenSigned, 12000)
102 : undefined
103 }
104 })
105 const conversationHistory = cleanArray(body.conversationHistory, MAX_HISTORY_ENTRIES)
106 .map((raw) => (raw ?? {}) as Record<string, unknown>)
107 .filter((m) => (m.sender === 'user' || m.sender === 'ai') && typeof m.text === 'string')
108 .map((m) => ({ sender: m.sender as 'user' | 'ai', text: cleanString(m.text, MAX_MESSAGE_CHARS) }))
109 
110 try {
111 // Paywall enforcement: the run must be charged and owned by this device,
112 // or a forged / unpaid run id could draw free generation. Fails open if
113 // the balance store is unreachable (see run-guard) so an outage never
114 // blocks a legit in-progress run.
115 const { runIsActive } = await import('~/server/utils/run-guard')
116 if (!(await runIsActive(event))) {
117 return {
118 success: false as const,
119 message: 'Run not active',
120 data: { userMessage: message, error: 'This run is no longer active. Start a new run to continue.' }
121 }
122 }
123 
124 const { callCharacterAI, callTimelineAI, callJudgeAI } = await import('~/server/utils/openai')
125 const { rollD20, applyCraftModifier } = await import('~/utils/dice')
126 const { CRAFT_MODIFIER } = await import('~/utils/craft')
127 const { hardBlockCheck, moderateAction } = await import('~/server/utils/moderation')
128 
129 // A blocked turn never burns a message: success:false + data.blocked lets
130 // the store show a distinct, honest "blocked" banner (not an infra retry).
131 const blockedResponse = (reason: string) => ({
132 success: false as const,
133 message: 'Blocked by moderation',
134 data: { userMessage: message, blocked: true as const, moderationReason: reason, error: reason }
135 })
136 
137 // Moderation, stage 1 — a deterministic hard block on the raw dispatch
138 // BEFORE we spend a generation token. The unforgivable categories (sexual
139 // content, anything involving minors, self-harm facilitation) need no
140 // context; the contextual Sentinel runs post-generation over the exchange.
141 const inputBlock = await hardBlockCheck(message, 'turn', 'input')
142 if (inputBlock.blocked) return blockedResponse(inputBlock.block.reason)
143 
144 // Contact gating, enforced authoritatively here (#72 deceased-only + #73
145 // require-grounding): the client payload is untrusted, and a direct POST (or
146 // a send racing the grounding debounce) could otherwise reach a living or
147 // ungrounded figure. Re-ground the name ourselves — cache-backed in the warm
148 // path (the same in-process cache /api/figure just filled), one bounded
149 // external lookup on a cold cache — and:
150 // • resolved + not deceased → block (living, or undatable: fail closed)
151 // • unresolved + transient → retry (a lookup we couldn't complete)
152 // • unresolved + definitive → block (#73: no free-form; reach a real figure)
153 // This gate enforces existence + liveness (the safety floor); a deceased
154 // figure's lifetime window is a client-side UX nicety (anachronism is
155 // mechanically allowed via the wager), so it is deliberately not re-checked here.
156 const { groundFigure, isDeceased } = await import('~/server/utils/figure-grounding')
157 const grounded = await groundFigure(figure)
158 if (grounded.resolved && !isDeceased(grounded)) {
159 return blockedResponse('This figure is still living and cannot be contacted — Revisionist only reaches figures from history.')
160 }
161 if (!grounded.resolved) {
162 return grounded.transient
163 ? {
164 success: false as const,
165 message: 'Could not verify the figure',
166 data: { userMessage: message, error: "Couldn't reach the record to verify who this is — please try again in a moment." }
167 }
168 : blockedResponse('No historical record found for this name — reach for a real, documented figure.')
169 }
170 
171 // The client supplies figureContext (it grounded the figure via /api/figure),
172 // so treat it as untrusted: coerce + bound each field before it becomes a
173 // "fact" in the character system prompt.
174 const grounding = {
175 when: whenLabel ?? (cleanString(when, MAX_ERA_CHARS) || undefined),
176 // The figure should take a pinned moment with period-appropriate
177 // granularity rather than false precision (the prompt line is
178 // conditional so year-only turns stay byte-identical).
179 momentRefined: figureMoment !== null,
180 description: cleanString(figureContext?.description, MAX_GROUNDING_CHARS) || undefined,
181 lifespan: cleanString(figureContext?.lifespan, MAX_ERA_CHARS) || undefined
182 }
183 
184 // The Judge also reads the run's thread — the figure's last reply (their last
185 // revealed condition/price) and a digest of recent ledger headlines — to grade
186 // CONTINUITY: does this dispatch build on what came before? Both are empty on
187 // turn 1, so the Judge prompt stays byte-identical there.
188 const lastFigureReply = [...conversationHistory].reverse().find(m => m.sender === 'ai')?.text ?? ''
189 const ledgerDigest = timeline
190 .filter(e => e.headline)
191 .slice(-5)
192 .map(e => `${e.era ? `[${e.era}] ` : ''}${e.headline}`)
193 
194 // Step 1: The Judge grades the dispatch's craft — the one place player skill
195 // directly tilts fate. A Judge outage grades 'sound' (modifier 0): an infra
196 // hiccup must never tilt the die either way.
197 const judged = await callJudgeAI({
198 objective: objectiveContext,
199 figureName: figure,
200 when: grounding.when,
201 momentRefined: figureMoment !== null,
202 userMessage: message,
203 lastFigureReply,
204 ledgerDigest
205 })
206 const craft = judged.success && judged.judge ? judged.judge.craft : 'sound'
207 const craftReason = judged.success && judged.judge ? judged.judge.reason : ''
208 // Continuity feeds the run-level momentum meter (slice 3); an outage → 'neutral'.
209 const continuity = judged.success && judged.judge ? judged.judge.continuity : 'neutral'
210 const rollModifier = CRAFT_MODIFIER[craft]
211 
212 // Step 2: Roll the dice of fate, tilted by craft (clamped to the die's faces).
213 const natural = rollD20()
214 const diceResult = applyCraftModifier(natural.roll, rollModifier)
215 
216 // Step 3: The chosen figure replies and acts, in character, blind to the goal.
217 // Grounding (when + real facts) anchors the role-play; the world-brief hands
218 // them the altered history their own moment would already know (changes at or
219 // before their year), so a turn-4 figure doesn't role-play the unaltered world.
220 // Headlines only — `detail` narrates downstream ripples (often stamped with
221 // later eras) and would hand the figure the future outright.
222 const worldBrief = figureYear === undefined
223 ? []
224 : timeline
225 .filter(e => typeof e.whenSigned === 'number' && e.whenSigned <= figureYear && e.headline)
226 .slice(-5)
227 .map(e => `${e.era ? `[${e.era}] ` : ''}${e.headline}`)
228 // Keep the figure honest: a contentless dispatch gives them nothing to act
229 // on, so even a favorable roll yields confusion, not a fabricated deed (#62).
230 const contentless = isContentless(message)
231 const character = await callCharacterAI(figure, message, conversationHistory, diceResult.outcome, grounding, worldBrief, contentless)
232 if (!character.success || !character.characterResponse) {
233 // A model-side safety refusal is a block, not an infra hiccup.
234 if (character.refused) return blockedResponse(character.error || 'This dispatch was declined by content moderation and cannot be sent.')
235 return {
236 success: false,
237 message: 'Failed to generate character response',
238 data: {
239 userMessage: message,
240 error: character.error || 'Character AI failed'
241 }
242 }
243 }
244 
245 const reply = character.characterResponse
246 
247 // Enforce the message-length constraint on the figure's reply.
248 if (reply.message && reply.message.length > MAX_MESSAGE_CHARS) {
249 console.warn(`Figure reply exceeded ${MAX_MESSAGE_CHARS} characters (${reply.message.length}); truncating.`)
250 reply.message = reply.message.substring(0, MAX_MESSAGE_CHARS - 3) + '...'
251 }
252 
253 // Step 4: The Timeline Engine judges how the action ripples toward the objective.
254 const timelineResult = await callTimelineAI({
255 objective: objectiveContext,
256 figureName: figure,
257 era: reply.era || objectiveContext.era,
258 userMessage: message,
259 characterMessage: reply.message,
260 characterAction: reply.action,
261 diceRoll: diceResult.roll,
262 diceOutcome: diceResult.outcome,
263 timeline,
264 figureYear,
265 figureMoment: figureMoment ? whenLabel : undefined,
266 objectiveAnchorYear,
267 craft,
268 momentum
269 })
270 
271 if (!timelineResult.success || !timelineResult.ripple) {
272 if (timelineResult.refused) return blockedResponse(timelineResult.error || 'This dispatch was declined by content moderation and cannot be sent.')
273 return {
274 success: false,
275 message: 'Failed to generate timeline analysis',
276 data: {
277 userMessage: message,
278 diceRoll: diceResult.roll,
279 diceOutcome: diceResult.outcome,
280 characterResponse: { message: reply.message, action: reply.action },
281 error: timelineResult.error || 'Timeline AI failed'
282 }
283 }
284 }
285 
286 const ripple = timelineResult.ripple
287 
288 // Moderation, stage 2 — the contextual Sentinel reviews the WHOLE exchange
289 // (full thread + this dispatch + the figure's reply and the timeline's
290 // narration) against the verbatim Usage Policy, and the detector hard-blocks
291 // the outputs. A block here suppresses the reveal: the generated text never
292 // ships, and the turn is refunded with an honest banner.
293 const moderation = await moderateAction({
294 surface: 'turn',
295 objective: { title: objectiveContext.title, description: objectiveContext.description },
296 figureName: figure,
297 era: reply.era || objectiveContext.era,
298 conversation: conversationHistory.map(m => ({ role: m.sender === 'user' ? 'user' : 'assistant', text: m.text })),
299 userText: message,
300 outputs: [reply.message, reply.action, ripple.headline, ripple.detail].filter((t): t is string => !!t)
301 })
302 if (moderation.blocked) return blockedResponse(moderation.block.reason)
⋯ 63 lines hidden (lines 303–365)
303 
304 // The last stand: a staked dispatch doubles its resolved swing, both ways,
305 // and may escape the per-turn fuse up to the full meter. The valence badge
306 // is re-asserted AFTER the doubling — the sign-guard inside callTimelineAI
307 // ran on the pre-stake value, and a doubled swing must wear its own color.
308 const { applyStake, VALENCE_SIGN_GUARD_MIN } = await import('~/utils/swing-bands')
309 const finalProgressChange = staked ? applyStake(ripple.progressChange) : ripple.progressChange
310 const finalValence = staked && Math.abs(finalProgressChange) > VALENCE_SIGN_GUARD_MIN
311 ? (finalProgressChange > 0 ? 'positive' as const : 'negative' as const)
312 : ripple.valence
313 
314 // Advance the run-level momentum meter from this turn's continuity + roll; the
315 // client overwrites its meter from this on ingest (issue #62).
316 const nextM = nextMomentum(momentum, continuity, diceResult.outcome)
317 
318 return {
319 success: true,
320 message: 'Timeline updated',
321 data: {
322 userMessage: message,
323 figure: {
324 name: figure,
325 era: reply.era,
326 descriptor: reply.persona
327 },
328 // The effective (craft-tilted) roll drives the bands and the UI; the
329 // natural roll + modifier ride along so the reveal can show the tilt.
330 diceRoll: diceResult.roll,
331 diceOutcome: diceResult.outcome,
332 naturalRoll: natural.roll,
333 rollModifier,
334 craft,
335 craftReason,
336 continuity,
337 momentum: nextM,
338 staked,
339 characterResponse: { message: reply.message, action: reply.action },
340 timeline: {
341 headline: ripple.headline,
342 detail: ripple.detail,
343 era: reply.era || objectiveContext.era,
344 progressChange: finalProgressChange,
345 baseProgressChange: ripple.baseProgressChange,
346 // The PRE-turn momentum that amplified this swing — for the
347 // reveal's equation (distinct from data.momentum, the new meter).
348 momentumAtSwing: momentum,
349 valence: finalValence,
350 anachronism: ripple.anachronism,
351 causalChain: ripple.causalChain
352 },
353 error: null
354 }
355 }
356 } catch (error) {
357 // Log the detail server-side; the wire gets a clean line (internal error
358 // text — stack hints, key issues, SDK messages — is not for the client).
359 console.error('API endpoint error:', error)
360 throw createError({
361 statusCode: 500,
362 statusMessage: 'Internal Server Error'
363 })
364 }
365})

The Chronicle and the epilogue are not in that list. Their endpoint fires the Chronicler and returns the prose untouched — no moderation call anywhere in it.

The chronicle/epilogue path: synthesize prose, return it. No moderateAction.

server/api/chronicle.post.ts · 78 lines
server/api/chronicle.post.ts78 lines · TypeScript
⋯ 59 lines hidden (lines 1–59)
1import {
2 cleanArray,
3 cleanString,
4 clampInt,
5 MAX_ERA_CHARS,
6 MAX_LEDGER_DETAIL_CHARS,
7 MAX_LEDGER_SWING,
8 MAX_OBJECTIVE_DESC_CHARS,
9 MAX_OBJECTIVE_TITLE_CHARS,
10 MAX_TIMELINE_ENTRIES
11} from '~/server/utils/validate'
12import type { ChronicleStatus } from '~/server/utils/prompt-builder'
13import { MAX_PROGRESS } from '~/utils/game-config'
14 
15const STATUSES: ChronicleStatus[] = ['playing', 'victory', 'defeat']
16 
17/**
18 * POST /api/chronicle — the Chronicler (Layer 3). Given the live objective, the
19 * running ledger, and the run's status, it narrates the altered timeline as it now
20 * stands. The client fires this non-blocking after every resolved turn, so the
21 * dice/progress reveal never waits on prose; the final telling is the epilogue.
22 *
23 * Like /api/send-message, every field below feeds a gpt-5.5 prompt, so the untrusted
24 * body is coerced + bounded here (the client caps live only in the store, which a raw
25 * POST bypasses). A model failure self-heals to `{ success: false }` — the caller
26 * keeps the prior chronicle rather than blanking the panel.
27 */
28export default defineEventHandler(async (event) => {
29 if (getMethod(event) !== 'POST') {
30 throw createError({ statusCode: 405, statusMessage: 'Method Not Allowed' })
31 }
32 
33 const body = await readBody(event)
34 
35 const objectiveContext = {
36 title: cleanString(body?.objective?.title, MAX_OBJECTIVE_TITLE_CHARS) || 'Alter the course of history',
37 description: cleanString(body?.objective?.description, MAX_OBJECTIVE_DESC_CHARS) || 'Bend the chain of events toward a better world.',
38 era: cleanString(body?.objective?.era, MAX_ERA_CHARS)
39 }
40 
41 // Sanitize the client-supplied ledger: cap the count and coerce each field, so a
42 // crafted body can't crash a `.map`, forge an unbounded prior history, or amplify
43 // token cost. (Mirrors send-message.post.ts.)
44 const timeline = cleanArray(body?.timeline, MAX_TIMELINE_ENTRIES).map((raw) => {
45 const e = (raw ?? {}) as Record<string, unknown>
46 return {
47 era: cleanString(e.era, MAX_ERA_CHARS),
48 figureName: cleanString(e.figureName, MAX_ERA_CHARS),
49 headline: cleanString(e.headline, MAX_OBJECTIVE_TITLE_CHARS),
50 detail: cleanString(e.detail, MAX_LEDGER_DETAIL_CHARS),
51 progressChange: clampInt(e.progressChange, MAX_LEDGER_SWING)
52 }
53 })
54 
55 const status: ChronicleStatus = STATUSES.includes(body?.status) ? body.status : 'playing'
56 const progress = Math.max(0, Math.min(MAX_PROGRESS, clampInt(body?.progress, MAX_PROGRESS)))
57 
58 try {
59 // Paywall enforcement: don't spend the chronicler's large generation on a
60 // forged / unpaid run. Graceful — the caller keeps the prior telling.
61 const { runIsActive } = await import('~/server/utils/run-guard')
62 if (!(await runIsActive(event))) {
63 return { success: false, error: 'Run not active' }
64 }
65 
66 const { callChroniclerAI } = await import('~/server/utils/openai')
67 const result = await callChroniclerAI({ objective: objectiveContext, timeline, status, progress })
68 
69 if (!result.success || !result.chronicle) {
70 return { success: false, error: result.error || 'Chronicle AI failed' }
71 }
72 return { success: true, chronicle: result.chronicle }
73 } catch (error) {
⋯ 5 lines hidden (lines 74–78)
74 console.error('Chronicle endpoint error:', error)
75 // Log the detail server-side; the wire gets a clean line.
76 throw createError({ statusCode: 500, statusMessage: 'Internal Server Error' })
77 }
78})

Supabase Storage is not actually wired

The issue calls Supabase Storage "already in the stack" and proposes it as the audio cache's home. Supabase is in the stack — for auth and Postgres. The config wires a URL, a key, and a redirect; nothing configures or touches a storage bucket anywhere in the repo.

Supabase: auth config only. The private runtimeConfig holds the model keys, no storage.

nuxt.config.ts · 75 lines
nuxt.config.ts75 lines · TypeScript
⋯ 27 lines hidden (lines 1–27)
1/**
2 * Nuxt 3 Configuration
3 * https://nuxt.com/docs/api/configuration/nuxt-config
4 */
5export default defineNuxtConfig({
6 // Ensure compatibility with modern Nuxt features
7 compatibilityDate: '2025-05-15',
8 
9 // Enable devtools for development
10 devtools: { enabled: true },
11 
12 // Register required modules
13 modules: ['@nuxt/ui', '@pinia/nuxt', '@nuxtjs/supabase'],
14 
15 // Supabase Auth (accounts). Anonymous-first: every visitor gets an anonymous
16 // user so the balance keys on a real user id from the first second; signing in
17 // with a magic link upgrades that same user in place (balance carries over).
18 // redirect:false — we DON'T gate play behind sign-in (anonymous play is the
19 // free-trial hook); buying is what requires an account. It also means the module
20 // registers no /confirm callback route, so the magic-link return is finalized in
21 // pages/play.vue (see utils/auth-return.ts). For that return to arrive, the
22 // Supabase project's Auth → URL Configuration → Redirect URLs must allowlist the
23 // deployed origin's /play (e.g. https://<origin>/play); otherwise Supabase falls
24 // back to the Site URL and the link never reaches the handler. The client key is
25 // the PUBLISHABLE key (safe to ship); the service key stays server-only in the
26 // balance store. Placeholders keep `nuxt build` working in CI without secrets;
27 // the real values come from NUXT_PUBLIC_SUPABASE_URL / _KEY at runtime.
28 supabase: {
29 url: process.env.SUPABASE_URL || 'https://placeholder.supabase.co',
30 key: process.env.SUPABASE_PUBLISHABLE_KEY || 'sb_publishable_placeholder',
31 redirect: false,
32 // We don't use generated DB types (the balance store talks to Supabase with the
33 // service client directly); disable the lookup so the module doesn't warn.
34 types: false
35 },
⋯ 16 lines hidden (lines 36–51)
36 
37 // Global CSS imports
38 css: ['~/assets/css/main.css'],
39 
40 // Color mode (via @nuxtjs/color-mode, bundled with @nuxt/ui). classSuffix '' so the
41 // class on <html> is exactly `dark` / `light` — what the .dark token block and
42 // Tailwind's dark: variant key off. Defaulting to `system` respects the OS pref, and
43 // the module's inline no-flash script + cookie persistence replace the old manual
44 // classList toggle (which didn't persist and could FOUC).
45 colorMode: {
46 classSuffix: '',
47 preference: 'system',
48 fallback: 'light'
49 },
50 
51 // Runtime configuration
52 runtimeConfig: {
53 // Private keys (only available on server-side)
54 openaiApiKey: process.env.OPENAI_API_KEY,
55 anthropicApiKey: process.env.ANTHROPIC_API_KEY,
⋯ 20 lines hidden (lines 56–75)
56 // Supabase — the run store (server-side only). Absent → in-memory dev store.
57 supabaseUrl: process.env.SUPABASE_URL,
58 supabaseSecretKey: process.env.SUPABASE_SECRET_KEY,
59 // Stripe — run packs (server-side only). Absent → checkout returns 503.
60 stripeSecretKey: process.env.STRIPE_SECRET_KEY,
61 stripeWebhookSecret: process.env.STRIPE_WEBHOOK_SECRET,
62 // Canonical site origin for Stripe redirect URLs (never the client Host header).
63 siteUrl: process.env.REVISIONIST_BASE_URL,
64 // Public keys (exposed to client-side)
65 public: {}
66 },
67 
68 // The standalone agents/ tooling package has its own deps + tsconfig; keep it
69 // out of the app's type program so `nuxt typecheck` never compiles the loops.
70 typescript: {
71 tsConfig: {
72 exclude: ['../agents']
73 }
74 }
75})

The recommendation, and why it follows

For a cached surface, latency stops being decisive — you generate once and replay from cache forever. That pivot (away from the issue's latency-first framing) is what tilts the pick toward OpenAI and reframes the fallback as a quality choice, not a latency one.

OpenAI gpt-4o-mini-ttsElevenLabsGoogle Chirp 3 HD
In the repo already?yes — SDK + key wiredno (new SDK + paid acct)no (new GCP dep)
Steerable narratoryes (instructions)no (Flash); v3 is offlineGemini TTS yes
Commercial termsown output, no attributionpaid plan requiredown output (GCP)
Best forthe v1 pick, least opsa bespoke premium voicea free tier
Vendor figures re-verified against live official sources on 2026-06-17.

The restated v1: surface, vendor, seam, cache, safety.

docs/design/text-to-speech-narration.md · 342 lines
docs/design/text-to-speech-narration.md342 lines · Markdown
⋯ 290 lines hidden (lines 1–290)
1# Text-to-Speech Narration — giving the Chronicle a voice, epilogue-first
2 
3> **Design exploration for [#93](https://github.com/mseeks/revisionist/issues/93).** This is
4> a recommendation, not an implementation. No TTS integration, no audio pipeline, no vendor
5> commitment — just the design space, a recommended v1, and the implementation issues this
6> note should spawn. Vendor pricing and model names were **re-verified against live official
7> sources on 2026-06-17** (see [Vendor landscape](#vendor-landscape-re-verified-2026-06-17));
8> they drift, so re-check before building. For what is true in the code today, read
9> [`../../CLAUDE.md`](../../CLAUDE.md); for forward intent, [`../ROADMAP.md`](../ROADMAP.md).
10> This is the **voice** half of giving the game a soundtrack; [#92](https://github.com/mseeks/revisionist/issues/92)
11> (soundscape SFX) is the **sound** half, a separate exploration.
12 
13## TL;DR — the recommendation
14 
15Voice the **epilogue first**: generate it on demand at the verdict screen, then cache it by
16a content hash so a re-listen or a shared run costs nothing. Use **OpenAI `gpt-4o-mini-tts`**
17— it reuses the `openai` SDK and key already in the repo (zero new dependency, zero new
18credential), it's steerable (you can prompt a grave, measured historian's voice without
19commissioning one), and its commercial terms are clean. Defer the rest.
20 
21| v1 (ship) | v2 (next) | Deferred / never |
22|---|---|---|
23| **Voice the epilogue** (on-demand → cache) | **Opt-in "listen"** on the per-turn Chronicle | Auto-voicing the Chronicle every turn (the cost + latency trap) |
24| **A TTS lane** through the AI seam (cost on the same `[ai]` line) | **Pre-generate** the 10 curated objective briefs | Per-figure character voices (charming, but multiplies calls + complexity) |
25| **Content-hash audio cache** (Supabase Storage bucket) | User-selectable narrator voice (ties to [#90](https://github.com/mseeks/revisionist/issues/90)) | Voicing timeline headlines/details (too granular for the value) |
26| **Moderate the epilogue text before voicing** (closes an existing gap) | Carry cached audio with a **shared run** ([#88](https://github.com/mseeks/revisionist/issues/88)/[#89](https://github.com/mseeks/revisionist/issues/89)) | A commissioned/cloned bespoke Chronicler voice |
27 
28Five load-bearing decisions, each argued below:
29 
301. **The epilogue is the sweet spot, and the code already isolates it.** It's produced once
31 per run by its *own* AI task (`epilogue`, distinct from `chronicler`), flagged in-code as
32 "the share artifact and the thing people pay for." On-demand-then-cache fits it perfectly:
33 highest value, lowest pressure.
342. **TTS does not ride the existing seam — it sits beside it.** The gateway's transport is
35 text-in / JSON-out; TTS is text-in / audio-out on a *separate* OpenAI endpoint. A TTS lane
36 is a small sibling transport that **reuses the same cost telemetry**, so TTS spend shows up
37 on the same `[ai]` line and counts against the same cap.
383. **Caching is the biggest lever, bigger than vendor choice.** Key by a content hash of
39 `text + voice + model + format`; an identical re-render costs zero. This needs a Supabase
40 **Storage bucket**, which is **not wired today** (only auth + Postgres are).
414. **Voicing the epilogue voices currently-unmoderated prose.** The Chronicle and epilogue
42 bypass the moderation seam today (only the figure's reply and the timeline headline/detail
43 pass it). Routing the epilogue text through `moderateAction` before voicing is a v1
44 prerequisite, not an afterthought.
455. **For a cached surface, latency barely matters — quality and ops do.** You generate once
46 and replay from cache forever, so the deciding factors are voice character, commercial
47 cleanliness, and operational simplicity. That tilts the choice toward OpenAI (already in
48 the stack) and reframes the fallback as a *quality* upgrade, not a latency one.
49 
50## Today: where the prose comes from
51 
52The game is dense with generated prose, and the [ROADMAP](../ROADMAP.md)'s four-layer AI is
53the source of all of it. Five surfaces render it; voicing is a question of *which* and *when*,
54and the answer differs per surface because the code does.
55 
56| Surface | Component | AI task | Store field | Volume | Moderated today? |
57|---|---|---|---|---|---|
58| **Epilogue** (final telling) | [`EndGameScreen.vue`](../../components/EndGameScreen.vue) | **`epilogue`** | `gameStore.chronicle` | ~once / run, ~400–800 chars | **no** |
59| Chronicle (per-turn retelling) | [`Chronicle.vue`](../../components/Chronicle.vue) | **`chronicler`** | `gameStore.chronicle` | every turn, regenerated | **no** |
60| Figure replies | [`MessageHistory.vue`](../../components/MessageHistory.vue) | `character` | `messageHistory[].text` (+ `figureName`) | per turn, ≤160 chars | yes (`reply.message`) |
61| Objective brief | [`MissionSelect.vue`](../../components/MissionSelect.vue) | curated + `objective` | `objectives.ts` / fetched | 10 curated (fixed) + composed | curated: human-authored |
62| Timeline headline/detail | [`TimelineLedger.vue`](../../components/TimelineLedger.vue) | `timeline` | `timelineEvents[]` | per turn, short | yes (`ripple.headline/detail`) |
63 
64Three facts here matter more than the issue's framing implied, and they sharpen the plan:
65 
66- **The epilogue is its own AI task, not a re-skin of the Chronicle.** Both write to the same
67 `gameStore.chronicle` slot (a `ChronicleEntry` of `title` + `paragraphs[]`), and the
68 *distinction is the task*: `callChroniclerAI` routes `status === 'playing'` to `chronicler`
69 and victory/defeat to **`epilogue`** ([`openai.ts:535`](../../server/utils/openai.ts)). The
70 routing table even annotates `epilogue` as *"the share artifact and the thing people pay
71 for"* ([`ai-routing.ts:105`](../../server/utils/ai-routing.ts)). So "voice the epilogue"
72 maps cleanly onto a seam the code already draws — you voice the output of one named task,
73 fired once, at a natural pause.
74- **The epilogue is short** — ~400–800 chars (two to four tight paragraphs), not the issue's
75 ~1–3K-char guess. That makes it *cheaper* to voice than the issue assumed (about a penny a
76 run; see [Costs](#costs)).
77- **The "closing movement" is a prompt instruction, not a separable field.** Victory prose is
78 asked to "END on a distinct closing movement" ([`prompt-builder.ts:314`](../../server/utils/prompt-builder.ts)),
79 but it arrives as one undivided `paragraphs[]`. So you can't cheaply voice "just the new
80 movement" of a Chronicle — you voice the whole current telling. This is exactly why the
81 per-turn Chronicle is the surface to make **opt-in**, not automatic.
82 
83## The crux: what to voice, and when
84 
85This was never one decision. It's per surface, and the game's own structure makes most of the
86answers obvious.
87 
88| Surface | When to voice | Why |
89|---|---|---|
90| **Epilogue** | ⭐ **on-demand → cache** | Once per run, read at the verdict pause. The share artifact and the paid surface. Highest value, lowest pressure. **The MVP.** |
91| Chronicle (per-turn) | **opt-in "listen" button** | Re-narrates the *whole* timeline every turn and is regenerated each turn — voicing it blindly is expensive *and* slow on every turn, and it can't be voiced "newest-only." Make it a button on the current telling. |
92| Objective brief | **pre-generate the 10 curated; on-demand for composed** | The curated pool is fixed and enumerable ([`objectives.ts`](../../server/utils/objectives.ts), 10 entries) — render them once at build/seed time and ship the audio. Live-composed briefs voice on demand, then cache. |
93| Figure replies | **defer past v1** | Ambitious (a distinct voice per figure — `figureName` is attached, so it's *possible*), but it multiplies calls and cost and adds real complexity. |
94| Timeline headline/detail | **skip v1** | Short, frequent, low marginal charm for the call volume. |
95 
96**The Chronicle is the cost/latency trap, by design.** The Chronicler "narrates the WHOLE
97altered timeline" and "is rewritten every turn"
98([`prompt-builder.ts:273`](../../server/utils/prompt-builder.ts)). Voicing that on every turn means
99multi-paragraph audio regenerated five times a run, each with a latency hit — the textbook
100"on-demand, regenerated often" case to avoid. A **"listen" button** turns it into a
101deliberate, cacheable request.
102 
103## Architecture fit: a TTS lane beside the AI seam
104 
105The issue says TTS "fits as a new task/lane through the same seam, instrumented the same way."
106That's *almost* right, and the gap is worth stating precisely, because it changes the v1
107scope.
108 
109**What's true:** every model call already routes through a per-task seam
110([`ai-gateway.ts`](../../server/utils/ai-gateway.ts) / [`ai-routing.ts`](../../server/utils/ai-routing.ts))
111that stamps **one `[ai]` cost line per call** — task, lane, model, tokens, latency, run id,
112and a dollar cost from the rate card — and records that cost to a spend cap. The rate card's
113own header calls itself "the GTM cost instrument's price half"
114([`cost.ts:2-4`](../../server/utils/cost.ts)). Per-call cost is a deliberate product lever,
115and TTS cost should join it.
116 
117**What's not true:** TTS can't ride the *transport*. `completeStructured` is hardwired
118text-in / JSON-schema-out (`turns` + `response_format`), and OpenAI's TTS is a *different*
119endpoint (`audio.speech.create`) that returns audio bytes. So a TTS lane is a **small sibling
120transport** next to `completeStructured`, not a new row that flows through it. Concretely, v1
121needs:
122 
1231. **A `synthesizeSpeech` transport** that calls `audio.speech.create` on the **same**
124 `getOpenAIClient()` ([`ai-gateway.ts:34`](../../server/utils/ai-gateway.ts)), and emits the
125 **same** `[ai]` telemetry via the existing `emit()` + `spendStore().record()`. Reuse the
126 plumbing; only the call shape differs.
1272. **A `tts` task in the routing table** — but **single-lane**. The table assumes every task
128 has both an `anthropic` and an `openai` route so `REVISIONIST_AI_LANE` can force-flip them;
129 Claude has no TTS, so a `tts` task can't honor a flip to Anthropic. Either pin `tts` to its
130 provider or let the flip skip tasks with no alternate lane.
1313. **A per-character rate-card entry.** `costUsd` today multiplies input/output/cached *text*
132 tokens by per-million rates ([`cost.ts:39-51`](../../server/utils/cost.ts)). TTS is billed
133 per character (`tts-1`/`tts-1-hd`) or per audio token (`gpt-4o-mini-tts`), so the rate card
134 gains a TTS entry and the telemetry's `usage` carries characters (or audio tokens). Small,
135 self-contained — and it keeps every TTS render visible on the same `[ai]` line and inside
136 the same spend cap.
137 
138None of this is a large refactor, but it is **more than "add a row to a table."** The v1
139foundation issue is exactly this lane.
140 
141## Vendor landscape (re-verified 2026-06-17)
142 
143Re-checked against each vendor's live pricing/docs. **The issue's headline figures held up;
144the corrections are in the model *names* and a few framing points.** Full sources in the
145spawned issues.
146 
147| Dimension | **OpenAI** `gpt-4o-mini-tts` | **ElevenLabs** Flash v2.5 / v3 | **Google** Chirp 3 HD |
148|---|---|---|---|
149| Price | **$0.60/1M input + $12/1M audio-output tokens** (≈ a penny per epilogue) | Flash **$0.05/1K chars**; v3/Multilingual **$0.10/1K chars** (paid plan) | **$30/1M chars** ($0.03/1K) + **1M chars/mo free, indefinitely** |
150| Steerable delivery | **yes** — free-text `instructions` (tone, pace, gravity) | no | Gemini TTS is steerable; Chirp 3 HD is not |
151| Quality | strong; 13 voices | top-tier; **10,000+ voice library** + consent-gated cloning | strong; large catalog |
152| Streaming | yes (HTTP chunked) | yes (HTTP + WebSocket) | REST + partial streaming |
153| Commercial use | **unrestricted**, you own output, no attribution, caching OK | **paid plan required** (free = non-commercial + attribution) | standard GCP terms, you own output |
154| In the repo already? | **yes — `openai` SDK + `OPENAI_API_KEY` are wired** | no (new SDK + new paid account + new key) | no (new GCP dep + service account) |
155| Best for | the steerable narrator, **least ops** | a bespoke premium Chronicler voice | a free tier + clean ownership |
156 
157Verification notes (corrections to the issue):
158 
159- **OpenAI prices confirmed exactly.** `tts-1` = $0.015/1K chars, `tts-1-hd` = $0.03/1K chars,
160 `gpt-4o-mini-tts` = $0.60/1M input + $12/1M audio-output tokens. Only `gpt-4o-mini-tts` is
161 steerable (the `instructions` param); `tts-1`/`tts-1-hd` are not. The "~$0.015/min" is
162 OpenAI's *own* convenience estimate, not purely third-party — and note it numerically equals
163 `tts-1`'s per-1K-char price by coincidence; different units, different models, don't
164 conflate.
165- **ElevenLabs prices confirmed.** Flash v2.5 ≈ $0.05/1K chars (0.5 credit/char), v3 /
166 Multilingual v2 ≈ $0.10/1K chars (1 credit/char) on a paid plan. The headline unit is
167 *credits*; the $/1K is plan-dependent. Flash's "~75 ms" is **inference-only** (the docs'
168 own footnote excludes app + network latency) — not a real player-experienced TTFB. **v3 is
169 not real-time** (its WebSocket stream-input endpoint doesn't even support v3), which is fine
170 for a pre-rendered, cached epilogue. Commercial use **requires a paid plan** (the entry
171 Starter tier, a few dollars a month).
172- **Challengers' model names are stale in the issue.** Cartesia's live model is **Sonic-3.5**
173 (not "Sonic 2/Turbo"), ~$0.037–0.039/1K chars, ~188 ms P50 in production — and its
174 commercial use is **tier-gated** (Pro+). Deepgram **Aura-2** is confirmed at $0.03/1K chars,
175 sub-200 ms, but English-centric. **Google Chirp 3 HD** is the strongest cost play: $30/1M
176 chars with an **indefinite 1M-char/month free tier** and clean GCP ownership — its only
177 cost against KISS here is a new GCP dependency.
178 
179## Costs
180 
181The epilogue is short — call it ~600 chars (estimated from its two-to-four-short-paragraph
182schema; there's no hard char cap in code, so treat the figures below as estimates), ≈ 30–50 s
183of speech:
184 
185- **Epilogue, once per run:****$0.01** (OpenAI), ≈ $0.03 (ElevenLabs Flash). Trivial — and
186 *cached*, so a re-listen or a shared run is **$0**. For reference, that's a rounding error
187 against the run's existing per-turn Claude spend (five turns, several Haiku/Sonnet calls
188 each), which the same `[ai]` line already tracks.
189- **Chronicle, every turn, on-demand:** ~5–10K chars/run ≈ **$0.15–0.50/run** *plus* a latency
190 hit each turn — the case to make opt-in and cache, never automatic.
191- **Curated objective briefs:** 10 × ~400 chars, rendered **once** at build/seed time and
192 shipped as static audio — effectively $0 amortized.
193 
194At this volume, **cost is not the deciding factor** — latency and quality are, and for a
195*cached* surface even latency falls away. Cost only bites at scale (the issue estimates
196~100K turns/mo at roughly $1.5K OpenAI vs $5–10K ElevenLabs if the Chronicle were voiced
197every turn — the strongest argument for caching and for keeping the Chronicle opt-in), and
198there the same `[ai]` instrument that prices the text lanes will price TTS too.
199 
200## Latency & streaming (only the on-demand surfaces)
201 
202The issue rightly stresses that **streaming is essential for on-demand voicing** — start
203playback on the first chunk, not the whole clip — and that TTFB should be benchmarked before
204committing. For the **cached epilogue, this mostly dissolves**: it's generated once and
205replayed from cache, so first-render latency is paid once and a re-listen is instant. But the
206two surfaces the plan keeps *on demand* still live under the issue's concern:
207 
208- The **opt-in Chronicle "listen"** and **live-composed objective briefs** are uncached on
209 first hit. Both should **stream** (both OpenAI and ElevenLabs support it — OpenAI via HTTP
210 chunked transfer, ElevenLabs via `convert-as-stream` / WebSocket), and their **TTFB is worth
211 a quick benchmark** before shipping. This is the one place ElevenLabs Flash v2.5's
212 lower-latency profile could matter — though for the cached MVP it does not.
213- **Concurrency caps** bind only if many players voice on demand at the same moment
214 (ElevenLabs is 4–40 concurrent by plan; OpenAI's audio calls draw on a rate-limit pool). A
215 non-issue for a once-per-run cached epilogue; worth a glance before the opt-in surfaces ship
216 to many players at once.
217 
218## Storage & caching
219 
220**Cache generated audio — this is the single biggest lever, bigger than vendor choice.** Key
221the cache by a **content hash of `text + voice + model + format`** (Node's
222`crypto.createHash('sha256')`; **no hashing utility exists in the repo today**, so the cache
223adds one). Then a regenerated-but-identical epilogue, a re-listen, or a shared run's narration
224costs **zero**.
225 
226Two grounded corrections to the issue's storage assumptions:
227 
228- **"Supabase Storage (already in the stack)" is not accurate.** Supabase is wired for **auth
229 + Postgres only** ([`nuxt.config.ts:28`](../../nuxt.config.ts) configures `url`/`key`/
230 `redirect`, no storage; the run/balance/spend stores talk to Postgres tables). Supabase
231 **Storage buckets are not configured or used anywhere.** Wiring a bucket is the audio
232 cache's natural home, but it's **greenfield work**, not a free given. It's still the right
233 home — same backend, same project — just not "already there."
234- **Run state is ephemeral today, so durable caching leans on [#83](https://github.com/mseeks/revisionist/issues/83).**
235 A run persists server-side as a single `runs` row that carries **no board state** (no
236 messages, no timeline, no prose); the board lives in the Pinia store and is **gone on
237 refresh**. A content-hash cache
238 works *within* a session immediately, and the content hash makes it durable across sessions
239 *regardless* of run persistence (same text → same key → same file). But carrying audio with
240 a **shared run** ([#88](https://github.com/mseeks/revisionist/issues/88)) wants #83's run
241 snapshot to exist first.
242 
243**Format: Opus** keeps the cache tiny (~hundreds of KB/min vs ~1 MB/min for MP3); both OpenAI
244and ElevenLabs emit it. Both vendors grant output ownership + storage on paid/commercial use,
245so caching is allowed.
246 
247## Voice selection
248 
249A **single "narrator of history" voice** is a brand choice. `gpt-4o-mini-tts` is **steerable**
250— prompt it `"Narrate gravely, measured, with the calm certainty of a historian for whom this
251is simply how things went"` (which echoes the epilogue prompt's own stance,
252[`prompt-builder.ts:314`](../../server/utils/prompt-builder.ts)) — so it fits a
253historian-narrator **without** commissioning a custom voice. That's the v1 pick.
254 
255- **Premium-voice upgrade:** ElevenLabs' 10,000+ library or a consent-gated cloned Chronicler
256 voice — the path if the stock narrator underwhelms. This is a *quality* decision, not a
257 latency one, because the surface is cached.
258- **Per-figure character voices** (each figure in a distinct voice) — high charm, more
259 calls/cost/complexity. Defer past v1.
260- **User-selectable voice** is a natural setting later, ties to [#90](https://github.com/mseeks/revisionist/issues/90).
261 
262## The moderation gap (handle in v1)
263 
264Worth its own heading, because it inverts one of the issue's claims. The issue says voicing
265"adds no new moderation surface" since the prose "already passes the moderation seam." **For
266the epilogue — the MVP — that's false.** Today `moderateAction` screens only the figure's
267reply, their action, and the timeline headline/detail
268([`send-message.post.ts:300`](../../server/api/send-message.post.ts)); the **Chronicle and
269epilogue prose bypass moderation entirely** ([`chronicle.post.ts`](../../server/api/chronicle.post.ts)
270fires the chronicler and returns the prose untouched).
271 
272So voicing the epilogue would voice **unmoderated** text. The right move is to **route the
273epilogue text through `moderateAction` before synthesizing** — which both makes the voiced
274surface safe and **closes a pre-existing gap** in the text path (a gap the moderation-audit
275loop would flag anyway). v1 should own this, not inherit it.
276 
277## Safety & scope
278 
279- **Cost stays visible.** Every TTS render emits an `[ai]` line and counts against the spend
280 cap — same instrument as the text lanes. No off-the-books audio spend.
281- **Secrets stay server-side.** A vendor key follows the existing pattern (a private
282 `runtimeConfig` entry + env fallback via `configuredKey`,
283 [`ai-gateway.ts:24`](../../server/utils/ai-gateway.ts)). OpenAI needs **no new key** — it
284 reuses `OPENAI_API_KEY`.
285- **Monetization is noted, not decided.** Voiced narration could be a paid perk (the epilogue
286 is already "the thing people pay for"); the accounts/paywall seam exists. Flag the question,
287 leave the call to the monetization track.
288 
289## Recommended v1, restated
290 
291- **Surface:** voice the **epilogue** on-demand at the verdict screen, then cache it. Make the
292 per-turn Chronicle an **opt-in "listen"** button. Pre-generate the 10 curated objective
293 briefs. Defer per-figure voices and timeline-string voicing.
294- **Vendor:** **OpenAI `gpt-4o-mini-tts`** — reuses the in-repo `openai` SDK + key, steerable
295 for a historian's voice, commercially clean. **Fallback is a quality decision** (ElevenLabs
296 v3/Multilingual for a bespoke Chronicler voice), or **Google Chirp 3 HD** if a free tier
297 matters — *not* a latency decision, because the surface is cached.
298- **Seam:** a `synthesizeSpeech` transport beside `completeStructured`, a **single-lane `tts`
299 task**, and a **per-character rate-card entry** so TTS cost rides the same `[ai]` line and
300 spend cap.
301- **Cache:** content hash of `text + voice + model + format` → a **Supabase Storage bucket**
302 (greenfield; Opus format). Durable cross-session via the hash; carry-with-shared-run waits
303 on [#83](https://github.com/mseeks/revisionist/issues/83).
304- **Safety:** **moderate the epilogue text before voicing**, closing the existing
305 Chronicle/epilogue moderation gap.
⋯ 37 lines hidden (lines 306–342)
306 
307## Implementation issues this note spawns
308 
309Ready-to-file, in dependency order:
310 
3111. **A TTS lane through the AI seam.** A `synthesizeSpeech` transport calling
312 `audio.speech.create` on the existing `getOpenAIClient()`, emitting the same `[ai]`
313 telemetry + spend record; a single-lane `tts` task in `AI_ROUTES`; a per-character TTS
314 entry in the rate card so cost stays visible. No UI. *Foundation for the rest.*
3152. **Voice the epilogue (the MVP).** On the verdict screen
316 ([`EndGameScreen.vue`](../../components/EndGameScreen.vue)), synthesize the `epilogue`
317 task's prose on demand with a steered historian voice, play it, and **route the text
318 through `moderateAction` first** (closing the moderation gap). *Depends on 1 and 3.*
3193. **The audio cache + a Supabase Storage bucket.** Wire a Storage bucket, a
320 content-hash key (`sha256(text+voice+model+format)`), Opus output, serve-from-cache.
321 *Depends on 1; the durable-across-session win is immediate; carry-with-shared-run ties to
322 [#83](https://github.com/mseeks/revisionist/issues/83).*
3234. **Pre-generate + cache the curated objective briefs.** Render the 10 curated objectives
324 ([`objectives.ts`](../../server/utils/objectives.ts)) once at build/seed time; ship the
325 audio. *Depends on 1 and 3.*
3265. **Opt-in "listen" on the per-turn Chronicle.** A button that voices the current telling
327 on request (never automatic), cached by the same hash. *Depends on 1–3.*
328 
329## Explicitly out of scope (per #93)
330 
331No implementation, no vendor integration, no audio pipeline. No commitment to a vendor —
332divergent research plus a recommendation. This note identifies *where* and *how* TTS would
333fit; it does not build it.
334 
335---
336 
337*Related:* [#92](https://github.com/mseeks/revisionist/issues/92) (soundscape SFX — the sound
338half to this voice half) · [#83](https://github.com/mseeks/revisionist/issues/83) (run
339persistence — the cache's durable home) · [#88](https://github.com/mseeks/revisionist/issues/88)
340/ [#89](https://github.com/mseeks/revisionist/issues/89) (sharing & replay — why cached audio
341should travel with a run) · [#90](https://github.com/mseeks/revisionist/issues/90) (settings —
342a user-selectable voice).

What the note spawns

A design exploration's job is to end with the implementation work teed up. The note closes with five ready-to-file issues in dependency order — the TTS lane first, the voiced epilogue (with its moderation step) on top, then the cache, the pre-generated briefs, and the opt-in Chronicle.

The five spawned issues, in dependency order.

docs/design/text-to-speech-narration.md · 342 lines
docs/design/text-to-speech-narration.md342 lines · Markdown
⋯ 308 lines hidden (lines 1–308)
1# Text-to-Speech Narration — giving the Chronicle a voice, epilogue-first
2 
3> **Design exploration for [#93](https://github.com/mseeks/revisionist/issues/93).** This is
4> a recommendation, not an implementation. No TTS integration, no audio pipeline, no vendor
5> commitment — just the design space, a recommended v1, and the implementation issues this
6> note should spawn. Vendor pricing and model names were **re-verified against live official
7> sources on 2026-06-17** (see [Vendor landscape](#vendor-landscape-re-verified-2026-06-17));
8> they drift, so re-check before building. For what is true in the code today, read
9> [`../../CLAUDE.md`](../../CLAUDE.md); for forward intent, [`../ROADMAP.md`](../ROADMAP.md).
10> This is the **voice** half of giving the game a soundtrack; [#92](https://github.com/mseeks/revisionist/issues/92)
11> (soundscape SFX) is the **sound** half, a separate exploration.
12 
13## TL;DR — the recommendation
14 
15Voice the **epilogue first**: generate it on demand at the verdict screen, then cache it by
16a content hash so a re-listen or a shared run costs nothing. Use **OpenAI `gpt-4o-mini-tts`**
17— it reuses the `openai` SDK and key already in the repo (zero new dependency, zero new
18credential), it's steerable (you can prompt a grave, measured historian's voice without
19commissioning one), and its commercial terms are clean. Defer the rest.
20 
21| v1 (ship) | v2 (next) | Deferred / never |
22|---|---|---|
23| **Voice the epilogue** (on-demand → cache) | **Opt-in "listen"** on the per-turn Chronicle | Auto-voicing the Chronicle every turn (the cost + latency trap) |
24| **A TTS lane** through the AI seam (cost on the same `[ai]` line) | **Pre-generate** the 10 curated objective briefs | Per-figure character voices (charming, but multiplies calls + complexity) |
25| **Content-hash audio cache** (Supabase Storage bucket) | User-selectable narrator voice (ties to [#90](https://github.com/mseeks/revisionist/issues/90)) | Voicing timeline headlines/details (too granular for the value) |
26| **Moderate the epilogue text before voicing** (closes an existing gap) | Carry cached audio with a **shared run** ([#88](https://github.com/mseeks/revisionist/issues/88)/[#89](https://github.com/mseeks/revisionist/issues/89)) | A commissioned/cloned bespoke Chronicler voice |
27 
28Five load-bearing decisions, each argued below:
29 
301. **The epilogue is the sweet spot, and the code already isolates it.** It's produced once
31 per run by its *own* AI task (`epilogue`, distinct from `chronicler`), flagged in-code as
32 "the share artifact and the thing people pay for." On-demand-then-cache fits it perfectly:
33 highest value, lowest pressure.
342. **TTS does not ride the existing seam — it sits beside it.** The gateway's transport is
35 text-in / JSON-out; TTS is text-in / audio-out on a *separate* OpenAI endpoint. A TTS lane
36 is a small sibling transport that **reuses the same cost telemetry**, so TTS spend shows up
37 on the same `[ai]` line and counts against the same cap.
383. **Caching is the biggest lever, bigger than vendor choice.** Key by a content hash of
39 `text + voice + model + format`; an identical re-render costs zero. This needs a Supabase
40 **Storage bucket**, which is **not wired today** (only auth + Postgres are).
414. **Voicing the epilogue voices currently-unmoderated prose.** The Chronicle and epilogue
42 bypass the moderation seam today (only the figure's reply and the timeline headline/detail
43 pass it). Routing the epilogue text through `moderateAction` before voicing is a v1
44 prerequisite, not an afterthought.
455. **For a cached surface, latency barely matters — quality and ops do.** You generate once
46 and replay from cache forever, so the deciding factors are voice character, commercial
47 cleanliness, and operational simplicity. That tilts the choice toward OpenAI (already in
48 the stack) and reframes the fallback as a *quality* upgrade, not a latency one.
49 
50## Today: where the prose comes from
51 
52The game is dense with generated prose, and the [ROADMAP](../ROADMAP.md)'s four-layer AI is
53the source of all of it. Five surfaces render it; voicing is a question of *which* and *when*,
54and the answer differs per surface because the code does.
55 
56| Surface | Component | AI task | Store field | Volume | Moderated today? |
57|---|---|---|---|---|---|
58| **Epilogue** (final telling) | [`EndGameScreen.vue`](../../components/EndGameScreen.vue) | **`epilogue`** | `gameStore.chronicle` | ~once / run, ~400–800 chars | **no** |
59| Chronicle (per-turn retelling) | [`Chronicle.vue`](../../components/Chronicle.vue) | **`chronicler`** | `gameStore.chronicle` | every turn, regenerated | **no** |
60| Figure replies | [`MessageHistory.vue`](../../components/MessageHistory.vue) | `character` | `messageHistory[].text` (+ `figureName`) | per turn, ≤160 chars | yes (`reply.message`) |
61| Objective brief | [`MissionSelect.vue`](../../components/MissionSelect.vue) | curated + `objective` | `objectives.ts` / fetched | 10 curated (fixed) + composed | curated: human-authored |
62| Timeline headline/detail | [`TimelineLedger.vue`](../../components/TimelineLedger.vue) | `timeline` | `timelineEvents[]` | per turn, short | yes (`ripple.headline/detail`) |
63 
64Three facts here matter more than the issue's framing implied, and they sharpen the plan:
65 
66- **The epilogue is its own AI task, not a re-skin of the Chronicle.** Both write to the same
67 `gameStore.chronicle` slot (a `ChronicleEntry` of `title` + `paragraphs[]`), and the
68 *distinction is the task*: `callChroniclerAI` routes `status === 'playing'` to `chronicler`
69 and victory/defeat to **`epilogue`** ([`openai.ts:535`](../../server/utils/openai.ts)). The
70 routing table even annotates `epilogue` as *"the share artifact and the thing people pay
71 for"* ([`ai-routing.ts:105`](../../server/utils/ai-routing.ts)). So "voice the epilogue"
72 maps cleanly onto a seam the code already draws — you voice the output of one named task,
73 fired once, at a natural pause.
74- **The epilogue is short** — ~400–800 chars (two to four tight paragraphs), not the issue's
75 ~1–3K-char guess. That makes it *cheaper* to voice than the issue assumed (about a penny a
76 run; see [Costs](#costs)).
77- **The "closing movement" is a prompt instruction, not a separable field.** Victory prose is
78 asked to "END on a distinct closing movement" ([`prompt-builder.ts:314`](../../server/utils/prompt-builder.ts)),
79 but it arrives as one undivided `paragraphs[]`. So you can't cheaply voice "just the new
80 movement" of a Chronicle — you voice the whole current telling. This is exactly why the
81 per-turn Chronicle is the surface to make **opt-in**, not automatic.
82 
83## The crux: what to voice, and when
84 
85This was never one decision. It's per surface, and the game's own structure makes most of the
86answers obvious.
87 
88| Surface | When to voice | Why |
89|---|---|---|
90| **Epilogue** | ⭐ **on-demand → cache** | Once per run, read at the verdict pause. The share artifact and the paid surface. Highest value, lowest pressure. **The MVP.** |
91| Chronicle (per-turn) | **opt-in "listen" button** | Re-narrates the *whole* timeline every turn and is regenerated each turn — voicing it blindly is expensive *and* slow on every turn, and it can't be voiced "newest-only." Make it a button on the current telling. |
92| Objective brief | **pre-generate the 10 curated; on-demand for composed** | The curated pool is fixed and enumerable ([`objectives.ts`](../../server/utils/objectives.ts), 10 entries) — render them once at build/seed time and ship the audio. Live-composed briefs voice on demand, then cache. |
93| Figure replies | **defer past v1** | Ambitious (a distinct voice per figure — `figureName` is attached, so it's *possible*), but it multiplies calls and cost and adds real complexity. |
94| Timeline headline/detail | **skip v1** | Short, frequent, low marginal charm for the call volume. |
95 
96**The Chronicle is the cost/latency trap, by design.** The Chronicler "narrates the WHOLE
97altered timeline" and "is rewritten every turn"
98([`prompt-builder.ts:273`](../../server/utils/prompt-builder.ts)). Voicing that on every turn means
99multi-paragraph audio regenerated five times a run, each with a latency hit — the textbook
100"on-demand, regenerated often" case to avoid. A **"listen" button** turns it into a
101deliberate, cacheable request.
102 
103## Architecture fit: a TTS lane beside the AI seam
104 
105The issue says TTS "fits as a new task/lane through the same seam, instrumented the same way."
106That's *almost* right, and the gap is worth stating precisely, because it changes the v1
107scope.
108 
109**What's true:** every model call already routes through a per-task seam
110([`ai-gateway.ts`](../../server/utils/ai-gateway.ts) / [`ai-routing.ts`](../../server/utils/ai-routing.ts))
111that stamps **one `[ai]` cost line per call** — task, lane, model, tokens, latency, run id,
112and a dollar cost from the rate card — and records that cost to a spend cap. The rate card's
113own header calls itself "the GTM cost instrument's price half"
114([`cost.ts:2-4`](../../server/utils/cost.ts)). Per-call cost is a deliberate product lever,
115and TTS cost should join it.
116 
117**What's not true:** TTS can't ride the *transport*. `completeStructured` is hardwired
118text-in / JSON-schema-out (`turns` + `response_format`), and OpenAI's TTS is a *different*
119endpoint (`audio.speech.create`) that returns audio bytes. So a TTS lane is a **small sibling
120transport** next to `completeStructured`, not a new row that flows through it. Concretely, v1
121needs:
122 
1231. **A `synthesizeSpeech` transport** that calls `audio.speech.create` on the **same**
124 `getOpenAIClient()` ([`ai-gateway.ts:34`](../../server/utils/ai-gateway.ts)), and emits the
125 **same** `[ai]` telemetry via the existing `emit()` + `spendStore().record()`. Reuse the
126 plumbing; only the call shape differs.
1272. **A `tts` task in the routing table** — but **single-lane**. The table assumes every task
128 has both an `anthropic` and an `openai` route so `REVISIONIST_AI_LANE` can force-flip them;
129 Claude has no TTS, so a `tts` task can't honor a flip to Anthropic. Either pin `tts` to its
130 provider or let the flip skip tasks with no alternate lane.
1313. **A per-character rate-card entry.** `costUsd` today multiplies input/output/cached *text*
132 tokens by per-million rates ([`cost.ts:39-51`](../../server/utils/cost.ts)). TTS is billed
133 per character (`tts-1`/`tts-1-hd`) or per audio token (`gpt-4o-mini-tts`), so the rate card
134 gains a TTS entry and the telemetry's `usage` carries characters (or audio tokens). Small,
135 self-contained — and it keeps every TTS render visible on the same `[ai]` line and inside
136 the same spend cap.
137 
138None of this is a large refactor, but it is **more than "add a row to a table."** The v1
139foundation issue is exactly this lane.
140 
141## Vendor landscape (re-verified 2026-06-17)
142 
143Re-checked against each vendor's live pricing/docs. **The issue's headline figures held up;
144the corrections are in the model *names* and a few framing points.** Full sources in the
145spawned issues.
146 
147| Dimension | **OpenAI** `gpt-4o-mini-tts` | **ElevenLabs** Flash v2.5 / v3 | **Google** Chirp 3 HD |
148|---|---|---|---|
149| Price | **$0.60/1M input + $12/1M audio-output tokens** (≈ a penny per epilogue) | Flash **$0.05/1K chars**; v3/Multilingual **$0.10/1K chars** (paid plan) | **$30/1M chars** ($0.03/1K) + **1M chars/mo free, indefinitely** |
150| Steerable delivery | **yes** — free-text `instructions` (tone, pace, gravity) | no | Gemini TTS is steerable; Chirp 3 HD is not |
151| Quality | strong; 13 voices | top-tier; **10,000+ voice library** + consent-gated cloning | strong; large catalog |
152| Streaming | yes (HTTP chunked) | yes (HTTP + WebSocket) | REST + partial streaming |
153| Commercial use | **unrestricted**, you own output, no attribution, caching OK | **paid plan required** (free = non-commercial + attribution) | standard GCP terms, you own output |
154| In the repo already? | **yes — `openai` SDK + `OPENAI_API_KEY` are wired** | no (new SDK + new paid account + new key) | no (new GCP dep + service account) |
155| Best for | the steerable narrator, **least ops** | a bespoke premium Chronicler voice | a free tier + clean ownership |
156 
157Verification notes (corrections to the issue):
158 
159- **OpenAI prices confirmed exactly.** `tts-1` = $0.015/1K chars, `tts-1-hd` = $0.03/1K chars,
160 `gpt-4o-mini-tts` = $0.60/1M input + $12/1M audio-output tokens. Only `gpt-4o-mini-tts` is
161 steerable (the `instructions` param); `tts-1`/`tts-1-hd` are not. The "~$0.015/min" is
162 OpenAI's *own* convenience estimate, not purely third-party — and note it numerically equals
163 `tts-1`'s per-1K-char price by coincidence; different units, different models, don't
164 conflate.
165- **ElevenLabs prices confirmed.** Flash v2.5 ≈ $0.05/1K chars (0.5 credit/char), v3 /
166 Multilingual v2 ≈ $0.10/1K chars (1 credit/char) on a paid plan. The headline unit is
167 *credits*; the $/1K is plan-dependent. Flash's "~75 ms" is **inference-only** (the docs'
168 own footnote excludes app + network latency) — not a real player-experienced TTFB. **v3 is
169 not real-time** (its WebSocket stream-input endpoint doesn't even support v3), which is fine
170 for a pre-rendered, cached epilogue. Commercial use **requires a paid plan** (the entry
171 Starter tier, a few dollars a month).
172- **Challengers' model names are stale in the issue.** Cartesia's live model is **Sonic-3.5**
173 (not "Sonic 2/Turbo"), ~$0.037–0.039/1K chars, ~188 ms P50 in production — and its
174 commercial use is **tier-gated** (Pro+). Deepgram **Aura-2** is confirmed at $0.03/1K chars,
175 sub-200 ms, but English-centric. **Google Chirp 3 HD** is the strongest cost play: $30/1M
176 chars with an **indefinite 1M-char/month free tier** and clean GCP ownership — its only
177 cost against KISS here is a new GCP dependency.
178 
179## Costs
180 
181The epilogue is short — call it ~600 chars (estimated from its two-to-four-short-paragraph
182schema; there's no hard char cap in code, so treat the figures below as estimates), ≈ 30–50 s
183of speech:
184 
185- **Epilogue, once per run:****$0.01** (OpenAI), ≈ $0.03 (ElevenLabs Flash). Trivial — and
186 *cached*, so a re-listen or a shared run is **$0**. For reference, that's a rounding error
187 against the run's existing per-turn Claude spend (five turns, several Haiku/Sonnet calls
188 each), which the same `[ai]` line already tracks.
189- **Chronicle, every turn, on-demand:** ~5–10K chars/run ≈ **$0.15–0.50/run** *plus* a latency
190 hit each turn — the case to make opt-in and cache, never automatic.
191- **Curated objective briefs:** 10 × ~400 chars, rendered **once** at build/seed time and
192 shipped as static audio — effectively $0 amortized.
193 
194At this volume, **cost is not the deciding factor** — latency and quality are, and for a
195*cached* surface even latency falls away. Cost only bites at scale (the issue estimates
196~100K turns/mo at roughly $1.5K OpenAI vs $5–10K ElevenLabs if the Chronicle were voiced
197every turn — the strongest argument for caching and for keeping the Chronicle opt-in), and
198there the same `[ai]` instrument that prices the text lanes will price TTS too.
199 
200## Latency & streaming (only the on-demand surfaces)
201 
202The issue rightly stresses that **streaming is essential for on-demand voicing** — start
203playback on the first chunk, not the whole clip — and that TTFB should be benchmarked before
204committing. For the **cached epilogue, this mostly dissolves**: it's generated once and
205replayed from cache, so first-render latency is paid once and a re-listen is instant. But the
206two surfaces the plan keeps *on demand* still live under the issue's concern:
207 
208- The **opt-in Chronicle "listen"** and **live-composed objective briefs** are uncached on
209 first hit. Both should **stream** (both OpenAI and ElevenLabs support it — OpenAI via HTTP
210 chunked transfer, ElevenLabs via `convert-as-stream` / WebSocket), and their **TTFB is worth
211 a quick benchmark** before shipping. This is the one place ElevenLabs Flash v2.5's
212 lower-latency profile could matter — though for the cached MVP it does not.
213- **Concurrency caps** bind only if many players voice on demand at the same moment
214 (ElevenLabs is 4–40 concurrent by plan; OpenAI's audio calls draw on a rate-limit pool). A
215 non-issue for a once-per-run cached epilogue; worth a glance before the opt-in surfaces ship
216 to many players at once.
217 
218## Storage & caching
219 
220**Cache generated audio — this is the single biggest lever, bigger than vendor choice.** Key
221the cache by a **content hash of `text + voice + model + format`** (Node's
222`crypto.createHash('sha256')`; **no hashing utility exists in the repo today**, so the cache
223adds one). Then a regenerated-but-identical epilogue, a re-listen, or a shared run's narration
224costs **zero**.
225 
226Two grounded corrections to the issue's storage assumptions:
227 
228- **"Supabase Storage (already in the stack)" is not accurate.** Supabase is wired for **auth
229 + Postgres only** ([`nuxt.config.ts:28`](../../nuxt.config.ts) configures `url`/`key`/
230 `redirect`, no storage; the run/balance/spend stores talk to Postgres tables). Supabase
231 **Storage buckets are not configured or used anywhere.** Wiring a bucket is the audio
232 cache's natural home, but it's **greenfield work**, not a free given. It's still the right
233 home — same backend, same project — just not "already there."
234- **Run state is ephemeral today, so durable caching leans on [#83](https://github.com/mseeks/revisionist/issues/83).**
235 A run persists server-side as a single `runs` row that carries **no board state** (no
236 messages, no timeline, no prose); the board lives in the Pinia store and is **gone on
237 refresh**. A content-hash cache
238 works *within* a session immediately, and the content hash makes it durable across sessions
239 *regardless* of run persistence (same text → same key → same file). But carrying audio with
240 a **shared run** ([#88](https://github.com/mseeks/revisionist/issues/88)) wants #83's run
241 snapshot to exist first.
242 
243**Format: Opus** keeps the cache tiny (~hundreds of KB/min vs ~1 MB/min for MP3); both OpenAI
244and ElevenLabs emit it. Both vendors grant output ownership + storage on paid/commercial use,
245so caching is allowed.
246 
247## Voice selection
248 
249A **single "narrator of history" voice** is a brand choice. `gpt-4o-mini-tts` is **steerable**
250— prompt it `"Narrate gravely, measured, with the calm certainty of a historian for whom this
251is simply how things went"` (which echoes the epilogue prompt's own stance,
252[`prompt-builder.ts:314`](../../server/utils/prompt-builder.ts)) — so it fits a
253historian-narrator **without** commissioning a custom voice. That's the v1 pick.
254 
255- **Premium-voice upgrade:** ElevenLabs' 10,000+ library or a consent-gated cloned Chronicler
256 voice — the path if the stock narrator underwhelms. This is a *quality* decision, not a
257 latency one, because the surface is cached.
258- **Per-figure character voices** (each figure in a distinct voice) — high charm, more
259 calls/cost/complexity. Defer past v1.
260- **User-selectable voice** is a natural setting later, ties to [#90](https://github.com/mseeks/revisionist/issues/90).
261 
262## The moderation gap (handle in v1)
263 
264Worth its own heading, because it inverts one of the issue's claims. The issue says voicing
265"adds no new moderation surface" since the prose "already passes the moderation seam." **For
266the epilogue — the MVP — that's false.** Today `moderateAction` screens only the figure's
267reply, their action, and the timeline headline/detail
268([`send-message.post.ts:300`](../../server/api/send-message.post.ts)); the **Chronicle and
269epilogue prose bypass moderation entirely** ([`chronicle.post.ts`](../../server/api/chronicle.post.ts)
270fires the chronicler and returns the prose untouched).
271 
272So voicing the epilogue would voice **unmoderated** text. The right move is to **route the
273epilogue text through `moderateAction` before synthesizing** — which both makes the voiced
274surface safe and **closes a pre-existing gap** in the text path (a gap the moderation-audit
275loop would flag anyway). v1 should own this, not inherit it.
276 
277## Safety & scope
278 
279- **Cost stays visible.** Every TTS render emits an `[ai]` line and counts against the spend
280 cap — same instrument as the text lanes. No off-the-books audio spend.
281- **Secrets stay server-side.** A vendor key follows the existing pattern (a private
282 `runtimeConfig` entry + env fallback via `configuredKey`,
283 [`ai-gateway.ts:24`](../../server/utils/ai-gateway.ts)). OpenAI needs **no new key** — it
284 reuses `OPENAI_API_KEY`.
285- **Monetization is noted, not decided.** Voiced narration could be a paid perk (the epilogue
286 is already "the thing people pay for"); the accounts/paywall seam exists. Flag the question,
287 leave the call to the monetization track.
288 
289## Recommended v1, restated
290 
291- **Surface:** voice the **epilogue** on-demand at the verdict screen, then cache it. Make the
292 per-turn Chronicle an **opt-in "listen"** button. Pre-generate the 10 curated objective
293 briefs. Defer per-figure voices and timeline-string voicing.
294- **Vendor:** **OpenAI `gpt-4o-mini-tts`** — reuses the in-repo `openai` SDK + key, steerable
295 for a historian's voice, commercially clean. **Fallback is a quality decision** (ElevenLabs
296 v3/Multilingual for a bespoke Chronicler voice), or **Google Chirp 3 HD** if a free tier
297 matters — *not* a latency decision, because the surface is cached.
298- **Seam:** a `synthesizeSpeech` transport beside `completeStructured`, a **single-lane `tts`
299 task**, and a **per-character rate-card entry** so TTS cost rides the same `[ai]` line and
300 spend cap.
301- **Cache:** content hash of `text + voice + model + format` → a **Supabase Storage bucket**
302 (greenfield; Opus format). Durable cross-session via the hash; carry-with-shared-run waits
303 on [#83](https://github.com/mseeks/revisionist/issues/83).
304- **Safety:** **moderate the epilogue text before voicing**, closing the existing
305 Chronicle/epilogue moderation gap.
306 
307## Implementation issues this note spawns
308 
309Ready-to-file, in dependency order:
310 
3111. **A TTS lane through the AI seam.** A `synthesizeSpeech` transport calling
312 `audio.speech.create` on the existing `getOpenAIClient()`, emitting the same `[ai]`
313 telemetry + spend record; a single-lane `tts` task in `AI_ROUTES`; a per-character TTS
314 entry in the rate card so cost stays visible. No UI. *Foundation for the rest.*
3152. **Voice the epilogue (the MVP).** On the verdict screen
316 ([`EndGameScreen.vue`](../../components/EndGameScreen.vue)), synthesize the `epilogue`
317 task's prose on demand with a steered historian voice, play it, and **route the text
318 through `moderateAction` first** (closing the moderation gap). *Depends on 1 and 3.*
3193. **The audio cache + a Supabase Storage bucket.** Wire a Storage bucket, a
320 content-hash key (`sha256(text+voice+model+format)`), Opus output, serve-from-cache.
321 *Depends on 1; the durable-across-session win is immediate; carry-with-shared-run ties to
322 [#83](https://github.com/mseeks/revisionist/issues/83).*
3234. **Pre-generate + cache the curated objective briefs.** Render the 10 curated objectives
324 ([`objectives.ts`](../../server/utils/objectives.ts)) once at build/seed time; ship the
325 audio. *Depends on 1 and 3.*
3265. **Opt-in "listen" on the per-turn Chronicle.** A button that voices the current telling
327 on request (never automatic), cached by the same hash. *Depends on 1–3.*
⋯ 15 lines hidden (lines 328–342)
328 
329## Explicitly out of scope (per #93)
330 
331No implementation, no vendor integration, no audio pipeline. No commitment to a vendor —
332divergent research plus a recommendation. This note identifies *where* and *how* TTS would
333fit; it does not build it.
334 
335---
336 
337*Related:* [#92](https://github.com/mseeks/revisionist/issues/92) (soundscape SFX — the sound
338half to this voice half) · [#83](https://github.com/mseeks/revisionist/issues/83) (run
339persistence — the cache's durable home) · [#88](https://github.com/mseeks/revisionist/issues/88)
340/ [#89](https://github.com/mseeks/revisionist/issues/89) (sharing & replay — why cached audio
341should travel with a run) · [#90](https://github.com/mseeks/revisionist/issues/90) (settings —
342a user-selectable voice).