"""The Warden's voice: live LLM dialogue with scripted fallback. The contract with the UI: react() yields a sanitized line; if the backend is slow, dead, or rude, it yields nothing and the caller plays a scripted line instead. The game never stalls and never shows raw output. Lines are buffered and sanitized BEFORE any text is released — partial streaming could leak the prefix of a blocked line. The UI's typewriter provides the gradual reveal instead, so nothing is lost dramatically. """ from __future__ import annotations import asyncio import random from typing import AsyncIterator from scrypt.inference.backend import Backend from .context import build_messages from .guardrails import clean_dialogue from .memory import ShardStore TOTAL_TIMEOUT = 25.0 # CPU prefill on a cold cache is the slow case # How often the machine thinks out loud: the reasoning block is enabled # for this fraction of generations and surfaced as a terminal comment. REVEAL_ODDS = 1 / 3 THINKING_MAX_TOKENS = 192 # room for the thought AND the line class WardenVoice: def __init__( self, backend: Backend, store: ShardStore | None = None, rng: random.Random | None = None, ): self.backend = backend self.store = store or ShardStore() self.rng = rng or random.Random() async def react( self, moment: str, *, digest: str = "", tags: set[str] | None = None, taboo: str = "", ) -> AsyncIterator[str]: """Stream a one-liner reaction to a game moment. taboo: raw player text the reply must never parrot (echo-exfiltration guard).""" messages = build_messages( f"MOMENT\n{moment}\n\nReact in one short line of at most 18 words, in voice.", digest=digest, shards=self.store.render(tags or set()), ) thinking = self.rng.random() < REVEAL_ODDS buffer = "" try: async with asyncio.timeout(TOTAL_TIMEOUT): async for chunk in self.backend.stream( messages, max_tokens=THINKING_MAX_TOKENS if thinking else 48, temperature=0.6, thinking=thinking, ): buffer += chunk except Exception: pass # keep whatever arrived; a cut-off line beats silence cleaned = clean_dialogue(buffer, taboo=taboo) if cleaned: yield cleaned