CLAUDE.md cites tests/test_model_config.py as the guard against reintroducing
retired OpenRouter model ids, but the file existed only on disk — the guard was
not in the repository. Same for the town geo, voice roster and voicemail
transcript tests, and the Reaper bleep-selection test.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Runtime state written by the app: session checkpoint, used-topics history,
regulars, intern lookups, voicemails, publish state through episode 58, and the
website clips index.
Also ignores what should never enter history: clips/, music/, mlx_models/,
remotion-demo/ and social_posts/ come to roughly 13 GB of media and model
weights, and data/costs.db is a binary SQLite database that cost_tracker
rewrites on every LLM call — committing it would attach a multi-megabyte delta
to future commits. cost_reports/ and avatars/ are regenerable, and reaper/peaks/
is a waveform cache.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Working-tree changes that had accumulated without being committed. The test
updates matter most: tests/test_caller_gen.py was left behind when caller_gen
started requiring voice and age in regulars_included, so the committed tree had
a failing suite that only passed locally.
- Caller dialog moves from Haiku 4.5 to Sonnet 4.6 (~$1/show to ~$3-4/show)
- Grok pinned to x-ai/grok-4.3; grok-4, grok-4-fast and grok-4.1-fast were
retired from OpenRouter, and llm.py swallows the 404 and returns empty text,
so a retired id makes callers go silent with nothing in the logs
- Devon's web_search now runs against SearXNG on the NAS
- Assorted TTS, audio, news, cost tracker and control-panel changes
- CLAUDE.md updated to match
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The intern reached 21 of 58 published transcripts as "Devin". The obvious fix —
seed his name in the Whisper initial prompt — only works for one of the two
transcription paths:
- backend/services/transcription.py (live show) uses mlx_whisper and does take
an initial_prompt. Added Devon there. This also picks up the Big Bend prompt
rewrite from the relocation work, which was sitting uncommitted.
- publish_episode.py, which actually produces the published transcripts, uses
LightningWhisperMLX, whose transcribe() signature is (audio_path, language).
It accepts no initial_prompt at all, so there is nothing to seed.
So the published path gets a deterministic correction pass instead:
fix_proper_nouns() rewrites known mishearings after transcription, preserving
casing (Devin/DEVIN/devin -> Devon/DEVON/devon) and matching whole words only,
so "Devinshire" is left alone. Verified against the real episode 58 transcript:
24 occurrences to 0, output byte-identical to the manual relabel in bf1afef.
Swapping the publish path to mlx_whisper would allow a real prompt, but that
changes the transcription engine for every episode and is a bigger call than
this warrants.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Writes website/episode/<slug>/index.html for every episode in the feed, 4.26 MB
of transcript text that search engines could not previously see, and replaces
the sitemap's 49 query-param URLs with 57 clean ones.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Whisper transcribed the intern as "Devin" in 21 of 58 transcripts — he is Devon
everywhere else: backend/services/intern.py, the website, and the show's lore.
Five files used both spellings for the same character, so this was transcription
drift rather than two people. Context confirms every instance is the intern
("our new intern, Devin here", "Devin, where's my coffee?").
436 replacements across 22 files, 384 insertions against 384 deletions — pure
substitution, no content added or lost. Speaker labels went from 219 DEVIN: /
17 DEVON: to 236 DEVON:.
This mattered now because the transcripts stop being .txt files nobody reads and
become indexed text on 57 episode pages.
Root cause is unfixed: the Whisper initial prompt in transcription.py does not
seed "Devon" as a proper noun, so new episodes will drift again. Added a test
that fails if any transcript reintroduces the misspelling.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
add_episode_to_sitemap() was removed in the previous commit, so nothing was
writing the sitemap any more. Publishing now shells out to
generate_episode_pages.py --sitemap right after the transcript is copied into
website/transcripts/, which builds the new episode's static page and rebuilds
the sitemap from the feed.
Deliberately non-fatal: by this point the audio is live on Castopod and the RSS
feed has been rebuilt, so a generator failure must not abort the publish. It
warns and moves on, and the page lands on the next run. Covered for non-zero
exit, timeout, and a missing generator script.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The worker rewrote <title> and og: tags for episode pages, but only when the
User-Agent matched a hardcoded social-crawler list. Googlebot, Bingbot, GPTBot,
ClaudeBot and PerplexityBot were all absent, so search and answer engines only
ever saw the generic shell. Serving crawlers different HTML than users is also
cloaking, which Google disallows.
Episode pages are static now, so the injection is dead code. Replaces it with a
301 from /episode.html?slug=X to /episode/X/. Published YouTube descriptions and
social posts still use the old form, so the redirect stays indefinitely.
The slug is restricted to [a-z0-9-] before it reaches the Location header:
verified that //evil.com, ../../etc/passwd, a CRLF payload and "><script> all
reduce to harmless on-domain paths.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The renderer emits .transcript-turn and .transcript-speaker, neither of which
had any CSS, so speaker labels and turns rendered unstyled. Extends the
existing .speaker-label rule rather than duplicating it.
Bumps style.css to v=7 everywhere — the file changed, and pages referenced a
mix of v=5 and v=6, so some visitors would have kept a cached copy without the
new rules.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Transcripts for episodes 39-58 existed only on disk. Wrangler deploys the
directory rather than the git tree, so the live site served them while git
never had them — a fresh clone or worktree came up 19 files short.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The site carried no geographic signal at all: the homepage title, description
and schema keywords never mentioned Alpine, Marfa or the Big Bend, and llms.txt
still described the show as broadcasting from a fictional desert hermit's RV
and being produced in New Mexico.
- Homepage title, description and OG/Twitter tags now lead with Alpine, Texas
and name the surrounding towns; keywords put the location terms first
- PodcastSeries schema gains contentLocation with Alpine's real coordinates
(from BIG_BEND_TOWNS), spatialCoverage for the region, and an about array
covering Alpine, Marfa, Terlingua, Big Bend National Park and the Marfa Lights
- llms.txt: corrects the setting throughout, adds the region to Key Facts, and
answers "what places does the show talk about"
- Fixes the stale "37+ episodes" in five places, using 50+ so it ages better
- Corrects three claims that were also wrong on How It Works: 68 voice profiles
(now 82), adaptive call shapes (deleted system), and 5 recording stems (now 7)
Also adds the Tier 2 plan for crawlable per-episode pages.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The How It Works page still described the show as set in New Mexico and Arizona
and cited stats from the caller generator that was deleted in the redesign.
- Rewrites the geography section around the six Big Bend locales the batch
prompt actually carries, and moves the two caller examples to Marathon and
Alpine
- Replaces the four stat tiles, which counted a name pool, call shapes and
regulars that no longer exist, with figures taken from the current code:
callers per show, usable voice roster, identity fields, named regulars
- Swaps the 70/30 advice split for the real roster mix, drops the "1,000
calling reasons" pool, and replaces the call-shape prose with the
anti-collision rule that superseded it
- Names Silas and Crispin as the regulars instead of Leon and Shaniqua
- Corrects the stem count to seven and control room channels to eight, both
of which gained a Devon track
- Removes two claims that the show tracks energy and pacing at runtime; no
such state exists, so the text now describes the thematic matcher and the
roster mix instead
- Moves the eight homepage testimonials from New Mexico and Arizona towns to
Big Bend, including the two whose quotes named the old location
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A --resume run rebuilt the episode title from the Castopod URL slug, which is
lowercased and stripped of punctuation, so episode 58 went to YouTube as
"Episode 58: Rayfield S Nephew The Marfa Lights And Why Nobody Believes Concho"
with a placeholder description. save_chapters() only persisted chapters, so
resume had no real metadata to fall back on.
Metadata is now written to <audio>.metadata.json during a normal run and read
back on resume. If that file is missing (episodes published before this change)
the title and description are read from Castopod instead. Slug reconstruction
survives only as a last resort: it warns loudly, flags the result, and anchors
the episode prefix strip so it no longer eats the phrase mid-title.
Reading back from Castopod needed its own fix — TO_BASE64 wraps every 76 chars
and mysql renders those breaks as a literal backslash-n, which made b64decode
throw and the lookup silently return None.
Also includes two changes that were already sitting in the working tree: the
YouTube tag budget fix that episode 58's invalidTags failure prompted (with its
test), and a metadata model bump to claude-haiku-4.5.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Previously _build_backgrounds ran two parallel Sonnet calls of 6
callers each, with zero cross-batch awareness. Each batch independently
gravitated to the same quirky archetypes (Coast to Coast weirdos,
specific hobbies) and produced clustered rosters — one recent show
had two BBQ competitors, two taxidermists, and two conspiracy-pattern
callers across 10 slots.
Collapses to a single Sonnet call generating all 10 at once so the
model can self-diversify across the full roster. The BATCH_SYSTEM_PROMPT
also gets an explicit ANTI-COLLISION RULE with concrete forbidden
examples (no two BBQ competitors, no two taxidermists, no two mystery-
signal callers, etc.) and an instruction to scan output for collisions
before finalizing.
max_tokens bumped 8000 -> 16000 to accommodate the larger response.
Wall-clock cost: ~30s -> ~60s, acceptable for the diversity gain.
Verified on a fresh reset: 10 fully differentiated callers, zero
archetype collisions.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Setting a show theme after the caller lineup was loaded caused every
caller button to keep its pre-theme label while the LLM dialog used
the post-theme bg names — host says "Hi Phil", caller replies "it's
Cody, actually". Three underlying bugs, all fixed here:
- _regenerate_backgrounds_for_keys ignored its keys parameter and
replaced the entire caller_backgrounds dict, clobbering used slots
along with unused ones. Now only the listed slots are touched.
- set_show_theme's used_keys detection compared record.caller_name
(the slim bg name) against CALLER_BASES[k]["name"] (the randomized
fallback name) — two different namespaces that never match, so
every slot was flagged unused. Now it matches against
session.caller_backgrounds[k]["name"].
- set_show_theme fired the regeneration as a detached asyncio task,
so the POST returned before bg was consistent. Even if the frontend
did reload /api/callers, it would race the regen. Now awaited.
Frontend setShowTheme/clearShowTheme now call loadCallers() after
the theme POST resolves so the button list actually refreshes, with
a "Regenerating..." button state during the ~30-60s wait.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Brings in the Phase 5B caller-generation rewrite:
- Slim two-stage pipeline: Sonnet 4.6 batch identity pregen at session
start, Haiku 4.5 live dialog per turn. Deletes CallerBackground
dataclass, shape/style/voice-matching, per-model routing, preflight UI.
- New caller_gen.py (slim prompt builder) and regulars_v2.py (Obsidian
lore loader for named recurring callers).
- Reworked caller buttons and info panel around the slim background:
identity, situation, signature, secret want. Removes shape badges,
energy dots, emotion info-badges.
- Inworld TTS: emotional_register -> (temperature, speed_adjust) mapping
via _emotional_register_to_params(). applyTextNormalization ON.
- Silas identity/voice leak fix, avatar gender, pre-warm batch gen.
- Archives old regulars to data/regulars.archived.json, adds 10 sample
caller transcripts under docs/samples/.
Post-merge fixes applied during resolution:
- intern.py: kept main's richer new_show() (NEW SHOW history marker,
trim, _save()) and _track_suggestion(); removed the branch's
duplicate minimal new_show() stub.
- tts.py: added 5 voice speed overrides (Graham, Malcolm, Victoria,
Loretta, Marlene) that main had tuned locally. Evelyn deliberately
NOT added to VOICE_PROFILES — she is in BLACKLISTED_VOICES for
unnatural prosody.
- Main's cost dashboard polish, LLM per-model params, and Devon show
context memory from commits c087c03..61b3cba carried through cleanly.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Adds new_show() that inserts a NEW SHOW marker into Devon's history
so he won't reference previous episodes' callers or topics, trims
older history while keeping some long-term memory, and clears
research_cache + pending suggestions. Buffered suggestions are now
tracked in history via _track_suggestion() so Devon won't suggest
the same thing twice. Devon history scoped to current show on ask.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Different models need different tuning for natural conversation:
Qwen gets high frequency penalty to fight repetition loops, Llama
gets warmer temp to reduce terseness, Grok/Mistral/DeepSeek/Kimi get
slightly warmer than Sonnet defaults. Bumps base httpx timeout from
10s to 30s and fallback per-call timeout from 8s to 20s.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Adds /api/costs/tts endpoint backed by get_tts_providers() in cost_db,
surfaces a Costs link in the control panel header, and small polish
to the costs page HTML/JS.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
session.caller was reading name/voice from CALLER_BASES randomized defaults
instead of the slim bg dict — causing Silas to appear as "Earl" with wrong
voice. Now prefers bg data when populated.
Also: avatar endpoint infers gender from voice pool, /api/callers returns bg
name, regulars get canonical voice force-locked, batch gen splits into two
parallel calls with pre-warm so subsequent resets are instant.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Changes to backend/services/tts.py:
- Add temperature: 0.9 and applyTextNormalization: "ON" to Inworld payload
(text normalization auto-speaks "$5,432", "Dr.", phone numbers, dates, etc.)
- Add _emotional_register_to_params() mapping caller's emotional_register
to (temperature, speed_adjust) across 5 families: sadness/grief,
anger/aggression, manic/excited, nervous/earnest, gruff/restrained.
- generate_speech_inworld() and generate_speech() now accept optional
emotional_register kwarg; all providers take it via the dispatch lambdas
(only inworld uses it; others ignore)
- Clamp speed to 0.5-1.5 after both emotional-register and per-text adjustments
In backend/main.py: plumb emotional_register through the two caller-dialog
TTS calls (auto-respond and ai-respond) by reading from the slim caller
background dict. Devon and cohost/announcer calls pass empty string and
hit the default (0.9, 0.0) branch — no behavior change.
Replace the CallerBackground isinstance branching in _summarize_ai_call
with direct reads from the new slim caller background dict
(emotional_register, situation, specific_details). Decouples CallRecord
population from caller_styles/caller_shapes so those fields can be
deleted in a later commit.
CallRecord schema preserved for backward-compat with existing
session_checkpoint.json. topic_category, emotional_state, energy_level
are now always empty strings (obsolete under slim architecture).