Skip to content

Release Notes v1.14.0

dudarenok-maker edited this page Jul 23, 2026 · 1 revision

Released 2026-07-23. View on GitHub.


Castwright learns two new languages — and the whole show comes to your tablet. This release makes Chinese and Japanese first-class: upload a zh/ja manuscript and it's cast, performed, and rendered like any other book, with CJK-aware analysis, honorific-tolerant attribution, and Coqui/Qwen synthesis. The companion app grows up on the second screen — a two-pane library-and-player layout on tablets and foldables that keeps playing right through a rotation — and any undownloaded chapter now plays instantly over your home LAN, no cert install and no wait. Under the headliners: analysis is now local-first and never routes to the cloud silently, cloud requests are sized to the free-tier caps so a big chapter can't drop, who's-speaking attribution holds up better on hard dialogue, GPU work is placed by measured free-VRAM capacity out of the box, and a run of reliability fixes — designed voices that survive an accidental re-analysis, German dialogue cast to the right characters, and a Qwen engine that never ships a silent line under memory pressure.


✨ Headline features

🌐 Chinese and Japanese join the cast (new) — fs-59

Any book, performed by a full cast, now stretches to two of the world's most-spoken languages. zh/ja manuscripts are no longer turned away at the confirm screen — they're rostered, attributed, cast, and rendered through the same pipeline as every other language.

  • Chinese and Japanese books are now first-class — no longer blocked at the confirm screen (fs-59 W5). zh/ja flip to supported:true in language-registry.ts, the final step of the fs-59 CJK tranche (registry, CJK script detection + word count, circumfix chapter-heading/front-matter lexicons, in-language roster/attribution prompt examples, and Coqui XTTS zhzh-cn eligibility all shipped in earlier waves, #1572/#1577/#1582/#1585/#1597). Both Coqui XTTS (zh-cn/ja) and Qwen render CJK, validated on-box. CJK attribution quality is analyzer-model-dependent — a local Qwen model measured ~62% recall / 72% coverage / 100% precision vs. a weaker lite default, so operators are recommended to pick a local Qwen analyzer (analyzerPhase0Model/analyzerPhase1Model) for CJK; there's no automatic per-language routing, so this stays a documented choice. The attribution-drift demotion safety net is unchanged. Ships full zh/ja The Coalfall Commission translations as new fixtures. See docs/features/254-fs59-cjk-w5-supported-flip.md. (#1004)

📱 The companion app opens up on tablets and foldables — app-21

The phone-first companion now uses the second screen: a two-pane list-detail layout with a persistent player that survives a book switch and a device rotation without ever stopping playback.

  • app-21 — Tablet & large-screen adaptive UI. The companion now renders a two-pane list-detail layout on tablets/foldables (≥840 dp): the library on the left, a persistent player pane on the right, driven by a homegrown adaptive shell (window_size.dart breakpoints + pane_split.dart foldable-hinge split — pure, unit-tested) with zero new dependencies. A pure runActivateBook orchestration + CompanionRuntime.activateBook binding own book-level audio (open/switch/reconcile) while a PlayerPane keyed by the active book self-activates in initState — a book switch reloads the pane without stopping playback, and rotation never tears the player down. PlayerController.switchBook carries bookTitle/artPath so lock-screen/car metadata survives a switch; a LifecycleResumePusher flushes resume on app pause. Medium widths get a cover-forward grid; phone (compact) behaviour is unchanged. All pure Flutter framework (Material 3, no Android-only plugins), so it doubles as the iPad UI when app-12 lands. 20 new paired Dart tests. Follow-up: the cover-forward grid gains a 16-dp horizontal gutter on medium/expanded windows so tablet covers don't run flush to the pane edges (#1595). (#1590)

▶️ Undownloaded chapters play instantly at home, over your LAN — app-10

Tap any chapter you haven't downloaded and it starts immediately on the home network — no OS certificate install, and the device token never leaves the app. Off-network it degrades cleanly to download-to-play.

  • app-10 — Stream-over-LAN instant play (companion). An undownloaded chapter plays instantly over the home LAN via an in-app loopback proxy that re-serves CA-pinned HTTPS audio as plaintext to the native player — no OS cert install, token never leaves the app. Failure degrades cleanly to download-to-play (or a re-pair prompt on an expired device token). (#553)
  • app-10 follow-ups: stream→stream late-error isolation + ApiClient.dispose() (#1579). Each LAN stream load carries a generation id (_streamGen/_streamingGen); a late teardown error from an outgoing stream arriving while the next chapter loads is recognised as superseded and ignored, instead of misrouted as the incoming chapter's failure (a stray "download to play" / re-pair). ApiClient.dispose() force-closes its three owned pinned HttpClients, called from CompanionRuntime.dispose() so a re-pair no longer orphans the old connection pools. Regression tests: the superseded-error case + a new api_client_dispose_test.dart.

🗣️ Analyzer: local-first & cloud reliability

  • Analysis is local-by-default and never routes to the cloud silently; slow local warms are waited out; large chapters no longer MAX_TOKENS on Gemini; review failures are surfaced, not swallowed (#1660). Fixes the incident where a local-configured user with a reachable Ollama had every script-review pass run on Gemini and fail on a large chapter (0% → empty). Six parts: (0) the default analysis engine + model flip to local / qwen3.5:4b in lockstep, retiring ANALYZER as an engine selector (a stray ANALYZER=gemini is now inert); (1) a new opt-out allowCloudFallback user setting (default true, non-breaking) so a Gemini key kept for TTS no longer silently reroutes analysis; (2) patient warm — warmOllamaModel waits out a slow load to analyzer.ollama.warmTimeoutMs (default 120000, was a hard 30s) with a typed reachability pre-check and a 1s liveness ticker; (3) honest failure — a run where every chapter failed ends as terminal error{code:'review_failed'} with a Retry toast (was a silent empty result); (4) chapterChunkBudget('gemini') is now finite for the output-heavy routes so a large chapter splits, with a force-split-on-truncation safety net. Design of record: docs/superpowers/plans/2026-07-15-analyzer-local-only-fallback-gate.md. (#1660)
  • Script-review now runs on your configured analyzer instead of always defaulting to Gemini — completing #1660's local-default for the review path. The manuscript view hardcoded REVIEW_MODEL = 'gemma-4-31b-it' (a Gemini id), and the server treats a request model as a priority-2 override that beats the engine setting — so the review forced onto Gemini regardless of a local setting, skipping the Ollama warm and, on a stale cloud model, wedging at "Waiting for model · 0%" indefinitely (observed 13+ min). reviewModel now reads the user's account.defaultAnalysisModel ('' → server resolves the default engine): a local id routes the review to local and warms it. Regression test in manuscript.test.tsx guards against the hardcoded Gemini id reappearing. (bug)
  • Cloud (Gemini/Gemma) requests are sized to the free-tier's per-minute token cap across every pass, so a big chapter can't 429 and drop (#1682). Gemini/Gemma free-tier enforces 16,000 input-tokens/minute, but no analyzer pass sized to it: stage-1 never chunked, stage-2 kept the full char budget, and script-review additionally prepends the ~14k-char cast roster, producing the observed 46k-char/>16k-token request that 429'd (22/22 logged Gemini failures were script-review). A new per-request input-token cap analyzer.gemini.maxInputTokensPerRequest (default 12000) is converted to a script-aware char budget and applied at all three cloud sizing seams; the output-heavy budget subtracts the serialized roster overhead. The output-heavy passes also reserve the system-prompt overhead (OUTPUT_HEAVY_CLOUD_RESERVED_TOKENS, 4000) before sizing the body, so a dense Cyrillic script-review chunk no longer trips RequestExceedsTpm (locked by output-heavy-tpm.test.ts). Three companion fixes: the daily-quota misclassification of a retriable per-minute 429 is corrected (and a residual RPM digit-count collision — a quotaValue":"15" matching the daily regex — closed at all three sites, #1695); Gemma's TPM limiter gets a real free-tier cap (was Infinity); and RateLimiter.acquire gains a total-timeout guard so an over-cap estimate can't spin forever. Local stage-2 truncation is reduced by the same lever. Design of record: docs/superpowers/plans/2026-07-17-cloud-request-sizing.md. (#1682, #1695)
  • An empty MAX_TOKENS response from a Gemini/Gemma model now adaptively re-splits instead of fatally failing the whole run (closes #1755). A dense chunk can exhaust the model's output cap before emitting any decodable text — the stream ends empty with finishReason === 'MAX_TOKENS'. That empty-buffer case fell through to the whole-book-fatal GeminiContentBlockedError, killing the analysis with a misleading "content filter blocked" message. It now routes to AnalyzerTruncatedError → the adaptive re-split, keyed on the reason not the model id; genuine content-filter reasons (RECITATION/SAFETY) keep the fatal fast-fail. Regression test in gemini.test.ts.
  • Script review shows a live heartbeat — which model is working, why it's waiting, and how far through the chapter — and recovers cleanly when the local model warms up cold (#1630). The progress pill ticks Analyzing...Loading model...Reading...Streaming... and counts intra-chapter progress instead of freezing per-chapter. The cold Ollama warm step aborts with a retriable WarmupTimeout and an in-app Retry nudge instead of blocking forever. When Ollama is unreachable, the Gemini fallback is no longer silent — onFallback fires and the pill reports "Gemini". (#1630)
  • Local Ollama analysis can run K chapters/chunks concurrently for ~2–3× overnight-batch throughput — safe on 6/8 GB cards and decoupled from TTS. A width-K limiter (analyzer.ollama.concurrency, default 2) bounds in-flight analyzer /api/chat calls; a per-resident-model refcounted GPU lease holds exactly one cross-engine slot while a model has any call in flight (K same-model calls share it — real concurrency; two different local models serialize; cloud passes overlap freely). Independent of GPU_VRAM_BUDGET, so raising K never widens TTS synthesis; the fixed acquire order is deadlock-free. Behavior-preserving at the default. Set Ollama-side OLLAMA_NUM_PARALLEL >= K. Whole-branch review confirmed all six concurrency invariants. (#1702)
  • Per-model Ollama analyzer keep-alive (seconds) in the Model Manager; retired the hardcoded RESIDENT_MODELS allowlist (#1696). Two in-cycle fixes: the manual Load on the Analysing screen warmed with the resolved keep-alive, which is 0 for any tag not in the coded defaults (e.g. a custom qwen36-cw-* quant) — Ollama's evict-immediately idiom, so Load loaded-then-dropped the model; an explicit Load now floors a resolved 0 up to a 30s hold (floorWarmKeepAlive, #1706). And the keep-alive field, a blur-only autosave, silently discarded a value typed then Enter'd/reloaded without tabbing away — onKeyDown Enter now routes through the commit path and every save flashes a transient "Saved". Regression tests pin both the floor and the Enter-save.
  • Two new Google models are selectable, gemini-3.5-flash-lite is the shipped cloud default, and Gemma's free-tier limits are corrected. Adds gemini-3.5-flash-lite (15 RPM / 250K TPM / 500 RPD) and gemini-3.6-flash (5 RPM / 250K TPM / 20 RPD) to the curated catalog and the server rate limiter at their real free-tier ceilings (a picked id missing from BUILTIN_LIMITS falls back to a conservative cap that would stall a book-length run). The shipped cloud default moves from gemma-4-31b-it to gemini-3.5-flash-lite (500 RPD comfortably parses a novel); the code-level last-resort fallback stays gemma-4-31b-it, immune to Gemini's RECITATION filter. Gemma's baked limits are corrected from a stale 15 RPM / "unlimited" TPM / 1,500 RPD to the actual 30 RPM / 16,000 TPM / 14,400 RPD across the limiter, registry, .env.example, and picker hints. Paired tests in models.test.ts / rate-limit.test.ts.
  • Analyzer throughput telemetry — the Admin console charts per-pass Ollama decode speed (tok/s) grouped by book + model, so analyzer speed drift across a long run is visible without grepping logs. (#1698)

🎯 Who's-speaking (attribution quality)

  • The stage-2 attribution prompt now carries an explicit, language-safe rules block on its first full-context pass — chapter-builder-only (#1757). The stage-2 line→speaker prompt was nearly rule-free (all guidance lived in stage-1). A shared STAGE2_ATTRIBUTION_RULES block (dialogue-tag-decisive, action-beat-names-speaker, untagged-continuation/two-hander alternation, narration-is-narrator, addressee-≠-speaker) is injected into buildStage2ChapterInbox, raising the raw ceiling all deterministic passes build on. Language-/quote-agnostic by construction. On-box eval (--runs 3) drove the scope: injecting into both stage-2 builders regressed ch44 −2.5 (the only chunked fixture) as the continuation rules misfire across chunk boundaries; chapter-builder-only recovered ch44 to baseline while keeping the wins (ch45 +4.7, ch46 +0.8), flat-or-better on cloud. Follow-ups: chunk-safe rules variant (#1758), RU/DE eval fixtures (#1759). See docs/features/265-attribution-eval-tuning.md. (#1757)
  • The deterministic post-passes now resolve rather than override the model's first full-context read (#1751). On the strong local analyzer, stage-2's cross-examiner + escalation re-ask could flip a correct attribution to a worse one — the escalation re-ask (a ≤1500-char, context-starved window) overwrote committed named answers with no quality gate (the dominant regressor, ch46 −12.8). Three waves land: E-core gates escalation to fill-only (it may resolve a genuinely-unresolved placeholder but never overwrites a named/structural answer); E1 grounds the re-ask with confident (≥0.8) neighbour anchors; A1/A2 mark a tag-name minted from a beat-only gap as strength: 'weak' so it flags on model disagreement instead of force-correcting (strong speech-verb and dash/quote-interior tags stay immutable). On-box averaged (3 runs): ch46 +11.1, Coalfall +4.6, no fixture regressed. See docs/features/247-dialogue-structure-attribution.md (invariant #2) + 265. (#1751)
  • The deterministic engine no longer mints a strong speaker-tag for the person being spoken to (#1763). findRosterName resolved a speech tag's speaker as the first roster name in the clause, subject/object-blind — so an addressee ("…," he said to Valkyrie.) or bystander name was minted as a strong tag-name and force-corrected the line to the wrong voice. A new pure findSubjectName resolves the tag's subject by verb position (nearest roster name before the verb; inverted said X after it; rejected when an addressee preposition, bystander conjunction, or subject pronoun intervenes — the pronoun clause makes Russian's caseless dative сказал он Валери resolve correctly). applyTag gates on a new LanguageConventions.addresseePrepositions opt-in (en/de/es/fr/ru); zh/ja and any unknown language stay byte-identical. Frozen-raw A/B (English, --runs 3): ch44 +1.93pp engine lift, ch43 +0.55pp, zero regression with no line reassigned. Built via SDD; ru/de/es/fr ship the same fix + per-language unit tests (quantified real-book acceptance deferred to #1759). See docs/features/265-attribution-eval-tuning.md. (closes #1763)
  • German dialogue attribution no longer collapses to the narrator, and mixed-closer paragraphs no longer over-merge (#1598, #1601). German declared „…" closing on the typographic U+201C, but the fs-61 demo books and real-world manuscripts close „…" with a plain ASCII " — so findQuoteRuns found zero runs per paragraph, classified each as narration, and crossExamine then demoted every non-narrator sentence to the narrator (measured 2.7% non-narrator where the model produced ~68%). #1598 adds the real-world closers to German's quotePairs; #1601 then groups closers by opener and matches the nearest closer of any glyph („[\s\S]*?(“|”|")) so a can't lazily run past a nearer closer to a farther one of another glyph. Measured on real ch.2: engine output rose 2.7% → 60.8% non-narrator, back in line with en/fr/ru. German-only; single-closer languages are byte-identical. Regression tests in dialogue-structure/parser.test.ts. (#1598, #1601)
  • A script-review structure hint no longer names a speaker who isn't in the chapter — fixed at the root (#1768, #1774). buildStructureEvidence could emit a [structure: speech, tag→X] hint naming a character with no presence in the chapter (on Playing with Fire ch41, Springheeled Jack's dialogue hinted tag→Mrs Edgley, who never appears). Root cause: melissa-edgley's roster alias "her mother" tokenizes to the stem "her" which, unique in the roster, indexed as a name; when "her" precedes a speech verb in a gap, findSubjectName returned the absent character as a strong tag-name. The #1768 evidence-projection guard neutralized the weak-anchor variant; #1774 fixes it at the source in buildNameIndex — a multi-word alias indexes only tokens that read as proper names (leading char not a cased-lowercase letter), so "her mother" contributes nothing, while a single-word lowercase epithet ("шеф", "boss") still anchors, capitalized tokens ("the Captain"Captain) survive, and caseless CJK is never gated. The phantom now falls through to the correct pronoun resolution (tag→Mrs Edgleytag→Nora). Facet 2 of #1774 (the guard's conservative downgrade of a genuine weak-only name anchor) is retained as an accepted, never-wrong tradeoff (plan 265). Paired tests in name-matcher.test.ts + evidence.test.ts. (#1768, #1774)
  • A first-person narrator's «я» lines are no longer scattered onto a side character (#1681). In a first-person book (origin: Ночной дозор, where «Я» landed on side-character Егор rather than protagonist Антон), neither stage tied the single first-person voice to the narrator. The stage-1 prompt now carries an id-rule for the single first-person voice, the stage-2 prompt anchors «я» to the narrator id, and a bare first-person-pronoun phantom is force-merged into its alias-holder. (#1681)
  • A tiny chapter heading no longer traps stage-2 attribution in a coverage retry loop (#1671). When a chunk contained only a short heading (e.g. Глава 4), attributing it in isolation drove the coverage ratio implausibly high (~702), so every retry was rejected and the chapter re-analysed indefinitely. splitBodyIntoChunks now merges a tiny heading fragment into the adjacent body chunk. (#1671)
  • The analyzer reliably sets each character's gender — which also stops female minors collapsing into a male cast row (#1673). The GRAMMAR schema made gender optional while tone was required; under Ollama constrained decoding a small model emitted tone on 100% of characters but gender on ~1/64. Missing gender silently disabled roster-dedup's cross-gender merge gate, so female minors (девушка/Галина/женщина) chained into a male "unknown" row. Fix 1: gender required in analyzerCharacterSchema (neutral always available so decoding never deadlocks; validation schema stays optional so old cast.json loads). Fix 2 (defense-in-depth): a Tier-3 component-consistency check refuses to auto-merge any union-find component carrying both concrete genders. Paired tests in schemas.test.ts / roster-dedup.test.ts. (bug)
  • A character detected under multiple names merges into one cast row — during and after analysis (#1662, #1666, #1675). A new roster-dedup Tier-3 alias-coreference pass collapses duplicates when the model's alias evidence is strong (a mutual alias link, or a full-name link) and surfaces weaker overlaps as merge suggestions (#1662). Separately, the live "Cast so far" roster accreted names client-side per chunk, so a character could flash two or three pills and re-analysing baked doubles in: #1675 makes the live roster a full-snapshot replace, #1666 fixes the server-side canonical-id ingest so a merged id can't be re-added on the next chunk. With the gender-required fix above, the cast comes back deduplicated, keeping the strongest designed voice for each character. (#1662, #1666, #1675)
  • CJK books no longer false-refuse analysis when honorifics fuse to names (奥杜万师傅奥杜万) (#1602). reconcileSentenceCharacterIds demotes any id not in the Phase-0 roster; over 5% demotion refuses the whole run. In zh/ja an honorific/role-title fuses to a name with no separator (奥杜万师傅 = Master Oduvan, 卡斯珀寡妇 = Widow Casper), and Phase-0 rostering vs Phase-1 attribution don't always agree on including the title — so a title-variant false-demotes and enough trip the drift gate (observed ~8% on a zh Coalfall book). A new cjk-honorifics.ts strips a conservative documented set of leading/trailing honorifics; a remapCjkHonorificIds pre-pass resolves an orphan to its roster id via honorific-stripped matching, but only on an unambiguous single match — zero or >1 keeps demote-to-narrator, so identity is never invented. Gated to zh/ja (en/de/ru/es/fr byte-identical). Unit tests in cjk-honorifics.test.ts + analysis.test.ts. (#1602)

🎙️ Voice design & casting

  • A cast-confirmed book that hasn't started generating now reopens on the Cast view (voice design), not the Generate tab — new voices_pending stage + "Cast ready" library badge. A new server-derived voices_pending status (castConfirmed && !generationStarted && not-complete, "started" derived from disk — any rendered audio or failed chapter, so no state.json field and no migration) sits between cast_pending and generating; such a book routes to the Cast view, shows a "Cast ready" badge, and counts under "In progress". The library status-pill lookup gains a defensive fallback so an unknown server status can't blank-crash the grid. (#1560)
  • Every new book's narrator gets a localized name and one consistent folkloric voice, instead of an English "Narrator" on a Kokoro preset. Newly analysed books seed the narrator with a per-language display name (Erzähler / Рассказчик / Narrador / Narrateur; Narrator kept as an alias) and a fixed folkloric persona, so it gets a designed Qwen voice rather than the plan-108 preset fallback. Applied in both analyzer jobs; a user rename or re-designed narrator voice survives reparse. No migration. (#1574)
  • Move every line on a character to someone else in one action, with a single-level undo (#1676c). Fixing a mis-attributed narrator used to mean reassigning hundreds of lines one at a time (the origin case, Night Watch's first-person narrator, has ~700+ lines). A new reusable ReassignLinesModal is driven by a discriminated source: the roster's per-character action, a new script-view multi-select, and the alias-unlink flow all funnel through one form. Selection is a render-independent Set<compositeKey> over the FULL candidate list (never mounted/virtualized rows), with select-all/invert/per-chapter facets and a target picker. Apply re-validates every key against the live store (a drifted key is skipped and reported), dispatches one cross-chapter setSentencesCharacterBulk, and surfaces a book-session-scoped undo banner that survives Cast↔Manuscript nav and is explicitly nulled on every book transition. New Playwright golden path. See docs/features/1676-cast-bulk-line-reassignment.md. (#1676)
  • Unlinking an alias can re-point it onto an existing character, not only split it to a new one (#1676b). The alias-chip ✕ used to only split an alias into a new standalone character (forcing a split-then-merge). It now opens a destination dialog: keep today's split-to-new, or move the alias onto an existing roster character, backed by a new POST /api/books/{id}/cast/repoint-alias that atomically strips the alias off the source and dedup-appends it to the target. Either destination re-opens the ReassignLinesModal seeded to wherever the alias landed, so the lines follow it (line move keeps its undo; the name transfer has no undo by design — moving it back is a trivial unlink → move). New coverage: 6 server-route cases, reducer + persist-rule tests, unlink-alias-dialog.test.tsx, and an e2e move-to-X path. (#1676)
  • Approving a voice redesign now invalidates that character's emotion variants — they were derived from the base embedding the redesign replaces (#1570). A redesign overwrites the base qwen-<uuid>.pt in place, but the derived __<emotion>.pt variants, their overrideTtsVoices.qwen.variants slots, and the sidecar's cached prompts were left anchored to the OLD embedding — so synth kept routing tagged lines to the stale variant and the bulk "Emotion variants" scope read "all done". promote-voice now tears every variant down (drop the variants map first, then delete each .pt/.json + evict it) right after the base swap; the drawer mirrors the teardown in redux on approve. Delete-only — no auto-remint. Regression tests in qwen-voice.test.ts / cast-slice.test.ts / profile-drawer.test.tsx. (#1570)
  • The intermittent Qwen meta-tensor load fault retries in-process instead of recycling the whole sidecar (#1557). _load_qwen_model hitting NotImplementedError: Cannot copy out of meta tensor used to schedule an immediate sidecar self-recycle on the FIRST fault — but the fault is intermittent (a fresh from_pretrained in the same process loads cleanly ~8s later). The load now retries in-process (a fixed 2 attempts, VRAM-reclaimed between) on that fault class only; the self-recycle fires ONLY when the fault is persistent. A different error (CUDA OOM, deterministic fault) re-raises truthfully with no recycle, so the caller's own handling fires as before. Regression tests in test_qwen_load_reclaim.py.
  • Pausing a cast design during the local-model persona pre-pass stops promptly instead of waiting out the current synthesis chunk (srv-48 follow-ups). GpuSemaphore.acquire takes an optional AbortSignal (queued waiters removed on abort, no token leak), and the pre-pass threads job.controller.signal through preparePersonaBatchunloadResidentSidecar, so a pause during a multi-minute pass takes effect at once; an aborted evict-wait falls back to CPU personas rather than erroring. Also hardened two srv-48 regression tests. (#1561)
  • The per-book top-menu reads in workflow order — Cast · Manuscript · Generate · Listen · Log · Voices. Reordering the single TABS array aligns both surfaces it feeds (the inline xl strip and the hamburger drawer). Cast → Manuscript → Generate → Listen are the per-book steps in order; Log and the cross-book Voices library trail as cross-cutting controls. A new top-bar.test.tsx case locks the order. (#1632)

⚙️ GPU placement & synthesis engine

  • The hand-set GPU token budget is retired in favour of measured VRAM capacity admission — SEG_CAPACITY_ADMISSION, now ON by default (=0 opts out). The four confusing GPU knobs (GPU_VRAM_BUDGET, GPU_CONCURRENCY, six GPU_WEIGHT_*, GPU_SAFE_COEXIST_MB) and the weighted GpuSemaphore are deleted. Replacement: the Python sidecar owns TTS admission — GET /capacity (cross-vendor free VRAM), a FootprintTable seeding each engine's measured decode peak, and a PlacementController/ReservationLedger that atomically fit-checks-and-reserves the op's peak against min(free, total−Σreserved)−GPU_RESERVE_MB per device (returns 503 {noCapacity,neededMb,deviceKey} on no-fit) — covering every heavy op: synthesis, model /load, voice design + emotion-variant minting, ASR, and speaker-embedding. Node owns the analyzer (K limiter on a CountSemaphore), a CapacityProbe (sidecar-first, nvidia-smi/rocm-smi fallback), an Ollama residency read + evict, and the evict-or-bounded-poll orchestration on a 503 (an actionable NoCapacityError toast at the cap, never a hang). After the sidecar+Node admission suites and the on-box 1-card/2-card acceptance (plan 264), the flag now defaults ON; three adversarial review rounds folded. The only surviving knob is GPU_RESERVE_MB. See docs/features/264-vram-aware-gpu-placement.md. (closes #1720)
  • Capacity footprint estimation self-corrects instead of ratcheting to a one-off spike, the reserve is per-device, and the rare heavy ops learn their own footprint (#1737, #1738, #1742). _observed_mb read max_memory_allocated with no peak reset, so every per-op sample was the process-lifetime high-water mark — one ~9 GB co-residency spike poisoned every later reading, and FootprintTable.record only ratcheted up, so a 1.7B op was refused 503 on an 8 GB card it fit (and being refused, could never self-correct). Fixed: (1) the CUDA peak counter resets before each op; (2) FootprintTable reserves the windowed p95 of the last 64 observations (once ≥5 exist) instead of an up-only max; (3) seeds re-grounded on measured peaks; (4) GPU_RESERVE_MB is now sized per-device (min(5% of that card's VRAM, GPU_RESERVE_MB), default cap 768→500). Separately, voice-design + emotion-mint (~5654 MB measured vs a plain 1.7B synth's ~3915) were drowned in the synth-dominated p95 window and under-reserved by ~1.7 GB — each design-family op now carries its own qwen.1.7b.design/.mint footprint key and learns its own p95 (cold-start seeds 6144 MB). (#1737, #1738, #1742)
  • Idle-engine eviction under capacity admission is device-targeted (#1721). When a heavy op can't fit and the sidecar frees idle engines (Qwen VoiceDesign + 1.7B-Base, ASR, ECAPA), a new _same_card check gates each candidate on its resolved device, so it only evicts an engine on the same card as the admitting op — previously it evicted every idle engine process-wide, so on a multi-GPU box it could free an engine on a different GPU (a needless reload; a cpu-resident engine is now skipped too, since freeing it can't add VRAM). Regression coverage in test_devices.py. (closes #1721)
  • Qwen no longer ships silent/near-empty audio when the Base model degenerates under memory pressure (#1594). A silent variant of the #1558 meta-tensor bad-load: under 8 GB VRAM churn the Qwen Base model could enter a degenerate-load state where the forward runs without error but emits near-empty audio — a broken line that shipped silently, since the load-time meta-fault guard can't see a load that "succeeded." The single-synth path now inspects the output: if a substantial line renders implausibly short (below ~20 ms per speakable character — letters/digits only, so long separator/ellipsis lines never false-positive), it reloads the Base model and retries once, then escalates down the supervised recycle and fails loud rather than shipping bad audio. Default-on, promoted to the tts.qwen.degenGuard registry knob (renders as the Qwen degeneracy guard toggle in Advanced Settings). The batch path is a follow-up (#1593). New GPU-free regression module test_qwen_degeneracy_guard.py.
  • Kokoro no longer eager-loads at sidecar startup by default — the ~1 GB it held resident is now free unless you opt in. fs-60 flipped the registry default tts.preload.kokoro to false, but the sidecar's own Python fallback in _preload_default_engines was still hardcoded True and buildSidecarEnv omits a knob at its registry default — so PRELOAD_KOKORO never reached the sidecar and it eager-loaded anyway. The Python fallback is now False. Kokoro warms on demand (or via POST /load); opt back in with PRELOAD_KOKORO=1 (Advanced Settings → "Preload Kokoro at startup"). Regression test flipped in test_kokoro.py.
  • Installing/repairing Coqui XTTS v2 (and Qwen) from the Model Manager no longer aborts with ERROR: Constraints cannot have extras, and once installed Coqui actually loads and speaks CJK (#1567, #1586). install-coqui.mjs pinned its install against requirements/base.txt via pip install … -c base.txt, but base.txt legitimately carries extras (uvicorn[standard]) which pip forbids in a -c constraints file, aborting before it starts (three more -c base.txt sites lived in install-qwen3.mjs). A new shared pip-constraints.mjs strips [extras] from each line into a temp constraints file and all four sites route through it (#1567). Separately, coqui-tts 0.27.5 raises ImportError at package import when torch>=2.9 and torchcodec is absent (the venv pins torch 2.11 for the CVE bump), so the installer now runs pip install torchcodec --no-deps — torchcodec need only be present, never functional — and installs the XTTS CJK text frontends coqui-tts doesn't pull (pypinyin for zh; cutlet + unidic-lite for ja), which fs-59 makes load-bearing. Verified end-to-end (real zh-cn + ja XTTS renders, Whisper round-trip at p=1.00). Unit-tested in install-coqui-steps.test.ts. (#1586)
  • A failed model install/repair — and the analysing view's failed-cast-detection retry panel — are legible again in dark mode (#1567, #1668). The in-app install affordances render their error body as text-rose-900/80 and the retry panel uses text-amber-800/80 / text-amber-900/90 / border-amber-200/70, but the [data-theme='dark'] overrides remapped only the bare//85//70 selectors — the /80 and /90 alpha variants were missed, so failure detail painted dark-on-dark. Added the missing selectors; guarded in dark-mode-css.test.ts alongside the existing alpha cases. (#1567, #1668)

🧭 First-run setup

  • The first-run wizard splits analyzer and voice setup into two steps (Analysis first), closes the local-Ollama dead-end, and adds a primary/backup analyzer signal (fe-49). The combined Models step becomes a local-first Analysis step then a Voice step. The Analysis step reuses the admin OllamaInstall + ModelPullStatus machinery so a local-only user can list pulled models and pull the suggested one in-app (previously it showed "Ollama is installed" and stopped), with Local via Ollama presented first. Analyzer readiness gains a warn tri-state: green = will run + a backup is provisioned; yellow (non-blocking) = will run, no backup; red = won't run. The pass/fail gate is byte-identical to plan 240warn is a pure additive relabel of the former PASS set. The Defaults step auto-derives analysisEngine from the picked model id so choosing a local model there actually routes generation. Note: the default cloud-only setup now correctly reads yellow "ready — no backup" rather than solid green — intended and fully non-blocking. See docs/features/257-fe49-analyzer-wizard-split.md. (#1642)
  • The admin Model settings show the same tri-state analyzer-readiness badge as the wizard (fe-49 follow-up #1641). The "Install / update analyzer (Ollama)" card gains a green/amber/red "Analyzer ready" / "ready — no backup" / "Analyzer needed" chip reading the same server-resolved diagnosis on a 30s poll. Message-only. (#1643)
  • The Voice step reports one consistent voice-engine status (single models-status source); fixes #1612. The runtime badge and each engine's install card could disagree ("Runtime installed" next to "Kokoro is not installed" for the same state). A single server computation (computeModelsStatus, one probeSidecarHealth() per request) now backs both the readiness blockers and a new GET /api/setup/models-status; the client's install cards become controlled components off one getModelsStatus() fetch. A new VOICE_ENGINES registry scopes this to the three installable engines. See docs/features/258-wizard-models-status.md. (#1644) Post-#1644 polish (#1727): VenvBootstrap gates its error branch on !installedOnDisk so a green ready card wins immediately; the Model Manager row toggle label is single-sourced from models-status; and the ready-card Re-check wiring is locked for Qwen and Coqui.
  • fs-38 Part B / fe-51: the wizard recommends the voice engine from a one-question needs check + detected VRAM. A server-pure recommendEngines(vramTotalMb) rides Part A's existing models-status fetch (no new endpoint) and answers the Voice step's first question — "Do you want expressive and/or multilingual audio?": "yes" leads the Qwen card (Coqui alongside) badged "Recommended for you"; "no" leads Kokoro. Capability is a hard filter; detected VRAM is only a soft caveat — answering "yes" with no GPU still leads Qwen (Kokoro can't serve a non-English book at all), a deliberate revision of the spec's literal "CPU-only → Kokoro". Answering pre-seeds defaultTtsModelKey so the Defaults step shows the recommended model pre-selected. See docs/features/259-fe51-engine-recommendation.md. Closes #1614.
  • New installs keep the audiobook library in a discoverable ~/Castwright folder, and first-run gains a Library step to confirm or change it (fs-21 follow-up). Both launchers default a fresh install's WORKSPACE_DIR to ~/Castwright instead of an install-local folder buried under the app tree (worst on Pinokio, where it vanished on uninstall). Guarded by a directory-existence check on both sites so upgraders are untouched, and an unusable home falls back to the install-local path. The wizard's Library step shows the resolved path with an editable override (prefilled from the raw saved override, not the resolved path), a restart-required badge, and a warning when books already exist. See docs/superpowers/specs/2026-07-15-first-run-library-location-design.md. (#1664)
  • The wizard no longer forgets a step's answer on Back/Next — which also stops it clobbering the Defaults voice model. SetupWizard mounts only the current step, so the Voice step's guided answer (held in local state) was destroyed on every page; re-selecting it re-fired chooseNeedssaveAccountSettings, overwriting a finer model the user picked on Defaults. The answer now lives on the wizard (voiceNeeds) and StepVoice is a controlled component, so it stays selected across paging. Regression: an RTL nav round-trip + a Playwright spec that pages Voice → Defaults → Voice. Also aligns the LAN-access "Regenerate certificate" button to the secondary style. (bug)
  • ops-28 — LAN cert hardening in the setup wizard. The wizard now detects a missing/expired LAN-HTTPS certificate (previously a silent fallback to loopback HTTP) and can regenerate it in place; the Admin LAN-access card gained the same health detection. New GET /api/lan/cert/status. (#1609)
  • First-run wizard — help is now one click away (fe-52 / fe-53). Every setup step shows a persistent "Need help?" footer (getting started, install guide, troubleshooting, report a problem, ask a question) plus a contextual "Learn more" deep-link to the wiki page for that step. (#1615, #1616)

📝 Script review & manuscript

  • Script review opens as a collapsed per-chapter/per-type summary with group-level approve, so a whole-book run is reviewable instead of a flat wall of ~1,000 cards; newly-discovered speakers are created once, not once per line (closes #1747). The flat op-class body of ScriptReviewDiff becomes a three-level accordion (chapter → type → op card, collapsed by default) driven by a pure selectReviewSummary(bucket) aggregation — no slice shape change. The op taxonomy is single-sourced as BULK_APPROVABLE/EXPAND_ONLY: the six mechanical classes get chapter- and type-level Approve controls; the two high-stakes classes (reattribute, flag_nonstory) stay expand-only. A bulk Apply that can't fully land surfaces a partial-apply toast. Create-once: consolidateProposedByName groups off-roster reattribute ops by normalized name so a speaker on N lines prompts a single confirm, auto-applied to every line; a proposed name colliding with a live cast member still gets that one confirm. Unit coverage + a new e2e/script-review-summary.spec.ts golden path. Plan: docs/superpowers/plans/2026-07-20-script-review-summary-and-create-once.md. (#1747)
  • A read-only scene-change divider renders in the manuscript/script-review view (#1679). Wherever the source had a word-free scene break (* * *, dinkus , a dash rule, or an EPUB <hr>), a hairline-flanked ✦ divider renders above the sentence that opens the next scene. A new post-attribution annotateSceneBreaks pass runs at the universal exit of attributeChapterStage2, so it populates on every chapter regardless of language, mutating only an additive-optional sceneBreakBefore? field; stripHtml converts <hr> into a detectable * * * line so EPUB/MOBI breaks survive parsing. Binding is marker-anchored (a Russian-hardening correction), landing on the true opener even when its restructured text isn't locatable. Purely cosmetic — nothing about chunking, attribution, coverage, or synthesis changes. Measured 7/7 correct on clean EN fixtures; Russian/restructure-heavy acceptance still owed. See docs/features/261-manuscript-scene-separator.md. (#1679)
  • A malformed merge suggestion no longer silently blanks the entire script-review view — and hydration failures are surfaced, not swallowed (#1735, closes #1734). A merge op with missing/empty mergeIds threw in planApply's merge branch ([].some(m => !m) is vacuously false, so members[0]!.chapterId blew up); the throw aborted planApply mid-hydration and was swallowed by hydrateScriptReview's fire-and-forget void …finally(), leaving the bucket unpopulated — the review modal never opened, the count read 0, and a whole-book Review Script click started a fresh run that discards the persisted ledger (the reported "hours of analysis gone"; findings were intact on the server the whole time). Fix: planApply rejects a merge with fewer than two existing members as unappliable instead of throwing, and the mount-time hydration call gains a .catch that surfaces a toast. Regression test reproduces the exact TypeError. (#1735)
  • A mock-mode script-review race no longer spawns a duplicate in-flight timeline (dev/e2e only). A hydration effect re-firing for the same book now joins the single canned timeline with buffered-op replay instead of starting a second racing one; cancellation keys on per-run identity. Mock/dev/e2e only; the real backend was already protected by its job registry. (#1496)

📚 Sample library

  • The bundled demo pack now ships a runnable Coalfall sample in every supported language, and a guard keeps it that way (fs-61, #1027). samples/ previously carried only the English Coalfall Commission; a non-English user couldn't get a runnable demo, because a Qwen voice is calibrated in its design language. New captures samples/the-coalfall-commission-{es,fr,de,ru}/ each ship the localized manuscript + language-matched Qwen voices (distinct voiceUuid-keyed .pt files, so all five co-load without clobber). A new server guard (language-sample-coverage.test.ts) asserts every supported:true language has a sample whose state.json language matches — adding a language now forces adding its sample.
  • New scripts/slim-epub-cover.mjs trims each sample's epub from ~5.6 MB to ~0.3 MB. The captured epubs embed a 2048² PNG cover that is the file weight; the tool resizes/converts it to a ~1000px JPG in place, rewriting content.opf + the titlepage reference while preserving every text entry byte-for-byte. Five samples cost ~4 MB instead of ~28 MB. Pure core in scripts/lib/, hermetically unit-tested.
  • Coalfall manuscript locations consolidated (srv-49, #1043). The brand/test-book/ (masters) ↔ samples/ (shippable) split is now settled, and samples/the-coalfall-commission/ gains a manuscript.en.md so all five editions are uniform Markdown in one place. The .ru.md test fixture was deliberately kept — a live e2e/marketing fixture, not a stray duplicate.

📱 Companion app

  • Companion: on-device demo / guest mode. A "Try the demo" entry on the pairing screen runs the whole app against a self-contained sample library with no server and no network (airplane-mode safe) — unblocking the Google Play "Sign-in details" review for the Open-testing promotion. Refs #1575.
  • Pairing now works from the friendly hostname castwright.local, and the Listen-tab flow names the device (#1621, #1623). Both pairing routes were loopback-only, but a desktop UI at https://castwright.local/#/admin reaches the server as 127.0.0.2 (deliberately non-loopback) and 403'd. A new mayStartPairingSession(req) gate — isLoopbackRequest || (isLanTokenEnforced && isFriendlyHostnameRequest) — replaces the raw loopback check on both routes (both already behind requireLanToken, so the only non-loopback caller that can reach them is an already-paired device). Bare-LAN-IP stays loopback-only; unpaired devices stay blocked. The Listen-tab PairDeviceModal now opens name-first so a phone paired from the desktop shows its chosen name in the LAN-access list instead of "Device"; a genuine 403 shows actionable guidance. See docs/features/256-lan-pair-from-friendly-hostname.md. (#1623)
  • Marketing harness now produces tablet (7"/10") and foldable Play Store screenshots of the two-pane adaptive UI in light + dark (app-22, #1589). Follow-up: capture-companion.mjs now derives the rotation per device from adb shell wm size so a natural-landscape Pixel Tablet captures correctly instead of being rotated into portrait and rejected by the expanded guard (#1628).

🐛 Other fixes

  • A finished book's designed voices can no longer be wiped by an accidental re-analysis (voice-strip incident 2026-07-14, #1626). Two faults let a re-analysis destroy a book's per-character Qwen voices (the incident stripped every language edition of The Coalfall Commission). (1) A fresh: true re-analysis set the voice-preservation merge's prior cast to [], so the interim + final cast.json writes overwrote the book voiceless; analysis.ts now snapshots the prior cast even on a fresh run, stripped of reuse continuity via dropReuseContinuityKeepDesignedVoice, so overrideTtsVoices/voiceUuid/ttsEngine survive. (2) A confirmed book's leftover paused analysis snapshot resurfaced as a resumable "active analysis" that, on click, re-analysed; scanActiveAnalyses now excludes confirmed books and the cold-boot pill hydration skips a non-running snapshot on a confirmed book. Regression tests in analysis.test.ts / merge-analysis-cast.test.ts / active-analyses.test.ts / analysis-pill-gate.test.ts. (#1626)
  • A rendered chapter no longer reads permanently stale when a sentence carries an inline audio tag ([emphatic], … — #1564). The #1105 text-staleness diff assumed both sides hash the raw text; false for a tagged sentence. The synth path strips the tag before synthesis, so the render stamps textHash over the SPOKEN text while the manuscript keeps the tag for display — the hashes never matched, so the chapter showed ⚠ Sentences reassigned · regenerate to refresh forever (regenerating re-strips + re-stamps, so it couldn't clear). Fixed by stripping audio tags on BOTH sides before hashing (textHashForStale(stripAudioTags(...)) server-side; a new src/lib/audio-tags.ts mirror client-side), pinned by a shared vector. A real edit to a tagged sentence's spoken words still reads stale. Fixes existing renders with no re-render needed. (#1564)
  • The server no longer dies on EADDRINUSE — in production it auto-shifts to the next free port (srv-60, #1608). listenWithAutoRebind walks startPort…startPort+19 and binds the first free one, for both the HTTP PORT and HTTPS LAN_HTTPS_PORT branch. Gated to NODE_ENV=production (dev keeps the actionable "already in use" exit). The success handler attaches once via once('listening') so the sidecar supervisor / mDNS / :443 forwarder never double-spawn, and the actual bound port propagates to the listening log, LAN URLs + pairing QR, the forwarder target, and csrf-origin.ts's allowedOrigins() so a device on a shifted port isn't CSRF-403'd. Regression tests in crash-logging.test.ts + csrf-origin.test.ts. (#1619)
  • Hardened the export pipeline against path traversal from crafted manuscript metadata (CodeQL, #1690). A book whose chapter slug carried ../ could steer findChapterAudio() / the caption *.segments.json read outside the book's audio directory — the one genuinely under-guarded seam among 35 open code-scanning alerts. Every tainted path segment (chapter.slug, the export filename, staging filenames, author/title sub-folders) is now routed through sanitizeIdSegment / assertContained (server/src/util/safe-path.ts), a no-op on real slugs so legitimate paths are byte-identical. Also: patchSelection drops __proto__/constructor/prototype keys before merging (js/prototype-polluting-assignment), and the export sendFile error log uses a static %s format. Regression tests pin traversal containment + the proto-pollution filter. Plan: docs/features/262-codeql-path-injection-hardening.md. (#1690)

🆘 Help

  • Troubleshooting is now grouped into searchable, collapsible categories (failures + FAQs merged by topic); Help and Admin link out to the matching GitHub wiki page. (#1607)
  • The Gemini API-key field (setup wizard + Account) shows a "Get a Gemini API key" link opening a new wiki walkthrough page (Getting-a-Gemini-API-Key) — not straight to Google — so the fragile aistudio.google.com URL lives only in the wiki and a Google reshuffle is a wiki edit, not an app release. Reuses the shared WikiLink. (fe-50, #1633)

Full changelog: v1.13.0...v1.14.0

Clone this wiki locally