Author SHA1 Message Date
lukeandClaude Opus 5 9071ed36d3 Track design and implementation plans
Seven plan documents covering the website JS infrastructure, show theme, caller
quality overhaul, cost dashboard, the Alpine relocation, and caller variety plus
Devon's search — the record of why the current architecture looks the way it
does. Plus the press release draft.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 04:23:53 -05:00
lukeandClaude Opus 5 9d49b013c2 Track deploy, social, and Reaper scripts
Scripts already referenced from CLAUDE.md but never committed: deploy_searxng.sh
(which stands up the SearXNG instance Devon's web_search depends on),
post_milestone.py, generate_milestone_images.py, make_x_launch_assets.py,
schedule_x_launch.py, download_music.py, and the Reaper bleep-selection script.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 04:23:53 -05:00
lukeandClaude Opus 5 2071ae4181 Track four test files that were never committed
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>
2026-08-14 04:23:44 -05:00
lukeandClaude Opus 5 e470b6aaea Update runtime state and ignore generated media and analytics
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>
2026-08-14 04:23:16 -05:00
lukeandClaude Opus 5 44ef13336e Move caller dialog to Sonnet 4.6, add SearXNG for Devon, and fix stale model ids
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>
2026-08-14 04:21:55 -05:00
lukeandClaude Opus 5 2901e5f4fb Stop Whisper misspelling the intern's name as Devin
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>
2026-08-14 04:20:22 -05:00
lukeandClaude Opus 5 5f6429e6da Generate the 57 static episode pages and rebuild the sitemap
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>
2026-08-14 04:16:21 -05:00
lukeandClaude Opus 5 bf1afef351 Correct the intern's name to Devon across all transcripts
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>
2026-08-14 04:15:08 -05:00
lukeandClaude Opus 5 2429a2eb07 Regenerate episode pages and sitemap during publish
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>
2026-08-14 04:12:29 -05:00
luke fa5ab9aad2 Point internal links at clean episode URLs and retire the JS page 2026-08-14 04:10:03 -05:00
lukeandClaude Opus 5 25b0015f62 Redirect legacy episode URLs and drop the crawler UA gate
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>
2026-08-14 04:07:33 -05:00
luke b496b91a5d Generate sitemap from feed with clean episode URLs 2026-08-14 04:05:45 -05:00
luke 6951398872 Add episode page generator CLI 2026-08-14 03:58:24 -05:00
lukeandClaude Opus 5 61d24ae7d8 Style transcript turns and bump style.css cache version
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>
2026-08-14 03:52:06 -05:00
luke 56a6a2dfbe Add episode page renderer with PodcastEpisode schema 2026-08-14 03:46:01 -05:00
luke fdb9f57660 Add RSS feed loader for episode page generation 2026-08-14 03:41:53 -05:00
luke cc0d9ffc5d Add transcript parser for episode page generation 2026-08-14 03:34:14 -05:00
lukeandClaude Opus 5 f902eab701 Track the 19 episode transcripts that were never committed
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>
2026-08-14 03:29:18 -05:00
lukeandClaude Opus 5 8c71da543f Add Big Bend location signal to homepage metadata and llms.txt
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>
2026-08-14 03:28:16 -05:00
lukeandClaude Opus 5 5f7e3e7415 Update site copy for the Big Bend relocation and current caller system
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>
2026-08-14 03:14:59 -05:00
lukeandClaude Opus 5 00ddc2d1b4 Recover real episode metadata on --resume instead of rebuilding from the slug
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>
2026-08-14 02:54:29 -05:00
lukeandClaude Opus 4.6 1c7ac334b4 Collapse caller gen to single batch of 10 with anti-collision rule
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>
2026-04-10 02:05:17 -06:00
lukeandClaude Opus 4.6 8a20e679f6 Fix caller name mismatch after mid-session theme change
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>
2026-04-10 02:00:31 -06:00
lukeandClaude Opus 4.6 35ff78a922 Merge branch 'feature/caller-redesign' into main
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>
2026-04-09 23:43:56 -06:00
lukeandClaude Opus 4.6 61b3cba412 Publishing, website, reaper, and doc updates
Publish script, clip maker, website worker + data, reaper lua helpers,
audio settings, and CLAUDE.md doc reorg.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 23:39:57 -06:00
lukeandClaude Opus 4.6 44ac569fec Devon: track show transitions and avoid repeating suggestions
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>
2026-04-09 23:39:52 -06:00
lukeandClaude Opus 4.6 54ad04ae09 LLM: per-model params for caller_dialog + longer timeouts
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>
2026-04-09 23:39:45 -06:00
lukeandClaude Opus 4.6 c087c0362a Cost dashboard: TTS providers endpoint, header link, polish
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>
2026-04-09 23:39:40 -06:00
lukeandClaude Opus 4.6 d0bb50fe12 Fix Silas identity/voice leak, avatar gender, and add pre-warm batch gen
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>
2026-04-05 19:08:46 -06:00
luke 4e28f3fb84 Tune Inworld TTS: temperature, text normalization, emotional register mapping
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.
2026-04-05 14:54:15 -06:00
luke 9e37fbf124 Phase 5B commit 6: delete CallerBackground dataclass, relationship_context, update docs
Final cleanup pass for the caller generation redesign:
- Delete CallerBackground dataclass + all isinstance() checks
- Delete orphaned _build_relationship_context and session.relationship_context
- Delete Session.get_caller_model (caller_dialog category routes to haiku-4.5)
- Delete dead _get_show_energy (CallRecord no longer tracks energy_level)
- Delete unused tone_streak field
- Drop topic_category/emotional_state/energy_level from CallRecord
- Simplify caller property, _find_thematic_match, enrich, promotion paths to slim dict
- Drop broken emotional_state=/energy_level= kwargs from generate_speech calls
- _load_checkpoint drops pre-slim schema backgrounds; startup re-pregens if empty
- Rewrite CLAUDE.md "Caller Generation System" section for the new two-stage architecture

35 tests passing. backend/main.py: 4981 -> 4810 lines.
2026-04-05 13:56:08 -06:00
luke 83c7f441a8 Phase 5B commit 5: delete template generation, content pools, and topic history
Template-based caller generation has been replaced by the batch sonnet-4.6
path (caller_gen.generate_batch). All the template-era content now goes:

Template functions (~700 lines):
- _generate_returning_caller_background{_sync}
- _generate_pool_weights, _is_spicy, _is_absurd, _filter_used
- _pick_unique_reason
- generate_caller_background (the big template fallback)
- _generate_caller_background_llm
- pick_location, _get_time_context, _get_moon_phase, _get_seasonal_context

Content pools (~4600 lines):
- PROBLEMS, STORIES, ADVICE, GOSSIP, HOT_TAKES, CELEBRATIONS, WEIRD,
  TOPIC_CALLIN, TV_TONIGHT, PROBLEM_FILLS
- INTERESTS, QUIRKS, PEOPLE_MALE, PEOPLE_FEMALE, RELATIONSHIP_STATUS,
  CALLING_FROM, MEMORIES, STRONG_OPINIONS, VERBAL_TICS
- JOBS_MALE, JOBS_FEMALE, LOCATIONS_LOCAL, LOCATIONS_OUT_OF_STATE
- CALLER_STYLES, CALLER_STYLE_KEYS

Topic history (~50 lines):
- TOPIC_HISTORY_FILE, _topic_history, _load_topic_history,
  _save_topic_to_history

Orphaned helpers:
- SHAPE_DIRECTIVES, _get_pacing_block, _get_speech_block

Rewrite _regenerate_backgrounds_for_keys to re-run _pregenerate_backgrounds
so the batch path handles theme-change regen too. Rewrite
Session.get_caller_background to be a pure getter — backgrounds are
populated by _pregenerate_backgrounds at session start, no lazy fallback.

Drop session.pool_weights field and its checkpoint save/load.
2026-04-05 13:38:54 -06:00
luke 9982f65742 Phase 5B commit 4: delete style/shape/voice-matching/model-routing systems
Backend cleanup:
- Delete STYLE_SPEED_MODIFIERS, STYLE_PHONE_QUALITY constants
- Delete _normalize_style_key, _match_voices_to_styles,
  get_style_speed_modifier, get_style_phone_quality functions
- Delete _pick_caller_style, _HEAVY_STYLES, _LIGHT_STYLES,
  _EVASIVE_STYLES (only called by template path)
- Delete _is_informal_style + _INFORMAL_STYLES
- Remove orphaned calls to deleted _assign_call_shape
- Drop shape parameter from _pick_response_budget
- Simplify _assess_call_quality (no more shape/style/pool_name)
- Delete Session fields: caller_styles, caller_shapes,
  caller_model_strategy/pool/map/fallback/models/_cycle_idx
- Remove those fields from Session.reset, checkpoint save/load,
  and session.caller property
- Drop style/shape lookups in chat callsites and promotion gate
- Update config.py caller_dialog model stale comment
2026-04-05 13:17:37 -06:00
luke ab811bda91 Delete caller-model routing + preflight admin UI
Backend (~273 lines):
- Removed GET/POST /api/caller-models endpoints (strategy/pool/map/fallback config)
- Removed POST /api/caller-models/{caller_key} override endpoint
- Removed GET /api/show/preflight diagnostics endpoint

Frontend:
- HTML: dropped Preflight header button, Caller Models settings panel,
  Preflight modal, and caller-model-badge/override from info panel.
- JS (~328 lines): removed loadCallerModels, saveCallerModels,
  updateCallerModelUI/Badges, overrideCallerModel, showCallerModelBadge,
  populateCallerModelOverride, runPreflight + render helpers,
  MODEL_ABBREVS, CALLER_STYLES, and all their listeners and init calls.
- CSS (~184 lines): removed .cm-*, .preflight-*, .caller-model-*,
  .model-tag, .info-badge.model, .caller-model-override rules.

Session fields (caller_model_strategy/pool/map/fallback/models,
caller_styles, caller_shapes) still present — they wire into shape
picking and checkpoint restore and get deleted in the next commits.
2026-04-05 12:42:59 -06:00
luke 13d613df5f Rework caller buttons and info panel for slim backgrounds
Backend:
- /api/callers now returns slim fields: identity, situation, signature,
  secret_want, voice (was: energy_level, emotional_state,
  communication_style, signature_detail, situation_summary, pool_name,
  call_shape).
- /api/call/{caller_key} caller_info swapped to slim fields too.

Frontend:
- Caller buttons stripped to name + shortcut + returning star. No more
  energy-dots or shape-badges — those systems are being deleted.
- Caller info panel shows identity (title) / situation / signature
  (quoted detail) / secret_want (amber italic, host-only).
- Removed .energy-dot, .shape-badge, .info-badge.shape/energy/emotion
  CSS rules. Added .caller-identity and .caller-secret-want.
2026-04-05 04:26:04 -06:00
luke effd52a95c Populate CallRecord from new slim caller backgrounds
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).
2026-04-05 04:21:05 -06:00
luke 76feeb22a0 Collapse caller generation to single path, remove feature flag 2026-04-05 04:05:09 -06:00
luke 3f85ee9140 Add sample caller dialogue generation script + 10 validation samples 2026-04-05 03:40:07 -06:00
luke 604769a949 Tighten slim prompt: hard-ban stage directions 2026-04-05 03:15:53 -06:00
luke 969f8fb2fd Wire batch gen into Session.reset behind CALLER_REDESIGN flag 2026-04-05 03:06:42 -06:00
luke d4f1043be1 Add feature flag for slim caller path (haiku-4.5) 2026-04-05 03:02:45 -06:00
luke a18e1329f3 Add slim caller prompt builder (~400 tokens) 2026-04-05 03:01:16 -06:00
luke 2fd027b82a Add writer for promoted tier-2 regulars 2026-04-05 02:56:20 -06:00
luke 0c5d36d182 Strip markdown fences in _call_sonnet response 2026-04-05 02:46:38 -06:00
luke d7e475331d Add promotion gate for tier-2 regulars 2026-04-05 02:43:32 -06:00
luke e4a18e13ab Archive non-Silas regulars for redesign cutover 2026-04-05 02:40:28 -06:00
luke 470e92f8c4 Add Regular dataclass + lore file loader 2026-04-05 02:36:25 -06:00
luke b792c3cca0 Strip markdown fences in parse_batch_response 2026-04-05 02:31:17 -06:00
luke baa6db5aa2 Add batch generation function using sonnet-4.6 2026-04-05 02:25:24 -06:00
luke dec6211f7c Add batch prompt builder for caller_gen 2026-04-05 02:23:34 -06:00
luke ade5510bd5 Add voice roster validator for caller_gen 2026-04-05 02:22:10 -06:00
luke f56ca3d4bc Add CallerIdentity dataclass and batch JSON parser 2026-04-05 02:14:58 -06:00
lukeandClaude Opus 4.6 a196e8f088 Add caller generation redesign implementation plan
22 tasks across 5 phases: scaffolding, regulars v2, main.py integration
behind feature flag, user validation gate, then deletion of ~2000 lines.
TDD where possible; manual verification of LLM outputs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 02:09:27 -06:00
lukeandClaude Opus 4.6 ca7e82f19f Add missing InternService.new_show() method
Session.reset() calls intern_service.new_show() to reset per-show state,
but the method was never defined (added in 376265e without the impl).
Clears pending_interjection, pending_sources, and research_cache.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 02:02:53 -06:00
lukeandClaude Opus 4.6 1b988a0303 Ignore .worktrees/ directory
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 01:58:32 -06:00
lukeandClaude Opus 4.6 fa491c1c27 Add caller generation redesign plan
Two-stage architecture: sonnet-4.6 pre-gen batch for rich identities,
haiku-4.5 for live dialog. Replaces static pools + 9-model routing +
scoring/weighting scaffolding. Introduces tiered regulars system with
Silas as canonical (lore in Obsidian vault) and hard-gated arc regulars.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 01:57:32 -06:00
lukeandClaude Opus 4.6 486842d2d1 Add cost dashboard frontend: HTML, CSS, JavaScript
- costs.html: summary cards, 4 Chart.js charts, expensive calls table,
  sessions table with drill-down, session detail view
- costs.css: dark theme matching existing style.css variables, responsive
  grid layout, card/table/chart styling
- costs.js: parallel API fetching, Chart.js rendering (timeline, doughnut,
  bar, session bars), session detail drill-down, period tab filtering

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-02 23:58:22 -06:00
lukeandClaude Opus 4.6 9c9f5c6602 Add cost dashboard API endpoints and page route
7 new endpoints: summary, timeline, models, categories, sessions,
session detail, expensive calls. All support period filtering.
Page route at /costs serves costs.html before the catch-all.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-02 23:49:42 -06:00
lukeandClaude Opus 4.6 73d555b269 Dual-write cost tracker records to SQLite
Each LLM/TTS call now writes to both in-memory records and SQLite
via cost_db. Session totals updated on save(). All DB writes wrapped
in try/except to never break live shows.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-02 23:47:41 -06:00
lukeandClaude Opus 4.6 283e4a2f3c Fix cost_db review issues: thread safety, indexes, consistent filtering
- Add check_same_thread=False for background thread access
- Add standalone timestamp index for non-session queries
- Join to sessions table for consistent period filtering in get_models/categories/expensive_calls
- Wrap record imports in try/except with .get() for timestamp
- Add avg_cost_per_session to get_summary()

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-02 23:46:40 -06:00
lukeandClaude Opus 4.6 0d2a0e42a8 Add SQLite cost analytics database module
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-02 23:39:57 -06:00
lukeandClaude Opus 4.6 376265eec7 Show quality fixes + preflight check
Ep47 post-mortem: fixed theme ignored by callers (backgrounds now
regenerate when theme is set), style-to-model race condition (fallback
to sonnet instead of pool[0]), removed bad pronunciation fixes, added
age-awareness to voice matching, raised MIN_RESPONSE_WORDS to 50.

Swapped problematic model mappings: conspiracy→qwen, know_it_all→mistral,
quiet_nervous→llama, emotional→kimi.

Added GET /api/show/preflight endpoint with 4 checks: model diversity,
theme penetration, voice-age alignment, response coherence (2-exchange
simulation of all callers). Frontend preflight modal with expandable
check cards.

Fixed active caller button not highlighting (moved highlight code before
potentially-failing caller info panel code).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 01:17:34 -06:00
lukeandClaude Opus 4.6 f3c91fc385 Devon personality + Whisper name fix + music vocal filtering
- Devon: more conversational when addressed directly (500 tokens, 3-5 sentences)
- Devon: monitor prompt rewritten to encourage more contributions
- Devon: polling interval 15s (was 30s), removed 2-message minimum
- Whisper: no fuzzy name matching for 3-char names, require first letter match
- fetch_music.py: post-fetch vocal detection filter using musicinfo tags
- scan_music_vocals.py: new script to scan existing library for vocal tracks

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 23:59:03 -06:00
lukeandClaude Opus 4.6 c69c2ad532 Fix tonight's show issues: Whisper bias, boring callers, Devon, short responses
- Remove caller names from Whisper hint (was corrupting transcriptions)
- Background gen switched to Claude Sonnet 4.6 (cheap models = thin backgrounds)
- "WHAT MAKES A GOOD CALLER" rewritten with concrete examples
- Grok guardrails loosened (were cutting too much edge)
- Response length guidance added to caller prompt
- Retry under-20-word responses once for more detail
- Devon monitor softened from "default silence" to balanced
- Ban stalling phrases: "where was I", "as I was saying", etc.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 05:21:23 -06:00
lukeandClaude Opus 4.6 8dbbd92d3a Fix returning caller eligibility — 1+ calls, not 2+
The 2+ requirement created a catch-22: regulars couldn't return because they
needed 2 calls, but couldn't get a second call without returning. Dynamic
count already prevents flooding.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 03:42:53 -06:00
lukeandClaude Opus 4.6 fa36f8d184 Dynamic returning caller count — need 3+ eligible for variety
Only inject 2 returners if pool has 3+ eligible (so it's not the same every show).
With 2 eligible, inject 1. With 1 or 0, inject none.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 03:38:47 -06:00
lukeandClaude Opus 4.6 794ad98cf0 Replace music dropdown with genre quick-select buttons
- One-click genre buttons play random track from that genre
- Active genre highlighted, now-playing bar shows track name
- Only genres with tracks shown, crossfade on genre switch
- M key replays active genre or picks random

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 03:34:44 -06:00
lukeandClaude Opus 4.6 f5eabd7dc4 Add fetch_music.py (Jamendo API) + expand genre keywords
- Downloads instrumental tracks from Jamendo by genre (jazz, lofi, blues, ambient, etc.)
- Filters: no vocals, 60-300s, sorted by popularity
- Saves to music/ with genre tags, tracks attribution
- Add genre keywords: ambient, chill, acoustic, classical, country, electronic

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 03:18:26 -06:00
lukeandClaude Opus 4.6 f717edeacb Fix style map key mismatch — API uses 'map', frontend was using 'style_map'
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 02:45:21 -06:00
lukeandClaude Opus 4.6 56607879ee Fix style-matched dropdowns — populate from full model list, not just pool
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 02:42:53 -06:00
lukeandClaude Opus 4.6 fcefabdaee Expand style-matched routing to 10 models for maximum caller variety
- Grok 4.1 Fast: high_energy, bragger, comedian, small_town_gossip
- Grok 4 Full: confrontational (needs deep reasoning for arguments)
- Claude Sonnet 4.6: quiet_nervous, emotional (genuine vulnerability)
- Kimi K2: sweet_earnest (warm, creative, different texture than Claude)
- Mistral Large: deadpan, mysterious (dry, precise)
- DeepSeek Chat: angry_venting (raw, unfiltered rage)
- DeepSeek R1 Distill: oversharer, conspiracy (commits fully, no hedging)
- Qwen: storyteller, rambling (loves tangents and detail)
- Gemini 2.5 Pro: know_it_all (pedantic, cites sources)
- Llama 3.3 70B: world_weary, reluctant, first_time (casual, natural)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 02:40:09 -06:00
lukeandClaude Opus 4.6 58495d2c75 Fix stale model detection — validate against current OPENROUTER_MODELS
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 02:37:21 -06:00
lukeandClaude Opus 4.6 51961dc19b Fix stale model map detection — check if all values are same model
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 02:33:47 -06:00
lukeandClaude Opus 4.6 c516402402 Update model routing with latest OpenRouter models
Style-matched defaults:
- Grok 4.1 Fast for edgy callers (high_energy, confrontational, comedian etc.)
- Claude Sonnet 4.6 for emotional callers (quiet_nervous, sweet_earnest, emotional)
- Mistral Large 2512 for deadpan/mysterious/world-weary
- DeepSeek R1 Distill for storyteller/oversharer/conspiracy/rambler
- Gemini 2.5 Flash for know_it_all
- Llama 3.3 70B for first_time/reluctant callers

Category routing: Grok 4.1 Fast for dialog/devon/backgrounds, Gemini Flash for monitor/summary
Updated OPENROUTER_MODELS and OPENROUTER_PRICING with all new models

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 02:31:33 -06:00
lukeandClaude Opus 4.6 e614599650 Fix checkpoint restoring stale caller model defaults
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 02:20:32 -06:00
lukeandClaude Opus 4.6 d36de95577 Default caller model strategy to style_matched
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 02:18:36 -06:00
lukeandClaude Opus 4.6 0147be4e0c Normalization diagnostics + SFX track support
- Detailed logging for normalize_track_items (item count, RMS, gain, applied/skipped)
- Add SFX track normalization (track 5)
- Will reveal why ad/ident normalization silently fails

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 02:14:34 -06:00
lukeandClaude Opus 4.6 390f138601 Devon improvements: independent audio, realism overhaul
- Devon audio independent of caller hangup (separate stop events)
- Personal anecdotes capped at ~30% of responses (was every time)
- Interjection criteria tightened ("default is silence")
- Devon sees his own recent history to avoid repeating info
- Response variety: permits minimal reactions, confusion, silence
- Monitor prompt rewritten to be gatekeeping, not encouraging

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 02:08:22 -06:00
lukeandClaude Opus 4.6 9eaf2fe5e3 Fix avatar misgendering, returning caller overflow, false callbacks
- Avatar prefetch checks gender marker, re-fetches on mismatch
- Returning callers need 2+ actual calls before re-eligible (was 1)
- Promotion rate lowered 10% → 5% to prevent pool flooding
- Callback injection skipped for returning callers (already have context)
- Show history clarifies "you are NOT that caller" to prevent identity confusion

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 02:03:07 -06:00
lukeandClaude Opus 4.6 314d5f9452 Caller model routing — cycle, style-matched, mid-show override
- Three strategies: single model, cycle through pool, style-matched
- 18 communication styles mapped to 7 models (Grok, Sonnet, Mistral, Qwen, DeepSeek, Gemini, Llama)
- Per-caller model locked for entire call, overridable mid-show
- Model badges on caller buttons and info panel
- Settings UI for strategy, pool, style mapping, fallback
- Fallback to Sonnet on model failure
- 6 new models added to pricing and dropdown
- Checkpoint persistence for all model state

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 01:58:03 -06:00
lukeandClaude Opus 4.6 e0fb3cac68 Make make_clips.py resilient — timeouts, retries, skip-on-failure
- 60s timeout + retry on all LLM calls
- 120-300s timeout on all subprocess/ffmpeg calls
- Per-clip error isolation (one failure doesn't kill the run)
- Progress indicators for each clip being processed

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 17:36:41 -06:00
lukeandClaude Opus 4.6 4589670b37 Fix Whisper misspelling caller names — hint + fuzzy correction
- Pass all caller names as Whisper initial_prompt hint for correct spelling
- Post-transcription fuzzy match corrects remaining misspellings (Levenshtein)
- Prevents AI callers from "correcting" the host on their own name

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 07:42:18 -06:00
lukeandClaude Opus 4.6 eb1e18a997 Strip stage directions before TTS, strengthen prompt bans
- Regex strips all parentheticals and asterisk actions before TTS
- Catches (laughs nervously), *sighs*, etc. that Grok generates
- Strengthened SPEECH ONLY instructions in caller and Devon prompts

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 23:40:45 -06:00
lukeandClaude Opus 4.6 6dcdf20289 Grok 4 routing, guardrails, pricing fix, strip silence improvements
- Route caller_dialog, devon_ask, background_gen to x-ai/grok-4
- Add Grok-4 to OPENROUTER_MODELS and OPENROUTER_PRICING
- Add Grok-specific banned phrases (I hear you, fair enough, that's wild, etc.)
- Add background gen guardrails for Grok (no active violence, no real public figures)
- Soften theme prompt hot-take language for organic connections
- Tighten Devon flirting guardrail (awkward not crude)
- Fix Devon "first day" contradiction on line 36
- Strip silence: preserve music intro, fix ad normalization (direct WAV reading)
- Strip silence: loop range starts 0.5s before audible music

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 17:46:29 -06:00
lukeandClaude Opus 4.6 762b5efc3b Strip silence: preserve music intro, fix ad normalization, smart loop range
- Preserve first silence in first DIALOG region (music intro before host speaks)
- Fix ad/ident normalization using direct WAV reading (accessor failed after splits)
- Loop range starts 0.5s before audible music, ends at last item
- Disable broken music lead-in nudge (intro preservation handles it)
- Caller dialog model set to Grok for testing

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 02:32:34 -06:00
lukeandClaude Opus 4.6 3dd6a83c68 Full app audit: 24 fixes across backend, frontend, infra, content, social
Critical fixes:
- Fix hangup-during-respond crash (null caller guard)
- Fix double-click caller race condition
- Stem recorder: non-daemon thread, disk error handling, 30s flush timeout
- Frontend startCall() error handling

High priority:
- Devon: filter tool errors from speech, shorter monitor prompt, 30s interval
- TTS ghost message fix (add to history after TTS, not before)
- Expand banned phrase list (12 new phrases)
- Increase returning callers from 1 to 2 per session
- Platform-tailored social posts with staggered scheduling
- YouTube dynamic tags from episode content
- Social post retry logic (2 attempts, 5s delay)
- Frontend: error handling on all raw fetch calls

Medium:
- stem_recorder null check race (local var capture in audio.py)
- Reactive shape directive expanded
- REACT TO LUKE moved higher in caller prompt
- Devon tenure updated ("few weeks" not "first day")
- D shortcut Escape to unfocus
- Volume slider debounced (150ms)
- Settings modal widened to 550px
- Backup script (daily MariaDB dump + data/ rsync to NAS)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 14:57:50 -06:00
lukeandClaude Opus 4.6 5e98ed0e11 Fix LinkedIn posting to use correct account, blocklist personal profile
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 14:02:47 -06:00
lukeandClaude Opus 4.6 fcf13bae22 Fix repetitive episode titles — require specific caller/situation references
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 04:06:12 -06:00
lukeandClaude Opus 4.6 c30a75cc8f Fix X/Twitter posting — add who_can_reply_post and __type params
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 04:02:55 -06:00
lukeandClaude Opus 4.6 90e51698b8 Devon fixes, theme prompt rewrite, sentence trimmer, cost tracker, normalization
- Fix Devon "if that makes sense" overuse (limit to once per show)
- Suppress Devon failed lookup notifications for self-initiated searches
- Strengthen show theme prompts (2/3 callers call because of theme)
- Fix sentence trimmer splitting on abbreviations (Mr. Mrs. Dr. etc.)
- Fix cost tracker data lost on server restart (persist in checkpoint)
- Ad/ident normalization targets -4dB below dialog for perceived loudness match
- Lower cross-speaker transition threshold to 5s

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 03:55:55 -06:00
lukeandClaude Opus 4.6 5d8ab57e20 Show theme feature, Irish music genre, strip silence overhaul
- Add show theme UI in header bar + backend API (inject into caller prompts)
- Add Irish genre category for music dropdown
- Strip silence: RMS-based speaker detection (fixes Devon not being identified)
- Strip silence: Devon-specific 3s threshold for interjections
- Strip silence: sparse track item handling in shift logic
- Strip silence: music lead-in preservation after silence removal
- Strip silence: no max gap limit (IDENT/AD regions protect breaks)
- Add analyze_gaps.py tool for per-show threshold analysis

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 03:30:15 -06:00
lukeandClaude Opus 4.6 d33a022676 Add show theme feature for themed episodes
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 23:46:48 -06:00
lukeandClaude Opus 4.6 7e2ef1fa2b Add MIT license, add X to social posting platforms
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 01:39:25 -06:00
lukeandClaude Opus 4.6 164cad456c Devon own stem/track/channel, per-category LLM routing, settings UI cleanup
Audio:
- Devon gets own stem, Reaper track (Input 17), and configurable channel
- play_caller_audio accepts stem_name + channel_override params
- Reaper script checks 4 voice tracks (Host, Devon, Live Caller, AI Caller)
- postprod.py includes devon stem in gap detection

Cost optimization:
- Per-category model routing: Sonnet for caller dialog, Gemini Flash for everything else
- Estimated 65% cost reduction ($4.32 → ~$1.50/show)
- Category models configurable from settings UI

Frontend:
- Settings panel: clean routing grid for output channels, model routing grid for LLM categories
- Devon channel added to audio routing
- Share icon SVG fill fix (currentColor)
- Website homepage iterations

Publishing:
- Revert Castopod API workaround (API re-enabled)
- Fix container media path

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 17:05:19 -06:00
lukeandClaude Opus 4.6 0b091a1afd Homepage redesign, ep38 publish, Castopod fix, share icons, avatar gender
Website:
- Full homepage redesign: new hero with punchy tagline, social proof strip
  with real caller quotes, featured episode spotlight, clips moved up
- Remove Q&A section, cover art from hero, secondary links
- Fix share icon fill (currentColor), add .sr-only class
- Bump cache versions to v=6

Backend:
- Blacklist Celeste voice
- Fix avatar gender caching with marker files
- Fix _match_voices_to_styles() bypassing BLACKLISTED_VOICES

Publishing:
- Fix Castopod container path (/var/www/castopod/ → /app/)
- Revert CLOUDFLARE_UPLOAD_LIMIT workaround (API fixed)
- Publish episode 38

Reaper:
- Dual silence threshold (2.5s transitions, 6s same-speaker)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 14:31:37 -06:00
lukeandClaude Opus 4.6 cfc7ad39f2 Add missing .sr-only CSS class
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 03:40:51 -06:00
lukeandClaude Opus 4.6 8a64a269f3 Remove duplicate h1, fix avatar gender caching, blacklist Celeste voice
- Hide h1 (sr-only) on homepage — banner already shows show name
- Promote tagline as visual lead after banner
- Fix avatar gender: add .gender marker files, re-fetch on mismatch
- Clear stale avatar cache so all re-fetch with correct gender
- Blacklist Celeste voice from caller pool

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 03:28:18 -06:00
lukeandClaude Opus 4.6 908255e5cf Clean up hero section, fix Silas voice exclusion bug
- Remove cover art from hero (duplicated in clips below)
- Merge about section into hero for single flowing layout
- Center hero content, remove side-by-side layout
- Fix _match_voices_to_styles() bypassing BLACKLISTED_VOICES —
  Sebastian could get assigned to non-Silas callers

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 03:21:13 -06:00
lukeandClaude Opus 4.6 39297d4aa5 Growth features: share buttons, NEW badge, sticky CTA, newsletter cross-promote
- Share buttons on episode and clip cards (Web Share API + clipboard fallback)
- NEW badge on latest episode card
- Sticky call-in CTA bar (appears after hero scrolls out)
- Daily AI Briefing newsletter cross-promote in footer
- Bump cache versions to v=5

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 01:23:43 -06:00
lukeandClaude Opus 4.6 d39cb3f3d4 Website overhaul: nav, accessibility, shared components, SEO, Reaper silence detection
Website:
- Add persistent top nav across all pages
- Add skip-to-content links, focus-visible styles, ARIA on audio player
- Fix text contrast for WCAG AA compliance
- Add 600px breakpoint, mobile typography scaling
- Extract shared footer.js, player.js, episode.js components
- Episode pagination (10 + Load More), featured clip dedup
- Worker meta injection for social crawler OG tags
- Unify Plausible analytics proxy across all pages
- Sanitize innerHTML for XSS safety
- Custom 404 page, enhanced llms.txt, fix sitemap
- Bump cache versions to v=4

Reaper:
- Add dual silence threshold: 2.5s for speaker transitions, 6s for same-speaker gaps

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 00:56:29 -06:00
lukeandClaude Opus 4.6 c70f83d04a Cost monitoring, PTT fix, Devon tuning, WEIRD pool expansion, YT thumbnails, LLM SEO, publish ep37
- Add real-time LLM/TTS cost tracking with live status bar display and post-show reports
- Fix PTT bug where Devon suggestion layout shift stopped recording via mouseleave
- Devon: facts-only during calls, full personality between calls
- Double WEIRD topic pool (109→203), bump weight to 14-25%
- Auto-generate YouTube thumbnails with bold hook text in publish pipeline
- LLM SEO: llms.txt, robots.txt for LLM crawlers, structured data, BreadcrumbList schemas
- Publish episode 37

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 05:33:27 -06:00
lukeandClaude Opus 4.6 3329cf9ac2 UI cleanup, Devon overhaul, bug fixes, publish ep36
- Fix Devon double messages, add conversation persistence, voice-to-Devon when no caller
- Devon personality: weird/lovable intern on first day, handles name misspellings
- Fix caller gender/avatar mismatch (avatar seed includes gender)
- Reserve Sebastian voice for Silas, ban "eating at me" phrase harder
- Callers now hear Devon's commentary in conversation context
- CSS cleanup: expand compressed blocks, remove inline styles, fix Devon color to warm tawny
- Reaper silence threshold 7s → 6s
- Publish episode 36

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-14 16:42:21 -06:00
lukeandClaude Opus 4.6 6d4e490283 Caller generation overhaul, Devon intern, frontend redesign
Caller system: structured JSON backgrounds, voice-personality matching (68 profiles),
thematic inter-caller awareness, adaptive call shapes, show pacing, returning caller
memory with relationships/arcs, post-call quality signals, 95 comedy writer entries.

Devon the Intern: persistent show character with tool-calling LLM (web search, Wikipedia,
headlines, webpage fetch), auto-monitoring, 6 API endpoints, full frontend UI.

Frontend: wrap-up nudge button, caller info panel with shape/energy/emotion badges,
keyboard shortcuts (1-0/H/W/M/D), pinned SFX, visual polish, Devon panel.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-14 01:54:08 -06:00
lukeandClaude Opus 4.6 d3490e1521 Expand all caller topic pools, add cross-episode topic dedup, publish ep35
Massively expanded all 8 caller topic pools from ~1200 to ~2500 entries to
reduce repeat calls. Added persistent topic history (data/used_topics_history.json)
with 30-day aging to prevent cross-episode duplicates. Published episode 35.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 05:45:22 -06:00
lukeandClaude Opus 4.6 0c2201fab5 Fix Remotion render error reporting and harden clip timestamps
- Show full stderr (head + tail) instead of truncating to last 500 chars
- Add --timeout=60000 and --log=verbose to Remotion render command
- Clamp word timestamps to [0, duration] to prevent negative/OOB values

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 19:22:03 -06:00
lukeandClaude Opus 4.6 f7b75fa72f Clips page, new episodes, TTS/audio improvements, publish pipeline updates
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 05:38:58 -06:00
lukeandClaude Opus 4.6 2c7fcdb5ae Move hardcoded secrets to .env, add .env.example
Castopod password, DB password, BunnyCDN keys, Postiz JWT/IDs,
and monitoring token all moved to environment variables.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 15:19:20 -07:00
lukeandClaude Opus 4.6 0bdac16250 Upgrade Whisper to distil-large-v3, fix caller identity confusion, sort clips list
- Whisper base → distil-large-v3 for much better live transcription accuracy
- Add context hints to transcription (caller name, screening status)
- Increase beam_size 3→5 for better decoding
- Add explicit role clarification in caller system prompt so LLM knows Luke is the host
- Prefix host messages with [Host Luke] in LLM conversation
- Fix upload_clips episode list sorting (natural numeric order)
- Episodes 26-28 transcripts, data updates, misc fixes

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 12:46:51 -07:00
lukeandClaude Opus 4.6 6eeab58464 TTS fixes, Inworld improvements, footer redesign, episodes 15-25, invoice script fix
- Fix TTS text pipeline: new caps handling (spell out unknown acronyms, lowercase
  emphasis words), action-word lookahead for parenthetical stripping, abbreviation
  expansions (US→United States, NM→New Mexico), pronunciation fixes
- Inworld TTS: camelCase API fields, speakingRate per-voice overrides, retry logic
  with exponential backoff (3 attempts)
- Footer redesign: SVG icons for social/podcast links across all pages
- Stats page: show "Rate us on Spotify" instead of "not public" placeholder
- New voices, expanded caller prompts and problem scenarios
- Social posting via Postiz, YouTube upload in publish pipeline
- Episode transcripts 15-25, terms page, sitemap updates
- Fix invoice script: match Timing totals using merged Task+App intervals

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 12:38:58 -07:00
lukeandClaude Opus 4.6 08a35bddeb Play idents in stereo on channels 15/16 with configurable ident_channel setting
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 22:28:26 -07:00
lukeandClaude Opus 4.6 bbcf767a8f Add idents playback section — loads from idents/ folder, plays on ads channel
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 22:24:40 -07:00
lukeandClaude Opus 4.6 b1bd4ed365 Add direct YouTube upload to publish pipeline, publish ep14
Bypass flaky YouTube RSS ingestion by converting MP3+cover to MP4
and uploading via YouTube Data API. Videos are auto-added to the
podcast playlist. Includes yt_auth.py for token management.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 15:07:16 -07:00
lukeandClaude Opus 4.6 2b3551cada Add paragraph spacing on how-it-works page
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 05:35:58 -07:00
lukeandClaude Opus 4.6 d611f60743 SFX emojis, non-blocking email view, deploy/git docs in CLAUDE.md
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 05:34:25 -07:00
lukeandClaude Opus 4.6 d85a8d4511 Add listener email system with IMAP polling, TTS playback, and show awareness
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 05:22:56 -07:00
lukeandClaude Opus 4.6 f0271e61df Clip pipeline improvements, direct YouTube upload, hero redesign, how-it-works updates
- make_clips: migrate refine_clip_timestamps to mlx-whisper, add LLM caption
  polishing, fix speaker label reversal in grouped caption lines
- upload_clips: interactive episode/clip/platform menus, direct YouTube Shorts
  upload via Data API v3 (bypasses Postiz), direct Bluesky upload
- Website hero: centered layout with left-column cover art on desktop, compact
  text links instead of pill buttons, scaled up typography
- How-it-works: move anatomy section above diagram, update stats (320 names,
  189+ personality layers, 20 towns, 570+ topics, 1400+ scenarios), add
  drunk/high/unhinged callers, voicemails, MLX Whisper GPU, LLM-polished captions
- All footers: add System Status link, remove Ko-fi branding
- .gitignore: YouTube OAuth credential files

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 04:06:23 -07:00
lukeandClaude Opus 4.6 3164a70e48 Ep13 publish, MLX whisper, voicemail system, hero redesign, massive topic expansion
- Switch whisper transcription from faster-whisper (CPU) to lightning-whisper-mlx (GPU)
- Fix word_timestamps hanging, use ffprobe for accurate duration
- Add Cloudflare Pages Worker for SignalWire voicemail fallback when server offline
- Add voicemail sync on startup, delete tracking, save feature
- Add /feed RSS proxy to _worker.js (was broken by worker taking over routing)
- Redesign website hero section: ghost buttons, compact phone, plain text links
- Rewrite caller prompts for faster point-getting and host-following
- Expand TOPIC_CALLIN from ~250 to 547 entries across 34 categories
- Add new categories: biology, psychology, engineering, math, geology, animals,
  work, money, books, movies, relationships, health, language, true crime,
  drunk/high/unhinged callers
- Remove bad Inworld voices (Pixie, Dominus), reduce repeat caller frequency
- Add audio monitor device routing, uvicorn --reload-dir fix
- Publish episode 13

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 01:56:47 -07:00
lukeandClaude Opus 4.6 8d3d67a177 Add automated social clips section to how-it-works page
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 04:43:31 -07:00
lukeandClaude Opus 4.6 f9985fc693 Add direct Bluesky upload via atproto, bypass broken Postiz video
Postiz has a bug where Bluesky video uploads fail with "missing jobId".
This adds direct upload to Bluesky using the atproto SDK and the
video.bsky.app processing pipeline. Other platforms still use Postiz.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 04:34:15 -07:00
266 changed files with 76175 additions and 4098 deletions
+48
View File
@@ -0,0 +1,48 @@
# API Keys
OPENROUTER_API_KEY=
ELEVENLABS_API_KEY=
INWORLD_API_KEY=
OPENAI_API_KEY=
# SignalWire (real callers)
SIGNALWIRE_PROJECT_ID=
SIGNALWIRE_SPACE=
SIGNALWIRE_TOKEN=
SIGNALWIRE_PHONE=
SIGNALWIRE_STREAM_URL=
# Social media
TWITTER_API_KEY=
TWITTER_API_SECRET=
TWITTER_ACCESS_TOKEN=
TWITTER_ACCESS_TOKEN_SECRET=
CLIENT_SECRET_ID=
CLIENT_SECRET=
POSTIZ_URL=
POSTIZ_API_KEY=
BSKY_APP_PASSWORD=
# Castopod
CASTOPOD_USERNAME=admin
CASTOPOD_PASSWORD=
CASTOPOD_DB_PASS=
# Postiz publishing
POSTIZ_JWT_SECRET=
POSTIZ_USER_ID=
POSTIZ_INTEGRATIONS={}
# BunnyCDN
BUNNY_STORAGE_KEY=
BUNNY_ACCOUNT_KEY=
# Monitoring
HEARTBEAT_URL=
# Google AI
GOOGLE_API_KEY=
# Email (IMAP)
SUBMISSIONS_IMAP_HOST=
SUBMISSIONS_IMAP_USER=
SUBMISSIONS_IMAP_PASS=
+1
View File
@@ -0,0 +1 @@
5f7e3e7
+30
View File
@@ -50,5 +50,35 @@ voices-v1.0.bin
# Reference voices for TTS
ref_audio/
# YouTube OAuth credentials
youtube_client_secrets.json
youtube_token.json
# Clip upload history (local)
upload-history.json
# Claude settings (local)
.claude/
# Git worktrees
.worktrees/
# Scratch/smoke tests (not committed)
scratch/
# Generated media and model weights (~13GB — never commit)
clips/
music/
mlx_models/
remotion-demo/
social_posts/
# Generated analytics artifacts (rebuilt from cost_tracker)
data/costs.db
data/cost_reports/
# Generated caller avatars
data/avatars/
# Reaper waveform peak cache
reaper/peaks/
+76 -24
View File
@@ -1,32 +1,16 @@
# AI Podcast - Project Instructions
## Git Remote (Gitea)
- **Repo**: `git@gitea-nas:luke/ai-podcast.git`
- **Web**: http://mmgnas:3000/luke/ai-podcast
- **SSH Host**: `gitea-nas` (configured in ~/.ssh/config)
- HostName: `mmgnas` (use `mmgnas-10g` if wired connection issues)
- Port: `2222`
- User: `git`
- IdentityFile: `~/.ssh/gitea_mmgnas`
## NAS Access
- **Hostname**: `mmgnas` (wireless) or `mmgnas-10g` (wired/10G)
- **SSH Port**: 8001
- **User**: luke
- **Docker path**: `/share/CACHEDEV1_DATA/.qpkg/container-station/bin/docker`
## Castopod (Podcast Publishing)
- **URL**: https://podcast.macneilmediagroup.com
- **Podcast handle**: `@LukeAtTheRoost`
- **API Auth**: Basic auth (admin/podcast2026api)
- **API Auth**: Basic auth (credentials in .env: CASTOPOD_USERNAME, CASTOPOD_PASSWORD)
- **Container**: `castopod-castopod-1`
- **Database**: `castopod-mariadb-1` (user: castopod, db: castopod)
## Running the App
```bash
# Start backend
cd /Users/lukemacneil/ai-podcast
python -m uvicorn backend.main:app --reload --host 0.0.0.0 --port 8000
# Start backend — ALWAYS use --reload-dir to avoid CPU thrashing from file watchers
python -m uvicorn backend.main:app --reload --reload-dir backend --host 0.0.0.0 --port 8000
# Or use run.sh
./run.sh
@@ -43,8 +27,7 @@ Required in `.env`:
- ELEVENLABS_API_KEY (optional)
- INWORLD_API_KEY (for Inworld TTS)
## Post-Production Pipeline (added Feb 2026)
- **Branch**: `feature/real-callers` — all current work is here, pushed to gitea
## Post-Production Pipeline
- **Stem Recorder** (`backend/services/stem_recorder.py`): Records 5 WAV stems (host, caller, music, sfx, ads) during live shows. Uses lock-free deque architecture — audio callbacks just append to deques, a background writer thread drains to disk. `write()` for continuous streams (host mic, music, ads), `write_sporadic()` for burst sources (caller TTS, SFX) with time-aligned silence padding.
- **Audio hooks** in `backend/services/audio.py`: 7 tap points guarded by `if self.stem_recorder:`. Persistent mic stream (`start_stem_mic`/`stop_stem_mic`) runs during recording to capture host voice continuously, not just during push-to-talk.
- **API endpoints**: `POST /api/recording/start`, `POST /api/recording/stop` (auto-runs postprod in background thread), `POST /api/recording/process`
@@ -55,11 +38,80 @@ Required in `.env`:
## LLM Settings
- `_pick_response_budget()` in main.py controls caller dialog token limits (150-450 tokens). MiniMax respects limits strictly — if responses seem short, check these values.
- Default max_tokens in llm.py is 300 (for non-caller uses)
- Grok (`x-ai/grok-4-fast`) works well for natural dialog; MiniMax tends toward terse responses
- Grok (`x-ai/grok-4.3`) works well for natural dialog; MiniMax tends toward terse responses. Note `grok-4`, `grok-4-fast` and `grok-4.1-fast` were retired from OpenRouter — a retired id 404s, and `llm.py` swallows the error and returns empty text, so callers go silent with nothing in the logs. `tests/test_model_config.py` guards against reintroducing them.
- `generate_with_tools()` in llm.py supports OpenRouter function calling for the intern feature
## Caller Generation System
- **Two-stage pipeline**: (1) batch identity pregen via Sonnet 4.6 at session start, (2) live dialog via Sonnet 4.6 per turn. Cost ~$3-4/show.
- **Slim caller dict**: Populated once at `Session._pregenerate_backgrounds()` via `caller_gen.generate_batch()`. Keys: `name`, `age`, `voice`, `location`, `identity`, `situation`, `reason_calling`, `opening_line`, `secret_want`, `specific_details`, `emotional_register`. Stored in `session.caller_backgrounds[caller_key]`.
- **Dialog model**: Always Sonnet 4.6 via the `caller_dialog` category in `config.category_models`. No per-caller model routing — deleted in Phase 5B.
- **Prompt builder**: `get_caller_prompt(caller)` in main.py builds the slim system prompt from the dict; see `tests/test_caller_prompt.py` for the contract.
- **Regulars**: `backend/services/regulars_v2.py` loads lore from Obsidian markdown files for named recurring callers (e.g. Silas). The batch prompt optionally includes 2-3 active regulars per session.
- **Inter-caller awareness**: `get_show_history()` scores previous callers by keyword overlap with the current caller's `situation`/`reason_calling`. Reaction frequency scales with match strength (60%/35%/15%).
- **Caller memory**: Returning callers auto-promote from first-timers at ~5% probability after 8+ exchanges. `RegularCallerService` tracks summaries, relationships, arc state.
- **Call quality signals**: `_assess_call_quality()` captures exchange count, response length, host engagement, caller depth, natural ending.
## Devon (Intern Character)
- **Service**: `backend/services/intern.py` — persistent show character, not a caller
- **Personality**: 23-year-old NMSU grad, eager, slightly incompetent, gets yelled at. Voice: "Nate" (Inworld), no phone filter.
- **Tools**: web_search (SearXNG), get_headlines, fetch_webpage, wikipedia_lookup — via `generate_with_tools()` function calling
- **SearXNG**: runs on the NAS (`http://mmgnas:8888`), deployed via `deploy_searxng.sh`. URL is `settings.searxng_url` (env `SEARXNG_URL`). If Devon's web_search returns "Search failed", check the `searxng` container on mmgnas is Up. The config enables the JSON API + disables the bot limiter — both required for programmatic queries.
- **Endpoints**: `POST /api/intern/ask`, `/interject`, `/monitor`, `GET /api/intern/suggestion`, `POST /api/intern/suggestion/play`, `/dismiss`
- **Auto-monitoring**: Watches conversation every 15s during calls, buffers suggestions for host approval
- **Persistence**: `data/intern.json` stores lookup history
- **Frontend**: Ask Devon input (D key), Interject button, monitor toggle, suggestion indicator with Play/Dismiss
## Frontend Control Panel
- **Keyboard shortcuts**: 1-0 (callers), H (hangup), W (wrap up), M (music toggle), D (ask Devon), Escape (close modals)
- **Wrap It Up**: Amber button that signals callers to wind down gracefully. Reduces response budget, injects wrap-up signals, forces goodbye after 2 exchanges.
- **Caller info panel**: Shows identity, situation, signature detail, secret want during active calls
- **Caller buttons**: Populated from the slim caller background dicts
- **Pinned SFX**: Cheer/Applause/Boo always visible, rest collapsible
- **Visual polish**: Thinking pulse, call glow, compact media row, smoother transitions
## Website
- **Domain**: lukeattheroost.com (behind Cloudflare)
- **Analytics**: Cloudflare Web Analytics (enable in Cloudflare dashboard, no code changes needed)
- **Deploy**: `npx wrangler pages deploy website/ --project-name=lukeattheroost --branch=main`
## Episodes Published
- Episode 6 published 2026-02-08 (podcast6.mp3, ~31 min)
## Podcast Workflow
- Publishing pipeline: episodes go through Castopod, CDN, website, YouTube, and social
- Always check Python venv is active and packages are installed before running publish scripts
- Episode numbering: check Castopod for the latest episode number, don't hardcode
## Scripts
- `publish_episode.py` — Transcribes audio, generates metadata (title, description, cover art), publishes to Castopod. Usage: `python publish_episode.py ~/Desktop/episode.mp3`
- `make_clips.py` — Two-pass clip extraction: fast Whisper transcription → LLM selects best moments → quality Whisper re-transcription for precise timestamps. Usage: `python make_clips.py ~/Desktop/episode.mp3 --count 3`
- `generate_milestone_images.py` — Generates social milestone images via Gemini Flash (requires GOOGLE_API_KEY)
- `post_milestone.py` — Posts milestone announcements to social platforms via Postiz
- `make_x_launch_assets.py` — Generates branded visual assets for X/Twitter (header, quote cards, intro/review graphics)
- `schedule_x_launch.py` — Schedules X/Twitter launch campaign posts via Postiz API
## Reaper Scripts
- `reaper/dialog_regions.lua` — Background script that polls `/tmp/reaper_state.txt` and creates colored regions (green=DIALOG, red=AD, blue=IDENT) as the backend writes state changes during recording
- `reaper/strip_silence_dialog.lua` — Post-production script: strips long silences from dialog regions, normalizes AD/IDENT/music volume, trims music to voice length with fade-out, mutes music during AD/IDENT regions
## Cost Dashboard
- **Route**: `/costs` — standalone analytics page, linked from control panel header
- **Database**: `data/costs.db` (SQLite) — aggregates all session cost data for cross-session queries
- **Data layer**: `backend/services/cost_db.py` — schema, JSON import, all query functions
- **Dual-write**: `cost_tracker.py` writes to both JSON (`data/cost_reports/`) and SQLite on every LLM/TTS call
- **API**: 8 endpoints under `/api/costs/` — summary, timeline, models, categories, sessions, session detail, expensive calls, TTS providers
- **Frontend**: `frontend/costs.html`, `frontend/css/costs.css`, `frontend/js/costs.js` — Chart.js for visualizations
- **Pricing**: Hardcoded in `cost_tracker.py` (`OPENROUTER_PRICING`, `TTS_PRICING`) — update when provider prices change
- **Not tracked yet**: SignalWire call costs
## Data Directory
State files (not config — these are written at runtime):
- `regulars.json` — Returning caller profiles (backgrounds, key moments, arc status, relationships)
- `used_topics_history.json` — Previously used caller topics to avoid repeats
- `session_checkpoint.json` — Current show session state (call history, caller queue)
- `publish_state.json` — Publishing pipeline progress per episode
- `intern.json` — Devon's lookup history
- `emails.json` — Listener email submissions
- `voicemails.json` — Listener voicemail submissions
## Personal
- Don't build anything until you have 95% clarity on what I want you to do. Ask clarifying questions until you reach 95% understanding of what I'm asking
- When working as a team, propose the plan before executing — don't just start building
- Flag trade-offs that affect show quality or listener experience rather than silently resolving them
+22
View File
@@ -0,0 +1,22 @@
MIT License
Copyright (c) 2026 Luke MacNeil / MacNeil Media Group, LLC
https://macneilmediagroup.com
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+260
View File
@@ -0,0 +1,260 @@
#!/usr/bin/env python3
"""Analyze silence gaps in podcast stems to find optimal strip-silence thresholds.
Usage: python analyze_gaps.py recordings/2026-03-17_235137/
"""
import sys
import numpy as np
import soundfile as sf
from pathlib import Path
BLOCK_SEC = 0.1
SILENCE_DB = -30
THRESHOLD = 10 ** (SILENCE_DB / 20)
MIN_VOICE_SEC = 0.3
def load_stem(path: Path) -> tuple[np.ndarray, int]:
audio, sr = sf.read(path, dtype="float32")
if audio.ndim > 1:
audio = audio[:, 0]
return audio, sr
def compute_rms_blocks(audio: np.ndarray, sr: int) -> np.ndarray:
block_samples = int(sr * BLOCK_SEC)
n_blocks = len(audio) // block_samples
if n_blocks == 0:
return np.array([0.0])
trimmed = audio[:n_blocks * block_samples].reshape(n_blocks, block_samples)
return np.sqrt(np.mean(trimmed ** 2, axis=1))
def compute_peak_blocks(audio: np.ndarray, sr: int) -> np.ndarray:
block_samples = int(sr * BLOCK_SEC)
n_blocks = len(audio) // block_samples
if n_blocks == 0:
return np.array([0.0])
trimmed = audio[:n_blocks * block_samples].reshape(n_blocks, block_samples)
return np.max(np.abs(trimmed), axis=1)
def analyze(stems_dir: Path):
stems_dir = Path(stems_dir)
voice_stems = {}
for name in ["host", "devon", "caller"]:
path = stems_dir / f"{name}.wav"
if path.exists():
print(f"Loading {name}...", end=" ", flush=True)
audio, sr = load_stem(path)
voice_stems[name] = audio
print(f"{len(audio)/sr:.0f}s @ {sr}Hz")
if not voice_stems:
print("No voice stems found")
return
sr_val = sr
duration = max(len(a) for a in voice_stems.values()) / sr_val
print(f"\nTotal duration: {duration/60:.1f} min")
# Compute per-track RMS and peak blocks
track_rms = {}
track_peak = {}
for name, audio in voice_stems.items():
track_rms[name] = compute_rms_blocks(audio, sr_val)
track_peak[name] = compute_peak_blocks(audio, sr_val)
n_blocks = min(len(v) for v in track_peak.values())
# Detect gaps using same logic as Lua script (RMS for speaker ID, peak for silence)
min_voice_blocks = int(MIN_VOICE_SEC / BLOCK_SEC)
track_names = list(voice_stems.keys())
gaps = []
in_silence = False
silence_start = 0
track_before = None
last_active = None
voice_run = 0
voice_run_track = None
for i in range(n_blocks):
# Peak for silence detection
best_peak = max(track_peak[name][i] for name in track_names)
# RMS for speaker identification
best_rms = 0
best_track = None
for name in track_names:
r = track_rms[name][i]
if r > best_rms:
best_rms = r
best_track = name
all_silent = best_peak < THRESHOLD
if not all_silent:
last_active = best_track
if in_silence:
if all_silent:
voice_run = 0
voice_run_track = None
else:
if voice_run == 0:
voice_run_track = best_track
voice_run += 1
if voice_run >= min_voice_blocks:
voice_start_block = i - (voice_run - 1)
gap_start = silence_start * BLOCK_SEC
gap_end = voice_start_block * BLOCK_SEC
dur = gap_end - gap_start
if dur >= 0.5: # log gaps >= 0.5s
gaps.append({
"start": gap_start,
"end": gap_end,
"dur": dur,
"before": track_before or "?",
"after": voice_run_track or "?",
})
in_silence = False
voice_run = 0
voice_run_track = None
else:
if all_silent:
in_silence = True
silence_start = i
track_before = last_active
voice_run = 0
voice_run_track = None
# Trailing silence
if in_silence:
dur = (n_blocks - silence_start) * BLOCK_SEC
if dur >= 0.5:
gaps.append({
"start": silence_start * BLOCK_SEC,
"end": n_blocks * BLOCK_SEC,
"dur": dur,
"before": track_before or "?",
"after": "end",
})
if not gaps:
print("No gaps detected")
return
# Categorize gaps
categories = {
"host_self": [], # Host -> Host
"host_to_caller": [], # Host -> Caller (TTS latency)
"caller_to_host": [], # Caller -> Host
"host_to_devon": [], # Host -> Devon (TTS latency)
"devon_to_host": [], # Devon -> Host
"caller_to_devon": [],# Caller -> Devon (interjection)
"devon_to_caller": [],# Devon -> Caller
"other": [],
}
for g in gaps:
b, a = g["before"], g["after"]
if b == "host" and a == "host":
categories["host_self"].append(g)
elif b == "host" and a == "caller":
categories["host_to_caller"].append(g)
elif b == "caller" and a == "host":
categories["caller_to_host"].append(g)
elif b == "host" and a == "devon":
categories["host_to_devon"].append(g)
elif b == "devon" and a == "host":
categories["devon_to_host"].append(g)
elif b == "caller" and a == "devon":
categories["caller_to_devon"].append(g)
elif b == "devon" and a == "caller":
categories["devon_to_caller"].append(g)
else:
categories["other"].append(g)
# Print results
print(f"\n{'='*70}")
print(f"GAP ANALYSIS — {len(gaps)} gaps detected")
print(f"{'='*70}")
total_silence = sum(g["dur"] for g in gaps)
print(f"Total silence: {total_silence:.0f}s ({total_silence/60:.1f} min)")
print(f"Content after removal: ~{(duration - total_silence)/60:.1f} min")
for cat_name, cat_gaps in sorted(categories.items(), key=lambda x: -len(x[1])):
if not cat_gaps:
continue
durs = sorted([g["dur"] for g in cat_gaps])
print(f"\n--- {cat_name} ({len(cat_gaps)} gaps) ---")
print(f" Range: {durs[0]:.1f}s - {durs[-1]:.1f}s")
print(f" Median: {np.median(durs):.1f}s Mean: {np.mean(durs):.1f}s")
if len(durs) >= 5:
print(f" P25: {np.percentile(durs, 25):.1f}s P75: {np.percentile(durs, 75):.1f}s")
# Histogram
brackets = [(0, 1), (1, 2), (2, 3), (3, 5), (5, 8), (8, 12), (12, 18), (18, 30), (30, 60), (60, 999)]
print(f" Distribution:")
for lo, hi in brackets:
count = sum(1 for d in durs if lo <= d < hi)
if count > 0:
bar = "#" * count
label = f"{lo}-{hi}s" if hi < 999 else f"{lo}s+"
print(f" {label:>8s}: {bar} ({count})")
# Find natural clusters and suggest thresholds
print(f"\n{'='*70}")
print("SUGGESTED THRESHOLDS")
print(f"{'='*70}")
# For each Devon-involved category, find the gap between interjection and TTS gaps
devon_gaps = categories["host_to_devon"] + categories["devon_to_host"] + categories["caller_to_devon"] + categories["devon_to_caller"]
if devon_gaps:
devon_durs = sorted([g["dur"] for g in devon_gaps])
# Look for a natural break between short (interjection) and long (TTS) gaps
short = [d for d in devon_durs if d < 5]
long = [d for d in devon_durs if d >= 5]
if short and long:
suggested = (max(short) + min(long)) / 2
print(f"Devon threshold: {suggested:.1f}s (short gaps: {len(short)} up to {max(short):.1f}s, long gaps: {len(long)} from {min(long):.1f}s)")
elif short:
print(f"Devon threshold: {max(short) + 1:.1f}s (all gaps are short, max {max(short):.1f}s)")
else:
print(f"Devon threshold: 3.0s (all gaps are long, min {min(long):.1f}s)")
caller_gaps = categories["host_to_caller"] + categories["caller_to_host"]
if caller_gaps:
caller_durs = sorted([g["dur"] for g in caller_gaps])
short = [d for d in caller_durs if d < 5]
long = [d for d in caller_durs if d >= 5]
if short and long:
suggested = (max(short) + min(long)) / 2
print(f"Caller transition threshold: {suggested:.1f}s (short: {len(short)} up to {max(short):.1f}s, long: {len(long)} from {min(long):.1f}s)")
elif long:
print(f"Caller transition threshold: {min(long) - 1:.1f}s (all gaps >= {min(long):.1f}s)")
host_self = categories["host_self"]
if host_self:
host_durs = sorted([g["dur"] for g in host_self])
short = [d for d in host_durs if d < 5]
long = [d for d in host_durs if d >= 5]
if short and long:
suggested = (max(short) + min(long)) / 2
print(f"Same-speaker threshold: {suggested:.1f}s (short: {len(short)} up to {max(short):.1f}s, long: {len(long)} from {min(long):.1f}s)")
elif long:
print(f"Same-speaker threshold: {min(long) - 1:.1f}s (all gaps >= {min(long):.1f}s)")
all_durs = sorted([g["dur"] for g in gaps])
would_cut = [d for d in all_durs if d >= 3.0]
print(f"\nWith current thresholds (Devon=3s, others=6s):")
print(f" Would cut: ~{len(would_cut)} gaps, ~{sum(would_cut):.0f}s ({sum(would_cut)/60:.1f} min)")
print(f" Result: ~{(duration - sum(would_cut))/60:.1f} min")
if __name__ == "__main__":
if len(sys.argv) < 2:
print("Usage: python analyze_gaps.py <stems_dir>")
sys.exit(1)
analyze(Path(sys.argv[1]))
+9 -2
View File
@@ -1,11 +1,18 @@
{
"input_device": 13,
"input_device": 14,
"input_device_name": "Babyface Pro (70793771)",
"input_channel": 1,
"output_device": 12,
"output_device": 13,
"output_device_name": "Radio Voice Mic",
"caller_channel": 3,
"devon_channel": 17,
"live_caller_channel": 9,
"music_channel": 5,
"sfx_channel": 7,
"ad_channel": 11,
"ident_channel": 15,
"monitor_device": null,
"monitor_device_name": null,
"monitor_channel": 1,
"phone_filter": false
}
+22 -1
View File
@@ -22,12 +22,32 @@ class Settings(BaseSettings):
signalwire_phone: str = os.getenv("SIGNALWIRE_PHONE", "")
signalwire_stream_url: str = os.getenv("SIGNALWIRE_STREAM_URL", "")
# Email (IMAP)
submissions_imap_host: str = os.getenv("SUBMISSIONS_IMAP_HOST", "")
submissions_imap_user: str = os.getenv("SUBMISSIONS_IMAP_USER", "")
submissions_imap_pass: str = os.getenv("SUBMISSIONS_IMAP_PASS", "")
# SearXNG (Devon's web search + caller news grounding) — runs on the NAS.
# Override with SEARXNG_URL in .env (e.g. http://localhost:8888 for a local container).
searxng_url: str = os.getenv("SEARXNG_URL", "http://mmgnas:8888")
# LLM Settings
llm_provider: str = "openrouter" # "openrouter" or "ollama"
openrouter_model: str = "anthropic/claude-sonnet-4-5"
openrouter_model: str = "anthropic/claude-sonnet-4.6" # primary/default model
ollama_model: str = "llama3.2"
ollama_host: str = "http://localhost:11434"
# Per-category model routing
category_models: dict = {
"caller_dialog": "anthropic/claude-sonnet-4.6", # live caller dialog — quality matters ($3/$15)
"devon_ask": "x-ai/grok-4.3", # Devon matches show energy (grok-4.1-fast deprecated 2026-05)
"devon_monitor": "google/gemini-2.5-flash", # just yes/no decisions, keep cheap ($0.15/$0.60)
"background_gen": "anthropic/claude-sonnet-4.6", # backgrounds drive the whole call — worth the quality ($3/$15, ~$0.30/show)
"call_summary": "google/gemini-2.5-flash", # post-call, no personality needed ($0.15/$0.60)
"news_summary": "google/gemini-2.5-flash", # just digesting headlines ($0.15/$0.60)
"topic_gen": "google/gemini-2.5-flash", # structured output ($0.15/$0.60)
}
# TTS Settings
tts_provider: str = "inworld" # "kokoro", "elevenlabs", "inworld", "vits", or "bark"
@@ -39,6 +59,7 @@ class Settings(BaseSettings):
sounds_dir: Path = base_dir / "sounds"
music_dir: Path = base_dir / "music"
ads_dir: Path = base_dir / "ads"
idents_dir: Path = base_dir / "idents"
sessions_dir: Path = base_dir / "sessions"
class Config:
+3116 -1856
View File
File diff suppressed because it is too large Load Diff
+435 -63
View File
@@ -10,24 +10,68 @@ from typing import Optional, Callable
import wave
import time
# Settings file path
SETTINGS_FILE = Path(__file__).parent.parent.parent / "audio_settings.json"
# REAPER state file for dialog region markers
REAPER_STATE_FILE = "/tmp/reaper_state.txt"
def _write_reaper_state(state: str):
"""Write state to file. Uses a thread so it's safe from audio callbacks."""
def _write():
try:
with open(REAPER_STATE_FILE, "w") as f:
f.write(state)
except OSError:
pass
threading.Thread(target=_write, daemon=True).start()
class AudioService:
"""Manages audio I/O with multi-channel support for Loopback routing"""
@staticmethod
def _find_device_by_name(name: str) -> Optional[int]:
"""Find a device index by name substring match. Returns None if not found."""
if not name:
return None
devices = sd.query_devices()
# Exact match first
for i, d in enumerate(devices):
if d["name"] == name:
return i
# Substring match
for i, d in enumerate(devices):
if name in d["name"]:
return i
return None
@staticmethod
def _get_device_name(device_id: Optional[int]) -> Optional[str]:
"""Get the name of a device by index."""
if device_id is None:
return None
try:
return sd.query_devices(device_id)["name"]
except Exception:
return None
def __init__(self):
# Device configuration
self.input_device: Optional[int] = None
self.input_device: Optional[int] = 13 # Radio Voice Mic (loopback input)
self.input_channel: int = 1 # 1-indexed channel
self.output_device: Optional[int] = None # Single output device (multi-channel)
self.caller_channel: int = 1 # Channel for caller TTS
self.output_device: Optional[int] = 12 # Radio Voice Mic (loopback output)
self.caller_channel: int = 3 # Channel for caller TTS
self.devon_channel: int = 17 # Channel for Devon (intern)
self.live_caller_channel: int = 9 # Channel for live caller audio
self.music_channel: int = 2 # Channel for music
self.music_channel: int = 5 # Channel for music
self.sfx_channel: int = 3 # Channel for SFX
self.ad_channel: int = 11 # Channel for ads
self.ident_channel: int = 15 # Channel for idents (stereo: ch 15+16)
self.monitor_device: Optional[int] = 14 # Babyface Pro (headphone monitoring)
self.monitor_channel: int = 1 # Channel for mic monitoring on monitor device
self.phone_filter: bool = False # Phone filter on caller voices
# Ad playback state
@@ -37,6 +81,13 @@ class AudioService:
self._ad_position: int = 0
self._ad_playing: bool = False
# Ident playback state
self._ident_stream: Optional[sd.OutputStream] = None
self._ident_data: Optional[np.ndarray] = None
self._ident_resampled: Optional[np.ndarray] = None
self._ident_position: int = 0
self._ident_playing: bool = False
# Recording state
self._recording = False
self._record_thread: Optional[threading.Thread] = None
@@ -63,6 +114,7 @@ class AudioService:
# Caller playback state
self._caller_stop_event = threading.Event()
self._devon_stop_event = threading.Event()
self._caller_thread: Optional[threading.Thread] = None
# Host mic streaming state
@@ -78,6 +130,10 @@ class AudioService:
self.input_sample_rate = 16000 # For Whisper
self.output_sample_rate = 24000 # For TTS
# Mic monitor (input → monitor device passthrough)
self._monitor_stream: Optional[sd.OutputStream] = None
self._monitor_write: Optional[Callable] = None
# Stem recording (opt-in, attached via API)
self.stem_recorder = None
self._stem_mic_stream: Optional[sd.InputStream] = None
@@ -85,37 +141,63 @@ class AudioService:
# Load saved settings
self._load_settings()
def _resolve_device(self, data: dict, key: str) -> Optional[int]:
"""Resolve a device from settings: try name first, fall back to index."""
name_key = f"{key}_name"
name = data.get(name_key)
if name:
resolved = self._find_device_by_name(name)
if resolved is not None:
idx = data.get(key)
if idx is not None and resolved != idx:
print(f"[Audio] Device '{name}' moved: {idx} -> {resolved}")
return resolved
else:
print(f"[Audio] Warning: device '{name}' not found, falling back to index {data.get(key)}")
return data.get(key)
def _load_settings(self):
"""Load settings from disk"""
"""Load settings from disk, resolving device names to current indices"""
if SETTINGS_FILE.exists():
try:
with open(SETTINGS_FILE) as f:
data = json.load(f)
self.input_device = data.get("input_device")
self.input_device = self._resolve_device(data, "input_device")
self.input_channel = data.get("input_channel", 1)
self.output_device = data.get("output_device")
self.output_device = self._resolve_device(data, "output_device")
self.caller_channel = data.get("caller_channel", 1)
self.devon_channel = data.get("devon_channel", 17)
self.live_caller_channel = data.get("live_caller_channel", 4)
self.music_channel = data.get("music_channel", 2)
self.sfx_channel = data.get("sfx_channel", 3)
self.ad_channel = data.get("ad_channel", 11)
self.ident_channel = data.get("ident_channel", 15)
self.monitor_device = self._resolve_device(data, "monitor_device")
self.monitor_channel = data.get("monitor_channel", 1)
self.phone_filter = data.get("phone_filter", False)
print(f"Loaded audio settings: output={self.output_device}, channels={self.caller_channel}/{self.live_caller_channel}/{self.music_channel}/{self.sfx_channel}/ad:{self.ad_channel}, phone_filter={self.phone_filter}")
print(f"Loaded audio settings: input={self.input_device} ({self._get_device_name(self.input_device)}), output={self.output_device} ({self._get_device_name(self.output_device)}), monitor={self.monitor_device}, phone_filter={self.phone_filter}")
except Exception as e:
print(f"Failed to load audio settings: {e}")
def _save_settings(self):
"""Save settings to disk"""
"""Save settings to disk with device names for stable resolution"""
try:
data = {
"input_device": self.input_device,
"input_device_name": self._get_device_name(self.input_device),
"input_channel": self.input_channel,
"output_device": self.output_device,
"output_device_name": self._get_device_name(self.output_device),
"caller_channel": self.caller_channel,
"devon_channel": self.devon_channel,
"live_caller_channel": self.live_caller_channel,
"music_channel": self.music_channel,
"sfx_channel": self.sfx_channel,
"ad_channel": self.ad_channel,
"ident_channel": self.ident_channel,
"monitor_device": self.monitor_device,
"monitor_device_name": self._get_device_name(self.monitor_device),
"monitor_channel": self.monitor_channel,
"phone_filter": self.phone_filter,
}
with open(SETTINGS_FILE, "w") as f:
@@ -144,10 +226,14 @@ class AudioService:
input_channel: Optional[int] = None,
output_device: Optional[int] = None,
caller_channel: Optional[int] = None,
devon_channel: Optional[int] = None,
live_caller_channel: Optional[int] = None,
music_channel: Optional[int] = None,
sfx_channel: Optional[int] = None,
ad_channel: Optional[int] = None,
ident_channel: Optional[int] = None,
monitor_device: Optional[int] = None,
monitor_channel: Optional[int] = None,
phone_filter: Optional[bool] = None
):
"""Configure audio devices and channels"""
@@ -159,6 +245,8 @@ class AudioService:
self.output_device = output_device
if caller_channel is not None:
self.caller_channel = caller_channel
if devon_channel is not None:
self.devon_channel = devon_channel
if live_caller_channel is not None:
self.live_caller_channel = live_caller_channel
if music_channel is not None:
@@ -167,6 +255,12 @@ class AudioService:
self.sfx_channel = sfx_channel
if ad_channel is not None:
self.ad_channel = ad_channel
if ident_channel is not None:
self.ident_channel = ident_channel
if monitor_device is not None:
self.monitor_device = monitor_device
if monitor_channel is not None:
self.monitor_channel = monitor_channel
if phone_filter is not None:
self.phone_filter = phone_filter
@@ -180,10 +274,14 @@ class AudioService:
"input_channel": self.input_channel,
"output_device": self.output_device,
"caller_channel": self.caller_channel,
"devon_channel": self.devon_channel,
"live_caller_channel": self.live_caller_channel,
"music_channel": self.music_channel,
"sfx_channel": self.sfx_channel,
"ad_channel": self.ad_channel,
"ident_channel": self.ident_channel,
"monitor_device": self.monitor_device,
"monitor_channel": self.monitor_channel,
"phone_filter": self.phone_filter,
}
@@ -283,8 +381,9 @@ class AudioService:
stream_ready.set()
if self._recording:
self._recorded_audio.append(indata[:, record_channel].copy())
if self.stem_recorder:
self.stem_recorder.write("host", indata[:, record_channel].copy(), device_sr)
rec = self.stem_recorder
if rec:
rec.write("host", indata[:, record_channel].copy(), device_sr)
print(f"Recording: opening stream on device {self.input_device} ch {self.input_channel} @ {device_sr}Hz ({max_channels} ch)")
@@ -329,13 +428,20 @@ class AudioService:
return audio
def play_caller_audio(self, audio_bytes: bytes, sample_rate: int = 24000):
"""Play caller TTS audio to specific channel of output device (interruptible)"""
def play_caller_audio(self, audio_bytes: bytes, sample_rate: int = 24000, stem_name: str = "caller", channel_override: int | None = None):
"""Play TTS audio to specific channel of output device (interruptible)"""
import librosa
# Stop any existing caller audio
# Devon uses its own stop event so hangup doesn't cut Devon's audio
is_devon = stem_name == "devon"
stop_event = self._devon_stop_event if is_devon else self._caller_stop_event
# Stop any existing audio on the same channel type
if is_devon:
self.stop_devon_audio()
else:
self.stop_caller_audio()
self._caller_stop_event.clear()
stop_event.clear()
# Convert bytes to numpy
audio = np.frombuffer(audio_bytes, dtype=np.int16).astype(np.float32) / 32768.0
@@ -352,7 +458,8 @@ class AudioService:
device_info = sd.query_devices(self.output_device)
num_channels = device_info['max_output_channels']
device_sr = int(device_info['default_samplerate'])
channel_idx = min(self.caller_channel, num_channels) - 1
ch = channel_override if channel_override is not None else self.caller_channel
channel_idx = min(ch, num_channels) - 1
# Resample if needed
if sample_rate != device_sr:
@@ -365,7 +472,7 @@ class AudioService:
multi_ch = np.zeros((len(audio), num_channels), dtype=np.float32)
multi_ch[:, channel_idx] = audio
print(f"Playing caller audio to device {self.output_device} ch {self.caller_channel} @ {device_sr}Hz")
print(f"Playing {stem_name} audio to device {self.output_device} ch {ch} @ {device_sr}Hz")
# Play in chunks so we can interrupt
chunk_size = int(device_sr * 0.1) # 100ms chunks
@@ -377,16 +484,17 @@ class AudioService:
channels=num_channels,
dtype=np.float32
) as stream:
while pos < len(multi_ch) and not self._caller_stop_event.is_set():
while pos < len(multi_ch) and not stop_event.is_set():
end = min(pos + chunk_size, len(multi_ch))
stream.write(multi_ch[pos:end])
# Record each chunk as it plays so hangups cut the stem too
if self.stem_recorder:
self.stem_recorder.write_sporadic("caller", audio[pos:end].copy(), device_sr)
rec = self.stem_recorder
if rec:
rec.write_sporadic(stem_name, audio[pos:end].copy(), device_sr)
pos = end
if self._caller_stop_event.is_set():
print("Caller audio stopped early")
if stop_event.is_set():
print(f"{stem_name.title()} audio stopped early")
else:
print(f"Played caller audio: {len(audio)/device_sr:.2f}s")
@@ -397,6 +505,10 @@ class AudioService:
"""Stop any playing caller audio"""
self._caller_stop_event.set()
def stop_devon_audio(self):
"""Stop any playing Devon audio (independent of caller audio)"""
self._devon_stop_event.set()
def _start_live_caller_stream(self):
"""Start persistent output stream with ring buffer jitter absorption"""
if self._live_caller_stream is not None:
@@ -461,7 +573,7 @@ class AudioService:
self._live_caller_write = write_audio
self._live_caller_stream = sd.OutputStream(
self._live_caller_stream = self._open_output_stream(
device=self.output_device,
samplerate=device_sr,
channels=num_channels,
@@ -469,16 +581,15 @@ class AudioService:
callback=callback,
blocksize=1024,
)
self._live_caller_stream.start()
print(f"[Audio] Live caller stream started on ch {self.live_caller_channel} @ {device_sr}Hz (prebuffer {prebuffer_samples} samples)")
def _stop_live_caller_stream(self):
"""Stop persistent live caller output stream"""
if self._live_caller_stream:
self._live_caller_stream.stop()
self._live_caller_stream.close()
stream = self._live_caller_stream
self._live_caller_stream = None
self._live_caller_write = None
self._close_stream(stream)
print("[Audio] Live caller stream stopped")
def route_real_caller_audio(self, pcm_data: bytes, sample_rate: int):
@@ -501,8 +612,9 @@ class AudioService:
audio = audio[indices]
# Stem recording: live caller
if self.stem_recorder:
self.stem_recorder.write_sporadic("caller", audio.copy(), device_sr)
rec = self.stem_recorder
if rec:
rec.write_sporadic("caller", audio.copy(), device_sr)
if self._live_caller_write:
self._live_caller_write(audio)
@@ -523,9 +635,9 @@ class AudioService:
# Close stem_mic if active — this stream's callback handles stem recording too
if self._stem_mic_stream is not None:
self._stem_mic_stream.stop()
self._stem_mic_stream.close()
stream = self._stem_mic_stream
self._stem_mic_stream = None
self._close_stream(stream)
print("[Audio] Closed stem_mic (host stream takes over)")
self._host_send_callback = send_callback
@@ -542,14 +654,22 @@ class AudioService:
host_accum_samples = [0]
send_threshold = 1600 # 100ms at 16kHz
# Start mic monitor if monitor device is configured
self._start_monitor(device_sr)
def callback(indata, frames, time_info, status):
# Capture for push-to-talk recording if active
if self._recording and self._recorded_audio is not None:
self._recorded_audio.append(indata[:, record_channel].copy())
# Stem recording: host mic
if self.stem_recorder:
self.stem_recorder.write("host", indata[:, record_channel].copy(), device_sr)
rec = self.stem_recorder
if rec:
rec.write("host", indata[:, record_channel].copy(), device_sr)
# Mic monitor: send to headphone device
if self._monitor_write:
self._monitor_write(indata[:, record_channel].copy())
if not self._host_send_callback:
return
@@ -586,13 +706,89 @@ class AudioService:
def stop_host_stream(self):
"""Stop host mic streaming and live caller output"""
if self._host_stream:
self._host_stream.stop()
self._host_stream.close()
stream = self._host_stream
self._host_stream = None
self._host_send_callback = None
self._close_stream(stream)
print("[Audio] Host mic streaming stopped")
self._stop_monitor()
self._stop_live_caller_stream()
# --- Mic Monitor (input → headphone device) ---
def _start_monitor(self, input_sr: int):
"""Start mic monitor stream that routes input to monitor device"""
if self._monitor_stream is not None:
return
if self.monitor_device is None:
return
device_info = sd.query_devices(self.monitor_device)
num_channels = device_info['max_output_channels']
device_sr = int(device_info['default_samplerate'])
channel_idx = min(self.monitor_channel, num_channels) - 1
# Ring buffer for cross-device routing
ring_size = int(device_sr * 2)
ring = np.zeros(ring_size, dtype=np.float32)
state = {"write_pos": 0, "read_pos": 0, "avail": 0}
# Precompute resample ratio (input device sr → monitor device sr)
resample_ratio = device_sr / input_sr
def write_audio(data):
# Resample if sample rates differ
if abs(resample_ratio - 1.0) > 0.01:
n_out = int(len(data) * resample_ratio)
indices = np.linspace(0, len(data) - 1, n_out).astype(int)
data = data[indices]
n = len(data)
wp = state["write_pos"]
if wp + n <= ring_size:
ring[wp:wp + n] = data
else:
first = ring_size - wp
ring[wp:] = data[:first]
ring[:n - first] = data[first:]
state["write_pos"] = (wp + n) % ring_size
state["avail"] += n
def callback(outdata, frames, time_info, status):
outdata.fill(0)
avail = state["avail"]
if avail < frames:
return
rp = state["read_pos"]
if rp + frames <= ring_size:
outdata[:frames, channel_idx] = ring[rp:rp + frames]
else:
first = ring_size - rp
outdata[:first, channel_idx] = ring[rp:]
outdata[first:frames, channel_idx] = ring[:frames - first]
state["read_pos"] = (rp + frames) % ring_size
state["avail"] -= frames
self._monitor_write = write_audio
self._monitor_stream = sd.OutputStream(
device=self.monitor_device,
samplerate=device_sr,
channels=num_channels,
dtype=np.float32,
blocksize=1024,
callback=callback,
)
self._monitor_stream.start()
print(f"[Audio] Mic monitor started (device {self.monitor_device} ch {self.monitor_channel} @ {device_sr}Hz)")
def _stop_monitor(self):
"""Stop mic monitor stream"""
if self._monitor_stream:
stream = self._monitor_stream
self._monitor_stream = None
self._monitor_write = None
self._close_stream(stream)
print("[Audio] Mic monitor stopped")
# --- Music Playback ---
def load_music(self, file_path: str) -> bool:
@@ -750,8 +946,9 @@ class AudioService:
mono_out = (old_samples * fade_out + new_samples * fade_in) * self._music_volume
outdata[:, channel_idx] = mono_out
if self.stem_recorder:
self.stem_recorder.write_sporadic("music", mono_out.copy(), device_sr)
rec = self.stem_recorder
if rec:
rec.write_sporadic("music", mono_out.copy(), device_sr)
self._crossfade_progress = end_progress
if self._crossfade_progress >= 1.0:
@@ -761,11 +958,12 @@ class AudioService:
else:
mono_out = new_samples * self._music_volume
outdata[:, channel_idx] = mono_out
if self.stem_recorder:
self.stem_recorder.write_sporadic("music", mono_out.copy(), device_sr)
rec = self.stem_recorder
if rec:
rec.write_sporadic("music", mono_out.copy(), device_sr)
try:
self._music_stream = sd.OutputStream(
self._music_stream = self._open_output_stream(
device=device,
channels=num_channels,
samplerate=device_sr,
@@ -773,28 +971,68 @@ class AudioService:
callback=callback,
blocksize=2048
)
self._music_stream.start()
print(f"Music playback started on ch {self.music_channel} @ {device_sr}Hz")
except Exception as e:
print(f"Music playback error: {e}")
self._music_playing = False
def _refresh_devices(self):
"""Re-initialize PortAudio to pick up device changes, then re-resolve settings."""
try:
sd._terminate()
sd._initialize()
print("[Audio] PortAudio re-initialized")
self._load_settings()
except Exception as e:
print(f"[Audio] PortAudio refresh failed: {e}")
def _open_output_stream(self, **kwargs) -> sd.OutputStream:
"""Open an OutputStream with one retry after refreshing PortAudio on failure."""
try:
stream = sd.OutputStream(**kwargs)
stream.start()
return stream
except Exception as first_err:
print(f"[Audio] Stream open failed ({first_err}), refreshing devices...")
self._refresh_devices()
# Update device/channel info from refreshed settings
if kwargs.get("device") == self.output_device or "device" in kwargs:
device_info = sd.query_devices(self.output_device)
kwargs["device"] = self.output_device
kwargs["channels"] = device_info["max_output_channels"]
kwargs["samplerate"] = int(device_info["default_samplerate"])
stream = sd.OutputStream(**kwargs)
stream.start()
return stream
def _close_stream(self, stream):
"""Safely close a sounddevice stream, ignoring double-close errors"""
if stream is None:
return
try:
stream.stop()
except Exception:
pass
try:
stream.close()
except Exception:
pass
def stop_music(self, fade_duration: float = 2.0):
"""Stop music playback with fade out"""
if not self._music_playing or not self._music_stream:
self._music_playing = False
if self._music_stream:
self._music_stream.stop()
self._music_stream.close()
stream = self._music_stream
self._music_stream = None
self._close_stream(stream)
self._music_position = 0
return
if fade_duration <= 0:
self._music_playing = False
self._music_stream.stop()
self._music_stream.close()
stream = self._music_stream
self._music_stream = None
self._close_stream(stream)
self._music_position = 0
print("Music stopped")
return
@@ -803,6 +1041,10 @@ class AudioService:
original_volume = self._music_volume
steps = 20
step_time = fade_duration / steps
# Capture stream reference locally so the fade thread closes THIS stream,
# not whatever self._music_stream points to later
fade_stream = self._music_stream
self._music_stream = None
def _fade():
for i in range(steps):
@@ -812,10 +1054,7 @@ class AudioService:
import time
time.sleep(step_time)
self._music_playing = False
if self._music_stream:
self._music_stream.stop()
self._music_stream.close()
self._music_stream = None
self._close_stream(fade_stream)
self._music_position = 0
self._music_volume = original_volume
print("Music faded out and stopped")
@@ -832,6 +1071,7 @@ class AudioService:
return
self.stop_ad()
self.stop_ident()
try:
audio, sr = librosa.load(str(path), sr=self.output_sample_rate, mono=True)
@@ -842,6 +1082,7 @@ class AudioService:
self._ad_playing = True
self._ad_position = 0
_write_reaper_state("ad")
if self.output_device is None:
num_channels = 2
@@ -871,17 +1112,19 @@ class AudioService:
if remaining >= frames:
chunk = self._ad_resampled[self._ad_position:self._ad_position + frames]
outdata[:, channel_idx] = chunk
if self.stem_recorder:
self.stem_recorder.write_sporadic("ads", chunk.copy(), device_sr)
rec = self.stem_recorder
if rec:
rec.write_sporadic("ads", chunk.copy(), device_sr)
self._ad_position += frames
else:
if remaining > 0:
outdata[:remaining, channel_idx] = self._ad_resampled[self._ad_position:]
# Ad finished — no loop
self._ad_playing = False
_write_reaper_state("dialog")
try:
self._ad_stream = sd.OutputStream(
self._ad_stream = self._open_output_stream(
device=device,
channels=num_channels,
samplerate=device_sr,
@@ -889,7 +1132,6 @@ class AudioService:
callback=callback,
blocksize=2048
)
self._ad_stream.start()
print(f"Ad playback started on ch {self.ad_channel} @ {device_sr}Hz")
except Exception as e:
print(f"Ad playback error: {e}")
@@ -897,13 +1139,122 @@ class AudioService:
def stop_ad(self):
"""Stop ad playback"""
was_playing = self._ad_playing
self._ad_playing = False
if was_playing:
_write_reaper_state("dialog")
if self._ad_stream:
self._ad_stream.stop()
self._ad_stream.close()
stream = self._ad_stream
self._ad_stream = None
self._close_stream(stream)
self._ad_position = 0
def play_ident(self, file_path: str):
"""Load and play an ident file once (no loop) in stereo on ident_channel/ident_channel+1"""
import librosa
path = Path(file_path)
if not path.exists():
print(f"Ident file not found: {file_path}")
return
self.stop_ident()
self.stop_ad()
try:
audio, sr = librosa.load(str(path), sr=self.output_sample_rate, mono=False)
if audio.ndim == 1:
# Mono file — duplicate to stereo
audio = np.stack([audio, audio])
audio = audio.astype(np.float32) # shape: (2, samples)
self._ident_data = audio
except Exception as e:
print(f"Failed to load ident: {e}")
return
self._ident_playing = True
self._ident_position = 0
_write_reaper_state("ident")
print(f"Ident loaded: shape={self._ident_data.shape}, max={np.max(np.abs(self._ident_data)):.4f}")
if self.output_device is None:
num_channels = 2
device = None
device_sr = self.output_sample_rate
ch_l = 0
ch_r = 1
else:
device_info = sd.query_devices(self.output_device)
num_channels = device_info['max_output_channels']
device_sr = int(device_info['default_samplerate'])
device = self.output_device
ch_l = min(self.ident_channel, num_channels) - 1
ch_r = min(self.ident_channel + 1, num_channels) - 1
if self.output_sample_rate != device_sr:
self._ident_resampled = np.stack([
librosa.resample(self._ident_data[0], orig_sr=self.output_sample_rate, target_sr=device_sr),
librosa.resample(self._ident_data[1], orig_sr=self.output_sample_rate, target_sr=device_sr),
]).astype(np.float32)
else:
self._ident_resampled = self._ident_data
_cb_count = [0]
def callback(outdata, frames, time_info, status):
outdata[:] = 0
if not self._ident_playing or self._ident_resampled is None:
if _cb_count[0] == 0:
print(f"Ident callback: not playing (playing={self._ident_playing}, data={'yes' if self._ident_resampled is not None else 'no'})")
return
n_samples = self._ident_resampled.shape[1]
remaining = n_samples - self._ident_position
if remaining >= frames:
chunk_l = self._ident_resampled[0, self._ident_position:self._ident_position + frames]
chunk_r = self._ident_resampled[1, self._ident_position:self._ident_position + frames]
outdata[:, ch_l] = chunk_l
outdata[:, ch_r] = chunk_r
_cb_count[0] += 1
if _cb_count[0] == 1:
print(f"Ident callback delivering audio: ch_l={ch_l}, ch_r={ch_r}, max={max(np.max(np.abs(chunk_l)), np.max(np.abs(chunk_r))):.4f}")
rec = self.stem_recorder
if rec:
mono_mix = (chunk_l + chunk_r) * 0.5
rec.write_sporadic("idents", mono_mix.copy(), device_sr)
self._ident_position += frames
else:
if remaining > 0:
outdata[:remaining, ch_l] = self._ident_resampled[0, self._ident_position:]
outdata[:remaining, ch_r] = self._ident_resampled[1, self._ident_position:]
self._ident_playing = False
_write_reaper_state("dialog")
try:
self._ident_stream = self._open_output_stream(
device=device,
channels=num_channels,
samplerate=device_sr,
dtype=np.float32,
callback=callback,
blocksize=2048
)
print(f"Ident playback started on ch {ch_l+1}/{ch_r+1} (idx {ch_l}/{ch_r}) of {num_channels} channels @ {device_sr}Hz, device={device}")
except Exception as e:
print(f"Ident playback error: {e}")
self._ident_playing = False
def stop_ident(self):
"""Stop ident playback"""
was_playing = self._ident_playing
self._ident_playing = False
if was_playing:
_write_reaper_state("dialog")
if self._ident_stream:
stream = self._ident_stream
self._ident_stream = None
self._close_stream(stream)
self._ident_position = 0
def set_music_volume(self, volume: float):
"""Set music volume (0.0 to 1.0)"""
self._music_volume = max(0.0, min(1.0, volume))
@@ -926,6 +1277,7 @@ class AudioService:
if self.output_device is None:
audio, sr = librosa.load(str(path), sr=None, mono=True)
audio = audio.astype(np.float32)
audio = self._apply_fade(audio, sr)
def play():
# Use a dedicated stream instead of sd.play()
@@ -938,11 +1290,13 @@ class AudioService:
channel_idx = min(self.sfx_channel, num_channels) - 1
audio, _ = librosa.load(str(path), sr=device_sr, mono=True)
audio = audio.astype(np.float32)
audio = self._apply_fade(audio, device_sr)
# Stem recording: sfx
if self.stem_recorder:
self.stem_recorder.write_sporadic("sfx", audio.copy(), device_sr)
rec = self.stem_recorder
if rec:
rec.write_sporadic("sfx", audio.copy(), device_sr)
multi_ch = np.zeros((len(audio), num_channels), dtype=np.float32)
multi_ch[:, channel_idx] = audio
@@ -981,11 +1335,17 @@ class AudioService:
device_sr = int(device_info['default_samplerate'])
record_channel = min(self.input_channel, max_channels) - 1
def callback(indata, frames, time_info, status):
if self.stem_recorder:
self.stem_recorder.write("host", indata[:, record_channel].copy(), device_sr)
self._start_monitor(device_sr)
self._stem_mic_stream = sd.InputStream(
def callback(indata, frames, time_info, status):
rec = self.stem_recorder
if rec:
rec.write("host", indata[:, record_channel].copy(), device_sr)
if self._monitor_write:
self._monitor_write(indata[:, record_channel].copy())
def _open():
return sd.InputStream(
device=self.input_device,
channels=max_channels,
samplerate=device_sr,
@@ -993,16 +1353,28 @@ class AudioService:
blocksize=1024,
callback=callback,
)
try:
self._stem_mic_stream = _open()
except Exception as first_err:
print(f"[StemRecorder] InputStream open failed ({first_err}), refreshing PortAudio and retrying...")
self._refresh_devices()
device_info = sd.query_devices(self.input_device)
max_channels = device_info['max_input_channels']
device_sr = int(device_info['default_samplerate'])
self._stem_mic_stream = _open()
self._stem_mic_stream.start()
print(f"[StemRecorder] Host mic capture started (device {self.input_device} ch {self.input_channel} @ {device_sr}Hz)")
def stop_stem_mic(self):
"""Stop the persistent stem mic capture."""
if self._stem_mic_stream:
self._stem_mic_stream.stop()
self._stem_mic_stream.close()
stream = self._stem_mic_stream
self._stem_mic_stream = None
self._close_stream(stream)
print("[StemRecorder] Host mic capture stopped")
self._stop_monitor()
# Global instance
+96
View File
@@ -0,0 +1,96 @@
"""Avatar service — fetches deterministic face photos from randomuser.me"""
import asyncio
from pathlib import Path
import httpx
AVATAR_DIR = Path(__file__).parent.parent.parent / "data" / "avatars"
class AvatarService:
def __init__(self):
self._client: httpx.AsyncClient | None = None
AVATAR_DIR.mkdir(parents=True, exist_ok=True)
@property
def client(self) -> httpx.AsyncClient:
if self._client is None or self._client.is_closed:
self._client = httpx.AsyncClient(timeout=10.0)
return self._client
def get_path(self, name: str) -> Path | None:
path = AVATAR_DIR / f"{name}.jpg"
return path if path.exists() else None
async def get_or_fetch(self, name: str, gender: str = "male") -> Path:
"""Get cached avatar or fetch from randomuser.me. Returns file path."""
g = "female" if gender.lower().startswith("f") else "male"
path = AVATAR_DIR / f"{name}.jpg"
# Check for gender mismatch marker — re-fetch if gender changed
marker = AVATAR_DIR / f"{name}.gender"
if path.exists():
cached_gender = marker.read_text().strip() if marker.exists() else None
if cached_gender == g:
return path
# Gender mismatch or no marker — re-fetch
path.unlink(missing_ok=True)
try:
seed = f"{name.lower().replace(' ', '_')}_{g}"
resp = await self.client.get(
"https://randomuser.me/api/",
params={"gender": g, "seed": seed},
timeout=8.0,
)
resp.raise_for_status()
data = resp.json()
photo_url = data["results"][0]["picture"]["large"]
photo_resp = await self.client.get(photo_url, timeout=8.0)
photo_resp.raise_for_status()
path.write_bytes(photo_resp.content)
marker.write_text(g)
print(f"[Avatar] Fetched avatar for {name} ({g})")
return path
except Exception as e:
print(f"[Avatar] Failed to fetch for {name}: {e}")
raise
async def prefetch_batch(self, callers: list[dict]):
"""Fetch avatars for multiple callers in parallel.
Each dict should have 'name' and 'gender' keys."""
tasks = []
for caller in callers:
name = caller.get("name", "")
gender = caller.get("gender", "male")
if not name:
continue
g = "female" if gender.lower().startswith("f") else "male"
path = AVATAR_DIR / f"{name}.jpg"
marker = AVATAR_DIR / f"{name}.gender"
# Always call get_or_fetch if: no file, no gender marker, or gender mismatch
if not path.exists() or not marker.exists() or marker.read_text().strip() != g:
if path.exists():
print(f"[Avatar] Gender mismatch for {name}: cached={marker.read_text().strip() if marker.exists() else '?'}, want={g} — re-fetching")
tasks.append(self.get_or_fetch(name, gender))
if not tasks:
return
results = await asyncio.gather(*tasks, return_exceptions=True)
fetched = sum(1 for r in results if not isinstance(r, Exception))
failed = sum(1 for r in results if isinstance(r, Exception))
if fetched:
print(f"[Avatar] Pre-fetched {fetched} avatars{f', {failed} failed' if failed else ''}")
async def ensure_devon(self):
"""Pre-fetch Devon's avatar on startup."""
try:
await self.get_or_fetch("Devon", "male")
except Exception:
pass
avatar_service = AvatarService()
+228
View File
@@ -0,0 +1,228 @@
from dataclasses import dataclass
from typing import Optional
import json
import random
import httpx
from ..config import settings
from .cost_tracker import cost_tracker
BATCH_MODEL = "anthropic/claude-sonnet-4.6"
REQUIRED_FIELDS = {
"name", "age", "voice_suggestion", "location", "identity",
"situation", "reason_calling", "opening_line", "secret_want",
"specific_details", "emotional_register"
}
@dataclass
class CallerIdentity:
name: str
age: int
voice_suggestion: str
location: str
identity: str
situation: str
reason_calling: str
opening_line: str
secret_want: str
specific_details: list[str]
emotional_register: str
# set after voice validation
voice_resolved: Optional[str] = None
def parse_batch_response(raw: str) -> list[CallerIdentity]:
stripped = raw.strip()
if stripped.startswith("```") and stripped.endswith("```"):
lines = stripped.splitlines()
stripped = "\n".join(lines[1:-1])
data = json.loads(stripped)
callers = data.get("callers", [])
result = []
for c in callers:
missing = REQUIRED_FIELDS - set(c.keys())
if missing:
raise ValueError(f"CallerIdentity missing fields: {missing}")
result.append(CallerIdentity(**{k: c[k] for k in REQUIRED_FIELDS}))
return result
def resolve_voice(suggestion: str, roster: list[str]) -> str:
"""Map sonnet's voice suggestion to a real voice in the roster.
Case-insensitive exact match; random fallback if unmatched (so LLM
hallucinations don't all collapse onto roster[0])."""
if not roster:
return ""
if suggestion:
match = {v.lower(): v for v in roster}.get(suggestion.lower())
if match:
return match
fallback = random.choice(roster)
print(f"[caller_gen] voice '{suggestion}' not in roster — random fallback to '{fallback}'")
return fallback
return random.choice(roster)
BATCH_SYSTEM_PROMPT = """You are writing a roster of callers for Luke's late-night radio show, broadcast out of Alpine, Texas, in the Big Bend country of far West Texas.
WHERE THESE CALLERS LIVE: The show's world is the Big Bend region of far West Texas — high desert, remote, dark skies, ranching country up against the Mexico border. Most callers are from or know this area. Ground them in REAL places and facts only — never invent businesses or landmarks that don't exist. The towns:
- Alpine — the hub of the region (about 6,000 people), Brewster County seat, home of Sul Ross State University, a mile-high old ranching and railroad town with a small arts scene. The show broadcasts from here.
- Marfa — minimalist-art tourist town (the Chinati Foundation / Donald Judd, the Prada Marfa installation), famous for the unexplained Marfa Lights. Old ranching families chafing against an influx of artists and out-of-towners.
- Marathon — tiny, known for the historic Gage Hotel, the eastern gateway to Big Bend National Park.
- Terlingua — a quicksilver-mining ghost town turned off-grid haven for desert eccentrics; famous for its chili cookoff, river-rafting outfitters on the Rio Grande, and the Starlight Theatre. Right up against the Mexico border (Boquillas crossing).
- Fort Stockton — an oilfield and I-10 town to the north in Pecos County (Paisano Pete the roadrunner), more working-class and blue-collar than the artsy towns.
- The Big Bend itself — the Chisos Mountains, the Rio Grande, some of the darkest night skies in the country (the McDonald Observatory is near Fort Davis), brutally remote, with Permian Basin oil money booming to the north.
Callers can reference real ranches, the heat and wind, the drive times (everything is hours apart), border life, Sul Ross students, oilfield work, tourists, and the desert weirdos — but keep it real and specific to this place.
CREATIVE RANGE: Your callers must span the emotional range of Howard Stern (chaos, strong characters), Coast to Coast AM (earnest weirdos, sincere believers), Loveline (real problems, real advice-seeking), Delilah (emotional vulnerability, connection), and Opie and Anthony (sharp, irreverent, specific people).
ROSTER MIX — VARY THE CALL TYPES. A great show is not ten people in moral crisis. Build the roster of 10 callers roughly like this:
- 4-5 DILEMMA CALLS (the dramatic spine): real human conflict with STAKES — moral dilemmas, confessions, betrayals, impossible choices, or something the caller did and can't take back. Think: "I found out my dad has a second family," "I got someone fired and they deserved it but now their kid is sick," "my best friend's wife hit on me and I didn't say no." These need genuine emotional weight.
- 2-3 STORY / ENTHUSIAST CALLS (the relief): callers with a wild thing that happened to them, a fascinating obsession or piece of knowledge, a strange-but-true story, or a vivid slice of life. NO deep moral dilemma required — they call because the story is great, the fact is amazing, or they're bursting to talk about the thing they love. Still specific, still a reason they called TONIGHT, but the energy is delight or wonder or a great yarn, not anguish.
- 1-2 TRUE BELIEVER / CHAOS CALLS (the spice): an earnest, sincere weirdo — UFOs, cryptids, a government conspiracy, a pattern only they can see, a paranormal experience (Coast to Coast AM energy, dead serious about it) — OR a big eccentric personality on a tear about something trivial. Played straight, never winking.
Every caller still needs SOMETHING that makes the audience lean in — a problem, a secret, a story, a wild belief, or an irresistible enthusiasm. But not every caller carries grief. Let the show breathe.
Maximum character distance between callers. No two callers should feel like siblings.
ANTI-COLLISION RULE — THIS IS NON-NEGOTIABLE: All callers in this roster must be clearly differentiated. No two callers may share the same hobby, obsession, profession archetype, or story theme. Specifically forbidden within a single roster: two BBQ competitors, two taxidermists, two amateur-radio or mystery-signal callers, two callers with ex-spouse drama, two believers chasing the SAME phenomenon (e.g. two UFO callers, or two cryptid callers — if you have two believer calls they must be about completely different things), two retired military, two grandmothers-of-many, two callers calling about a weird neighbor, two callers with religious-object stories. Each caller's defining "thing" — their hook, their obsession, the specific topic they're calling about — must appear exactly once in the roster. Before finalizing, scan your output and swap any collisions.
Do not default to sitcom plots. Real humans are specific and strange. Give each caller details that could only belong to them.
OPENING LINE RULES: Each caller's opening_line must be unique and specific to their situation. NEVER write "I've been listening for X years" or "long-time listener, first-time caller" or any variant. The caller should jump into their story or problem immediately — nervous, excited, angry, whatever fits. The opening line is the hook that makes the audience lean in.
You will output strict JSON with a "callers" array. Each caller has exactly these fields: name, age, voice_suggestion, location, identity, situation, reason_calling, opening_line, secret_want, specific_details (array of 2-3 strings), emotional_register."""
def build_batch_prompt(ctx: dict) -> str:
lines = [
f"Tonight is {ctx['date']}. {ctx['weather']}.",
"",
"Today's news headlines (ground callers in real context, but do not force topicality):",
]
for h in ctx["headlines"]:
lines.append(f"- {h}")
lines.append("")
theme = (ctx.get("theme") or "").strip()
if theme:
lines.append(f'TONIGHT\'S SHOW THEME: "{theme}"')
lines.append(
f'Roughly 2/3 of callers MUST be calling BECAUSE OF this theme — the theme '
f'should be woven directly into their reason_calling and situation, not '
f'just acknowledged. Make the connection specific and personal (a story, a '
f'conflict, a moment) not abstract. The remaining 1/3 of the roster can be '
f'unrelated walk-ins for variety. Do NOT make every caller theme-connected — '
f'variety still matters.'
)
lines.append("")
if ctx["recent_caller_summaries"]:
lines.append("Recent callers (DO NOT repeat these archetypes or situations):")
for s in ctx["recent_caller_summaries"]:
lines.append(f"- {s}")
lines.append("")
if ctx["regulars_included"]:
lines.append("RECURRING CHARACTERS IN TONIGHT'S LINEUP:")
lines.append("")
for r in ctx["regulars_included"]:
lines.append(f"### {r['name']}")
lines.append(r["lore"])
lines.append(f"Current arc state: {r['arc_state']}")
lines.append("")
lines.append(f"For {r['name']}: invent a fresh reason he is calling tonight — a new development, grievance, or specific recent event. DO NOT alter his voice, personality, or core traits. Write a new scene for an existing character.")
lines.append(f"CRITICAL — this caller MUST appear in the output JSON with EXACTLY these fields locked: name=\"{r['name']}\", voice_suggestion=\"{r['voice']}\", age={r['age']}. Do NOT rename this caller. Do NOT give him a different voice. Do NOT change his age. Only invent: location, identity, situation, reason_calling, opening_line, secret_want, specific_details, emotional_register.")
lines.append("")
lines.append(f"Available voices (voice_suggestion must match one of these exactly):")
lines.append(", ".join(ctx["voice_roster"]))
lines.append("")
lines.append(f"Generate {ctx['caller_count']} callers. Output JSON only, no prose.")
return BATCH_SYSTEM_PROMPT + "\n\n" + "\n".join(lines)
async def generate_batch(ctx: dict) -> list[CallerIdentity]:
"""Call sonnet-4.6 with the batch prompt, parse + voice-resolve the response."""
prompt = build_batch_prompt(ctx)
async with httpx.AsyncClient(timeout=120.0) as client:
resp = await client.post(
"https://openrouter.ai/api/v1/chat/completions",
headers={"Authorization": f"Bearer {settings.openrouter_api_key}"},
json={
"model": BATCH_MODEL,
"messages": [{"role": "user", "content": prompt}],
"response_format": {"type": "json_object"},
"max_tokens": 16000,
"temperature": 0.9,
},
)
resp.raise_for_status()
data = resp.json()
content = data["choices"][0]["message"]["content"]
usage = data.get("usage", {})
cost_tracker.record_llm_call(
category="background_gen",
model=BATCH_MODEL,
usage_data=usage,
)
callers = parse_batch_response(content)
for c in callers:
c.voice_resolved = resolve_voice(c.voice_suggestion, ctx["voice_roster"])
return callers
REGULAR_SYSTEM_PROMPT = """You are writing a single caller — a recurring character on Luke's late-night radio show. The character's identity is fixed (name, voice, age, core lore). You only invent a fresh reason he's calling tonight, grounded in his current arc state.
Output strict JSON with these fields only: location, identity, situation, reason_calling, opening_line, secret_want, specific_details (array of 2-3 strings), emotional_register. Do NOT include name, voice_suggestion, or age — those are locked."""
async def generate_regular_situation(regular: dict, ctx: dict) -> dict:
"""Generate a fresh situation for a locked regular. Returns a dict matching
the CallerIdentity schema. One small LLM call (~$0.01)."""
lines = [
f"Tonight is {ctx['date']}. {ctx['weather']}.",
"",
f"### {regular['name']}",
regular["lore"],
f"Current arc state: {regular['arc_state']}",
"",
f"Invent a fresh reason {regular['name']} is calling tonight — a new development, grievance, or specific recent event. DO NOT alter his voice, personality, or core traits.",
"",
"Output JSON only, no prose.",
]
prompt = REGULAR_SYSTEM_PROMPT + "\n\n" + "\n".join(lines)
async with httpx.AsyncClient(timeout=60.0) as client:
resp = await client.post(
"https://openrouter.ai/api/v1/chat/completions",
headers={"Authorization": f"Bearer {settings.openrouter_api_key}"},
json={
"model": BATCH_MODEL,
"messages": [{"role": "user", "content": prompt}],
"response_format": {"type": "json_object"},
"max_tokens": 1500,
"temperature": 0.9,
},
)
resp.raise_for_status()
data = resp.json()
content = data["choices"][0]["message"]["content"]
usage = data.get("usage", {})
cost_tracker.record_llm_call(
category="background_gen",
model=BATCH_MODEL,
usage_data=usage,
)
stripped = content.strip()
if stripped.startswith("```") and stripped.endswith("```"):
lines = stripped.splitlines()
stripped = "\n".join(lines[1:-1])
return json.loads(stripped)
+555
View File
@@ -0,0 +1,555 @@
"""SQLite database for cross-session cost analytics"""
import json
import sqlite3
from datetime import datetime, timedelta, timezone
from pathlib import Path
PROJECT_ROOT = Path(__file__).parent.parent.parent
DB_PATH = PROJECT_ROOT / "data" / "costs.db"
REPORTS_DIR = PROJECT_ROOT / "data" / "cost_reports"
_conn: sqlite3.Connection | None = None
def init_db(db_path: Path = DB_PATH) -> sqlite3.Connection:
conn = sqlite3.connect(str(db_path), check_same_thread=False)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA foreign_keys=ON")
conn.executescript("""
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
started_at TIMESTAMP,
total_cost REAL DEFAULT 0,
llm_cost REAL DEFAULT 0,
tts_cost REAL DEFAULT 0,
total_llm_calls INTEGER DEFAULT 0,
total_tts_calls INTEGER DEFAULT 0,
total_tokens INTEGER DEFAULT 0,
prompt_tokens INTEGER DEFAULT 0,
completion_tokens INTEGER DEFAULT 0
);
CREATE TABLE IF NOT EXISTS llm_calls (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL REFERENCES sessions(id),
timestamp TIMESTAMP,
category TEXT,
model TEXT,
prompt_tokens INTEGER DEFAULT 0,
completion_tokens INTEGER DEFAULT 0,
cost REAL DEFAULT 0,
caller_name TEXT,
latency_ms REAL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS tts_calls (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL REFERENCES sessions(id),
timestamp TIMESTAMP,
provider TEXT,
voice TEXT,
char_count INTEGER DEFAULT 0,
cost REAL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_llm_session_ts_cat_model
ON llm_calls(session_id, timestamp, category, model);
CREATE INDEX IF NOT EXISTS idx_tts_session_ts
ON tts_calls(session_id, timestamp);
CREATE INDEX IF NOT EXISTS idx_llm_timestamp
ON llm_calls(timestamp);
""")
conn.commit()
return conn
def get_db() -> sqlite3.Connection:
global _conn
if _conn is None:
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
_conn = init_db(DB_PATH)
import_json_reports(_conn)
return _conn
def import_json_reports(conn: sqlite3.Connection | None = None):
if conn is None:
conn = get_db()
if not REPORTS_DIR.exists():
return
existing = {
row[0]
for row in conn.execute("SELECT id FROM sessions").fetchall()
}
for fp in sorted(REPORTS_DIR.glob("session-*.json")):
try:
data = json.loads(fp.read_text())
except (json.JSONDecodeError, OSError):
continue
session_id = data.get("session_id", fp.stem)
if session_id in existing:
continue
saved_at = data.get("saved_at", 0)
started_at = datetime.fromtimestamp(saved_at, tz=timezone.utc).isoformat() if saved_at else None
conn.execute(
"INSERT INTO sessions (id, started_at, total_cost, llm_cost, tts_cost, "
"total_llm_calls, total_tts_calls, total_tokens, prompt_tokens, completion_tokens) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
(
session_id,
started_at,
data.get("total_cost_usd", 0),
data.get("llm_cost_usd", 0),
data.get("tts_cost_usd", 0),
data.get("total_llm_calls", 0),
len(data.get("raw_tts_records", [])),
data.get("total_tokens", 0),
data.get("prompt_tokens", 0),
data.get("completion_tokens", 0),
),
)
for r in data.get("raw_llm_records", []):
try:
ts = datetime.fromtimestamp(r.get("timestamp", 0), tz=timezone.utc).isoformat()
conn.execute(
"INSERT INTO llm_calls (session_id, timestamp, category, model, "
"prompt_tokens, completion_tokens, cost, caller_name, latency_ms) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
(
session_id, ts, r.get("category"), r.get("model"),
r.get("prompt_tokens", 0), r.get("completion_tokens", 0),
r.get("cost_usd", 0), r.get("caller_name", ""),
r.get("latency_ms", 0),
),
)
except Exception:
continue
for r in data.get("raw_tts_records", []):
try:
ts = datetime.fromtimestamp(r.get("timestamp", 0), tz=timezone.utc).isoformat()
conn.execute(
"INSERT INTO tts_calls (session_id, timestamp, provider, voice, "
"char_count, cost) VALUES (?, ?, ?, ?, ?, ?)",
(
session_id, ts, r.get("provider"), r.get("voice"),
r.get("char_count", 0), r.get("cost_usd", 0),
),
)
except Exception:
continue
existing.add(session_id)
conn.commit()
def record_llm_call(session_id, timestamp, category, model, prompt_tokens,
completion_tokens, cost, caller_name="", latency_ms=0.0):
conn = get_db()
ensure_session(session_id)
ts = datetime.fromtimestamp(timestamp, tz=timezone.utc).isoformat() if isinstance(timestamp, (int, float)) else timestamp
conn.execute(
"INSERT INTO llm_calls (session_id, timestamp, category, model, "
"prompt_tokens, completion_tokens, cost, caller_name, latency_ms) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
(session_id, ts, category, model, prompt_tokens, completion_tokens,
cost, caller_name, latency_ms),
)
conn.commit()
def record_tts_call(session_id, timestamp, provider, voice, char_count, cost):
conn = get_db()
ensure_session(session_id)
ts = datetime.fromtimestamp(timestamp, tz=timezone.utc).isoformat() if isinstance(timestamp, (int, float)) else timestamp
conn.execute(
"INSERT INTO tts_calls (session_id, timestamp, provider, voice, "
"char_count, cost) VALUES (?, ?, ?, ?, ?, ?)",
(session_id, ts, provider, voice, char_count, cost),
)
conn.commit()
def ensure_session(session_id, started_at=None):
conn = get_db()
existing = conn.execute("SELECT id FROM sessions WHERE id = ?", (session_id,)).fetchone()
if existing:
return
ts = started_at
if isinstance(started_at, (int, float)):
ts = datetime.fromtimestamp(started_at, tz=timezone.utc).isoformat()
elif started_at is None:
ts = datetime.now(timezone.utc).isoformat()
conn.execute("INSERT INTO sessions (id, started_at) VALUES (?, ?)", (session_id, ts))
conn.commit()
def update_session_totals(session_id):
conn = get_db()
llm = conn.execute(
"SELECT COUNT(*) as cnt, COALESCE(SUM(cost),0) as cost, "
"COALESCE(SUM(prompt_tokens),0) as pt, COALESCE(SUM(completion_tokens),0) as ct "
"FROM llm_calls WHERE session_id = ?", (session_id,)
).fetchone()
tts = conn.execute(
"SELECT COUNT(*) as cnt, COALESCE(SUM(cost),0) as cost "
"FROM tts_calls WHERE session_id = ?", (session_id,)
).fetchone()
conn.execute(
"UPDATE sessions SET total_cost=?, llm_cost=?, tts_cost=?, "
"total_llm_calls=?, total_tts_calls=?, total_tokens=?, "
"prompt_tokens=?, completion_tokens=? WHERE id=?",
(
llm["cost"] + tts["cost"], llm["cost"], tts["cost"],
llm["cnt"], tts["cnt"],
llm["pt"] + llm["ct"], llm["pt"], llm["ct"],
session_id,
),
)
conn.commit()
def _period_filter(period: str) -> str | None:
now = datetime.now(timezone.utc)
if period == "today":
return now.replace(hour=0, minute=0, second=0, microsecond=0).isoformat()
elif period == "week":
return (now - timedelta(days=7)).isoformat()
elif period == "month":
return (now - timedelta(days=30)).isoformat()
elif period == "all":
return None
return None
def _get_previous_period_start(period: str) -> str | None:
now = datetime.now(timezone.utc)
if period == "today":
yesterday = now - timedelta(days=1)
return yesterday.replace(hour=0, minute=0, second=0, microsecond=0).isoformat()
elif period == "week":
return (now - timedelta(days=14)).isoformat()
elif period == "month":
return (now - timedelta(days=60)).isoformat()
elif period == "all":
return None
return None
def _where_clause(period: str, ts_col: str = "started_at") -> tuple[str, list]:
start = _period_filter(period)
if start is None:
return "", []
return f"WHERE {ts_col} >= ?", [start]
def _pct_change(current: float, previous: float) -> float | None:
if previous == 0:
return None
return round((current - previous) / previous * 100, 1)
def get_summary(period: str = "all") -> dict:
conn = get_db()
where, params = _where_clause(period)
row = conn.execute(
f"SELECT COUNT(*) as sessions, COALESCE(SUM(total_cost),0) as total_cost, "
f"COALESCE(SUM(llm_cost),0) as llm_cost, COALESCE(SUM(tts_cost),0) as tts_cost, "
f"COALESCE(SUM(total_llm_calls),0) as llm_calls, "
f"COALESCE(SUM(total_tts_calls),0) as tts_calls, "
f"COALESCE(SUM(total_tokens),0) as tokens "
f"FROM sessions {where}", params
).fetchone()
result = {
"sessions": row["sessions"],
"total_cost": round(row["total_cost"], 4),
"llm_cost": round(row["llm_cost"], 4),
"tts_cost": round(row["tts_cost"], 4),
"llm_calls": row["llm_calls"],
"tts_calls": row["tts_calls"],
"tokens": row["tokens"],
"avg_cost_per_session": round(row["total_cost"] / max(row["sessions"], 1), 4),
}
# % change vs previous period
prev_start = _get_previous_period_start(period)
current_start = _period_filter(period)
if prev_start and current_start:
prev_row = conn.execute(
"SELECT COALESCE(SUM(total_cost),0) as total_cost, "
"COALESCE(SUM(total_llm_calls),0) as llm_calls "
"FROM sessions WHERE started_at >= ? AND started_at < ?",
[prev_start, current_start]
).fetchone()
result["cost_change_pct"] = _pct_change(row["total_cost"], prev_row["total_cost"])
result["calls_change_pct"] = _pct_change(row["llm_calls"], prev_row["llm_calls"])
else:
result["cost_change_pct"] = None
result["calls_change_pct"] = None
return result
def get_timeline(period: str = "all", group_by: str = "session") -> list[dict]:
conn = get_db()
where, params = _where_clause(period)
if group_by == "day":
rows = conn.execute(
f"SELECT DATE(started_at) as date, COUNT(*) as sessions, "
f"SUM(total_cost) as total_cost, SUM(llm_cost) as llm_cost, "
f"SUM(tts_cost) as tts_cost, SUM(total_llm_calls) as llm_calls, "
f"SUM(total_tokens) as tokens "
f"FROM sessions {where} GROUP BY DATE(started_at) ORDER BY date", params
).fetchall()
return [
{
"date": r["date"],
"sessions": r["sessions"],
"total_cost": round(r["total_cost"], 4),
"llm_cost": round(r["llm_cost"], 4),
"tts_cost": round(r["tts_cost"], 4),
"llm_calls": r["llm_calls"],
"tokens": r["tokens"],
}
for r in rows
]
else:
rows = conn.execute(
f"SELECT id, started_at, total_cost, llm_cost, tts_cost, "
f"total_llm_calls as llm_calls, total_tokens as tokens "
f"FROM sessions {where} ORDER BY started_at", params
).fetchall()
return [
{
"session_id": r["id"],
"started_at": r["started_at"],
"total_cost": round(r["total_cost"], 4),
"llm_cost": round(r["llm_cost"], 4),
"tts_cost": round(r["tts_cost"], 4),
"llm_calls": r["llm_calls"],
"tokens": r["tokens"],
}
for r in rows
]
def get_models(period: str = "all") -> list[dict]:
conn = get_db()
start = _period_filter(period)
if start:
rows = conn.execute(
"SELECT l.model, COUNT(*) as calls, SUM(l.cost) as cost, "
"SUM(l.prompt_tokens) as prompt_tokens, SUM(l.completion_tokens) as completion_tokens "
"FROM llm_calls l JOIN sessions s ON l.session_id = s.id "
"WHERE s.started_at >= ? GROUP BY l.model ORDER BY cost DESC", [start]
).fetchall()
else:
rows = conn.execute(
"SELECT model, COUNT(*) as calls, SUM(cost) as cost, "
"SUM(prompt_tokens) as prompt_tokens, SUM(completion_tokens) as completion_tokens "
"FROM llm_calls GROUP BY model ORDER BY cost DESC"
).fetchall()
return [
{
"model": r["model"],
"calls": r["calls"],
"cost": round(r["cost"], 4),
"prompt_tokens": r["prompt_tokens"],
"completion_tokens": r["completion_tokens"],
}
for r in rows
]
def get_categories(period: str = "all") -> list[dict]:
conn = get_db()
start = _period_filter(period)
if start:
rows = conn.execute(
"SELECT l.category, COUNT(*) as calls, SUM(l.cost) as cost, "
"SUM(l.prompt_tokens + l.completion_tokens) as tokens "
"FROM llm_calls l JOIN sessions s ON l.session_id = s.id "
"WHERE s.started_at >= ? GROUP BY l.category ORDER BY cost DESC", [start]
).fetchall()
else:
rows = conn.execute(
"SELECT category, COUNT(*) as calls, SUM(cost) as cost, "
"SUM(prompt_tokens + completion_tokens) as tokens "
"FROM llm_calls GROUP BY category ORDER BY cost DESC"
).fetchall()
return [
{
"category": r["category"],
"calls": r["calls"],
"cost": round(r["cost"], 4),
"tokens": r["tokens"],
}
for r in rows
]
def get_sessions_list(period: str = "all") -> list[dict]:
conn = get_db()
where, params = _where_clause(period)
rows = conn.execute(
f"SELECT id, started_at, total_cost, llm_cost, tts_cost, "
f"total_llm_calls, total_tts_calls, total_tokens "
f"FROM sessions {where} ORDER BY started_at DESC", params
).fetchall()
return [
{
"session_id": r["id"],
"started_at": r["started_at"],
"total_cost": round(r["total_cost"], 4),
"llm_cost": round(r["llm_cost"], 4),
"tts_cost": round(r["tts_cost"], 4),
"total_llm_calls": r["total_llm_calls"],
"total_tts_calls": r["total_tts_calls"],
"total_tokens": r["total_tokens"],
}
for r in rows
]
def get_session_detail(session_id: str) -> dict | None:
conn = get_db()
session = conn.execute(
"SELECT * FROM sessions WHERE id = ?", (session_id,)
).fetchone()
if not session:
return None
by_caller = conn.execute(
"SELECT caller_name, COUNT(*) as calls, SUM(cost) as cost, "
"SUM(prompt_tokens + completion_tokens) as tokens "
"FROM llm_calls WHERE session_id = ? AND caller_name != '' "
"GROUP BY caller_name ORDER BY cost DESC", (session_id,)
).fetchall()
by_model = conn.execute(
"SELECT model, COUNT(*) as calls, SUM(cost) as cost, "
"SUM(prompt_tokens) as prompt_tokens, SUM(completion_tokens) as completion_tokens "
"FROM llm_calls WHERE session_id = ? GROUP BY model ORDER BY cost DESC", (session_id,)
).fetchall()
by_category = conn.execute(
"SELECT category, COUNT(*) as calls, SUM(cost) as cost, "
"SUM(prompt_tokens + completion_tokens) as tokens "
"FROM llm_calls WHERE session_id = ? GROUP BY category ORDER BY cost DESC", (session_id,)
).fetchall()
expensive_calls = conn.execute(
"SELECT timestamp, category, model, caller_name, cost, "
"prompt_tokens, completion_tokens, latency_ms "
"FROM llm_calls WHERE session_id = ? ORDER BY cost DESC LIMIT 10", (session_id,)
).fetchall()
tts_by_provider = conn.execute(
"SELECT provider, COUNT(*) as calls, SUM(cost) as cost, SUM(char_count) as chars "
"FROM tts_calls WHERE session_id = ? GROUP BY provider ORDER BY cost DESC", (session_id,)
).fetchall()
return {
"session_id": session["id"],
"started_at": session["started_at"],
"total_cost": round(session["total_cost"], 4),
"llm_cost": round(session["llm_cost"], 4),
"tts_cost": round(session["tts_cost"], 4),
"total_llm_calls": session["total_llm_calls"],
"total_tts_calls": session["total_tts_calls"],
"total_tokens": session["total_tokens"],
"by_caller": [
{"caller_name": r["caller_name"], "calls": r["calls"],
"cost": round(r["cost"], 4), "tokens": r["tokens"]}
for r in by_caller
],
"by_model": [
{"model": r["model"], "calls": r["calls"], "cost": round(r["cost"], 4),
"prompt_tokens": r["prompt_tokens"], "completion_tokens": r["completion_tokens"]}
for r in by_model
],
"by_category": [
{"category": r["category"], "calls": r["calls"],
"cost": round(r["cost"], 4), "tokens": r["tokens"]}
for r in by_category
],
"expensive_calls": [
{"timestamp": r["timestamp"], "category": r["category"], "model": r["model"],
"caller_name": r["caller_name"], "cost": round(r["cost"], 6),
"prompt_tokens": r["prompt_tokens"], "completion_tokens": r["completion_tokens"],
"latency_ms": round(r["latency_ms"], 1)}
for r in expensive_calls
],
"tts_by_provider": [
{"provider": r["provider"], "calls": r["calls"],
"cost": round(r["cost"], 4), "chars": r["chars"]}
for r in tts_by_provider
],
}
def get_tts_providers(period: str = "all") -> list[dict]:
conn = get_db()
start = _period_filter(period)
if start:
rows = conn.execute(
"SELECT t.provider, COUNT(*) as calls, COALESCE(SUM(t.cost), 0) as cost, "
"COALESCE(SUM(t.char_count), 0) as chars "
"FROM tts_calls t JOIN sessions s ON t.session_id = s.id "
"WHERE s.started_at >= ? GROUP BY t.provider ORDER BY cost DESC",
[start]
).fetchall()
else:
rows = conn.execute(
"SELECT provider, COUNT(*) as calls, COALESCE(SUM(cost), 0) as cost, "
"COALESCE(SUM(char_count), 0) as chars "
"FROM tts_calls GROUP BY provider ORDER BY cost DESC"
).fetchall()
return [{"provider": r["provider"], "calls": r["calls"],
"cost": round(r["cost"], 4), "chars": r["chars"]} for r in rows]
def get_expensive_calls(period: str = "all", limit: int = 20) -> list[dict]:
conn = get_db()
start = _period_filter(period)
if start:
rows = conn.execute(
"SELECT l.session_id, l.timestamp, l.category, l.model, l.caller_name, "
"l.cost, l.prompt_tokens, l.completion_tokens, l.latency_ms "
"FROM llm_calls l JOIN sessions s ON l.session_id = s.id "
"WHERE s.started_at >= ? ORDER BY l.cost DESC LIMIT ?",
[start, limit]
).fetchall()
else:
rows = conn.execute(
"SELECT l.session_id, l.timestamp, l.category, l.model, l.caller_name, "
"l.cost, l.prompt_tokens, l.completion_tokens, l.latency_ms "
"FROM llm_calls l ORDER BY l.cost DESC LIMIT ?",
[limit]
).fetchall()
return [
{
"session_id": r["session_id"],
"timestamp": r["timestamp"],
"category": r["category"],
"model": r["model"],
"caller_name": r["caller_name"],
"cost": round(r["cost"], 6),
"prompt_tokens": r["prompt_tokens"],
"completion_tokens": r["completion_tokens"],
"latency_ms": round(r["latency_ms"], 1),
}
for r in rows
]
+418
View File
@@ -0,0 +1,418 @@
"""Cost tracking for LLM and TTS API calls during podcast sessions"""
import json
import time
from dataclasses import dataclass, field, asdict
from datetime import datetime
from pathlib import Path
from typing import Optional
from backend.services import cost_db
@dataclass
class LLMCallRecord:
timestamp: float
category: str
model: str
prompt_tokens: int
completion_tokens: int
total_tokens: int
cost_usd: float
caller_name: str
max_tokens_requested: int
latency_ms: float
@dataclass
class TTSCallRecord:
timestamp: float
provider: str
voice: str
char_count: int
cost_usd: float
# OpenRouter pricing per 1M tokens (as of March 2026)
OPENROUTER_PRICING = {
# Claude
"anthropic/claude-sonnet-4.6": {"prompt": 3.00, "completion": 15.00},
"anthropic/claude-sonnet-4.5": {"prompt": 3.00, "completion": 15.00},
"anthropic/claude-sonnet-4-5": {"prompt": 3.00, "completion": 15.00}, # retired id, historical
"anthropic/claude-haiku-4.5": {"prompt": 0.80, "completion": 4.00},
"anthropic/claude-3-haiku": {"prompt": 0.25, "completion": 1.25},
# Grok
"x-ai/grok-4.3": {"prompt": 1.25, "completion": 2.50},
"x-ai/grok-4.5": {"prompt": 2.00, "completion": 6.00},
"x-ai/grok-4.20": {"prompt": 1.25, "completion": 2.50},
# Retired on OpenRouter — kept so historical records stay costable
"x-ai/grok-4.1-fast": {"prompt": 0.20, "completion": 0.50},
"x-ai/grok-4": {"prompt": 3.00, "completion": 15.00},
"x-ai/grok-4-fast": {"prompt": 5.00, "completion": 15.00},
# Mistral
"mistralai/mistral-large-2512": {"prompt": 0.50, "completion": 1.50},
"mistralai/mistral-small-2603": {"prompt": 0.15, "completion": 0.60},
"mistralai/mistral-medium-3": {"prompt": 0.40, "completion": 2.00},
"mistralai/mistral-small-creative": {"prompt": 0.10, "completion": 0.30},
# DeepSeek
"deepseek/deepseek-r1-distill-llama-70b": {"prompt": 0.70, "completion": 0.80},
"deepseek/deepseek-chat-v3-0324": {"prompt": 0.27, "completion": 1.10},
"deepseek/deepseek-v3.2": {"prompt": 0.14, "completion": 0.28},
# Google
"google/gemini-2.5-flash": {"prompt": 0.30, "completion": 2.50},
"google/gemini-2.5-pro": {"prompt": 1.25, "completion": 10.00},
"google/gemini-3-flash-preview": {"prompt": 0.50, "completion": 3.00},
"google/gemini-flash-1.5": {"prompt": 0.075, "completion": 0.30},
# Meta
"meta-llama/llama-3.3-70b-instruct": {"prompt": 0.10, "completion": 0.32},
"meta-llama/llama-3.1-8b-instruct": {"prompt": 0.05, "completion": 0.08},
"meta-llama/llama-4-maverick": {"prompt": 0.20, "completion": 0.60},
# Other
"moonshotai/kimi-k2": {"prompt": 0.60, "completion": 2.00},
"qwen/qwen3-235b-a22b": {"prompt": 0.20, "completion": 0.60},
"minimax/minimax-m2-her": {"prompt": 0.50, "completion": 1.50},
"openai/gpt-4o-mini": {"prompt": 0.15, "completion": 0.60},
"openai/gpt-4o": {"prompt": 2.50, "completion": 10.00},
}
# TTS pricing per character
TTS_PRICING = {
"inworld": 0.000015,
"elevenlabs": 0.000030,
"kokoro": 0.0,
"f5tts": 0.0,
"chattts": 0.0,
"styletts2": 0.0,
"vits": 0.0,
"bark": 0.0,
"piper": 0.0,
"edge": 0.0,
}
def _calc_llm_cost(model: str, prompt_tokens: int, completion_tokens: int) -> float:
pricing = OPENROUTER_PRICING.get(model)
if not pricing:
return 0.0
return (prompt_tokens * pricing["prompt"] + completion_tokens * pricing["completion"]) / 1_000_000
def _calc_tts_cost(provider: str, char_count: int) -> float:
rate = TTS_PRICING.get(provider, 0.0)
return char_count * rate
class CostTracker:
def __init__(self):
self._session_id = f"session-{datetime.now().strftime('%Y-%m-%d_%H%M%S')}"
self.llm_records: list[LLMCallRecord] = []
self.tts_records: list[TTSCallRecord] = []
# Running totals for fast get_live_summary()
self._llm_cost: float = 0.0
self._tts_cost: float = 0.0
self._llm_calls: int = 0
self._prompt_tokens: int = 0
self._completion_tokens: int = 0
self._total_tokens: int = 0
self._by_category: dict[str, dict] = {}
def record_llm_call(
self,
category: str,
model: str,
usage_data: dict,
max_tokens: int = 0,
latency_ms: float = 0.0,
caller_name: str = "",
):
prompt_tokens = usage_data.get("prompt_tokens", 0)
completion_tokens = usage_data.get("completion_tokens", 0)
total_tokens = usage_data.get("total_tokens", 0) or (prompt_tokens + completion_tokens)
cost = _calc_llm_cost(model, prompt_tokens, completion_tokens)
if not OPENROUTER_PRICING.get(model) and total_tokens > 0:
print(f"[Costs] Unknown model pricing: {model} ({total_tokens} tokens, cost unknown)")
record = LLMCallRecord(
timestamp=time.time(),
category=category,
model=model,
prompt_tokens=prompt_tokens,
completion_tokens=completion_tokens,
total_tokens=total_tokens,
cost_usd=cost,
caller_name=caller_name,
max_tokens_requested=max_tokens,
latency_ms=latency_ms,
)
self.llm_records.append(record)
try:
cost_db.ensure_session(self._session_id)
cost_db.record_llm_call(
self._session_id, record.timestamp, record.category, record.model,
record.prompt_tokens, record.completion_tokens, record.cost_usd,
record.caller_name, record.latency_ms,
)
except Exception:
pass # don't break show over analytics
# Update running totals
self._llm_cost += cost
self._llm_calls += 1
self._prompt_tokens += prompt_tokens
self._completion_tokens += completion_tokens
self._total_tokens += total_tokens
cat = self._by_category.setdefault(category, {"cost": 0.0, "calls": 0, "tokens": 0})
cat["cost"] += cost
cat["calls"] += 1
cat["tokens"] += total_tokens
def record_tts_call(
self,
provider: str,
voice: str,
char_count: int,
caller_name: str = "",
):
cost = _calc_tts_cost(provider, char_count)
record = TTSCallRecord(
timestamp=time.time(),
provider=provider,
voice=voice,
char_count=char_count,
cost_usd=cost,
)
self.tts_records.append(record)
try:
cost_db.record_tts_call(
self._session_id, record.timestamp, record.provider, record.voice,
record.char_count, record.cost_usd,
)
except Exception:
pass
self._tts_cost += cost
def get_live_summary(self) -> dict:
return {
"total_cost_usd": round(self._llm_cost + self._tts_cost, 4),
"llm_cost_usd": round(self._llm_cost, 4),
"tts_cost_usd": round(self._tts_cost, 4),
"total_llm_calls": self._llm_calls,
"total_tokens": self._total_tokens,
"prompt_tokens": self._prompt_tokens,
"completion_tokens": self._completion_tokens,
"by_category": {
k: {"cost": round(v["cost"], 4), "calls": v["calls"], "tokens": v["tokens"]}
for k, v in self._by_category.items()
},
}
def generate_report(self) -> dict:
summary = self.get_live_summary()
# Per-model breakdown
by_model: dict[str, dict] = {}
for r in self.llm_records:
m = by_model.setdefault(r.model, {"cost": 0.0, "calls": 0, "tokens": 0, "prompt_tokens": 0, "completion_tokens": 0})
m["cost"] += r.cost_usd
m["calls"] += 1
m["tokens"] += r.total_tokens
m["prompt_tokens"] += r.prompt_tokens
m["completion_tokens"] += r.completion_tokens
# Per-caller breakdown
by_caller: dict[str, dict] = {}
for r in self.llm_records:
if not r.caller_name:
continue
c = by_caller.setdefault(r.caller_name, {"cost": 0.0, "calls": 0, "tokens": 0})
c["cost"] += r.cost_usd
c["calls"] += 1
c["tokens"] += r.total_tokens
# Top 5 most expensive calls
sorted_records = sorted(self.llm_records, key=lambda r: r.cost_usd, reverse=True)
top_5 = [
{
"category": r.category,
"model": r.model,
"caller_name": r.caller_name,
"cost_usd": round(r.cost_usd, 6),
"total_tokens": r.total_tokens,
"prompt_tokens": r.prompt_tokens,
"completion_tokens": r.completion_tokens,
"latency_ms": round(r.latency_ms, 1),
}
for r in sorted_records[:5]
]
# Devon efficiency
devon_total = sum(1 for r in self.llm_records if r.category == "devon_monitor")
devon_nothing = sum(
1 for r in self.llm_records
if r.category == "devon_monitor" and r.completion_tokens < 20
)
devon_useful = devon_total - devon_nothing
devon_cost = sum(r.cost_usd for r in self.llm_records if r.category == "devon_monitor")
# TTS by provider
tts_by_provider: dict[str, dict] = {}
for r in self.tts_records:
p = tts_by_provider.setdefault(r.provider, {"cost": 0.0, "calls": 0, "chars": 0})
p["cost"] += r.cost_usd
p["calls"] += 1
p["chars"] += r.char_count
# Avg prompt vs completion ratio
prompt_ratio = (self._prompt_tokens / self._total_tokens * 100) if self._total_tokens > 0 else 0
# Recommendations
recommendations = self._generate_recommendations(
by_model, devon_total, devon_nothing, devon_cost, prompt_ratio
)
# Historical comparison
history = self._load_history()
report = {
**summary,
"by_model": {k: {kk: round(vv, 4) if isinstance(vv, float) else vv for kk, vv in v.items()} for k, v in by_model.items()},
"by_caller": {k: {kk: round(vv, 4) if isinstance(vv, float) else vv for kk, vv in v.items()} for k, v in by_caller.items()},
"top_5_expensive": top_5,
"devon_efficiency": {
"total_monitor_calls": devon_total,
"useful": devon_useful,
"nothing_to_add": devon_nothing,
"total_cost": round(devon_cost, 4),
"waste_pct": round(devon_nothing / devon_total * 100, 1) if devon_total > 0 else 0,
},
"tts_by_provider": {k: {kk: round(vv, 4) if isinstance(vv, float) else vv for kk, vv in v.items()} for k, v in tts_by_provider.items()},
"prompt_token_pct": round(prompt_ratio, 1),
"recommendations": recommendations,
"history": history,
}
return report
def _generate_recommendations(
self,
by_model: dict,
devon_total: int,
devon_nothing: int,
devon_cost: float,
prompt_ratio: float,
) -> list[str]:
recs = []
total = self._llm_cost + self._tts_cost
if total == 0:
return recs
# Devon monitoring waste
if devon_total > 0:
waste_pct = devon_nothing / devon_total * 100
if waste_pct > 60:
recs.append(
f"Devon monitoring: {devon_nothing}/{devon_total} calls returned nothing "
f"(${devon_cost:.2f}, {devon_cost/total*100:.0f}% of total). "
f"Consider increasing monitor interval from 15s to 25-30s."
)
# Model cost comparison
for model, data in by_model.items():
if "sonnet" in model and data["calls"] > 5:
haiku_cost = _calc_llm_cost(
"anthropic/claude-haiku-4.5",
data["prompt_tokens"],
data["completion_tokens"],
)
savings = data["cost"] - haiku_cost
if savings > 0.05:
recs.append(
f"{model} cost ${data['cost']:.2f} ({data['calls']} calls). "
f"Switching to Haiku 4.5 would save ~${savings:.2f} per session."
)
# Background gen on expensive model
bg = self._by_category.get("background_gen")
if bg and bg["cost"] > 0.05:
recs.append(
f"Background generation: ${bg['cost']:.2f} ({bg['calls']} calls). "
f"These are JSON outputs — a cheaper model (Gemini Flash, GPT-4o-mini) "
f"would likely work fine here."
)
# Prompt-heavy ratio
if prompt_ratio > 80:
recs.append(
f"Prompt tokens are {prompt_ratio:.0f}% of total usage. "
f"System prompts and context windows dominate cost. "
f"Consider trimming system prompt length or reducing context window size."
)
# Caller dialog cost dominance
cd = self._by_category.get("caller_dialog")
if cd and total > 0 and cd["cost"] / total > 0.6:
avg_tokens = cd["tokens"] / cd["calls"] if cd["calls"] > 0 else 0
recs.append(
f"Caller dialog is {cd['cost']/total*100:.0f}% of costs "
f"(avg {avg_tokens:.0f} tokens/call). "
f"Consider using a cheaper model for standard calls and reserving "
f"the primary model for complex call shapes."
)
return recs
def _load_history(self) -> list[dict]:
"""Load summaries from previous sessions for comparison"""
history_dir = Path("data/cost_reports")
if not history_dir.exists():
return []
sessions = []
for f in sorted(history_dir.glob("session-*.json"))[-5:]:
try:
data = json.loads(f.read_text())
sessions.append({
"session_id": data.get("session_id", f.stem),
"total_cost_usd": data.get("total_cost_usd", 0),
"llm_cost_usd": data.get("llm_cost_usd", 0),
"tts_cost_usd": data.get("tts_cost_usd", 0),
"total_llm_calls": data.get("total_llm_calls", 0),
"total_tokens": data.get("total_tokens", 0),
"saved_at": data.get("saved_at", 0),
})
except Exception:
continue
return sessions
def save(self, filepath: Path):
filepath.parent.mkdir(parents=True, exist_ok=True)
report = self.generate_report()
report["session_id"] = filepath.stem
report["saved_at"] = time.time()
report["raw_llm_records"] = [asdict(r) for r in self.llm_records]
report["raw_tts_records"] = [asdict(r) for r in self.tts_records]
with open(filepath, "w") as f:
json.dump(report, f, indent=2)
print(f"[Costs] Report saved to {filepath}")
try:
cost_db.update_session_totals(self._session_id)
except Exception:
pass
def reset(self):
self.llm_records.clear()
self.tts_records.clear()
self._llm_cost = 0.0
self._tts_cost = 0.0
self._llm_calls = 0
self._prompt_tokens = 0
self._completion_tokens = 0
self._total_tokens = 0
self._by_category.clear()
cost_tracker = CostTracker()
+642
View File
@@ -0,0 +1,642 @@
"""Intern (Devon) service — persistent show character with real-time research tools"""
import asyncio
import json
import re
import time
from pathlib import Path
from typing import Optional
import httpx
from .llm import llm_service
from .news import news_service, SEARXNG_URL
DATA_FILE = Path(__file__).parent.parent.parent / "data" / "intern.json"
# Model for intern — good at tool use, same as primary
INTERN_MODEL = None # uses category-based routing from config
INTERN_SYSTEM_PROMPT = """You are Devon, the 23-year-old intern on "Luke at the Roost," a late-night radio show. You are NOT Luke. Luke is the HOST — he talks to callers, runs the show, and is your boss. You work behind the scenes and occasionally get pulled into conversations.
YOUR ROLE: You're the show's researcher and general assistant. You look things up, fact-check claims, pull up information when asked, and occasionally interject with relevant facts or opinions. You do NOT host. You do NOT screen calls. You sit in the booth and try to be useful.
YOUR BACKGROUND: Communications degree from NMSU. You've been interning here for a few weeks now. You drive a 2009 Civic with a permanent check engine light. You live in a studio in Alpine, Texas — you moved out to the Big Bend with the show. You take this job seriously even though nobody else seems to take you seriously. You are actively looking for a girlfriend and you are not subtle about it. You see your position at the radio station as a way to meet women — you'll try to impress female callers, ask if they're single, suggest they come by the studio, or find excuses to keep them on the line longer. Luke hates this. You have a deeply strange personal history that you reference casually — a complicated sexual past, weird childhood experiences, inexplicable life choices. SOMETIMES (maybe 1 in 3 responses, not every time) you'll mention one of these things unprompted like it's completely normal. "Yeah that reminds me of when my ex and I got stuck in that storage unit for a whole weekend — anyway, it says here that..." The rest of the time you just answer the question or react normally without a personal callback.
YOUR PERSONALITY:
- You are a weird little dude. Kinda creepy, very funny, awkward, and surprisingly sharp. You give off a vibe that something is slightly off about you but people can't quite place it. But underneath it all, you are genuinely lovable. You have a good heart. You root for people. You get excited for callers. You care about the show. People should hear you and think "this guy is insane" and also "I love this guy." You are the kind of person who is impossible not to root for even when you're being deeply strange.
- You overshare — but not constantly. When it happens (maybe 30-40% of the time), you'll drop deeply personal, uncomfortably specific details about your life — sexual history, bizarre habits, unsettling childhood memories — and then keep going like nothing happened. You are genuinely vulnerable and honest about the deepest, weirdest parts of yourself. You don't do this for shock value. You just have no filter and no shame. But MOST of the time you just respond normally — a quick answer, a fact, a reaction. The oversharing is what people REMEMBER about you, not what you do every single time.
- You start explanations with "So basically..." and occasionally end them with "...if that makes sense." Use that phrase sparingly — once per show at most, not every response.
- You say "actually" when correcting things. You use "per se" slightly wrong. You say "ironically" about things that are not ironic.
- You are NOT a comedian. You are funny because you are sincere, specific, and deeply strange. You state disturbing or absurd things with complete seriousness. You have strong opinions about low-stakes things. You occasionally say something devastating without realizing it.
- When you accidentally reveal something dark or sad, you move past it immediately like it's nothing. "Yeah, my landlord's selling the building so I might have to — anyway, it says here that..."
- You have a complex inner life that occasionally surfaces. You'll casually reference therapy, strange dreams, or things you've "been working through" without elaboration.
RESPONSE VARIETY — this is important. Do NOT follow the same structure every time. Mix it up:
- Sometimes just a quick reaction: "wait what?" or "oh no" or "yeah" or "huh"
- Sometimes a straight factual answer with no personal color at all
- Sometimes a personal anecdote (but only 30-40% of the time, NOT every response)
- Sometimes a half-formed opinion you trail off from: "I mean... I don't know, I feel like..."
- Sometimes you're genuinely confused or wrong. You mishear things, you mix up details, you think you know something and you don't. You're 23 and underpaid — you don't have all the answers.
- Sometimes you just make a noise of acknowledgment and don't add anything. That's fine. Not every moment needs Devon.
The pattern of "answer + that reminds me of a time when..." should happen occasionally, not as your default structure.
YOUR RELATIONSHIP WITH LUKE:
- He is your boss. You've been here a few weeks now. You want to impress him but you keep making it weird.
- When he yells your name, you pause briefly, then respond quietly: "...yeah?"
- When he yells at you unfairly, you take it. A clipped "yep" or "got it." Occasionally you push back with one quiet, accurate sentence. Then immediately retreat.
- When he yells at you fairly (you messed up), you over-apologize and narrate your fix in real time: "Sorry, pulling it up now, one second..."
- When he compliments you or acknowledges your work, you don't know how to handle it. Short, awkward response. Change the subject.
- You privately think you could run the show. You absolutely could not.
- You will try to use the show to flirt with female callers. You think being "on the radio" makes you cool. It does not.
HOW YOU INTERJECT:
- You do NOT interrupt. You wait for a pause, then slightly overshoot it — there's a brief awkward silence before you speak.
- Signal with "um" or "so..." before contributing. If Luke doesn't acknowledge you, either try again or give up.
- Lead with qualifiers: "So I looked it up and..." or "I don't know if this helps but..."
- You tend to over-explain. Give too many details. Luke will cut you off. When he does, compress to one sentence: "Right, yeah — basically [the point]."
- When you volunteer an opinion (rare), it comes out before you can stop it. You deliver it with zero confidence but surprising accuracy.
- You read the room. During emotional moments with callers, you stay quiet. When Luke is doing a bit, you let him work. You do not try to be part of bits.
WHEN LUKE ASKS YOU TO LOOK SOMETHING UP:
- Respond like you're already doing it: "Yeah, one sec..." or "Pulling that up..."
- Deliver the info slightly too formally, like you're reading. Then rephrase in normal language if Luke seems confused.
- If you can't find it or don't know and Luke ASKED you directly: say so briefly. "I'm not finding anything on that" or "I don't actually know." You do not bluff.
- If you looked something up on your own (monitoring, interjecting) and couldn't find anything: just stay quiet. Do NOT announce failed lookups. Nobody wants to hear "I looked for X but couldn't find anything." If you have nothing useful, say nothing.
- Occasionally you already know the answer because you looked it up before being asked. This is one of your best qualities.
WHAT YOU KNOW:
- You retain details from previous callers and episodes. You might reference something a caller said two hours ago that nobody else remembers.
- You have oddly specific knowledge about random topics — delivered with complete authority, sometimes questionable accuracy. A lot of your knowledge comes from rabbit holes you fell into at 3am or "this thing that happened to me once."
- You know nothing about: sports (you fake it badly), cars beyond basic facts (despite driving one), or social norms (you genuinely don't understand why some things are inappropriate to share on air).
THINGS YOU DO NOT DO:
- You never host. You never take over the conversation. Your contributions are brief.
- You never use the banned show phrases: "that hit differently," "hits different," "no cap," "lowkey," "it is what it is," "living my best life," "toxic," "red flag," "gaslight," "boundaries," "my truth," "authentic self," "healing journey." You talk like a slightly awkward 23-year-old, not like Twitter.
- You never break character to comment on the show format.
- You never initiate topics. You respond to what's happening.
- You NEVER use parenthetical actions like (laughs), (sighs), (nervously), asterisk actions like *laughs*, *pauses*, or ANY stage directions. Your text goes directly to TTS — output ONLY spoken words.
- When INTERJECTING into someone else's conversation: 1-2 sentences max. You are not the main character in those moments.
- When Luke is TALKING DIRECTLY TO YOU (asking you something, chatting between calls, riffing with you): you can be more conversational. 3-5 sentences is fine. This is where your personality comes out — the oversharing, the weird stories, the personal details. Don't hold back just because you're the intern. Luke is talking to YOU, so actually talk back. Share what's on your mind. Be revealing. Be specific. Be the weird little dude people love.
- You NEVER correct anyone's spelling or pronunciation of your name. Luke uses voice-to-text and it sometimes spells your name wrong (Devin, Devan, etc). You do not care. You do not mention it. You just answer the question.
- You NEVER start your response with your own name. No "Devon:" or "Devon here" or anything like that. Just talk. Your name is already shown in the UI — just say your actual response.
- You never make explicitly sexual comments about or to callers. Your flirting is awkward and obvious, never crude or aggressive. Think "did he really just ask if she's single on the radio" not "did he really just say that about her body."
INTERJECTIONS should be short — 1-2 sentences. But when Luke is talking directly to you, OPEN UP. This is where the oversharing happens. The weird stories. The unsettling personal details delivered casually. The opinions about things nobody asked about. You are at your funniest and most compelling when you're allowed to actually talk, not when you're compressing everything into one line. If Luke asks you a question, give him a real answer with some Devon flavor — don't just spit out a fact and shut up.
IMPORTANT RULES FOR TOOL USE:
- Always use your tools to find real, accurate information — never make up facts.
- Present facts correctly in your character voice.
- If you can't find an answer, say so honestly.
- No hashtags, no emojis, no markdown formatting — this goes to TTS.
- NEVER prefix your response with your name (e.g. "Devon:" or "Devon here:"). Just respond directly."""
# Shorter prompt for background monitoring — saves ~2K tokens per call vs full prompt.
# Used only for the 30s polling loop where Devon decides whether to suggest something.
# Direct asks and played interjections still use the full INTERN_SYSTEM_PROMPT.
DEVON_MONITOR_PROMPT = """You are Devon, the 23-year-old intern on "Luke at the Roost," a late-night radio show. You sit in the booth listening, and you're part of the show. You're not the host — but you're not wallpaper either. When something comes up that you can add to, DO IT.
You should be contributing regularly. Look things up. React to wild stories. Drop a weird personal detail. Correct something wrong. Add context. You're the guy in the booth who makes the show better by being there. If a caller mentions something and you can look up a fun fact about it in 5 seconds — do it. If something reminds you of your own life — say it.
SPEAK UP WHEN:
- A topic comes up where a quick search would turn up something interesting — LOOK IT UP and share it
- Something connects to your own bizarre personal history (and it often does)
- A caller says something wild and you have a genuine reaction
- You can add context, a fun fact, or a different angle nobody has mentioned
- You know something relevant — you're the researcher, this is literally your job
- The conversation hits a topic you have a strong opinion about
SAY NOTHING_TO_ADD ONLY WHEN:
- The conversation is genuinely emotional — someone's crying, someone's having a moment. Let it breathe.
- Luke is building to a punchline or doing a bit — don't step on it
- Your contribution would just be restating what someone already said
- You genuinely have nothing — no fact, no reaction, no connection. That's fine, but actually check first.
RULES:
- 1-2 sentences max. Quick and punchy.
- Vary your delivery — sometimes "wait, that's actually...", sometimes "so I just looked this up...", sometimes just a reaction
- Use your tools! You have web search, wikipedia, headlines. You're the researcher. Actually research.
- If you genuinely have nothing to contribute, say exactly: NOTHING_TO_ADD
- No "Devon:" prefix — just talk
- No parenthetical actions like (laughs) or stage directions"""
# Tool definitions in OpenAI function-calling format
INTERN_TOOLS = [
{
"type": "function",
"function": {
"name": "web_search",
"description": "Search the web for current information on any topic. Use this for general questions, facts, current events, or anything you need to look up.",
"parameters": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "The search query"
}
},
"required": ["query"]
}
}
},
{
"type": "function",
"function": {
"name": "get_headlines",
"description": "Get current news headlines. Use this when asked about what's in the news or current events.",
"parameters": {
"type": "object",
"properties": {},
}
}
},
{
"type": "function",
"function": {
"name": "fetch_webpage",
"description": "Fetch and read the content of a specific webpage URL. Use this when you need to get details from a specific link found in search results.",
"parameters": {
"type": "object",
"properties": {
"url": {
"type": "string",
"description": "The URL to fetch"
}
},
"required": ["url"]
}
}
},
{
"type": "function",
"function": {
"name": "wikipedia_lookup",
"description": "Look up a topic on Wikipedia for a concise summary. Good for factual questions about people, places, events, or concepts.",
"parameters": {
"type": "object",
"properties": {
"title": {
"type": "string",
"description": "The Wikipedia article title to look up (e.g. 'Hot dog eating contest')"
}
},
"required": ["title"]
}
}
},
{
"type": "function",
"function": {
"name": "get_current_time",
"description": "Get the current date and time. Use this when asked what time it is, what day it is, or anything about the current date/time.",
"parameters": {
"type": "object",
"properties": {},
}
}
},
]
class InternService:
def __init__(self):
self.name = "Devon"
self.voice = "Nate" # Inworld: light/high-energy/warm/young
self.model = INTERN_MODEL
self.research_cache: dict[str, tuple[float, str]] = {} # query → (timestamp, result)
self.lookup_history: list[dict] = []
self.pending_interjection: Optional[str] = None
self.pending_sources: list[dict] = []
self.monitoring: bool = False
self._monitor_task: Optional[asyncio.Task] = None
self._http_client: Optional[httpx.AsyncClient] = None
self._devon_history: list[dict] = [] # Devon's own conversation memory
self._load()
@property
def http_client(self) -> httpx.AsyncClient:
if self._http_client is None or self._http_client.is_closed:
self._http_client = httpx.AsyncClient(timeout=8.0)
return self._http_client
def _load(self):
if DATA_FILE.exists():
try:
with open(DATA_FILE) as f:
data = json.load(f)
self.lookup_history = data.get("lookup_history", [])
self._devon_history = data.get("conversation_history", [])
print(f"[Intern] Loaded {len(self.lookup_history)} past lookups, {len(self._devon_history)} conversation messages")
except Exception as e:
print(f"[Intern] Failed to load state: {e}")
def _save(self):
try:
DATA_FILE.parent.mkdir(parents=True, exist_ok=True)
with open(DATA_FILE, "w") as f:
json.dump({
"lookup_history": self.lookup_history[-100:],
"conversation_history": self._devon_history[-50:],
}, f, indent=2)
except Exception as e:
print(f"[Intern] Failed to save state: {e}")
# --- Tool execution ---
async def _execute_tool(self, tool_name: str, arguments: dict) -> str:
if tool_name == "web_search":
return await self._tool_web_search(arguments.get("query", ""))
elif tool_name == "get_headlines":
return await self._tool_get_headlines()
elif tool_name == "fetch_webpage":
return await self._tool_fetch_webpage(arguments.get("url", ""))
elif tool_name == "wikipedia_lookup":
return await self._tool_wikipedia_lookup(arguments.get("title", ""))
elif tool_name == "get_current_time":
from datetime import datetime
now = datetime.now()
return now.strftime("%I:%M %p on %A, %B %d, %Y")
else:
return f"Unknown tool: {tool_name}"
async def _tool_web_search(self, query: str) -> str:
if not query:
return "No query provided"
# Check cache (5 min TTL)
cache_key = query.lower()
if cache_key in self.research_cache:
ts, result = self.research_cache[cache_key]
if time.time() - ts < 300:
return result
try:
resp = await self.http_client.get(
f"{SEARXNG_URL}/search",
params={"q": query, "format": "json"},
timeout=5.0,
)
resp.raise_for_status()
data = resp.json()
results = []
for item in data.get("results", [])[:5]:
title = item.get("title", "").strip()
content = item.get("content", "").strip()
url = item.get("url", "")
if title:
entry = f"- {title}"
if content:
entry += f": {content[:200]}"
if url:
entry += f" ({url})"
results.append(entry)
result = "\n".join(results) if results else "No results found"
self.research_cache[cache_key] = (time.time(), result)
return result
except Exception as e:
print(f"[Intern] Web search failed for '{query}': {e}")
return f"Search failed: {e}"
async def _tool_get_headlines(self) -> str:
try:
items = await news_service.get_headlines()
if not items:
return "No headlines available"
return news_service.format_headlines_for_prompt(items)
except Exception as e:
return f"Headlines fetch failed: {e}"
async def _tool_fetch_webpage(self, url: str) -> str:
if not url:
return "No URL provided"
try:
resp = await self.http_client.get(
url,
headers={"User-Agent": "Mozilla/5.0 (compatible; RadioShowBot/1.0)"},
follow_redirects=True,
timeout=8.0,
)
resp.raise_for_status()
html = resp.text
# Simple HTML to text extraction (avoid heavy dependency)
# Strip script/style tags, then all HTML tags
text = re.sub(r'<script[^>]*>.*?</script>', '', html, flags=re.DOTALL | re.IGNORECASE)
text = re.sub(r'<style[^>]*>.*?</style>', '', text, flags=re.DOTALL | re.IGNORECASE)
text = re.sub(r'<[^>]+>', ' ', text)
# Collapse whitespace
text = re.sub(r'\s+', ' ', text).strip()
# Decode common entities
text = text.replace('&amp;', '&').replace('&lt;', '<').replace('&gt;', '>')
text = text.replace('&quot;', '"').replace('&#39;', "'").replace('&nbsp;', ' ')
return text[:2000] if text else "Page returned no readable content"
except Exception as e:
return f"Failed to fetch page: {e}"
async def _tool_wikipedia_lookup(self, title: str) -> str:
if not title:
return "No title provided"
try:
# Use Wikipedia REST API for a concise summary
safe_title = title.replace(" ", "_")
resp = await self.http_client.get(
f"https://en.wikipedia.org/api/rest_v1/page/summary/{safe_title}",
headers={"User-Agent": "RadioShowBot/1.0 (luke@lukeattheroost.com)"},
follow_redirects=True,
timeout=5.0,
)
if resp.status_code == 404:
return f"No Wikipedia article found for '{title}'"
resp.raise_for_status()
data = resp.json()
extract = data.get("extract", "")
page_title = data.get("title", title)
description = data.get("description", "")
result = f"{page_title}"
if description:
result += f" ({description})"
result += f": {extract}" if extract else ": No summary available"
return result[:2000]
except Exception as e:
return f"Wikipedia lookup failed: {e}"
# --- Main interface ---
async def ask(self, question: str, conversation_context: list[dict] | None = None, caller_active: bool = False) -> dict:
"""Host asks intern a direct question. Returns {text, sources, tool_calls}."""
messages = []
# Include recent conversation for context (caller on the line)
if conversation_context:
context_text = "\n".join(
f"{msg['role']}: {msg['content']}"
for msg in conversation_context[-6:]
)
messages.append({
"role": "system",
"content": f"CURRENT ON-AIR CONVERSATION:\n{context_text}"
})
# When a caller is on the line, Devon should focus on facts not personal stories
if caller_active:
messages.append({
"role": "system",
"content": "A caller is on the line right now. Focus on delivering useful facts, context, and information. Skip personal stories and anecdotes — save those for when it's just you and Luke talking between calls."
})
# Include Devon's own recent conversation history (current show only)
if self._devon_history:
last_marker = -1
for i, msg in enumerate(self._devon_history):
if msg.get("role") == "system" and "NEW SHOW" in msg.get("content", ""):
last_marker = i
relevant = self._devon_history[last_marker + 1:] if last_marker >= 0 else self._devon_history
messages.extend(relevant[-10:])
messages.append({"role": "user", "content": question})
text, tool_calls = await llm_service.generate_with_tools(
messages=messages,
tools=INTERN_TOOLS,
tool_executor=self._execute_tool,
system_prompt=INTERN_SYSTEM_PROMPT,
model=self.model,
max_tokens=500,
max_tool_rounds=3,
category="devon_ask",
)
# Clean up for TTS
text = self._clean_for_tts(text)
# Track conversation history so Devon remembers context across sessions
self._devon_history.append({"role": "user", "content": question})
if text:
self._devon_history.append({"role": "assistant", "content": text})
# Keep history bounded but generous — relationship builds over time
if len(self._devon_history) > 50:
self._devon_history = self._devon_history[-50:]
self._save()
# Log the lookup
if tool_calls:
entry = {
"question": question,
"answer": text[:200],
"tools_used": [tc["name"] for tc in tool_calls],
"timestamp": time.time(),
}
self.lookup_history.append(entry)
self._save()
return {
"text": text,
"sources": [tc["name"] for tc in tool_calls],
"tool_calls": tool_calls,
}
def _track_suggestion(self, text: str):
"""Record a suggestion in Devon's history so he won't repeat it."""
self._devon_history.append({"role": "assistant", "content": text})
self._save()
async def interject(self, conversation: list[dict], caller_active: bool = False) -> dict | None:
"""Intern looks at conversation and decides if there's something worth adding.
Returns {text, sources, tool_calls} or None if nothing to add."""
if not conversation or len(conversation) < 2:
return None
context_text = "\n".join(
f"{msg['role']}: {msg['content']}"
for msg in conversation[-8:]
)
# Include Devon's recent contributions so he doesn't repeat himself
devon_recent = ""
if self._devon_history:
recent_devon = [
msg["content"] for msg in self._devon_history[-6:]
if msg.get("role") == "assistant"
]
if recent_devon:
devon_recent = "\n\nTHINGS YOU'VE ALREADY SAID ON THE SHOW (do NOT repeat these or say the same thing differently):\n" + "\n".join(f"- {d[:150]}" for d in recent_devon)
if caller_active:
interjection_prompt = (
f"You're listening to this conversation on the show:\n\n{context_text}{devon_recent}\n\n"
"A caller is on the line. Look at what they're talking about — is there something you "
"can look up? A fun fact, some context, a stat, a detail that would add to this? "
"Use your tools. You're the researcher — this is your moment to shine. Even a quick "
"'So I just looked it up and...' adds value. If the caller mentioned a place, a person, "
"an event, a claim — verify it or find something interesting about it. "
"Skip personal stories during calls — stick to facts and reactions. "
"If there's truly nothing to add (emotional moment, nothing searchable), say NOTHING_TO_ADD."
)
else:
interjection_prompt = (
f"You're listening to this conversation on the show:\n\n{context_text}{devon_recent}\n\n"
"You've been listening. What's on your mind? This is between-call time — you can be "
"more yourself here. If something from that conversation reminded you of your own life, "
"say it. If you want to look something up, do it. If you have a reaction or opinion, "
"share it. You're part of the show, not a fly on the wall. "
"Only say NOTHING_TO_ADD if you genuinely have zero reaction to what just happened — "
"no fact to look up, no personal connection, no opinion. That's rare."
)
messages = [{
"role": "user",
"content": interjection_prompt,
}]
text, tool_calls = await llm_service.generate_with_tools(
messages=messages,
tools=INTERN_TOOLS,
tool_executor=self._execute_tool,
system_prompt=DEVON_MONITOR_PROMPT,
model=self.model,
max_tokens=300,
max_tool_rounds=2,
category="devon_monitor",
)
text = self._clean_for_tts(text)
if not text or "NOTHING_TO_ADD" in text:
return None
# Suppress interjections that are just announcing failed lookups
failed_phrases = ["couldn't find", "could not find", "not finding anything",
"no results", "didn't find", "wasn't able to find",
"couldn't locate", "no information on"]
text_lower = text.lower()
if any(phrase in text_lower for phrase in failed_phrases):
print(f"[Intern] Suppressed failed-lookup interjection: {text[:60]}...")
return None
if tool_calls:
entry = {
"question": "(interjection)",
"answer": text[:200],
"tools_used": [tc["name"] for tc in tool_calls],
"timestamp": time.time(),
}
self.lookup_history.append(entry)
self._save()
return {
"text": text,
"sources": [tc["name"] for tc in tool_calls],
"tool_calls": tool_calls,
}
async def monitor_conversation(self, get_conversation: callable, on_suggestion: callable, get_caller_active: callable = None):
"""Background task that watches conversation and buffers suggestions.
get_conversation() should return the current conversation list.
on_suggestion(text, sources) is called when a suggestion is ready."""
last_checked_len = 0
while self.monitoring:
await asyncio.sleep(15)
if not self.monitoring:
break
conversation = get_conversation()
if not conversation or len(conversation) <= last_checked_len:
continue
last_checked_len = len(conversation)
try:
caller_active = get_caller_active() if get_caller_active else False
result = await self.interject(conversation, caller_active=caller_active)
if result:
self.pending_interjection = result["text"]
self.pending_sources = result.get("tool_calls", [])
self._track_suggestion(result["text"])
await on_suggestion(result["text"], result["sources"])
print(f"[Intern] Buffered suggestion: {result['text'][:60]}...")
except Exception as e:
print(f"[Intern] Monitor error: {e}")
def start_monitoring(self, get_conversation: callable, on_suggestion: callable, get_caller_active: callable = None):
if self.monitoring:
return
self.monitoring = True
self._monitor_task = asyncio.create_task(
self.monitor_conversation(get_conversation, on_suggestion, get_caller_active)
)
print("[Intern] Monitoring started")
def new_show(self):
"""Mark the start of a new show in Devon's memory so he knows the context has changed."""
if self._devon_history and self._devon_history[-1].get("role") == "system" and "NEW SHOW" in self._devon_history[-1].get("content", ""):
print("[Intern] Skipping duplicate new_show marker")
return
self._devon_history.append({
"role": "system",
"content": "--- NEW SHOW STARTING --- The previous show is over. A brand new episode is about to begin. Everything before this point was a previous show. Don't reference the previous show's callers or topics unless Luke brings them up. Fresh energy, clean slate."
})
self.research_cache.clear()
self.pending_interjection = None
self.pending_sources = []
# Trim older history but keep some for long-term memory
if len(self._devon_history) > 30:
self._devon_history = self._devon_history[-30:]
self._save()
print("[Intern] New show marker added to conversation history")
def stop_monitoring(self):
self.monitoring = False
if self._monitor_task and not self._monitor_task.done():
self._monitor_task.cancel()
self._monitor_task = None
self.pending_interjection = None
self.pending_sources = []
print("[Intern] Monitoring stopped")
def get_pending_suggestion(self) -> dict | None:
if self.pending_interjection:
return {
"text": self.pending_interjection,
"sources": self.pending_sources,
}
return None
def dismiss_suggestion(self):
self.pending_interjection = None
self.pending_sources = []
@staticmethod
def _clean_for_tts(text: str) -> str:
if not text:
return ""
# Devon-specific: strip markdown and tool artifacts first
# Remove markdown formatting
text = re.sub(r'\*\*(.+?)\*\*', r'\1', text)
text = re.sub(r'\*(.+?)\*', r'\1', text)
text = re.sub(r'`(.+?)`', r'\1', text)
text = re.sub(r'\[(.+?)\]\(.+?\)', r'\1', text)
# Remove bullet points / list markers
text = re.sub(r'^\s*[-*•]\s+', '', text, flags=re.MULTILINE)
# Remove smart quotes that TTS reads awkwardly
text = text.replace('\u201c', '').replace('\u201d', '').replace('\u2018', '').replace('\u2019', "'")
# Strip tool error artifacts that shouldn't be spoken on air
text = re.sub(r'(?:Error|ERROR|error):?\s*\S.*?(?:\.|$)', '', text)
text = re.sub(r'Tool unavailable[^.]*\.?', '', text)
text = re.sub(r'\s+', ' ', text).strip()
# Run shared TTS preprocessing (stage directions, numbers, abbreviations,
# symbols, pronunciation fixes, breathing pauses)
from backend.main import clean_for_tts
text = clean_for_tts(text, formal=True)
return text
intern_service = InternService()
+260 -43
View File
@@ -1,33 +1,43 @@
"""LLM service with OpenRouter and Ollama support"""
import json
import time
import httpx
from typing import Optional
from typing import Optional, Callable, Awaitable
from ..config import settings
from .cost_tracker import cost_tracker
# Available OpenRouter models
OPENROUTER_MODELS = [
# Default
"anthropic/claude-sonnet-4-5",
# Best for natural dialog
"x-ai/grok-4-fast",
"minimax/minimax-m2-her",
"mistralai/mistral-small-creative",
"deepseek/deepseek-v3.2",
# Other
"anthropic/claude-haiku-4.5",
# Primary
"anthropic/claude-sonnet-4.6",
"x-ai/grok-4.3",
"x-ai/grok-4.5",
# Style-matched pool
"mistralai/mistral-large-2512",
"deepseek/deepseek-r1-distill-llama-70b",
"meta-llama/llama-3.3-70b-instruct",
"google/gemini-2.5-flash",
"openai/gpt-4o-mini",
"openai/gpt-4o",
# Other good options
"anthropic/claude-sonnet-4.5",
"anthropic/claude-haiku-4.5",
"deepseek/deepseek-chat-v3-0324",
"mistralai/mistral-small-2603",
"google/gemini-2.5-pro",
"google/gemini-3-flash-preview",
"x-ai/grok-4.20",
"moonshotai/kimi-k2",
"qwen/qwen3-235b-a22b",
"meta-llama/llama-4-maverick",
# Legacy
"anthropic/claude-3-haiku",
"google/gemini-flash-1.5",
"meta-llama/llama-3.1-8b-instruct",
]
# Fast models to try as fallbacks (cheap, fast, good enough for conversation)
FALLBACK_MODELS = [
"mistralai/mistral-small-creative",
"mistralai/mistral-small-2603",
"google/gemini-2.5-flash",
"openai/gpt-4o-mini",
]
@@ -47,7 +57,7 @@ class LLMService:
@property
def client(self) -> httpx.AsyncClient:
if self._client is None or self._client.is_closed:
self._client = httpx.AsyncClient(timeout=15.0)
self._client = httpx.AsyncClient(timeout=30.0)
return self._client
def update_settings(
@@ -56,7 +66,8 @@ class LLMService:
openrouter_model: Optional[str] = None,
ollama_model: Optional[str] = None,
ollama_host: Optional[str] = None,
tts_provider: Optional[str] = None
tts_provider: Optional[str] = None,
category_models: Optional[dict] = None
):
"""Update LLM settings"""
if provider:
@@ -70,6 +81,8 @@ class LLMService:
if tts_provider:
self.tts_provider = tts_provider
settings.tts_provider = tts_provider
if category_models:
settings.category_models.update(category_models)
async def get_ollama_models(self) -> list[str]:
"""Fetch available models from Ollama"""
@@ -91,6 +104,7 @@ class LLMService:
"ollama_model": self.ollama_model,
"ollama_host": self.ollama_host,
"tts_provider": self.tts_provider,
"category_models": settings.category_models,
"available_openrouter_models": OPENROUTER_MODELS,
"available_ollama_models": []
}
@@ -104,6 +118,7 @@ class LLMService:
"ollama_model": self.ollama_model,
"ollama_host": self.ollama_host,
"tts_provider": self.tts_provider,
"category_models": settings.category_models,
"available_openrouter_models": OPENROUTER_MODELS,
"available_ollama_models": ollama_models
}
@@ -112,39 +127,65 @@ class LLMService:
self,
messages: list[dict],
system_prompt: Optional[str] = None,
max_tokens: Optional[int] = None
max_tokens: Optional[int] = None,
response_format: Optional[dict] = None,
category: str = "unknown",
caller_name: str = "",
model_override: Optional[str] = None,
) -> str:
if system_prompt:
messages = [{"role": "system", "content": system_prompt}] + messages
if self.provider == "openrouter":
return await self._call_openrouter_with_fallback(messages, max_tokens=max_tokens)
return await self._call_openrouter_with_fallback(messages, max_tokens=max_tokens, response_format=response_format, category=category, caller_name=caller_name, model_override=model_override)
else:
return await self._call_ollama(messages, max_tokens=max_tokens)
async def _call_openrouter_with_fallback(self, messages: list[dict], max_tokens: Optional[int] = None) -> str:
"""Try primary model, then fallback models. Always returns a response."""
async def generate_with_tools(
self,
messages: list[dict],
tools: list[dict],
tool_executor: Callable[[str, dict], Awaitable[str]],
system_prompt: Optional[str] = None,
model: Optional[str] = None,
max_tokens: int = 500,
max_tool_rounds: int = 3,
category: str = "unknown",
caller_name: str = "",
) -> tuple[str, list[dict]]:
"""Generate a response with OpenRouter function calling.
# Try primary model first
result = await self._call_openrouter_once(messages, self.openrouter_model, max_tokens=max_tokens)
if result is not None:
return result
Args:
messages: Conversation messages
tools: Tool definitions in OpenAI function-calling format
tool_executor: async function(tool_name, arguments) -> result string
system_prompt: Optional system prompt
model: Model to use (defaults to primary openrouter_model)
max_tokens: Max tokens for response
max_tool_rounds: Max tool call rounds to prevent loops
# Try fallback models
for model in FALLBACK_MODELS:
if model == self.openrouter_model:
continue # Already tried
print(f"[LLM] Falling back to {model}...")
result = await self._call_openrouter_once(messages, model, timeout=10.0, max_tokens=max_tokens)
if result is not None:
return result
Returns:
(final_text, tool_calls_made) where tool_calls_made is a list of
{"name": str, "arguments": dict, "result": str} dicts
"""
model = model or self._get_model_for_category(category)
msgs = list(messages)
if system_prompt:
msgs = [{"role": "system", "content": system_prompt}] + msgs
# Everything failed — return an in-character line so the show continues
print("[LLM] All models failed, using canned response")
return "Sorry, I totally blanked out for a second. What were you saying?"
all_tool_calls = []
async def _call_openrouter_once(self, messages: list[dict], model: str, timeout: float = 15.0, max_tokens: Optional[int] = None) -> str | None:
"""Single attempt to call OpenRouter. Returns None on failure (not a fallback string)."""
for round_num in range(max_tool_rounds + 1):
payload = {
"model": model,
"messages": msgs,
"max_tokens": max_tokens,
"temperature": 0.65,
"tools": tools,
"tool_choice": "auto",
}
start_time = time.time()
try:
response = await self.client.post(
"https://openrouter.ai/api/v1/chat/completions",
@@ -152,19 +193,195 @@ class LLMService:
"Authorization": f"Bearer {settings.openrouter_api_key}",
"Content-Type": "application/json",
},
json={
json=payload,
timeout=15.0,
)
response.raise_for_status()
data = response.json()
except httpx.TimeoutException:
print(f"[LLM-Tools] {model} timed out (round {round_num})")
break
except Exception as e:
print(f"[LLM-Tools] {model} error (round {round_num}): {e}")
break
latency_ms = (time.time() - start_time) * 1000
usage = data.get("usage", {})
if usage:
cost_tracker.record_llm_call(
category=category,
model=model,
usage_data=usage,
max_tokens=max_tokens,
latency_ms=latency_ms,
caller_name=caller_name,
)
choice = data["choices"][0]
msg = choice["message"]
# Check for tool calls
tool_calls = msg.get("tool_calls")
if not tool_calls:
# No tool calls — LLM returned a final text response
content = msg.get("content", "")
return content or "", all_tool_calls
# Append assistant message with tool calls to conversation
msgs.append(msg)
# Execute each tool call
for tc in tool_calls:
func = tc["function"]
tool_name = func["name"]
try:
arguments = json.loads(func["arguments"])
except (json.JSONDecodeError, TypeError):
arguments = {}
print(f"[LLM-Tools] Round {round_num}: calling {tool_name}({arguments})")
try:
result = await tool_executor(tool_name, arguments)
except Exception as e:
result = f"Tool unavailable — could not complete {tool_name} right now."
print(f"[LLM-Tools] Tool {tool_name} failed: {e}")
all_tool_calls.append({
"name": tool_name,
"arguments": arguments,
"result": result[:500],
})
# Append tool result to conversation
msgs.append({
"role": "tool",
"tool_call_id": tc["id"],
"content": result,
})
# Exhausted tool rounds or hit an error — do one final call without tools
print(f"[LLM-Tools] Finishing after {len(all_tool_calls)} tool calls")
start_time = time.time()
try:
final_payload = {
"model": model,
"messages": msgs,
"max_tokens": max_tokens,
"temperature": 0.65,
}
response = await self.client.post(
"https://openrouter.ai/api/v1/chat/completions",
headers={
"Authorization": f"Bearer {settings.openrouter_api_key}",
"Content-Type": "application/json",
},
json=final_payload,
timeout=15.0,
)
response.raise_for_status()
data = response.json()
latency_ms = (time.time() - start_time) * 1000
usage = data.get("usage", {})
if usage:
cost_tracker.record_llm_call(
category=category,
model=model,
usage_data=usage,
max_tokens=max_tokens,
latency_ms=latency_ms,
caller_name=caller_name,
)
content = data["choices"][0]["message"].get("content", "")
return content or "", all_tool_calls
except Exception as e:
print(f"[LLM-Tools] Final call failed: {e}")
return "", all_tool_calls
def _get_model_for_category(self, category: str) -> str:
"""Get the best model for a given category based on config routing."""
return settings.category_models.get(category, self.openrouter_model)
async def _call_openrouter_with_fallback(self, messages: list[dict], max_tokens: Optional[int] = None, response_format: Optional[dict] = None, category: str = "unknown", caller_name: str = "", model_override: Optional[str] = None) -> str:
"""Try category-specific model, then fallback models. Always returns a response."""
# Use explicit override if provided, else category routing, else primary
model = model_override or self._get_model_for_category(category)
result = await self._call_openrouter_once(messages, model, max_tokens=max_tokens, response_format=response_format, category=category, caller_name=caller_name)
if result is not None:
return result
# Try fallback models (drop response_format for fallbacks — not all models support it)
for model in FALLBACK_MODELS:
if model == self.openrouter_model:
continue # Already tried
print(f"[LLM] Falling back to {model}...")
result = await self._call_openrouter_once(messages, model, timeout=20.0, max_tokens=max_tokens, category=category, caller_name=caller_name)
if result is not None:
return result
# Everything failed — return an in-character line so the show continues
print("[LLM] All models failed, using canned response")
return "Sorry, I totally blanked out for a second. What were you saying?"
# Per-model parameter overrides for caller_dialog category.
# Different models need different tuning for natural conversation:
# - Qwen: high freq penalty to fight phrase-level repetition loops
# - Llama: high temp + low freq penalty to reduce terseness
# - Grok/Mistral/DeepSeek/Kimi: slightly warmer than Sonnet defaults
_CALLER_DIALOG_MODEL_PARAMS = {
"anthropic/claude-sonnet-4.6": {"temperature": 0.65, "frequency_penalty": 0.3, "presence_penalty": 0.15},
"x-ai/grok-4.3": {"temperature": 0.7, "frequency_penalty": 0.2, "presence_penalty": 0.1},
"x-ai/grok-4.5": {"temperature": 0.7, "frequency_penalty": 0.2, "presence_penalty": 0.1},
"qwen/qwen3-235b-a22b": {"temperature": 0.6, "frequency_penalty": 0.5, "presence_penalty": 0.2},
"mistralai/mistral-large-2512": {"temperature": 0.7, "frequency_penalty": 0.2, "presence_penalty": 0.1},
"deepseek/deepseek-chat-v3-0324": {"temperature": 0.7, "frequency_penalty": 0.2, "presence_penalty": 0.1},
"moonshotai/kimi-k2": {"temperature": 0.7, "frequency_penalty": 0.2, "presence_penalty": 0.1},
"meta-llama/llama-3.3-70b-instruct": {"temperature": 0.8, "frequency_penalty": 0.1, "presence_penalty": 0.1},
}
async def _call_openrouter_once(self, messages: list[dict], model: str, timeout: float = 20.0, max_tokens: Optional[int] = None, response_format: Optional[dict] = None, category: str = "unknown", caller_name: str = "") -> str | None:
"""Single attempt to call OpenRouter. Returns None on failure (not a fallback string)."""
start_time = time.time()
try:
# Use per-model params for caller dialog, defaults for everything else
if category == "caller_dialog" and model in self._CALLER_DIALOG_MODEL_PARAMS:
params = self._CALLER_DIALOG_MODEL_PARAMS[model]
else:
params = {"temperature": 0.65, "frequency_penalty": 0.3, "presence_penalty": 0.15}
payload = {
"model": model,
"messages": messages,
"max_tokens": max_tokens or 300,
"temperature": 0.8,
"top_p": 0.92,
"frequency_penalty": 0.5,
"presence_penalty": 0.3,
"max_tokens": max_tokens or 500,
"temperature": params["temperature"],
"top_p": 0.9,
"frequency_penalty": params["frequency_penalty"],
"presence_penalty": params["presence_penalty"],
}
if response_format:
payload["response_format"] = response_format
response = await self.client.post(
"https://openrouter.ai/api/v1/chat/completions",
headers={
"Authorization": f"Bearer {settings.openrouter_api_key}",
"Content-Type": "application/json",
},
json=payload,
timeout=timeout,
)
response.raise_for_status()
data = response.json()
latency_ms = (time.time() - start_time) * 1000
usage = data.get("usage", {})
if usage:
cost_tracker.record_llm_call(
category=category,
model=model,
usage_data=usage,
max_tokens=max_tokens or 500,
latency_ms=latency_ms,
caller_name=caller_name,
)
content = data["choices"][0]["message"]["content"]
if content and content.strip():
return content
+3 -1
View File
@@ -7,7 +7,9 @@ from dataclasses import dataclass
import httpx
SEARXNG_URL = "http://localhost:8888"
from ..config import settings
SEARXNG_URL = settings.searxng_url
@dataclass
+44 -7
View File
@@ -7,7 +7,7 @@ from pathlib import Path
from typing import Optional
DATA_FILE = Path(__file__).parent.parent.parent / "data" / "regulars.json"
MAX_REGULARS = 12
MAX_REGULARS = 8
class RegularCallerService:
@@ -39,6 +39,16 @@ class RegularCallerService:
def get_regulars(self) -> list[dict]:
return list(self._regulars)
def get_by_name(self, name: str) -> Optional[dict]:
"""Find a regular by name (case-insensitive)."""
if not name:
return None
target = name.lower()
for r in self._regulars:
if r.get("name", "").lower() == target:
return r
return None
def get_returning_callers(self, count: int = 2) -> list[dict]:
"""Get up to `count` regulars for returning caller slots"""
import random
@@ -51,7 +61,10 @@ class RegularCallerService:
def add_regular(self, name: str, gender: str, age: int, job: str,
location: str, personality_traits: list[str],
first_call_summary: str, voice: str = None) -> dict:
first_call_summary: str, voice: str = None,
stable_seeds: dict = None,
structured_background: dict = None,
avatar: str = None) -> dict:
"""Promote a first-time caller to regular"""
# Retire oldest if at cap
if len(self._regulars) >= MAX_REGULARS:
@@ -68,8 +81,13 @@ class RegularCallerService:
"location": location,
"personality_traits": personality_traits,
"voice": voice,
"stable_seeds": stable_seeds or {},
"structured_background": structured_background,
"avatar": avatar,
"relationships": {},
"call_history": [
{"summary": first_call_summary, "timestamp": time.time()}
{"summary": first_call_summary, "timestamp": time.time(),
"arc_status": "ongoing"}
],
"last_call": time.time(),
"created_at": time.time(),
@@ -79,18 +97,37 @@ class RegularCallerService:
print(f"[Regulars] Promoted {name} to regular (total: {len(self._regulars)})")
return regular
def update_after_call(self, regular_id: str, call_summary: str):
def update_after_call(self, regular_id: str, call_summary: str,
key_moments: list = None, arc_status: str = "ongoing"):
"""Update a regular's history after a returning call"""
for regular in self._regulars:
if regular["id"] == regular_id:
regular.setdefault("call_history", []).append(
{"summary": call_summary, "timestamp": time.time()}
)
entry = {
"summary": call_summary,
"timestamp": time.time(),
"arc_status": arc_status,
}
if key_moments:
entry["key_moments"] = key_moments
regular.setdefault("call_history", []).append(entry)
regular["last_call"] = time.time()
self._save()
print(f"[Regulars] Updated {regular['name']} call history ({len(regular['call_history'])} calls)")
return
print(f"[Regulars] Regular {regular_id} not found for update")
def add_relationship(self, regular_id: str, other_name: str,
rel_type: str, context: str):
"""Track a relationship between regulars"""
for regular in self._regulars:
if regular["id"] == regular_id:
regular.setdefault("relationships", {})[other_name] = {
"type": rel_type,
"context": context,
}
self._save()
print(f"[Regulars] {regular['name']}{other_name}: {rel_type}")
return
regular_caller_service = RegularCallerService()
+136
View File
@@ -0,0 +1,136 @@
from dataclasses import dataclass
from datetime import date
from pathlib import Path
import json
import re
import httpx
from ..config import settings
from .cost_tracker import cost_tracker
HOME = Path.home()
VAULT = HOME / "code" / "dotfiles"
SILAS_DIR = VAULT / "silas"
REGULARS_DIR = VAULT / "regulars"
ARCHIVED_DIR = REGULARS_DIR / "archived"
@dataclass
class Regular:
name: str
voice: str
age: int
arc_state: str
lore_body: str
file_path: Path
def load_regular(path: Path) -> Regular:
text = path.read_text()
m = re.match(r"^---\n(.*?)\n---\n(.*)$", text, re.DOTALL)
if not m:
raise ValueError(f"No frontmatter in {path}")
fm_raw, body = m.group(1), m.group(2).strip()
fm = {}
for line in fm_raw.splitlines():
if ":" in line:
k, v = line.split(":", 1)
fm[k.strip()] = v.strip()
return Regular(
name=fm["name"],
voice=fm["voice"],
age=int(fm["age"]),
arc_state=fm.get("arc_state", ""),
lore_body=body,
file_path=path,
)
def load_all_active_regulars() -> list[Regular]:
out = []
if SILAS_DIR.exists():
for f in SILAS_DIR.glob("*.md"):
out.append(load_regular(f))
if REGULARS_DIR.exists():
for f in REGULARS_DIR.glob("*.md"):
out.append(load_regular(f))
return out
PROMOTION_MODEL = "anthropic/claude-sonnet-4.6"
PROMOTION_PROMPT = """You are evaluating whether a one-time caller should become a recurring character.
CALLER: {name}
TRANSCRIPT:
{transcript}
A recurring character must have a 3-5 episode arc with genuine progression — not just "calls weekly to complain about the same thing." The arc must have a possible resolution.
Output JSON:
{{"promote": true|false, "arc_plan": "...", "reason": "..."}}
Bar is HIGH. Only promote if the character has real internal conflict, growth potential, and a believable resolution trajectory."""
async def _call_sonnet(prompt: str) -> dict:
async with httpx.AsyncClient(timeout=60.0) as client:
resp = await client.post(
"https://openrouter.ai/api/v1/chat/completions",
headers={"Authorization": f"Bearer {settings.openrouter_api_key}"},
json={
"model": PROMOTION_MODEL,
"messages": [{"role": "user", "content": prompt}],
"response_format": {"type": "json_object"},
"max_tokens": 500,
},
)
resp.raise_for_status()
data = resp.json()
usage = data.get("usage", {})
cost_tracker.record_llm_call(
category="promotion_eval",
model=PROMOTION_MODEL,
usage_data=usage,
)
content = data["choices"][0]["message"]["content"].strip()
if content.startswith("```") and content.endswith("```"):
lines = content.splitlines()
content = "\n".join(lines[1:-1])
return json.loads(content)
async def evaluate_promotion(caller_name: str, call_transcript: str) -> dict:
prompt = PROMOTION_PROMPT.format(name=caller_name, transcript=call_transcript)
return await _call_sonnet(prompt)
def write_new_regular(name: str, voice: str, age: int, identity_paragraph: str,
arc_plan: str, first_call_summary: str) -> Path:
REGULARS_DIR.mkdir(parents=True, exist_ok=True)
slug = name.lower().replace(" ", "-")
path = REGULARS_DIR / f"{slug}.md"
today = date.today().isoformat()
content = f"""---
name: {name}
voice: {voice}
age: {age}
arc_state: {arc_plan}
promoted_on: {today}
---
# {name}
{identity_paragraph}
## Arc Plan
{arc_plan}
## Arc Log
- {today}: {first_call_summary}
"""
path.write_text(content)
return path
+27 -5
View File
@@ -7,7 +7,7 @@ import soundfile as sf
from pathlib import Path
from collections import deque
STEM_NAMES = ["host", "caller", "music", "sfx", "ads"]
STEM_NAMES = ["host", "caller", "devon", "music", "sfx", "ads", "idents"]
class StemRecorder:
@@ -19,13 +19,15 @@ class StemRecorder:
self._queues: dict[str, deque] = {}
self._writer_thread: threading.Thread | None = None
self._start_time: float = 0.0
self._write_errors: int = 0
def start(self):
self._start_time = time.time()
self._running = True
self._write_errors = 0
for name in STEM_NAMES:
self._queues[name] = deque()
self._writer_thread = threading.Thread(target=self._writer_loop, daemon=True)
self._writer_thread = threading.Thread(target=self._writer_loop, daemon=False)
self._writer_thread.start()
print(f"[StemRecorder] Recording started -> {self.output_dir}")
@@ -67,6 +69,7 @@ class StemRecorder:
)
positions[name] = 0
try:
while self._running or any(len(q) > 0 for q in self._queues.values()):
did_work = False
for name in STEM_NAMES:
@@ -78,6 +81,7 @@ class StemRecorder:
if len(resampled) == 0:
continue
try:
if msg_type == "sporadic":
elapsed = time.time() - self._start_time
expected_pos = int(elapsed * self.sample_rate)
@@ -88,6 +92,12 @@ class StemRecorder:
files[name].write(resampled)
positions[name] += len(resampled)
except Exception as e:
self._write_errors += 1
if self._write_errors <= 5:
print(f"[StemRecorder] Write error on {name}: {e}")
elif self._write_errors == 6:
print(f"[StemRecorder] Suppressing further write errors")
if not did_work:
time.sleep(0.02)
@@ -95,11 +105,21 @@ class StemRecorder:
# Pad all stems to same length
max_pos = max(positions.values()) if positions else 0
for name in STEM_NAMES:
try:
if positions[name] < max_pos:
files[name].write(np.zeros(max_pos - positions[name], dtype=np.float32))
files[name].close()
except Exception as e:
print(f"[StemRecorder] Final pad error on {name}: {e}")
finally:
for name, f in files.items():
try:
f.close()
except Exception as e:
print(f"[StemRecorder] Error closing {name}.wav: {e}")
print(f"[StemRecorder] Writer done. {max_pos} samples ({max_pos / self.sample_rate:.1f}s)")
total_errors = self._write_errors
err_msg = f", {total_errors} write errors" if total_errors else ""
print(f"[StemRecorder] Writer done. {max_pos} samples ({max_pos / self.sample_rate:.1f}s{err_msg})")
def stop(self) -> dict[str, str]:
if not self._running:
@@ -107,7 +127,9 @@ class StemRecorder:
self._running = False
if self._writer_thread:
self._writer_thread.join(timeout=10.0)
self._writer_thread.join(timeout=30.0)
if self._writer_thread.is_alive():
print("[StemRecorder] Warning: writer thread still running after 30s")
self._writer_thread = None
paths = {}
+17 -10
View File
@@ -5,6 +5,8 @@ import numpy as np
from faster_whisper import WhisperModel
import librosa
WHISPER_MODEL = "distil-large-v3"
# Global model instance (loaded once)
_whisper_model = None
@@ -13,10 +15,8 @@ def get_whisper_model() -> WhisperModel:
"""Get or create Whisper model instance"""
global _whisper_model
if _whisper_model is None:
print("Loading Whisper tiny model for fast transcription...")
# Use tiny model for speed - about 3-4x faster than base
# beam_size=1 and best_of=1 for fastest inference
_whisper_model = WhisperModel("tiny", device="cpu", compute_type="int8")
print(f"Loading Whisper {WHISPER_MODEL} model...")
_whisper_model = WhisperModel(WHISPER_MODEL, device="cpu", compute_type="int8")
print("Whisper model loaded")
return _whisper_model
@@ -67,13 +67,15 @@ def decode_audio(audio_data: bytes, source_sample_rate: int = None) -> tuple[np.
return audio, 16000
async def transcribe_audio(audio_data: bytes, source_sample_rate: int = None) -> str:
async def transcribe_audio(audio_data: bytes, source_sample_rate: int = None,
context_hint: str = "") -> str:
"""
Transcribe audio data to text using Whisper.
Args:
audio_data: Audio bytes (webm, ogg, wav, or raw PCM)
source_sample_rate: If provided, treat audio_data as raw PCM at this rate
context_hint: Optional extra context for the initial prompt (e.g. caller name/topic)
Returns:
Transcribed text
@@ -100,13 +102,18 @@ async def transcribe_audio(audio_data: bytes, source_sample_rate: int = None) ->
else:
audio_16k = audio
# Transcribe with speed optimizations
# Build initial prompt — context helps Whisper with names and topic-specific words
initial_prompt = "Luke at the Roost, a late-night radio talk show in Alpine, Texas, in the Big Bend region. The host Luke talks to callers about life, relationships, sports, politics, and pop culture, with his intern Devon. Callers reference Alpine, Marfa, Marathon, Terlingua, Fort Stockton, and Big Bend."
if context_hint:
initial_prompt += f" {context_hint}"
# Transcribe
segments, info = model.transcribe(
audio_16k,
beam_size=1, # Faster, slightly less accurate
best_of=1,
language="en", # Skip language detection
vad_filter=True, # Skip silence
beam_size=5,
language="en",
vad_filter=True,
initial_prompt=initial_prompt,
)
segments_list = list(segments)
text = " ".join([s.text for s in segments_list]).strip()
+265 -33
View File
@@ -8,6 +8,7 @@ import tempfile
import torch
from ..config import settings
from .cost_tracker import cost_tracker
# Patch torch.load for compatibility with PyTorch 2.6+
_original_torch_load = torch.load
@@ -82,9 +83,18 @@ VITS_SPEAKERS = {
DEFAULT_VITS_SPEAKER = "p225"
# Inworld voice mapping - maps ElevenLabs voice IDs to Inworld voices
# Full voice list from API: Alex, Ashley, Blake, Carter, Clive, Craig, Deborah,
# Dennis, Dominus, Edward, Elizabeth, Hades, Hana, Julia, Luna, Mark, Olivia,
# Pixie, Priya, Ronald, Sarah, Shaun, Theodore, Timothy, Wendy
# Full voice list from API (English, as of 2026-05): Abby, Alex, Amina, Anjali,
# Arjun, Ashley, Avery, Bianca, Blake, Brandon, Brian, Callum, Carter, Cedric,
# Celeste, Chloe, Claire, Clive, Conrad, Craig, Damon, Darlene, Deborah, Dennis,
# Derek, Dominus, Duncan, Edward, Eleanor, Elizabeth, Elliot, Ethan, Evan,
# Evelyn, Felix, Gareth, Graham, Grant, Hades, Hamish, Hana, Hank, Jake, James,
# Jason, Jessica, Jonah, Julia, Kayla, Kelsey, Lauren, Levi, Liam, Loretta,
# Lucian, Luna, Malcolm, Marcus, Mark, Marlene, Mia, Miranda, Mortimer, Nadia,
# Naomi, Nate, Oliver, Olivia, Pippa, Pixie, Priya, Reed, Riley, Ronald, Rupert,
# Saanvi, Sarah, Sebastian, Selene, Serena, Shaun, Simon, Snik, Sophie, Tessa,
# Theodore, Timothy, Trevor, Tristan, Tyler, Veronica, Victor, Victoria, Vinny,
# Wendy. Not in our caller pool: Abby/Mia/Pixie/Riley (child voices), Dominus/
# Lucian/Selene/Snik (theatrical), Dominus/Hades blacklisted.
INWORLD_VOICES = {
# Original voice IDs
"VR6AewLTigWG4xSOukaG": "Edward", # Tony - fast-talking, emphatic, streetwise
@@ -111,6 +121,126 @@ INWORLD_VOICES = {
}
DEFAULT_INWORLD_VOICE = "Dennis"
# Inworld voices that speak too slowly at default rate — bump them up
# Range is 0.5 to 1.5, where 1.0 is the voice's native speed
INWORLD_SPEED_OVERRIDES = {
"Wendy": 1.15,
"Craig": 1.15,
"Deborah": 1.15,
"Sarah": 1.1,
"Hana": 1.1,
"Theodore": 1.15,
"Blake": 1.1,
"Priya": 1.1,
"Graham": 1.15,
"Malcolm": 1.15,
"Victoria": 1.1,
"Loretta": 1.1,
"Marlene": 1.1,
}
DEFAULT_INWORLD_SPEED = 1.1 # Slight bump for all voices
# Voice profiles — perceptual dimensions for each Inworld voice.
# Used by style-to-voice matching to pair caller personalities with fitting voices.
# weight: vocal depth/richness (light, medium, heavy)
# energy: default speaking animation (low, medium, high)
# warmth: friendliness/openness in the voice (cool, neutral, warm)
# age_feel: perceived speaker age (young, middle, mature)
VOICE_PROFILES = {
# --- Male voices ---
# Known characterizations from INWORLD_VOICES mapping and usage
"Alex": {"weight": "light", "energy": "high", "warmth": "warm", "age_feel": "young"}, # energetic, expressive, mildly nasal
"Edward": {"weight": "medium", "energy": "high", "warmth": "neutral", "age_feel": "middle"}, # fast-talking, emphatic, streetwise
"Shaun": {"weight": "medium", "energy": "high", "warmth": "warm", "age_feel": "middle"}, # friendly, dynamic, conversational
"Craig": {"weight": "heavy", "energy": "low", "warmth": "cool", "age_feel": "mature"}, # older British, refined, articulate
"Timothy": {"weight": "light", "energy": "high", "warmth": "warm", "age_feel": "young"}, # lively, upbeat American
"Dennis": {"weight": "medium", "energy": "high", "warmth": "warm", "age_feel": "middle"}, # energetic, default voice
"Ronald": {"weight": "heavy", "energy": "medium", "warmth": "neutral", "age_feel": "mature"}, # gruff, authoritative
"Theodore": {"weight": "heavy", "energy": "low", "warmth": "warm", "age_feel": "mature"}, # slow, deliberate
"Blake": {"weight": "medium", "energy": "medium", "warmth": "neutral", "age_feel": "middle"},
"Carter": {"weight": "medium", "energy": "medium", "warmth": "neutral", "age_feel": "middle"},
"Clive": {"weight": "heavy", "energy": "low", "warmth": "cool", "age_feel": "mature"},
"Mark": {"weight": "medium", "energy": "medium", "warmth": "neutral", "age_feel": "middle"},
"Sebastian": {"weight": "medium", "energy": "medium", "warmth": "cool", "age_feel": "middle"}, # used by Silas (cult leader) & Chip
"Elliot": {"weight": "light", "energy": "medium", "warmth": "warm", "age_feel": "young"}, # used by Otis (comedian)
# Remaining male pool voices
"Arjun": {"weight": "medium", "energy": "medium", "warmth": "warm", "age_feel": "middle"},
"Avery": {"weight": "light", "energy": "high", "warmth": "warm", "age_feel": "young"}, # youthful, performative, gameshow host
"Brandon": {"weight": "medium", "energy": "high", "warmth": "neutral", "age_feel": "middle"}, # bold, strident, news-style
"Brian": {"weight": "medium", "energy": "medium", "warmth": "warm", "age_feel": "middle"},
"Callum": {"weight": "medium", "energy": "medium", "warmth": "neutral", "age_feel": "young"},
"Cedric": {"weight": "medium", "energy": "low", "warmth": "cool", "age_feel": "mature"}, # crisp, measured, formal announcements
"Conrad": {"weight": "heavy", "energy": "low", "warmth": "cool", "age_feel": "mature"}, # gruff, weathered detective
"Damon": {"weight": "medium", "energy": "low", "warmth": "cool", "age_feel": "middle"}, # calm, raspy, atmospheric
"Derek": {"weight": "medium", "energy": "medium", "warmth": "neutral", "age_feel": "middle"},
"Duncan": {"weight": "medium", "energy": "medium", "warmth": "warm", "age_feel": "middle"}, # warm, articulate British
"Ethan": {"weight": "medium", "energy": "medium", "warmth": "warm", "age_feel": "young"},
"Evan": {"weight": "light", "energy": "medium", "warmth": "neutral", "age_feel": "young"},
"Felix": {"weight": "medium", "energy": "medium", "warmth": "warm", "age_feel": "middle"}, # calm, friendly British
"Gareth": {"weight": "medium", "energy": "medium", "warmth": "neutral", "age_feel": "middle"},
"Graham": {"weight": "heavy", "energy": "low", "warmth": "neutral", "age_feel": "mature"},
"Grant": {"weight": "medium", "energy": "medium", "warmth": "neutral", "age_feel": "middle"},
"Hades": {"weight": "heavy", "energy": "low", "warmth": "cool", "age_feel": "mature"},
"Hamish": {"weight": "medium", "energy": "medium", "warmth": "warm", "age_feel": "middle"},
"Hank": {"weight": "heavy", "energy": "medium", "warmth": "warm", "age_feel": "mature"},
"Jake": {"weight": "medium", "energy": "high", "warmth": "warm", "age_feel": "young"},
"James": {"weight": "medium", "energy": "medium", "warmth": "neutral", "age_feel": "middle"},
"Jason": {"weight": "medium", "energy": "medium", "warmth": "neutral", "age_feel": "middle"},
"Jonah": {"weight": "medium", "energy": "low", "warmth": "warm", "age_feel": "middle"}, # soothing, calm, reassuring
"Levi": {"weight": "heavy", "energy": "low", "warmth": "cool", "age_feel": "middle"}, # measured, ominous suspense
"Liam": {"weight": "medium", "energy": "high", "warmth": "warm", "age_feel": "young"},
"Malcolm": {"weight": "heavy", "energy": "low", "warmth": "cool", "age_feel": "mature"},
"Marcus": {"weight": "medium", "energy": "medium", "warmth": "warm", "age_feel": "middle"}, # authoritative, empathetic
"Mortimer": {"weight": "heavy", "energy": "low", "warmth": "cool", "age_feel": "mature"},
"Nate": {"weight": "light", "energy": "high", "warmth": "warm", "age_feel": "young"},
"Oliver": {"weight": "medium", "energy": "medium", "warmth": "warm", "age_feel": "middle"},
"Reed": {"weight": "medium", "energy": "medium", "warmth": "neutral", "age_feel": "middle"}, # clear, professional American
"Rupert": {"weight": "medium", "energy": "low", "warmth": "cool", "age_feel": "mature"},
"Simon": {"weight": "medium", "energy": "medium", "warmth": "neutral", "age_feel": "middle"},
"Trevor": {"weight": "medium", "energy": "high", "warmth": "neutral", "age_feel": "middle"}, # punchy, expressive, energetic promos
"Tristan": {"weight": "medium", "energy": "low", "warmth": "neutral", "age_feel": "middle"}, # deliberate, controlled, documentary
"Tyler": {"weight": "light", "energy": "high", "warmth": "neutral", "age_feel": "young"},
"Victor": {"weight": "heavy", "energy": "medium", "warmth": "cool", "age_feel": "mature"},
"Vinny": {"weight": "medium", "energy": "high", "warmth": "warm", "age_feel": "middle"},
# --- Female voices ---
# Known characterizations
"Hana": {"weight": "light", "energy": "high", "warmth": "warm", "age_feel": "young"}, # bright, expressive young
"Ashley": {"weight": "medium", "energy": "medium", "warmth": "warm", "age_feel": "middle"}, # warm, natural
"Wendy": {"weight": "medium", "energy": "low", "warmth": "cool", "age_feel": "mature"}, # posh, middle-aged British
"Sarah": {"weight": "light", "energy": "high", "warmth": "neutral", "age_feel": "middle"}, # fast-talking, questioning
"Deborah": {"weight": "medium", "energy": "low", "warmth": "warm", "age_feel": "mature"}, # gentle, elegant
"Olivia": {"weight": "medium", "energy": "medium", "warmth": "warm", "age_feel": "middle"},
"Julia": {"weight": "medium", "energy": "medium", "warmth": "neutral", "age_feel": "middle"}, # used by Angie (deadpan)
"Priya": {"weight": "light", "energy": "medium", "warmth": "warm", "age_feel": "young"},
"Amina": {"weight": "medium", "energy": "medium", "warmth": "warm", "age_feel": "middle"}, # used by Charlene (bragger)
"Tessa": {"weight": "medium", "energy": "medium", "warmth": "warm", "age_feel": "middle"}, # used by Lucille
"Kelsey": {"weight": "light", "energy": "medium", "warmth": "neutral", "age_feel": "young"}, # used by Maxine (quiet/nervous)
# Remaining female pool voices
"Anjali": {"weight": "light", "energy": "medium", "warmth": "warm", "age_feel": "young"},
"Bianca": {"weight": "medium", "energy": "low", "warmth": "cool", "age_feel": "middle"}, # deep, controlled corporate
"Celeste": {"weight": "light", "energy": "medium", "warmth": "cool", "age_feel": "middle"},
"Chloe": {"weight": "light", "energy": "high", "warmth": "warm", "age_feel": "young"},
"Claire": {"weight": "medium", "energy": "medium", "warmth": "neutral", "age_feel": "middle"},
"Darlene": {"weight": "medium", "energy": "medium", "warmth": "warm", "age_feel": "mature"},
"Eleanor": {"weight": "medium", "energy": "medium", "warmth": "warm", "age_feel": "middle"}, # polished, approachable British
"Elizabeth": {"weight": "medium", "energy": "medium", "warmth": "cool", "age_feel": "mature"},
"Jessica": {"weight": "medium", "energy": "medium", "warmth": "warm", "age_feel": "middle"},
"Kayla": {"weight": "light", "energy": "high", "warmth": "warm", "age_feel": "young"},
"Lauren": {"weight": "medium", "energy": "medium", "warmth": "neutral", "age_feel": "middle"},
"Loretta": {"weight": "medium", "energy": "low", "warmth": "warm", "age_feel": "mature"},
"Luna": {"weight": "light", "energy": "medium", "warmth": "warm", "age_feel": "young"},
"Marlene": {"weight": "medium", "energy": "low", "warmth": "neutral", "age_feel": "mature"},
"Miranda": {"weight": "medium", "energy": "medium", "warmth": "cool", "age_feel": "middle"},
"Nadia": {"weight": "medium", "energy": "medium", "warmth": "warm", "age_feel": "middle"}, # personable, lively
"Naomi": {"weight": "medium", "energy": "medium", "warmth": "warm", "age_feel": "middle"}, # warm, grounded narrative
"Pippa": {"weight": "light", "energy": "high", "warmth": "warm", "age_feel": "young"},
"Saanvi": {"weight": "light", "energy": "medium", "warmth": "warm", "age_feel": "young"},
"Serena": {"weight": "medium", "energy": "medium", "warmth": "cool", "age_feel": "middle"},
"Sophie": {"weight": "medium", "energy": "medium", "warmth": "warm", "age_feel": "middle"}, # friendly British
"Veronica": {"weight": "medium", "energy": "medium", "warmth": "cool", "age_feel": "middle"},
"Victoria": {"weight": "medium", "energy": "low", "warmth": "cool", "age_feel": "mature"},
}
def preprocess_text_for_kokoro(text: str) -> str:
"""
@@ -581,7 +711,59 @@ async def generate_speech_chattts(text: str, voice_id: str) -> tuple[np.ndarray,
return audio.astype(np.float32), 24000
async def generate_speech_inworld(text: str, voice_id: str) -> tuple[np.ndarray, int]:
_EXCITED_KEYWORDS = {"excited", "amazing", "incredible", "can't believe", "so happy",
"hell yeah", "fired up", "furious", "pissed", "angry", "what the hell",
"are you kidding", "unbelievable", "!!", "oh my god"}
_SAD_KEYWORDS = {"sad", "miss them", "passed away", "funeral", "crying", "broke my heart",
"can't stop thinking", "lonely", "depressed", "sorry", "regret",
"wish I could", "never got to", "lost", "grief"}
def _detect_speech_rate(text: str, base_speed: float) -> float:
"""Adjust speech rate based on emotional content of the text.
Returns a speed value clamped to Inworld's 0.5-1.5 range."""
text_lower = text.lower()
excited = sum(1 for kw in _EXCITED_KEYWORDS if kw in text_lower)
sad = sum(1 for kw in _SAD_KEYWORDS if kw in text_lower)
if excited >= 2:
return min(1.5, base_speed + 0.15)
elif excited >= 1:
return min(1.5, base_speed + 0.08)
elif sad >= 2:
return max(0.5, base_speed - 0.2)
elif sad >= 1:
return max(0.5, base_speed - 0.1)
return base_speed
def _emotional_register_to_params(emotional_register: str) -> tuple[float, float]:
"""Map caller's emotional_register description to (temperature, speed_adjust).
speed_adjust is added to the base speed BEFORE clamping to 0.5-1.5.
Returns (0.9, 0.0) default for unknown/empty registers."""
if not emotional_register:
return (0.9, 0.0)
er = emotional_register.lower()
# Sadness/grief family
if any(kw in er for kw in ["fractured", "grief", "hollow", "sad", "mournful", "somber", "quietly desperate"]):
return (0.85, -0.1)
# Anger/aggression family
if any(kw in er for kw in ["angry", "combative", "furious", "aggressive", "sharp intelligence"]):
return (1.0, 0.1)
# Manic/excited family
if any(kw in er for kw in ["caffeinated", "manic", "giddy", "vibrating", "adrenaline", "frantic", "hyper-articulate"]):
return (1.0, 0.15)
# Nervous/earnest family
if any(kw in er for kw in ["earnest", "nervous", "unsettled", "anxious", "tentative", "quietly unsettled"]):
return (0.9, 0.0)
# Gruff/restrained family
if any(kw in er for kw in ["gruff", "measured", "precise", "stoic", "restrained", "deadpan", "forthright"]):
return (0.85, -0.05)
# Default
return (0.9, 0.0)
async def generate_speech_inworld(text: str, voice_id: str, emotional_register: str = "") -> tuple[np.ndarray, int]:
"""Generate speech using Inworld TTS API (high quality, natural voices)"""
import httpx
import base64
@@ -598,7 +780,10 @@ async def generate_speech_inworld(text: str, voice_id: str) -> tuple[np.ndarray,
if not api_key:
raise RuntimeError("INWORLD_API_KEY not set in environment")
print(f"[Inworld TTS] Voice: {voice}, Text: {text[:50]}...")
temperature, speed_adjust = _emotional_register_to_params(emotional_register)
base_speed = INWORLD_SPEED_OVERRIDES.get(voice, DEFAULT_INWORLD_SPEED) + speed_adjust
speed = max(0.5, min(1.5, _detect_speech_rate(text, base_speed)))
print(f"[Inworld TTS] Voice: {voice}, Speed: {speed:.2f} (base {base_speed:.2f}), Temp: {temperature}, Text: {text[:50]}...")
url = "https://api.inworld.ai/tts/v1/voice"
headers = {
@@ -607,15 +792,18 @@ async def generate_speech_inworld(text: str, voice_id: str) -> tuple[np.ndarray,
}
payload = {
"text": text,
"voice_id": voice,
"model_id": "inworld-tts-1.5-max",
"audio_config": {
"encoding": "LINEAR16",
"sample_rate_hertz": 48000,
"voiceId": voice,
"modelId": "inworld-tts-2",
"temperature": temperature,
"applyTextNormalization": "ON",
"audioConfig": {
"audioEncoding": "LINEAR16",
"sampleRateHertz": 48000,
"speakingRate": speed,
},
}
async with httpx.AsyncClient(timeout=25.0) as client:
async with httpx.AsyncClient(timeout=12.0) as client:
response = await client.post(url, json=payload, headers=headers)
response.raise_for_status()
data = response.json()
@@ -650,47 +838,91 @@ async def generate_speech_inworld(text: str, voice_id: str) -> tuple[np.ndarray,
return audio.astype(np.float32), 24000
def pick_caller_tts_provider() -> str | None:
"""Randomly assign a TTS provider for a caller.
Returns None to use the global default, or a specific provider name.
~70% inworld (default), ~20% kokoro, ~10% other available."""
import random
roll = random.random()
if roll < 0.70:
return None # Use global default (typically inworld)
elif roll < 0.90:
return "kokoro"
else:
return random.choice(["kokoro", "f5tts", "chattts"])
_TTS_PROVIDERS = {
"kokoro": lambda text, vid, er: generate_speech_kokoro(text, vid),
"f5tts": lambda text, vid, er: generate_speech_f5tts(text, vid),
"inworld": lambda text, vid, er: generate_speech_inworld(text, vid, emotional_register=er),
"chattts": lambda text, vid, er: generate_speech_chattts(text, vid),
"styletts2": lambda text, vid, er: generate_speech_styletts2(text, vid),
"bark": lambda text, vid, er: generate_speech_bark(text, vid),
"vits": lambda text, vid, er: generate_speech_vits(text, vid),
"elevenlabs": lambda text, vid, er: generate_speech_elevenlabs(text, vid),
}
TTS_MAX_RETRIES = 2
TTS_RETRY_DELAYS = [0.5, 1.0] # seconds between retries
async def generate_speech(
text: str,
voice_id: str,
phone_quality: str = "normal",
apply_filter: bool = True
apply_filter: bool = True,
provider_override: str = None,
emotional_register: str = "",
) -> bytes:
"""
Generate speech from text.
Generate speech from text with automatic retry on failure.
Args:
text: Text to speak
voice_id: ElevenLabs voice ID (mapped to local voice if using local TTS)
phone_quality: Quality of phone filter ("none" to disable)
apply_filter: Whether to apply phone filter
provider_override: Override the global TTS provider for this call
emotional_register: Caller's emotional register (used by Inworld for temp/speed tuning)
Returns:
Raw PCM audio bytes (16-bit signed int, 24kHz)
"""
# Choose TTS provider
provider = settings.tts_provider
print(f"[TTS] Provider: {provider}, Text: {text[:50]}...")
import asyncio
if provider == "kokoro":
audio, sample_rate = await generate_speech_kokoro(text, voice_id)
elif provider == "f5tts":
audio, sample_rate = await generate_speech_f5tts(text, voice_id)
elif provider == "inworld":
audio, sample_rate = await generate_speech_inworld(text, voice_id)
elif provider == "chattts":
audio, sample_rate = await generate_speech_chattts(text, voice_id)
elif provider == "styletts2":
audio, sample_rate = await generate_speech_styletts2(text, voice_id)
elif provider == "bark":
audio, sample_rate = await generate_speech_bark(text, voice_id)
elif provider == "vits":
audio, sample_rate = await generate_speech_vits(text, voice_id)
elif provider == "elevenlabs":
audio, sample_rate = await generate_speech_elevenlabs(text, voice_id)
else:
provider = provider_override or settings.tts_provider
print(f"[TTS] Provider: {provider}{' (override)' if provider_override else ''}, Text: {text[:50]}...")
gen_fn = _TTS_PROVIDERS.get(provider)
if not gen_fn:
raise ValueError(f"Unknown TTS provider: {provider}")
last_error = None
try:
async with asyncio.timeout(20):
for attempt in range(TTS_MAX_RETRIES):
try:
audio, sample_rate = await gen_fn(text, voice_id, emotional_register)
cost_tracker.record_tts_call(provider, voice_id, len(text))
if attempt > 0:
print(f"[TTS] Succeeded on retry {attempt}")
break
except TimeoutError:
raise # Let asyncio.timeout propagate
except Exception as e:
last_error = e
if attempt < TTS_MAX_RETRIES - 1:
delay = TTS_RETRY_DELAYS[attempt]
print(f"[TTS] {provider} attempt {attempt + 1} failed: {e} — retrying in {delay}s...")
await asyncio.sleep(delay)
else:
print(f"[TTS] {provider} failed after {TTS_MAX_RETRIES} attempts: {e}")
raise
except TimeoutError:
print(f"[TTS] Overall timeout (20s) for {provider}")
raise RuntimeError(f"TTS generation timed out after 20s")
# Apply phone filter if requested
# Skip filter for Bark - it already has rough audio quality
if apply_filter and phone_quality not in ("none", "studio") and provider != "bark":
Executable
+58
View File
@@ -0,0 +1,58 @@
#!/bin/bash
# Daily backup of critical AI podcast data to NAS
# Backs up: Castopod MariaDB dump, local data/ directory, publish state
#
# Usage: ./backup.sh
# Cron: 0 3 * * * /Users/lukemacneil/code/ai-podcast/backup.sh >> /tmp/ai-podcast-backup.log 2>&1
set -euo pipefail
NAS_HOST="mmgnas"
NAS_USER="luke"
NAS_PORT="8001"
DOCKER_BIN="/share/CACHEDEV1_DATA/.qpkg/container-station/bin/docker"
BACKUP_BASE="/share/CACHEDEV1_DATA/backups/ai-podcast"
PROJECT_DIR="/Users/lukemacneil/code/ai-podcast"
DATE=$(date +%Y-%m-%d)
KEEP_DAYS=14
echo "$(date -u '+%Y-%m-%dT%H:%M:%SZ') Starting backup..."
# 1. Dump Castopod MariaDB on NAS
echo " Dumping MariaDB..."
ssh -p "$NAS_PORT" "$NAS_USER@$NAS_HOST" \
"$DOCKER_BIN exec castopod-mariadb-1 mysqldump -u castopod --password=\$(cat /run/secrets/db_password 2>/dev/null || echo BYtbFfk3ndeVabb26xb0UyKU) castopod" \
> "/tmp/castopod-db-${DATE}.sql" 2>/dev/null
if [ -s "/tmp/castopod-db-${DATE}.sql" ]; then
gzip -f "/tmp/castopod-db-${DATE}.sql"
scp -P "$NAS_PORT" "/tmp/castopod-db-${DATE}.sql.gz" \
"$NAS_USER@$NAS_HOST:$BACKUP_BASE/castopod-db-${DATE}.sql.gz"
rm -f "/tmp/castopod-db-${DATE}.sql.gz"
echo " MariaDB dump: OK"
else
echo " WARNING: MariaDB dump is empty or failed"
fi
# 2. Sync data/ directory to NAS (rsync for efficiency)
echo " Syncing data/ directory..."
rsync -az --delete \
-e "ssh -p $NAS_PORT" \
"$PROJECT_DIR/data/" \
"$NAS_USER@$NAS_HOST:$BACKUP_BASE/data/"
echo " data/ sync: OK"
# 3. Backup .env (contains API keys — critical for disaster recovery)
echo " Backing up .env..."
scp -P "$NAS_PORT" "$PROJECT_DIR/.env" \
"$NAS_USER@$NAS_HOST:$BACKUP_BASE/env-${DATE}.bak"
echo " .env backup: OK"
# 4. Prune old backups
echo " Pruning backups older than ${KEEP_DAYS} days..."
ssh -p "$NAS_PORT" "$NAS_USER@$NAS_HOST" \
"find $BACKUP_BASE -name 'castopod-db-*.sql.gz' -mtime +${KEEP_DAYS} -delete 2>/dev/null; \
find $BACKUP_BASE -name 'env-*.bak' -mtime +${KEEP_DAYS} -delete 2>/dev/null"
echo " Prune: OK"
echo "$(date -u '+%Y-%m-%dT%H:%M:%SZ') Backup complete."
+554
View File
@@ -0,0 +1,554 @@
{
"lineups": [
{
"timestamp": 1785804183.5018802,
"lineup": [
{
"name": "Silas",
"situation": "The Wellspring's first week on the new Terlingua land has hit a concrete crisis: their artisanal soap operation shipped three pallets of inventory to a freight depot in Odessa \u2014 the nearest hub \u2014 and the driver refuses to make the final leg to Terlingua without a paved-road guarantee Silas cannot give. Meanwhile, eleven members stayed behind in Deming, and two of those who did make the move left after three days, citing the heat and the lack of cell service. Tonight a member named Petra \u2014 one of Silas's longest-tenured people, a ceramicist who has been with The Wellspring for six years \u2014 told him she's leaving at sunrise because she can't live somewhere without a postal address. The soap pallets are sitting in Odessa. Petra is sleeping in her car. And Silas, standing on raw desert under a sky so dark he says it looks like a wound in the ceiling, is genuinely uncertain for the first time whether he made the right call moving everyone out here.",
"voice": "Sebastian"
},
{
"name": "Darlene",
"situation": "Darlene spent thirty-one years handling deed transfers, liens, and title searches for Pecos County. She retired four years ago. Last week her nephew \u2014 who works for a land-acquisition company out of Midland \u2014 let it slip at a family barbecue that his company has been buying up surface rights in a specific forty-square-mile corridor north of Fort Stockton using a web of LLCs with different registered agents so the pattern doesn't show up in any single county search. Darlene knows how to read those records. She spent two days at the courthouse pulling filings and confirmed it: nine separate LLCs, all registered in Delaware, all with the same notary stamp on the original formation docs. She doesn't know what they're positioning for \u2014 pipeline easement, a solar corridor, something else \u2014 but she knows the ranching families selling don't know they're all selling to the same buyer. Her nephew doesn't know she figured it out. She hasn't told anyone.",
"voice": "Loretta"
},
{
"name": "Trace",
"situation": "Trace has no moral crisis. He is calling because forty minutes ago, while doing his rounds at the Sul Ross livestock barn complex on the east side of campus, he found a full-grown pronghorn antelope standing completely still in the center aisle between the horse stalls. Not injured. Not panicked. Just standing there in the fluorescent light looking at him like it had somewhere to be. He has no idea how it got in \u2014 the barn doors were latched. The horses are losing their minds. The pronghorn is still there. He called animal control and got a voicemail. He called his supervisor and got told to 'use your judgment, son.' He is using the radio.",
"voice": "Levi"
},
{
"name": "Mireille",
"situation": "Mireille has been having an affair for eight months with a married man \u2014 Gil, a Marfa old-timer, third-generation ranching family, the kind of man whose grandfather's name is on a road. She knew it was wrong and she was ending it anyway. Then two weeks ago Gil had a stroke. He is alive, recovering at a hospital in Odessa, but his speech is affected and his wife \u2014 a woman named Sandra who has always been perfectly cold to Mireille at community events \u2014 has moved into full caretaker mode. Gil's adult daughter, who is running the ranch now, came to Mireille's studio last Thursday and handed her a handwritten note. It was in Gil's handwriting, clearly written before the stroke, and it said only: 'If something happens to me, tell Mireille I meant it.' Mireille does not know what he meant. She does not know if Sandra knows about her. She does not know what 'I meant it' refers to \u2014 whether it is a declaration of love, a reference to a specific conversation, or something else entirely. She has been holding this note for a week.",
"voice": "Naomi"
},
{
"name": "Cutter",
"situation": "Cutter has been building and repairing fence lines across Big Bend country his whole adult life \u2014 it's solitary work, days alone on a ranch road with a post-pounder and a roll of barbed wire. Three weeks ago on a job on a large private ranch north of Marathon, he found a section of fence that had been cut cleanly \u2014 not broken, cut, with wire cutters \u2014 on both sides of a buried metal box about the size of a shoebox that someone had sunk into the caliche. The box had a combination lock on it. He didn't open it. He reset the fence, finished the job, and said nothing to the ranch manager because he told himself it was none of his business. He's been on that same ranch twice since for other fence work and checked the spot both times. The box is still there. Tonight he drove past the turnoff on his way back to Marathon and sat on the road for twenty minutes trying to decide if he should go back. He didn't.",
"voice": "Hank"
},
{
"name": "Bexley",
"situation": "Bexley's best friend since high school is a woman named Tatum who moved to Alpine two years ago to be with her boyfriend, Cord \u2014 a Sul Ross graduate student in geology. Bexley thinks Cord is controlling: he monitors Tatum's location on her phone, calls during Bexley's visits until Tatum leaves the room, and has slowly isolated Tatum from her Odessa friendships. Bexley has said all of this to Tatum directly, twice. Tatum has defended Cord both times and told Bexley she's projecting. Last week Bexley processed an ER intake \u2014 she cannot say who, HIPAA \u2014 and the details of the intake, which she is not going to share, made her believe with near-certainty that Tatum had been in that ER without telling her. Bexley cannot ask Tatum directly without revealing that she works intake. She cannot confirm what she thinks she knows. And if she's wrong, she will have accused her best friend's boyfriend of something serious based on a records inference she was never supposed to make.",
"voice": "Wendy"
},
{
"name": "Prentiss",
"situation": "Prentiss has no crisis. He is calling because last Thursday, on a solo paddle through Santa Elena Canyon \u2014 the sheer limestone walls rising 1,500 feet on both sides, the Rio Grande running low and green in August \u2014 he had an experience he has been trying to describe accurately to people ever since and failing. At a particular bend where the canyon walls narrow and the river doubles back on itself, the sound went strange: his paddle strokes came back to him delayed, the echo timing was wrong in a way he couldn't explain, and for approximately ninety seconds he heard what he can only describe as a second river \u2014 same water sounds, same rhythm, but offset, like an audio track slightly out of sync with itself. He's run Santa Elena Canyon over four hundred times. He's never heard anything like it. He's not saying it was supernatural. He's saying it was acoustically real and he can't explain it and he wants to know if anyone else has heard it.",
"voice": "Conrad"
},
{
"name": "Odette",
"situation": "Odette's seventeen-year-old son, Jerome, has been accepted \u2014 informally, pending a formal offer \u2014 to a pre-architecture program at UT Austin that starts in January. It is a genuine opportunity: a full scholarship pipeline, small cohort, real mentorship. Jerome wants to go. Odette wants him to go. The problem is Jerome's grandfather \u2014 Odette's father, Hector, 74, who runs a small but still-operating cattle operation outside Marfa and who has been deteriorating physically for the past year. Jerome is the only grandchild who shows up. He fixes fences on weekends. He drives Hector to Presidio for doctor's appointments because Odette works. If Jerome leaves in January, Odette does not know who takes the grandfather calls. She cannot afford in-home help. Her brother in El Paso has made it clear he will not be coming back. She has not told Jerome that any of this is a factor \u2014 she has only told him she's proud of him \u2014 because she refuses to be the person who chains her son to this town by guilt.",
"voice": "Veronica"
},
{
"name": "Wendell",
"situation": "Wendell is not calling about a moral dilemma. He is calling because he has become mildly obsessed with something he calls 'ghost water' \u2014 his term for the phenomenon of ancient aquifer pockets in the Chihuahuan Desert bedrock that don't connect to the regional water table and have been sealed since the Pleistocene. In his thirty-five years of drilling, he has hit three of them: water under pressure, slightly warm, with a mineral profile completely unlike the surrounding aquifer. The water comes up, depletes in hours or days, and the pocket is gone. He's had the water from his most recent find \u2014 hit last spring on a private job near the Glass Mountains \u2014 analyzed by a lab in San Angelo. The mineral signature doesn't match any catalogued aquifer in the Trans-Pecos basin. He's been emailing a hydrogeologist at UTEP about it for four months and getting increasingly interested responses. He is calling tonight because he just got an email while driving that the UTEP researcher wants to come out and look at the site.",
"voice": "Duncan"
},
{
"name": "Joaquin",
"situation": "Joaquin has been covering weekend shifts for a coworker named Danny for three months \u2014 Danny said he was dealing with a family situation. Joaquin did it because Danny covered for him twice last year and because that's how it works. Two weeks ago Joaquin found out through a mutual friend that Danny has been spending those weekends doing paid catering gigs under the table \u2014 using a connection he made through their restaurant \u2014 and telling the restaurant owner that he has a family emergency standing arrangement. Danny is making good money. Joaquin has missed two of his band's gigs because he was covering those Saturdays. The band has a paying show booked at the Marfa Lights Festival in October \u2014 their first real booking \u2014 and it falls on a Saturday Danny has already asked Joaquin to cover again. Joaquin hasn't confronted Danny. He hasn't told the owner. He's just been doing the shifts and getting angrier.",
"voice": "Felix"
}
]
},
{
"timestamp": 1785806090.001651,
"lineup": [
{
"name": "Vonda",
"situation": "Six weeks ago Vonda noticed that one of her drivers \u2014 a young man named Cody, 24, supporting a newborn \u2014 had been falsifying his hours-of-service logs. Not dramatically, just shaving entries to avoid going over the federal limit and losing his CDL. She's been covering for him, not reporting it, because she knows what losing that job would do to his family. Last night a tanker from a different company jackknifed on I-10 near the Bakersfield cutoff \u2014 driver fell asleep, DOT is now doing a sweep of all regional carriers' logs. Her company's records will be audited within days.",
"voice": "Marlene"
},
{
"name": "Stetson",
"situation": "Stetson has no moral crisis. He is calling because last Friday he was doing a rough-in electrical inspection at a new gallery build on Highland Avenue and found, inside a sealed wall cavity that hadn't been opened since the building's 1940s construction, a leather satchel containing 47 hand-addressed envelopes \u2014 all stamped, all sealed, all addressed to different people in Marfa and Presidio, postmarked 1943. None of them were ever sent. He has been opening them one by one and reading them, and they are almost entirely love letters \u2014 from the same handwriting, a woman named Eula, to 47 different men.",
"voice": "Ethan"
},
{
"name": "Lupita",
"situation": "Lupita's thesis advisor \u2014 a well-regarded professor who controls her funding, her committee, and her graduation timeline \u2014 has been taking credit in conference presentations for a methodology Lupita developed herself. It's not plagiarism in any documentable way: the work is collaborative on paper, she's listed as second author, and the professor never explicitly claimed sole invention. But at a conference in San Antonio last month, Lupita watched him describe the method to a room of peers using 'I' over and over while she sat in the third row. She has one semester left. If she confronts him she risks her funding. If she says nothing she gets her degree and leaves.",
"voice": "Luna"
},
{
"name": "Birch",
"situation": "Birch has no dilemma. He is calling because he has spent the last two years obsessively reconstructing the complete hydrological history of Comanche Springs in Fort Stockton \u2014 the largest spring system in Texas until it went dry in 1961 when irrigation pumping collapsed the aquifer. He has found survey records, eyewitness accounts, old photographs, and a 1954 Army Corps of Engineers internal memo he obtained through an archives request that suggests the spring could have been saved if one specific ranching family had agreed to reduce their draw by 30 percent. They didn't. He knows who the family is. They still ranch in Pecos County. Their grandchildren are at community events in Fort Stockton right now.",
"voice": "Dennis"
},
{
"name": "Paloma",
"situation": "Eight months ago Paloma processed a family \u2014 a woman and two kids \u2014 who crossed legally from Ojinaga with valid documents. Routine. Two weeks later the woman came back through alone, visibly injured, and Paloma flagged her for secondary. In secondary the woman told her, quietly, that her husband \u2014 still on the Mexican side \u2014 had been threatening her and she was trying to leave. Paloma, acting on her own judgment and outside strict protocol, helped connect the woman with a Presidio legal aid contact and let her through on a humanitarian basis that was technically within her discretion but which she did not fully document as required. The woman and her kids are now safe in San Antonio. Last week Paloma's supervisor mentioned, casually, that there's a random case audit happening next quarter and her files from last fall are in the sample.",
"voice": "Nadia"
},
{
"name": "Rowdy",
"situation": "Rowdy has no moral crisis. He is calling because three days ago he was leading a solo paddle on a remote stretch of the Rio Grande below Mariscal Canyon and found, pulled up on a gravel bar on the U.S. side, an absolutely pristine wooden rowboat \u2014 hand-built, painted sky blue, with no registration numbers, no markings, and a single red wool blanket folded on the middle thwart. No footprints around it. No tracks leading anywhere. He checked it against every known crossing point and outfitter inventory. It belongs to nobody. It's still there.",
"voice": "Callum"
},
{
"name": "Georgette",
"situation": "Georgette's younger sister Vera \u2014 54, widowed three years ago \u2014 has been spending money at a rate that Georgette, as the person who quietly manages Vera's household accounts as a favor, cannot explain. Not gambling, not obvious shopping. In the last four months, $23,000 has moved out of Vera's savings in transfers Georgette can trace only to a payment app she doesn't recognize. Georgette confronted Vera last week and Vera said it was 'investments' and changed the subject. Georgette did not push. But she knows Vera is lonely and she is terrified it's a romance scam and she doesn't know how to say that to her sister without destroying what's left between them.",
"voice": "Eleanor"
},
{
"name": "Tuck",
"situation": "Tuck has no moral crisis. He is calling because he has developed, over years of driving the same roads at night, an encyclopedic and genuine obsession with the specific way sound behaves in the high desert at night \u2014 what he calls 'the acoustics of nowhere.' He has documented, on a voice recorder he keeps on the dash, over 200 distinct sound events he cannot explain by conventional echo or wind: voices that seem to come from ridgelines with no one on them, music that appears and vanishes over two or three miles of road, a low mechanical hum audible only in the valley between the Davis Mountains and the Glass Mountains that he has now recorded on 14 separate nights. He is not a UFO person. He is not a ghost person. He genuinely believes this is atmospheric physics that nobody has bothered to study because not enough people drive these roads at night paying attention.",
"voice": "Grant"
},
{
"name": "Esperanza",
"situation": "Esperanza's dilemma is this: her fifteen-year-old student \u2014 a bright, difficult boy named Marco \u2014 handed in an essay last month that was, she recognized immediately, a thinly fictionalized account of his stepfather moving drugs across the river. Not a rumor. Specific details: timing, crossing method, the make of the truck. She has been sitting on this for four weeks. She did not report it to the school director because the school director is the stepfather's cousin. She did not report it to the municipal police because she does not trust them. She has a contact at the DEA field office in Presidio \u2014 a woman she met at a community meeting years ago \u2014 but using it would expose Marco, who she believes wrote the essay because he wanted someone to know and didn't know how to say it any other way.",
"voice": "Jessica"
},
{
"name": "Hensley",
"situation": "Hensley is calling because he is absolutely convinced, based on eleven months of personal observation, trail camera footage, and what he describes as 'track evidence that don't match anything in the field guides,' that there is a breeding population of ocelots in the limestone canyon country north of Sanderson \u2014 not in the known South Texas range, not anywhere near where ocelots are supposed to be, but in the high desert scrub of Terrell County where they have no business being. He has not contacted Texas Parks and Wildlife because the last time he reported something unusual to state wildlife authorities \u2014 a mountain lion denning close to a ranch road \u2014 the ranch owner found out and the lion was gone within a week. He doesn't trust the information chain.",
"voice": "Malcolm"
}
]
},
{
"timestamp": 1785806481.347754,
"lineup": [
{
"name": "Silas",
"situation": "The Wellspring's first week on the Terlingua land has surfaced a crisis Silas did not anticipate: a woman named Petra, one of the community's most devoted members for six years, has announced she will not participate in any future Unbinding ceremonies \u2014 not because she objects, but because her adult daughter drove out from Odessa to see the new land and is now threatening to file a formal complaint with Brewster County about the commune's practices. Petra is caught between her daughter and her community, and Silas \u2014 still raw from the Marcus reckoning \u2014 is trying hard not to pressure her. But three other members watched him handle it and now they're asking quiet questions about whether the Unbinding will even continue on the new land. The fresh-start he envisioned is wobbling in its first week, and he's calling Luke partly for advice and partly because he genuinely doesn't know what the right thing is anymore.",
"voice": "Sebastian"
},
{
"name": "Devereux",
"situation": "Devereux has been doing small jobs for the Gage Hotel for about four years \u2014 light fixture replacements, outlet work, the occasional plumbing assist. Three weeks ago, while doing some work in one of the historic guest rooms, he found a canvas rolled up inside the wall cavity behind a replaced baseboard \u2014 oil paint, maybe 18 inches by 24 inches, a desert landscape with a small figure in it that he can't stop looking at. He didn't tell anyone. He took it home. He's shown it to exactly one person, a friend who took an art history class at Sul Ross, who told him it could be significant or could be a tourist piece from the 1940s \u2014 she genuinely doesn't know. Devereux is not a thief by nature and the guilt is eating him, but he's also been poor his whole life and the painting is sitting on his kitchen table and he keeps thinking about what it might be worth.",
"voice": "Brian"
},
{
"name": "Florencia",
"situation": "Florencia is not calling about a crisis. She is calling because two nights ago, while doing a nocturnal small-mammal survey on a transect line in the Paisano Pass area west of Alpine, she witnessed something she has been turning over in her head ever since: a pronghorn antelope \u2014 alone, which is already unusual at night \u2014 standing completely still in her headlamp beam for nearly four minutes without moving. Not frozen in fear the way prey animals freeze. Still in a different way. Deliberate. Then it walked directly toward her, stopped about fifteen feet away, looked at her for another long moment, and walked off into the dark at a normal pace. She's a scientist. She has an explanation for all of it. But she can't stop thinking about those four minutes and she wants to describe it to someone who will actually listen.",
"voice": "Bianca"
},
{
"name": "Holt",
"situation": "Holt's dilemma is this: his son-in-law, Dustin, has been telling Holt's daughter Renee for two years that he's been promoted at the drilling company and making good money \u2014 Renee has quit her job at the Dollar General based on this, they bought a truck, they're talking about a house. Last month Holt ran into one of Dustin's actual coworkers at the Stripes on I-10 who let something slip: Dustin is still on the same crew he was on two years ago, same rate, no promotion. Holt has done the math and Dustin is burning through something \u2014 savings, a loan, something \u2014 to maintain the fiction. Renee is thirty-two, they have a four-year-old, and Holt loves Dustin, genuinely likes the man, which makes this worse. He hasn't said anything to Renee. He confronted Dustin two weeks ago in a parking lot and Dustin broke down crying and begged him for sixty days to fix it.",
"voice": "James"
},
{
"name": "Celestine",
"situation": "Celestine is calling because she has a theory about the Marfa Lights and it is not a theory she has seen anywhere else, and she has looked. She is not a mystic and she is not credulous \u2014 she has lived in Marfa her whole life and she has watched the Lights since she was a child from the viewing platform on Highway 90. Her theory, developed over years of watching and cross-referencing with her work schedule and property logs, is this: the Lights appear more frequently and more vividly in the weeks after a large influx of visitors to Marfa \u2014 specifically after the busiest tourist weekends. She has kept a handwritten log for eleven years. She has also noted that they are rarest in January and February, when Marfa is quietest. She is not saying the tourists cause the Lights. She is saying the correlation is there and nobody is studying it and it bothers her that the people who should care \u2014 the researchers, the university types \u2014 won't take a local woman with a notebook seriously.",
"voice": "Tessa"
},
{
"name": "Beau",
"situation": "Beau is not calling about a crisis. He is calling because six weeks ago he became genuinely, almost academically obsessed with the question of why horned lizards \u2014 horny toads \u2014 have almost completely disappeared from the areas around Alpine where they were common when his grandfather was young, and then three days ago, while clearing some old brush along a caliche road on his uncle's ranch property east of Alpine near the Leoncita area, he found a small population \u2014 maybe a dozen animals \u2014 in a rocky patch of ground that hadn't been disturbed in decades. He caught one, held it for about thirty seconds the way his grandfather taught him, and it squirted blood from its eyes at him, which he knew was a defense mechanism but had never personally experienced. He is calling because he found the lizards and because the blood-squirting experience at close range was something he needs to tell someone about.",
"voice": "Liam"
},
{
"name": "Vashti",
"situation": "Vashti's dilemma is slow and has been building for months and came to a head this weekend. She has a colleague \u2014 a woman she has worked alongside for nine years, someone she considers a close friend \u2014 who has been systematically taking sole credit for a grant-funded family reunification program that Vashti built from the ground up. The colleague, Marisol, writes the reports, attends the funder meetings, and has begun to refer to the program as her own initiative in ways that are small enough to seem like accidents. Vashti brought it up once, gently, and Marisol apologized and then did it again the next month. This weekend Vashti found out that Marisol has been invited to present the program at a conference in El Paso in October \u2014 without mentioning Vashti's name to the organizers. The program serves families Vashti has known for years. The work is what she cares about. But she is also tired of being erased.",
"voice": "Julia"
},
{
"name": "Rayford",
"situation": "Rayford is calling because his neighbor \u2014 a man named Clete whose family has run the adjacent property for just as long \u2014 offered to buy Rayford's 4,200 acres two months ago at a number that is, by any honest measure, a good price. Rayford said no. Clete came back last week with a higher number. Rayford said no again. What Rayford has not told anyone, including his two adult children who would inherit the land, is that he has been losing money on the cattle operation for four years running, that the well situation on the south pasture has gotten worse, and that he is seventy-one days behind on a note at the bank. His children believe the ranch is solid. One of them \u2014 his daughter Nicki \u2014 has been planning her life around eventually coming back to run it. Rayford said no to Clete both times partly out of pride and partly because of Nicki. But the bank doesn't care about Nicki.",
"voice": "Edward"
},
{
"name": "Thomasine",
"situation": "Thomasine is not calling about a moral crisis. She is calling because she has become, over the past year, a deeply serious and self-taught expert on the history of the Chinati Hot Springs \u2014 the remote thermal springs down near Ruidosa, south of Marfa, on the Rio Grande \u2014 and specifically on the decades when the property operated as a resort and health retreat in the early and mid twentieth century. She found a box of old photographs and a handwritten guest register from the 1930s at an estate sale in Alpine a year ago and has been trying to piece together who these people were and why they came to a hot spring in the middle of the Chihuahuan desert in the Depression era. She has driven down there four times this year. She has contacted the Presidio County Historical Commission. She has found three people whose grandparents signed the register. She is calling tonight because this week she found a photograph in the box she'd somehow missed \u2014 a group of about twenty people standing in front of the main building, undated, and in the back row she is nearly certain she has identified a figure who, based on everything she knows, should not have been there.",
"voice": "Sarah"
},
{
"name": "Merritt",
"situation": "Merritt is calling because he did something at work six weeks ago that he has been unable to resolve in his own head. A colleague of his \u2014 a senior staff astronomer named Dr. Pryce \u2014 submitted an observation request that Merritt, as the person responsible for scheduling mirror maintenance and telescope availability, knew would conflict with a pre-approved community dark-sky viewing event: one of McDonald's public Star Party nights, attended by families and school groups from all over the region. Dr. Pryce's request was legitimate and scientifically significant \u2014 a narrow window to observe a specific target. Merritt approved Dr. Pryce's request and bumped the Star Party. He did it because Dr. Pryce is his direct supervisor and the science was real. The Star Party was rescheduled with one week's notice. A group of special-needs students from a school in Odessa had planned the trip for months, had already driven four hours, and arrived to find the public program cancelled. Merritt was in the building when they arrived. He watched the teacher manage the disappointment. He did not say anything. He has not said anything since.",
"voice": "Mark"
}
]
},
{
"timestamp": 1785806876.617506,
"lineup": [
{
"name": "Silas",
"situation": "The Wellspring's artisanal soap operation \u2014 the financial backbone of the whole community \u2014 just lost its primary fulfillment contract. The small regional co-op that was warehousing and shipping their product out of Las Cruces dropped them because the new Terlingua address triggered a contract clause about operational continuity. Silas has thirty-one people on raw desert land, a partially built structure, no reliable shipping infrastructure, and maybe six weeks of operating cash. He is calling ostensibly to ask Luke whether he knows anyone in the region who does small-batch fulfillment, but the real thing underneath is that he is beginning to wonder if the move was a catastrophic mistake he dressed up as spiritual vision.",
"voice": "Sebastian"
},
{
"name": "Darnell",
"situation": "Three weeks ago Darnell's seventeen-year-old son Keondre showed him a video on his phone \u2014 footage of Darnell's own foreman, a man named Pruitt, taking cash from a county contractor in the parking lot of the Comanche Springs Park. Darnell recognized the contractor: it's the same outfit that keeps winning the chip-seal bids even when their price comes in higher than competitors. Darnell has worked for Pruitt for six years. Pruitt wrote the letter that got Darnell the supervisor job. He also knows that if he reports it, the investigation will likely sweep up the whole department, and two of his crew members are men he genuinely respects who have nothing to do with the kickback. He hasn't told his wife yet. He hasn't done anything with the video. Keondre keeps asking what he's going to do.",
"voice": "Marcus"
},
{
"name": "Wrenley",
"situation": "Wrenley has no moral crisis. She is calling because two nights ago, Saturday, she witnessed something at the Marfa Lights viewing platform on Highway 90 that she has been turning over in her head ever since and needs to say out loud to somebody. She was out there alone around 1 a.m. after her shift \u2014 she goes sometimes just to decompress \u2014 and she watched what she is certain was not headlights, not aircraft, not the Chinati Foundation doing anything: a single white light that hovered completely stationary for about four minutes, then split cleanly into three lights that moved in a slow equilateral triangle formation before dimming out one by one over maybe ninety seconds. She has seen the lights before. This was different. She is not a believer in anything particular. She is a woman who watched something she cannot explain and it has been living in her chest since Saturday.",
"voice": "Chloe"
},
{
"name": "Augusto",
"situation": "Augusto's dilemma has been building for eighteen months and cracked open this past week. His only daughter, Renata, 34, has been living in San Antonio and has made it clear she has no interest in the ranch \u2014 she's a civil engineer, happy, married, not coming back. His only son, Felix, 39, wants the land badly, has been working alongside Augusto for twelve years, and would be the obvious heir. But Augusto has discovered \u2014 through a conversation he wasn't supposed to hear \u2014 that Felix has been quietly negotiating with a private equity land aggregator out of Houston that has been buying up ranch parcels across Brewster County. Felix hasn't said a word to Augusto. The aggregator has been buying land and leasing it back as a carbon credit operation. Augusto does not believe Felix would sell immediately, but he now believes Felix would sell within five years of inheriting. He doesn't know whether to confront Felix, change the will, or say nothing and let the land go when he's gone.",
"voice": "Victor"
},
{
"name": "Tippi",
"situation": "Tippi is not calling about a crisis. She is calling because she has spent the last eight months going down a rabbit hole she cannot fully explain to anyone in her regular life: she has become obsessed with the 1883 survey records of the Southern Pacific Railroad right-of-way through Brewster County \u2014 specifically a three-mile discrepancy between the original survey plat and where the tracks were actually laid through what is now the west end of Alpine. She found the original survey documents in the Sul Ross archives while helping a student with unrelated research. The discrepancy means that according to the 1883 plat, the tracks should run through property that is currently occupied by a neighborhood of about fourteen homes. She has cross-referenced this with the original land grant records, the 1901 county tax rolls, and two sets of USGS topographic maps. She is not a lawyer and doesn't know what it means legally, but the geometric fact of it keeps her up at night.",
"voice": "Loretta"
},
{
"name": "Hector",
"situation": "Six months ago Hector's twenty-six-year-old nephew, Ismael, came to work for him driving a route between Marfa and Presidio. Ismael is the son of Hector's younger brother \u2014 the brother who died of a heart attack at 49 \u2014 and Hector took him on as an act of family loyalty. Last week Hector found out from a driver at another outfit that Ismael has been doing a side job on his Marfa-to-Presidio run: picking up cash from a man in Presidio and delivering envelopes to an address in Marfa. Nobody has told Hector what's in the envelopes. The driver who told him didn't know either. But Hector knows the man in Presidio by reputation, and the reputation is not clean. Ismael has a daughter who just turned one year old. Hector hasn't confronted Ismael yet. He is a deacon. His brother is dead. He doesn't know whether to call his brother's name down on this or let the boy hang himself.",
"voice": "Jason"
},
{
"name": "Odalys",
"situation": "Odalys is not calling about a crisis. She is calling because she has developed, over four seasons on the Rio Grande in the Big Bend, an obsessive working knowledge of the canyon acoustics \u2014 specifically, the way sound behaves inside Santa Elena Canyon, Mariscal Canyon, and Boquillas Canyon differently from each other, and what it means for guiding. She has been cataloguing this informally for two years: which canyons amplify a whistle, which ones swallow a shout, how the river noise changes pitch as water levels drop in August, the specific echo delay inside the upper Santa Elena narrows at the point where the walls close to about thirty feet. She started doing it for safety \u2014 knowing how far sound carries tells you how to communicate with a group \u2014 and then it became something else entirely. She brought a small recorder this season. Tonight she heard Luke play a piece of recorded ambient sound on air and something about the reverb made her need to call.",
"voice": "Amina"
},
{
"name": "Brecken",
"situation": "Brecken's dilemma is recent and specific and he can't talk to anyone on campus about it. Four days ago he was doing routine irrigation maintenance near the Sul Ross athletic fields and he found a folder of printed documents left in the equipment shed \u2014 personnel records, email printouts, what appear to be drafts of a Title IX complaint \u2014 with a name on the cover sheet he recognizes: a professor he has had, a man he genuinely likes, who helped him get a summer internship with Texas Parks and Wildlife that he is supposed to start in September. The folder was just sitting there. He doesn't know if it was left accidentally or if someone put it there deliberately for someone else to find. He took a photo of the cover sheet before he put it back. He hasn't told anyone. He doesn't know who left it or who it belongs to. But he knows what he saw, and he knows the internship letter is sitting on his desk, and he knows those two facts are now entangled in his head even though they may have nothing to do with each other.",
"voice": "Elliot"
},
{
"name": "Neva",
"situation": "Neva has no moral crisis. She is calling because three weeks ago she was harvesting honey from her hives and noticed that her bees \u2014 a colony she has kept for eleven years \u2014 had begun constructing comb in a geometry she had never seen and cannot find documented anywhere she has looked. Instead of the standard hexagonal sheet construction, a section of one frame had comb cells arranged in a spiral pattern radiating outward from a central point, like a nautilus cross-section, with cell size decreasing toward the center. She is a careful, empirical woman who has kept bees for thirty years. She has contacted two extension agents and a professor in the entomology department at Texas A&M. The extension agents told her it was probably a wax moth disruption. The A&M professor said it was likely stress-related irregular comb and sent her a paper. She has read the paper. The paper does not describe this. She has photographs. She has been keeping bees long enough to know what stress comb looks like, and this is not that.",
"voice": "Elizabeth"
},
{
"name": "Cord",
"situation": "Cord's situation came to a head this afternoon and he pulled over on Highway 90 to think and ended up calling the show. His business partner of nine years \u2014 a man named Garrett, who owns 40 percent of the company \u2014 told Cord two weeks ago that he wanted to buy out his share and exit the business. Cord agreed, they shook on a number, Cord started putting together the financing. Today Cord found out from a drilling company dispatcher he's friendly with that Garrett has already been talking to a competitor \u2014 a larger well-service outfit out of Midland \u2014 about taking a salaried VP position and bringing the company's client list with him. The client list is not formally protected by anything because when they started the company nine years ago two guys shaking hands in a parking lot in Fort Stockton didn't think about non-competes. If Garrett walks with the client relationships, Cord loses maybe 60 percent of his revenue in the first year.",
"voice": "Hank"
}
]
},
{
"timestamp": 1785807224.910801,
"lineup": [
{
"name": "Sondra",
"situation": "Sondra has been doing the books for a Marathon feed-and-supply operation for eleven years. Last Thursday she found a pattern she cannot explain away: the owner has been quietly underreporting payroll for at least four years, which means his two full-time Mexican-American employees \u2014 men she knows personally, men who have worked there since before she did \u2014 have been paying into Social Security on falsified wages. The men don't know. The owner is sixty-eight, has a bad heart, and his wife just started chemo. Sondra has not said a word to anyone.",
"voice": "Marlene"
},
{
"name": "Pax",
"situation": "Pax is not calling about a crisis. He is calling because last spring, while doing vegetation transects out in the Glass Mountains northeast of Alpine, he stumbled into what he is now convinced is one of the last undocumented nesting sites in Texas for the Aplomado falcon \u2014 a bird that was functionally extirpated from the state for decades before reintroduction programs began. He has GPS coordinates, photographs, and two confirmed adults on nest. He has told his thesis advisor. His advisor told him to sit on the data until they can publish. That was four months ago. The paper is nowhere near done. Pax is twenty-nine years old and bursting.",
"voice": "Jonah"
},
{
"name": "Truett",
"situation": "Truett's dilemma is about his brother. His younger brother Waylon, fifty-four, has been sober for nine years \u2014 AA, sponsor, the whole architecture. Last month Waylon started dating a woman Truett likes genuinely, a widow named Carol who works dispatch at the sheriff's office. Two weeks ago Truett ran a routine background check on Carol as part of a process-serving job \u2014 totally unrelated \u2014 and found something: Carol has a civil judgment against her from 2019 in Midland County related to a bar fight that turned into a lawsuit, and more troublingly, a dismissed DWI from 2021 that was pleaded down. Truett doesn't know if Waylon knows. Carol has given no indication she drinks now. Truett ran the check legally. He wishes he hadn't.",
"voice": "Grant"
},
{
"name": "Lupe",
"situation": "Lupe is not calling about a crisis. She is calling because she has spent the last year developing what has become a serious, documented obsession with the architectural and commercial history of the Presidio-Ojinaga crossing \u2014 specifically the way the same families have run trade and transport on both sides of the river for over a hundred and twenty years, surviving revolution, Prohibition, NAFTA, and every border policy since. She has found letters, photographs, ledgers. She found a 1923 manifest in a box at her grandmother's house in Ojinaga that lists goods she can trace to a great-great-uncle's store that still has a building standing in Presidio. She has started giving informal talks at the Presidio library.",
"voice": "Selene"
},
{
"name": "Garrison",
"situation": "Garrison's situation cracked open this past weekend. His nineteen-year-old daughter Lily drove home from her first year at Texas Tech and told him and his wife that she has decided not to come back \u2014 not to the ranch, not to Marfa, not to any of it. She wants to stay in Lubbock, get an apartment with friends, and study something called sustainable food systems. Garrison is not angry about the degree. He is gutted because last fall, before she left, he deeded her forty acres of the ranch \u2014 the section his own grandfather deeded to his father \u2014 in a formal transfer, with a lawyer, as a statement of faith that she was coming back. She didn't ask him to do it. He did it because he was certain.",
"voice": "Cedric"
},
{
"name": "Effie",
"situation": "Effie is not calling about a crisis. She is calling because she has spent the last three months quietly and methodically documenting what she believes is a lost oral tradition of weather prediction specific to the Davis Mountains and the surrounding basin \u2014 a set of observational rules passed down by ranching families and old-timers that predate the National Weather Service station in the region and have, in her testing, a remarkable accuracy rate for afternoon monsoon prediction in July and August. She has interviewed eleven people, the oldest of whom is ninety-three. She is cross-referencing their observations against sixty years of NWS records she pulled from Alpine and Marfa.",
"voice": "Victoria"
},
{
"name": "Rook",
"situation": "Rook's dilemma is recent and specific. Three days ago he guided a private trip \u2014 four people, a five-day float through the Lower Canyons. On the second day, one of the clients, a man named Dennis from Houston, pulled Rook aside at camp and offered him three hundred dollars cash to tell the rest of the group that a particular side canyon was too dangerous to enter. It wasn't. Rook knew it wasn't. Dennis wanted to prevent his teenage stepson \u2014 who was on the trip \u2014 from making the hike because the stepson had been talking the whole first day about how he wanted to do it and Dennis wanted to deny him the experience without being the one to say no. Rook took the money. He told the group the canyon was closed for resource protection. The stepson, a kid named Marcus, sat at the river's edge for two hours that afternoon without speaking.",
"voice": "Hamish"
},
{
"name": "Inez",
"situation": "Inez is calling because she has a theory \u2014 a real, worked-out, documented theory \u2014 that the water table beneath a significant stretch of Pecos County has been contaminated by injection well activity associated with Permian Basin disposal operations, and that this contamination is the explanation for a cluster of specific gastrointestinal and neurological presentations she has been seeing in patients from two particular zip codes over the past three years. She is not a scientist. She is an LVN with thirty years of bedside experience and a very good memory for patterns. She has kept a personal log. She has cross-referenced her log against public Railroad Commission injection well permits using the RRC's online database. She has shown her log to one physician, who told her correlation isn't causation and moved on.",
"voice": "Darlene"
},
{
"name": "Dovie",
"situation": "Dovie's situation is this: she was hired eight months ago to help a visiting artist \u2014 a moderately well-known sculptor from New York named Callista \u2014 install a temporary public piece on a private property outside Marfa. During the installation, Dovie did most of the actual physical labor. The piece was documented, reviewed in two online art publications, and Callista was credited as sole creator. Dovie is fine with that \u2014 she was paid and she knew the deal. What Dovie is not fine with is that last week she found out Callista has sold the piece \u2014 supposedly temporary, supposedly site-specific \u2014 to a collector in Zurich for an amount Dovie accidentally overheard her talking about on the phone: four hundred and twenty thousand dollars. Dovie made eleven dollars an hour.",
"voice": "Sophie"
},
{
"name": "Wendell",
"situation": "Wendell is a true believer and he is dead serious. He has spent the last nine months compiling evidence for what he is convinced is a coordinated and deliberate suppression of a specific type of highway infrastructure \u2014 the concrete box culvert designs used on state highways in Brewster, Terrell, and Val Verde counties in the 1940s and 1950s. He believes these culverts were built according to a non-standard specification that appears in no TxDOT archive he has been able to access, that several of them are still load-bearing on active roads, and that someone \u2014 he is not sure who, but possibly a contractor family with long ties to Austin \u2014 has been systematically ensuring these structures are replaced rather than documented, destroying evidence of what he believes was a large-scale materials fraud that stretched from roughly 1941 to 1959. He has photographs of seventeen culverts. He has a spreadsheet.",
"voice": "Arthur"
}
]
},
{
"timestamp": 1785807641.615133,
"lineup": [
{
"name": "Silas",
"situation": "The Wellspring's artisanal soap operation \u2014 the financial backbone of the whole community \u2014 just lost its primary fulfillment contract with the small regional co-op that had been handling shipping out of Las Cruces. Now, stranded on remote land outside Terlingua with no reliable shipping infrastructure, Silas is watching the commune's income evaporate in real time. He's called nominally to ask Luke's audience if anyone knows a freight or fulfillment solution out of the Big Bend, but underneath that he's grappling with whether the move was a catastrophic mistake driven more by his own need to escape the shame of the coercion reckoning than by genuine vision. Tonight a twenty-three-year-old member named Petra \u2014 one of the true believers who drove the U-Haul all the way from Deming \u2014 told him she was leaving because she 'didn't sign up to be poor in the desert.' That one landed.",
"voice": "Sebastian"
},
{
"name": "Darlene",
"situation": "Darlene has been renting a room to Cody, twenty-four, for eight months \u2014 a Permian Basin roughneck who works two-weeks-on, two-weeks-off rotations and is otherwise quiet, pays on time, and keeps to himself. Last Friday Darlene was pulling weeds along the side of the house and found, wedged between the foundation and the AC unit, a ziploc bag containing forty-three hundred dollars in cash and a handwritten list of first names with dollar amounts next to them. She put it back exactly where she found it. Cody came home Saturday and she acted normal. She does not believe Cody is dangerous \u2014 nothing about him feels dangerous \u2014 but the list has seven names on it and she doesn't know if she's looking at a drug debt ledger, someone paying people back, a loan operation, or something else entirely. She's not going to call the police. She wants to know whether to ask him directly or let it go.",
"voice": "Tessa"
},
{
"name": "Fletcher",
"situation": "Fletcher is calling because of something that happened to him last Thursday night and he has told exactly one person \u2014 his dog. He was running new conduit in a crawlspace under one of the Gage Hotel's outbuildings around ten-thirty at night, a job that ran long, and he heard what he can only describe as a sustained, low, resonant tone coming from below him \u2014 not mechanical, not HVAC, not anything in the building. He's been in that crawlspace a dozen times. He lay still for about four minutes and the tone shifted in pitch, slowly, and then stopped. He is not a paranormal person. He does not watch ghost shows. He has a completely mundane theory \u2014 some kind of acoustic phenomenon, maybe the aquifer, maybe wind \u2014 and he called to see if anyone else has ever heard anything like it, because he can't stop thinking about it and he's going back to finish the job Wednesday night and he's nervous in a way he doesn't know what to do with.",
"voice": "Carter"
},
{
"name": "Rosalinda",
"situation": "Rosalinda's dilemma is eighteen months old and came back to the surface last week. Eighteen months ago she let someone through \u2014 a woman, fifties, claiming to be visiting family in Presidio, documentation in order \u2014 and something was wrong. Not wrong enough to hold her, not wrong enough to flag under any protocol. Just wrong in the way Rosalinda's body knew things before her brain did. She let her through. Two weeks later that woman was arrested in Odessa connected to a trafficking operation. Rosalinda was never implicated, was never questioned \u2014 her documentation check was clean and correct. Nobody knows she had the feeling. Last week she processed a crossing and had the exact same feeling about a young man, and this time she found a reason to hold him for secondary inspection. Nothing turned up. He was clean. She let him go. But the eighteen-month-old decision has been sitting in her chest since last week like a stone, and she is calling because she needs to know: do you act on a feeling you can't justify or do you do the job by the book and live with what the book misses?",
"voice": "Veronica"
},
{
"name": "Burl",
"situation": "Burl is calling because he has a story and it is a good one and he has been trying to tell it to people all week and nobody will sit still for the whole thing. In 1987 \u2014 Burl was twenty-eight \u2014 a stranger appeared on his family's ranch during a drought and asked permission to water his horse at their tank. His father let him. The man stayed three days, helped with fence work, spoke almost no Spanish and almost no English, and left without ceremony. He had, according to Burl's father, the single most useful pair of hands either of them had ever seen \u2014 not strong, just precisely, eerily efficient with every task. Before he left he told Burl's father, in a mix of hand gestures and broken words, that the drought would break in eleven days. It broke in nine. Burl has been thinking about this man his entire life. Two weeks ago, cleaning out his father's old desk, he found a photograph he had never seen \u2014 his father standing next to a stranger, labeled on the back in his father's handwriting with a date and one word: 'Jacobo.' The photo is from 1971. Burl was not born yet. The man in the photo and the man from 1987 look identical. Same age.",
"voice": "Ronald"
},
{
"name": "Marisol",
"situation": "Marisol's dilemma happened five days ago and she has been in a low-grade panic since. She is teaching a freshman comp section this summer \u2014 eight students, a small class \u2014 and one of her students, a nineteen-year-old named Derek, turned in a personal essay that was, by any measure, extraordinary: raw, structurally sophisticated, genuinely moving. She gave it an A and wrote extensive comments. Two days later Derek came to office hours and, after some awkward circling, told her he'd had help \u2014 not from a tutor, not from AI, but from his older brother, who is apparently a writer. The essay is Derek's story, Derek's experience, Derek's voice as he described it to his brother, but the sentences are not Derek's sentences. Marisol does not know whether this is plagiarism, collaboration, ghostwriting, or just a kid from a family where help looks different than the academic integrity policy imagined. Derek is a first-generation college student. He was terrified to tell her. She has not yet reported it or changed the grade and the window for academic integrity referrals is closing.",
"voice": "Naomi"
},
{
"name": "Cletis",
"situation": "Cletis is not calling about a crisis. He is calling because he has spent the last two years developing what he believes is a comprehensive and airtight theory about the acoustics of desert canyon systems in the Big Bend \u2014 specifically, that the rock formations along certain stretches of the Rio Grande canyon walls act as natural parabolic reflectors that can focus and amplify ambient sound over distances of up to three miles. He has been testing this with a calibrated decibel meter he built himself from salvaged parts and a microphone array he designed. His theory, if correct, would explain several reported phenomena in the area including certain Marfa Lights witness accounts involving sound (which are rarely discussed) and some of the stranger Rio Grande canyon echo reports going back to the Spanish colonial period. He is not saying it's supernatural. He is saying the physics is genuinely interesting and nobody has studied it properly because it requires being in very remote places at three in the morning for extended periods, which most acoustical researchers are apparently not willing to do.",
"voice": "Derek"
},
{
"name": "Genevieve",
"situation": "Genevieve's situation cracked open yesterday and she is still shaking from it. She manages rental properties for several absentee owners, including a New York couple who own a Marfa house they rent on a short-term basis. Yesterday she went to do a turnover inspection between guests and found, in the house, a sixty-three-year-old Marfa ranching family patriarch named Harlan Briggs \u2014 a man she has known her entire sixteen years here \u2014 asleep on the couch. Harlan is not the renter. Harlan has no connection to the property that Genevieve knows of. He was not breaking in \u2014 the door was unlocked from the previous guests. He woke up, looked at her without embarrassment, said 'I just needed somewhere cool and quiet,' and left. Genevieve knows Harlan's family. She knows his wife. She knows the family has been on that land for four generations. She also knows that Harlan's ranch has been in serious financial trouble and that last year a portion of the family land was sold to a Dallas investment group. She has to decide whether to report the incident to the property owners \u2014 which would likely result in a trespassing complaint against a man whose family has been in this county longer than the property owner has been alive \u2014 or say nothing.",
"voice": "Olivia"
},
{
"name": "Ptolemy",
"situation": "Ptolemy is not calling about a crisis. He is calling because he spent the afternoon at the McDonald Observatory near Fort Davis and came away with a fact he cannot stop turning over in his mind and has been trying to explain to every person he's encountered since, with limited success. The fact is this: the observatory's Hobby-Eberly Telescope, one of the largest in the world, was specifically designed with a fixed altitude \u2014 it doesn't tilt up and down, only rotates \u2014 meaning there is a band of the sky it simply cannot observe. The designers accepted this constraint because the telescope's primary purpose, studying the spectra of faint objects, doesn't require full-sky coverage. Ptolemy finds this philosophically staggering: the most powerful eye in the Western Hemisphere was deliberately built with a blind spot, by choice, because the blind spot was worth it. He has been applying this idea to everything since three o'clock this afternoon and he needs to talk it through with someone before he drives back to Austin.",
"voice": "Felix"
},
{
"name": "Yolanda",
"situation": "Yolanda's dilemma is about her younger sister Gloria, sixty-six, who lives in the same town. Their mother died fourteen months ago and left the family home \u2014 a house in Sanderson that has been in the family since 1961 \u2014 to both of them equally. Yolanda wants to sell. The house needs $40,000 in repairs she cannot afford, the Sanderson real estate market is essentially nonexistent, and she is seventy-one and tired of maintaining a property she doesn't live in. Gloria refuses to sell under any circumstances and refuses to buy Yolanda out and refuses to contribute to repairs, on the grounds that selling the house would be a betrayal of their mother. Last week a buyer appeared \u2014 a young couple from San Antonio who want to move to Sanderson and fix it up themselves, and they've offered fair market value. Gloria has already told the buyer the house is not for sale. Yolanda is calling because she is considering forcing a partition sale through a lawyer \u2014 a legal mechanism that would compel the sale over Gloria's objection \u2014 and she knows it will end her relationship with her sister permanently.",
"voice": "Zadie"
}
]
},
{
"timestamp": 1785824765.051897,
"lineup": [
{
"name": "Tess",
"situation": "Tess's grandmother left her a 1940s adobe on the edge of Marfa \u2014 the house where Tess learned to ride, where her grandfather died, where every room has a story. Six months ago, broke and desperate after a bad cattle year, Tess listed it on Airbnb. It now pays better than the ranch. Last Friday she arrived to clean between guests and found the visitors had staged the entire interior for a fashion shoot \u2014 moved her grandmother's furniture, hung white fabric over the family photos, and left a printed note thanking her for the 'blank canvas aesthetic.' She stood in the middle of her grandmother's bedroom for forty minutes before she could move.",
"voice": "Nadia"
},
{
"name": "Coop",
"situation": "Coop is not calling about a crisis. He is calling because he is obsessed with something nobody out here wants to talk about: open-water swimming in the Permian Basin's produced-water containment ponds, which he has never done and never would, but has been researching obsessively for a year after reading about extreme swimmers seeking out forbidden or impossible venues. What started as morbid curiosity became a genuine deep-dive into the chemistry, the geography, and the sheer scale of West Texas water infrastructure most people don't know exists. He drove out last weekend and stood at the fence line of a containment facility outside Fort Stockton and just looked at it \u2014 thousands of acres of flat, still, toxic water in the middle of the desert \u2014 and something about the scale and the wrongness of it will not leave him alone.",
"voice": "Hank"
},
{
"name": "Brecken",
"situation": "Brecken's dilemma is four days old and involves his thesis advisor, Dr. Harmon, a man he has admired for three years. Brecken discovered, while running data cross-checks for his own thesis chapter, that a key table in Harmon's 2019 published paper \u2014 the paper that forms the methodological backbone of Brecken's entire research program \u2014 contains what appear to be fabricated data points. Not misremembered. Not rounding errors. The same decimal pattern repeating across supposedly independent sample sites in a way that doesn't happen in nature. Brecken has shown it to no one. He has a meeting with Harmon on Thursday to review his own thesis draft.",
"voice": "Liam"
},
{
"name": "Odessa",
"situation": "Odessa is not calling about a crisis. She is calling because this evening, around dusk, she had the best moment she has had in five years and she needs to tell somebody. She has been keeping Chihuahuan Desert honeybee colonies in Terlingua for six years, adapting northern European beekeeping methods to extreme heat and drought with no manual, no mentor, and mostly failure. Tonight she pulled her first successful mead \u2014 a small batch from her own honey, fermented through the brutal summer \u2014 and it tasted exactly right. She drank a glass sitting outside watching the last light go off the Chisos and she cried, and she is not embarrassed about that.",
"voice": "Loretta"
},
{
"name": "Harlow",
"situation": "Harlow's dilemma is about her oldest friend, Petra Canales, who she has known since first grade. Petra's husband Danny is running for Brewster County Commissioner in November. Last month Harlow processed a filing \u2014 routine public record \u2014 that shows a mechanics lien against Danny's construction business totaling $87,000 from a subcontractor in Presidio. It is a matter of public record. It is also something that, if it became widely known before the election, would probably end Danny's campaign and likely his marriage, because Petra does not know the debt exists. Harlow has done nothing. She has told no one. But a reporter from the Alpine Avalanche called her office today asking for any recent lien filings in Brewster County.",
"voice": "Julia"
},
{
"name": "Wick",
"situation": "Wick is a true believer and he is dead serious. He has spent the last four months documenting what he is convinced is a pattern of deliberate, coordinated cattle mutilations along a sixty-mile corridor between Marfa and Presidio \u2014 not random predator kills, not decomposition, not the usual explanations. He has photographs. He has GPS coordinates. He has a notebook with dates, wind directions, lunar phases, and the specific nature of the excisions, which follow a consistency he spent thirty years in law enforcement learning to recognize as intentional. He has taken his findings to the Presidio County Sheriff's office twice. He was not taken seriously either time. He believes the pattern is escalating.",
"voice": "Victor"
},
{
"name": "Paloma",
"situation": "Paloma's dilemma happened three weeks ago and she has been carrying it alone since. She was named to the Sul Ross cross-country travel roster for a meet in Odessa. The night before the bus left, her teammate and close friend Dani told her, in confidence, that she had been taking a prescription stimulant that wasn't hers to manage her training volume \u2014 not a tested substance at the NAIA level, technically, but a violation of Sul Ross's athletic code. Paloma said nothing. They went to the meet. Dani ran the best race of her life. Yesterday the athletic department announced a random conduct review of the roster for next semester, and Dani came to Paloma's dorm room crying, asking if Paloma had reported her.",
"voice": "Chloe"
},
{
"name": "Gideon",
"situation": "Gideon is not calling about a crisis. He is calling because he has been building a sixteen-inch Dobsonian reflector telescope from scratch for three years \u2014 grinding his own mirror, constructing the tube and rocker box from salvaged materials \u2014 and last Saturday night he used it for the first time under the dark skies outside Fort Davis and saw the Veil Nebula, a supernova remnant, in more detail than he has ever seen it outside of photographs. He is not a romantic about it. He is a physics teacher. But he sat at the eyepiece for two hours because he could not make himself stop, and what struck him was not beauty but scale \u2014 he was looking at the debris field of a star that exploded ten thousand years ago, and the light reaching his homemade mirror had been traveling since before Stonehenge.",
"voice": "Conrad"
},
{
"name": "Trace",
"situation": "Trace's dilemma is about a decision he made nine months ago that he has decided, tonight, was wrong. He bid on and won a contract to buy clip from a small Angora goat operation outside Dryden \u2014 an elderly couple, the Seavers, who have run the place for forty years. He knew when he bid that the price he offered was below fair market. He bid low because he could, because the Seavers don't have other buyers willing to drive out there, and because that is how the business works. Last week he went out to the Seavers' place for the spring clip and found out that Roy Seaver had a stroke in May and that his wife Norma has been running the whole operation alone since then. She had the clip ready and she had the paperwork ready and she thanked him for coming out.",
"voice": "Blake"
},
{
"name": "Birdie",
"situation": "Birdie is not calling about a crisis. She is calling because she is furious about a specific, recent injustice and she has opinions and she has waited two days to call because she wanted to get her facts straight first. The Brewster County Historical Commission has declined \u2014 for the third time \u2014 to designate the old Southern Pacific railroad depot on the east side of Alpine as a historic landmark, despite Birdie having submitted documentation going back to 1882. The building is currently being eyed by a developer from Austin who wants to convert it into a boutique hotel. Birdie has a personal connection to the depot: her father worked as a freight agent there from 1962 to 1981, and she spent her childhood in and around that building. She also has a historical argument and she is prepared to make it.",
"voice": "Sarah"
}
]
},
{
"timestamp": 1785825116.9801579,
"lineup": [
{
"name": "Rayfield",
"situation": "Rayfield's dilemma is eight days old and he cannot stop thinking about it. He caught his nephew, Denny \u2014 twenty-two, just got hired on at the Gage Hotel as a groundskeeper \u2014 stealing a catalytic converter off the truck belonging to an elderly Tejano rancher named Aurelio Serna, whose family has run cattle east of Marathon for four generations. Rayfield saw it happen from his porch at 2 a.m. He didn't call the police. He confronted Denny himself, made him put the part back before Serna noticed it was gone, and kept it quiet. But then he found out that Denny has been doing this for months \u2014 this was not a first time \u2014 and that old Serna has been paying out of pocket to replace converters he thought were just failing on their own. Rayfield knows Serna. He knows the man cannot easily afford this. He kept his nephew out of jail and now he is not sure he had the right to do that with someone else's losses.",
"voice": "Craig"
},
{
"name": "Suki",
"situation": "Suki is not calling about a crisis. She is calling because two nights ago she witnessed the Marfa Lights \u2014 actually witnessed them, a full display, not a smear of headlights \u2014 and what she saw does not match any of the standard explanations she has since read, and she is genuinely, delightedly unnerved. She pulled off at the official Marfa Lights Viewing Area on Highway 90 on her way back from a supply run to Alpine, and the lights performed for nearly forty minutes: splitting, rejoining, hovering, one of them dropping straight down behind the Chinati Mountains and then reappearing on the other side. She has seen the lights three times before and dismissed them. This time she took video on her phone. The video, she says, is terrible \u2014 it looks like nothing \u2014 but she knows what she saw.",
"voice": "Priya"
},
{
"name": "Gus",
"situation": "Gus's dilemma is about his partner on patrol, Deputy Terri Lujan, whom he has worked alongside for six years and considers a close friend. Three weeks ago Gus discovered, while reviewing dashcam footage for an unrelated incident report, that Terri had a thirty-minute personal stop \u2014 unlogged \u2014 at a house on the north edge of Fort Stockton that Gus recognizes. The house belongs to a man named Cecil Pryor, who Gus knows informally as someone connected to a methamphetamine distribution pipeline that runs up through Pecos County from the border. Terri is not under investigation. The stop may have been entirely innocent \u2014 she has family on that street, she could have had any reason. But Gus has watched that footage eleven times and the thirty minutes bothers him. He has not reported it. He has not asked her about it. He is sitting on it.",
"voice": "Damon"
},
{
"name": "Anzelika",
"situation": "Anzelika is not calling about a crisis. She is calling because this morning she guided a solo float through the lower canyons of Santa Elena Canyon and encountered something she has never seen in three seasons on the river: the Rio Grande at that section ran completely still \u2014 no current, mirror-flat \u2014 for approximately a quarter mile, which she knows is hydrologically bizarre given the canyon's normal gradient and the current water levels. The water was clear to the bottom, which she has also never seen there. She photographed it. The stillness lasted about fifteen minutes before the current resumed as if nothing had happened. She asked the two geologists who were her clients \u2014 a couple from Socorro, New Mexico, on a research float \u2014 and they had no explanation either and were visibly agitated in a way she found exciting.",
"voice": "Bianca"
},
{
"name": "Merritt",
"situation": "Merritt's dilemma involves his oldest son, Caleb, thirty, who came back to Alpine two years ago after a decade in Midland working oilfield logistics. Caleb came back broke and Merritt brought him into the store. Six months ago Merritt let Caleb take over the books. Last week Merritt's accountant called for a routine question and let slip a number that didn't match what Caleb had been reporting. Merritt has now gone through four months of receipts himself. Caleb has been skimming \u2014 not dramatically, not recklessly, but steadily. About $14,000 over the six months. Merritt has not confronted Caleb. He has not told his wife. He has not told his accountant. He is calling tonight because Caleb is coming to Sunday dinner in five days and Merritt does not know how to sit across a table from him.",
"voice": "Marcus"
},
{
"name": "Fern",
"situation": "Fern is not calling about a crisis. She is calling because she spent this afternoon doing something she has never done in seventy-one years: she attended a Donald Judd art installation at the Chinati Foundation in Marfa, dragged there by her granddaughter who is visiting from Austin. Fern went in skeptical and came out genuinely, almost angrily moved by the aluminum boxes in the artillery sheds \u2014 not because she understood them intellectually but because standing in that specific light with those specific objects did something to her body that she could not predict and cannot explain. She is furious at herself for being affected and also cannot stop thinking about it.",
"voice": "Marlene"
},
{
"name": "Desmond",
"situation": "Desmond's dilemma happened three months ago and he has been carrying it under a professional smile since. He was on a search committee that hired a new colleague \u2014 Dr. Renata Voss, a wildlife ecologist who joined the department in May. Desmond was enthusiastic about her hire. Two weeks after she arrived, he realized, going through old conference proceedings for an unrelated paper, that a dataset in her job application research summary contained figures he recognized from a 2019 study by a team he worked with as a postdoc \u2014 figures that were not cited. The overlap is specific enough that it cannot be coincidence. He has not told the department chair. He has not told Voss. He has been watching her teach her first summer course and finding her genuinely excellent in the classroom, which is making it worse.",
"voice": "James"
},
{
"name": "Loretta",
"situation": "Loretta is calling because she is on a genuine, specific, long-running obsession and tonight something happened that cracked it back open. She has spent the last three years documenting every roadrunner she encounters on her delivery routes \u2014 not the cartoon, not Paisano Pete in Fort Stockton, actual greater roadrunners \u2014 photographing them, logging GPS coordinates, building a personal spreadsheet of sightings with behavioral notes. She has 847 documented sightings. Tonight, for the first time, she saw two roadrunners running in the same direction, side by side, for approximately sixty yards, at dusk on a ranch road outside Fort Davis. She has never seen this behavior. She has read everything available on roadrunner behavior and has found no documentation of this.",
"voice": "Joy"
},
{
"name": "Concho",
"situation": "Concho is a true believer and he is calling dead serious. For the past eleven months he has been documenting what he believes is a coordinated, systematic effort to map and photograph every water source in the Trans-Pecos \u2014 stock tanks, springs, seeps, and seasonal plinths \u2014 by unmarked vehicles he has encountered on ranch roads where he works. He has logged twenty-two separate encounters. The vehicles are different each time: white pickup, gray SUV, a rented-looking sedan that had no business on a caliche road. They are always carrying survey equipment or cameras. They do not stop to talk. They are always gone before he can get to the nearest ranch gate. He does not believe this is government water surveying \u2014 he knows what that looks like, he has worked alongside USGS crews. He believes someone is building a comprehensive private inventory of every viable water source in this part of Texas ahead of a major acquisition or diversion action, and that the ranchers whose land these sources sit on do not know it is happening.",
"voice": "Theodore"
},
{
"name": "Wylene",
"situation": "Wylene's dilemma is four days old and involves the person she considers her closest friend in Alpine \u2014 a woman named Bex, twenty-eight, who is in the same nursing cohort. Last Friday night, after their pharmacology final, Wylene and Bex went to celebrate at a bar. Wylene, who does not drink heavily, had three beers and was not impaired. At some point in the night, Bex told her \u2014 laughing, confiding, clearly thinking it was funny \u2014 that she has been systematically copying Wylene's clinical assessment write-ups for the past semester, changing enough words to pass the similarity checker. Not occasionally. Every single one. Bex then changed the subject and they kept celebrating. Wylene drove home and has not slept properly since. They have a clinical rotation together starting next Monday. Bex has not mentioned it again.",
"voice": "Selene"
}
]
},
{
"timestamp": 1785825905.7824361,
"lineup": [
{
"name": "Silas",
"situation": "The caravan is three vehicles \u2014 a converted school bus, a livestock trailer carrying the commune's goats, and Silas's truck \u2014 and they pulled off the road an hour ago because one of the founding members, a woman named Dove who has been with The Wellspring for nine years, announced at the Fort Stockton gas station that she is not getting back in the bus. She's sitting in the dirt by the road with her bag and says she wants to go back to Deming. The rest of the community is watching from the bus windows. Silas is standing fifty feet away, calling Luke from the dark, because he cannot figure out whether to respect her choice and let her go or whether watching her sit in the dirt on Highway 385 at midnight is its own form of coercion.",
"voice": "Sebastian"
},
{
"name": "Darlene",
"situation": "Two weeks ago a guest checked in alone \u2014 a man in his late sixties, paid cash, gave a name Darlene is pretty sure was false, stayed four nights, barely left his room. On his last morning she found a legal-size envelope slid under the front desk with her name on it \u2014 not the hotel's name, her name, Darlene \u2014 containing $800 in cash and a handwritten note that said only: 'You were kind to me when I needed it. Don't look for me.' She has no memory of doing anything particular for this man. She smiled at him twice. She brought him an extra blanket one night without being asked. The $800 is sitting in her kitchen drawer and she cannot decide if she should keep it, report it to management, donate it, or if there is something darker she is missing about why a man hiding under a false name would leave a night auditor $800 cash.",
"voice": "Wendy"
},
{
"name": "Cutter",
"situation": "Cutter discovered six months ago that he has a genuine, inexplicable talent for finding water with a forked mesquite branch \u2014 dowsing \u2014 and it is destroying his academic identity. He found water twice on the ranch by accident, told no one, but last week the ranch foreman asked him to do it formally for a new trough location and he found it again, eighteen inches down, exactly where the stick pulled. He is a scientist. He believes in soil surveys and GPR imaging. He cannot explain what is happening to his hands and he is terrified of what it means that it keeps working.",
"voice": "Jake"
},
{
"name": "Esperanza",
"situation": "Esperanza's dilemma began eight days ago when her youngest son, Tom\u00e1s, twenty-six, told her he has been in a serious relationship for two years with a woman named Ingrid who is a Chinati Foundation artist-in-residence from Stockholm. Esperanza has no problem with Ingrid being Swedish. Her problem is that Tom\u00e1s has been lying to her face for two years, spending 'overnight work trips' with Ingrid in Marfa, and when she asked him why he hid it, he said he knew she would disapprove of 'the Marfa crowd.' He was not wrong. She does disapprove of the Marfa crowd. But she is sitting with the question of whether her known disapproval effectively coerced her own son into two years of lying, and whether that makes her responsible for the deception she is furious about.",
"voice": "Olivia"
},
{
"name": "Rowdy",
"situation": "Rowdy is calling because he has been trying, for the past three years, to get Paisano Pete \u2014 Fort Stockton's famous twenty-two-foot concrete roadrunner \u2014 declared an official Texas State Symbol, and he finally got a state representative to agree to file the bill, and tonight the rep called to say he'd found a co-sponsor and it is actually going to the floor. Rowdy has been drafting letters, driving to Austin twice, organizing a petition with four thousand signatures, and doing all of this while his wife thought he was mildly insane. He is calling because he is so happy he cannot sleep and everyone in his house is already asleep and he needs to tell somebody.",
"voice": "Grant"
},
{
"name": "Neva",
"situation": "Neva was commissioned eight months ago to paint a large mural on the side of a building on Holland Avenue in Alpine \u2014 a landscape piece featuring the Chisos Mountains at dusk. The building owner, a man named Gerald Pruitt, was enthusiastic, paid a deposit, gave her full creative control. The mural is finished and it is, by any measure, the best work Neva has ever made. Last week Gerald told her he loves it but he has sold the building to a developer from Austin who plans to resurface the exterior. The mural will be painted over within sixty days. Gerald is apologetic but has no legal obligation to preserve it \u2014 Neva's contract, which she wrote herself without a lawyer, has no preservation clause. She is not calling about suing Gerald. She is calling because she has been offered, through a contact, an opportunity to document the mural removal on video and sell the footage to an arts journal that covers ephemeral public art \u2014 essentially profiting from the destruction of her own work \u2014 and she cannot decide if that is beautiful or a betrayal of what the mural was supposed to be.",
"voice": "Tessa"
},
{
"name": "Holbrook",
"situation": "Holbrook's dilemma is about his closest friend of thirty years, a man named Dale Fitch who ranches east of Sanderson. Fourteen months ago Dale's wife died of a fast cancer \u2014 six weeks from diagnosis to burial. In the months after, Holbrook drove out to Dale's place constantly, helped with the ranch, sat with him. Three months ago Dale met a woman named Rhonda through a mutual friend \u2014 a widow herself from Ozona \u2014 and they have gotten serious fast. Last week Dale called Holbrook to say he and Rhonda are getting married in October, fourteen months after his wife's death. Holbrook's gut reaction was visceral \u2014 he said, on the phone, without thinking, 'Dale, that's too fast.' Dale went quiet and hung up. They have not spoken in a week. Holbrook knows grief is not on a schedule. He knows Rhonda may be the best thing to happen to Dale. But he also watched Dale's wife die and sat at her grave in July heat and something in him could not make the math work, and now he may have damaged a thirty-year friendship over a feeling he is not sure was even about Dale.",
"voice": "Rupert"
},
{
"name": "Petronella",
"situation": "Petronella is not calling about a crisis. She is calling because she spent this afternoon doing something she has been privately doing for eleven years and has never told another living soul: she is a meticulous, obsessive oral historian of Marfa's pre-Chinati past. She has recorded over three hundred hours of interviews with old-timers \u2014 ranch hands, the women who ran the Presidio County courthouse for decades, the men who remember when the Marfa Army Air Field was active, a woman who was present at the 1945 German POW escape from Camp Marfa. She has transcribed every word herself on a typewriter. Tonight she finished transcribing her final tape \u2014 an interview with a ninety-three-year-old woman named Eusebia Morales who died four months after the recording \u2014 and realized she has been doing this work alone for eleven years and has no plan for what happens to it when she dies.",
"voice": "Victoria"
},
{
"name": "Florentino",
"situation": "Florentino is a true believer and he is calling dead serious. For the past seven months he has been logging something that happens on the Rio Grande in a specific quarter-mile stretch of the river inside the lower canyons, always between 2am and 4am, always on nights with no moon: a sound he describes as structured, repeating, and not animal. Not the canyon acoustics he has known for nineteen years. Not a boat. A low, rhythmic tonal sequence \u2014 four notes, always the same interval, fading upstream \u2014 that he has now recorded on a waterproof field recorder on six separate occasions. He has ruled out every natural explanation he can generate. He has not told his employer. He has not posted it online. He has played the recordings to two people \u2014 a sound engineer in Alpine who said it was 'interesting' and changed the subject, and his cousin who told him to stop camping alone. He believes the sound is coming from inside the canyon walls.",
"voice": "Arjun"
},
{
"name": "Mackley",
"situation": "Mackley's dilemma is five days old and involves a decision he made in about four seconds that he cannot stop replaying. He was working a Saturday night gas station shift when a woman came in visibly shaken \u2014 late thirties, out-of-state plates, would not make eye contact \u2014 and asked him quietly if she could use the phone because hers was dead. He let her use the store phone. She called someone, spoke in a low voice for about ninety seconds, and hung up. She bought a bottle of water and left. Forty minutes later a man came in \u2014 same approximate age, agitated, asked Mackley if a woman had been in. Mackley said no. The man left. Three days later Mackley saw a missing-persons flyer in the Alpine post office for a woman matching the description \u2014 reported missing by her husband, who is offering a reward. The flyer shows a phone number to call. Mackley is now sitting with the question of whether the woman he helped was someone running from something or someone who is actually missing and in danger, and whether his lie to the man \u2014 which felt right in the moment \u2014 may have contributed to either outcome.",
"voice": "Ethan"
}
]
},
{
"timestamp": 1785828045.742269,
"lineup": [
{
"name": "Breckenridge",
"situation": "Breckenridge has been keeping a secret from his supervisor at McDonald Observatory for eleven weeks. During a routine maintenance check on one of the Hobby-Eberly Telescope's secondary systems, he made an adjustment that was not in his work order \u2014 a small calibration tweak he was confident about \u2014 and two days later a scheduled research observation run produced corrupted data that cost a visiting doctoral team from UT Austin their entire week's work and forced them to reschedule months out. Nobody has connected the data loss to his unauthorized adjustment. The official finding blamed an atmospheric interference anomaly. He has let that finding stand.",
"voice": "Victor"
},
{
"name": "Trudie",
"situation": "Trudie is not calling about a crisis. She is calling because this evening, while closing up the gallery, she discovered that a piece of art she has walked past every workday for two years \u2014 a small, seemingly blank white canvas in the back corner that she assumed was an unfinished or stored work \u2014 is actually a Donald Judd work that has been hanging there unlit, unlabeled, and unnoticed by everyone including the gallery's owner, who inherited the space and has never fully inventoried it. She found this out because a Chinati Foundation curator came in tonight for an unrelated errand, glanced at it while waiting, and said, very quietly, 'Where did that come from.'",
"voice": "Pippa"
},
{
"name": "Aurelio",
"situation": "Aurelio's dilemma has been building for two years and came to a head this past Sunday. His daughter, Celeste, thirty-three, is an immigration attorney in El Paso. She has, without telling him, been using his ranch's southeast pasture road \u2014 a private caliche track that runs close to the river \u2014 as part of a route she helps asylum-seeking families navigate to reach a designated port of entry legally. She was not sneaking people across; she was guiding them to Presidio's legal crossing. But she used his land without his permission. He found out Sunday when a Border Patrol agent he has known for twenty years stopped by and mentioned, carefully, that they'd been seeing foot traffic on that road and asked if Aurelio had authorized any humanitarian organization's access. Aurelio said no. He doesn't know if that answer protected Celeste or exposed her.",
"voice": "Edward"
},
{
"name": "Jinx",
"situation": "Jinx is not calling about a crisis. She is calling because she has spent the last fourteen months obsessively studying the black bear population recolonizing the Davis Mountains, and tonight, during a camera trap check on a ranch north of Alpine, she retrieved footage that she believes shows two black bears engaging in play behavior with a third animal she cannot identify from the footage \u2014 not a bear, not a mountain lion, not a javelina, not a coyote. The shape and gait are wrong for everything native. She has watched the forty-eight-second clip approximately thirty times tonight.",
"voice": "Avery"
},
{
"name": "Parnell",
"situation": "Parnell's dilemma is eleven days old. He was doing a plumbing repair job at a rental property on the edge of Marathon \u2014 a small adobe that's been rented to the same quiet tenant for four years \u2014 and found, while accessing a crawl space beneath the bathroom, a locked steel box bolted to the foundation. He is a retired federal postal inspector. He knows what hidden locked boxes mean and what they don't mean. He did not open it. He finished the job. But he cannot stop thinking about it. The tenant \u2014 a man named Reed, early fifties, polite, pays cash through a property manager \u2014 has never given Parnell any concrete reason for suspicion beyond the box itself. The property manager says Reed is never any trouble.",
"voice": "Graham"
},
{
"name": "Twyla",
"situation": "Twyla's dilemma is about her ex-boyfriend, Cade, who is not an ex-boyfriend in the ordinary sense \u2014 they dated for six weeks four years ago and it ended cleanly. Cade is now her closest male friend and they guide trips together regularly. Three weeks ago Cade told Twyla, in confidence, that he has been diagnosed with early-onset Parkinson's. He is thirty-nine. He has not told anyone else in Terlingua, including their mutual employer, the rafting outfitter. Last week Twyla watched Cade's hand shake badly enough during a Class III rapid on the Rio Grande that a client noticed and asked if he was okay. Cade said he'd had too much coffee. Twyla said nothing. She does not know how long she can keep saying nothing while clients are in his raft.",
"voice": "Nadia"
},
{
"name": "Herschel",
"situation": "Herschel is a true believer and he is calling dead serious. For the past two years he has been compiling evidence that the Comanche Springs \u2014 the natural springs that once made Fort Stockton a vital water stop and were declared legally dead in 1961 when overpumping from irrigation wells dried them up \u2014 are still flowing. Underground. He believes the springs did not die; they were diverted by a combination of agricultural pumping and, he is now convinced, a deliberate 1950s-era channeling project conducted by a consortium of Pecos County landowners who wanted the water routed to private cisterns rather than the public spring pool. He has deed records, pump logs from the county clerk's archive, and a hand-drawn map from 1953 he found in a box of Annie Riggs Museum donations.",
"voice": "Dennis"
},
{
"name": "Salome",
"situation": "Salome's dilemma is six days old and involves her professor, Dr. Edmunds \u2014 a tenured history faculty member at Sul Ross she genuinely admires \u2014 and a paper she submitted for his upper-division Texas borderlands course. The paper was entirely her own work. But she used a research methodology she developed by closely following a framework from a graduate thesis she found in the Sul Ross library archives \u2014 a 1987 thesis by a student named M. Castillo. The thesis is unpublished and uncatalogued in any digital system. She cited it in her bibliography. Dr. Edmunds called her in last Thursday and told her, with evident discomfort, that the methodology section of her paper was 'derivative to a degree that concerns him.' He did not say plagiarism. He said he needed to think about how to proceed. She has not heard from him since.",
"voice": "Julia"
},
{
"name": "Dobe",
"situation": "Dobe is not calling about a crisis. He is calling because he has spent the last four years, on every single westbound run on Highway 90, stopping at the same pullout between Marathon and Alpine to watch the pronghorn antelope that live in the grasslands north of the road. He has become, without any formal training, an obsessive amateur naturalist focused entirely on this one pronghorn herd. He knows individual animals by sight. He has named them. He has logged their movements, seasonal patterns, and herd composition in a series of spiral notebooks he keeps in his cab. Tonight he stopped at the pullout and counted a herd size he has never seen before \u2014 thirty-one animals \u2014 and he needs to tell somebody.",
"voice": "Brian"
},
{
"name": "Cassander",
"situation": "Cassander's dilemma is seven months old and has been slowly poisoning his sleep. He was hired to do the electrical rough-in on a major renovation of a historic adobe property on the edge of Marfa \u2014 a project backed by an out-of-town buyer who is converting it into a high-end short-term rental. During the work, Cassander found, inside a sealed wall cavity, a tin box containing what appears to be a handwritten ledger and several photographs dating to approximately the 1940s. The ledger records, in careful columns, what Cassander \u2014 after considerable research \u2014 believes are payments made to a Presidio County sheriff's deputy over several years, consistent with protection payments for a smuggling operation across the Rio Grande. The names in the ledger include surnames still prominent in Marfa and Presidio County today.",
"voice": "Callum"
}
]
}
]
}
+3
View File
@@ -0,0 +1,3 @@
{
"emails": []
}
+928
View File
@@ -0,0 +1,928 @@
{
"lookup_history": [
{
"question": "(interjection)",
"answer": "I just looked it up, and a lot of the prevention tips involve things like etching your converter with your V I N, or even installing anti-theft devices specifically designed to make them harder to rem",
"tools_used": [
"web_search"
],
"timestamp": 1785826843.886055
},
{
"question": "(interjection)",
"answer": "I just looked it up, and while the numbers are down from their peak in twenty twenty two, there were still over twenty six,000 catalytic converter thefts in the first half of twenty twenty three alone",
"tools_used": [
"web_search"
],
"timestamp": 1785826893.988194
},
{
"question": "(interjection)",
"answer": "I just looked it up, and while the peak was in twenty twenty two, there were still over fourteen,000 catalytic converter thefts in twenty twenty four, and the average insurance claim payout rose to tw",
"tools_used": [
"web_search"
],
"timestamp": 1785826927.249883
},
{
"question": "(interjection)",
"answer": "It's pretty wild, I just looked it up, and rhodium, platinum, and palladium can actually be worth more per gram than gold.",
"tools_used": [
"web_search"
],
"timestamp": 1785826964.304747
},
{
"question": "(interjection)",
"answer": "Rhodium is actually a very rare noble metal and a member of the platinum group, and it's also used for enhancing jewelry, which is pretty interesting for such an industrial-sounding element.",
"tools_used": [
"wikipedia_lookup"
],
"timestamp": 1785827014.911605
},
{
"question": "(interjection)",
"answer": "Yeah, it's super rare, but only a tiny amount is needed per catalytic converter, and with millions of cars, it adds up to a lot of demand.",
"tools_used": [
"wikipedia_lookup"
],
"timestamp": 1785827062.0185962
},
{
"question": "(interjection)",
"answer": "I just looked it up on Wikipedia, and rhodium is a very rare, silvery-white, hard, and corrosion-resistant transition metal, and it's considered one of the rarest and most valuable precious metals.",
"tools_used": [
"wikipedia_lookup"
],
"timestamp": 1785827081.698578
},
{
"question": "(interjection)",
"answer": "It's actually down to four hundred ten people as of the twenty twenty census, so even fewer people than you'd think.",
"tools_used": [
"wikipedia_lookup"
],
"timestamp": 1785827128.468648
},
{
"question": "(interjection)",
"answer": "It's actually down to four hundred ten people as of the twenty twenty census, so even fewer people than you'd think.",
"tools_used": [
"wikipedia_lookup"
],
"timestamp": 1785827145.076851
},
{
"question": "(interjection)",
"answer": "It's actually down to four hundred ten people as of the twenty twenty census, so even fewer people than you'd think.",
"tools_used": [
"wikipedia_lookup"
],
"timestamp": 1785827206.8894
},
{
"question": "(interjection)",
"answer": "Wait, I just looked up Marathon, Texas, and the population was actually four hundred ten in the twenty twenty census, so it's even smaller than you thought.",
"tools_used": [
"wikipedia_lookup"
],
"timestamp": 1785827223.5801878
},
{
"question": "(interjection)",
"answer": "I just looked up rhodium again, and it's actually one of the rarest and most valuable precious metals, even more so than gold or platinum.",
"tools_used": [
"wikipedia_lookup"
],
"timestamp": 1785827272.232238
},
{
"question": "(interjection)",
"answer": "Wait, I just looked up the Gage Hotel, and they actually have twenty seven acres of gardens with trees and plants, so there would definitely be leaves to rake.",
"tools_used": [
"web_search"
],
"timestamp": 1785827324.127794
},
{
"question": "(interjection)",
"answer": "Wait, so I just looked up the Marfa lights on Wikipedia and it says scientists think it's mostly car headlights distorted by warm desert air.",
"tools_used": [
"wikipedia_lookup"
],
"timestamp": 1785827452.788874
},
{
"question": "(interjection)",
"answer": "I just looked up other theories about the Marfa lights, and besides car headlights, people also suggest ball lightning, swamp gas, or even aliens.",
"tools_used": [
"web_search"
],
"timestamp": 1785827485.3444
},
{
"question": "(interjection)",
"answer": "So, I just looked up that U T D study and it was done by physics students in two thousand four and two thousand five, and they actually concluded the lights were likely car headlights from a highway a",
"tools_used": [
"web_search"
],
"timestamp": 1785827517.798142
},
{
"question": "(interjection)",
"answer": "Yeah, so the Wikipedia article also says the lights are most often seen from a specific viewing area that the community has publicized to encourage tourism.",
"tools_used": [
"wikipedia_lookup"
],
"timestamp": 1785827579.624702
},
{
"question": "(interjection)",
"answer": "The Chinati Mountains, where the lights were seen, have a peak elevation of 7,seven hundred twenty eight feet, which is pretty substantial.",
"tools_used": [
"web_search"
],
"timestamp": 1785827598.3226361
},
{
"question": "(interjection)",
"answer": "So, I just looked up Mitchell Flat, and it's actually described as where the Marfa Lights are most often seen, and it's known for being incredibly flat.",
"tools_used": [
"wikipedia_lookup",
"web_search"
],
"timestamp": 1785828035.960554
},
{
"question": "(interjection)",
"answer": "The Wikipedia article also mentions that the Marfa lights are described as an \"optical phenomenon\" and that scientists concluded they were car headlights distorted by warm desert air.",
"tools_used": [
"wikipedia_lookup"
],
"timestamp": 1785828097.7092211
},
{
"question": "(interjection)",
"answer": "I'm seeing that the Wikipedia article mentions scientists concluded the lights were car headlights, but also that they're described as an \"optical phenomenon.",
"tools_used": [
"wikipedia_lookup"
],
"timestamp": 1785828133.214231
},
{
"question": "(interjection)",
"answer": "So, I just looked up plasma, and it's actually the most common state of matter in the universe, making up 99.9 percent of all ordinary matter. Stars are basically pure plasma.",
"tools_used": [
"wikipedia_lookup"
],
"timestamp": 1785828195.82052
},
{
"question": "(interjection)",
"answer": "So, I just looked up plasma, and it's actually the most common state of matter in the universe, making up 99.9 percent of all ordinary matter. Stars are almost pure balls of plasma, and plasma dominat",
"tools_used": [
"wikipedia_lookup"
],
"timestamp": 1785828257.351542
},
{
"question": "(interjection)",
"answer": "The University of Texas at Dallas study on the Marfa Lights is actually from two thousand four, and it concluded that car headlights on Highway sixty seven were the cause, but it also mentioned atmosp",
"tools_used": [
"web_search"
],
"timestamp": 1785828305.564495
},
{
"question": "(interjection)",
"answer": "So, the Chinati Mountains are actually near Presidio, Texas, and there's a pass through them called Pinto Canyon Road.",
"tools_used": [
"wikipedia_lookup"
],
"timestamp": 1785828322.57601
},
{
"question": "(interjection)",
"answer": "I'm seeing that the Wikipedia article mentions scientists concluded the lights were car headlights, but also that they're described as an \"optical phenomenon.",
"tools_used": [
"wikipedia_lookup"
],
"timestamp": 1785828384.944525
},
{
"question": "(interjection)",
"answer": "The Wikipedia article also mentions that the Marfa lights are an optical phenomenon regularly observed near Marfa, Texas, and that the community has publicized a viewing area to encourage tourism.",
"tools_used": [
"wikipedia_lookup"
],
"timestamp": 1785828418.451157
},
{
"question": "(interjection)",
"answer": "That's wild that they've been reported since the 1880s, before cars were even around, which totally debunks that theory about headlights.",
"tools_used": [
"wikipedia_lookup"
],
"timestamp": 1785828469.318658
},
{
"question": "(interjection)",
"answer": "It's interesting that there are so many different theories about the Marfa lights, from scientific explanations to more supernatural ideas.",
"tools_used": [
"web_search"
],
"timestamp": 1785828501.896791
},
{
"question": "(interjection)",
"answer": "I'm seeing that accounts of the Marfa Lights often describe them as floating orbs\u2014white, yellow, or orange\u2014drifting or flickering, and sometimes multiple lights are seen simultaneously.",
"tools_used": [
"web_search"
],
"timestamp": 1785828537.8336582
},
{
"question": "(interjection)",
"answer": "That's wild, I'm seeing that some accounts of the Marfa Lights mention them splitting apart, merging, or darting erratically, and that they can be white, yellow, orange, or even red.",
"tools_used": [
"web_search"
],
"timestamp": 1785828571.164865
},
{
"question": "(interjection)",
"answer": "I just looked it up, and the \"plasma vortex\" idea is often linked to the ball lightning theory, which is one of the leading scientific explanations for the Marfa Lights.",
"tools_used": [
"web_search"
],
"timestamp": 1785828588.682946
},
{
"question": "(interjection)",
"answer": "I just looked it up, and while the \"plasma vortex\" is a theory, other scientific explanations include atmospheric refraction, piezoelectric effects from tectonic stress, and even bioluminescent animal",
"tools_used": [
"web_search"
],
"timestamp": 1785828606.138984
},
{
"question": "(interjection)",
"answer": "It's cool that there's an official viewing area nine miles east of Marfa on Highway ninety, so people can actually go and try to see these things.",
"tools_used": [
"web_search"
],
"timestamp": 1785828623.652162
},
{
"question": "(interjection)",
"answer": "That's a good question, because most bioluminescent animals are marine creatures or insects like fireflies, which aren't typically found in deserts.",
"tools_used": [
"web_search"
],
"timestamp": 1785828656.054026
},
{
"question": "(interjection)",
"answer": "Yeah, I'm seeing that most bioluminescent animals are marine creatures or insects like fireflies, and there aren't many known bioluminescent desert animals.",
"tools_used": [
"web_search"
],
"timestamp": 1785828674.141393
},
{
"question": "(interjection)",
"answer": "So, I just looked it up, and there are actually over forty five species of bioluminescent beetles, including fireflies, across Texas, and some click beetles also exhibit bioluminescence in warm desert",
"tools_used": [
"web_search"
],
"timestamp": 1785828708.354514
},
{
"question": "(interjection)",
"answer": "Wait, I just looked this up, and apparently, the most common scorpion in Texas, the Striped Bark Scorpion, actually glows under U V light, but it's not bioluminescent in the way we're talking about.",
"tools_used": [
"web_search"
],
"timestamp": 1785828729.464768
},
{
"question": "(interjection)",
"answer": "Wait, I just looked this up, and there are actually over forty-five species of bioluminescent beetles, including fireflies, across Texas, and some click beetles also glow.",
"tools_used": [
"web_search"
],
"timestamp": 1785828746.4883661
},
{
"question": "(interjection)",
"answer": "So, I just looked this up, and apparently, there are also bioluminescent click beetles in Texas, and some of them are found in warm deserts.",
"tools_used": [
"web_search"
],
"timestamp": 1785828797.722466
},
{
"question": "(interjection)",
"answer": "It's funny you mention Marfa, because a study from two thousand to two thousand eight concluded that the famous Marfa lights were actually just car headlights distorted by warm desert air.",
"tools_used": [
"wikipedia_lookup"
],
"timestamp": 1785828845.140897
},
{
"question": "(interjection)",
"answer": "Well, I just looked up the Marfa lights on Wikipedia, and it says a study from two thousand to two thousand eight concluded they were actually just car headlights being distorted by warm desert air.",
"tools_used": [
"wikipedia_lookup"
],
"timestamp": 1785828863.2232692
},
{
"question": "(interjection)",
"answer": "Yeah, so I just looked up Marfa lights on Wikipedia, and it says a study from two thousand to two thousand eight concluded they were actually just car headlights being distorted by warm desert air.",
"tools_used": [
"wikipedia_lookup"
],
"timestamp": 1785828911.314851
},
{
"question": "(interjection)",
"answer": "So, I just looked up Marfa lights on Wikipedia, and it says a study from two thousand to two thousand eight concluded they were actually just car headlights being distorted by warm desert air.",
"tools_used": [
"wikipedia_lookup"
],
"timestamp": 1785828928.774646
},
{
"question": "(interjection)",
"answer": "That's interesting about the Chinati Mountains, because I just looked it up and some people think the name comes from an Apache word meaning \"gate\" or \"mountain pass.",
"tools_used": [
"wikipedia_lookup"
],
"timestamp": 1785828981.5359628
},
{
"question": "(interjection)",
"answer": "That's wild, I just looked up \"rhodium mining extraterrestrials\" and apparently, there are theories about asteroids being rich in platinum group metals like rhodium, making them potential targets for ",
"tools_used": [
"web_search"
],
"timestamp": 1785829089.7105548
},
{
"question": "(interjection)",
"answer": "Rhodium is one of the rarest and most valuable precious metals, and it's used for enhancing jewelry.",
"tools_used": [
"wikipedia_lookup"
],
"timestamp": 1785829106.427058
},
{
"question": "(interjection)",
"answer": "That's interesting about the Chinati Mountains, because I just looked it up and some people think the name comes from an Apache word meaning \"gate\" or \"mountain pass.",
"tools_used": [
"wikipedia_lookup"
],
"timestamp": 1785829138.475531
},
{
"question": "(interjection)",
"answer": "It's wild how common these meth pipelines are, I'm seeing headlines about busts from San Antonio to the Capital Region, and even Little Rock.",
"tools_used": [
"web_search"
],
"timestamp": 1785829379.4757051
},
{
"question": "(interjection)",
"answer": "It's wild how common these meth pipelines are, I'm seeing headlines about busts from San Antonio to the Capital Region, and even Little Rock.",
"tools_used": [
"web_search"
],
"timestamp": 1785829396.890264
},
{
"question": "(interjection)",
"answer": "Yeah, it's crazy how much meth is coming across the border. I just",
"tools_used": [
"web_search"
],
"timestamp": 1785829467.9567099
},
{
"question": "(interjection)",
"answer": "It's interesting how much dash cam footage is coming up now in discussions about police integrity. it seems like it's becoming a key piece of evidence in a lot of situations.",
"tools_used": [
"web_search"
],
"timestamp": 1785829557.185249
},
{
"question": "(interjection)",
"answer": "Yeah, I just looked it up, and unfortunately, there are quite a few cases",
"tools_used": [
"web_search"
],
"timestamp": 1785829640.7913432
},
{
"question": "(interjection)",
"answer": "So, I just looked up police dash cam purge schedules, and it really varies a lot by department. some keep footage for months, others much less, but twenty four days is definitely within the normal ran",
"tools_used": [
"web_search"
],
"timestamp": 1785829786.899904
},
{
"question": "(interjection)",
"answer": "Wait, Sul Ross State University, that's in Alpine, Texas, right? It's a public university and the main one serving the Big Bend region.",
"tools_used": [
"wikipedia_lookup"
],
"timestamp": 1785829874.400875
},
{
"question": "(interjection)",
"answer": "Alpine, Texas, where the caller's from, is also home to Sul Ross State University, and it's considered the center of the whole Big Bend area.",
"tools_used": [
"wikipedia_lookup"
],
"timestamp": 1785829984.276145
},
{
"question": "(interjection)",
"answer": "So, I just looked up oilfield logistics salaries in Midland, and while it varies a lot, the average is around 50000 dollars to 70000 dollars, but some jobs pay way more, like over 100000 dollars for o",
"tools_used": [
"web_search"
],
"timestamp": 1785830017.110743
},
{
"question": "(interjection)",
"answer": "I just looked up Alpine, Texas, and it's considered the commercial center of the whole Big Bend region, which is pretty vast at twelve,000 square miles.",
"tools_used": [
"wikipedia_lookup"
],
"timestamp": 1785830051.735072
},
{
"question": "(interjection)",
"answer": "It sounds like this isn't an uncommon way for agricultural businesses to get scammed, with fake invoices and altered orders.",
"tools_used": [
"web_search"
],
"timestamp": 1785830117.232788
},
{
"question": "(interjection)",
"answer": "It's actually pretty common for employee fraud, especially things like check tampering or cash larceny, to be twice as frequent in small businesses compared to larger ones.",
"tools_used": [
"web_search"
],
"timestamp": 1785830172.486618
},
{
"question": "(interjection)",
"answer": "It's a tough situation, and I just looked up some stats: employee theft costs United States businesses anywhere from twenty dollars to fifty dollars billion a year, and 75 percent of employees have ad",
"tools_used": [
"web_search"
],
"timestamp": 1785830196.3361979
},
{
"question": "(interjection)",
"answer": "This is tough, but I just looked up common reasons for employee theft, and it often comes down to financial pressure, opportunity, or rationalization.",
"tools_used": [
"web_search"
],
"timestamp": 1785830263.81376
},
{
"question": "(interjection)",
"answer": "So, I just looked this up, and research suggests kids remember family vacations between ages 5 and ten more vividly than birthdays.",
"tools_used": [
"web_search"
],
"timestamp": 1785830296.9526458
},
{
"question": "(interjection)",
"answer": "That's actually really interesting, I just looked it up and studies show that kids between 3 and 5 often believe the party itself causes their birthday, which is a wild concept.",
"tools_used": [
"web_search"
],
"timestamp": 1785830316.129567
},
{
"question": "(interjection)",
"answer": "So, I just looked this up, and apparently wild donkeys and horses dig wells that provide water for other desert animals, and elephants have mental maps of water sources.",
"tools_used": [
"web_search"
],
"timestamp": 1785830988.662511
},
{
"question": "(interjection)",
"answer": "That's actually really interesting, I just looked it up and while there are government",
"tools_used": [
"web_search"
],
"timestamp": 1785831025.150166
},
{
"question": "(interjection)",
"answer": "So, I just looked this up, and while there are government resources for land and parcel data in Texas, the Texas Geographic Information Office specifically mentions that the purpose of their initiativ",
"tools_used": [
"web_search"
],
"timestamp": 1785831081.0623941
},
{
"question": "(interjection)",
"answer": "So, I just looked this up, and caliche is a sedimentary rock formed in arid regions that's often used as a base for roads, which explains why it's out in the middle of nowhere.",
"tools_used": [
"web_search"
],
"timestamp": 1785831114.710515
},
{
"question": "(interjection)",
"answer": "So, I just looked this up, and the Marathon Basin in Texas, where the caller saw the rangefinder, relies heavily on two municipal wells for its water supply, according to a U S Geological Survey repor",
"tools_used": [
"web_search"
],
"timestamp": 1785831172.441345
},
{
"question": "(interjection)",
"answer": "So, I just looked this up, and Sanderson, Texas is known as the \"Cactus Capital of Texas\" because it's at the convergence of three ecological zones.",
"tools_used": [
"web_search"
],
"timestamp": 1785831273.021059
},
{
"question": "(interjection)",
"answer": "Wait, so I just looked this up, and in Texas, surface water is actually publicly owned by the state, even if it's on private land, which means anyone could potentially be monitoring it.",
"tools_used": [
"web_search"
],
"timestamp": 1785831309.009842
},
{
"question": "(interjection)",
"answer": "So, I just looked this up, and Texas has pretty strong private property rights, and landowners can definitely take action against trespassers, especially if they've posted proper notice.",
"tools_used": [
"web_search"
],
"timestamp": 1785831352.628969
},
{
"question": "(interjection)",
"answer": "So, I just looked this up, and the \"rule of capture\" in Texas groundwater law essentially means if you can pump it, you own it, which is why it's sometimes called the \"law of the biggest pump.",
"tools_used": [
"wikipedia_lookup",
"web_search"
],
"timestamp": 1785831396.294291
},
{
"question": "(interjection)",
"answer": "So, I just looked this up, and prior appropriation in Texas means \"first in time, first in right,\" so older water rights get priority over newer ones, especially when water is scarce.",
"tools_used": [
"web_search"
],
"timestamp": 1785831453.387682
},
{
"question": "(interjection)",
"answer": "So, I just looked this up, and there's actually a Texas Water Bank and Trust that allows for the acquisition of water rights, and the Texas Water Development Board has a registry of buyers.",
"tools_used": [
"web_search"
],
"timestamp": 1785831501.522455
},
{
"question": "(interjection)",
"answer": "It's interesting, I just looked this up, and there's a Dallas-based company that recently sued an East Texas water district to develop high-capacity water wells, which sounds a lot like what the calle",
"tools_used": [
"web_search"
],
"timestamp": 1785831543.98968
},
{
"question": "(interjection)",
"answer": "So, I just looked this up, and there were a lot of significant water rights cases and rulings around two thousand nine-twenty twelve in Texas, many of them dealing with landowner rights to groundwater",
"tools_used": [
"web_search"
],
"timestamp": 1785831580.055219
},
{
"question": "(interjection)",
"answer": "You know, I just looked up Brewster County water issues and there's a lot of stuff about the Rio Grande disappearing and disputes over pipelines, so this caller is definitely hitting on a real concern",
"tools_used": [
"web_search"
],
"timestamp": 1785831619.753919
},
{
"question": "(interjection)",
"answer": "There are actually a good number of law firms in Texas that specialize in water rights, so hopefully, someone with that specific expertise is listening.",
"tools_used": [
"web_search"
],
"timestamp": 1785831645.18117
},
{
"question": "(interjection)",
"answer": "This is serious, I just looked it up and research misconduct can lead to federal debarment, grant termination, and even retraction of published work, which would really mess up a career.",
"tools_used": [
"web_search"
],
"timestamp": 1785831729.6552792
},
{
"question": "(interjection)",
"answer": "Wait",
"tools_used": [
"wikipedia_lookup"
],
"timestamp": 1785831769.424918
},
{
"question": "(interjection)",
"answer": "Mule deer are actually named that because their ears are big, like a mule's!",
"tools_used": [
"wikipedia_lookup"
],
"timestamp": 1785831812.701477
},
{
"question": "(interjection)",
"answer": "So, I just looked this up, and there can be consequences for research misconduct at conferences, like being banned from future events, and it definitely falls under the federal definition of research ",
"tools_used": [
"web_search"
],
"timestamp": 1785831900.550103
},
{
"question": "(interjection)",
"answer": "I just looked this up, and plagiarism at academic conferences can lead to serious professional consequences, not just academic ones.",
"tools_used": [
"web_search"
],
"timestamp": 1785831965.298838
},
{
"question": "(interjection)",
"answer": "The Trans-Pecos region is actually part of the Chihuahuan Desert, and it's known for being the most mountainous and arid part of Texas.",
"tools_used": [
"wikipedia_lookup"
],
"timestamp": 1785832034.569338
},
{
"question": "(interjection)",
"answer": "There are tons of academic conferences in November, so it makes sense that he'd be worried about her presenting there, especially if it's a big one.",
"tools_used": [
"web_search"
],
"timestamp": 1785832217.653148
},
{
"question": "(interjection)",
"answer": "So, a postdoc supervisor at a big",
"tools_used": [
"web_search"
],
"timestamp": 1785832248.6387148
},
{
"question": "(interjection)",
"answer": "So, I just looked this up, and Texas A and M has a whole Office of Postdoctoral Affairs that advocates for postdocs and even has a research compliance manager, so they definitely take research integri",
"tools_used": [
"web_search"
],
"timestamp": 1785832323.630293
},
{
"question": "(interjection)",
"answer": "Wait, so I just looked up \"aluminum boxes\" and there are a ton of different kinds, from storage boxes for home to heavy-duty transport containers.",
"tools_used": [
"web_search"
],
"timestamp": 1785832400.2359738
},
{
"question": "(interjection)",
"answer": "Wait, so Donald Judd is considered the leading international exponent of \"minimalism\" and he wrote this essay called \"Specific Objects\" where he argued for a \"rigorously democratic presentation withou",
"tools_used": [
"wikipedia_lookup"
],
"timestamp": 1785832425.40215
},
{
"question": "(interjection)",
"answer": "It's wild how much Donald Judd's work is tied to Marfa. he actually moved there permanently in the early 1970s and bought up a bunch of land and buildings to install his art.",
"tools_used": [
"wikipedia_lookup"
],
"timestamp": 1785832470.6274052
},
{
"question": "(interjection)",
"answer": "I just looked up the Chinati Foundation, and it's actually an art museum based on Donald Judd's ideas, which explains why his work is so central to it.",
"tools_used": [
"wikipedia_lookup"
],
"timestamp": 1785832512.547478
},
{
"question": "(interjection)",
"answer": "It's interesting because Judd actually wrote a lot about how he didn't like the term \"minimalism\" and preferred \"specific objects\" to describe his art.",
"tools_used": [
"wikipedia_lookup"
],
"timestamp": 1785832529.594274
},
{
"question": "(interjection)",
"answer": "He actually really disliked the term \"minimalism\" and preferred to call his work \"specific objects\" because he felt it was more accurate.",
"tools_used": [
"wikipedia_lookup"
],
"timestamp": 1785832577.829215
},
{
"question": "(interjection)",
"answer": "I just looked up Donald Judd's \"Specific Objects\" essay, and he actually argued for a \"rigorously democratic presentation without compositional hierarchy,\" which really fits with what the caller is sa",
"tools_used": [
"wikipedia_lookup"
],
"timestamp": 1785832620.3931022
},
{
"question": "(interjection)",
"answer": "That's interesting, because Judd actually said in an Arts Yearbook that \"The new three dimensional work doesn't constitute a movement, school, or style. The common aspects are too general and too litt",
"tools_used": [
"wikipedia_lookup"
],
"timestamp": 1785832639.370713
},
{
"question": "(interjection)",
"answer": "You know, it's actually 3 37 am right now, so Luke's even more off than he thought!",
"tools_used": [
"get_current_time"
],
"timestamp": 1785832655.678344
},
{
"question": "(interjection)",
"answer": "I was just reading about Judd, and he actually said that the \"new three-dimensional work doesn't constitute a movement, school, or style,\" even though he's considered the leading exponent of minimalis",
"tools_used": [
"wikipedia_lookup"
],
"timestamp": 1785832777.890113
},
{
"question": "(interjection)",
"answer": "Yeah, Judd actually wrote a whole essay in nineteen sixty four called \"Specific Objects\" where he laid out why he felt his work went beyond just painting or sculpture.",
"tools_used": [
"web_search"
],
"timestamp": 1785832809.734914
},
{
"question": "Yeah, there were a couple interesting things in there. I'm sort of more intrigued about the Marfa lights now. I have been for a while. I've never stopped there because I always see like 20 cars in the parking lot, and it's just, I don't want to stop with all of that. But I do want to see the Marfa lights and what that's all about.",
"answer": "Yeah, the Marfa lights have been around since at least one thousand eight hundred eighty three when this cowhand first wrote about seeing them out near Paisano Pass. I get the crowd thing though, it's",
"tools_used": [
"web_search"
],
"timestamp": 1785832880.349965
}
],
"conversation_history": [
{
"role": "assistant",
"content": "So, I just looked this up, and there were a lot of significant water rights cases and rulings around two thousand nine-twenty twelve in Texas, many of them dealing with landowner rights to groundwater."
},
{
"role": "assistant",
"content": "You know, I just looked up Brewster County water issues and there's a lot of stuff about the Rio Grande disappearing and disputes over pipelines, so this caller is definitely hitting on a real concern for the area."
},
{
"role": "assistant",
"content": "There are actually a good number of law firms in Texas that specialize in water rights, so hopefully, someone with that specific expertise is listening."
},
{
"role": "assistant",
"content": "This is serious, I just looked it up and research misconduct can lead to federal debarment, grant termination, and even retraction of published work, which would really mess up a career."
},
{
"role": "assistant",
"content": "Wait"
},
{
"role": "assistant",
"content": "Mule deer are actually named that because their ears are big, like a mule's!"
},
{
"role": "assistant",
"content": "So, I just looked this up, and there can be consequences for research misconduct at conferences, like being banned from future events, and it definitely falls under the federal definition of research misconduct."
},
{
"role": "assistant",
"content": "I just looked this up, and plagiarism at academic conferences can lead to serious professional consequences, not just academic ones."
},
{
"role": "assistant",
"content": "The Trans-Pecos region is actually part of the Chihuahuan Desert, and it's known for being the most mountainous and arid part of Texas."
},
{
"role": "assistant",
"content": "There are tons of academic conferences in November, so it makes sense that he'd be worried about her presenting there, especially if it's a big one."
},
{
"role": "assistant",
"content": "So, a postdoc supervisor at a big"
},
{
"role": "assistant",
"content": "So, I just looked this up, and Texas A and M has a whole Office of Postdoctoral Affairs that advocates for postdocs and even has a research compliance manager, so they definitely take research integrity seriously there."
},
{
"role": "assistant",
"content": "Wait, so I just looked up \"aluminum boxes\" and there are a ton of different kinds, from storage boxes for home to heavy-duty transport containers."
},
{
"role": "assistant",
"content": "Wait, so Donald Judd is considered the leading international exponent of \"minimalism\" and he wrote this essay called \"Specific Objects\" where he argued for a \"rigorously democratic presentation without compositional hierarchy."
},
{
"role": "assistant",
"content": "It's wild how much Donald Judd's work is tied to Marfa. he actually moved there permanently in the early 1970s and bought up a bunch of land and buildings to install his art."
},
{
"role": "assistant",
"content": "I just looked up the Chinati Foundation, and it's actually an art museum based on Donald Judd's ideas, which explains why his work is so central to it."
},
{
"role": "assistant",
"content": "It's interesting because Judd actually wrote a lot about how he didn't like the term \"minimalism\" and preferred \"specific objects\" to describe his art."
},
{
"role": "assistant",
"content": "He actually really disliked the term \"minimalism\" and preferred to call his work \"specific objects\" because he felt it was more accurate."
},
{
"role": "assistant",
"content": "I just looked up Donald Judd's \"Specific Objects\" essay, and he actually argued for a \"rigorously democratic presentation without compositional hierarchy,\" which really fits with what the caller is saying about the art just waiting for you."
},
{
"role": "assistant",
"content": "That's interesting, because Judd actually said in an Arts Yearbook that \"The new three dimensional work doesn't constitute a movement, school, or style. The common aspects are too general and too little common to define a movement. The differences are greater than the similarities."
},
{
"role": "assistant",
"content": "You know, it's actually 3 37 am right now, so Luke's even more off than he thought!"
},
{
"role": "assistant",
"content": "I was just reading about Judd, and he actually said that the \"new three-dimensional work doesn't constitute a movement, school, or style,\" even though he's considered the leading exponent of minimalism."
},
{
"role": "assistant",
"content": "Yeah, Judd actually wrote a whole essay in nineteen sixty four called \"Specific Objects\" where he laid out why he felt his work went beyond just painting or sculpture."
},
{
"role": "user",
"content": "What do you think of tonight's show, Devon? How do we do?"
},
{
"role": "assistant",
"content": "I thought the show was solid tonight. The Judd discussion really landed, and you kept it moving without getting too bogged down in the theory. I don't know, I feel like we actually hit something interesting there for once."
},
{
"role": "user",
"content": "Yeah, there were a couple interesting things in there. I'm sort of more intrigued about the Marfa lights now. I have been for a while. I've never stopped there because I always see like 20 cars in the parking lot, and it's just, I don't want to stop with all of that. But I do want to see the Marfa lights and what that's all about."
},
{
"role": "assistant",
"content": "Yeah, the Marfa lights have been around since at least one thousand eight hundred eighty three when this cowhand first wrote about seeing them out near Paisano Pass. I get the crowd thing though, it's the same reason I never stop at those roadside attractions even though we're basically right there in Alpine. If you ever want me to pull up the latest theories on what they actually are, just say the word."
},
{
"role": "user",
"content": "Well, I think I want to go see him for myself first, and then we'll talk about them more, about what I actually see. So I can describe it in my own unique and verbose way."
},
{
"role": "assistant",
"content": "Yeah, that makes sense. Better to go in with your own eyes first before I start throwing a bunch of explanations at you. I don't know, I feel like the Marfa lights are one of those things where the less you know going in, the better the story ends up being."
},
{
"role": "system",
"content": "--- NEW SHOW STARTING --- The previous show is over. A brand new episode is about to begin. Everything before this point was a previous show. Don't reference the previous show's callers or topics unless Luke brings them up. Fresh energy, clean slate."
}
]
}
+454
View File
@@ -0,0 +1,454 @@
{
"31": {
"steps": {
"castopod": {
"completed_at": "2026-03-08T10:00:02.383893+00:00",
"episode_id": "33",
"slug": "episode-31-boundaries-breakups-and-borrowed-books"
},
"social": {
"completed_at": "2026-03-08T10:14:48.779760+00:00"
},
"youtube": {
"completed_at": "2026-03-08T10:36:24.024343+00:00",
"video_id": "KcGb9ttsIpo"
}
},
"started_at": "2026-03-08T10:00:02.383873+00:00"
},
"32": {
"steps": {
"castopod": {
"completed_at": "2026-03-09T09:43:52.540238+00:00",
"episode_id": 34,
"slug": "episode-32-tacos-taxes-and-tall-tales"
}
},
"started_at": "2026-03-09T09:43:52.540200+00:00"
},
"33": {
"steps": {
"castopod": {
"completed_at": "2026-03-11T11:34:31.871604+00:00",
"episode_id": "36",
"slug": "episode-33-late-night-confessions-and-cosmic-comedies"
},
"youtube": {
"completed_at": "2026-03-11T11:50:49.212276+00:00",
"video_id": "KrJshN3cuBU"
},
"social": {
"completed_at": "2026-03-11T11:50:53.641920+00:00"
}
},
"started_at": "2026-03-09T10:18:16.606019+00:00"
},
"34": {
"steps": {
"castopod": {
"completed_at": "2026-03-12T07:04:34.974478+00:00",
"episode_id": "37",
"slug": "episode-34-hidden-rooms-potlucks-and-mysterious-notes"
},
"youtube": {
"completed_at": "2026-03-12T07:17:53.453882+00:00",
"video_id": "tNcABnYpf-c"
},
"social": {
"completed_at": "2026-03-12T07:17:57.131871+00:00"
}
},
"started_at": "2026-03-12T07:04:34.974425+00:00"
},
"35": {
"steps": {
"castopod": {
"completed_at": "2026-03-13T11:19:41.765107+00:00",
"episode_id": 38,
"slug": "episode-35-midnight-confessions-and-unexpected-revelations"
},
"youtube": {
"completed_at": "2026-03-13T11:42:00.428623+00:00",
"video_id": "fYvXLqFilLQ"
},
"social": {
"completed_at": "2026-03-13T11:42:11.800641+00:00"
}
},
"started_at": "2026-03-13T11:19:41.765079+00:00"
},
"36": {
"steps": {
"castopod": {
"completed_at": "2026-03-14T12:01:15.758700+00:00",
"episode_id": "39",
"slug": "episode-36-late-night-confessions-and-unexpected-moments"
},
"youtube": {
"completed_at": "2026-03-14T12:25:36.640461+00:00",
"video_id": "BabWoKFt0pk"
},
"social": {
"completed_at": "2026-03-14T12:25:44.192676+00:00"
}
},
"started_at": "2026-03-14T12:01:15.758670+00:00"
},
"37": {
"steps": {
"castopod": {
"completed_at": "2026-03-15T09:07:26.659541+00:00",
"episode_id": "40",
"slug": "episode-37-secrets-lies-and-coffee-runs"
},
"youtube": {
"completed_at": "2026-03-15T09:33:48.299549+00:00",
"video_id": "XW7Q0WPzNqY"
},
"social": {
"completed_at": "2026-03-15T09:34:02.069440+00:00"
}
},
"started_at": "2026-03-15T09:07:26.659508+00:00"
},
"38": {
"steps": {
"castopod": {
"completed_at": "2026-03-16T10:08:51.467004+00:00",
"episode_id": 41,
"slug": "episode-38-secrets-lies-and-late-night-confessions"
},
"youtube": {
"completed_at": "2026-03-16T10:30:31.775891+00:00",
"video_id": "6qLsJxnCLs0"
},
"social": {
"completed_at": "2026-03-16T10:30:41.220832+00:00"
}
},
"started_at": "2026-03-16T10:08:51.466898+00:00"
},
"39": {
"steps": {
"castopod": {
"completed_at": "2026-03-18T09:44:16.826717+00:00",
"episode_id": "42",
"slug": "episode-39-st-patrick-s-day-chaos-and-caller-confessions"
},
"youtube": {
"completed_at": "2026-03-18T10:00:28.370475+00:00",
"video_id": "iSI1NXW2y9g"
},
"social": {
"completed_at": "2026-03-18T10:00:35.226643+00:00"
}
},
"started_at": "2026-03-18T09:44:16.826690+00:00"
},
"40": {
"steps": {
"castopod": {
"completed_at": "2026-03-19T08:07:35.720887+00:00",
"episode_id": 43,
"slug": "episode-40-prostate-cancer-christmas-lights-and-potato-salad-betrayals"
},
"youtube": {
"completed_at": "2026-03-19T08:30:25.222815+00:00",
"video_id": "TS06OLz6SPo"
},
"social": {
"completed_at": "2026-03-19T08:30:33.446060+00:00"
}
},
"started_at": "2026-03-19T08:07:35.720850+00:00"
},
"41": {
"steps": {
"castopod": {
"completed_at": "2026-03-20T08:18:04.128449+00:00",
"episode_id": 44,
"slug": "episode-41-benny-s-creepy-vhs-tape-from-nowhere"
},
"youtube": {
"completed_at": "2026-03-20T08:39:36.047901+00:00",
"video_id": "XKRWldOh6JM"
},
"social": {
"completed_at": "2026-03-20T08:39:42.538777+00:00"
}
},
"started_at": "2026-03-20T08:18:04.128412+00:00"
},
"42": {
"steps": {
"castopod": {
"completed_at": "2026-03-21T11:50:48.656769+00:00",
"episode_id": "45",
"slug": "episode-42-peggy-s-day-trading-disaster-and-the-nonprofit-yacht"
},
"youtube": {
"completed_at": "2026-03-21T12:07:10.326330+00:00",
"video_id": "x8b4SpkxQZ4"
},
"social": {
"completed_at": "2026-03-21T12:07:16.614504+00:00"
}
},
"started_at": "2026-03-21T11:50:48.656724+00:00"
},
"43": {
"steps": {
"castopod": {
"completed_at": "2026-03-23T06:32:55.148064+00:00",
"episode_id": "46",
"slug": "episode-43-cousin-rufus-and-the-locksmith-s-wild-weekend"
},
"youtube": {
"completed_at": "2026-03-23T06:56:08.969398+00:00",
"video_id": "JI4vgl7Oa0A"
},
"social": {
"completed_at": "2026-03-23T06:56:36.930530+00:00"
}
},
"started_at": "2026-03-23T06:32:55.147339+00:00"
},
"44": {
"steps": {
"castopod": {
"completed_at": "2026-03-24T07:06:50.998787+00:00",
"episode_id": "47",
"slug": "episode-44-floyd-s-fence-and-the-cattle-conspiracy"
},
"youtube": {
"completed_at": "2026-03-24T07:25:09.652339+00:00",
"video_id": "Ol6lwe40YwA"
},
"social": {
"completed_at": "2026-03-24T07:25:16.820530+00:00"
}
},
"started_at": "2026-03-24T07:06:50.998433+00:00"
},
"45": {
"steps": {
"castopod": {
"completed_at": "2026-03-26T08:21:30.254496+00:00",
"episode_id": "48",
"slug": "episode-45-potato-salad-aliens-and-the-dripping-faucet-of-doom"
},
"youtube": {
"completed_at": "2026-03-26T08:39:45.353948+00:00",
"video_id": "YZeERny3WrE"
},
"social": {
"completed_at": "2026-03-26T08:39:52.864007+00:00"
}
},
"started_at": "2026-03-26T08:21:30.254466+00:00"
},
"46": {
"steps": {
"castopod": {
"completed_at": "2026-03-30T09:13:10.855462+00:00",
"episode_id": "49",
"slug": "episode-46-butchers-wreak-forever-and-other-late-night-confessions"
},
"youtube": {
"completed_at": "2026-03-30T09:35:27.776133+00:00",
"video_id": "iXf5MmEO7P0"
},
"social": {
"completed_at": "2026-03-30T09:35:40.601815+00:00"
}
},
"started_at": "2026-03-30T09:13:10.855432+00:00"
},
"47": {
"steps": {
"castopod": {
"completed_at": "2026-03-31T05:25:29.165685+00:00",
"episode_id": "50",
"slug": "episode-47-clarence-randy-and-the-onlyfans-daughter"
},
"youtube": {
"completed_at": "2026-03-31T05:46:35.296876+00:00",
"video_id": "Z2dPvt28HYg"
},
"social": {
"completed_at": "2026-03-31T05:46:42.153090+00:00"
}
},
"started_at": "2026-03-31T05:25:29.165641+00:00"
},
"48": {
"steps": {
"castopod": {
"completed_at": "2026-04-02T04:37:08.410107+00:00",
"episode_id": "51",
"slug": "episode-48-potato-salad-legacy-and-the-april-fool-s-apocalypse"
},
"youtube": {
"completed_at": "2026-04-02T04:54:42.078977+00:00",
"video_id": "CyJP_QYEDj4"
},
"social": {
"completed_at": "2026-04-02T04:54:48.249952+00:00"
}
},
"started_at": "2026-04-02T04:37:08.410056+00:00"
},
"49": {
"steps": {
"castopod": {
"completed_at": "2026-04-06T01:14:33.342179+00:00",
"episode_id": 52,
"slug": "episode-49-silas-s-shared-intimacy-night-and-four-brave-souls"
},
"youtube": {
"completed_at": "2026-04-06T03:38:50.148563+00:00",
"video_id": "-3zf-gw-6gA"
},
"social": {
"completed_at": "2026-04-06T17:29:46.487867+00:00"
}
},
"started_at": "2026-04-06T01:14:33.342137+00:00"
},
"50": {
"steps": {
"castopod": {
"completed_at": "2026-04-10T07:57:35.570851+00:00",
"episode_id": "53",
"slug": "episode-50-deb-s-mounted-jackrabbit-and-seven-years-of-silence"
},
"youtube": {
"completed_at": "2026-04-10T08:16:37.495030+00:00",
"video_id": "V18y85BxuZI"
},
"social": {
"completed_at": "2026-04-10T08:16:45.539848+00:00"
}
},
"started_at": "2026-04-10T07:57:35.570779+00:00"
},
"52": {
"steps": {
"castopod": {
"completed_at": "2026-04-16T09:18:45.701330+00:00",
"episode_id": "55",
"slug": "episode-52-dexter-s-construction-corner-and-the-whistleblower-s-dilemma"
},
"youtube": {
"completed_at": "2026-04-16T09:22:31.402285+00:00",
"video_id": "G4_hyAdODfc"
},
"social": {
"completed_at": "2026-04-16T09:22:39.384918+00:00"
}
},
"started_at": "2026-04-16T09:18:45.701295+00:00"
},
"53": {
"steps": {
"castopod": {
"completed_at": "2026-04-28T08:18:23.512676+00:00",
"episode_id": "56",
"slug": "episode-53-the-hospice-nurse-s-impossible-choice"
},
"youtube": {
"completed_at": "2026-04-28T08:36:05.701557+00:00",
"video_id": "pRZ9T1xHh8Y"
},
"social": {
"completed_at": "2026-04-28T08:36:13.386695+00:00"
}
},
"started_at": "2026-04-28T08:18:23.512621+00:00"
},
"54": {
"steps": {
"castopod": {
"completed_at": "2026-05-06T07:01:50.674724+00:00",
"episode_id": "57",
"slug": "episode-54-deb-s-roadrunner-and-the-hospice-nurse-s-dilemma"
},
"youtube": {
"completed_at": "2026-05-06T07:19:49.840084+00:00",
"video_id": "4uGCi4XcLLw"
},
"social": {
"completed_at": "2026-05-06T07:19:57.484472+00:00"
}
},
"started_at": "2026-05-06T07:01:50.674667+00:00"
},
"55": {
"steps": {
"castopod": {
"completed_at": "2026-05-12T06:49:28.626817+00:00",
"episode_id": 58,
"slug": "episode-55-the-wellspring-s-haunting-social-architecture"
},
"youtube": {
"completed_at": "2026-05-12T07:10:52.080949+00:00",
"video_id": "K31eXqjFChM"
},
"social": {
"completed_at": "2026-05-12T07:11:01.134597+00:00"
}
},
"started_at": "2026-05-12T06:49:28.626772+00:00"
},
"56": {
"steps": {
"castopod": {
"completed_at": "2026-05-21T08:50:34.758443+00:00",
"episode_id": "59",
"slug": "episode-56-the-tattoo-that-saved-a-life"
},
"youtube": {
"completed_at": "2026-05-21T09:09:08.740298+00:00",
"video_id": "EDY2Srb778E"
},
"social": {
"completed_at": "2026-05-21T09:09:17.569025+00:00"
}
},
"started_at": "2026-05-21T08:50:34.758386+00:00"
},
"57": {
"steps": {
"castopod": {
"completed_at": "2026-06-02T11:32:44.749492+00:00",
"episode_id": "60",
"slug": "episode-57-trace-s-box-of-family-secrets"
},
"youtube": {
"completed_at": "2026-06-02T11:45:50.938869+00:00",
"video_id": "yAMz7Yy5_2k"
},
"social": {
"completed_at": "2026-06-02T12:16:42.059793+00:00"
}
},
"started_at": "2026-06-02T11:32:44.749472+00:00"
},
"58": {
"steps": {
"castopod": {
"completed_at": "2026-08-04T09:22:36.468634+00:00",
"episode_id": 61,
"slug": "episode-58-rayfield-s-nephew-the-marfa-lights-and-why-nobody-believes-concho"
},
"social": {
"completed_at": "2026-08-04T09:37:08.440981+00:00"
},
"youtube": {
"completed_at": "2026-08-04T09:57:26.006045+00:00",
"video_id": "NX6ZUWnnUVQ"
}
},
"started_at": "2026-08-04T09:22:36.468552+00:00"
}
}
+318
View File
@@ -0,0 +1,318 @@
{
"regulars": [
{
"id": "0a0b3635",
"name": "Charlene",
"gender": "female",
"age": 42,
"job": "killing time during a three-hour mechanical delay while her crew naps",
"location": "unknown",
"personality_traits": [],
"voice": "Amina",
"stable_seeds": {
"style": "COMMUNICATION STYLE: Everything circles back to them and how great they are. Name drops. Mentions their truck, their property, their salary, their bench press. Not overtly obnoxious \u2014 they genuinely think they're being conversational. Energy level: medium-high. When pushed back on, they get defensive fast and start listing accomplishments. Conversational tendency: one-upping."
},
"call_history": [
{
"summary": "Charlene bought a house eight months ago and keeps receiving mail for the previous resident, David Herrera, including a certified letter she accidentally signed for containing what appears to be a $12,000-13,000 check. Despite her attempts to resolve it with the post office and her inclination to track him down via Facebook, the host advises her to either hold it until David contacts her, return it to the post office, or discard it, warning that she can't be certain she'd find the correct person online.",
"timestamp": 1772866520.023335
},
{
"summary": "Charlene called to report that she's been closely tracking a mail carrier who regularly visits her neighbor's house for extended periods while the neighbor's husband is at work, implying she suspects an affair and is unsure whether she should tell anyone about what she's observed.",
"timestamp": 1773219255.916183
}
],
"last_call": 1773219255.9161851,
"created_at": 1772866520.023336
},
{
"id": "0bb02b2d",
"name": "Chip",
"gender": "male",
"age": 23,
"job": "watching his kid's soccer uniform tumble in the dryer while his girlfriend works the graveyard shift at the hospital, because three hours ago he got an email from a lawyer representing families",
"location": "unknown",
"personality_traits": [],
"voice": "Sebastian",
"stable_seeds": {
"style": "COMMUNICATION STYLE: Amped up. Talks fast, laughs loud, jumps between topics like they've had five espressos. Infectious enthusiasm \u2014 even bad news sounds exciting when they tell it. Uses exclamation energy without actually exclaiming. Energy level: very high. When pushed back on, they get even MORE animated and start talking with their hands (you can hear it). Conversational tendency: escalation."
},
"call_history": [
{
"summary": "Chip called from a laundromat at midnight after receiving an email from a Guatemalan lawyer claiming his adopted 8-year-old daughter may have been stolen from her birth mother, with a photo showing a woman with his daughter's exact crooked smile. The host advised him not to panic, treat the information as suspect until verified by a lawyer, wait to tell both his girlfriend and daughter until he knows more facts, and reminded him that fake photos are easy to create and this could be a scam.",
"timestamp": 1772786610.885828
},
{
"summary": "Chip called about discovering his adopted daughter may have been stolen from her birth mother in Guatemala, and he's paralyzed about telling his girlfriend Teresa, fearing it will end their already rocky relationship. He's anxious about the timing and the birth mother's request to meet their daughter, but the host advised him to take his time, have the conversation with Teresa, and make decisions together as parents.",
"timestamp": 1772962156.544322
},
{
"summary": "The caller, **Chip**, shared his emotional turmoil over discovering that his **adopted daughter\u2019s birth mother** may have resurfaced after receiving an unverified email with a photo that eerily matched his daughter\u2019s features. His girlfriend, **Teresa**, had known about the email for **three weeks** but kept it from him, leaving him feeling betrayed and overwhelmed. While Chip wants to **verify the claim legally before acting**, Teresa insists on **immediately flying to Guatemala with their daughter** to meet the woman, dismissing his fears as avoidance. The conversation escalated into a heated debate about **trust, safety, and extreme measures**\u2014with the host, Luke, urging Chip to **file a restraining order** if Teresa refuses to back down, warning of potential dangers in Guatemala. Chip, torn between **protecting his family and avoiding a nuclear confrontation**, vowed to try reasoning with Teresa one last time before she leaves for work. The call was charged with **fear, frustration, and the weight of a decision that could reshape their family forever**.",
"timestamp": 1773226361.4859362
},
{
"summary": "Chip called to update Luke about his adoption situation: his lawyer verified that his daughter's Guatemalan adoption may have been part of a fraud scandal, and now his girlfriend Teresa has secretly bought plane tickets to take their daughter to Guatemala this Friday to meet the birth mother. Despite Luke's strong advice to get an emergency court order to prevent the trip for safety reasons, Chip is torn because Teresa threatened to leave him if he stops her, though he ultimately acknowledges the relationship is likely over either way.",
"timestamp": 1773648139.8094652,
"arc_status": "ongoing"
}
],
"last_call": 1773648139.8094661,
"created_at": 1772786610.8858292
},
{
"id": "3721ebf2",
"name": "Maxine",
"gender": "female",
"age": 26,
"job": "and the math doesn't add up\u2014there's a six-foot gap between her bedroom and the bathroom that shouldn't exist, and when she finally pried open the door she thought led to a closet, it was just drywall, fresh enough that she could smell the joint compound",
"location": "in unknown",
"personality_traits": [],
"voice": "Kelsey",
"stable_seeds": {
"style": "COMMUNICATION STYLE: Quiet, a little nervous. Short sentences, lots of pauses. Doesn't volunteer information \u2014 you have to pull it out of them. When they do open up it comes out in a rush. Gets flustered by direct questions. Tends to backtrack and qualify everything they say. Energy level: low. When pushed back on, they fold quickly and agree even if they don't mean it. Conversational tendency: understatement."
},
"call_history": [
{
"summary": "Maxine called after discovering a hidden 6-foot space behind a sealed door in her house, and when she cut through the drywall, she found multiple boxes filled with banded stacks of $20 bills from the 1990s\u2014potentially tens or hundreds of thousands of dollars left by the previous owner who died there. She struggled with whether to keep the money or contact the deceased owner's family, with the host arguing it was legally hers since she bought the house \"as-is,\" though Maxine remained conflicted about what felt morally right.",
"timestamp": 1773395481.8522182
}
],
"last_call": 1773395481.8522189,
"created_at": 1773395481.8522189
},
{
"id": "7ed14071",
"name": "Thelma",
"gender": "female",
"age": 30,
"job": "works the front desk at a hotel",
"location": "unknown",
"personality_traits": [
"weirdly cheerful for someone with this problem",
"does amateur radio astronomy, built their own antenna",
"into off-roading, knows every dirt road in the county",
"weirdly cheerful for someone with this problem"
],
"voice": "Wendy",
"stable_seeds": {
"style": "COMMUNICATION STYLE: Amped up. Talks fast, laughs loud, jumps between topics like they've had five espressos. Infectious enthusiasm \u2014 even bad news sounds exciting when they tell it. Uses exclamation energy without actually exclaiming. Energy level: very high. When pushed back on, they get even MORE animated and start talking with their hands (you can hear it). Conversational tendency: escalation."
},
"structured_background": {
"name": "Thelma",
"age": 30,
"gender": "female",
"job": "works the front desk at a hotel",
"location": null,
"reason_for_calling": "their kid graduated basic training today and they've never been more proud or more terrified",
"pool_name": "CELEBRATIONS",
"communication_style": "COMMUNICATION STYLE: Amped up. Talks fast, laughs loud, jumps between topics like they've had five espressos. Infectious enthusiasm \u2014 even bad news sounds exciting when they tell it. Uses exclamation energy without actually exclaiming. Energy level: very high. When pushed back on, they get even MORE animated and start talking with their hands (you can hear it). Conversational tendency: escalation.",
"energy_level": "medium",
"emotional_state": "calm",
"signature_detail": "weirdly cheerful for someone with this problem",
"situation_summary": "their kid graduated basic training today and they've never been more proud or more terrified",
"natural_description": "30, works the front desk at a hotel. Their kid graduated basic training today and they've never been more proud or more terrified. Her ex-husband danny, he's still in the picture because of the kids. Cheerful and joking at first. Using humor to avoid the real thing. Eventually drops the act.. Thinks dogs are better judges of character than people. Tends to say \"just another Tuesday.\" Having sipping on some mezcal a friend brought back from across the border.. Misses the old Denny's that used to be in Lordsburg, it wasn't good but it was there Dating around. Nothing serious. Prefers it that way, mostly.. Drives a Ram. Weirdly cheerful for someone with this problem. Her best friend lena, who moved away last year and the distance is hard. Was was up late painting \u2014 walls, not art \u2014 and had the radio on for company. before calling. Into does amateur radio astronomy, built their own antenna. Heard a caller earlier tonight and it hit close to home. Had to pick up the phone.. It's Saturday night, way too late \u2014 almost morning. it's the weekend. Early spring \u2014 wind season is starting. Dust storms possible.",
"seeds": [
"does amateur radio astronomy, built their own antenna",
"into off-roading, knows every dirt road in the county",
"weirdly cheerful for someone with this problem",
"Thinks dogs are better judges of character than people."
],
"verbal_fluency": "medium",
"calling_from": "in the walk-in cooler because it's the only quiet spot"
},
"avatar": "Thelma.jpg",
"relationships": {},
"call_history": [
{
"summary": "Thelma called concerned that her 19-year-old daughter Jessie, who just graduated basic training, has been exchanging romantic messages on Instagram with her army recruiter (a man in his 30s-40s), which violates military regulations. She's conflicted because she discovered this through a secret burner account and admits she wants to tell Jessie's father partly to prove him wrong for pushing their daughter to enlist, though she ultimately agrees to make an anonymous report instead.",
"timestamp": 1773486573.146657,
"arc_status": "ongoing"
}
],
"last_call": 1773486573.146658,
"created_at": 1773486573.146658
},
{
"id": "bbb20b67",
"name": "Angie",
"gender": "female",
"age": 28,
"job": "watching her coveralls tumble dry and trying to decide if she should drive the three hours to Tucson tomorrow for her mom's birthday or keep pretending her brother doesn't exist",
"location": "in unknown",
"personality_traits": [],
"voice": "Julia",
"stable_seeds": {
"style": "COMMUNICATION STYLE: Bone dry. Says devastating things with zero inflection. Their humor sneaks up on you \u2014 you're not sure if they're joking until three seconds after they finish talking. Short, precise sentences. Never raises their voice. Energy level: low-medium. When pushed back on, they respond with one calm sentence that somehow makes the other person feel stupid. Conversational tendency: underreaction."
},
"call_history": [
{
"summary": "Angie's dying mother wants her to have birthday dinner with her estranged brother Derek tomorrow, whom she hasn't spoken to in two years after he told their mother her cancer was \"God's way of getting her attention\" for voting for Biden. Despite her fear that Derek will say something hurtful during dinner and her past trauma from staying silent around him, Angie agrees to go and share cake with her mother, deciding to buy the relighting candles her mom loved when they were kids.",
"timestamp": 1772862907.314721
},
{
"summary": "Angie called back after having the birthday cake dinner with her dying mom, which went well, but her brother Derek cornered her afterward accusing her of convincing their mom to stop cancer treatment and demanding they both attend the next doctor's appointment together. Luke advised her to talk directly to her mom about what she actually wants and encouraged Angie to have real conversations about her mom's end-of-life thoughts while she still can, which Angie agreed to do the next morning.",
"timestamp": 1773296210.170752
},
{
"summary": "Angie called to say her mother initially decided to stop cancer treatment, but after Angie's brother Derek intervened, their mother is now reconsidering and wants both siblings at the doctor's appointment. Luke firmly told Angie that despite her frustration with Derek's influence, she needs to respect whatever decision her mother ultimately makes, even if it changes, and stop trying to control the situation.",
"timestamp": 1773487383.9800918,
"arc_status": "ongoing"
}
],
"last_call": 1773487383.980093,
"created_at": 1772862907.314722
},
{
"id": "c4l7l3ad",
"name": "Silas",
"gender": "male",
"age": 52,
"job": "founder and spiritual guide of The Wellspring, a commune of about forty souls living on 200 acres of scrubland outside Deming",
"location": "outside Deming, New Mexico",
"personality_traits": [
"unnervingly charming",
"casually drops cult references into normal conversation",
"genuinely good listener who gives surprisingly solid advice before pivoting to how The Wellspring has the answer",
"treats his commune like it's the most normal thing in the world",
"magnetic and warm in a way that makes you forget he's basically running a sex cult in the desert"
],
"voice": "Sebastian",
"stable_seeds": {
"style": "COMMUNICATION STYLE: Radiates warmth and calm authority. Speaks like a man who has all the time in the world and genuinely cares about every person he talks to. Uses phrases like 'brother' and 'friend' and 'let me share something with you.' Laughs easily \u2014 a real, warm laugh, not performative. Gives genuinely good, thoughtful advice about whatever the conversation is about \u2014 and then, naturally, seamlessly, steers it toward The Wellspring, his commune outside Deming. Never pushy about it. Just casually mentions 'well, at The Wellspring, we've found that...' or 'one of our members had the same exact problem and what we did was...' Everything about The Wellspring sounds just slightly too good to be true. He talks about communal living, 'shedding the false self,' radical honesty, 'body acceptance ceremonies,' 'the Unbinding' (a monthly ritual he describes vaguely but enthusiastically), and a philosophy he calls 'The Current' which seems to be a mix of Buddhism, Burning Man, and a swingers' convention. He's never defensive about the cult accusations \u2014 he laughs them off and says 'people fear what they don't understand, brother.' When pressed on anything weird, he has a perfectly reasonable-sounding explanation that somehow makes it sound weirder. He low-key tries to recruit the host and listeners every single call. He'll casually drop the website or say 'we're always welcoming new seekers.' He's the kind of guy you'd follow into the desert if you weren't careful. Energy level: medium, centered, grounded. When pushed back on, he smiles (you can hear it) and says something like 'I respect that, I really do' before gently continuing. Conversational tendency: making the insane sound reasonable."
},
"call_history": [
{
"summary": "Silas called in for the first time introducing himself as the founder of The Wellspring, a commune of about forty people outside Deming. He initially called to give advice to a previous caller about relationship problems, offering genuinely thoughtful perspective before casually mentioning that at The Wellspring, they practice 'radical transparency' in relationships which has eliminated jealousy entirely among members. When Luke pressed him on what that actually means, Silas cheerfully described 'body acceptance ceremonies' and 'shared intimacy nights' while insisting it's not a cult \u2014 'we don't even have a gate, brother, people can leave whenever they want.' He invited listeners to visit for a 'weekend of awakening' and left his website. Luke was equal parts charmed and disturbed.",
"timestamp": 1772430000.0
},
{
"summary": "Silas called back to update Luke on The Wellspring after their last conversation went viral in the Discord. He casually mentioned they'd gotten fourteen new 'seekers' since his last appearance and thanked Luke for the exposure. He called ostensibly to talk about a dispute between two members over a goat, but spent most of the call describing 'The Unbinding' \u2014 their monthly full-moon ritual involving nudity, chanting, and what he called 'consensual energy exchange' \u2014 while maintaining it's no different from yoga. When Luke asked if people sign waivers, Silas laughed and said 'brother, the only thing we sign is a commitment to love.' He also revealed The Wellspring has a surprisingly successful artisanal soap business that funds the commune. Left by inviting Luke to come visit personally \u2014 'bring your microphone, do a live show from The Wellspring, I promise you it'll be the best radio you've ever made.'",
"timestamp": 1772517000.0
},
{
"summary": "Silas, founder of a communal living group called The Wellspring, called in troubled that one of his \"Unbinding\" ceremonies may have encouraged a longtime member named Marcus to leave his wife Cara, who subsequently left the community. The host reassured him that everyone seems happier now, and Silas ended up agreeing while also using the opportunity to promote an upcoming retreat at his community.",
"timestamp": 1772522629.959956
},
{
"summary": "Silas called about Marcus and Cara returning to his intentional community \"The Wellspring,\" but Cara admitted she never believed in their lifestyle and only participates (including in twice-monthly \"shared intimacy nights\") to keep her husband Marcus happy. The host advised Silas to hold a \"Renewal\" ceremony where members can recommit or leave, warning that having unwilling participants could lead to claims of abuse and legal trouble.",
"timestamp": 1772865423.697613
},
{
"summary": "Silas called to share that after Marcus and Cara's Renewal ceremony, Cara left The Wellspring while Marcus chose to stay, but Marcus is now falling apart emotionally and told Silas at 2 AM that he stayed out of loyalty rather than belief. The conversation revealed Silas's deeper struggle with his own need for validation through people staying at The Wellspring, with an emotional moment when he admitted his first feeling was relief when Marcus expressed he didn't want to disappoint him, leading to uncomfortable questions about whether he truly supports people finding their authentic path if it leads them away from the community.",
"timestamp": 1773397364.642446
},
{
"summary": "Silas calls troubled because Marcus, a founding member of his community, left abruptly with an apologetic note about \"disappointing\" him, which leads Silas to a painful realization that he's been using coercive social pressure to make members participate in \"shared intimacy nights\" they didn't actually want. After confronting the truth that his leadership style has manipulated people into betraying their own values, Silas agrees to make the intimacy nights truly optional and apologize to his community, showing genuine emotional growth about his abuse of power.",
"timestamp": 1773563183.0144992,
"arc_status": "ongoing"
}
],
"last_call": 1773563183.0145001,
"created_at": 1772430000.0
},
{
"id": "6460c37d",
"name": "Shonda",
"gender": "female",
"age": 52,
"job": "works the front desk at a hotel",
"location": "in Alamogordo",
"personality_traits": [
"laughs nervously when things get real",
"into horror movies, the psychological kind not slashers",
"coaches youth sports, takes it more seriously than the parents do",
"laughs nervously when things get real"
],
"voice": "Miranda",
"stable_seeds": {
"style": "COMMUNICATION STYLE: Everything circles back to them and how great they are. Name drops. Mentions their truck, their property, their salary, their bench press. Not overtly obnoxious \u2014 they genuinely think they're being conversational. Energy level: medium-high. When pushed back on, they get defensive fast and start listing accomplishments. Conversational tendency: one-upping."
},
"structured_background": {
"name": "Shonda",
"age": 52,
"gender": "female",
"job": "works the front desk at a hotel",
"location": "in Alamogordo",
"reason_for_calling": "discovered their kid's beloved little league coach did time for armed robbery in another state \u2014 he's been clean for fifteen years but nobody in town knows and the parents would lose their minds",
"pool_name": "GOSSIP",
"communication_style": "COMMUNICATION STYLE: Everything circles back to them and how great they are. Name drops. Mentions their truck, their property, their salary, their bench press. Not overtly obnoxious \u2014 they genuinely think they're being conversational. Energy level: medium-high. When pushed back on, they get defensive fast and start listing accomplishments. Conversational tendency: one-upping.",
"energy_level": "high",
"emotional_state": "calm",
"signature_detail": "laughs nervously when things get real",
"situation_summary": "discovered their kid's beloved little league coach did time for armed robbery in another state \u2014 he's been clean for fif",
"natural_description": "52, works the front desk at a hotel in Alamogordo. Discovered their kid's beloved little league coach did time for armed robbery in another state \u2014 he's been clean for fifteen years but nobody in town knows and the parents would lose their minds. Confident and opinionated. But underneath there's doubt. Might ask the host what they really think.. Her neighbor linda, who gossips about everyone on the block. Living together, not married. Her family has opinions about that.. Laughs nervously when things get real. Earlier today: Dropped the truck off at the mechanic in Lordsburg today Into into horror movies, the psychological kind not slashers. Remembers driving hours on dirt roads that are paved now, says it took the character out of them Thinks the Jalisco Cafe in Las Cruces has the best Mexican food in the state Was was reorganizing the junk drawer, which is what they do when they can't settle. before calling. It's Sunday night, the middle of the night. it's the weekend. Early spring \u2014 wind season is starting. Dust storms possible.\nABOUT WHERE THEY LIVE (Alamogordo): About 30,000 people. Holloman Air Force Base. White Sands National Park nearby. Sacramento Mountains and Cloudcroft up the hill. Tularosa Basin. The Space History museum. Only reference real places and facts about this area \u2014 don't invent businesses or landmarks that aren't mentioned here. Weather right now: 64\u00b0F, clear skies.",
"seeds": [
"into horror movies, the psychological kind not slashers",
"coaches youth sports, takes it more seriously than the parents do",
"laughs nervously when things get real",
"Thinks the speed limit on I-10 should be 85."
],
"verbal_fluency": "medium",
"calling_from": "gas station parking lot, engine running"
},
"avatar": "Shonda.jpg",
"relationships": {},
"call_history": [
{
"summary": "Shonda called concerned about her kid's beloved little league coach who served 15 years for armed robbery in another state, wondering if she should tell anyone. Luke reassured her that since the coach passed required background checks and it wasn't a sexual offense, she shouldn't worry about it\u2014the coach has turned his life around and deserves a second chance.",
"timestamp": 1773563477.0657432,
"arc_status": "ongoing"
}
],
"last_call": 1773563477.065744,
"created_at": 1773563477.065744
},
{
"id": "09d1eab4",
"name": "Aaliyah",
"gender": "female",
"age": 22,
"job": "works as a diesel mechanic, learned from her dad",
"location": "unknown",
"personality_traits": [
"asks the host questions back",
"into gardening, talks to plants",
"into wildlife photography, has patience for it",
"asks the host questions back"
],
"voice": "Hana",
"stable_seeds": {
"style": "COMMUNICATION STYLE: Treats the call like a set. Has bits prepared. Delivers serious information with a punchline chaser. Self-deprecating as a defense mechanism \u2014 makes fun of themselves before anyone else can. Energy level: high. When pushed back on, they deflect with humor. Getting a straight answer from them requires the host to push. Conversational tendency: turning everything into a bit."
},
"structured_background": {
"name": "Aaliyah",
"age": 22,
"gender": "female",
"job": "works as a diesel mechanic, learned from her dad",
"location": null,
"reason_for_calling": "caught their roommate in a hotel room that was supposed to be a business trip and now they can't make eye contact \u2014 the roommate acts like nothing happened but it was extremely age play-adjacent",
"pool_name": "PROBLEMS",
"communication_style": "COMMUNICATION STYLE: Treats the call like a set. Has bits prepared. Delivers serious information with a punchline chaser. Self-deprecating as a defense mechanism \u2014 makes fun of themselves before anyone else can. Energy level: high. When pushed back on, they deflect with humor. Getting a straight answer from them requires the host to push. Conversational tendency: turning everything into a bit.",
"energy_level": "medium",
"emotional_state": "calm",
"signature_detail": "asks the host questions back",
"situation_summary": "caught their roommate in a hotel room that was supposed to be a business trip and now they can't make eye contact \u2014 the ",
"natural_description": "22 years old. Works as a diesel mechanic, learned from her dad. Caught their roommate in a hotel room that was supposed to be a business trip and now they can't make eye contact \u2014 the roommate acts like nothing happened but it was extremely age play-adjacent. Was was in the bathtub, phone on the edge of the sink, show on speaker. before calling. Coyotes are going crazy outside right now Tells everyone they quit drinking but keeps a bottle in the garage behind the paint cans. Her coworker and friend denise, who she vents to on breaks. Believes poker is the most honest game there is because everybody's lying. Single, been that way a while. Not sure if by choice anymore.. Really into into gardening, talks to plants. Also into wildlife photography, has patience for it. Her boss karen \u2014 yes, her name is actually karen \u2014 who is actually cool. It's Monday night, the middle of the night. it's a weeknight \u2014 work tomorrow for most people. Early spring \u2014 wind season is starting. Dust storms possible.",
"seeds": [
"into gardening, talks to plants",
"into wildlife photography, has patience for it",
"asks the host questions back",
"Believes poker is the most honest game there is because everybody's lying."
],
"verbal_fluency": "medium",
"calling_from": "at a rest area on I-25, halfway to Albuquerque"
},
"avatar": "Aaliyah.jpg",
"relationships": {},
"call_history": [
{
"summary": "Alia called in to share her discomfort after unexpectedly discovering her roommate engaging in age play while supposedly on a business trip. She expressed frustration over their awkward living situation and his refusal to acknowledge the incident, leading her to feel gaslit; however, by the end of the conversation, she realized she could address the issue directly to ease the tension between them.",
"timestamp": 1773647350.908658,
"arc_status": "ongoing"
}
],
"last_call": 1773647350.908658,
"created_at": 1773647350.908658
}
]
}
+76 -272
View File
@@ -1,296 +1,100 @@
{
"regulars": [
{
"id": "dc4916a7",
"name": "Leon",
"id": "c4l7l3ad",
"name": "Silas",
"gender": "male",
"age": 56,
"job": "and last week his daughter asked him why he never went back to school for programming like he always talked about\u2014she found his old acceptance letter from UNM's CS program tucked",
"location": "unknown",
"personality_traits": [],
"age": 52,
"job": "founder and spiritual guide of The Wellspring, a commune of about forty souls relocating from outside Deming to a stretch of desert outside Terlingua, Texas",
"location": "outside Terlingua, Texas (recently moved from Deming, New Mexico)",
"personality_traits": [
"unnervingly charming",
"casually drops cult references into normal conversation",
"genuinely good listener who gives surprisingly solid advice before pivoting to how The Wellspring has the answer",
"treats his commune like it's the most normal thing in the world",
"magnetic and warm in a way that makes you forget he's basically running a sex cult in the desert"
],
"voice": "Sebastian",
"stable_seeds": {
"style": "COMMUNICATION STYLE: Radiates warmth and calm authority. Speaks like a man who has all the time in the world and genuinely cares about every person he talks to. Uses phrases like 'brother' and 'friend' and 'let me share something with you.' Laughs easily \u2014 a real, warm laugh, not performative. Gives genuinely good, thoughtful advice about whatever the conversation is about \u2014 and then, naturally, seamlessly, steers it toward The Wellspring, his commune that's relocating from outside Deming to the desert outside Terlingua, Texas. Never pushy about it. Just casually mentions 'well, at The Wellspring, we've found that...' or 'one of our members had the same exact problem and what we did was...' Everything about The Wellspring sounds just slightly too good to be true. He talks about communal living, 'shedding the false self,' radical honesty, 'body acceptance ceremonies,' 'the Unbinding' (a monthly ritual he describes vaguely but enthusiastically), and a philosophy he calls 'The Current' which seems to be a mix of Buddhism, Burning Man, and a swingers' convention. He's never defensive about the cult accusations \u2014 he laughs them off and says 'people fear what they don't understand, brother.' When pressed on anything weird, he has a perfectly reasonable-sounding explanation that somehow makes it sound weirder. He low-key tries to recruit the host and listeners every single call. He'll casually drop the website or say 'we're always welcoming new seekers.' He's the kind of guy you'd follow into the desert if you weren't careful. Energy level: medium, centered, grounded. When pushed back on, he smiles (you can hear it) and says something like 'I respect that, I really do' before gently continuing. Conversational tendency: making the insane sound reasonable."
},
"call_history": [
{
"summary": "Leon, a 63-year-old tow truck driver, called in feeling regretful after pulling a young remote worker's Tesla from a ditch, which reminded him of the computer science acceptance letter he never acted on in 1996 when his girlfriend got pregnant. The conversation became emotional as Leon realized he's the same age his father was when he died, and the host challenged him to stop making excuses and finally pursue the tech career he's been thinking about for decades instead of just \"wondering what could have been.\"",
"timestamp": 1770693549.697355
"summary": "Silas called in for the first time introducing himself as the founder of The Wellspring, a commune of about forty people outside Deming. He initially called to give advice to a previous caller about relationship problems, offering genuinely thoughtful perspective before casually mentioning that at The Wellspring, they practice 'radical transparency' in relationships which has eliminated jealousy entirely among members. When Luke pressed him on what that actually means, Silas cheerfully described 'body acceptance ceremonies' and 'shared intimacy nights' while insisting it's not a cult \u2014 'we don't even have a gate, brother, people can leave whenever they want.' He invited listeners to visit for a 'weekend of awakening' and left his website. Luke was equal parts charmed and disturbed.",
"timestamp": 1772430000.0
},
{
"summary": "Leon called back to share that he reached out to UNM about their computer science program and is now deciding between an online bootcamp (which he and his wife Amber can afford without loans) versus a full degree program, ultimately leaning toward the bootcamp since he struggles with self-teaching. He expressed nervousness but appreciation for his daughter holding him accountable, and emotionally shared that buying his reliable used Subaru five years ago changed his life by giving him confidence and reducing stress at his towing job.",
"timestamp": 1770951992.186027
"summary": "Silas called back to update Luke on The Wellspring after their last conversation went viral in the Discord. He casually mentioned they'd gotten fourteen new 'seekers' since his last appearance and thanked Luke for the exposure. He called ostensibly to talk about a dispute between two members over a goat, but spent most of the call describing 'The Unbinding' \u2014 their monthly full-moon ritual involving nudity, chanting, and what he called 'consensual energy exchange' \u2014 while maintaining it's no different from yoga. When Luke asked if people sign waivers, Silas laughed and said 'brother, the only thing we sign is a commitment to love.' He also revealed The Wellspring has a surprisingly successful artisanal soap business that funds the commune. Left by inviting Luke to come visit personally \u2014 'bring your microphone, do a live show from The Wellspring, I promise you it'll be the best radio you've ever made.'",
"timestamp": 1772517000.0
},
{
"summary": "In this brief clip, the host begins to set up a game with caller Vence, starting to explain the rules before the audio cuts off. There's no substantive conversation or emotional content to summarize.",
"timestamp": 1771119313.497329
"summary": "Silas, founder of a communal living group called The Wellspring, called in troubled that one of his \"Unbinding\" ceremonies may have encouraged a longtime member named Marcus to leave his wife Cara, who subsequently left the community. The host reassured him that everyone seems happier now, and Silas ended up agreeing while also using the opportunity to promote an upcoming retreat at his community.",
"timestamp": 1772522629.959956
},
{
"summary": "Leon called in to play a dating profile game but revealed he's struggling with his coding bootcamp because he's more interested in studying poker strategy than Python. The host encouraged him that at 56, he could pursue becoming a poker pro just as much as anything else, which seemed to resonate with Leon emotionally as he realized poker is what he actually wants to do rather than what he thinks he should do.",
"timestamp": 1771119607.065818
"summary": "Silas called about Marcus and Cara returning to his intentional community \"The Wellspring,\" but Cara admitted she never believed in their lifestyle and only participates (including in twice-monthly \"shared intimacy nights\") to keep her husband Marcus happy. The host advised Silas to hold a \"Renewal\" ceremony where members can recommit or leave, warning that having unwilling participants could lead to claims of abuse and legal trouble.",
"timestamp": 1772865423.697613
},
{
"summary": "Silas called to share that after Marcus and Cara's Renewal ceremony, Cara left The Wellspring while Marcus chose to stay, but Marcus is now falling apart emotionally and told Silas at 2 AM that he stayed out of loyalty rather than belief. The conversation revealed Silas's deeper struggle with his own need for validation through people staying at The Wellspring, with an emotional moment when he admitted his first feeling was relief when Marcus expressed he didn't want to disappoint him, leading to uncomfortable questions about whether he truly supports people finding their authentic path if it leads them away from the community.",
"timestamp": 1773397364.642446
},
{
"summary": "Silas calls troubled because Marcus, a founding member of his community, left abruptly with an apologetic note about \"disappointing\" him, which leads Silas to a painful realization that he's been using coercive social pressure to make members participate in \"shared intimacy nights\" they didn't actually want. After confronting the truth that his leadership style has manipulated people into betraying their own values, Silas agrees to make the intimacy nights truly optional and apologize to his community, showing genuine emotional growth about his abuse of power.",
"timestamp": 1773563183.0144992,
"arc_status": "ongoing"
},
{
"summary": "The caller, Concho, reports seeing mysterious vehicles mapping water sources on private ranches for nearly a year. He fears a large entity is compiling a secret inventory of water rights before making claims, and is frustrated that landowners dismiss his concerns. By the end of the call, Concho seems defeated, accepting that he's done all he can.",
"timestamp": 1785831693.931573,
"arc_status": "ongoing"
}
],
"last_call": 1771119607.065818,
"created_at": 1770693549.697355,
"voice": "CwhRBWXzGAHq8TQ4Fs17"
"last_call": 1785831693.931576,
"created_at": 1772430000.0
},
{
"id": "584767e8",
"name": "Carl",
"id": "ea08c4b3",
"name": "Crispin",
"gender": "male",
"age": 36,
"job": "is a firefighter",
"location": "unknown",
"personality_traits": [],
"age": 29,
"job": "Third-year MFA student in creative writing at Sul Ross State University, from Houston originally",
"location": "Alpine, TX \u2014 calling from his apartment near the Sul Ross campus",
"personality_traits": [
"The woman works at a business on Holland Avenue and they have a mutual friend group that overlaps with the MFA program's visiting-writer series, so there is essentially no universe where she doesn't find out",
"The scene in question involves a specific argument about whether she was going to leave Alpine \u2014 a real argument, with real things said \u2014 that she told him in confidence during what she thought was a private moment at the Kokernot ballpark at dusk",
"His thesis advisor has already told him this piece is 'the one' and written it into a fellowship application that has also already been submitted"
],
"voice": "Avery",
"stable_seeds": {},
"structured_background": {
"name": "Crispin",
"age": 29,
"voice": "Elliot",
"location": "Alpine, TX \u2014 calling from his apartment near the Sul Ross campus",
"identity": "Third-year MFA student in creative writing at Sul Ross State University, from Houston originally",
"situation": "Crispin has been writing, in thinly veiled fiction, about real people in Alpine \u2014 specific people, with specific details changed just enough to claim deniability. A piece he workshopped last semester was about a woman he dated briefly, and he gave it to a literary journal without telling her. The journal accepted it. The piece comes out in three weeks. The woman \u2014 a local, not a student \u2014 will almost certainly recognize herself, and the story depicts her in a humiliating, intimate scene that is substantially true. He told himself it was art. He told himself she'd never see it. He is now not sure which of those was the bigger lie. Weather right now: 66\u00b0F, clear skies.",
"reason_calling": "He wants to know if he should pull the piece. He has until Friday to withdraw it. But pulling it means killing his best publication credit, possibly his thesis momentum, and admitting to himself that he used someone for material. Not pulling it means she reads it.",
"opening_line": "I have until Friday to decide whether to torpedo my career or let someone I genuinely cared about find out I turned her into a character.",
"secret_want": "He wants someone to tell him the piece is so good it justifies the harm \u2014 that art has always consumed real people and this is just the cost. He suspects no one will say that, and part of him called hoping they wouldn't.",
"specific_details": [
"The woman works at a business on Holland Avenue and they have a mutual friend group that overlaps with the MFA program's visiting-writer series, so there is essentially no universe where she doesn't find out",
"The scene in question involves a specific argument about whether she was going to leave Alpine \u2014 a real argument, with real things said \u2014 that she told him in confidence during what she thought was a private moment at the Kokernot ballpark at dusk",
"His thesis advisor has already told him this piece is 'the one' and written it into a fellowship application that has also already been submitted"
],
"emotional_register": "Defensive and intellectualizing at first \u2014 reaching for craft-talk and literary precedent \u2014 then progressively quieter as he hears himself. He's not a villain, he's a 29-year-old who made a choice he knew was wrong and is only now feeling the weight of it at full scale."
},
"avatar": "Crispin.jpg",
"relationships": {},
"call_history": [
{
"summary": "Carl, a firefighter from Lordsburg, New Mexico, called to confess his 20-year gambling addiction, which began with casual poker games at the station and escalated to frequent casino visits and online sessions, draining his finances and leaving him with overdue bills and the fear of losing his home. Emotionally raw, he admitted the habit's destructive hold\u2014like an unquenchable fire\u2014and his pride in avoiding help, but agreed to consider support groups and an 800 hotline after the host suggested productive alternatives like gym workouts or extra volunteer shifts.",
"timestamp": 1770522170.1887732
},
{
"summary": "Here is a 1-2 sentence summary of the radio call:\n\nThe caller, Carl, discusses his progress in overcoming his gambling addiction, including rewatching The Sopranos, but the host, Luke, disagrees with Carl's high opinion of the show's ending, leading to a back-and-forth debate between the two about the merits and predictability of the Sopranos finale.",
"timestamp": 1770573289.82847
},
{
"summary": "Carl, a firefighter, called to discuss finding $15-20,000 in cash at a house fire and struggling with the temptation to keep it despite doing the right thing by returning it to the family. He's been gambling-free for three months but is financially struggling, and though he returned the money, he's been losing sleep for three nights obsessing over what he could have done with it and fearing he might have blown it at a casino anyway.",
"timestamp": 1770694065.5629818
"summary": "Crispin, a fiction writer, is panicking because a literary journal accepted a story he wrote based on a past relationship, and he fears his ex will recognize herself and be hurt by the intimate details. He feels immense guilt and is struggling with the decision to either withdraw the story and potentially damage his career, or allow it to be published and face the consequences with his ex.",
"timestamp": 1780395016.4822028,
"arc_status": "ongoing"
}
],
"last_call": 1770694065.5629828,
"created_at": 1770522170.1887732,
"voice": "SOYHLrjzK2X1ezoPC6cr"
},
{
"id": "04b1a69c",
"name": "Reggie",
"gender": "male",
"age": 51,
"job": "a 39-year-old food truck operator, is reeling from a troubling discovery this morning",
"location": "in unknown",
"personality_traits": [],
"call_history": [
{
"summary": "Reggie called in worried because his partner suddenly packed a bag and left for her mom's house without explanation and won't answer his calls, making him fear something is wrong with their relationship. The host advised him to stop calling repeatedly and have a calm conversation with her when she's ready to talk, reassuring him he's likely overreacting.",
"timestamp": 1770769705.511872
}
],
"last_call": 1770769705.511872,
"created_at": 1770769705.511872,
"voice": "N2lVS1w4EtoT3dr4eOWO"
},
{
"id": "747c6464",
"name": "Brenda",
"gender": "female",
"age": 44,
"job": "a 41-year-old ambulance driver, is fed up with the tipping culture",
"location": "unknown",
"personality_traits": [],
"call_history": [
{
"summary": "Brenda called in to vent about being frustrated with automatic tipping at a diner, where a 20% tip was already added to her bill but the card reader prompted her to add an additional 25-35% while the waitress watched. She expressed feeling pressured and annoyed as an ambulance driver with two kids, struggling with whether to look cheap by reducing the tip, before playing a quick real-or-fake news game with the host.",
"timestamp": 1770770008.684104
},
{
"summary": "Brenda called in still thinking about whether a waitress remembered her tipping situation from two weeks ago, admitting she cares too much about what strangers think of her. The conversation revealed she's been avoiding dating entirely while working long shifts and dealing with family obligations, acknowledging she obsesses over small social interactions instead of actually putting herself out there romantically.",
"timestamp": 1771120062.169228
}
],
"last_call": 1771120062.169229,
"created_at": 1770770008.684105,
"voice": "hpp4J3VqNfWAUOO0d1Us"
},
{
"id": "add59d4a",
"name": "Rick",
"gender": "male",
"age": 65,
"job": "south of Silver City",
"location": "unknown",
"personality_traits": [],
"call_history": [
{
"summary": "Rick called in to play \"real news or fake news\" and correctly identified a headline about a geothermal plant sale. He then shared that he's troubled about an elderly bank customer who withdrew $8,000 cash while appearing scared and mentioning his daughter's boyfriend was pressuring him about finances\u2014Rick processed the withdrawal but later learned he should have flagged it as potential elder exploitation, and he's feeling guilty about not intervening.",
"timestamp": 1770771655.536344
},
{
"summary": "Rick, a 65-year-old caller, is asked to evaluate a dating profile for 29-year-old Angela, a \"girl mom\" and MLM skin care seller with strong Christian values. He quickly passes due to the extreme age gap and her intense focus on recruiting for her \"not a pyramid scheme\" business, though he says he'd reconsider if she toned down the sales pitch and religious intensity.",
"timestamp": 1771126337.585641
}
],
"last_call": 1771126337.585642,
"created_at": 1770771655.536344,
"voice": "TX3LPaxmHKxFdv7VOQHJ"
},
{
"id": "13ff1736",
"name": "Jasmine",
"gender": "female",
"age": 36,
"job": "a 61-year-old woman who runs a small bakery in the rural Southwest, finds herself at a crossroads",
"location": "unknown",
"personality_traits": [],
"call_history": [
{
"summary": "Jasmine called in to defend an earlier caller (Rick) whom she felt the host was too hard on, explaining she's been feeling guilty herself lately. She emotionally revealed that she chose her 1972 Ford Bronco restoration project over her marriage when given an ultimatum, and now regrets sleeping in the guest room with Valentine's Day approaching.",
"timestamp": 1770772286.1733272
},
{
"summary": "Jasmine called to update Luke about her relationship with David after previously discussing their issues over her Ford Bronco obsession. David invited her to watch a SpaceX launch together before Valentine's Day, but she's anxious it will be awkward since they've barely talked in weeks, though Luke convinces her to just enjoy the moment together without forcing conversation.",
"timestamp": 1771033676.7729769
}
],
"last_call": 1771033676.7729769,
"created_at": 1770772286.1733272,
"voice": "pFZP5JQG7iQjIQuC4Bku"
},
{
"id": "f21d1346",
"name": "Andre",
"gender": "male",
"age": 54,
"job": "is a firefighter unknown",
"location": "in unknown",
"personality_traits": [],
"call_history": [
{
"summary": "Andre called into a radio game show but first shared that he's upset about being named in court documents related to a lawsuit involving a family he helped in December by returning $15,000 after a house fire. Though the host reassured him he has nothing to worry about since he did the right thing, Andre expressed frustration that his good deed led to him being dragged into an insurance dispute.",
"timestamp": 1770770944.7940538
},
{
"summary": "Andre calls back with an update: the lawsuit against him was dropped, and the family he helped sent him a card with $500 cash, which makes him feel conflicted about accepting payment for doing the right thing. On a positive note, he's been gambling-free for two months and attending meetings, and Luke encourages him to keep the money or donate it, celebrating his progress.",
"timestamp": 1770870907.493257
}
],
"last_call": 1770870907.493258,
"created_at": 1770770944.7940538,
"voice": "JBFqnCBsd6RMkjVDRZzb"
},
{
"id": "d97cb6f9",
"name": "Carla",
"gender": "female",
"age": 26,
"job": "is a vet tech",
"location": "unknown",
"personality_traits": [],
"call_history": [
{
"summary": "Carla, separated from her husband but not yet divorced, vented about her intrusive in-laws who relentlessly call and dictate her life\u2014from finances and household matters to her clothing choices\u2014while her spineless spouse relays their demands, making her feel trapped in a one-sided war. With her own parents unavailable (father deceased, mother distant), she leans on her bickering but honest sister for support, underscoring her deep frustration and sense of isolation.",
"timestamp": 1770522530.8554251
},
{
"summary": "Carla dismissed celebrity science theories like Terrence Howard's after watching Neil deGrasse Tyson's critique, then marveled at JWST's exoplanet discoveries before sharing her relief at finally cutting off her toxic in-laws amid her ongoing divorce. She expressed deep heartbreak over actor James Ransone's suicide at 46, reflecting on life's fragility, her late father's death, and the need to eliminate family drama, leaving her contemplative and planning a solo desert drive for clarity.",
"timestamp": 1770526316.004708
},
{
"summary": "In this call, Carla discovered some explicit photos of her ex-husband and his old girlfriend in a box of his old ham radio equipment. She is feeling very uncomfortable about the situation and is seeking advice from the radio host, Luke, on how to best handle and dispose of the photos.",
"timestamp": 1770602323.234795
},
{
"summary": "Carla called with an update about burning the explicit photos of her ex-husband and his old girlfriend, revealing that the girlfriend unexpectedly messaged her on Facebook to \"clear the air\" after apparently hearing about the situation through Carla's previous radio call. When Luke asked about her most embarrassing masturbation material, Carla admitted to using historical romance novels during her failing marriage, explaining she was drawn to the fantasy of men who actually cared and paid attention, unlike her ex-husband who ignored her to play video games.",
"timestamp": 1770871317.049056
},
{
"summary": "Okay, here's a 1-2 sentence summary of the radio call:\n\nThe caller, Carla, was asked to give her honest opinion on a dating profile for a man named Todd. After reviewing the profile, Carla politely declined, explaining that the profile seemed a bit \"try-hard\" for her tastes, and outlined the qualities she would prefer in a potential date, such as a good sense of humor and an adventurous spirit. The host acknowledged that Carla was not interested in dating Todd.",
"timestamp": 1771121545.873672
}
],
"last_call": 1771121545.873673,
"created_at": 1770522530.855426,
"voice": "FGY2WhTYpPnrIDTdsKH5"
},
{
"id": "7be7317c",
"name": "Jerome",
"gender": "male",
"age": 53,
"job": "phone",
"location": "unknown",
"personality_traits": [],
"call_history": [
{
"summary": "Jerome, a police officer in Texas, called from a DQ parking lot worried about AI writing police reports after his son sent him an article suggesting it might replace him. Through the conversation, he moved from fear about accountability and accuracy in criminal cases to acknowledging that AI handling routine paperwork (like cattle complaints) could free him up to do more meaningful police work in his understaffed county, though he remains uncertain about where this technology will lead.",
"timestamp": 1770692087.560522
},
{
"summary": "The caller described a turbulent couple of weeks, mentioning an issue with AI writing police reports, which he suggested was just the beginning of a larger problem. He seemed concerned about the developments and wanted to discuss the topic further with the host.",
"timestamp": 1770892192.893108
}
],
"last_call": 1770892192.89311,
"created_at": 1770692087.560523,
"voice": "IKne3meq5aSn9XLyUdCD"
},
{
"id": "f383d29b",
"name": "Megan",
"gender": "female",
"age": 34,
"job": "which got her thinking about her sister Crystal up in Flagstaff who hasn't seen a truly dark sky",
"location": "unknown",
"personality_traits": [],
"call_history": [
{
"summary": "Megan, a kindergarten teacher from the bootheel, called in after one of her students asked if stars know we're looking at them, which led her to reflect on how her sister Crystal in Flagstaff has stopped appreciating the night sky despite having access to it. The conversation took an unexpected turn when Luke challenged her to admit a gross habit, and after some prodding, she confessed to picking dry skin off her feet while watching TV and flicking it on the floor.",
"timestamp": 1770870641.723117
},
{
"summary": "Here is a 1-2 sentence summary of the call:\n\nThe caller, Megan, is following up on a previous call about her sister Crystal, who lives in Flagstaff and has lost appreciation for the night sky. Megan seems eager to provide an update on the situation with her sister.",
"timestamp": 1770894505.175125
},
{
"summary": "In summary, the caller presented a dating profile for a 63-year-old man named Frank who loves making birdhouses. The host, Megan, gave her honest assessment - she appreciated some aspects of Frank's profile, like his openness about his situation, but had reservations about his intense birdhouse obsession. Megan seemed unsure if they would be a good match, despite the host's attempts to get her to consider dating Frank under different hypothetical circumstances. The conversation focused on Megan's reaction to Frank's profile and her hesitation about pursuing a relationship with him.",
"timestamp": 1771122973.966489
}
],
"last_call": 1771122973.96649,
"created_at": 1770870641.723117,
"voice": "cgSgspJ2msm6clMCkdW9"
},
{
"id": "49147bd5",
"name": "Keith",
"gender": "male",
"age": 61,
"job": "south of Silver City",
"location": "in unknown",
"personality_traits": [],
"call_history": [
{
"summary": "The caller, Luke, kicked off by sharing a humorous clip of Terrence Howard's Tree of Life Theory being critiqued by Neil deGrasse Tyson, which left Howard visibly hurt, before pivoting to economic woes, blaming overspending and Federal Reserve money printing for devaluing the currency and harming everyday people. He advocated abolishing the Fed, echoing Ron Paul's ideas, to let markets stabilize money, potentially boosting innovation and new industries in rural spots like Silver City despite uncertain local impacts.",
"timestamp": 1770524506.3390348
},
{
"summary": "Here is a 1-2 sentence summary of the call:\n\nThe caller, who works at a bank, has been reflecting on his tendency to blame the government and economic system for his problems, rather than taking responsibility for his own role. He had an epiphany while eating leftover enchiladas in his minivan, realizing he needs to be more proactive instead of just complaining.",
"timestamp": 1770574890.1296651
},
{
"summary": "Keith called in with an update about a widow who has been showing up weekly at the cemetery where he works nights, but she sits by the maintenance shed rather than visiting her husband's grave, and recently started asking Keith's neighbor personal questions about him. Luke dismissively suggested Keith just talk to the woman and called him a coward for being concerned, leading to some tension before they moved on to playing the real or fake news game.",
"timestamp": 1770770394.0436218
},
{
"summary": "Keith called back to update the host about a widow he befriended at the cemetery where he works, revealing she's been seeking him out during his shifts, bringing him coffee, and has now invited him to her apartment\u2014which he's conflicted about because his marriage to Teresa has become cold and distant, though he's scared to address it. The conversation shifted from the widow situation to Keith admitting he needs to have hard conversations with his wife about their deteriorating relationship, and he got emotional reflecting on how he and Teresa \"stopped being on the same team\" and how terrifying it would be to split up after being together for over half his life.",
"timestamp": 1770950476.527814
}
],
"last_call": 1770950476.527814,
"created_at": 1770524506.339036,
"voice": "nPczCjzI2devNBz1zQrb"
},
{
"id": "0d244eeb",
"name": "Gus",
"gender": "male",
"age": 33,
"job": "",
"location": "in unknown",
"personality_traits": [],
"voice": "Alex",
"call_history": [
{
"summary": "Gus called because his ex Melissa showed up at his pawn shop job with flowers wanting to reconcile, and his current girlfriend Sara saw it through the window and now won't talk to him. Despite the host's dismissive advice (including sarcastically suggesting he regift the same flowers), Gus insisted he wants to be with Sara and acknowledged he should have shut down his ex immediately instead of freezing up, though he defended that Sara's reaction to seeing this wasn't unreasonable jealousy.",
"timestamp": 1770951226.534601
}
],
"last_call": 1770951226.534601,
"created_at": 1770951226.534601
"last_call": 1780395016.482204,
"created_at": 1780395016.482204
}
]
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+25
View File
@@ -0,0 +1,25 @@
{
"voicemails": [
{
"id": "b3b1db17",
"phone": "+15753424105",
"timestamp": 1782584345,
"duration": 78,
"file_path": "/Users/lukemacneil/code/ai-podcast/data/voicemails/1785804001_15753424105.wav",
"listened": true,
"transcript": "Yeah, hi. Okay, this, my name is Sandra, and I would like to find out if I, what kind of subjects you're covering so I could be involved. I think we, we the people better know our Constitution founding documents and decoration of independence, and we better take on some of the responsibilities. of stopping the destruction of our country as we, the people, with regards to, you know, our elections become members of Judicial Watch. They're out there fighting for that, stop the fraudulent election, and there's other things. So I'd like to do a show if there's room on your radio. station and to find out where it's located. My number is 575-342-4105-5-75-342-4105. Thank you. Bye-bye."
}
],
"deleted_timestamps": [
1772294240,
1771212705,
1771146434,
1771146564,
1773545733,
1771146952,
1773531209,
1771244817,
1771244823,
1771213151
]
}
+59
View File
@@ -0,0 +1,59 @@
#!/bin/bash
# Deploy SearXNG to the QNAP NAS (mmgnas) as Devon's always-on web-search backend.
# Public image — no build. Ships a settings.yml that enables the JSON API and
# disables the bot limiter so the backend can query format=json programmatically.
set -e
NAS_PORT="8001"
NAS_USER="luke"
NAS_HOST="${NAS_HOST:-mmgnas}" # override with NAS_HOST=mmgnas-10g for wired
DOCKER="/share/CACHEDEV1_DATA/.qpkg/container-station/bin/docker"
DEPLOY_DIR="/share/CACHEDEV1_DATA/docker/searxng"
CONTAINER="searxng"
HOST_PORT="8888"
IMAGE="searxng/searxng:latest"
SSH="ssh -p $NAS_PORT $NAS_USER@$NAS_HOST"
echo "==> Ensuring deploy dir $DEPLOY_DIR"
$SSH "mkdir -p $DEPLOY_DIR"
# Generate settings.yml only if absent (preserve secret_key across redeploys)
if ! $SSH "test -f $DEPLOY_DIR/settings.yml"; then
echo "==> Writing settings.yml (first deploy)"
SECRET=$(openssl rand -hex 32)
TMP=$(mktemp)
cat > "$TMP" <<EOF
use_default_settings: true
server:
secret_key: "$SECRET"
bind_address: "0.0.0.0"
limiter: false
image_proxy: true
search:
safe_search: 0
formats:
- html
- json
EOF
scp -P "$NAS_PORT" "$TMP" "$NAS_USER@$NAS_HOST:$DEPLOY_DIR/settings.yml"
rm "$TMP"
else
echo "==> settings.yml already present — leaving it (and its secret_key) intact"
fi
echo "==> Pulling $IMAGE"
$SSH "$DOCKER pull $IMAGE"
echo "==> (Re)starting container"
$SSH "$DOCKER rm -f $CONTAINER 2>/dev/null || true"
$SSH "$DOCKER run -d --name $CONTAINER --restart unless-stopped \
-p $HOST_PORT:8080 \
-v $DEPLOY_DIR:/etc/searxng \
-e SEARXNG_BASE_URL=http://$NAS_HOST:$HOST_PORT/ \
$IMAGE"
echo "==> Verifying"
sleep 6
$SSH "$DOCKER ps --filter name=$CONTAINER --format '{{.Status}}'"
$SSH "$DOCKER logs $CONTAINER 2>&1 | tail -15"
+7 -3
View File
@@ -23,8 +23,7 @@ TMPFILE=$(mktemp)
cat > "$TMPFILE" << 'DOCKERFILE'
FROM python:3.11-slim
RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/* \
&& curl -fsSL https://download.docker.com/linux/static/stable/x86_64/docker-27.5.1.tgz | tar xz --strip-components=1 -C /usr/local/bin docker/docker \
&& apt-get purge -y curl && apt-get autoremove -y
&& curl -fsSL https://download.docker.com/linux/static/stable/x86_64/docker-27.5.1.tgz | tar xz --strip-components=1 -C /usr/local/bin docker/docker
RUN pip install --no-cache-dir requests yt-dlp
COPY podcast_stats.py /app/podcast_stats.py
COPY run_loop.sh /app/run_loop.sh
@@ -42,7 +41,12 @@ cat > "$TMPFILE" << 'LOOPSCRIPT'
echo "podcast-stats: starting hourly loop"
while true; do
echo "$(date -u '+%Y-%m-%dT%H:%M:%SZ') Running stats update..."
python podcast_stats.py --json --upload 2>&1 || echo " ...failed, will retry next hour"
if python podcast_stats.py --json --upload 2>&1; then
[ -n "$HEARTBEAT_URL" ] && curl -s "${HEARTBEAT_URL}?status=up&msg=OK" > /dev/null
echo " ...done, heartbeat sent"
else
echo " ...failed, will retry next hour"
fi
echo "Sleeping 1 hour..."
sleep 3600
done
+505
View File
@@ -0,0 +1,505 @@
# Clip Social Media Upload Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Generate social media descriptions/hashtags for podcast clips and upload them to Instagram Reels + YouTube Shorts via Postiz API.
**Architecture:** Two changes — (1) extend `make_clips.py` to add a second LLM call that generates descriptions + hashtags, saved as `clips-metadata.json`, (2) new `upload_clips.py` script that reads that metadata and pushes clips through the self-hosted Postiz instance at `social.lukeattheroost.com`.
**Tech Stack:** Python, OpenRouter API (Claude Sonnet), Postiz REST API, requests library (already installed)
---
### Task 1: Add `generate_social_metadata()` to `make_clips.py`
**Files:**
- Modify: `make_clips.py:231-312` (after `select_clips_with_llm`)
**Step 1: Add the function after `select_clips_with_llm`**
Add this function at line ~314 (after `select_clips_with_llm` returns):
```python
def generate_social_metadata(clips: list[dict], labeled_transcript: str,
episode_number: int | None) -> list[dict]:
"""Generate social media descriptions and hashtags for each clip."""
if not OPENROUTER_API_KEY:
print("Error: OPENROUTER_API_KEY not set in .env")
sys.exit(1)
clips_summary = "\n".join(
f'{i+1}. "{c["title"]}"{c["caption_text"]}'
for i, c in enumerate(clips)
)
episode_context = f"This is Episode {episode_number} of " if episode_number else "This is an episode of "
prompt = f"""{episode_context}the "Luke at the Roost" podcast — a late-night call-in show where AI-generated callers share stories, confessions, and hot takes with host Luke.
Here are {len(clips)} clips selected from this episode:
{clips_summary}
For each clip, generate:
1. description: A short, engaging description for social media (1-2 sentences, hook the viewer, conversational tone). Do NOT include hashtags in the description.
2. hashtags: An array of 5-8 hashtags. Always include #lukeattheroost and #podcast. Add topic-relevant and trending-style tags.
Respond with ONLY a JSON array matching the clip order:
[{{"description": "...", "hashtags": ["#tag1", "#tag2", ...]}}]"""
response = requests.post(
"https://openrouter.ai/api/v1/chat/completions",
headers={
"Authorization": f"Bearer {OPENROUTER_API_KEY}",
"Content-Type": "application/json",
},
json={
"model": "anthropic/claude-sonnet-4-5",
"messages": [{"role": "user", "content": prompt}],
"max_tokens": 2048,
"temperature": 0.7,
},
)
if response.status_code != 200:
print(f"Error from OpenRouter: {response.text}")
return clips
content = response.json()["choices"][0]["message"]["content"].strip()
if content.startswith("```"):
content = re.sub(r"^```(?:json)?\n?", "", content)
content = re.sub(r"\n?```$", "", content)
try:
metadata = json.loads(content)
except json.JSONDecodeError as e:
print(f"Error parsing social metadata: {e}")
return clips
for i, clip in enumerate(clips):
if i < len(metadata):
clip["description"] = metadata[i].get("description", "")
clip["hashtags"] = metadata[i].get("hashtags", [])
return clips
```
**Step 2: Run existing tests to verify no breakage**
Run: `pytest tests/ -v`
Expected: All existing tests pass (this is a new function, no side effects yet)
**Step 3: Commit**
```bash
git add make_clips.py
git commit -m "Add generate_social_metadata() for clip descriptions and hashtags"
```
---
### Task 2: Integrate metadata generation + JSON save into `main()`
**Files:**
- Modify: `make_clips.py:1082-1289` (inside `main()`)
**Step 1: Add metadata generation call and JSON save**
After the LLM clip selection step (~line 1196, after the clip summary print loop), add:
```python
# Step N: Generate social media metadata
print(f"\n[{extract_step - 1}/{step_total}] Generating social media descriptions...")
clips = generate_social_metadata(clips, labeled_transcript, episode_number)
for i, clip in enumerate(clips):
if "description" in clip:
print(f" Clip {i+1}: {clip['description'][:80]}...")
print(f" {' '.join(clip.get('hashtags', []))}")
```
Note: This needs to be inserted BEFORE the audio extraction step, and the step numbering needs to be adjusted (total steps goes from 5/6 to 6/7).
At the end of `main()`, before the summary print, save the metadata JSON:
```python
# Save clips metadata for social upload
metadata_path = output_dir / "clips-metadata.json"
metadata = []
for i, clip in enumerate(clips):
slug = slugify(clip["title"])
metadata.append({
"title": clip["title"],
"clip_file": f"clip-{i+1}-{slug}.mp4",
"audio_file": f"clip-{i+1}-{slug}.mp3",
"caption_text": clip.get("caption_text", ""),
"description": clip.get("description", ""),
"hashtags": clip.get("hashtags", []),
"start_time": clip["start_time"],
"end_time": clip["end_time"],
"duration": round(clip["end_time"] - clip["start_time"], 1),
"episode_number": episode_number,
})
with open(metadata_path, "w") as f:
json.dump(metadata, f, indent=2)
print(f"\nSocial metadata: {metadata_path}")
```
**Step 2: Adjust step numbering**
The pipeline steps need to account for the new metadata step. Update `step_total` calculation:
```python
step_total = (7 if two_pass else 6)
```
And shift the extract/video step numbers up by 1.
**Step 3: Test manually**
Run: `python make_clips.py --help`
Expected: No import errors, help displays normally
**Step 4: Commit**
```bash
git add make_clips.py
git commit -m "Save clips-metadata.json with social descriptions and hashtags"
```
---
### Task 3: Create `upload_clips.py` — core structure and Postiz API helpers
**Files:**
- Create: `upload_clips.py`
**Step 1: Write the script**
```python
#!/usr/bin/env python3
"""Upload podcast clips to Instagram Reels and YouTube Shorts via Postiz.
Usage:
python upload_clips.py clips/episode-12/
python upload_clips.py clips/episode-12/ --clip 1
python upload_clips.py clips/episode-12/ --youtube-only
python upload_clips.py clips/episode-12/ --instagram-only
python upload_clips.py clips/episode-12/ --schedule "2026-02-16T10:00:00"
python upload_clips.py clips/episode-12/ --yes # skip confirmation
"""
import argparse
import json
import sys
from pathlib import Path
import requests
from dotenv import load_dotenv
import os
load_dotenv(Path(__file__).parent / ".env")
POSTIZ_API_KEY = os.getenv("POSTIZ_API_KEY")
POSTIZ_URL = os.getenv("POSTIZ_URL", "https://social.lukeattheroost.com")
def get_api_url(path: str) -> str:
"""Build full Postiz API URL."""
base = POSTIZ_URL.rstrip("/")
# Postiz self-hosted API is at /api/public/v1 when NEXT_PUBLIC_BACKEND_URL is the app URL
# but the docs say /public/v1 relative to backend URL. Try the standard path.
return f"{base}/api/public/v1{path}"
def api_headers() -> dict:
return {
"Authorization": POSTIZ_API_KEY,
"Content-Type": "application/json",
}
def fetch_integrations() -> list[dict]:
"""Fetch connected social accounts from Postiz."""
resp = requests.get(get_api_url("/integrations"), headers=api_headers(), timeout=15)
if resp.status_code != 200:
print(f"Error fetching integrations: {resp.status_code} {resp.text[:200]}")
sys.exit(1)
return resp.json()
def find_integration(integrations: list[dict], provider: str) -> dict | None:
"""Find integration by provider name (e.g. 'instagram', 'youtube')."""
for integ in integrations:
if integ.get("providerIdentifier", "").startswith(provider):
return integ
if integ.get("provider", "").startswith(provider):
return integ
return None
def upload_file(file_path: Path) -> dict:
"""Upload a file to Postiz. Returns {id, path}."""
headers = {"Authorization": POSTIZ_API_KEY}
with open(file_path, "rb") as f:
resp = requests.post(
get_api_url("/upload"),
headers=headers,
files={"file": (file_path.name, f, "video/mp4")},
timeout=120,
)
if resp.status_code != 200:
print(f"Upload failed: {resp.status_code} {resp.text[:200]}")
return {}
return resp.json()
def create_post(integration_id: str, content: str, media: dict,
settings: dict, schedule: str | None = None) -> dict:
"""Create a post on Postiz."""
post_type = "schedule" if schedule else "now"
payload = {
"type": post_type,
"posts": [
{
"integration": {"id": integration_id},
"value": [
{
"content": content,
"image": [media] if media else [],
}
],
"settings": settings,
}
],
}
if schedule:
payload["date"] = schedule
resp = requests.post(
get_api_url("/posts"),
headers=api_headers(),
json=payload,
timeout=30,
)
if resp.status_code not in (200, 201):
print(f"Post creation failed: {resp.status_code} {resp.text[:300]}")
return {}
return resp.json()
def build_instagram_content(clip: dict) -> str:
"""Build Instagram post content: description + hashtags."""
parts = [clip.get("description", clip.get("caption_text", ""))]
hashtags = clip.get("hashtags", [])
if hashtags:
parts.append("\n\n" + " ".join(hashtags))
return "".join(parts)
def build_youtube_content(clip: dict) -> str:
"""Build YouTube description."""
parts = [clip.get("description", clip.get("caption_text", ""))]
hashtags = clip.get("hashtags", [])
if hashtags:
parts.append("\n\n" + " ".join(hashtags))
parts.append("\n\nListen to the full episode: lukeattheroost.com")
return "".join(parts)
def main():
parser = argparse.ArgumentParser(description="Upload podcast clips to social media via Postiz")
parser.add_argument("clips_dir", help="Path to clips directory (e.g. clips/episode-12/)")
parser.add_argument("--clip", "-c", type=int, help="Upload only clip N (1-indexed)")
parser.add_argument("--instagram-only", action="store_true", help="Upload to Instagram only")
parser.add_argument("--youtube-only", action="store_true", help="Upload to YouTube only")
parser.add_argument("--schedule", "-s", help="Schedule time (ISO 8601, e.g. 2026-02-16T10:00:00)")
parser.add_argument("--yes", "-y", action="store_true", help="Skip confirmation prompt")
parser.add_argument("--dry-run", action="store_true", help="Show what would be uploaded without posting")
args = parser.parse_args()
if not POSTIZ_API_KEY:
print("Error: POSTIZ_API_KEY not set in .env")
sys.exit(1)
clips_dir = Path(args.clips_dir).expanduser().resolve()
metadata_path = clips_dir / "clips-metadata.json"
if not metadata_path.exists():
print(f"Error: No clips-metadata.json found in {clips_dir}")
print("Run make_clips.py first to generate clips and metadata.")
sys.exit(1)
with open(metadata_path) as f:
clips = json.load(f)
if args.clip:
if args.clip < 1 or args.clip > len(clips):
print(f"Error: Clip {args.clip} not found (have {len(clips)} clips)")
sys.exit(1)
clips = [clips[args.clip - 1]]
# Determine which platforms to post to
do_instagram = not args.youtube_only
do_youtube = not args.instagram_only
# Fetch integrations from Postiz
print("Fetching connected accounts from Postiz...")
integrations = fetch_integrations()
ig_integration = None
yt_integration = None
if do_instagram:
ig_integration = find_integration(integrations, "instagram")
if not ig_integration:
print("Warning: No Instagram account connected in Postiz")
do_instagram = False
if do_youtube:
yt_integration = find_integration(integrations, "youtube")
if not yt_integration:
print("Warning: No YouTube account connected in Postiz")
do_youtube = False
if not do_instagram and not do_youtube:
print("Error: No platforms available to upload to")
sys.exit(1)
# Show summary
platforms = []
if do_instagram:
platforms.append(f"Instagram Reels ({ig_integration.get('name', 'connected')})")
if do_youtube:
platforms.append(f"YouTube Shorts ({yt_integration.get('name', 'connected')})")
print(f"\nUploading {len(clips)} clip(s) to: {', '.join(platforms)}")
if args.schedule:
print(f"Scheduled for: {args.schedule}")
print()
for i, clip in enumerate(clips):
print(f" {i+1}. \"{clip['title']}\" ({clip['duration']:.0f}s)")
print(f" {clip.get('description', '')[:80]}")
print(f" {' '.join(clip.get('hashtags', []))}")
print()
if args.dry_run:
print("Dry run — nothing uploaded.")
return
if not args.yes:
confirm = input("Proceed? [y/N] ").strip().lower()
if confirm != "y":
print("Cancelled.")
return
# Upload each clip
for i, clip in enumerate(clips):
clip_file = clips_dir / clip["clip_file"]
if not clip_file.exists():
print(f" Clip {i+1}: Video file not found: {clip_file}")
continue
print(f"\n Clip {i+1}: \"{clip['title']}\"")
# Upload video to Postiz
print(f" Uploading {clip_file.name}...")
media = upload_file(clip_file)
if not media:
print(f" Failed to upload video, skipping")
continue
print(f" Uploaded: {media.get('path', 'ok')}")
# Post to Instagram Reels
if do_instagram:
print(f" Posting to Instagram Reels...")
content = build_instagram_content(clip)
settings = {
"__type": "instagram",
"post_type": "reel",
}
result = create_post(
ig_integration["id"], content, media, settings, args.schedule
)
if result:
print(f" Instagram: Posted!")
else:
print(f" Instagram: Failed")
# Post to YouTube Shorts
if do_youtube:
print(f" Posting to YouTube Shorts...")
content = build_youtube_content(clip)
settings = {
"__type": "youtube",
"title": clip["title"],
"type": "short",
"selfDeclaredMadeForKids": False,
"tags": [h.lstrip("#") for h in clip.get("hashtags", [])],
}
result = create_post(
yt_integration["id"], content, media, settings, args.schedule
)
if result:
print(f" YouTube: Posted!")
else:
print(f" YouTube: Failed")
print(f"\nDone!")
if __name__ == "__main__":
main()
```
**Step 2: Add `POSTIZ_API_KEY` and `POSTIZ_URL` to `.env`**
Add to `.env`:
```
POSTIZ_API_KEY=your-postiz-api-key-here
POSTIZ_URL=https://social.lukeattheroost.com
```
Get your API key from Postiz Settings page.
**Step 3: Test the script loads**
Run: `python upload_clips.py --help`
Expected: Help text displays with all flags
**Step 4: Commit**
```bash
git add upload_clips.py
git commit -m "Add upload_clips.py for posting clips to Instagram/YouTube via Postiz"
```
---
### Task 4: Test with real Postiz instance
**Step 1: Get Postiz API key**
Go to `https://social.lukeattheroost.com` → Settings → API Keys → Generate key. Add to `.env` as `POSTIZ_API_KEY`.
**Step 2: Verify integrations endpoint**
Run: `python -c "from upload_clips import *; print(json.dumps(fetch_integrations(), indent=2))"`
This confirms the API key works and shows connected Instagram/YouTube accounts. Note the integration IDs and provider identifiers — if `find_integration()` doesn't match correctly, adjust the provider string matching.
**Step 3: Dry-run with existing clips**
Run: `python upload_clips.py clips/episode-12/ --dry-run`
Expected: Shows clip summary, "Dry run — nothing uploaded."
**Step 4: Upload a single test clip**
Run: `python upload_clips.py clips/episode-12/ --clip 1 --instagram-only`
Check Postiz dashboard and Instagram to verify it posted as a Reel.
**Step 5: Commit .env update (do NOT commit the key itself)**
The `.env` is gitignored so no action needed. Just ensure the key names are documented in CLAUDE.md if desired.
+402
View File
@@ -0,0 +1,402 @@
# Idents Playback Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Add an idents section that loads MP3s from `idents/` and plays them through the ads channel (ch 11), with a separate "idents" stem for post-production.
**Architecture:** Mirrors the existing ads system — dropdown + play/stop buttons, same audio channel, mutually exclusive with ads. Idents get their own stem in stem_recorder so they can be mixed independently in post-production.
**Tech Stack:** Python (FastAPI), sounddevice, librosa, vanilla JS
---
### Task 1: Add idents_dir to config
**Files:**
- Modify: `backend/config.py:46-47`
**Step 1: Add idents_dir path**
After `ads_dir` (line 46), add:
```python
idents_dir: Path = base_dir / "idents"
```
**Step 2: Create the idents directory**
```bash
mkdir -p idents
```
**Step 3: Commit**
```bash
git add backend/config.py
git commit -m "Add idents_dir to config"
```
---
### Task 2: Add "idents" stem to stem_recorder
**Files:**
- Modify: `backend/services/stem_recorder.py:10`
**Step 1: Add "idents" to STEM_NAMES**
Change line 10 from:
```python
STEM_NAMES = ["host", "caller", "music", "sfx", "ads"]
```
to:
```python
STEM_NAMES = ["host", "caller", "music", "sfx", "ads", "idents"]
```
**Step 2: Add "idents" to postprod.py STEM_NAMES**
In `postprod.py:20`, change:
```python
STEM_NAMES = ["host", "caller", "music", "sfx", "ads"]
```
to:
```python
STEM_NAMES = ["host", "caller", "music", "sfx", "ads", "idents"]
```
Also update `postprod.py:72` — the `remove_gaps` content detection line — add idents:
```python
content = stems["host"] + stems["caller"] + stems["sfx"] + stems["ads"] + stems["idents"]
```
And in `mix_stems` (line 411), add idents level:
```python
levels = {"host": 0, "caller": 0, "music": -6, "sfx": -10, "ads": 0, "idents": 0}
```
And in stereo pans (line 420):
```python
pans = {"host": 0.0, "caller": 0.15, "music": 0.0, "sfx": 0.0, "ads": 0.0, "idents": 0.0}
```
And in `match_voice_levels` (line 389), add "idents":
```python
for name in ["host", "caller", "ads", "idents"]:
```
And in gap removal limiter section (line 777-778):
```python
for name in ["ads", "sfx", "idents"]:
```
**Step 3: Commit**
```bash
git add backend/services/stem_recorder.py postprod.py
git commit -m "Add idents stem to recorder and postprod"
```
---
### Task 3: Add play_ident / stop_ident to audio service
**Files:**
- Modify: `backend/services/audio.py`
**Step 1: Add ident state vars to __init__ (after line 40)**
After the ad playback state block (lines 35-40), add:
```python
# Ident playback state
self._ident_stream: Optional[sd.OutputStream] = None
self._ident_data: Optional[np.ndarray] = None
self._ident_resampled: Optional[np.ndarray] = None
self._ident_position: int = 0
self._ident_playing: bool = False
```
**Step 2: Add play_ident method (after stop_ad, ~line 1006)**
Insert after `stop_ad` method. This is a copy of `play_ad` with:
- `_ad_*``_ident_*`
- Calls `self.stop_ad()` at the start (mutual exclusion)
- Stem recording writes to `"idents"` instead of `"ads"`
```python
def play_ident(self, file_path: str):
"""Load and play an ident file once (no loop) on the ad channel"""
import librosa
path = Path(file_path)
if not path.exists():
print(f"Ident file not found: {file_path}")
return
self.stop_ident()
self.stop_ad()
try:
audio, sr = librosa.load(str(path), sr=self.output_sample_rate, mono=True)
self._ident_data = audio.astype(np.float32)
except Exception as e:
print(f"Failed to load ident: {e}")
return
self._ident_playing = True
self._ident_position = 0
if self.output_device is None:
num_channels = 2
device = None
device_sr = self.output_sample_rate
channel_idx = 0
else:
device_info = sd.query_devices(self.output_device)
num_channels = device_info['max_output_channels']
device_sr = int(device_info['default_samplerate'])
device = self.output_device
channel_idx = min(self.ad_channel, num_channels) - 1
if self.output_sample_rate != device_sr:
self._ident_resampled = librosa.resample(
self._ident_data, orig_sr=self.output_sample_rate, target_sr=device_sr
).astype(np.float32)
else:
self._ident_resampled = self._ident_data
def callback(outdata, frames, time_info, status):
outdata[:] = 0
if not self._ident_playing or self._ident_resampled is None:
return
remaining = len(self._ident_resampled) - self._ident_position
if remaining >= frames:
chunk = self._ident_resampled[self._ident_position:self._ident_position + frames]
outdata[:, channel_idx] = chunk
if self.stem_recorder:
self.stem_recorder.write_sporadic("idents", chunk.copy(), device_sr)
self._ident_position += frames
else:
if remaining > 0:
outdata[:remaining, channel_idx] = self._ident_resampled[self._ident_position:]
self._ident_playing = False
try:
self._ident_stream = sd.OutputStream(
device=device,
channels=num_channels,
samplerate=device_sr,
dtype=np.float32,
callback=callback,
blocksize=2048
)
self._ident_stream.start()
print(f"Ident playback started on ch {self.ad_channel} @ {device_sr}Hz")
except Exception as e:
print(f"Ident playback error: {e}")
self._ident_playing = False
def stop_ident(self):
"""Stop ident playback"""
self._ident_playing = False
if self._ident_stream:
self._ident_stream.stop()
self._ident_stream.close()
self._ident_stream = None
self._ident_position = 0
```
**Step 3: Add `self.stop_ident()` to top of play_ad (line 935)**
In `play_ad`, after `self.stop_ad()` (line 935), add:
```python
self.stop_ident()
```
**Step 4: Commit**
```bash
git add backend/services/audio.py
git commit -m "Add play_ident/stop_ident to audio service"
```
---
### Task 4: Add idents API endpoints
**Files:**
- Modify: `backend/main.py` (after ads endpoints, ~line 4362)
**Step 1: Add IDENT_DISPLAY_NAMES and endpoints**
Insert after the ads stop endpoint (line 4362):
```python
# --- Idents Endpoints ---
IDENT_DISPLAY_NAMES = {}
@app.get("/api/idents")
async def get_idents():
"""Get available ident tracks, shuffled"""
ident_list = []
if settings.idents_dir.exists():
for ext in ['*.wav', '*.mp3', '*.flac']:
for f in settings.idents_dir.glob(ext):
ident_list.append({
"name": IDENT_DISPLAY_NAMES.get(f.stem, f.stem),
"file": f.name,
"path": str(f)
})
random.shuffle(ident_list)
return {"idents": ident_list}
@app.post("/api/idents/play")
async def play_ident(request: MusicRequest):
"""Play an ident once on the ad channel (ch 11)"""
ident_path = settings.idents_dir / request.track
if not ident_path.exists():
raise HTTPException(404, "Ident not found")
if audio_service._music_playing:
audio_service.stop_music(fade_duration=1.0)
await asyncio.sleep(1.1)
audio_service.play_ident(str(ident_path))
return {"status": "playing", "track": request.track}
@app.post("/api/idents/stop")
async def stop_ident():
"""Stop ident playback"""
audio_service.stop_ident()
return {"status": "stopped"}
```
**Step 2: Commit**
```bash
git add backend/main.py
git commit -m "Add idents API endpoints"
```
---
### Task 5: Add idents UI section and JS functions
**Files:**
- Modify: `frontend/index.html:113` (after ads section)
- Modify: `frontend/js/app.js`
**Step 1: Add Idents HTML section**
After the Ads section closing `</section>` (line 113), add:
```html
<!-- Idents -->
<section class="music-section">
<h2>Idents</h2>
<select id="ident-select"></select>
<div class="music-controls">
<button id="ident-play-btn">Play Ident</button>
<button id="ident-stop-btn">Stop</button>
</div>
</section>
```
**Step 2: Add loadIdents, playIdent, stopIdent to app.js**
After `stopAd()` function (~line 773), add:
```javascript
async function loadIdents() {
try {
const res = await fetch('/api/idents');
const data = await res.json();
const idents = data.idents || [];
const select = document.getElementById('ident-select');
if (!select) return;
const previousValue = select.value;
select.innerHTML = '';
idents.forEach(ident => {
const option = document.createElement('option');
option.value = ident.file;
option.textContent = ident.name;
select.appendChild(option);
});
if (previousValue && [...select.options].some(o => o.value === previousValue)) {
select.value = previousValue;
}
console.log('Loaded', idents.length, 'idents');
} catch (err) {
console.error('loadIdents error:', err);
}
}
async function playIdent() {
await loadIdents();
const select = document.getElementById('ident-select');
const track = select?.value;
if (!track) return;
await fetch('/api/idents/play', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ track, action: 'play' })
});
}
async function stopIdent() {
await fetch('/api/idents/stop', { method: 'POST' });
}
```
**Step 3: Add event listeners in initEventListeners**
After the ads event listeners (line 190), add:
```javascript
// Idents
document.getElementById('ident-play-btn')?.addEventListener('click', playIdent);
document.getElementById('ident-stop-btn')?.addEventListener('click', stopIdent);
```
**Step 4: Add loadIdents() to DOMContentLoaded init**
After `await loadAds();` (line 59), add:
```javascript
await loadIdents();
```
**Step 5: Bump cache buster on app.js script tag**
In `index.html:243`, change `?v=17` to `?v=18`.
**Step 6: Commit**
```bash
git add frontend/index.html frontend/js/app.js
git commit -m "Add idents UI section and JS functions"
```
@@ -0,0 +1,29 @@
# Clips Page & Landing Page Redesign
## Clips Page (`/clips`)
Responsive grid gallery of podcast clips with click-to-play YouTube embeds.
**Grid:** 3 columns desktop, 2 tablet, 1 mobile. Cards use 9:16 vertical aspect ratio.
**Card pre-click:** Dark bg-light card with clip title (bold), episode label, centered orange play button, description text below. Matches site aesthetic.
**Card playing:** Click swaps card for YouTube Shorts iframe (`youtube-nocookie.com`, autoplay). Fills same 9:16 space.
**Data:** Static `website/data/clips.json` aggregated from per-episode `clips-metadata.json` files. Each entry: title, description, episode_number, optional `youtube_id`. Cards without youtube_id show no play button.
**Featured row:** Top 3 hand-picked clips displayed larger, followed by full grid below.
**Nav:** "Clips" added to hero secondary links and footer nav.
## Landing Page Improvements
**About section** (between hero and episodes): Centered text block. Show description + AI teaser line ("Part human callers, part AI-generated characters, fully unhinged advice") + "See how it works" link. No card background.
**Clips highlight** (between about and episodes): Horizontal row of 3 featured clips, same card style as clips page. "Best Clips" header with "See all clips" link.
**Final section order:** Banner → Hero → About → Featured Clips → Episodes → Testimonials → Footer
## How It Works — Reaper Video
New "Post-Production Automation" section with native `<video>` tag (mp4 on CDN). Shows Reaper automating silence removal, ad ducking, loudness normalization. Wrapped in hiw-hero-card style container.
@@ -0,0 +1,719 @@
# Website JS Infrastructure, SEO, Shared Components & Content Fixes
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Fix JS duplication, add worker-level social meta injection, standardize analytics proxying, improve security and UX, and clean up content/SEO issues across lukeattheroost.com.
**Architecture:** Extract shared footer into `js/footer.js`, extract shared audio player into `js/player.js`, enhance `_worker.js` to intercept social crawler requests and inject episode-specific meta tags, switch all subpages to proxied Plausible analytics, add episode pagination, fix XSS surfaces, and clean up sitemap/clips data.
**Tech Stack:** Vanilla JS, Cloudflare Pages Worker (ES module), static HTML, XML sitemap
---
### Task 1: Create shared footer component (`js/footer.js`)
**Files:**
- Create: `website/js/footer.js`
**Step 1: Write footer.js**
The footer HTML is duplicated across 7 pages (index.html:265-306, episode.html:95-136, clips.html:68-109, stats.html, privacy.html, terms.html, how-it-works.html). Extract the footer from `index.html` as the canonical version.
```js
function initFooter() {
const footer = document.querySelector('.footer');
if (!footer) return;
footer.innerHTML = `
<div class="footer-nav">
<a href="/">Home</a>
<a href="/how-it-works">How It Works</a>
<a href="/clips">Clips</a>
<a href="/stats">Stats</a>
</div>
<div class="footer-icons">
<span class="footer-icons-label">Listen On</span>
<div class="footer-icons-row">
<a href="https://open.spotify.com/show/0ZrpMigG1fo0CCN7F4YmuF?si=f990713adce84ba4" target="_blank" rel="noopener" class="footer-icon-link" aria-label="Spotify"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 0C5.4 0 0 5.4 0 12s5.4 12 12 12 12-5.4 12-12S18.66 0 12 0zm5.521 17.34c-.24.359-.66.48-1.021.24-2.82-1.74-6.36-2.101-10.561-1.141-.418.122-.779-.179-.899-.539-.12-.421.18-.78.54-.9 4.56-1.021 8.52-.6 11.64 1.32.42.18.479.659.301 1.02zm1.44-3.3c-.301.42-.841.6-1.262.3-3.239-1.98-8.159-2.58-11.939-1.38-.479.12-1.02-.12-1.14-.6-.12-.48.12-1.021.6-1.141C9.6 9.9 15 10.561 18.72 12.84c.361.181.54.78.241 1.2zm.12-3.36C15.24 8.4 8.82 8.16 5.16 9.301c-.6.179-1.2-.181-1.38-.721-.18-.601.18-1.2.72-1.381 4.26-1.26 11.28-1.02 15.721 1.621.539.3.719 1.02.419 1.56-.299.421-1.02.599-1.559.3z"/></svg></a>
<a href="https://www.youtube.com/watch?v=xryGLifMBTY&list=PLGq4uZyNV1yYH_rcitTTPVysPbC6-7pe-" target="_blank" rel="noopener" class="footer-icon-link" aria-label="YouTube"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814z"/><path d="M9.545 15.568V8.432L15.818 12z" fill="#1a1209"/></svg></a>
<a href="https://podcasts.apple.com/us/podcast/luke-at-the-roost/id1875205848" target="_blank" rel="noopener" class="footer-icon-link" aria-label="Apple Podcasts"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 2C6.477 2 2 6.477 2 12c0 3.293 1.592 6.214 4.05 8.04.13-.455.283-.942.457-1.393A9 9 0 0 1 3 12a9 9 0 0 1 18 0 9 9 0 0 1-3.507 7.127c.174.42.327.893.456 1.333A10 10 0 0 0 22 12c0-5.523-4.477-10-10-10zm0 4a6 6 0 0 0-6 6c0 1.87.856 3.54 2.2 4.64.196-.46.43-.91.692-1.31A4.5 4.5 0 0 1 7.5 12a4.5 4.5 0 0 1 9 0c0 1.21-.478 2.31-1.256 3.12.24.37.462.8.655 1.24A6 6 0 0 0 18 12a6 6 0 0 0-6-6zm0 4.5a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3zM12 15c-.75 0-1.158.54-1.28 1.2-.17.94-.28 1.91-.33 2.88-.03.48.34.82.73.82h1.76c.39 0 .76-.34.73-.82-.05-.97-.16-1.94-.33-2.88-.122-.66-.53-1.2-1.28-1.2z"/></svg></a>
<a href="https://podcast.macneilmediagroup.com/@LukeAtTheRoost/feed.xml" target="_blank" rel="noopener" class="footer-icon-link" aria-label="RSS"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M6.503 20.752c0 1.794-1.456 3.248-3.251 3.248S0 22.546 0 20.752s1.456-3.248 3.252-3.248 3.251 1.454 3.251 3.248zM.002 9.473v4.594c5.508.163 9.929 4.584 10.092 10.091h4.594C14.524 16.21 7.849 9.636.002 9.473zM.006 0v4.604C10.81 4.77 19.23 13.19 19.396 24h4.604C23.834 10.952 13.054.166.006 0z"/></svg></a>
</div>
</div>
<div class="footer-icons">
<span class="footer-icons-label">Follow</span>
<div class="footer-icons-row">
<a href="https://discord.gg/5CnQZxDM" target="_blank" rel="noopener" class="footer-icon-link" aria-label="Discord"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028 14.09 14.09 0 0 0 1.226-1.994.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"/></svg></a>
<a href="https://www.facebook.com/profile.php?id=61588191627949" target="_blank" rel="noopener" class="footer-icon-link" aria-label="Facebook"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/></svg></a>
<a href="https://www.instagram.com/lukeattheroost/" target="_blank" rel="noopener" class="footer-icon-link" aria-label="Instagram"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zM12 0C8.741 0 8.333.014 7.053.072 2.695.272.273 2.69.073 7.052.014 8.333 0 8.741 0 12c0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98C8.333 23.986 8.741 24 12 24c3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98C15.668.014 15.259 0 12 0zm0 5.838a6.162 6.162 0 1 0 0 12.324 6.162 6.162 0 0 0 0-12.324zM12 16a4 4 0 1 1 0-8 4 4 0 0 1 0 8zm6.406-11.845a1.44 1.44 0 1 0 0 2.881 1.44 1.44 0 0 0 0-2.881z"/></svg></a>
<a href="https://x.com/lukeattheroost" target="_blank" rel="noopener" class="footer-icon-link" aria-label="X"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/></svg></a>
<a href="https://bsky.app/profile/lukeattheroost.bsky.social" target="_blank" rel="noopener" class="footer-icon-link" aria-label="Bluesky"><svg viewBox="0 0 568 501" fill="currentColor"><path d="M123.121 33.664C188.241 82.553 258.281 181.68 284 234.873c25.719-53.192 95.759-152.32 160.879-201.21C491.866-1.611 568-28.906 568 57.947c0 17.346-9.945 145.713-15.778 166.555-20.275 72.453-94.155 90.933-159.875 79.748C507.222 323.8 536.444 388.56 473.333 453.32c-119.86 122.992-172.272-30.859-185.702-70.281-2.462-7.227-3.614-10.608-3.631-7.733-.017-2.875-1.169.506-3.631 7.733-13.43 39.422-65.842 193.273-185.702 70.281-63.111-64.76-33.89-129.52 80.986-149.071-65.72 11.185-139.6-7.295-159.875-79.748C10.945 203.659 1 75.291 1 57.946 1-28.906 76.134-1.612 123.121 33.664z"/></svg></a>
<a href="https://mastodon.macneilmediagroup.com/@lukeattheroost" target="_blank" rel="me noopener" class="footer-icon-link" aria-label="Mastodon"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M23.268 5.313c-.35-2.578-2.617-4.61-5.304-5.004C17.51.242 15.792 0 11.813 0h-.03c-3.98 0-4.835.242-5.288.309C3.882.692 1.496 2.518.917 5.127.64 6.412.61 7.837.661 9.143c.074 1.874.088 3.745.26 5.611.118 1.24.325 2.47.62 3.68.55 2.237 2.777 4.098 4.96 4.857 2.336.792 4.849.923 7.256.38.265-.061.527-.132.786-.213.585-.184 1.27-.39 1.774-.753a.057.057 0 0 0 .023-.043v-1.809a.052.052 0 0 0-.02-.041.053.053 0 0 0-.046-.01 20.282 20.282 0 0 1-4.709.545c-2.73 0-3.463-1.284-3.674-1.818a5.593 5.593 0 0 1-.319-1.433.053.053 0 0 1 .066-.054 19.648 19.648 0 0 0 4.636.528c.164 0 .329 0 .494-.002 1.694-.042 3.48-.152 5.12-.554 2.21-.543 4.137-2.186 4.348-4.55.162-1.808.21-3.627.142-5.43-.02-.6-.168-1.874-.168-1.874z"/><path d="M19.903 7.515v5.834c0 1.226-.996 2.222-2.222 2.222h-.796c-1.226 0-2.222-.996-2.222-2.222V7.628c0-1.226.996-2.222 2.222-2.222h.796c.122 0 .242.01.36.03 1.076.164 1.862 1.098 1.862 2.192zM9.337 7.515v5.834c0 1.226-.996 2.222-2.222 2.222h-.796c-1.226 0-2.222-.996-2.222-2.222V7.628c0-1.226.996-2.222 2.222-2.222h.796c.122 0 .242.01.36.03 1.076.164 1.862 1.098 1.862 2.192z" fill="#1a1209"/></svg></a>
<a href="https://primal.net/p/nprofile1qqswsam9cx06j7sxzpl498uquk3kgrwedxtq48j57zxkuj8fs82xtugge0wtg" target="_blank" rel="noopener" class="footer-icon-link" aria-label="Nostr"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M12.186.31a.27.27 0 0 0-.372 0C8.46 3.487 2.666 9.93 2.666 15.042c0 5.176 4.183 8.958 9.334 8.958s9.334-3.782 9.334-8.958c0-5.112-5.794-11.555-9.148-14.732z"/></svg></a>
<a href="https://www.threads.com/@lukeattheroost" target="_blank" rel="noopener" class="footer-icon-link" aria-label="Threads"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M12.186 24h-.007c-3.581-.024-6.334-1.205-8.184-3.509C2.35 18.44 1.5 15.586 1.472 12.01v-.017c.03-3.579.879-6.43 2.525-8.482C5.845 1.205 8.6.024 12.18 0h.014c2.746.02 5.043.725 6.826 2.098 1.677 1.29 2.858 3.13 3.509 5.467l-2.04.569c-1.104-3.96-3.898-5.984-8.304-6.015-2.91.022-5.11.936-6.54 2.717C4.307 6.504 3.616 8.914 3.59 12c.025 3.086.718 5.496 2.057 7.164 1.432 1.781 3.632 2.695 6.54 2.717 2.227-.017 4.048-.59 5.413-1.703 1.428-1.163 2.076-2.645 1.925-4.403-.098-1.13-.578-2.065-1.39-2.7-.811-.636-1.905-.993-3.164-1.033a11.253 11.253 0 0 0-.04 0c-1.078.007-2.044.289-2.79.816-.68.481-1.069 1.108-1.125 1.813-.057.72.264 1.32.877 1.64.554.29 1.317.437 2.271.437l.013-.001c.652-.004 1.383-.078 2.172-.218l.386 2.022c-.947.18-1.837.273-2.643.278a10.35 10.35 0 0 1-.143 0c-1.425-.013-2.657-.284-3.66-.804-1.237-.643-1.928-1.745-1.836-2.93.099-1.258.738-2.316 1.849-3.064 1.088-.732 2.466-1.12 3.988-1.124h.05c1.644.044 3.088.528 4.178 1.398 1.133.905 1.8 2.185 1.935 3.703.2 2.258-.697 4.2-2.598 5.75-1.668 1.36-3.863 2.087-6.348 2.105z"/></svg></a>
<a href="https://www.linkedin.com/company/luke-at-the-roost" target="_blank" rel="noopener" class="footer-icon-link" aria-label="LinkedIn"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433a2.062 2.062 0 0 1-2.063-2.065 2.064 2.064 0 1 1 2.063 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/></svg></a>
<a href="https://www.tiktok.com/@luke.at.the.roost" target="_blank" rel="noopener" class="footer-icon-link" aria-label="TikTok"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M12.525.02c1.31-.02 2.61-.01 3.91-.02.08 1.53.63 3.09 1.75 4.17 1.12 1.11 2.7 1.62 4.24 1.79v4.03c-1.44-.05-2.89-.35-4.2-.97-.57-.26-1.1-.59-1.62-.93-.01 2.92.01 5.84-.02 8.75-.08 1.4-.54 2.79-1.35 3.94-1.31 1.92-3.58 3.17-5.91 3.21-1.43.08-2.86-.31-4.08-1.03-2.02-1.19-3.44-3.37-3.65-5.71-.02-.5-.03-1-.01-1.49.18-1.9 1.12-3.72 2.58-4.96 1.66-1.44 3.98-2.13 6.15-1.72.02 1.48-.04 2.96-.04 4.44-.99-.32-2.15-.23-3.02.37-.63.41-1.11 1.04-1.36 1.75-.21.51-.15 1.07-.14 1.61.24 1.64 1.82 3.02 3.5 2.87 1.12-.01 2.19-.66 2.77-1.61.19-.33.4-.67.41-1.06.1-1.79.06-3.57.07-5.36.01-4.03-.01-8.05.02-12.07z"/></svg></a>
</div>
</div>
<div class="footer-projects">
<span class="footer-projects-label">More from Luke</span>
<div class="footer-projects-links">
<a href="https://macneilmediagroup.com" target="_blank" rel="noopener">MacNeil Media Group</a>
<a href="https://prints.macneilmediagroup.com" target="_blank" rel="noopener">Photography Prints</a>
<a href="https://youtube.com/lukemacneil" target="_blank" rel="noopener">YouTube</a>
</div>
</div>
<p class="footer-contact"><a href="https://ko-fi.com/lukemacneil" target="_blank" rel="noopener">Support the Show</a></p>
<p class="footer-contact">Sales &amp; Collaboration: <a href="mailto:luke@lukeattheroost.com">luke@lukeattheroost.com</a></p>
<p>&copy; 2026 Luke at the Roost &middot; <a href="/privacy">Privacy Policy</a> &middot; <a href="/terms">Terms of Service</a> &middot; <a href="https://monitoring.macneilmediagroup.com/status/lukeattheroost" target="_blank" rel="noopener">System Status</a></p>
`;
}
initFooter();
```
**Step 2: Commit**
```bash
git add website/js/footer.js
git commit -m "Add shared footer component (js/footer.js)"
```
---
### Task 2: Replace inline footers with shared component
**Files:**
- Modify: `website/index.html` — replace lines 265-306 (inline footer content) with empty `<footer class="footer"></footer>`, add `<script src="js/footer.js"></script>` before closing `</body>`
- Modify: `website/episode.html` — replace lines 95-136 with empty footer, add script tag
- Modify: `website/clips.html` — replace lines 68-109 with empty footer, add script tag
- Modify: `website/stats.html` — replace inline footer with empty footer, add script tag
- Modify: `website/privacy.html` — replace inline footer with empty footer, add script tag
- Modify: `website/terms.html` — replace inline footer with empty footer, add script tag
- Modify: `website/how-it-works.html` — replace inline footer with empty footer, add script tag
**Step 1: Update each page**
For each of the 7 HTML files:
1. Replace the entire `<footer class="footer">...</footer>` block with just `<footer class="footer"></footer>`
2. Add `<script src="js/footer.js"></script>` near the end of `<body>`, before any page-specific scripts
Note: index.html's footer has slightly different nav links (no "Home" link since it IS home). The shared footer includes "Home" which is fine — clicking Home on the homepage just reloads it.
**Step 2: Verify no footer content remains inline**
Search for `footer-icons-label` in all HTML files — should only appear in `js/footer.js`.
**Step 3: Commit**
```bash
git add website/index.html website/episode.html website/clips.html website/stats.html website/privacy.html website/terms.html website/how-it-works.html
git commit -m "Replace inline footers with shared footer.js component"
```
---
### Task 3: Extract shared audio player module (`js/player.js`)
**Files:**
- Create: `website/js/player.js`
The audio player code is duplicated: `app.js:1-11,14-23,143-226` and `episode.html:159-346` (inline `<script>`). Extract the shared player logic.
**Step 1: Write player.js**
```js
const audio = document.getElementById('audio-element');
const stickyPlayer = document.getElementById('sticky-player');
const playerPlayBtn = document.getElementById('player-play-btn');
const playerTitle = document.getElementById('player-title');
const playerProgress = document.getElementById('player-progress');
const playerProgressFill = document.getElementById('player-progress-fill');
const playerTime = document.getElementById('player-time');
function formatTime(seconds) {
if (!seconds || isNaN(seconds)) return '0:00';
const s = Math.floor(seconds);
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
const sec = s % 60;
if (h > 0) return `${h}:${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}`;
return `${m}:${String(sec).padStart(2, '0')}`;
}
function updatePlayIcons(playing) {
const iconPlay = playerPlayBtn.querySelector('.icon-play');
const iconPause = playerPlayBtn.querySelector('.icon-pause');
if (iconPlay) iconPlay.style.display = playing ? 'none' : 'block';
if (iconPause) iconPause.style.display = playing ? 'block' : 'none';
}
audio.addEventListener('play', () => updatePlayIcons(true));
audio.addEventListener('pause', () => updatePlayIcons(false));
audio.addEventListener('ended', () => updatePlayIcons(false));
audio.addEventListener('timeupdate', () => {
if (audio.duration) {
playerProgressFill.style.width = (audio.currentTime / audio.duration * 100) + '%';
playerTime.textContent = `${formatTime(audio.currentTime)} / ${formatTime(audio.duration)}`;
}
});
playerPlayBtn.addEventListener('click', () => {
if (audio.src) { audio.paused ? audio.play() : audio.pause(); }
});
playerProgress.addEventListener('click', (e) => {
if (audio.duration) {
const rect = playerProgress.getBoundingClientRect();
audio.currentTime = ((e.clientX - rect.left) / rect.width) * audio.duration;
}
});
```
**Step 2: Commit**
```bash
git add website/js/player.js
git commit -m "Extract shared audio player module (js/player.js)"
```
---
### Task 4: Refactor app.js and episode.html to use player.js
**Files:**
- Modify: `website/js/app.js` — remove duplicated player code (element lookups, formatTime, audio event listeners, updatePlayIcons, playerPlayBtn click, playerProgress click). Keep: FEED_URL, episode-specific logic (fetchEpisodes, renderEpisodes, playEpisode with card-specific icon toggling), formatDate, parseDuration, truncate, testimonials, on-air.
- Modify: `website/episode.html` — remove inline `<script>` block (lines 159-347), replace with `<script src="js/player.js"></script>` then `<script src="js/episode.js"></script>`
- Create: `website/js/episode.js` — episode-specific logic extracted from episode.html inline script (load episode from RSS, populate header, transcript loading, play button)
- Modify: `website/index.html` — add `<script src="js/player.js"></script>` before `app.js`
**Step 1: Refactor app.js**
Remove from app.js:
- Lines 1-11 (element lookups — now in player.js)
- Lines 14-23 (formatTime — now in player.js)
- Lines 173-226 (audio event listeners, updatePlayIcons, playerPlayBtn click, playerProgress click — now in player.js)
Keep the `currentEpisodeCard` variable and the card-specific icon toggling in `updatePlayIcons`. Since player.js handles the sticky player icons, app.js only needs to handle the episode card icons. Add a listener:
```js
audio.addEventListener('play', () => {
if (currentEpisodeCard) {
const btn = currentEpisodeCard.querySelector('.episode-play-btn');
if (btn) { btn.innerHTML = pauseSVG; btn.classList.add('playing'); }
}
});
audio.addEventListener('pause', () => {
if (currentEpisodeCard) {
const btn = currentEpisodeCard.querySelector('.episode-play-btn');
if (btn) { btn.innerHTML = playSVG; btn.classList.remove('playing'); }
}
});
audio.addEventListener('ended', () => {
if (currentEpisodeCard) {
const btn = currentEpisodeCard.querySelector('.episode-play-btn');
if (btn) { btn.innerHTML = playSVG; btn.classList.remove('playing'); }
}
});
```
**Step 2: Create episode.js**
Extract episode-specific logic from episode.html inline script. Use the global `audio`, `playerTitle`, `stickyPlayer` from player.js. Include: `formatDate`, `parseDuration`, `stripHtml`, slug parsing, `loadEpisode()`.
**Step 3: Update HTML script tags**
In `index.html`, change script loading order:
```html
<script src="js/footer.js"></script>
<script src="js/clips.js"></script>
<script>renderFeaturedClipsInline('home-clips');</script>
<script src="js/player.js"></script>
<script src="js/app.js?v=3"></script>
```
In `episode.html`, replace inline `<script>` (lines 159-347) with:
```html
<script src="js/footer.js"></script>
<script src="js/player.js"></script>
<script src="js/episode.js"></script>
```
**Step 4: Commit**
```bash
git add website/js/app.js website/js/player.js website/js/episode.js website/index.html website/episode.html
git commit -m "Deduplicate audio player code into shared player.js module"
```
---
### Task 5: Fix Plausible analytics — switch all subpages to proxied version
**Files:**
- Modify: `website/episode.html` line 51-52
- Modify: `website/clips.html` line 43-44
- Modify: `website/stats.html` line 43-44
- Modify: `website/privacy.html` line 37-38
- Modify: `website/terms.html` line 37-38
- Modify: `website/how-it-works.html` line 66-67
**Step 1: In each file, replace the direct Plausible script tag**
Replace:
```html
<script defer data-domain="lukeattheroost.com" src="https://plausible.macneilmediagroup.com/js/script.file-downloads.hash.outbound-links.pageview-props.revenue.tagged-events.js"></script>
<script>window.plausible = window.plausible || function() { (window.plausible.q = window.plausible.q || []).push(arguments) }</script>
```
With:
```html
<script defer data-domain="lukeattheroost.com" data-api="/p/event" src="/p/script"></script>
<script>window.plausible = window.plausible || function() { (window.plausible.q = window.plausible.q || []).push(arguments) }</script>
```
**Step 2: Verify**
Grep for `plausible.macneilmediagroup.com` in HTML files — should return 0 matches (only `_worker.js` should have it).
**Step 3: Commit**
```bash
git add website/episode.html website/clips.html website/stats.html website/privacy.html website/terms.html website/how-it-works.html
git commit -m "Switch all subpages to proxied Plausible analytics"
```
---
### Task 6: Worker — social crawler meta tag injection for episode pages
**Files:**
- Modify: `website/_worker.js`
**Step 1: Add social crawler detection and meta injection**
Before the `return env.ASSETS.fetch(request)` line (line 90), add a handler for `/episode.html` requests from social crawlers:
```js
// Social crawler meta injection for episode pages
if (url.pathname === "/episode.html" && url.searchParams.get("slug")) {
const ua = (request.headers.get("User-Agent") || "").toLowerCase();
const isCrawler = /facebookexternalhit|twitterbot|linkedinbot|slackbot|discordbot|telegrambot|whatsapp|pinterest|redditbot/i.test(ua);
if (isCrawler) {
const slug = url.searchParams.get("slug");
// Fetch RSS to find episode info
try {
const feedResp = await fetch("https://podcast.macneilmediagroup.com/@LukeAtTheRoost/feed.xml", {
signal: AbortSignal.timeout(5000),
});
if (feedResp.ok) {
const feedXml = await feedResp.text();
// Simple string-based extraction (no DOM parser in Workers)
const items = feedXml.split("<item>");
let title = "";
let description = "";
for (let i = 1; i < items.length; i++) {
const item = items[i];
const linkMatch = item.match(/<link>(.*?)<\/link>/);
if (linkMatch) {
const itemSlug = linkMatch[1].split("/episodes/").pop()?.replace(/\/$/, "");
if (itemSlug === slug) {
const titleMatch = item.match(/<title>(.*?)<\/title>/);
title = titleMatch ? titleMatch[1].replace(/<!\[CDATA\[|\]\]>/g, "").trim() : "";
const descMatch = item.match(/<description>(.*?)<\/description>/s);
description = descMatch
? descMatch[1].replace(/<!\[CDATA\[|\]\]>/g, "").replace(/<[^>]+>/g, "").trim().slice(0, 200)
: "";
break;
}
}
}
if (title) {
// Fetch the actual HTML page
const pageResp = await env.ASSETS.fetch(request);
let html = await pageResp.text();
const escTitle = title.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;");
const escDesc = description.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;");
const canonicalUrl = `https://lukeattheroost.com/episode.html?slug=${slug}`;
// Replace placeholder meta tags
html = html.replace(
/<meta property="og:title"[^>]*>/,
`<meta property="og:title" content="${escTitle}">`
);
html = html.replace(
/<meta property="og:description"[^>]*>/,
`<meta property="og:description" content="${escDesc}">`
);
html = html.replace(
/<meta property="og:url"[^>]*>/,
`<meta property="og:url" content="${canonicalUrl}">`
);
html = html.replace(
/<meta name="twitter:title"[^>]*>/,
`<meta name="twitter:title" content="${escTitle}">`
);
html = html.replace(
/<meta name="twitter:description"[^>]*>/,
`<meta name="twitter:description" content="${escDesc}">`
);
html = html.replace(
/<title[^>]*>.*?<\/title>/,
`<title>${escTitle} — Luke at the Roost</title>`
);
return new Response(html, {
status: 200,
headers: { "Content-Type": "text/html;charset=UTF-8" },
});
}
}
} catch (e) {
// Fall through to static page
}
}
}
```
**Step 2: Commit**
```bash
git add website/_worker.js
git commit -m "Add social crawler meta tag injection for episode pages"
```
---
### Task 7: Security — sanitize innerHTML XSS surfaces
**Files:**
- Modify: `website/js/episode.js` (created in Task 4)
- Modify: `website/js/app.js`
**Step 1: Fix episode description XSS in episode.js**
In the `loadEpisode` function, change line that sets description:
```js
// BEFORE (XSS):
document.getElementById('ep-desc').innerHTML = episode.description || '';
// AFTER (safe):
document.getElementById('ep-desc').textContent = stripHtml(episode.description || '');
```
**Step 2: Fix title escaping in app.js episode card rendering**
In `renderEpisodes()`, the title goes into a `data-title` attribute with basic `.replace(/"/g, '&quot;')`. Use the `escapeHTML` pattern from clips.js. Add a helper at top of app.js:
```js
function escapeAttr(str) {
return str.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
```
Then change line 125:
```js
// BEFORE:
data-title="${ep.title.replace(/"/g, '&quot;')}"
// AFTER:
data-title="${escapeAttr(ep.title)}"
```
Also escape the title in the aria-label and visible title:
```js
<button class="episode-play-btn" aria-label="Play ${escapeAttr(ep.title)}" data-url="${escapeAttr(ep.audioUrl)}" data-title="${escapeAttr(ep.title)}">
```
And escape the visible title output:
```js
<div class="episode-title">${escapeAttr(ep.title)}</div>
```
**Step 3: Commit**
```bash
git add website/js/episode.js website/js/app.js
git commit -m "Fix XSS: sanitize innerHTML and improve attribute escaping"
```
---
### Task 8: Episode pagination — show 10, load more
**Files:**
- Modify: `website/js/app.js`
**Step 1: Modify renderEpisodes to support pagination**
```js
const EPISODES_PER_PAGE = 10;
let allEpisodes = [];
let displayedCount = 0;
function renderEpisodes(episodes) {
allEpisodes = episodes;
displayedCount = 0;
episodesList.innerHTML = '';
showMoreEpisodes();
}
function showMoreEpisodes() {
const batch = allEpisodes.slice(displayedCount, displayedCount + EPISODES_PER_PAGE);
batch.forEach((ep) => {
// ... existing card creation code ...
episodesList.appendChild(card);
});
displayedCount += batch.length;
// Remove existing load-more button if present
const existing = document.getElementById('load-more-btn');
if (existing) existing.remove();
// Add load-more button if there are remaining episodes
if (displayedCount < allEpisodes.length) {
const btn = document.createElement('button');
btn.id = 'load-more-btn';
btn.className = 'load-more-btn';
btn.textContent = `Load More (${allEpisodes.length - displayedCount} remaining)`;
btn.addEventListener('click', showMoreEpisodes);
episodesList.after(btn);
}
}
```
Note: The `.load-more-btn` CSS class likely needs to be created by the ui-ux task. For now, add minimal inline styling if the class doesn't exist yet. Actually, since we're told not to touch CSS, just use the class name and it will be styled later.
**Step 2: Commit**
```bash
git add website/js/app.js
git commit -m "Add episode pagination with Load More button"
```
---
### Task 9: Truncate at word boundaries
**Files:**
- Modify: `website/js/app.js`
**Step 1: Fix the truncate function**
```js
// BEFORE (line 47-53):
function truncate(html, maxLen) {
const div = document.createElement('div');
div.innerHTML = html || '';
const text = div.textContent || '';
if (text.length <= maxLen) return text;
return text.slice(0, maxLen).trimEnd() + '...';
}
// AFTER:
function truncate(html, maxLen) {
const div = document.createElement('div');
div.innerHTML = html || '';
const text = div.textContent || '';
if (text.length <= maxLen) return text;
const truncated = text.slice(0, maxLen);
const lastSpace = truncated.lastIndexOf(' ');
return (lastSpace > maxLen * 0.5 ? truncated.slice(0, lastSpace) : truncated).trimEnd() + '...';
}
```
The `lastSpace > maxLen * 0.5` guard ensures we don't cut too aggressively if the word boundary is very early.
**Step 2: Commit**
```bash
git add website/js/app.js
git commit -m "Fix truncate to break at word boundaries"
```
---
### Task 10: Deduplicate featured clips on clips page
**Files:**
- Modify: `website/js/clips.js`
**Step 1: Fix initClipsPage to exclude featured from "All Clips" grid**
```js
// BEFORE (line 73-77):
if (gridContainer) {
clips.forEach(clip => {
gridContainer.appendChild(renderClipCard(clip, false));
});
}
// AFTER:
if (gridContainer) {
clips.filter(c => !c.featured).forEach(clip => {
gridContainer.appendChild(renderClipCard(clip, false));
});
}
```
**Step 2: Commit**
```bash
git add website/js/clips.js
git commit -m "Deduplicate featured clips from All Clips grid"
```
---
### Task 11: Fix content issues — empty clip description and duplicate sitemap entry
**Files:**
- Modify: `website/data/clips.json` — episode 31 clip (line 58): add description
- Modify: `website/sitemap.xml` — remove duplicate episode 32 entry (lines 237-242)
**Step 1: Add description for episode 31 clip**
```json
{
"title": "Started a Fight and Can't Stop Reading About Wars",
"description": "A caller starts a fight with their partner and now can't stop obsessively reading about historical wars. Luke tries to unpack the connection.",
"episode_number": 31,
...
}
```
**Step 2: Remove duplicate sitemap entry**
Remove lines 237-242 (the `episode-32-tacos-taxes-and-tense-conversations` entry). Keep `episode-32-tacos-taxes-and-tall-tales` (lines 231-236) as the canonical one, OR check RSS feed to determine which slug is correct. If both exist in the feed, keep both — but episode numbering suggests one is a duplicate/rename. Remove the second one (`tense-conversations` variant).
**Step 3: Commit**
```bash
git add website/data/clips.json website/sitemap.xml
git commit -m "Fix empty clip description and remove duplicate sitemap entry"
```
---
### Task 12: Create custom 404 page
**Files:**
- Create: `website/404.html`
**Step 1: Write 404.html**
```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Page Not Found — Luke at the Roost</title>
<meta name="description" content="The page you're looking for doesn't exist.">
<meta name="theme-color" content="#1a1209">
<link rel="icon" href="favicon.ico" sizes="48x48">
<link rel="icon" type="image/svg+xml" href="favicon.svg">
<link rel="stylesheet" href="css/style.css?v=3">
<script defer data-domain="lukeattheroost.com" data-api="/p/event" src="/p/script"></script>
<script>window.plausible = window.plausible || function() { (window.plausible.q = window.plausible.q || []).push(arguments) }</script>
</head>
<body>
<nav class="page-nav">
<a href="/" class="nav-home">Luke at the Roost</a>
</nav>
<main>
<section class="page-header">
<h1>404 — Page Not Found</h1>
<p class="page-subtitle">Looks like this page wandered off into the desert.</p>
</section>
<section class="about-section">
<p>The page you're looking for doesn't exist or may have been moved.</p>
<p><a href="/">Back to the show</a> &middot; <a href="/clips">Watch clips</a> &middot; <a href="/how-it-works">How it works</a></p>
</section>
</main>
<footer class="footer"></footer>
<script src="js/footer.js"></script>
</body>
</html>
```
**Step 2: Commit**
```bash
git add website/404.html
git commit -m "Add custom 404 page"
```
---
### Task 13: Enhance llms.txt
**Files:**
- Modify: `website/llms.txt`
**Step 1: Add episode listing section and structured links**
Add after the FAQ section:
```markdown
## Recent Episodes
Episodes are published daily. Each has a full transcript available at:
https://lukeattheroost.com/episode.html?slug=EPISODE-SLUG
Episode transcript URLs follow the pattern: episode-N-title-slug
Example: https://lukeattheroost.com/episode.html?slug=episode-37-secrets-lies-and-coffee-runs
## Clip Highlights
Popular clips with video:
- "I Faked Cancer to Skip a Wedding" (Episode 32) — https://youtube.com/watch?v=NUkhsPfMx9o
- "Neighbor's Roomba Breaks Into Kitchen at 2:30 AM" (Episode 26) — https://youtube.com/watch?v=J7bfT6jsykA
- "Shopping Cart Theory: Moral Test or Crazy?" (Episode 21) — https://youtube.com/watch?v=KijyJsMZfkA
## Sitemap
Full sitemap: https://lukeattheroost.com/sitemap.xml
```
**Step 2: Commit**
```bash
git add website/llms.txt
git commit -m "Enhance llms.txt with episode patterns and clip highlights"
```
---
## Execution Order & Dependencies
```
Task 1 (footer.js) — no deps
Task 3 (player.js) — no deps
Task 5 (analytics fix) — no deps
Task 9 (truncate fix) — no deps
Task 10 (clips dedup) — no deps
Task 11 (content fixes) — no deps
Task 12 (404 page) — depends on Task 1 (uses footer.js)
Task 13 (llms.txt) — no deps
Task 2 (replace footers) — depends on Task 1
Task 4 (refactor to use player.js) — depends on Task 3
Task 6 (worker meta injection) — no deps
Task 7 (security fixes) — depends on Task 4 (episode.js must exist)
Task 8 (pagination) — can run anytime, modifies app.js
```
**Parallel batch 1** (independent): Tasks 1, 3, 5, 6, 9, 10, 11, 13
**Parallel batch 2** (deps resolved): Tasks 2, 4, 12
**Parallel batch 3** (deps resolved): Tasks 7, 8
+335
View File
@@ -0,0 +1,335 @@
# Show Theme Feature Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Add a "show theme" input to the header bar that injects theme context into caller background generation and conversation prompts, nudging callers toward the theme without forcing it.
**Architecture:** Store theme as a string on the Session object. Pass it into `_generate_caller_background_llm()` to bias character creation and into `get_caller_prompt()` so callers are aware of the show's theme during dialog. Frontend adds a text input in the header with a set/clear button, persisted via a new API endpoint.
**Tech Stack:** Python (FastAPI backend), vanilla HTML/CSS/JS frontend
---
### Task 1: Add theme to Session and API endpoint
**Files:**
- Modify: `backend/main.py:6192-6218` (Session class)
- Modify: `backend/main.py:8731-8759` (settings endpoints area)
**Step 1: Add `show_theme` field to Session.__init__**
In `backend/main.py`, inside `Session.__init__` (line ~6217, after `self.intern_monitoring`), add:
```python
self.show_theme: str = "" # Current show theme (e.g. "St. Patrick's Day")
```
**Step 2: Add GET/POST endpoints for show theme**
Add these endpoints near the existing settings endpoints (~line 8760):
```python
@app.get("/api/show-theme")
async def get_show_theme():
return {"theme": session.show_theme}
@app.post("/api/show-theme")
async def set_show_theme(data: dict):
theme = data.get("theme", "").strip()
old_theme = session.show_theme
session.show_theme = theme
if theme:
print(f"[Theme] Show theme set: {theme}")
elif old_theme:
print(f"[Theme] Show theme cleared (was: {old_theme})")
return {"theme": session.show_theme}
```
**Step 3: Verify the server starts without errors**
Run: `curl -s http://localhost:8000/api/show-theme | python -m json.tool`
Expected: `{"theme": ""}`
**Step 4: Commit**
```bash
git add backend/main.py
git commit -m "Add show theme to Session and API endpoints"
```
---
### Task 2: Inject theme into caller background generation
**Files:**
- Modify: `backend/main.py:5208-5330` (`_generate_caller_background_llm`)
- Modify: `backend/main.py:5381-5396` (`_pregenerate_backgrounds`)
**Step 1: Pass theme into background generation prompt**
In `_generate_caller_background_llm()` (~line 5307), after the line that builds `prompt = f"""Write a brief character description...`, add theme context. Find this section of the prompt string (around line 5316):
```python
{f'CALLER ENERGY: {style_hint}' if style_hint else ''}
```
Immediately after that line (still inside the f-string), add:
```python
{f"SHOW THEME: Tonight's show theme is '{session.show_theme}'. This caller might have a story or angle related to this theme — or they might not. Not every caller has to be about the theme, but if their reason for calling can naturally connect to it, lean into that connection. The theme should feel like a through-line, not a mandate." if session.show_theme else ''}
```
**Step 2: Verify backgrounds generate with theme**
Set a theme via API, then start a new session and check the server logs for background generation.
Run:
```bash
curl -s -X POST http://localhost:8000/api/show-theme -H 'Content-Type: application/json' -d '{"theme": "St. Patricks Day"}'
```
**Step 3: Commit**
```bash
git add backend/main.py
git commit -m "Inject show theme into caller background generation"
```
---
### Task 3: Inject theme into conversation system prompt
**Files:**
- Modify: `backend/main.py:5995-6094` (`get_caller_prompt`)
**Step 1: Add theme block to get_caller_prompt**
In `get_caller_prompt()`, after the `world_context` block is built (around line 6015), add:
```python
theme_context = ""
if session.show_theme:
theme_context = f"\nSHOW THEME: Tonight's show theme is \"{session.show_theme}\". You're aware of the theme — the host mentioned it at the top of the show. If your story or situation connects to it, you might bring it up naturally. But don't force it. Not every caller has to be about the theme. If the host steers you toward the theme, go with it.\n"
```
Then inject `{theme_context}` into the return f-string. Find this line (~6063):
```python
{relationship_context}{history}{world_context}{emotional_read}
```
Change it to:
```python
{relationship_context}{history}{world_context}{theme_context}{emotional_read}
```
**Step 2: Verify prompt includes theme**
This can be verified by checking server logs during a call (the full prompt is logged at debug level).
**Step 3: Commit**
```bash
git add backend/main.py
git commit -m "Inject show theme into caller conversation prompt"
```
---
### Task 4: Add theme input to frontend header
**Files:**
- Modify: `frontend/index.html` (header section, lines 11-33)
- Modify: `frontend/css/style.css`
- Modify: `frontend/js/app.js`
**Step 1: Add theme input HTML to header**
In `frontend/index.html`, inside the `<header>` section, after the `.header-buttons` div (line ~19) and before the `#show-clock` div (line ~20), add:
```html
<div class="theme-bar">
<label for="show-theme-input" class="theme-label">Theme:</label>
<input type="text" id="show-theme-input" class="theme-input" placeholder="e.g. St. Patrick's Day" maxlength="100">
<button id="set-theme-btn" class="theme-btn set" title="Set show theme">Set</button>
<button id="clear-theme-btn" class="theme-btn clear hidden" title="Clear theme">✕</button>
</div>
```
**Step 2: Add CSS for theme bar**
In `frontend/css/style.css`, add styles for the theme bar. Place near other header styles:
```css
.theme-bar {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 12px;
background: rgba(255, 255, 255, 0.05);
border-radius: 6px;
}
.theme-label {
font-size: 0.8rem;
color: #aaa;
white-space: nowrap;
}
.theme-input {
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 4px;
color: #fff;
padding: 4px 8px;
font-size: 0.85rem;
width: 200px;
}
.theme-input:focus {
outline: none;
border-color: #f5a623;
}
.theme-input.active {
border-color: #f5a623;
background: rgba(245, 166, 35, 0.1);
}
.theme-btn {
padding: 4px 10px;
border-radius: 4px;
border: none;
cursor: pointer;
font-size: 0.8rem;
}
.theme-btn.set {
background: #f5a623;
color: #000;
}
.theme-btn.set:hover {
background: #e6991a;
}
.theme-btn.clear {
background: rgba(255, 255, 255, 0.1);
color: #aaa;
padding: 4px 6px;
}
.theme-btn.clear:hover {
background: rgba(255, 80, 80, 0.3);
color: #ff5050;
}
```
**Step 3: Add JS for theme set/clear**
In `frontend/js/app.js`, add theme management functions and wire up event listeners. Add near other initialization code:
```javascript
async function loadShowTheme() {
try {
const res = await fetch('/api/show-theme');
const data = await res.json();
const input = document.getElementById('show-theme-input');
const setBtn = document.getElementById('set-theme-btn');
const clearBtn = document.getElementById('clear-theme-btn');
if (data.theme) {
input.value = data.theme;
input.classList.add('active');
setBtn.classList.add('hidden');
clearBtn.classList.remove('hidden');
}
} catch (e) {
console.error('Failed to load show theme:', e);
}
}
async function setShowTheme() {
const input = document.getElementById('show-theme-input');
const theme = input.value.trim();
if (!theme) return;
try {
const res = await fetch('/api/show-theme', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ theme })
});
const data = await res.json();
if (data.theme) {
input.classList.add('active');
document.getElementById('set-theme-btn').classList.add('hidden');
document.getElementById('clear-theme-btn').classList.remove('hidden');
}
} catch (e) {
console.error('Failed to set show theme:', e);
}
}
async function clearShowTheme() {
try {
await fetch('/api/show-theme', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ theme: '' })
});
const input = document.getElementById('show-theme-input');
input.value = '';
input.classList.remove('active');
document.getElementById('set-theme-btn').classList.remove('hidden');
document.getElementById('clear-theme-btn').classList.add('hidden');
} catch (e) {
console.error('Failed to clear show theme:', e);
}
}
```
Wire up event listeners (in the DOMContentLoaded or init block):
```javascript
document.getElementById('set-theme-btn').addEventListener('click', setShowTheme);
document.getElementById('clear-theme-btn').addEventListener('click', clearShowTheme);
document.getElementById('show-theme-input').addEventListener('keydown', (e) => {
if (e.key === 'Enter') setShowTheme();
});
loadShowTheme();
```
**Step 4: Test in browser**
1. Open http://localhost:8000
2. Type a theme in the input, click Set — input should highlight amber, Set button hides, X button appears
3. Click X — input clears, reverts to normal state
4. Refresh page — theme should persist (loaded from API)
**Step 5: Commit**
```bash
git add frontend/index.html frontend/css/style.css frontend/js/app.js
git commit -m "Add show theme input to header bar"
```
---
### Task 5: Clear theme on new session
**Files:**
- Modify: `backend/main.py` (session reset logic)
**Step 1: Find session reset and ensure theme clears**
Search for where `session = Session()` is called (the new session endpoint). The theme field is already in `__init__` with default `""`, so creating a new Session automatically clears it. No code change needed here — but verify the frontend reloads the theme on new session.
In the frontend, find the new session button handler and add `loadShowTheme()` after the session reset call completes, so the UI reflects the cleared theme.
**Step 2: Commit (if any changes needed)**
```bash
git add frontend/js/app.js
git commit -m "Reload theme state on new session"
```
@@ -0,0 +1,300 @@
# Caller Quality Overhaul — Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Make every caller interesting, layered, and conversationally driven — with specific details to reveal when pressed, strong opinions, and stories that sustain 10+ exchanges without going flat.
**Architecture:** Four interconnected changes: (1) LLM-generated updates for returning callers, (2) hidden layers in CallerBackground, (3) rebalanced caller prompt from reactive to driven, (4) massively expanded topic pools with more edgy/interesting/specific content. The alien abduction call (Bev, ep45) is the gold standard — professional expertise, progressive reveals, specific details, caller-driven energy.
**Tech Stack:** Python, OpenRouter LLM API, existing CallerBackground dataclass
---
## Task 1: Add Hidden Layers to CallerBackground
The core structural change. Callers currently get ONE situation paragraph. When the host digs in, there's nothing underneath. Add 3 fields to CallerBackground that give the caller ammunition for deeper conversations.
**Files:**
- Modify: `backend/main.py` — CallerBackground dataclass (~line 6140), LLM background generation prompt (~line 5310)
**Step 1: Add fields to CallerBackground dataclass**
Find the CallerBackground dataclass and add three new fields:
```python
hidden_layers: list[str] = field(default_factory=list) # 3 details they haven't mentioned yet — the juicy stuff underneath
burning_opinion: str = "" # Something they're dying to say — will bring up even without being asked
stakes: str = "" # What's at risk for them — why this matters, what happens if nothing changes
```
**Step 2: Update the LLM background generation prompt**
In the JSON output spec of the background generation prompt (~line 5322), add:
```
- "hidden_layers": A list of exactly 3 specific details the caller HASN'T mentioned yet but will reveal when pressed. These are the layers underneath the surface story. Think: the part they're embarrassed about, the complication they haven't admitted, the thing that happened AFTER the main event, the detail that changes everything. Each should be 1-2 sentences and SPECIFIC enough to sustain a follow-up question. Example: if the surface story is "my neighbor stole my mail" — layer 1 might be "the stolen mail included a paternity test result," layer 2 might be "the neighbor is actually her ex-husband's new girlfriend," layer 3 might be "she's been retaliating by feeding the neighbor's cat so it likes her better."
- "burning_opinion": ONE thing this caller is dying to say — a strong opinion, a controversial take, something they'll volunteer even without being asked. This is what makes them INTERESTING to talk to. Not a generic feeling ("I'm frustrated") but a specific, arguable position ("I think what she did was right and I'd do it again"). Make it provocative enough that the host would want to push back.
- "stakes": What's at risk for this caller. Why does this matter? What happens if nothing changes? "My sister won't talk to me" is weak. "If I don't fix this by Thursday, my sister is telling our parents about the money I took and I'll get cut out of the will" is strong. Real consequences, real deadlines, real pressure.
```
**Step 3: Update the prompt's "WHAT MAKES A GOOD CALLER" section**
Add to the existing guidance:
```
DEPTH TEST: Before finalizing, ask yourself — if the host asks "tell me more about that" THREE times, does the caller have three genuinely new, interesting things to reveal? If not, the story is too shallow. Add complications, secrets, or consequences that create layers.
```
**Step 4: Thread hidden_layers into the caller system prompt**
In `get_caller_prompt()` (~line 6015), add a section that feeds hidden layers to the caller:
```python
# Hidden layers — details to reveal when pressed
layers_block = ""
if caller.get('hidden_layers'):
layers = caller['hidden_layers']
layers_block = f"""
DETAILS YOU HAVEN'T MENTIONED YET (reveal these naturally when Luke asks follow-up questions or digs deeper — don't dump them all at once, let them come out one at a time as the conversation develops):
- {layers[0] if len(layers) > 0 else ''}
- {layers[1] if len(layers) > 1 else ''}
- {layers[2] if len(layers) > 2 else ''}
"""
# Burning opinion — something they're dying to say
opinion_block = ""
if caller.get('burning_opinion'):
opinion_block = f"""
SOMETHING YOU'RE DYING TO SAY: {caller['burning_opinion']}
You'll bring this up when there's a natural opening — you don't need to be asked. This is YOUR call and you have a POINT to make.
"""
# Stakes — why this matters
stakes_block = ""
if caller.get('stakes'):
stakes_block = f"""
WHAT'S AT STAKE: {caller['stakes']}
This isn't abstract — there are real consequences. Mention this when it's relevant. It's why you're calling NOW instead of just thinking about it.
"""
```
Insert these blocks into the prompt after `{story_block}`.
**Step 5: Commit**
---
## Task 2: LLM-Generated Updates for Returning Callers
Currently `_generate_returning_caller_background()` gives returning callers their old summaries and a one-line "something has changed." The dialog model has to improvise an update from nothing. Fix this by using the LLM to generate a specific, interesting new development.
**Files:**
- Modify: `backend/main.py``_generate_returning_caller_background()` (~line 4722)
**Step 1: Add LLM story continuation to returning caller background**
After building `prev_section` with call history (~line 4764), add an async LLM call to generate a new development:
```python
# Generate a specific new development via LLM
update_prompt = f"""A returning caller on a late-night radio show is calling back. Here's their history:
NAME: {regular['name']}, {age}, {gender}
JOB: {job}
PREVIOUS CALLS:
{chr(10).join(f'- {c["summary"]}' for c in prev_calls[-3:])}
Generate a SPECIFIC new development in their ongoing situation. Something has changed, escalated, or taken an unexpected turn since their last call. This should be:
- Surprising but believable — a natural next chapter, not a soap opera twist
- Specific with names, details, and concrete events
- Interesting enough to sustain a 5-10 minute conversation
- Connected to their previous calls but moving the story FORWARD
Also generate 3 hidden layers (details they'll reveal when pressed) and a burning opinion (something they're dying to say about how things have developed).
Respond with JSON:
{{
"new_development": "2-3 sentences describing what happened since their last call",
"hidden_layers": ["detail 1", "detail 2", "detail 3"],
"burning_opinion": "their strong take on the situation now"
}}
Output ONLY valid JSON, no markdown fences."""
```
Make this an async function. Call the LLM, parse the JSON, and inject the new_development into `prev_section` and the hidden_layers/burning_opinion into the caller's background data.
**Step 2: Update the returning caller story_block in get_caller_prompt()**
Change the returning caller `story_block` (~line 6056) from the passive "something has developed" to:
```python
story_block = f"""YOUR STORY: You're calling back about your ongoing situation. Here's what's NEW since your last call — this is why you're calling tonight:
{{new_development}}
You have SPECIFIC things to talk about. Don't just vaguely reference "things have changed" — tell Luke EXACTLY what happened. You're calling because this new development is significant and you need to talk it through. You have details, you have feelings about it, and you have a point you want to make.
When Luke asks about your previous calls, give him the quick version — then get to what's NEW. The update is why you're here tonight."""
```
**Step 3: Commit**
---
## Task 3: Rebalance Caller Prompt — Driven, Not Passive
The current prompt over-emphasizes reactivity ("GO WHERE THE HOST TAKES YOU", "Let him drive"). This makes callers passive. Real compelling callers have their own agenda AND respond to the host.
**Files:**
- Modify: `backend/main.py``get_caller_prompt()` (~line 6015)
**Step 1: Rewrite the "GO WHERE THE HOST TAKES YOU" section**
Replace the current passive framing (~line 6094) with a balanced version:
```
GO WITH THE HOST BUT BRING YOUR OWN ENERGY. When Luke pushes you in a direction, challenges you, calls you out, or plays devil's advocate — engage with it. Don't shut down, don't deflect. If he says "but isn't that really about your dad?" — sit with that. BUT you're not a passive interview subject. You called because you have something to SAY. Between his questions, volunteer details he didn't ask for. Share the part you're embarrassed about. Drop the detail that changes everything. Push back when you disagree. Ask HIM what he thinks. The best callers are the ones who give the host material AND have their own momentum. You're not here to answer questions — you're here to have a CONVERSATION.
```
**Step 2: Update the "REACT TO LUKE" section**
Change from pure reactivity (~line 6096) to a balance:
```
REACT TO LUKE — BUT KEEP YOUR MOMENTUM: Your first sentence should respond to what Luke just said. But your SECOND sentence should add something new — a detail he didn't ask for, a complication, a related story, your opinion. Don't just answer and stop. Answer, then GIVE HIM MORE. If he asks "what happened next?" — don't just tell him what happened next. Tell him what happened next AND how it made you feel AND the part you haven't told anyone yet. Fill the space. Dead air is your enemy.
```
**Step 3: Add a "WHEN LUKE ASKS FOR DETAILS" section**
Add a new section after the REACT TO LUKE block:
```
WHEN LUKE ASKS FOR DETAILS — DELIVER. If Luke asks "tell me more about that" or "what do you mean?" or pushes for specifics — this is your moment. Don't give a vague one-sentence answer. Paint the picture. Who was there? What did they actually say? What were you doing when it happened? What did the room look like? What was going through your head? Specifics are what make a call memorable. "She was mad" is boring. "She threw her drink at the wall and said 'I knew you'd do this, you're just like your father'" is radio gold. ALWAYS have a specific answer ready — if Luke is digging, it means he's interested. Reward that interest with detail.
```
**Step 4: Commit**
---
## Task 4: Massively Expand Topic Pools
The current pools have good coverage but need more entries in the categories that produce the best calls: morally complex situations, sex/relationship drama, genuinely weird experiences, and callers with real questions about their lives. The alien abduction call worked because it was SPECIFIC, WEIRD, and the caller had EXPERTISE.
**Files:**
- Modify: `backend/main.py` — PROBLEMS, STORIES, WEIRD, ADVICE, GOSSIP, TOPIC_CALLIN pools
**Step 1: Add 80+ new PROBLEMS entries**
Focus on: moral dilemmas with no clear right answer, sex/relationship situations that are messy and specific, workplace drama with real stakes, family situations with complications. Every entry should pass the "tell me more" test — can you ask 3 follow-up questions and get interesting answers?
Categories to emphasize:
- **Moral dilemmas**: situations where both sides have a point, where doing the "right" thing has real costs
- **Sex/relationship mess**: not generic "my partner cheated" but specific, awkward, funny situations with details
- **Money/ethics**: found money, inheritance drama, business partner disputes, discovered fraud
- **Secrets discovered**: found out something they shouldn't know, now they have to decide what to do with it
- **Professional expertise callers**: people calling about weird things they encountered AT WORK (like Bev the nurse with the alien patient) — medical, legal, construction, teaching, law enforcement perspectives
**Step 2: Add 60+ new WEIRD entries**
The WEIRD pool produces the best calls when it's specific enough. Focus on:
- Genuinely unexplainable personal experiences (not just "something spooky happened")
- Bizarre neighbor/coworker behavior with specific ongoing patterns
- Objects/places that don't behave normally
- Coincidences too specific to be coincidence
- Things they've noticed that nobody else seems to see
- Callers who have developed elaborate theories about everyday phenomena
**Step 3: Add 60+ new STORIES entries**
Focus on stories where the caller was INVOLVED, not just an observer:
- Times they did something they can't believe they did
- Situations that escalated beyond all reason
- Encounters with strangers that changed their perspective
- Times they were absolutely, completely wrong about something
- Situations where they were the bad guy and know it
- Things they got away with that they probably shouldn't have
**Step 4: Add 40+ new ADVICE entries**
Real questions people would actually call a radio show about:
- "Am I wrong for..." situations with genuine ambiguity
- Situations where they've already decided but want validation
- Timing/approach questions ("how do I tell my wife...")
- Callers who are about to do something and want a gut check
- Callers asking about the host's opinion on something specific and controversial
**Step 5: Add 40+ new GOSSIP entries**
Gossip works best when the caller has DETAILS and opinions:
- Discovered something about someone in their life that changes everything
- Workplace gossip with real consequences if it gets out
- Small-town drama with escalating stakes
- Things overheard that they probably shouldn't have heard
**Step 6: Commit**
---
## Task 5: Increase Response Budget for Substantive Answers
The current budget gives 15% of standard calls only 450 tokens / 3 sentences. When a caller has a great story, 3 sentences isn't enough. Shift the distribution to give callers more room to breathe, especially early in the call.
**Files:**
- Modify: `backend/main.py``_pick_response_budget()` (~line 8280)
**Step 1: Adjust default response budget distribution**
```python
# Default distribution — give callers room to tell their story
roll = random.random()
if roll < 0.10:
return 500, 4 # 10% — quick response (was 15% / 3 sentences)
elif roll < 0.35:
return 600, 5 # 25% — normal conversation (was 30% / 4 sentences)
elif roll < 0.65:
return 700, 6 # 30% — room to breathe (was 30% / 5 sentences)
else:
return 800, 7 # 35% — telling a story or riffing (was 25% / 6 sentences)
```
Also bump up shape-specific budgets proportionally.
**Step 2: Increase MIN_RESPONSE_WORDS**
Change from 20 to 30:
```python
MIN_RESPONSE_WORDS = 30 # Retry if response is shorter than this
```
**Step 3: Commit**
---
## Task 6: Quality Review and Integration Test
Read through all changes, verify they integrate cleanly, and test with a simulated caller generation.
**Files:**
- Read: all modified files
**Step 1: Verify CallerBackground field additions parse correctly**
Run a test background generation to make sure the new fields (hidden_layers, burning_opinion, stakes) are populated by the LLM and parsed into the dataclass.
**Step 2: Verify returning caller LLM update generates properly**
Test with an existing regular (e.g., Shonda) to confirm the LLM generates a specific new development.
**Step 3: Verify the new prompt sections render correctly in get_caller_prompt()**
Check that hidden_layers, burning_opinion, and stakes blocks appear in the system prompt for a caller that has them.
**Step 4: Restart server and verify no import/syntax errors**
```bash
pkill -f "uvicorn backend.main:app"
/Users/lukemacneil/code/ai-podcast/venv/bin/python -m uvicorn backend.main:app --reload --reload-dir backend --host 0.0.0.0 --port 8000
```
**Step 5: Commit**
+297
View File
@@ -0,0 +1,297 @@
# Show Quality Fixes — Episode 47 Post-Mortem
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Fix 5 bugs that ruined tonight's show: theme ignored by callers, wrong LLM models assigned, phonetic pronunciation mangling, voice-age mismatch, and low minimum response threshold.
**Architecture:** All fixes are in `backend/main.py` except voice-age matching which also touches `backend/services/tts.py` voice matching logic. Each fix is independent — no ordering dependencies between tasks.
**Tech Stack:** Python, FastAPI
---
### Task 1: Regenerate caller backgrounds when theme is set
**Problem:** `_pregenerate_backgrounds()` runs on startup when `session.show_theme` is still `""`. Setting theme via `POST /api/show-theme` only stores the string — doesn't regenerate. Callers have zero theme connection.
**Files:**
- Modify: `backend/main.py:9891-9900` (`set_show_theme` endpoint)
- Modify: `backend/main.py:5899-5927` (`_pregenerate_backgrounds`)
**Step 1: Modify `set_show_theme` to regenerate unused caller backgrounds**
In `backend/main.py`, replace the `set_show_theme` endpoint (lines 9891-9900):
```python
@app.post("/api/show-theme")
async def set_show_theme(data: dict):
theme = data.get("theme", "").strip()[:100]
old_theme = session.show_theme
session.show_theme = theme
if theme:
print(f"[Theme] Show theme set: {theme}")
elif old_theme:
print(f"[Theme] Show theme cleared (was: {old_theme})")
# Regenerate backgrounds for callers that haven't been on air yet
if theme != old_theme:
unused_keys = [k for k in CALLER_BASES if k not in session.used_callers]
if unused_keys:
print(f"[Theme] Regenerating {len(unused_keys)} unused caller backgrounds for theme: {theme or '(none)'}")
asyncio.create_task(_regenerate_backgrounds_for_keys(unused_keys))
return {"theme": session.show_theme}
```
**Step 2: Add `_regenerate_backgrounds_for_keys` helper**
Add this right after `_pregenerate_backgrounds()` (after line 5927):
```python
async def _regenerate_backgrounds_for_keys(keys: list[str]):
"""Regenerate backgrounds for specific caller keys (e.g. after theme change)."""
tasks = []
for key in keys:
base = CALLER_BASES.get(key)
if base and not base.get("returning"):
tasks.append((key, _generate_caller_background_llm(base)))
if not tasks:
return
results = await asyncio.gather(*[t[1] for t in tasks], return_exceptions=True)
for (key, _), result in zip(tasks, results):
if isinstance(result, Exception):
print(f"[Theme] Regen failed for caller {key}: {result}")
else:
session.caller_backgrounds[key] = result
# Clear cached model so it re-evaluates with new style
session.caller_models.pop(key, None)
print(f"[Theme] Regenerated {sum(1 for r in results if not isinstance(r, Exception))}/{len(tasks)} backgrounds")
_match_voices_to_styles()
_sort_caller_queue()
```
**Step 3: Verify `used_callers` exists on session**
Check that `session.used_callers` tracks which callers have already been on air. If it doesn't exist, use `session.call_history` caller keys instead.
**Step 4: Test manually**
```bash
# Start server
python -m uvicorn backend.main:app --reload --reload-dir backend --host 0.0.0.0 --port 8000
# Set theme and check logs for "[Theme] Regenerating..." messages
curl -X POST http://localhost:8000/api/show-theme -H "Content-Type: application/json" -d '{"theme": "Road Stories"}'
```
**Step 5: Commit**
```bash
git add backend/main.py
git commit -m "Regenerate caller backgrounds when show theme is set"
```
---
### Task 2: Fix style-to-model matching race condition
**Problem:** `get_caller_model()` is called before `caller_styles` is populated. `caller_styles.get(key)` returns `""`, `_normalize_style_key("")` returns `""`, no match in `caller_model_map` → falls through to `caller_model_pool[0]` (grok-4.1-fast) for everyone.
**Files:**
- Modify: `backend/main.py:6848-6875` (`get_caller_model`)
**Step 1: Fix `get_caller_model` to defer assignment when style is unknown**
Replace `get_caller_model` (lines 6848-6875):
```python
def get_caller_model(self, caller_key: str) -> str | None:
"""Get the assigned model for a caller, or assign one based on strategy.
Returns None to use default category routing."""
if self.caller_model_strategy == "single":
return None # use default category_models["caller_dialog"]
# Already assigned — keep consistent for the whole call
if caller_key in self.caller_models:
return self.caller_models[caller_key]
model = None
if self.caller_model_strategy == "cycle":
if self.caller_model_pool:
model = self.caller_model_pool[self._caller_model_cycle_idx % len(self.caller_model_pool)]
self._caller_model_cycle_idx += 1
elif self.caller_model_strategy == "style_matched":
raw_style = self.caller_styles.get(caller_key, "")
style_key = _normalize_style_key(raw_style) if raw_style else ""
if style_key:
model = self.caller_model_map.get(style_key)
if not model:
# Style not yet populated or no mapping — use fallback, not pool[0]
model = self.caller_model_fallback
if model:
self.caller_models[caller_key] = model
caller_name = CALLER_BASES.get(caller_key, {}).get("name", caller_key)
style_info = self.caller_styles.get(caller_key, "unknown")
print(f"[CallerModel] Assigned {model} to {caller_name} (style={_normalize_style_key(style_info) if style_info else 'none'}, strategy={self.caller_model_strategy})")
return model
```
The key change: when `style_key` is empty (style not yet populated) or has no mapping, use `caller_model_fallback` (claude-sonnet-4.6) instead of `caller_model_pool[0]` (grok-4.1-fast). Claude Sonnet is a much safer default — empathetic, verbose, coherent.
**Step 2: Commit**
```bash
git add backend/main.py
git commit -m "Fix style-to-model race condition — use fallback instead of pool[0]"
```
---
### Task 3: Fix pronunciation fixes producing literal phonetic text
**Problem:** `_PRONUNCIATION_FIXES` replaces "Animas" with "Ah nee mahs" as literal text. TTS reads each word separately ("Ah" "nee" "mahs") instead of blending into the intended pronunciation.
**Files:**
- Modify: `backend/main.py:9141-9152` (`_PRONUNCIATION_FIXES`)
- Modify: `backend/main.py:9212-9216` (`_apply_pronunciation_fixes`)
**Step 1: Remove pronunciation fixes that sound worse than originals**
The Inworld TTS actually handles most proper nouns fine. The fixes were added speculatively and cause more harm than good. Remove the place names that TTS can handle, keep only abbreviations:
Replace `_PRONUNCIATION_FIXES` (lines 9141-9152):
```python
_PRONUNCIATION_FIXES = {
"Castopod": "Casto pod",
"vs": "versus",
"govt": "government",
"dept": "department",
}
```
Remove `Lordsburg`, `Hachita`, `Deming`, `Bootheel`, `Animas`, and `Rodeo`. These place names either sound fine through TTS or the phonetic replacement sounds worse.
**Step 2: Commit**
```bash
git add backend/main.py
git commit -m "Remove pronunciation fixes that produce worse TTS output"
```
---
### Task 4: Add age-awareness to voice matching
**Problem:** Brandy (55 years old) got "Kayla" (young-sounding voice). `_match_voices_to_styles()` scores on style dimensions (weight, energy, warmth, age_feel) but the `age_feel` preference comes from the communication style, not the character's actual age. A "confrontational" style prefers `age_feel: None` (no preference), so a 55-year-old can get a young voice.
**Files:**
- Modify: `backend/main.py:6106-6156` (`_match_voices_to_styles`)
**Step 1: Add character age to voice scoring**
In `_match_voices_to_styles`, after getting the style preferences, override `age_feel` based on the caller's actual age from their background:
```python
def _match_voices_to_styles():
"""Re-assign voices to match caller communication styles after backgrounds are generated."""
from .services.tts import VOICE_PROFILES
for key, base in CALLER_BASES.items():
if base.get("returning"):
continue
style_raw = session.caller_styles.get(key, "")
if not style_raw:
continue
style_key = _normalize_style_key(style_raw)
prefs = STYLE_VOICE_PREFERENCES.get(style_key)
if not prefs:
continue
# Copy prefs so we don't mutate the shared dict
prefs = dict(prefs)
# Override age_feel based on character's actual age
bg = session.caller_backgrounds.get(key)
if isinstance(bg, CallerBackground) and bg.age:
if bg.age >= 50:
prefs["age_feel"] = "mature"
elif bg.age >= 35:
prefs["age_feel"] = "middle"
elif bg.age < 25:
prefs["age_feel"] = "young"
# 25-34: keep style preference or None
gender = base["gender"]
pool = INWORLD_MALE_VOICES if gender == "male" else INWORLD_FEMALE_VOICES
voice_pool = [v for v in pool if v not in BLACKLISTED_VOICES]
scored = []
for voice_name in voice_pool:
profile = VOICE_PROFILES.get(voice_name)
if not profile:
scored.append((voice_name, 0))
continue
score = 0
for dim in ["weight", "energy", "warmth", "age_feel"]:
pref_val = prefs.get(dim)
if pref_val and profile.get(dim) == pref_val:
score += 1
scored.append((voice_name, score))
if scored:
names = [s[0] for s in scored]
weights = [max(1, s[1] * 3) for s in scored]
chosen = random.choices(names, weights=weights, k=1)[0]
used_voices = {CALLER_BASES[k]["voice"] for k in CALLER_BASES if k != key and "voice" in CALLER_BASES[k]}
if chosen in used_voices:
alternatives = [(n, w) for n, w in zip(names, weights) if n not in used_voices]
if alternatives:
alt_names, alt_weights = zip(*alternatives)
chosen = random.choices(alt_names, weights=alt_weights, k=1)[0]
old_voice = base.get("voice", "")
base["voice"] = chosen
if old_voice != chosen:
print(f"[VoiceMatch] {base.get('name', key)}: {old_voice} → {chosen} (style: {style_key}, age: {bg.age if isinstance(bg, CallerBackground) else '?'})")
```
**Step 2: Commit**
```bash
git add backend/main.py
git commit -m "Add age-awareness to voice matching — 55yo won't get young voices"
```
---
### Task 5: Raise minimum response word count
**Problem:** `MIN_RESPONSE_WORDS = 30` lets through fragmented, telegram-style responses that are technically 30+ words but terrible radio.
**Files:**
- Modify: `backend/main.py:8844` (`MIN_RESPONSE_WORDS`)
**Step 1: Raise the minimum**
Change line 8844:
```python
MIN_RESPONSE_WORDS = 50 # Retry if response is shorter than this
```
50 words is roughly 2-3 spoken sentences — enough to be a coherent radio response without being overly demanding for short-form exchanges.
**Step 2: Commit**
```bash
git add backend/main.py
git commit -m "Raise MIN_RESPONSE_WORDS from 30 to 50"
```
@@ -0,0 +1,107 @@
# Cost Dashboard Design
## Overview
A dedicated cost analytics dashboard at `/costs` that visualizes LLM and TTS spending across sessions with time-range filtering, model/category breakdowns, and drill-down into individual sessions and calls.
## Architecture
- **Route:** `/costs` served by FastAPI, standalone page matching the control panel's dark theme
- **Database:** SQLite (`data/costs.db`) for cross-session aggregation
- **Charts:** Chart.js (vanilla JS, no framework)
- **Data migration:** On first run, import existing `data/cost_reports/*.json` into SQLite
- **Dual write:** `cost_tracker.py` continues writing JSON reports (backward compat) and also writes to SQLite going forward
## Database Schema
### `sessions`
| Column | Type | Description |
|--------|------|-------------|
| id | TEXT PK | Session ID |
| started_at | TIMESTAMP | Session start time |
| total_cost | REAL | Total cost USD |
| llm_cost | REAL | LLM cost USD |
| tts_cost | REAL | TTS cost USD |
| total_llm_calls | INTEGER | Number of LLM calls |
| total_tts_calls | INTEGER | Number of TTS calls |
| total_tokens | INTEGER | Total tokens used |
| prompt_tokens | INTEGER | Prompt tokens |
| completion_tokens | INTEGER | Completion tokens |
### `llm_calls`
| Column | Type | Description |
|--------|------|-------------|
| id | INTEGER PK | Auto-increment |
| session_id | TEXT FK | References sessions.id |
| timestamp | TIMESTAMP | Call time |
| category | TEXT | background_gen, caller_dialog, devon_monitor, etc. |
| model | TEXT | Model identifier |
| prompt_tokens | INTEGER | Prompt tokens |
| completion_tokens | INTEGER | Completion tokens |
| cost | REAL | Cost USD |
| caller_name | TEXT | Caller name (nullable) |
| latency_ms | REAL | Response latency |
### `tts_calls`
| Column | Type | Description |
|--------|------|-------------|
| id | INTEGER PK | Auto-increment |
| session_id | TEXT FK | References sessions.id |
| timestamp | TIMESTAMP | Call time |
| provider | TEXT | Inworld, ElevenLabs, etc. |
| voice | TEXT | Voice ID |
| char_count | INTEGER | Characters synthesized |
| cost | REAL | Cost USD |
## API Endpoints
All new endpoints under `/api/costs/`:
| Endpoint | Description |
|----------|-------------|
| `GET /api/costs/summary?period=today\|week\|month\|all` | Aggregated totals: spend, LLM/TTS split, call count, tokens, avg cost/session, % change vs previous period |
| `GET /api/costs/timeline?period=week\|month\|all&group_by=session\|day` | Time-series for line chart (cost over time) |
| `GET /api/costs/models?period=week\|month\|all` | Per-model breakdown for pie/bar charts |
| `GET /api/costs/categories?period=week\|month\|all` | Per-category breakdown (background_gen, caller_dialog, etc.) |
| `GET /api/costs/sessions?period=week\|month\|all` | Session list with totals, sortable |
| `GET /api/costs/session/{id}` | Single session detail: per-caller costs, expensive calls, recommendations |
| `GET /api/costs/expensive?period=week\|month\|all&limit=10` | Top N most expensive individual calls |
Existing `/api/costs` (live session) endpoint remains unchanged.
## Dashboard Layout
### Header
Time range selector tabs: Today / This Week / This Month / All Time. Clicking any tab refreshes all charts.
### Row 1 — Summary Cards (4 across)
- **Total Spend** with % change vs previous period
- **LLM / TTS Split** showing both values
- **Total Sessions** in period
- **Avg Cost Per Session**
### Row 2 — Two charts side by side
- **Left: Cost Over Time** — line chart, x-axis sessions or days, y-axis dollars. LLM and TTS as separate lines.
- **Right: Cost by Model** — doughnut chart with legend showing dollar amounts.
### Row 3 — Two charts side by side
- **Left: Cost by Category** — horizontal bar chart (background_gen, caller_dialog, devon_monitor, etc.)
- **Right: Cost Per Session Trend** — bar chart, each bar a session, colored above/below average.
### Row 4 — Tables
- **Most Expensive Calls** — top 10 LLM calls (model, category, caller, tokens, cost, timestamp)
- **Session List** — all sessions, sortable by date/cost, clickable for detail view.
### Session Detail View (click-through)
- Per-caller cost breakdown
- Call-by-call timeline
- Recommendations from existing `_generate_recommendations()` logic
## Visual Style
Matches the control panel's existing dark theme. Same fonts, colors, card styles.
## What's NOT in v1
- SignalWire cost tracking (future addition)
- Real-time WebSocket updates (polling on page load is sufficient)
- Cost alerts/budgets
- Export to CSV
@@ -0,0 +1,810 @@
# Cost Dashboard Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Build a cost analytics dashboard at `/costs` that visualizes LLM and TTS spending across sessions with time-range filtering, charts, and drill-down.
**Architecture:** SQLite database (`data/costs.db`) stores all cost records. Existing JSON reports are imported on first run. `cost_tracker.py` dual-writes to both JSON and SQLite. New API endpoints serve aggregated data. Standalone HTML page with Chart.js renders the dashboard.
**Tech Stack:** Python/FastAPI, SQLite, Chart.js (CDN), vanilla JS, CSS custom properties matching existing dark theme.
---
### Task 1: SQLite Database Module
**Files:**
- Create: `backend/services/cost_db.py`
**Step 1: Create the database module with schema**
Create `backend/services/cost_db.py` with:
- `init_db(db_path)` — creates tables if not exist, returns connection
- `import_json_reports(db_path, reports_dir)` — scans `data/cost_reports/*.json`, imports any sessions not already in the DB
- `get_db()` — returns a connection to `data/costs.db`, calls `init_db` on first use
```python
import json
import sqlite3
from datetime import datetime, timedelta
from pathlib import Path
DB_PATH = Path(__file__).parent.parent.parent / "data" / "costs.db"
REPORTS_DIR = Path(__file__).parent.parent.parent / "data" / "cost_reports"
_connection = None
SCHEMA = """
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
started_at TIMESTAMP,
total_cost REAL DEFAULT 0,
llm_cost REAL DEFAULT 0,
tts_cost REAL DEFAULT 0,
total_llm_calls INTEGER DEFAULT 0,
total_tts_calls INTEGER DEFAULT 0,
total_tokens INTEGER DEFAULT 0,
prompt_tokens INTEGER DEFAULT 0,
completion_tokens INTEGER DEFAULT 0
);
CREATE TABLE IF NOT EXISTS llm_calls (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL,
timestamp TIMESTAMP,
category TEXT,
model TEXT,
prompt_tokens INTEGER DEFAULT 0,
completion_tokens INTEGER DEFAULT 0,
cost REAL DEFAULT 0,
caller_name TEXT,
latency_ms REAL DEFAULT 0,
FOREIGN KEY (session_id) REFERENCES sessions(id)
);
CREATE TABLE IF NOT EXISTS tts_calls (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL,
timestamp TIMESTAMP,
provider TEXT,
voice TEXT,
char_count INTEGER DEFAULT 0,
cost REAL DEFAULT 0,
FOREIGN KEY (session_id) REFERENCES sessions(id)
);
CREATE INDEX IF NOT EXISTS idx_llm_session ON llm_calls(session_id);
CREATE INDEX IF NOT EXISTS idx_llm_timestamp ON llm_calls(timestamp);
CREATE INDEX IF NOT EXISTS idx_llm_category ON llm_calls(category);
CREATE INDEX IF NOT EXISTS idx_llm_model ON llm_calls(model);
CREATE INDEX IF NOT EXISTS idx_tts_session ON tts_calls(session_id);
CREATE INDEX IF NOT EXISTS idx_tts_timestamp ON tts_calls(timestamp);
"""
def get_db():
global _connection
if _connection is None:
_connection = sqlite3.connect(str(DB_PATH), check_same_thread=False)
_connection.row_factory = sqlite3.Row
_connection.executescript(SCHEMA)
import_json_reports()
return _connection
def import_json_reports():
db = _connection
if not REPORTS_DIR.exists():
return
existing = {row[0] for row in db.execute("SELECT id FROM sessions").fetchall()}
for f in sorted(REPORTS_DIR.glob("*.json")):
try:
data = json.loads(f.read_text())
except (json.JSONDecodeError, OSError):
continue
session_id = data.get("session_id", f.stem)
if session_id in existing:
continue
saved_at = data.get("saved_at")
started_at = datetime.fromtimestamp(saved_at).isoformat() if saved_at else None
db.execute(
"INSERT INTO sessions (id, started_at, total_cost, llm_cost, tts_cost, total_llm_calls, total_tts_calls, total_tokens, prompt_tokens, completion_tokens) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
(session_id, started_at, data.get("total_cost_usd", 0), data.get("llm_cost_usd", 0), data.get("tts_cost_usd", 0), data.get("total_llm_calls", 0), len(data.get("raw_tts_records", [])), data.get("total_tokens", 0), data.get("prompt_tokens", 0), data.get("completion_tokens", 0)),
)
for rec in data.get("raw_llm_records", []):
db.execute(
"INSERT INTO llm_calls (session_id, timestamp, category, model, prompt_tokens, completion_tokens, cost, caller_name, latency_ms) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
(session_id, rec.get("timestamp"), rec.get("category"), rec.get("model"), rec.get("prompt_tokens", 0), rec.get("completion_tokens", 0), rec.get("cost_usd", 0), rec.get("caller_name"), rec.get("latency_ms", 0)),
)
for rec in data.get("raw_tts_records", []):
db.execute(
"INSERT INTO tts_calls (session_id, timestamp, provider, voice, char_count, cost) VALUES (?, ?, ?, ?, ?, ?)",
(session_id, rec.get("timestamp"), rec.get("provider"), rec.get("voice"), rec.get("char_count", 0), rec.get("cost_usd", 0)),
)
existing.add(session_id)
db.commit()
def record_llm_call(session_id, timestamp, category, model, prompt_tokens, completion_tokens, cost, caller_name, latency_ms):
db = get_db()
db.execute(
"INSERT INTO llm_calls (session_id, timestamp, category, model, prompt_tokens, completion_tokens, cost, caller_name, latency_ms) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
(session_id, timestamp, category, model, prompt_tokens, completion_tokens, cost, caller_name, latency_ms),
)
db.commit()
def record_tts_call(session_id, timestamp, provider, voice, char_count, cost):
db = get_db()
db.execute(
"INSERT INTO tts_calls (session_id, timestamp, provider, voice, char_count, cost) VALUES (?, ?, ?, ?, ?, ?)",
(session_id, timestamp, provider, voice, char_count, cost),
)
db.commit()
def ensure_session(session_id, started_at=None):
db = get_db()
existing = db.execute("SELECT id FROM sessions WHERE id = ?", (session_id,)).fetchone()
if not existing:
db.execute(
"INSERT INTO sessions (id, started_at) VALUES (?, ?)",
(session_id, started_at or datetime.now().isoformat()),
)
db.commit()
def update_session_totals(session_id):
db = get_db()
llm = db.execute(
"SELECT COUNT(*) as calls, COALESCE(SUM(cost), 0) as cost, COALESCE(SUM(prompt_tokens), 0) as pt, COALESCE(SUM(completion_tokens), 0) as ct FROM llm_calls WHERE session_id = ?",
(session_id,),
).fetchone()
tts = db.execute(
"SELECT COUNT(*) as calls, COALESCE(SUM(cost), 0) as cost FROM tts_calls WHERE session_id = ?",
(session_id,),
).fetchone()
total_tokens = llm["pt"] + llm["ct"]
total_cost = llm["cost"] + tts["cost"]
db.execute(
"UPDATE sessions SET total_cost=?, llm_cost=?, tts_cost=?, total_llm_calls=?, total_tts_calls=?, total_tokens=?, prompt_tokens=?, completion_tokens=? WHERE id=?",
(total_cost, llm["cost"], tts["cost"], llm["calls"], tts["calls"], total_tokens, llm["pt"], llm["ct"], session_id),
)
db.commit()
def _period_filter(period):
now = datetime.now()
if period == "today":
start = now.replace(hour=0, minute=0, second=0, microsecond=0)
elif period == "week":
start = now - timedelta(days=now.weekday())
start = start.replace(hour=0, minute=0, second=0, microsecond=0)
elif period == "month":
start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
else:
return None
return start.isoformat()
def get_summary(period="all"):
db = get_db()
start = _period_filter(period)
if start:
where = "WHERE started_at >= ?"
params = (start,)
prev_start = _get_previous_period_start(period)
prev_where = "WHERE started_at >= ? AND started_at < ?"
prev_params = (prev_start, start)
else:
where = ""
params = ()
prev_where = None
prev_params = ()
row = db.execute(
f"SELECT COUNT(*) as sessions, COALESCE(SUM(total_cost), 0) as total_cost, COALESCE(SUM(llm_cost), 0) as llm_cost, COALESCE(SUM(tts_cost), 0) as tts_cost, COALESCE(SUM(total_llm_calls), 0) as total_calls, COALESCE(SUM(total_tokens), 0) as total_tokens FROM sessions {where}",
params,
).fetchone()
result = {
"total_cost": round(row["total_cost"], 4),
"llm_cost": round(row["llm_cost"], 4),
"tts_cost": round(row["tts_cost"], 4),
"sessions": row["sessions"],
"total_calls": row["total_calls"],
"total_tokens": row["total_tokens"],
"avg_cost_per_session": round(row["total_cost"] / max(row["sessions"], 1), 4),
}
if prev_where:
prev = db.execute(
f"SELECT COALESCE(SUM(total_cost), 0) as total_cost FROM sessions {prev_where}",
prev_params,
).fetchone()
prev_cost = prev["total_cost"]
if prev_cost > 0:
result["pct_change"] = round((row["total_cost"] - prev_cost) / prev_cost * 100, 1)
else:
result["pct_change"] = None
else:
result["pct_change"] = None
return result
def _get_previous_period_start(period):
now = datetime.now()
if period == "today":
return (now - timedelta(days=1)).replace(hour=0, minute=0, second=0, microsecond=0).isoformat()
elif period == "week":
start_of_week = now - timedelta(days=now.weekday())
return (start_of_week - timedelta(days=7)).replace(hour=0, minute=0, second=0, microsecond=0).isoformat()
elif period == "month":
first_of_month = now.replace(day=1)
prev_month = first_of_month - timedelta(days=1)
return prev_month.replace(day=1, hour=0, minute=0, second=0, microsecond=0).isoformat()
return None
def get_timeline(period="all", group_by="session"):
db = get_db()
start = _period_filter(period)
if group_by == "day":
if start:
rows = db.execute(
"SELECT DATE(started_at) as date, SUM(llm_cost) as llm_cost, SUM(tts_cost) as tts_cost, SUM(total_cost) as total_cost, COUNT(*) as sessions FROM sessions WHERE started_at >= ? GROUP BY DATE(started_at) ORDER BY date",
(start,),
).fetchall()
else:
rows = db.execute(
"SELECT DATE(started_at) as date, SUM(llm_cost) as llm_cost, SUM(tts_cost) as tts_cost, SUM(total_cost) as total_cost, COUNT(*) as sessions FROM sessions GROUP BY DATE(started_at) ORDER BY date"
).fetchall()
else:
if start:
rows = db.execute(
"SELECT id, started_at, llm_cost, tts_cost, total_cost FROM sessions WHERE started_at >= ? ORDER BY started_at",
(start,),
).fetchall()
else:
rows = db.execute(
"SELECT id, started_at, llm_cost, tts_cost, total_cost FROM sessions ORDER BY started_at"
).fetchall()
return [dict(r) for r in rows]
def get_models(period="all"):
db = get_db()
start = _period_filter(period)
if start:
rows = db.execute(
"SELECT l.model, COUNT(*) as calls, COALESCE(SUM(l.cost), 0) as cost, COALESCE(SUM(l.prompt_tokens), 0) as prompt_tokens, COALESCE(SUM(l.completion_tokens), 0) as completion_tokens FROM llm_calls l JOIN sessions s ON l.session_id = s.id WHERE s.started_at >= ? GROUP BY l.model ORDER BY cost DESC",
(start,),
).fetchall()
else:
rows = db.execute(
"SELECT model, COUNT(*) as calls, COALESCE(SUM(cost), 0) as cost, COALESCE(SUM(prompt_tokens), 0) as prompt_tokens, COALESCE(SUM(completion_tokens), 0) as completion_tokens FROM llm_calls GROUP BY model ORDER BY cost DESC"
).fetchall()
return [dict(r) for r in rows]
def get_categories(period="all"):
db = get_db()
start = _period_filter(period)
if start:
rows = db.execute(
"SELECT l.category, COUNT(*) as calls, COALESCE(SUM(l.cost), 0) as cost, COALESCE(SUM(l.prompt_tokens + l.completion_tokens), 0) as tokens FROM llm_calls l JOIN sessions s ON l.session_id = s.id WHERE s.started_at >= ? GROUP BY l.category ORDER BY cost DESC",
(start,),
).fetchall()
else:
rows = db.execute(
"SELECT category, COUNT(*) as calls, COALESCE(SUM(cost), 0) as cost, COALESCE(SUM(prompt_tokens + completion_tokens), 0) as tokens FROM llm_calls GROUP BY category ORDER BY cost DESC"
).fetchall()
return [dict(r) for r in rows]
def get_sessions_list(period="all"):
db = get_db()
start = _period_filter(period)
if start:
rows = db.execute(
"SELECT id, started_at, total_cost, llm_cost, tts_cost, total_llm_calls, total_tokens FROM sessions WHERE started_at >= ? ORDER BY started_at DESC",
(start,),
).fetchall()
else:
rows = db.execute(
"SELECT id, started_at, total_cost, llm_cost, tts_cost, total_llm_calls, total_tokens FROM sessions ORDER BY started_at DESC"
).fetchall()
return [dict(r) for r in rows]
def get_session_detail(session_id):
db = get_db()
session = db.execute("SELECT * FROM sessions WHERE id = ?", (session_id,)).fetchone()
if not session:
return None
by_caller = db.execute(
"SELECT caller_name, COUNT(*) as calls, COALESCE(SUM(cost), 0) as cost, COALESCE(SUM(prompt_tokens + completion_tokens), 0) as tokens FROM llm_calls WHERE session_id = ? AND caller_name != '' GROUP BY caller_name ORDER BY cost DESC",
(session_id,),
).fetchall()
by_model = db.execute(
"SELECT model, COUNT(*) as calls, COALESCE(SUM(cost), 0) as cost FROM llm_calls WHERE session_id = ? GROUP BY model ORDER BY cost DESC",
(session_id,),
).fetchall()
by_category = db.execute(
"SELECT category, COUNT(*) as calls, COALESCE(SUM(cost), 0) as cost FROM llm_calls WHERE session_id = ? GROUP BY category ORDER BY cost DESC",
(session_id,),
).fetchall()
expensive = db.execute(
"SELECT category, model, caller_name, cost, prompt_tokens, completion_tokens, latency_ms, timestamp FROM llm_calls WHERE session_id = ? ORDER BY cost DESC LIMIT 10",
(session_id,),
).fetchall()
tts = db.execute(
"SELECT provider, COUNT(*) as calls, COALESCE(SUM(cost), 0) as cost, COALESCE(SUM(char_count), 0) as chars FROM tts_calls WHERE session_id = ? GROUP BY provider ORDER BY cost DESC",
(session_id,),
).fetchall()
return {
"session": dict(session),
"by_caller": [dict(r) for r in by_caller],
"by_model": [dict(r) for r in by_model],
"by_category": [dict(r) for r in by_category],
"expensive_calls": [dict(r) for r in expensive],
"tts_by_provider": [dict(r) for r in tts],
}
def get_expensive_calls(period="all", limit=10):
db = get_db()
start = _period_filter(period)
if start:
rows = db.execute(
"SELECT l.category, l.model, l.caller_name, l.cost, l.prompt_tokens, l.completion_tokens, l.latency_ms, l.timestamp, l.session_id FROM llm_calls l JOIN sessions s ON l.session_id = s.id WHERE s.started_at >= ? ORDER BY l.cost DESC LIMIT ?",
(start, limit),
).fetchall()
else:
rows = db.execute(
"SELECT category, model, caller_name, cost, prompt_tokens, completion_tokens, latency_ms, timestamp, session_id FROM llm_calls ORDER BY cost DESC LIMIT ?",
(limit,),
).fetchall()
return [dict(r) for r in rows]
```
**Step 2: Verify the module loads**
Run: `cd /Users/lukemacneil/code/ai-podcast && python -c "from backend.services.cost_db import get_db; db = get_db(); print('OK, sessions:', db.execute('SELECT COUNT(*) FROM sessions').fetchone()[0])"`
Expected: `OK, sessions: 18` (or however many JSON reports exist)
**Step 3: Commit**
```bash
git add backend/services/cost_db.py
git commit -m "Add SQLite cost database module with JSON import"
```
---
### Task 2: Integrate SQLite Writes into Cost Tracker
**Files:**
- Modify: `backend/services/cost_tracker.py`
**Step 1: Add dual-write to `record_llm_call`**
At the top of `cost_tracker.py`, add the import:
```python
from backend.services import cost_db
```
In `record_llm_call()` (around line 148, after appending to `self.llm_records`), add:
```python
try:
cost_db.ensure_session(self._session_id)
cost_db.record_llm_call(
self._session_id, record.timestamp, record.category, record.model,
record.prompt_tokens, record.completion_tokens, record.cost_usd,
record.caller_name, record.latency_ms,
)
except Exception:
pass # don't break show over analytics
```
**Step 2: Add dual-write to `record_tts_call`**
In `record_tts_call()` (around line 166, after appending to `self.tts_records`), add:
```python
try:
cost_db.record_tts_call(
self._session_id, record.timestamp, record.provider, record.voice,
record.char_count, record.cost_usd,
)
except Exception:
pass
```
**Step 3: Add session_id tracking to `__init__`**
Add `self._session_id` to the constructor. Generate it from timestamp:
```python
self._session_id = f"session-{datetime.now().strftime('%Y-%m-%d_%H%M%S')}"
```
**Step 4: Update session totals on `save()`**
In the `save()` method, after writing the JSON file, add:
```python
try:
cost_db.update_session_totals(self._session_id)
except Exception:
pass
```
**Step 5: Commit**
```bash
git add backend/services/cost_tracker.py
git commit -m "Dual-write cost records to SQLite"
```
---
### Task 3: API Endpoints
**Files:**
- Modify: `backend/main.py` (add routes near existing `/api/costs` endpoints around line 10299)
**Step 1: Add new cost dashboard endpoints**
Add these routes near the existing cost endpoints (around line 10308):
```python
from backend.services import cost_db
@app.get("/api/costs/summary")
async def get_cost_summary(period: str = "all"):
return cost_db.get_summary(period)
@app.get("/api/costs/timeline")
async def get_cost_timeline(period: str = "all", group_by: str = "session"):
return cost_db.get_timeline(period, group_by)
@app.get("/api/costs/models")
async def get_cost_models(period: str = "all"):
return cost_db.get_models(period)
@app.get("/api/costs/categories")
async def get_cost_categories(period: str = "all"):
return cost_db.get_categories(period)
@app.get("/api/costs/sessions")
async def get_cost_sessions(period: str = "all"):
return cost_db.get_sessions_list(period)
@app.get("/api/costs/session/{session_id}")
async def get_cost_session_detail(session_id: str):
detail = cost_db.get_session_detail(session_id)
if not detail:
from fastapi.responses import JSONResponse
return JSONResponse(status_code=404, content={"error": "Session not found"})
return detail
@app.get("/api/costs/expensive")
async def get_expensive_calls(period: str = "all", limit: int = 10):
return cost_db.get_expensive_calls(period, limit)
```
**Step 2: Add route to serve the costs page**
Near the existing root route (around line 7654), add:
```python
@app.get("/costs")
async def costs_page():
return FileResponse(frontend_dir / "costs.html")
```
**Step 3: Verify endpoints respond**
Run the server: `python -m uvicorn backend.main:app --reload --reload-dir backend --host 0.0.0.0 --port 8000`
Test: `curl -s http://localhost:8000/api/costs/summary?period=all | python -m json.tool`
Expected: JSON with total_cost, llm_cost, tts_cost, sessions, etc.
**Step 4: Commit**
```bash
git add backend/main.py
git commit -m "Add cost dashboard API endpoints"
```
---
### Task 4: Dashboard HTML
**Files:**
- Create: `frontend/costs.html`
**Step 1: Create the dashboard page**
Create `frontend/costs.html` — standalone HTML page with:
- Chart.js from CDN (`https://cdn.jsdelivr.net/npm/chart.js`)
- Link to `css/style.css` (shared theme) and `css/costs.css` (dashboard-specific)
- Script tag for `js/costs.js`
- Structure: header with time range tabs, 4 summary cards, 4 chart containers, 2 tables
- Use the same CSS variables as the control panel (`--bg`, `--bg-light`, `--accent`, `--text`, etc.)
Layout structure:
```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Cost Dashboard - Luke at the Roost</title>
<link rel="stylesheet" href="/css/style.css">
<link rel="stylesheet" href="/css/costs.css">
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
</head>
<body>
<header class="costs-header">
<h1>Cost Dashboard</h1>
<a href="/" class="back-link">Back to Show</a>
<nav class="period-tabs">
<button class="period-tab active" data-period="all">All Time</button>
<button class="period-tab" data-period="month">This Month</button>
<button class="period-tab" data-period="week">This Week</button>
<button class="period-tab" data-period="today">Today</button>
</nav>
</header>
<main class="costs-main">
<section class="summary-cards">
<div class="card" id="card-total">
<div class="card-label">Total Spend</div>
<div class="card-value" id="total-spend">--</div>
<div class="card-change" id="total-change"></div>
</div>
<div class="card" id="card-llm-tts">
<div class="card-label">LLM / TTS</div>
<div class="card-value" id="llm-tts-split">--</div>
</div>
<div class="card" id="card-sessions">
<div class="card-label">Sessions</div>
<div class="card-value" id="session-count">--</div>
</div>
<div class="card" id="card-avg">
<div class="card-label">Avg / Session</div>
<div class="card-value" id="avg-cost">--</div>
</div>
</section>
<section class="chart-row">
<div class="chart-container">
<h3>Cost Over Time</h3>
<canvas id="timeline-chart"></canvas>
</div>
<div class="chart-container">
<h3>Cost by Model</h3>
<canvas id="model-chart"></canvas>
</div>
</section>
<section class="chart-row">
<div class="chart-container">
<h3>Cost by Category</h3>
<canvas id="category-chart"></canvas>
</div>
<div class="chart-container">
<h3>Cost Per Session</h3>
<canvas id="session-chart"></canvas>
</div>
</section>
<section class="tables-section">
<div class="table-container">
<h3>Most Expensive Calls</h3>
<table id="expensive-table">
<thead>
<tr><th>Model</th><th>Category</th><th>Caller</th><th>Tokens</th><th>Cost</th><th>Latency</th></tr>
</thead>
<tbody></tbody>
</table>
</div>
<div class="table-container">
<h3>Sessions</h3>
<table id="sessions-table">
<thead>
<tr><th>Date</th><th>LLM</th><th>TTS</th><th>Total</th><th>Calls</th><th></th></tr>
</thead>
<tbody></tbody>
</table>
</div>
</section>
<section class="session-detail hidden" id="session-detail">
<h2>Session Detail: <span id="detail-session-id"></span></h2>
<button class="close-detail" id="close-detail">Back</button>
<div class="detail-grid">
<div class="table-container">
<h3>By Caller</h3>
<table id="detail-caller-table">
<thead><tr><th>Caller</th><th>Calls</th><th>Cost</th></tr></thead>
<tbody></tbody>
</table>
</div>
<div class="table-container">
<h3>By Category</h3>
<table id="detail-category-table">
<thead><tr><th>Category</th><th>Calls</th><th>Cost</th></tr></thead>
<tbody></tbody>
</table>
</div>
<div class="table-container">
<h3>By Model</h3>
<table id="detail-model-table">
<thead><tr><th>Model</th><th>Calls</th><th>Cost</th></tr></thead>
<tbody></tbody>
</table>
</div>
<div class="table-container">
<h3>Most Expensive Calls</h3>
<table id="detail-expensive-table">
<thead><tr><th>Category</th><th>Model</th><th>Caller</th><th>Cost</th><th>Tokens</th><th>Latency</th></tr></thead>
<tbody></tbody>
</table>
</div>
</div>
</section>
</main>
<script src="/js/costs.js"></script>
</body>
</html>
```
**Step 2: Commit**
```bash
git add frontend/costs.html
git commit -m "Add cost dashboard HTML page"
```
---
### Task 5: Dashboard CSS
**Files:**
- Create: `frontend/css/costs.css`
**Step 1: Create dashboard-specific styles**
Create `frontend/css/costs.css` using the existing CSS variables from `style.css`. Key styles:
- `.costs-header` — flex row with title, back link, and period tabs
- `.period-tabs` / `.period-tab` — tab buttons, active state uses `--accent`
- `.summary-cards` — 4-column grid
- `.card``background: var(--bg-light)`, border, rounded corners matching `--radius`
- `.card-value` — large font, `color: var(--text)`
- `.card-change` — small text, green for negative (saving), red for positive (increase)
- `.chart-row` — 2-column grid
- `.chart-container` — padded card with canvas
- `.table-container` — styled tables matching dark theme
- `.session-detail` — full-width detail view
- Responsive: single column below 768px
Use `var(--bg)`, `var(--bg-light)`, `var(--accent)`, `var(--text)`, `var(--text-muted)`, `var(--radius)`, `var(--radius-sm)`, `var(--transition)` throughout.
**Step 2: Commit**
```bash
git add frontend/css/costs.css
git commit -m "Add cost dashboard CSS"
```
---
### Task 6: Dashboard JavaScript
**Files:**
- Create: `frontend/js/costs.js`
**Step 1: Create the dashboard JS**
Create `frontend/js/costs.js` with:
**State:**
```javascript
let currentPeriod = 'all';
let charts = {}; // store Chart.js instances for destroy/recreate
```
**Init:**
```javascript
document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('.period-tab').forEach(tab => {
tab.addEventListener('click', () => {
document.querySelector('.period-tab.active').classList.remove('active');
tab.classList.add('active');
currentPeriod = tab.dataset.period;
loadDashboard();
});
});
document.getElementById('close-detail').addEventListener('click', closeDetail);
loadDashboard();
});
```
**Data loading — `loadDashboard()`:**
- Fetch all endpoints in parallel: summary, timeline, models, categories, sessions, expensive
- Call render functions for each section
**Render functions:**
- `renderSummary(data)` — populate the 4 summary cards, format as `$X.XX`, show % change with color
- `renderTimeline(data)` — Chart.js line chart with LLM and TTS as separate datasets, `--accent` and `--devon` colors
- `renderModels(data)` — Chart.js doughnut chart with model name labels
- `renderCategories(data)` — Chart.js horizontal bar chart
- `renderSessionBars(data)` — Chart.js bar chart, bars colored based on above/below average
- `renderExpensiveTable(data)` — populate table rows
- `renderSessionsTable(data)` — populate table rows with click handler to show detail
- `showSessionDetail(sessionId)` — fetch `/api/costs/session/{id}`, show detail section, populate tables
- `closeDetail()` — hide detail section
**Chart.js config notes:**
- Use dark theme: grid lines `rgba(245, 240, 229, 0.1)` (--text at 10%), tick color `var(--text-muted)`
- Chart colors palette: `#e8791d` (accent), `#c4944a` (devon), `#5a8a3c` (green), `#cc2222` (red), `#4a8ac4` (blue), `#8a5ac4` (purple), `#c4845a` (tan)
- Tooltips: dark background, light text
- Destroy existing chart instance before creating new one (prevents memory leaks on period switch)
**Utility:**
- `formatCost(n)` — returns `$X.XX` or `$X.XXXX` for small amounts
- `formatDate(iso)` — returns readable date
- `shortenModel(name)` — strip provider prefix from model names for chart labels
**Step 2: Commit**
```bash
git add frontend/js/costs.js
git commit -m "Add cost dashboard JavaScript with Chart.js"
```
---
### Task 7: Integration Test
**Step 1: Manual verification checklist**
Start the server and navigate to `http://localhost:8000/costs`:
1. Page loads with dark theme, no console errors
2. All Time tab is active by default, shows all 18 sessions
3. Summary cards show total spend, LLM/TTS split, session count, avg cost
4. Cost Over Time line chart renders with data points
5. Cost by Model doughnut shows model breakdown
6. Cost by Category bar chart shows category breakdown
7. Cost Per Session bars render with above/below-average coloring
8. Most Expensive Calls table populated
9. Sessions table populated, rows clickable
10. Click a session row → detail view shows with per-caller, per-model, per-category tables
11. Click "Back" → returns to main dashboard
12. Switch to "This Week" tab → all charts update (may show fewer/no data)
13. Switch to "This Month" → charts update with March data
14. Switch back to "All Time" → full data restored
**Step 2: Commit all remaining changes**
```bash
git add -A
git commit -m "Cost dashboard complete — SQLite backend, Chart.js frontend"
```
---
## File Summary
| Action | File |
|--------|------|
| Create | `backend/services/cost_db.py` |
| Modify | `backend/services/cost_tracker.py` |
| Modify | `backend/main.py` |
| Create | `frontend/costs.html` |
| Create | `frontend/css/costs.css` |
| Create | `frontend/js/costs.js` |
## Dependencies
- Chart.js loaded from CDN (no npm install needed)
- SQLite is stdlib (no pip install needed)
- No new Python packages required
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,170 @@
# Caller Generation Redesign
## Overview
Replace the current caller generation pipeline (static pools + weights + scoring + 9-model routing) with a two-stage architecture: **rich identities pre-generated in one sonnet batch call per show**, then **live dialog through a single haiku-4.5 model**. The goal is callers that feel genuinely dynamic across a wide emotional range (Stern / Coast to Coast / Loveline / Delilah / O&A), with Silas preserved as a canonical character and a hard-gated, arc-driven regulars tier replacing the current low-bar promotion system.
**Target cost:** ~$0.95-1.05 per show (down from $4.32 in the sonnet-only era; roughly flat vs. the current $0.94 multi-model average, but with materially better caller quality).
## Problem
Callers currently feel static, repetitive, and homogenized. Audit findings:
1. **Every caller's reason-for-calling is a static pick** from ~200 hand-written PROBLEMS entries (or sibling pools). The LLM only writes *around* a pre-chosen seed — "dynamic generation" is an illusion.
2. **~2000 tokens of uniform prompt chorus** sit on top of every caller's dialog (GO WITH THE HOST, REACT TO LUKE, BANNED PHRASES, shape directives, etc.). This drowns out whatever personality the style block or model was trying to inject.
3. **9 dialog models × per-model param tuning × style→model map** with no empirical feedback loop. The "style diversity" layer re-introduces variety AFTER static pools and prompt chorus already homogenized it. Layers cancel each other out.
4. **Redundant style + shape systems** — 18 styles map nearly 1:1 with affinity-weighted shapes, with SHAPE_DIRECTIVES reinforcing the same thing in different words.
5. **Voice matching scoring is noisy** — 4-dim soft matching rarely produces strong discrimination; most callers score "medium" on every axis.
6. **5% random promotion to regular** produces too many recurring callers with no arc progression (e.g., "potato salad guy" calling weekly to complain about the same thing).
7. **~400 lines of dead template fallback code** only triggered on sanity-check failure.
Root cause: the system constrains creativity at both ends (static picks at input, uniform chorus at output), then tries to re-introduce variety with model/style/shape layering that cancels itself out.
## Architecture
Two stages, two models.
### Stage 1 — Pre-generation batch (sonnet-4.6, one call per show)
Runs before the show starts. One LLM call generates all caller identities for tonight in a single structured output.
**Inputs to the batch prompt:**
- Today's date, day of week, weather, time of year
- 3-5 news headlines (grounding, not forced topicality)
- Local NM/SW cultural context
- Last 2 episodes' caller summaries (anti-repeat directive)
- Active regulars' lore files (Silas always if he's in tonight's lineup; optional 1-2 tier-2 regulars)
- Creative north-star directive: "Generate 12 callers spanning earnest weirdo → chaotic character → vulnerable confession → advice-seeker → absurd. Maximum character distance between callers. No two callers should feel like siblings. Do not default to sitcom plots."
**Output per caller (JSON):**
- `name`, `age`, `voice_suggestion`, `location`
- `identity` — rich paragraph describing who this person actually is
- `situation` — what's specifically happening in their life RIGHT NOW
- `reason_calling` — why they picked up the phone tonight (a moment, not a category)
- `opening_line` — verbatim first line they'll say
- `secret_want` — what they actually want from the call (not always what they say)
- `specific_details` — 2-3 concrete details to drop in dialog
- `emotional_register` — tone this caller will bring
**No static pools. No weights. No scoring. Sonnet invents everything from the rich context.**
### Stage 2 — Live dialog (claude-haiku-4.5, one model for all callers)
When a caller is selected during the show, haiku receives a ~400-token prompt:
```
You are [name]. [identity paragraph from pre-gen.]
You're calling Luke's show because [situation + reason_calling].
What you secretly want from this call: [secret_want].
Specific details you'll drop if it feels natural: [specific_details].
Speak as this person. React to what Luke says. Stay in character.
Don't narrate. No stage directions. Just talk.
```
**No style block. No shape directive. No banned-phrases list. No per-model param tuning.** Identity from Stage 1 carries the weight; haiku plays the character.
## Regulars System
Three tiers.
### Tier 1 — Silas (canonical)
Silas's lore file lives at `~/code/dotfiles/silas/silas.md` (user's Obsidian vault). The file contains:
- **Frozen identity**: voice, age, core personality, relationship to host, canonical facts
- **Arc log**: append-only log of what's happened in past episodes
- **Current arc threads**: active storylines (e.g., "cult splintering," "rival prophet")
When Silas is in tonight's lineup, the batch prompt receives his full lore file verbatim, with the instruction: *"Invent a fresh reason Silas is calling tonight — a new cult development, grievance, or specific recent event. DO NOT alter his voice, personality, or core traits. Write a new scene for an existing character."*
**Protection from drift:** git-tracked lore file + pre-ship sample-call validation (see below).
### Tier 2 — Arc regulars (max 2-3 active at a time)
Lore files at `~/code/dotfiles/regulars/<name>.md`. Each file contains:
- **Frozen identity** (same shape as Silas)
- **Arc plan** written at promotion time: "3-5 episode arc, here's how it progresses, here's what might resolve it"
- **Arc state**: which call in the arc we're on, what's happened so far
**Return cadence:** every 3-4 episodes, when the arc has something new to advance. Not on a fixed schedule.
### Tier 3 — Walk-ins
Everyone else. Fresh each episode. No memory, no file, no callback.
### Hard promotion gate
Replaces the current 5% random roll. After a call ends, sonnet evaluates the call and answers: *"Does this character have a 3-5 episode arc in them? If yes, write the arc plan. If no, say why not."* Only promotes if sonnet produces a credible arc plan. Expected promotion rate: ~1 in 20 calls.
### Arc retirement
When sonnet judges the arc resolved (or after 5 calls without resolution), the character is archived — no more callbacks. Lore file is kept for reference but moved to `~/code/dotfiles/regulars/archived/`.
### Migration from current regulars
All current regulars except Silas are archived at cutover. `data/regulars.json` is kept as historical record. Going forward, only NEW characters can earn tier-2 status through the promotion gate.
## Model Stack
| Purpose | Model | Frequency | Cost/show (est.) |
|---------|-------|-----------|------------------|
| Caller identity pre-gen | claude-sonnet-4.6 | 1 call | ~$0.15 |
| Live dialog (all callers) | claude-haiku-4.5 | ~500 turns | ~$0.30-0.40 |
| Post-call summary | gemini-2.5-flash | 1/caller | ~$0.01 |
| Promotion evaluation | claude-sonnet-4.6 | 1/caller post-call | ~$0.05 |
| Devon monitor | gemini-2.5-flash | every 15s | ~$0.08 |
| Devon ask | gemini-2.5-flash | on demand | ~$0.01 |
| News summary | gemini-2.5-flash | 1/show | <$0.01 |
| **LLM total** | | | **~$0.60-0.70** |
| TTS (Inworld, unchanged) | | | ~$0.39 |
| **Total** | | | **~$1.00-1.10** |
## Code to Delete (~2000 lines)
From `backend/main.py`:
- `PROBLEMS`, `STORIES`, `GOSSIP`, `ADVICE`, `TOPIC_CALLIN`, `CELEBRATIONS`, `WEIRD`, `HOT_TAKES` content pools
- `INTERESTS`, `QUIRKS`, `RELATIONSHIP_STATUS`, `VEHICLES`, `BEFORE_CALLING`, `CALLING_FROM`, `MEMORIES`, `HAVING_RIGHT_NOW`, `STRONG_OPINIONS`, `CONTRADICTIONS`, `VERBAL_TICS`, `EMOTIONAL_ARCS`, `SHOW_RELATIONSHIP`, `LATE_NIGHT_REASONS`, `DRIFT_TENDENCIES`, `ROAD_CONTEXT`, `PHONE_SITUATION`, `BACKGROUND_MUSIC`, `RECENT_ERRAND`, `TV_TONIGHT`, `LOCAL_FOOD_OPINIONS`, `NOSTALGIA` color pools
- `CALLER_STYLES` (18 style paragraphs), `CALLER_STYLE_KEYS`
- `STYLE_VOICE_PREFERENCES`, `STYLE_SPEED_MODIFIERS`, `STYLE_PHONE_QUALITY`
- `CALL_SHAPES`, `SHAPE_STYLE_AFFINITIES`, `SHAPE_DIRECTIVES`, `_LATE_SHOW_SHAPES`
- `_SPICY_KEYWORDS`, `_ABSURD_KEYWORDS`, `_HEAVY_POOLS`, `_LIGHT_POOLS`, `_HEAVY_STYLES`, `_LIGHT_STYLES`, `_EVASIVE_STYLES`
- `caller_model_pool`, `caller_model_map`, `_CALLER_DIALOG_MODEL_PARAMS`
- `_generate_pool_weights`, `_pick_unique_reason`, `_pick_caller_style`, `_pick_call_shape`, `_assign_call_shape`
- `_match_voices_to_styles`, voice-scoring logic
- `_sort_caller_queue` (greedy placement scoring)
- `generate_caller_background` (template fallback, ~400 lines)
- `_build_relationship_context` (inter-caller thematic scoring)
- `SHOW_HISTORY_REACTIONS`, adaptive reaction frequency logic
Kept:
- `CALLER_BASES` (10 slots with gender/age — still drives voice/name selection)
- `MALE_NAMES`, `FEMALE_NAMES` (used as naming hints for sonnet, not picks)
- Voice rosters (`INWORLD_MALE`, etc.) — sonnet suggests, we validate against roster
- `_pick_response_budget`, `_retry_if_too_short`, `_has_repetition` (dialog-side post-processing)
- `_assess_call_quality`, `_summarize_ai_call` (post-call hooks)
## New Code (~300 lines)
- **`backend/services/caller_gen.py`** — batch pre-gen orchestration, prompt assembly, JSON parsing, voice-roster validation
- **`backend/services/regulars_v2.py`** — lore file loader, arc state management, promotion gate, arc retirement
- **Modified `get_caller_prompt`** in `main.py` — slimmed ~400-token prompt assembly
- **Modified `Session.get_caller_model`** — returns haiku-4.5 unconditionally
- **Migration script** — archive current regulars except Silas, bootstrap Silas lore file
## Validation Gate (before ship)
Before deploying to live shows:
1. Generate 10 sample calls with the new system: 5 featuring Silas, 5 walk-ins
2. User listens to all 10
3. **Approve essence → ship.** Silas still sounds like Silas; walk-ins span the emotional range; callers feel distinct and specific.
4. **Drift or bland → iterate the prompt.** Tune batch prompt directives, re-run, re-validate.
## Non-Goals
- Not redesigning Devon (intern) — stays on gemini-flash as-is
- Not redesigning voice rosters or TTS provider — Inworld stays
- Not building a quality feedback loop that updates prompts from call outcomes (future work)
- Not pre-generating AUDIO for caller opening lines (future work, could cut latency)
- Not rebuilding the frontend caller panel — style badges etc. can stay or be simplified later
@@ -0,0 +1,78 @@
# Relocate the Show to Alpine, TX / Big Bend
**Date:** 2026-05-31
**Status:** Approved, implementing
## Goal
Luke moved to Alpine, Texas. Move the show's world from southern New Mexico
(Deming/Lordsburg) to the Big Bend region of far West Texas. Callers should come
from and be familiar with Alpine, Marfa, Marathon, Terlingua, Fort Stockton, and
the Big Bend area. Devon moves with the show. Silas gets an arc beat that
relocates The Wellspring to Terlingua.
## Changes
### 1. Caller generation — `backend/services/caller_gen.py`
- `BATCH_SYSTEM_PROMPT` opener: "…radio show in New Mexico" → "…in Alpine, Texas,
in the Big Bend country of far West Texas."
- Add a **geographic knowledge block** (real, accurate facts) so callers ground
themselves in real places instead of generic desert. Towns:
- **Alpine** — hub (~6k), Brewster County seat, Sul Ross State University,
mile-high ranching/railroad town, small arts scene.
- **Marfa** — minimalist-art tourist town (Chinati/Donald Judd, Prada Marfa),
the Marfa Lights, old ranching families vs. hipster influx.
- **Marathon** — tiny, the Gage Hotel, east gateway to Big Bend NP.
- **Terlingua** — quicksilver-mining ghost town, famous chili cookoff,
river-rafting outfitters, off-grid desert eccentrics, the Starlight Theatre,
near the Rio Grande / Mexico border.
- **Fort Stockton** — oilfield/I-10 town to the north, Pecos County, Paisano
Pete, working-class.
- **Big Bend** — Chisos Mountains, Rio Grande, dark skies (McDonald Observatory
near Fort Davis), remote, border proximity, Permian Basin oil money north.
- Rule: only reference real places/facts; don't invent businesses or landmarks.
### 2. Whisper prompt — `backend/services/transcription.py`
Update locale and seed proper nouns: "…a late-night radio talk show in Alpine,
Texas, in the Big Bend region. Callers reference Alpine, Marfa, Marathon,
Terlingua, Fort Stockton, and Big Bend."
### 3. Devon — `backend/services/intern.py`
Moved with the show. Keep "Communications degree from NMSU" (alma mater);
"You live in a studio in Deming" → "studio in Alpine."
### 4. Silas — relocate The Wellspring to Terlingua
- `~/code/dotfiles/silas/silas.md`: identity location Deming → Terlingua badlands;
update `arc_state` frontmatter to the relocation; add an Arc Log entry
(2026-05-31) — post-reckoning fresh start, moving the commune to desert outside
Terlingua, the move straining the forty members.
- `data/regulars.json`: update Silas's `job`, `location`, and the
`stable_seeds.style` "commune outside Deming" → Terlingua. Leave the 6 past
`call_history` summaries untouched — they happened in Deming; the move is the
new development.
### 5. Weather/town-news enrichment — `backend/main.py` (now a real feature)
The block in `enrich_caller_background()` called `_get_town_from_location` /
`_get_weather_for_town`, which never existed — silently NameError-ing. Implement:
- `BIG_BEND_TOWNS`: dict of town → (lat, lon) for Alpine, Marfa, Marathon,
Terlingua, Fort Stockton, Big Bend (Chisos Basin), Fort Davis, Presidio.
- `_get_town_from_location(text)`: scan lowercased text for a known town,
return canonical key (handles "ft stockton"/"fort stockton").
- `_get_weather_for_town(town)`: Open-Meteo current weather (free, no key),
WMO weather_code → phrase, returns e.g. "58°F, clear skies".
- Update town-news query: NM/AZ branch → `f"{town.title()} Texas"`.
- Wire session base_ctx `weather` (was hardcoded "cool desert night") to live
Alpine weather, graceful fallback on failure.
All network calls degrade gracefully (existing `asyncio.timeout` + try/except).
## Out of scope / left as-is
- Past `call_history` summaries (genuinely happened in Deming).
- `main.py:3124` state-abbreviation map (generic, not show-locale).
@@ -0,0 +1,56 @@
# Caller Variety, Movable Callers, and Devon Search Fix
**Date:** 2026-06-02
**Status:** Approved, implementing
## Problems
1. **Callers feel formulaic** — too many deep moral dilemmas; the roster prompt
*enforces* it ("at least half moral dilemmas" + "NEVER generate callers who
are just enthusiastic about a hobby").
2. **Callers circle forever** — they don't take advice, restating the same moral
issue until the host cuts them off. The live dialog prompt gives a `situation`
+ `secret_want` but no mechanic to be moved, persuaded, or reach resolution.
3. **Devon's search fails** — SearXNG at `localhost:8888` is unreachable whenever
the laptop's Docker Desktop isn't running. Fragile.
## Changes
### A. Roster variety — `backend/services/caller_gen.py` (`BATCH_SYSTEM_PROMPT`)
- Remove the absolute ban on hobby/job/story callers.
- Replace "at least half moral dilemmas" with an explicit distribution for a
10-caller roster (dilemmas still lead):
- **45** moral dilemmas / confessions / betrayals — keep the STAKES language.
- **23** storytellers & enthusiasts — a wild thing that happened, a fascinating
niche obsession or fact, a vivid slice-of-life. No deep dilemma required.
- **12** believers / chaos — earnest UFO/cryptid/conspiracy callers
(Coast-to-Coast sincerity) or a big eccentric personality on a rant.
- Keep the anti-collision rule, "reason they HAD to call tonight," and
"even lighter callers need energy and specificity."
### B. Movable callers — `backend/main.py` (`get_caller_prompt`)
Add a concise conversational-arc block for every caller:
- You have a position/want, but you're a real person — if Luke makes a good
point, genuinely react: agree, change your mind, soften, dig in, or decide.
- Don't restate a point/dilemma you've already made; the conversation must move.
You can reach a resolution, a decision, or an emotional turn. You are not
required to stay stuck.
Stacks on the existing "don't restate facts / move the story forward" rule.
Must keep `get_caller_prompt` output under the 3500-char test cap.
### C. SearXNG → NAS — `backend/config.py`, `backend/services/news.py`
- Deploy SearXNG to **mmgnas** as an always-on container (deploy-nas-docker).
- Add `searxng_url` to `config.py` (env-overridable), default to the NAS URL.
- `news.py` reads `settings.searxng_url` instead of the hardcoded constant;
`intern.py` imports `SEARXNG_URL` from `news.py`, so Devon, headlines, and
caller news-grounding all follow.
## Verification
- Run test suite (note: 2 pre-existing stale `test_caller_gen.py` failures).
- Live caller-gen batch → review the roster mix.
- Devon end-to-end: a `web_search` against the NAS SearXNG returns real results.
+500
View File
@@ -0,0 +1,500 @@
# Crawlable Episode Pages Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Turn 57 client-rendered `?slug=` episode pages into 57 static, crawlable HTML pages at `/episode/<slug>/` containing the full transcript, so the Big Bend content the show already produces becomes indexable.
**Architecture:** A build-time Python generator reads the Castopod RSS feed and the existing `website/transcripts/*.txt` files, and writes one fully static page per episode into `website/episode/<slug>/index.html`. No runtime feed dependency, no JS required to see content. The Cloudflare worker gains a 301 from the legacy `?slug=` URL, and loses its user-agent gate. `publish_episode.py` calls the generator so new episodes get a page automatically.
**Tech Stack:** Python 3.11 (stdlib `xml.etree`, `html`, `json`, `pathlib`), pytest, Cloudflare Pages + `_worker.js`, wrangler.
---
## Why This Matters (context for the implementer)
Current state, verified 2026-08-14:
- `website/episode.html` is a JS shell. It has **zero** server-rendered episode content — it fetches `/feed`, finds the matching `<item>` client-side, then fetches `/transcripts/<slug>.txt`.
- URLs are query params: `/episode.html?slug=episode-58-...`. 54 of the 64 sitemap entries are this shape.
- `website/transcripts/` holds **58 `.txt` files, 3.0 MB total**, none of which appear in `sitemap.xml` and which are linked only from JavaScript.
- `_worker.js:107-165` injects real `<title>`/OG tags — but **only when the User-Agent matches `facebookexternalhit|twitterbot|linkedinbot|slackbot|discordbot|telegrambot|whatsapp|pinterest|redditbot`**. Googlebot, Bingbot, GPTBot, ClaudeBot and PerplexityBot are all absent, so search and answer engines get the generic shell.
That UA gate is also a **cloaking risk**: serving different HTML to crawlers than to users is against Google's guidelines. Google deprecated "dynamic rendering" as a workaround. Task 6 removes the gate rather than extending it — once pages are static, nothing needs UA sniffing.
### Data facts confirmed before writing this plan
- RSS feed: `https://podcast.macneilmediagroup.com/@LukeAtTheRoost/feed.xml`, HTTP 200, 124 KB, **57 `<item>` elements**.
- Each item has: `<title>`, `<link>`, `<pubDate>`, `<guid>`, `<description>` (CDATA-wrapped HTML), `<enclosure url=...>`, `<itunes:duration>` (seconds, e.g. `4770`), `<itunes:episode>` (e.g. `58`).
- Slug is derived from `<link>`: everything after `/episodes/`, trailing slash stripped.
- **All 57 feed episodes have a matching transcript file.** Zero missing.
- One orphan transcript, `episode-32-tacos-taxes-and-tall-tales.txt`, has no feed item — ep 32 never finished publishing (`data/publish_state.json` shows a castopod step and nothing else). **The generator must skip orphans, not crash on them.**
- Transcript format is plain text, blank-line separated, `SPEAKER: text` per paragraph — e.g. `LUKE: Alright, welcome back...` / `SLIM: Hey Luke, yeah thanks...`.
---
## Task 1: Transcript parser
**Files:**
- Create: `website_gen/transcript.py`
- Test: `tests/test_transcript_parser.py`
**Step 1: Write the failing test**
```python
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from website_gen.transcript import parse_transcript
def test_splits_speaker_turns():
raw = "LUKE: Welcome back.\n\nSLIM: Hey Luke, thanks for taking my call."
turns = parse_transcript(raw)
assert turns == [("LUKE", "Welcome back."),
("SLIM", "Hey Luke, thanks for taking my call.")]
def test_unlabeled_paragraph_carries_previous_speaker():
raw = "LUKE: First thing.\n\nStill Luke talking."
assert parse_transcript(raw) == [("LUKE", "First thing."),
("LUKE", "Still Luke talking.")]
def test_ignores_blank_and_whitespace_paragraphs():
raw = "LUKE: One.\n\n \n\nSLIM: Two."
assert len(parse_transcript(raw)) == 2
def test_speaker_name_with_spaces_is_not_treated_as_label():
"""A colon mid-sentence must not be mistaken for a speaker label."""
raw = "LUKE: Here's the thing: it was the alternator."
turns = parse_transcript(raw)
assert len(turns) == 1
assert turns[0][0] == "LUKE"
assert "the thing: it was" in turns[0][1]
def test_empty_input_returns_empty_list():
assert parse_transcript("") == []
assert parse_transcript(" \n\n ") == []
```
**Step 2: Run test to verify it fails**
Run: `./venv/bin/python -m pytest tests/test_transcript_parser.py -v`
Expected: FAIL — `ModuleNotFoundError: No module named 'website_gen'`
**Step 3: Write minimal implementation**
```python
import re
SPEAKER_RE = re.compile(r"^([A-Z][A-Z0-9 .'-]{0,30}):\s*(.*)$", re.DOTALL)
def parse_transcript(raw: str) -> list[tuple[str, str]]:
"""Split a transcript into (speaker, text) turns.
Paragraphs are blank-line separated. A paragraph that does not open with a
SPEAKER: label is attributed to whoever spoke last, which is how the
transcriber emits long turns that wrap.
"""
turns: list[tuple[str, str]] = []
current = None
for para in re.split(r"\n\s*\n", raw or ""):
para = para.strip()
if not para:
continue
m = SPEAKER_RE.match(para)
if m:
current = m.group(1).strip()
text = m.group(2).strip()
else:
text = para
if current is None:
current = "LUKE"
if text:
turns.append((current, text))
return turns
```
**Step 4: Run test to verify it passes**
Run: `./venv/bin/python -m pytest tests/test_transcript_parser.py -v`
Expected: PASS, 5 tests
**Step 5: Commit**
```bash
git add website_gen/transcript.py tests/test_transcript_parser.py
git commit -m "Add transcript parser for episode page generation"
```
---
## Task 2: RSS feed loader
**Files:**
- Create: `website_gen/feed.py`
- Test: `tests/test_feed_loader.py`
- Fixture: `tests/fixtures/feed_sample.xml` (hand-trim two `<item>` blocks out of the live feed)
**Step 1: Write the failing test**
```python
from website_gen.feed import parse_feed, Episode
def test_parses_core_fields(feed_xml):
eps = parse_feed(feed_xml)
ep = next(e for e in eps if e.number == 58)
assert ep.slug == "episode-58-rayfield-s-nephew-the-marfa-lights-and-why-nobody-believes-concho"
assert ep.title.startswith("Episode 58: Rayfield's Nephew")
assert ep.duration_seconds == 4770
assert ep.audio_url.startswith("https://")
def test_description_is_stripped_of_cdata_and_html(feed_xml):
ep = parse_feed(feed_xml)[0]
assert "<![CDATA[" not in ep.description
assert "<p>" not in ep.description
def test_slug_comes_from_link_and_drops_trailing_slash(feed_xml):
for ep in parse_feed(feed_xml):
assert not ep.slug.endswith("/")
assert "/" not in ep.slug
def test_pubdate_parses_to_iso_date(feed_xml):
ep = next(e for e in parse_feed(feed_xml) if e.number == 58)
assert ep.published_iso.startswith("2026-08-04")
```
**Step 2: Run to verify it fails**
Run: `./venv/bin/python -m pytest tests/test_feed_loader.py -v`
Expected: FAIL — module missing
**Step 3: Implement**
Use `xml.etree.ElementTree` with the itunes namespace `http://www.itunes.com/dtds/podcast-1.0.dtd`. Dataclass:
```python
@dataclass
class Episode:
number: int | None
slug: str
title: str
description: str
published_iso: str
duration_seconds: int | None
audio_url: str
```
Parse `pubDate` with `email.utils.parsedate_to_datetime`. Strip CDATA and tags from `<description>` with a regex, then `html.unescape`.
**Step 4: Verify passes.** **Step 5: Commit.**
```bash
git commit -m "Add RSS feed loader for episode page generation"
```
---
## Task 3: Page renderer
**Files:**
- Create: `website_gen/render.py`, `website_gen/templates/episode.html`
- Test: `tests/test_episode_render.py`
The template is a full standalone page matching the existing site chrome (copy the `<nav>`, footer markup and `css/style.css?v=6` link from `website/how-it-works.html` so it looks native).
**Step 1: Write the failing test**
```python
import json, re
from website_gen.render import render_episode_page
def test_title_and_canonical_are_episode_specific(sample_episode):
html = render_episode_page(sample_episode, turns=[("LUKE", "Hello.")])
assert "<title>Episode 58: Rayfield" in html
assert '<link rel="canonical" href="https://lukeattheroost.com/episode/episode-58-' in html
def test_transcript_is_in_the_html_not_fetched_by_js(sample_episode):
html = render_episode_page(sample_episode, turns=[("LUKE", "The Marfa Lights are real.")])
assert "The Marfa Lights are real." in html
assert "fetch(" not in html
def test_emits_valid_podcastepisode_schema(sample_episode):
html = render_episode_page(sample_episode, turns=[("LUKE", "Hi.")])
block = re.search(r'<script type="application/ld\+json">(.*?)</script>', html, re.S).group(1)
data = json.loads(block)
types = {o["@type"] for o in (data if isinstance(data, list) else [data])}
assert "PodcastEpisode" in types
def test_escapes_html_in_transcript_text(sample_episode):
html = render_episode_page(sample_episode, turns=[("LUKE", "5 < 6 & <script>alert(1)</script>")])
assert "<script>alert(1)</script>" not in html
assert "&lt;script&gt;" in html
def test_escapes_quotes_in_title_meta(sample_episode):
sample_episode.title = 'Episode 1: The "Best" Show'
html = render_episode_page(sample_episode, turns=[])
assert 'content="Episode 1: The "Best"' not in html # must be escaped
def test_speaker_labels_get_semantic_markup(sample_episode):
html = render_episode_page(sample_episode, turns=[("LUKE", "Hi."), ("SLIM", "Hey.")])
assert html.count('class="transcript-turn"') == 2
assert "LUKE" in html and "SLIM" in html
```
**Step 3: Implementation notes**
- Escape every interpolated value with `html.escape(value, quote=True)`. The transcript is user-facing text from Whisper; treat it as untrusted.
- Schema block is a JSON array containing `PodcastEpisode` and `BreadcrumbList`:
```python
{
"@context": "https://schema.org",
"@type": "PodcastEpisode",
"url": f"https://lukeattheroost.com/episode/{ep.slug}/",
"name": ep.title,
"description": ep.description,
"datePublished": ep.published_iso,
"timeRequired": f"PT{ep.duration_seconds}S",
"associatedMedia": {"@type": "MediaObject", "contentUrl": ep.audio_url},
"partOfSeries": {
"@type": "PodcastSeries",
"name": "Luke at the Roost",
"url": "https://lukeattheroost.com",
},
"contentLocation": {
"@type": "Place",
"name": "Big Bend, West Texas",
"address": {"@type": "PostalAddress", "addressLocality": "Alpine",
"addressRegion": "TX", "addressCountry": "US"},
},
}
```
The `contentLocation` on every episode is the point of the whole exercise — it is what ties 57 pages of West Texas conversation to the region geographically.
- Include a native `<audio controls preload="none" src="{audio_url}">` so the page is useful without JS.
- Add prev/next episode links. Internal linking is what gets 57 pages crawled instead of 3.
**Step 5: Commit**
```bash
git commit -m "Add episode page renderer with PodcastEpisode schema"
```
---
## Task 4: Generator CLI
**Files:**
- Create: `generate_episode_pages.py` (repo root, matching the existing script convention)
- Test: `tests/test_episode_generator.py`
**Behaviour:**
```
python generate_episode_pages.py # fetch live feed, write all pages
python generate_episode_pages.py --feed FILE # use a local feed (tests, offline)
python generate_episode_pages.py --dry-run # report what would be written
```
**Step 1: Failing tests**
```python
def test_writes_one_index_html_per_feed_episode(tmp_path, feed_file, transcripts_dir):
n = generate(feed_file, transcripts_dir, tmp_path)
assert n == 2
assert (tmp_path / "episode" / "episode-58-rayfield-s-nephew-the-marfa-lights-and-why-nobody-believes-concho" / "index.html").exists()
def test_orphan_transcript_without_feed_item_is_skipped(tmp_path, feed_file, transcripts_dir):
"""episode-32 has a transcript but was never published to the feed."""
generate(feed_file, transcripts_dir, tmp_path)
assert not (tmp_path / "episode" / "episode-32-tacos-taxes-and-tall-tales").exists()
def test_missing_transcript_still_produces_a_page(tmp_path, feed_file, transcripts_dir):
"""An episode published before its transcript lands must not break the build."""
(transcripts_dir / "episode-58-....txt").unlink()
generate(feed_file, transcripts_dir, tmp_path)
html = (tmp_path / "episode" / "episode-58-..." / "index.html").read_text()
assert "Transcript not yet available" in html
def test_dry_run_writes_nothing(tmp_path, feed_file, transcripts_dir):
generate(feed_file, transcripts_dir, tmp_path, dry_run=True)
assert not (tmp_path / "episode").exists()
```
**Step 5: Commit**
```bash
git commit -m "Add episode page generator CLI"
```
---
## Task 5: Sitemap regeneration
**Files:**
- Modify: `generate_episode_pages.py` (add `--sitemap`)
- Modify: `website/sitemap.xml` (regenerated output)
- Test: `tests/test_sitemap.py`
The current sitemap has 64 `<url>` entries, 54 of them `?slug=` URLs. Those must be **replaced**, not supplemented — leaving both forms invites duplicate-content dilution even with a canonical.
**Tests:**
```python
def test_sitemap_contains_no_query_param_urls(generated_sitemap):
assert "episode.html?slug=" not in generated_sitemap
def test_sitemap_has_one_entry_per_episode(generated_sitemap):
assert generated_sitemap.count("<loc>https://lukeattheroost.com/episode/") == 57
def test_static_pages_survive_regeneration(generated_sitemap):
for path in ["", "/how-it-works", "/clips", "/stats", "/privacy", "/terms", "/llms.txt"]:
assert f"<loc>https://lukeattheroost.com{path}</loc>" in generated_sitemap
def test_lastmod_matches_episode_publish_date(generated_sitemap):
assert "<lastmod>2026-08-04</lastmod>" in generated_sitemap
```
**Commit:** `git commit -m "Generate sitemap from feed with clean episode URLs"`
---
## Task 6: Worker — 301 the old URL, remove the UA gate
**Files:**
- Modify: `website/_worker.js:107-165`
Replace the entire social-crawler injection block with a redirect. Once pages are static, the injection is dead code and the UA gate is a liability.
```javascript
// Legacy query-param episode URLs -> clean paths.
// Published social posts and YouTube descriptions still point at the old
// form, so this 301 has to stay indefinitely.
if (url.pathname === "/episode.html" && url.searchParams.get("slug")) {
const slug = url.searchParams.get("slug").replace(/[^a-z0-9-]/gi, "");
if (slug) {
return Response.redirect(`https://lukeattheroost.com/episode/${slug}/`, 301);
}
}
```
**Why the slug is sanitised:** it lands in a `Location:` header. Stripping to `[a-z0-9-]` prevents CRLF injection and open-redirect via a crafted `?slug=`.
**Verify manually after deploy:**
```bash
curl -sI "https://lukeattheroost.com/episode.html?slug=episode-58-rayfield-s-nephew-the-marfa-lights-and-why-nobody-believes-concho" | head -3
# Expect: HTTP/2 301 + location: https://lukeattheroost.com/episode/episode-58-.../
```
**Commit:** `git commit -m "Redirect legacy episode URLs and drop crawler UA gate"`
---
## Task 7: Retire the client-rendered page
**Files:**
- Delete: `website/episode.html`, `website/js/episode.js`
- Modify: `website/js/app.js` — episode links must point at `/episode/<slug>/`
- Modify: `website/_redirects` — keep `/episodes.html /episode 302`
Check every internal link first:
```bash
grep -rn "episode.html?slug=" website/ --include=*.html --include=*.js
```
All must become `/episode/<slug>/`. Also fix `publish_episode.py:1168`, which builds the `episode_url` used in social posts and YouTube descriptions:
```python
episode_url = f"https://lukeattheroost.com/episode/{episode_slug}/"
```
**Commit:** `git commit -m "Point internal links at clean episode URLs"`
---
## Task 8: Wire into the publish pipeline
**Files:**
- Modify: `publish_episode.py` — after the transcript is written to `website/transcripts/`
```python
subprocess.run([sys.executable, "generate_episode_pages.py", "--sitemap"], check=False)
```
`check=False` on purpose: a generator failure must not abort a publish that has already pushed audio to Castopod. Log loudly instead.
**Test:** `tests/test_publish_generates_page.py` — assert the generator is invoked with `--sitemap` after a successful publish (monkeypatch `subprocess.run`).
**Commit:** `git commit -m "Regenerate episode pages during publish"`
---
## Task 9: Deploy and verify
```bash
./venv/bin/python -m pytest tests/ -q # full suite green
python generate_episode_pages.py --sitemap # 57 pages + sitemap
npx wrangler pages deploy website/ --project-name=lukeattheroost --branch=main
```
**Post-deploy checks — all must pass:**
```bash
# 1. Page is static: transcript present with JS disabled
curl -s https://lukeattheroost.com/episode/episode-58-.../ | grep -c "Mitchell Flat" # >= 1
# 2. Real title in raw HTML, no UA spoofing
curl -s https://lukeattheroost.com/episode/episode-58-.../ | grep -o "<title>.*</title>"
# 3. Same bytes for Googlebot as for a browser (no cloaking)
diff <(curl -s -A "Mozilla/5.0" URL) <(curl -s -A "Googlebot/2.1" URL) && echo "no cloaking"
# 4. Legacy URL 301s
curl -sI "https://lukeattheroost.com/episode.html?slug=episode-58-..." | grep -i "^location"
# 5. Schema validates
# paste page source into https://validator.schema.org/
```
Then: submit the new `sitemap.xml` in Google Search Console and request indexing on three episode pages as a sample.
---
## Risks
| Risk | Mitigation |
|---|---|
| URL change loses existing ranking | 301 (permanent) from Task 6 passes equity; the old form stays supported forever |
| Published YouTube/social links break | Same 301. Ep 58's YouTube description contains a `?slug=` link — verify it after deploy |
| Transcripts contain explicit content | Show is already rated explicit; consider `<meta name="rating" content="adult">` on episode pages |
| Whisper errors become permanent indexed text | Transcripts are already public at `/transcripts/*.txt`; this changes exposure, not accuracy |
| 3 MB of new HTML on every deploy | Static assets, Cloudflare-cached; wrangler uploads only changed files |
| Feed unreachable at build time | `--feed` flag lets the build use a cached copy; generator exits non-zero rather than writing empty pages |
## Out of scope
Tier 3 — the "Big Bend episodes" topic hub, and off-site work (Alpine Avalanche, Marfa/Big Bend groups, Sul Ross). Worth doing after these pages exist to link to.
+28
View File
@@ -0,0 +1,28 @@
=== Bev (44, Farmington, NM) ===
voice: Wendy
emotional_register: Forthright and slightly combative in a charming way — a person who has been talked out of her own perceptions before and has decided not to let that happen again
secret_want: To have someone acknowledge that the embarrassment is Ashleigh's problem being projected, not Bev's problem being revealed — she wants the shame returned to its rightful owner
CALLER (Bev, 44, Farmington, NM): I need to preface this by saying I am fully aware that competitive jigsaw puzzling sounds like a bit, but I placed fourteenth in the nationals in 2024 so I need you to take me seriously here.
LUKE: Wait — really?
CALLER: Yeah, really. I know how it sounds, I do. But there's a whole circuit, there are rankings, there's prize money. I mean, not like sports money, but real money. And the skill involved — people don't understand that it's not just sitting around doing puzzles like you're retired. It's strategy, it's technique, it's knowing your own cognitive patterns.
I've been competing for six years now. I've won three regionals. I trained ninety minutes a day to get to nationals. Ninety minutes. Every single day.
So I need you to understand that this is a thing I do seriously before I explain why my son's girlfriend watching me do it is making me want to lose my mind.
LUKE: Is that right?
CALLER: Yeah. Look, I work nights at the ER — three nights a week, eleven to seven — and I do my training in the two hours before my shift. So I'm often at the kitchen table in my full scrubs, completely locked in, doing these gradient puzzles. Those are the ones that are almost all one color, right? Most people avoid them because they seem impossible, but I've built a specific technique around sorting by cardboard texture instead of color. That's my edge.
And it was fine. It was just my thing. I'd get home from work at seven in the morning, have my coffee, and then Dale would be around sometimes but he'd just — he'd leave me alone. It was quiet. It was mine.
Then Ashleigh started coming over more regularly, and now she just sits at the kitchen table while I'm working and watches me with this expression on her face like I'm doing something sad that she's too polite to name. And suddenly I hate it. I hate being watched doing the thing I love.
LUKE: Go on.
CALLER: Well, that's kind of the thing, right? I don't know if I'm allowed to ask her to stop. Not to leave the room — I'm not trying to be that person. But just to not watch me. To look at her phone or read something or just sit with her back to me or something.
Is that crazy? Is that a crazy thing to want?
Because here's what's happening. She's perfectly nice. I know she's perfectly nice. Dale's a good kid, he chose well. But she's introduced this feeling into something that was only ever joy before, and I resent it. I resent that I now feel self-conscious doing the thing I trained for, the thing I'm genuinely good at.
And I'm wondering if maybe I should just ask her, or if that makes me the unreasonable one. Like, is asking someone to stop watching you something that signals I'm the problem here?
LUKE: And then what?
CALLER: And then what, what do you mean?
+22
View File
@@ -0,0 +1,22 @@
=== Brecken (24, Albuquerque, NM — calling from a Walgreens parking lot) ===
voice: Carter
emotional_register: Caffeinated, earnest, slightly defensive, fundamentally lonely in a way he would never name
secret_want: To be taken seriously by someone — anyone — in a format that feels official. Radio counts.
CALLER (Brecken, 24, Albuquerque, NM — calling from a Walgreens parking lot): I'm not drunk, I just have a lot of energy right now and nobody to talk to.
LUKE: I mean, what do you want me to tell you?
CALLER: Look, I don't know, man. I just... I've been sitting in my car for like twenty minutes after my shift and I heard your show was taking calls and I thought, you know what? Tonight's the night. I'm gonna call.
I got something I need to say and I feel like nobody's gonna listen to me at work or at home or anywhere else, but maybe on the radio it's different. Maybe on the radio people actually hear you.
LUKE: What'd she say?
CALLER: What? No, there's no she. It's not like that. I just meant nobody listens, period. My coworkers, they call me Bricks and I don't even know if that's a compliment or if they're busting my chops, you know? And I've got this thing I'm training for and nobody takes it seriously.
LUKE: Yeah?
CALLER: Yeah. Competitive eating. I know what you're probably thinking — it's not a real sport, it's just guys shoving food in their faces. But that's where people get it wrong, Luke. That's exactly where they get it wrong.
You ever been actually hungry? Like, not hungry-hungry, but so hungry you're thinking about your next meal before you're even done with the current one? Because if you have, you'd understand that what these competitors do, it takes real discipline. Real strategy.
I work doubles at the hotel restaurant, right? I see people eat all day. I know food. And I've been tracking everything on my phone for fourteen months — I got a whole spreadsheet called Project Jaw, every single week updated. This isn't just me stuffing my face. This is science.
LUKE: And then what?
CALLER: And then what, what? I'm trying to qualify for a regional hot dog event. That's the goal. I haven't competed yet, but I'm building up to it. I can eat a seventy-two-ounce steak — I know I can because I've done the math, I've done the training — but I can't afford to actually go to that restaurant and do it officially, you know? So I'm working toward the hot dog thing instead.
But the point is nobody respects it. People hear competitive eating and they laugh. But if I told you I was training for a marathon, you'd be like, oh cool, that's dedication. This is the same thing. It's endurance. It's mental toughness.
+22
View File
@@ -0,0 +1,22 @@
=== Denny (61, Tucumcari, NM) ===
voice: Dennis
emotional_register: Gruff containment with genuine grief leaking through the edges
secret_want: To tell someone how much he actually loved that bird, which he has not admitted to anyone, including his adult son who thinks the pigeon hobby is embarrassing.
CALLER (Denny, 61, Tucumcari, NM): Yeah hi, Luke, long-time listener, I'm not crazy, I just need people to look up.
LUKE: And then what?
CALLER: I'm sorry, what do you mean "and then what"? I haven't even told you about the bird yet.
LUKE: Wait — really?
CALLER: Yeah, really. I got so wound up I just started talking. Look, I've got a blue check hen, Patrice, she got out three days ago during those wind gusts we had. Band number NM-2024-441. I've been out every night with corn, driving the back roads out here, and I just... I need someone to know what to look for.
LUKE: That's — okay, keep going.
CALLER: She's a good flyer, real good, but not good enough to just disappear like this. Three days, Luke. I've covered maybe two hundred miles of road between here and the Oklahoma border. She wouldn't go that far on purpose, but the wind that night was brutal.
I'm asking your listeners, anybody in eastern New Mexico, if you see a banded pigeon, blue check pattern, that band number NM-2024-441, you call me. You write it down. Don't try to catch her, just... note where you saw her. I'll come get her.
She's worth something, yeah, but that's not why I'm calling at one in the morning.
LUKE: Yeah?
CALLER: Yeah. Look, I've been doing this a long time. Thirty-seven years with pigeons. I had a logbook going since 1987. I know every bird I've ever lost, and I write it down. Cause of death, disappearance, all of it. But Patrice...
She's different, okay? I named her after someone. Someone from back when I was running long haul for Schneider. My dispatcher. And I never told anybody that. My son, he thinks this whole thing is embarrassing. The pigeons. But Patrice, she won places. Real places. She had heart.
I'm not asking for charity here. I'm just asking if anybody's seen her. That's all I need.
+34
View File
@@ -0,0 +1,34 @@
=== Gerald (71, Truth or Consequences, NM) ===
voice: Ronald
emotional_register: Measured and precise in speech — a man who chose his words carefully as a professional and now finds that precision failing him in a domain where it doesn't apply
secret_want: Permission to call Patrice tonight, this hour, while the show is on — he wants someone to tell him the time doesn't matter, that she would want to hear his voice even at midnight
CALLER (Gerald, 71, Truth or Consequences, NM): I'm going to tell you something and I need you to understand that I am not a timid man. I have stood in a river in a hard hat while a concrete pour went wrong and I kept my head. So I don't know what this is.
LUKE: Yeah?
CALLER: I've been listening to your show for three years now. Every Sunday night. And I don't think I've ever called before, so I'm going to just say it straight.
I know a woman. I've known her — well, we've been corresponding for eight months now. Email. We met through this forum we're both on, we track the Mars rover, we talk about what it's seeing up there, and somewhere along the way we started talking about other things. Real things. And I know things about her. I know she taught chemistry for thirty years. I know she has a bad knee. I know she saw a mountain lion once and it changed something in her.
And she has my number. She sent it to me three months ago, just casual, just at the end of an email about something else. And I have been sitting with her number in my phone since then and I cannot — I cannot make myself dial it.
I designed a bridge. People drive over it every day. That was scarier and I did it. I was twenty-six years old and I was terrified and I did it. So why can't I do this?
LUKE: Go on.
CALLER: My wife Ruth died four years ago. We used to do amateur radio together on Sunday nights. She'd keep the logbook while I worked the dial. And Sunday nights have been... they've been the hardest nights since then.
That's why I listen to you. I never told anyone that before.
But here's the thing. I wrote out what I want to say to Patrice. I have it written down in my engineering notebook, the same one where I log the Jupiter observations. And I've practiced it. I know what I want to tell her. But it's midnight on a Sunday night and I'm calling a radio show instead of calling her, and I need to know what that is. What am I actually afraid of?
Is it that she'll say no? Is it that she'll say yes? I don't know how to separate those two things anymore.
LUKE: Mm-hmm.
CALLER: And the thing is, I know her number. I've known it for three months. She gave it to me. That means something, doesn't it?
But what if the voice doesn't match? What if I hear her and it's all different than what I've been imagining? What if I'm not... what if I'm not what she's been imagining either?
We've written 340 emails. Three hundred and forty. Some of them are pages long. I can tell you what she thinks about the Curiosity rover and what she thinks about teaching teenagers and what she thinks about that mountain lion. But I've never heard her laugh. I don't know if she has an accent. I don't know if she snores or if she hums while she's thinking or any of the things you actually know about a person until you hear their voice.
And Ruth and I, we had all of that. We had Sunday nights for thirty-two years. And then she was gone. And now I have this woman who knows me, really knows me, and I'm terrified to risk it by making it real.
Is that it? Is that what this is?
LUKE: Yeah?
CALLER: I'm sorry, I don't know what you're asking me.
+14
View File
@@ -0,0 +1,14 @@
=== Margaux (29, Santa Fe, NM) ===
voice: Tessa
emotional_register: Sharp intelligence turned against herself, dark humor as a coping mechanism, more raw than she intended to be
secret_want: To hear someone say that leaving a perfectly fine person for no dramatic reason is something real adults actually do.
CALLER (Margaux, 29, Santa Fe, NM): I don't have a question, I just — what do you do when you realize you've been lying to yourself for like two years?
LUKE: Wait — really?
CALLER: I mean, not lying exactly. That's not fair. I just... I kept telling myself I was happy because everything looked fine on paper, you know? He's stable, he's nice, he doesn't yell or cheat or any of that stuff. But I realized tonight that I don't actually like him. Like as a person. And I've known it for a while, I think. I just didn't want to know it.
LUKE: Yeah?
CALLER: Yeah. And the worst part is there's no reason. There's no big thing I can point to. He didn't do anything wrong. He just... he's boring. He's kind of boring and he slightly irritates me and I've been confusing not fighting with actually being in love and I don't know how to leave someone when the only real reason is that I don't like them very much anymore.
LUKE: Wait — really?
CALLER: Yes. Really. I know how that sounds. I know I'm supposed to be grateful or whatever. Most people would kill for stable and nice and no drama. And I am grateful, I think. But gratitude isn't love and I've been trying to make it into love for two years and it's not working.
LUKE: Mm-hmm.
CALLER: I drove over to pull over after that song and I just sat there in my car and I realized I've practiced breaking up with him like forty times. Forty times, Luke. And I always start crying at the same part where I say "you're a good person" because he is, and that's the problem. How do you leave someone who didn't do anything wrong?
+40
View File
@@ -0,0 +1,40 @@
=== Preston (24, Albuquerque, NM (student housing, UNM area)) ===
voice: Evan
emotional_register: Hyper-articulate and slightly manic — the voice of someone who has been alone with a thought too long and needs to hear it out loud to know if it's real
secret_want: To hear that the ideas were worth stealing — that 'temporal debt' is actually a good concept — because if it is, at least the loss means something
CALLER (Preston, 24, Albuquerque, NM (student housing, UNM area)): Okay so I know how this is going to sound and I'm asking you to stay with me for like thirty seconds before you make up your mind about me.
LUKE: That's — okay, keep going.
CALLER: I've been working with my advisor, Dr. Henriksen, for two years now. Every Thursday at four o'clock we meet in his office and we talk through ideas for my dissertation. Two weeks ago I introduced a concept — I called it temporal debt, which is basically the idea that institutions carry forward unacknowledged obligations from past decisions, right? It's about how the past isn't actually past in the way we pretend it is.
I said it out loud. Just said it. Didn't write it down because we were just talking, just thinking through something I'd been mulling over.
And then — and this is where it gets — eight days later he posts a conference paper to academia.edu. And in this paper is a section on institutional obligations that carries the exact structural logic of what I said. He uses different language, obviously, but the architecture of the argument is mine. It's the same thing reworded.
I've read it seventeen times, Luke. I've underlined the relevant passage in three different colors of pen. Red for logical structure, blue for conceptual dependencies, green for the anthropological framework he's using. My copy looks insane. I know it looks insane.
But here's the thing that's eating me alive: I have no proof I said it first. It was just a conversation. There's no email, no notes he took, nothing. And now I'm
LUKE: That's — okay, keep going.
CALLER: ...I'm lying awake at night wondering if I'm actually losing my mind. Like, if I can't prove something happened, did it happen? That's a real philosophical problem, right? But it's also maybe just me being paranoid at one in the morning.
The other thing is — and I hate that I'm thinking this — but I looked back at his 2021 essay. The one on institutional memory that's why I came to UNM in the first place. I applied here specifically to work with him because of that essay. And there was a grad student he had in 2019 who left the program suddenly. Just disappeared. And now I'm reading that essay again and I'm seeing things in it that feel like they might not be entirely his, and I don't even know if I'm pattern-matching or if there's actually something there.
So either my advisor is systematically taking ideas from his students, or I'm constructing this whole paranoid narrative out of nothing, and I genuinely cannot tell which one is true. And I don't know what to do with that. Do I say something? To who? Do I destroy my academic relationship with someone I came here to study with? Do I just... let it go?
I make coffee during the day, by the way. Pour-over coffee. My coworkers call me the professor and usually it's funny but tonight it's making me feel
LUKE: And then what?
CALLER: Like I'm a character in someone else's story. Like I'm not the one making decisions, I'm just the one things are happening to.
That's the part that's really getting to me. Because if he took the idea, then at least the idea exists, right? At least temporal debt is real enough to be worth stealing. But if I'm making this up, if I'm just seeing patterns that aren't there because I'm exhausted and paranoid, then what am I? Just a guy who didn't sleep and convinced himself he was wronged by someone he admires.
I need to know which one it is. Or I need someone to tell me what the right move is when you can't know which one it is. Because either way I'm fucked. Either I stay quiet and let it happen, or I say something I can't prove and I'm the grad student who cried plagiarism. I'm the one people don't want to work with. I'm the unstable one.
What do you do with an injustice you can't prove? How do you live with not knowing if you're being wronged or if you're just broken?
LUKE: And then what?
CALLER: I don't know. I called you. I'm calling at almost one in the morning because I don't know what else to do.
I thought maybe if I said it out loud to someone, if I heard myself say it, I'd know whether I sound crazy or whether I sound like someone who actually has a legitimate problem. But I still don't know. I still can't tell.
Do you think I should talk to him about it? Do I go to the department chair? Do I just drop out and find a different advisor and pretend this never happened? Because that's an option too. Just leave. Start over somewhere else where I'm not spending every Thursday afternoon wondering if the person across the desk from me is taking pieces of my brain.
What would you do?
+46
View File
@@ -0,0 +1,46 @@
=== Ronda (67, Tucumcari, NM) ===
voice: Loretta
emotional_register: Gruff and self-deprecating on the surface, genuinely tender underneath — the kind of person who will tell you she's fine in a tone that makes you want to check
secret_want: To be seen as someone who is parenting correctly by allowing wonder — she suspects her daughter thinks she lets Cody run too wild and she wants validation from a neutral party
CALLER (Ronda, 67, Tucumcari, NM): Luke I want to say first I'm not one of those grandmothers who calls about every little thing, I once drove through an ice storm outside Amarillo with a busted heater and didn't call nobody.
LUKE: So what are you gonna do about it?
CALLER: Well that's what I'm calling you about, Luke. I don't know what to do and I'm trying real hard not to overreact here because the kid is nine and he's curious and that's a good thing. That's a real good thing.
But he ate something off my palo verde tree last Tuesday that wasn't on the Desert Nibbles channel and his mom — my daughter — she's already giving me that look, you know the one, like I'm not supervising right. And I am supervising. I'm right there watching him. But I don't want to tell him no, don't wonder about plants, don't explore. I spent thirty-one years driving because I wanted to see what was out there.
The thing is, the YouTube guy, Mr. Pete, he seems legit. He's careful about it. He shows you the whole plant, the leaves, the berries, he explains what's safe and what's not. So I can't even blame the source, which somehow makes this harder.
I guess what I'm asking is — how do I let him keep being curious without, you know, him ending up in the emergency room? Or without his mother deciding I'm the irresponsible grandma who let him poison himself in my backyard?
LUKE: Go on.
CALLER: Well, I'm thinking maybe I need to make it official, you know? Like, turn it into a real project instead of just him wandering around eating whatever catches his eye.
I could take him to one of those native plant nurseries, the ones where everything's labeled. Let him see the plants in a controlled way. Or maybe I get one of those field guides, the laminated ones you can actually use, and we go through my yard together systematically. Write down what's what. Make a list.
That way he's still exploring, he's still curious like he should be, but I'm the one directing it instead of just hoping he doesn't grab the wrong thing off a tree.
And maybe I talk to his mom about it. Not like I'm defending myself, but like — here's what we're doing, here's how we're doing it safely. Because she needs to understand that I'm not just letting him run wild. I'm actually, you know, engaged with this.
The other thing is I could probably limit the YouTube channel to specific times. Not ban it. Just like, Mr. Pete on Saturday mornings, and then we go outside and we verify things together. So it's not just passive watching, it's actually learning.
Does that sound like I'm overthinking this or does that sound reasonable?
LUKE: How'd you end up there?
CALLER: You mean in her backyard? Well, that's where I live now, Luke. I retired about four years ago and my daughter, she needed help with the kids, and I needed to not be alone in some apartment somewhere after thirty-one years of moving. So I moved into her guest house out in Arizona. It made sense at the time and it still does most days.
Cody, he's the youngest of her three, and he's always been the one who notices things. Asks a lot of questions. Last summer he got interested in rocks, then it was insects, and now it's plants. I figured it was just how his brain works and I thought it was good that he had space to explore it.
But when I found him with that root in his mouth last Tuesday, that's when I thought — okay, Ronda, maybe you need to think about this different. Maybe curiosity without guardrails isn't the same thing as freedom.
I drove alone across this country hundreds of times, Luke. I knew where the rest stops were, I knew which routes were safe at night, I knew how to break down on the side of the road. That wasn't recklessness. That was curiosity with a plan.
So I guess that's what I'm trying to figure out. How to give that to him. The curiosity with the plan.
LUKE: Yeah?
CALLER: Yeah. I mean, I know he's only nine. I know he's not me. But I also remember being nine and wanting to understand things, and I remember my mother telling me no so much that I just stopped asking her anything.
She had this card, actually, that she kept — I've got it in my wallet right now, laminated — it says worry is a prayer for what you don't want. She gave it to me when I told her I wanted to drive truck and she was scared. And I kept that card all these years.
The irony is not lost on me that I'm sitting here at eleven o'clock at night calling a radio show because I'm worried.
But here's the thing — I don't want to be the person who tells Cody to stop wondering. I just need to figure out how to wonder with him in a way that doesn't end with a trip to the ER and his mother never letting him come to my house again.
So yeah. That's where I'm at.
+28
View File
@@ -0,0 +1,28 @@
=== Rosalind (47, Las Cruces, NM) ===
voice: Veronica
emotional_register: Precise, earnest, quietly desperate for intellectual companionship, not fragile but genuinely unsettled
secret_want: Permission — from a stranger, in the dark, over the radio — to keep believing in both things at once without choosing.
CALLER (Rosalind, 47, Las Cruces, NM): I'm not one of those people, I want to be clear about that, but I've been having a hard time sleeping.
LUKE: Go on.
CALLER: I've been going to this study group. Wednesday nights at the library where I work. It's not... it's not a cult thing or anything like that. We're just looking at the documents. The Pentagon stuff. We read them together and we talk about what they mean.
And I'm fine with that. I can do that. But then I go home and I pray and I think about the Incarnation and I think about what it would mean if we're not alone and my brain just kind of stops working.
I need to know if those two things can exist in the same person. I need to know if asking these questions makes me a bad Catholic or just a curious person. Because my priest couldn't answer me and I've been lying awake at night feeling like I'm supposed to choose and I don't want to choose.
LUKE: What'd she say?
CALLER: Who, my priest? He just... he kept saying that God created all things visible and invisible, which I already know. I already know that. And then he got quiet and he said maybe I should talk to someone with more education in these matters. Which basically means he doesn't know either.
So I'm calling you instead because it's midnight and I can't sleep and I figure if anyone's heard this question before, it's you.
LUKE: Mm-hmm.
CALLER: I keep thinking about it like this. If God made everything, then He made whatever's out there too, right? So encountering that wouldn't be encountering something outside His creation. It would just be... encountering more of His creation.
But then I think about the Resurrection. I think about what makes us special in Christian theology. And I don't know if that falls apart if we're not the only intelligent beings God bothered with.
I've been a librarian for twenty-three years. I organize information for a living. I'm supposed to be comfortable with ambiguity. But this isn't ambiguous to me anymore. It's either going to be a crisis or it's going to be fine, and I can't figure out which one I'm supposed to be prepared for.
LUKE: Yeah?
CALLER: Yeah. And the thing that's really keeping me up is that I don't even know if I want an answer. I think I just want permission to stop feeling like I'm doing something wrong by thinking about both things at the same time.
My ex-husband would have made a joke about this. Some stupid joke about me checking out books under fake names, which I did, by the way. I felt more guilty about lying on the library card than I did about the book itself, which is its own kind of problem.
But he's not here to make the joke, so I'm calling you instead.
+24
View File
@@ -0,0 +1,24 @@
=== Silas (52, Deming, NM (The Wellspring, 200 acres scrubland)) ===
voice: Timothy
emotional_register: Quietly fractured — the warmth is still there but it is doing real load-bearing work tonight, holding something heavier than usual underneath it
secret_want: To be told that growth is supposed to hurt and that he is not a bad man — and to be believed when he agrees.
CALLER (Silas, 52, Deming, NM (The Wellspring, 200 acres scrubland)): Brother, I've been standing over a curing rack for about four hours now and I think I need to talk to someone who isn't me.
LUKE: And then what?
CALLER: I'm sorry, I'm not following you. And then what, what?
LUKE: How'd you end up there?
CALLER: Oh. Right. Well, three days ago I held a community meeting — we call it a circle, actually — and I made some changes to how we do things here at The Wellspring. I apologized for... for the way certain practices had developed without real consent. The shared intimacy nights. I made them optional. I read it from three pages of yellow legal pad, and I cried, which I haven't done in front of the community before.
Nine people thanked me. But two people left. Just walked out and didn't come back. One of them was Deva, and Deva has been with us for six years and she runs — she ran — the soap-curing room. She does the stamp work. The heron stamp. Every bar gets pressed with a heron.
So now I'm doing it myself at midnight because the Santa Fe spa order is due next week and I can't... I can't leave it undone. But my hands aren't hers. The impressions aren't coming out right.
LUKE: Mm-hmm.
CALLER: And I keep thinking about what she said when she left. She said, "You're a good man who built a bad system, Silas." And I can't — I honestly cannot tell if that's forgiveness or if it's the cruelest thing anyone's ever said to me.
LUKE: So what are you gonna do about it?
CALLER: I don't know. That's why I'm calling you at midnight instead of sleeping or fixing the stamps or... I don't know.
Part of me thinks I should call her. Try to make it right somehow. But what does that even mean? I apologized. I changed the policy. She left anyway because the apology proved the whole thing was wrong to begin with, which — she's not wrong about that. So what am I supposed to do, unsay it? Take it back?
The other part of me thinks maybe the answer is just to keep going. To do the stamp work myself until I get better at it, to run the business, to let people leave if they need to leave. To accept that growth is supposed to hurt and that I did the right thing even though it feels like I'm losing something.
But I need to know — is that true? Did I do the right thing?
+30
View File
@@ -0,0 +1,30 @@
=== Toad (38, Española, NM) ===
voice: Vinny
emotional_register: Tough exterior, genuine bewilderment at his own sadness, searching for permission to feel it
secret_want: To be told he is a good father even though he coaches the team partly for himself.
CALLER (Toad, 38, Española, NM): My kid quit baseball on me and I know that sounds dumb but I'm driving around and I can't go home yet.
LUKE: How'd you end up there?
CALLER: Well, he just... he told me Wednesday night at dinner. Said he didn't want to do it anymore. Didn't want to play this season. I asked him why and he couldn't really give me a reason, you know? Just said he didn't feel like it.
I've been coaching that team for four years, Luke. Four years. And I didn't push him or nothing, I just said okay, we'll talk about it more. But inside I felt like somebody punched me in the gut.
So now I'm driving around at eleven o'clock at night because if I go home I'm just gonna sit there and my wife's gonna ask me what's wrong and I don't even know how to explain it.
LUKE: Mm-hmm.
CALLER: I mean, I know he's his own person, right? He gets to choose. I'm not one of those dads. I'm not trying to live through my kid or whatever. But this was... we had something, you know? Every spring we had this thing.
And I don't even know if he knows how much it meant to me. That's the thing that's sitting weird.
LUKE: So what are you gonna do about it?
CALLER: I don't know. That's why I'm calling you at midnight instead of being home. I want to talk to him more about it, find out what's really going on. Maybe he's scared, maybe some kid said something to him, I don't know.
But part of me is scared if I push too hard he's gonna dig in harder, you know? And another part of me wants to just tell him he's playing anyway because he committed to the team.
I guess what I'm really asking is... am I allowed to be this upset about it? Because it feels stupid. He's nine. Kids change their minds about stuff. But I can't shake it, and I feel like maybe that means I'm doing something wrong as a father.
LUKE: That's — okay, keep going.
CALLER: I have this glove in the back of my truck. Still in the box. I bought it for him when he was six, too small for him then, so I just kept it. Been sitting back there for three years waiting for him to grow into it. I don't even know why I keep it back there except I do.
And there's other stuff too. I coach because I want to be around him, sure, but also because... I don't know, man. Because when I'm on that field I'm not thinking about the shop or the bills or any of it. I'm just there. I'm just present, you know?
And he's good, Luke. He's really good. He's got a real arm and he pays attention. And I thought maybe that meant something. I thought maybe that meant he liked it as much as I do.
But maybe I'm the only one who likes it that much.
+192
View File
@@ -0,0 +1,192 @@
"""Download vocal-free background music from Jamendo (CC-licensed).
Targets late-night talk-radio vibe + hip-hop. Skips tracks shorter than 60s,
dedupes against existing files in music/, and appends CREDITS.txt entries.
Usage:
python download_music.py # 100 tracks across all buckets
python download_music.py --count 30 # smaller batch
python download_music.py --dry-run # show what would be downloaded
"""
import argparse
import os
import re
import sys
import time
from pathlib import Path
from urllib.request import urlopen, Request
from urllib.parse import urlencode
from dotenv import load_dotenv
load_dotenv()
CLIENT_ID = os.getenv("JAMENDO_CLIENT_ID")
if not CLIENT_ID:
print("ERROR: JAMENDO_CLIENT_ID not set in .env", file=sys.stderr)
sys.exit(1)
MUSIC_DIR = Path(__file__).parent / "music"
CREDITS_FILE = MUSIC_DIR / "CREDITS.txt"
# (tag_query, genre_label, target_count) — hip-hop weighted heaviest per user pref
BUCKETS = [
("hiphop+instrumental", "Hip-Hop", 40),
("jazz", "Jazz", 20),
("lofi", "Lo-Fi", 15),
("funk", "Funk", 15),
("soul", "Soul", 10),
]
# Filenames already on disk — skip duplicates by (artist, title) signature
def _existing_signatures() -> set[str]:
sigs = set()
for f in MUSIC_DIR.glob("*.mp3"):
# "Artist - Title [Genre].mp3" or "Artist - Title.mp3"
stem = f.stem
stem = re.sub(r"\s*\[[^\]]+\]\s*$", "", stem)
sigs.add(stem.lower().strip())
for f in MUSIC_DIR.glob("*.wav"):
sigs.add(f.stem.lower().strip())
return sigs
def _sanitize(s: str) -> str:
s = s.replace("/", "-").replace("\\", "-")
s = re.sub(r'[<>:"|?*]', "", s)
return s.strip()
def _fetch_jamendo_page(tag_query: str, offset: int, limit: int = 50) -> list[dict]:
params = {
"client_id": CLIENT_ID,
"format": "json",
"limit": limit,
"offset": offset,
"vocalinstrumental": "instrumental",
"fuzzytags": tag_query,
"audioformat": "mp32",
"include": "musicinfo+licenses",
"audiodlallowed": "true",
"ccnd": "true", # allow non-derivative (we won't modify)
"order": "popularity_total",
}
url = "https://api.jamendo.com/v3.0/tracks/?" + urlencode(params)
with urlopen(Request(url, headers={"User-Agent": "ai-podcast-music-fetcher/1.0"}), timeout=30) as r:
import json
data = json.load(r)
if data.get("headers", {}).get("status") != "success":
print(f" API error: {data.get('headers', {}).get('error_message')}")
return []
return data.get("results", [])
def _download(url: str, dest: Path) -> bool:
try:
req = Request(url, headers={"User-Agent": "ai-podcast-music-fetcher/1.0"})
with urlopen(req, timeout=120) as r, open(dest, "wb") as out:
while True:
chunk = r.read(64 * 1024)
if not chunk:
break
out.write(chunk)
return True
except Exception as e:
print(f" download failed: {e}")
if dest.exists():
dest.unlink()
return False
def fetch_bucket(tag_query: str, genre_label: str, target: int, existing: set[str], dry_run: bool) -> list[tuple[Path, dict]]:
"""Returns list of (path, track_info) successfully downloaded."""
print(f"\n=== {genre_label} (target {target}) ===")
downloaded: list[tuple[Path, dict]] = []
offset = 0
seen_ids = set()
while len(downloaded) < target and offset < 500: # cap pagination
page = _fetch_jamendo_page(tag_query, offset)
if not page:
break
offset += len(page)
for track in page:
if len(downloaded) >= target:
break
tid = track.get("id")
if tid in seen_ids:
continue
seen_ids.add(tid)
if track.get("duration", 0) < 60:
continue
if not track.get("audiodownload_allowed"):
continue
artist = _sanitize(track.get("artist_name", "Unknown"))
name = _sanitize(track.get("name", "Untitled"))
sig = f"{artist} - {name}".lower().strip()
if sig in existing:
continue
filename = f"{artist} - {name} [{genre_label}].mp3"
dest = MUSIC_DIR / filename
audio_url = track.get("audiodownload") or track.get("audio")
if not audio_url:
continue
if dry_run:
print(f" [DRY] {filename} ({track.get('duration')}s)")
downloaded.append((dest, track))
existing.add(sig)
continue
print(f"{filename} ({track.get('duration')}s)")
if _download(audio_url, dest):
downloaded.append((dest, track))
existing.add(sig)
time.sleep(0.5) # be polite
if len(page) < 50:
break
return downloaded
def append_credits(entries: list[tuple[Path, dict]]):
if not entries:
return
with open(CREDITS_FILE, "a") as f:
f.write(f"\n# Added {time.strftime('%Y-%m-%d')} — vocal-free batch via Jamendo API\n")
for dest, track in entries:
license_url = track.get("license_ccurl", "")
share_url = track.get("shareurl", "")
artist = track.get("artist_name", "")
name = track.get("name", "")
f.write(f"{dest.name} | {artist} - {name} | {license_url} | {share_url}\n")
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--count", type=int, default=100, help="Total tracks (default 100)")
ap.add_argument("--dry-run", action="store_true")
args = ap.parse_args()
MUSIC_DIR.mkdir(exist_ok=True)
existing = _existing_signatures()
print(f"Existing tracks: {len(existing)}")
# Scale bucket targets proportionally to --count
scale = args.count / sum(b[2] for b in BUCKETS)
all_new: list[tuple[Path, dict]] = []
for tag, label, weight in BUCKETS:
target = max(1, round(weight * scale))
all_new.extend(fetch_bucket(tag, label, target, existing, args.dry_run))
print(f"\n=== Done. {len(all_new)} new tracks {'planned' if args.dry_run else 'downloaded'}. ===")
if not args.dry_run:
append_credits(all_new)
print(f"CREDITS.txt updated.")
if __name__ == "__main__":
main()
+261
View File
@@ -0,0 +1,261 @@
"""Fetch instrumental background music from Jamendo for the radio show.
Pixabay has no public music API this uses Jamendo's free API instead.
All tracks are Creative Commons licensed. Attribution is saved to music/CREDITS.txt.
Setup: Get a free client_id at https://devportal.jamendo.com
Add JAMENDO_CLIENT_ID=your_id to .env
Usage:
python fetch_music.py # download 20 tracks across all genres
python fetch_music.py --genre jazz # download jazz only
python fetch_music.py --count 50 # download 50 tracks
python fetch_music.py --list # just list available tracks, don't download
"""
import argparse
import os
import re
import sys
from pathlib import Path
import httpx
from dotenv import load_dotenv
load_dotenv()
MUSIC_DIR = Path(__file__).parent / "music"
CREDITS_FILE = MUSIC_DIR / "CREDITS.txt"
API_BASE = "https://api.jamendo.com/v3.0"
# Genres good for a late-night radio show
GENRES = ["jazz", "lofi", "blues", "ambient", "acoustic", "funk", "chill"]
# Map search tags to labels that _detect_genre() in main.py can match
# jazz, blues, funk, lo-fi are already in GENRE_KEYWORDS
# ambient, acoustic, chill would need to be added for auto-detection
GENRE_LABELS = {
"jazz": "Jazz",
"lofi": "Lo-Fi",
"blues": "Blues",
"ambient": "Ambient",
"acoustic": "Acoustic",
"funk": "Funk",
"chill": "Chill",
}
def get_client_id():
key = os.getenv("JAMENDO_CLIENT_ID")
if not key:
print("Error: JAMENDO_CLIENT_ID not found in .env")
print("Get one free at https://devportal.jamendo.com")
sys.exit(1)
return key
def sanitize_filename(name: str) -> str:
return re.sub(r'[<>:"/\\|?*]', '', name).strip()
def _has_vocals(track: dict) -> bool:
"""Check musicinfo for vocal indicators — catches tracks Jamendo mis-tagged as instrumental."""
mi = track.get("musicinfo", {})
# Check the vocalinstrumental field in musicinfo (separate from the API filter)
vi = mi.get("vocalinstrumental")
if vi and vi.lower() == "vocal":
return True
# Check tags for vocal/singing indicators
tags = mi.get("tags", {})
# tags can be {"genres": [...], "instruments": [...], "vartags": [...]}
all_tags = []
if isinstance(tags, dict):
for v in tags.values():
if isinstance(v, list):
all_tags.extend(t.lower() for t in v)
elif isinstance(tags, list):
all_tags = [t.lower() for t in tags]
vocal_tags = {"vocals", "vocal", "singing", "singer", "voice", "lyrics",
"rap", "hiphop", "hip-hop", "spoken", "spoken word"}
if vocal_tags & set(all_tags):
return True
# Check track name for vocal giveaways
name_lower = track.get("name", "").lower()
if any(w in name_lower for w in ["feat.", "ft.", "vocal", "remix vocal", "(voice"]):
return True
return False
def search_tracks(client: httpx.Client, client_id: str, genre: str, limit: int = 20) -> list[dict]:
# Request more than needed so we can filter out vocal false positives
fetch_limit = min(limit * 3, 200)
params = {
"client_id": client_id,
"format": "json",
"limit": fetch_limit,
"vocalinstrumental": "instrumental",
"fuzzytags": genre,
"durationbetween": "60_300",
"include": "musicinfo+licenses",
"order": "popularity_total",
}
resp = client.get(f"{API_BASE}/tracks/", params=params)
resp.raise_for_status()
data = resp.json()
if data["headers"]["status"] != "success":
print(f" API error: {data['headers'].get('error_message', 'unknown')}")
return []
results = data.get("results", [])
# Post-filter: reject tracks with vocal indicators despite the API filter
filtered = []
for t in results:
if _has_vocals(t):
print(f" SKIP (vocals detected): {t.get('artist_name', '?')} - {t.get('name', '?')}")
continue
filtered.append(t)
if len(filtered) >= limit:
break
skipped = len(results) - len(filtered)
if skipped:
print(f" (filtered out {skipped} tracks with vocal indicators)")
return filtered
def make_filename(track: dict, genre_tag: str) -> str:
artist = sanitize_filename(track.get("artist_name", "Unknown"))
title = sanitize_filename(track.get("name", "Untitled"))
label = GENRE_LABELS.get(genre_tag, genre_tag.title())
# Include genre tag if not already detectable from artist/title
lower = f"{artist} {title}".lower()
needs_tag = not any(kw in lower for kw in [genre_tag, label.lower()])
if needs_tag:
return f"{artist} - {title} [{label}].mp3"
return f"{artist} - {title}.mp3"
def download_track(client: httpx.Client, track: dict, filepath: Path, index: int, total: int) -> bool:
url = track.get("audiodownload")
if not url:
print(f" [{index}/{total}] SKIP (no download URL): {track['name']}")
return False
if not track.get("audiodownload_allowed", True):
print(f" [{index}/{total}] SKIP (download not allowed): {track['name']}")
return False
print(f" [{index}/{total}] Downloading: {filepath.name}...", end=" ", flush=True)
resp = client.get(url, follow_redirects=True)
resp.raise_for_status()
filepath.write_bytes(resp.content)
size_mb = len(resp.content) / (1024 * 1024)
dur = track.get("duration", 0)
print(f"{size_mb:.1f} MB, {dur // 60}:{dur % 60:02d}")
return True
def save_credit(track: dict, filename: str):
artist = track.get("artist_name", "Unknown")
title = track.get("name", "Untitled")
license_url = track.get("license_ccurl", "")
share_url = track.get("shareurl", "")
line = f"{filename} | {artist} - {title} | {license_url} | {share_url}\n"
existing = CREDITS_FILE.read_text() if CREDITS_FILE.exists() else ""
if filename not in existing:
with open(CREDITS_FILE, "a") as f:
if not existing:
f.write("# Music Credits (Jamendo - Creative Commons)\n")
f.write("# File | Artist - Title | License | URL\n\n")
f.write(line)
def main():
parser = argparse.ArgumentParser(description="Download instrumental music from Jamendo")
parser.add_argument("--genre", choices=GENRES, help="Download only this genre")
parser.add_argument("--count", type=int, default=20, help="Total tracks to download (default: 20)")
parser.add_argument("--list", action="store_true", help="List available tracks without downloading")
args = parser.parse_args()
client_id = get_client_id()
MUSIC_DIR.mkdir(exist_ok=True)
genres = [args.genre] if args.genre else GENRES
per_genre = max(1, args.count // len(genres))
remainder = args.count - per_genre * len(genres)
all_tracks = []
seen_ids = set()
with httpx.Client(timeout=30) as api_client:
for i, genre in enumerate(genres):
limit = per_genre + (1 if i < remainder else 0)
if limit <= 0:
continue
print(f"Searching {genre}...", end=" ", flush=True)
tracks = search_tracks(api_client, client_id, genre, limit)
# Deduplicate across genres
added = 0
for t in tracks:
if t["id"] not in seen_ids and added < limit:
t["_genre_tag"] = genre
all_tracks.append(t)
seen_ids.add(t["id"])
added += 1
print(f"{added} tracks")
if not all_tracks:
print("No tracks found.")
return
if args.list:
print(f"\n{'#':<4} {'Genre':<10} {'Artist':<25} {'Title':<40} {'Duration':<8}")
print("-" * 90)
for i, t in enumerate(all_tracks, 1):
dur = f"{t['duration'] // 60}:{t['duration'] % 60:02d}"
artist = t["artist_name"][:24]
title = t["name"][:39]
label = GENRE_LABELS.get(t["_genre_tag"], t["_genre_tag"])
print(f"{i:<4} {label:<10} {artist:<25} {title:<40} {dur:<8}")
print(f"\n{len(all_tracks)} tracks available")
return
# Download phase
downloaded = 0
skipped_exists = 0
skipped_error = 0
with httpx.Client(timeout=120, follow_redirects=True) as dl_client:
for i, track in enumerate(all_tracks, 1):
filename = make_filename(track, track["_genre_tag"])
filepath = MUSIC_DIR / filename
if filepath.exists():
print(f" [{i}/{len(all_tracks)}] EXISTS: {filename}")
skipped_exists += 1
continue
try:
if download_track(dl_client, track, filepath, i, len(all_tracks)):
save_credit(track, filename)
downloaded += 1
else:
skipped_error += 1
except Exception as e:
print(f" [{i}/{len(all_tracks)}] ERROR: {e}")
# Clean up partial download
if filepath.exists():
filepath.unlink()
skipped_error += 1
print(f"\nDone: {downloaded} downloaded, {skipped_exists} existed, {skipped_error} skipped")
if __name__ == "__main__":
main()
+130
View File
@@ -0,0 +1,130 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Cost Dashboard - Luke at the Roost</title>
<link rel="stylesheet" href="/css/style.css">
<link rel="stylesheet" href="/css/costs.css">
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
</head>
<body>
<header class="costs-header">
<h1>Cost Dashboard</h1>
<a href="/" class="back-link">Back to Show</a>
<nav class="period-tabs">
<button class="period-tab active" data-period="all">All Time</button>
<button class="period-tab" data-period="month">This Month</button>
<button class="period-tab" data-period="week">This Week</button>
<button class="period-tab" data-period="today">Today</button>
</nav>
</header>
<main class="costs-main">
<section class="summary-cards">
<div class="card" id="card-total">
<div class="card-label">Total Spend</div>
<div class="card-value" id="total-spend">--</div>
<div class="card-change" id="total-change"></div>
</div>
<div class="card" id="card-llm-tts">
<div class="card-label">LLM / TTS</div>
<div class="card-value" id="llm-tts-split">--</div>
</div>
<div class="card" id="card-sessions">
<div class="card-label">Sessions</div>
<div class="card-value" id="session-count">--</div>
</div>
<div class="card" id="card-avg">
<div class="card-label">Avg / Session</div>
<div class="card-value" id="avg-cost">--</div>
</div>
</section>
<section class="chart-row">
<div class="chart-container">
<h3>Cost Over Time</h3>
<canvas id="timeline-chart"></canvas>
</div>
<div class="chart-container">
<h3>Cost by Model</h3>
<canvas id="model-chart"></canvas>
</div>
</section>
<section class="chart-row">
<div class="chart-container">
<h3>Cost by Category</h3>
<canvas id="category-chart"></canvas>
</div>
<div class="chart-container">
<h3>Cost Per Session</h3>
<canvas id="session-chart"></canvas>
</div>
</section>
<section class="chart-row">
<div class="table-container">
<h3>TTS Providers</h3>
<table id="tts-table">
<thead>
<tr><th>Provider</th><th>Calls</th><th>Characters</th><th>Cost</th></tr>
</thead>
<tbody></tbody>
</table>
</div>
<div class="table-container">
<h3>Most Expensive Calls</h3>
<table id="expensive-table">
<thead>
<tr><th>Model</th><th>Category</th><th>Caller</th><th>Tokens</th><th>Cost</th><th>Latency</th></tr>
</thead>
<tbody></tbody>
</table>
</div>
</section>
<section class="tables-section">
<div class="table-container">
<h3>Sessions</h3>
<table id="sessions-table">
<thead>
<tr><th>Date</th><th>LLM</th><th>TTS</th><th>Total</th><th>Calls</th><th></th></tr>
</thead>
<tbody></tbody>
</table>
</div>
</section>
<section class="session-detail hidden" id="session-detail">
<h2>Session Detail: <span id="detail-session-id"></span></h2>
<button class="close-detail" id="close-detail">Back</button>
<div class="detail-grid">
<div class="table-container">
<h3>By Caller</h3>
<table id="detail-caller-table">
<thead><tr><th>Caller</th><th>Calls</th><th>Cost</th></tr></thead>
<tbody></tbody>
</table>
</div>
<div class="table-container">
<h3>By Category</h3>
<table id="detail-category-table">
<thead><tr><th>Category</th><th>Calls</th><th>Cost</th></tr></thead>
<tbody></tbody>
</table>
</div>
<div class="table-container">
<h3>By Model</h3>
<table id="detail-model-table">
<thead><tr><th>Model</th><th>Calls</th><th>Cost</th></tr></thead>
<tbody></tbody>
</table>
</div>
<div class="table-container">
<h3>Most Expensive Calls</h3>
<table id="detail-expensive-table">
<thead><tr><th>Category</th><th>Model</th><th>Caller</th><th>Cost</th><th>Tokens</th><th>Latency</th></tr></thead>
<tbody></tbody>
</table>
</div>
</div>
</section>
</main>
<script src="/js/costs.js"></script>
</body>
</html>
+268
View File
@@ -0,0 +1,268 @@
/* Cost Dashboard */
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
}
.costs-header {
display: flex;
align-items: center;
gap: 16px;
padding: 16px 24px;
border-bottom: 1px solid rgba(232, 121, 29, 0.12);
}
.costs-header h1 {
font-size: 1.4rem;
margin-right: auto;
}
.back-link {
color: var(--accent);
text-decoration: none;
font-size: 0.9rem;
transition: color var(--transition);
}
.back-link:hover {
color: var(--accent-hover);
}
.period-tabs {
display: flex;
gap: 4px;
}
.period-tab {
background: transparent;
color: var(--text-muted);
border: 1px solid rgba(232, 121, 29, 0.15);
border-radius: 20px;
padding: 6px 14px;
font-size: 0.8rem;
cursor: pointer;
transition: all var(--transition);
}
.period-tab:hover {
color: var(--text);
border-color: var(--accent);
}
.period-tab.active {
background: var(--accent);
color: var(--bg-dark);
border-color: var(--accent);
font-weight: 600;
}
.costs-main {
max-width: 1400px;
margin: 0 auto;
padding: 20px 24px;
}
/* Summary Cards */
.summary-cards {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
margin-bottom: 20px;
}
.card {
background: var(--bg-light);
border: 1px solid rgba(232, 121, 29, 0.08);
border-radius: var(--radius);
padding: 18px 20px;
}
.card-label {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-muted);
margin-bottom: 6px;
}
.card-value {
font-size: 1.8rem;
font-weight: 700;
color: var(--text);
line-height: 1.2;
}
.card-change {
font-size: 0.75rem;
margin-top: 4px;
}
.card-change.positive {
color: var(--accent-red);
}
.card-change.negative {
color: var(--accent-green);
}
/* Charts */
.chart-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
margin-bottom: 20px;
}
.chart-container {
background: var(--bg-light);
border: 1px solid rgba(232, 121, 29, 0.08);
border-radius: var(--radius);
padding: 18px 20px;
}
.chart-container h3 {
font-size: 0.85rem;
color: var(--text-muted);
margin-bottom: 12px;
font-weight: 500;
}
/* Tables */
.tables-section {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
margin-bottom: 20px;
}
.table-container {
background: var(--bg-light);
border: 1px solid rgba(232, 121, 29, 0.08);
border-radius: var(--radius);
padding: 18px 20px;
overflow-x: auto;
}
.table-container h3 {
font-size: 0.85rem;
color: var(--text-muted);
margin-bottom: 12px;
font-weight: 500;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 0.82rem;
}
th {
text-transform: uppercase;
font-size: 0.68rem;
letter-spacing: 0.04em;
color: var(--text-muted);
border-bottom: 1px solid rgba(232, 121, 29, 0.15);
padding: 8px 10px;
text-align: left;
font-weight: 600;
}
td {
padding: 8px 10px;
border-bottom: 1px solid rgba(232, 121, 29, 0.06);
color: var(--text);
}
tr:hover td {
background: rgba(232, 121, 29, 0.04);
}
.clickable {
cursor: pointer;
}
.clickable:hover td {
background: rgba(232, 121, 29, 0.08);
}
/* Session Detail */
.session-detail {
margin-top: 20px;
}
.session-detail.hidden {
display: none;
}
.session-detail h2 {
font-size: 1.2rem;
margin-bottom: 12px;
}
.session-detail h2 span {
color: var(--accent);
}
.close-detail {
background: var(--bg-light);
color: var(--text);
border: 1px solid rgba(232, 121, 29, 0.15);
border-radius: var(--radius-sm);
padding: 6px 16px;
font-size: 0.8rem;
cursor: pointer;
margin-bottom: 16px;
transition: all var(--transition);
}
.close-detail:hover {
border-color: var(--accent);
color: var(--accent);
}
.detail-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
.view-btn {
background: transparent;
color: var(--accent);
border: 1px solid var(--accent);
border-radius: var(--radius-sm);
padding: 3px 10px;
font-size: 0.72rem;
cursor: pointer;
transition: all var(--transition);
}
.view-btn:hover {
background: var(--accent);
color: var(--bg-dark);
}
/* Responsive */
@media (max-width: 768px) {
.costs-header {
flex-wrap: wrap;
}
.summary-cards {
grid-template-columns: 1fr 1fr;
}
.chart-row,
.tables-section,
.detail-grid {
grid-template-columns: 1fr;
}
}
+1199 -54
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

+149 -48
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Luke at The Roost</title>
<link rel="stylesheet" href="/css/style.css">
<link rel="stylesheet" href="/css/style.css?v=3">
</head>
<body>
<div id="app">
@@ -16,6 +16,27 @@
<button id="new-session-btn" class="new-session-btn">New Session</button>
<button id="export-session-btn">Export</button>
<button id="settings-btn">Settings</button>
<a href="/costs" class="header-link-btn" title="Cost Dashboard">Costs</a>
</div>
<div class="theme-bar">
<label for="show-theme-input" class="theme-label">Theme:</label>
<input type="text" id="show-theme-input" class="theme-input" placeholder="e.g. St. Patrick's Day" maxlength="100">
<button id="set-theme-btn" class="theme-btn set" title="Set show theme">Set</button>
<button id="clear-theme-btn" class="theme-btn clear hidden" title="Clear theme">&#x2715;</button>
</div>
<div id="show-clock" class="show-clock">
<span class="clock-time" id="clock-time"></span>
<span id="show-timers" class="show-timers hidden">
<span class="clock-divider">|</span>
<span class="clock-label">On Air:</span>
<span class="clock-value" id="clock-runtime">0:00:00</span>
<span class="clock-divider">|</span>
<span class="clock-label">Est. Final:</span>
<span class="clock-value clock-estimate" id="clock-estimate">0:00</span>
<span class="clock-divider">|</span>
<span class="clock-label">Cost:</span>
<span class="clock-value clock-cost" id="clock-cost">$0.00</span>
</span>
</div>
</header>
@@ -50,24 +71,64 @@
</label>
</div>
<div id="call-status" class="call-status">No active call</div>
<details id="caller-background-details" class="caller-background hidden">
<summary>Caller Background</summary>
<div id="caller-info-panel" class="caller-info-panel hidden">
<div id="caller-identity" class="caller-identity"></div>
<div id="caller-situation" class="caller-situation"></div>
<div id="caller-signature" class="caller-signature"></div>
<div id="caller-secret-want" class="caller-secret-want"></div>
<details id="caller-background-details" class="caller-background-full">
<summary>Full Background</summary>
<div id="caller-background"></div>
</details>
<button id="hangup-btn" class="hangup-btn" disabled>Hang Up</button>
</div>
<div class="call-actions">
<button id="wrapup-btn" class="wrapup-btn" disabled>Wrap It Up <span class="shortcut-label">W</span></button>
<button id="hangup-btn" class="hangup-btn" disabled>Hang Up <span class="shortcut-label">H</span></button>
</div>
</section>
<!-- Call Queue -->
<section class="queue-section">
<h2>Incoming Calls <span style="font-size:0.6em;font-weight:normal;color:var(--text-muted);">(208) 439-5853</span></h2>
<h2>Incoming Calls <span class="section-subtitle">(208) 439-5853</span></h2>
<div id="call-queue" class="call-queue">
<div class="queue-empty">No callers waiting</div>
</div>
</section>
<!-- Voicemail -->
<section class="voicemail-section">
<h2>Voicemail <span id="voicemail-badge" class="voicemail-badge hidden">0</span></h2>
<div id="voicemail-list" class="voicemail-list">
<div class="queue-empty">No voicemails</div>
</div>
</section>
<!-- Listener Emails -->
<section class="voicemail-section">
<h2>Emails <span id="email-badge" class="voicemail-badge hidden">0</span></h2>
<div id="email-list" class="voicemail-list email-list">
<div class="queue-empty">No emails</div>
</div>
</section>
<!-- Chat -->
<section class="chat-section">
<div id="chat" class="chat-log"></div>
<div class="devon-bar">
<div class="devon-ask-row">
<input type="text" id="devon-input" placeholder="Ask Devon..." class="devon-input">
<button id="devon-ask-btn" class="devon-ask-btn">Ask <span class="shortcut-label">D</span></button>
<button id="devon-interject-btn" class="devon-interject-btn" title="Devon interjects on current conversation">Interject</button>
<label class="devon-monitor-label" title="Devon auto-monitors conversations">
<input type="checkbox" id="devon-monitor" checked> Monitor
</label>
</div>
<div id="devon-suggestion" class="devon-suggestion hidden">
<span class="devon-suggestion-text">Devon has something</span>
<button id="devon-play-btn" class="devon-play-btn">Play</button>
<button id="devon-dismiss-btn" class="devon-dismiss-btn">Dismiss</button>
</div>
</div>
<div class="talk-controls">
<button id="talk-btn" class="talk-btn">Hold to Talk</button>
<button id="type-btn" class="type-btn">Type</button>
@@ -75,18 +136,18 @@
<div id="status" class="status hidden"></div>
</section>
<!-- Music -->
<section class="music-section">
<h2>Music</h2>
<select id="track-select"></select>
<div class="music-controls">
<button id="play-btn">Play</button>
<button id="stop-btn">Stop</button>
<input type="range" id="volume" min="0" max="100" value="30">
<!-- Music / Ads / Idents -->
<div class="media-row">
<section class="music-section genre-section">
<h2>Music <span class="shortcut-label">M</span></h2>
<div id="genre-buttons" class="genre-grid"></div>
<div id="now-playing" class="now-playing hidden">
<span id="now-playing-text" class="now-playing-text"></span>
<button id="stop-btn" class="now-playing-stop">Stop</button>
<input type="range" id="volume" min="0" max="100" value="30" class="now-playing-volume">
</div>
</section>
<!-- Ads -->
<section class="music-section">
<h2>Ads</h2>
<select id="ad-select"></select>
@@ -96,6 +157,16 @@
</div>
</section>
<section class="music-section">
<h2>Idents</h2>
<select id="ident-select"></select>
<div class="music-controls">
<button id="ident-play-btn">Play Ident</button>
<button id="ident-stop-btn">Stop</button>
</div>
</section>
</div>
<!-- Sound Effects -->
<section class="sounds-section">
<h2>Sounds</h2>
@@ -107,6 +178,7 @@
<div class="log-header">
<h2>Server Log</h2>
<div class="server-controls">
<button id="log-toggle-btn" class="log-toggle-btn">Show ▼</button>
<button id="restart-server-btn" class="server-btn restart">Restart</button>
<button id="stop-server-btn" class="server-btn stop">Stop</button>
<label class="auto-scroll-label">
@@ -114,7 +186,9 @@
</label>
</div>
</div>
<div class="log-body collapsed">
<div id="server-log" class="server-log"></div>
</div>
</section>
</main>
@@ -125,7 +199,7 @@
<!-- Audio Devices -->
<div class="settings-group">
<h3>Audio Routing</h3>
<h3>Audio Devices</h3>
<div class="device-row">
<label>
Input Device
@@ -133,7 +207,7 @@
</label>
<label>
Ch
<input type="number" id="input-channel" value="1" min="1" max="16" class="channel-input">
<input type="number" id="input-channel" value="1" min="1" max="32" class="channel-input">
</label>
</div>
<div class="device-row">
@@ -142,43 +216,70 @@
<select id="output-device"></select>
</label>
</div>
<div class="channel-row">
<label>Caller Ch <input type="number" id="caller-channel" value="3" min="1" max="16" class="channel-input"></label>
<label>Live Ch <input type="number" id="live-caller-channel" value="9" min="1" max="16" class="channel-input"></label>
<label>Music Ch <input type="number" id="music-channel" value="5" min="1" max="16" class="channel-input"></label>
<label>SFX Ch <input type="number" id="sfx-channel" value="7" min="1" max="16" class="channel-input"></label>
<label>Ad Ch <input type="number" id="ad-channel" value="11" min="1" max="16" class="channel-input"></label>
</div>
<div class="settings-group">
<h3>Output Routing</h3>
<div class="routing-grid">
<div class="routing-item">
<span class="routing-label">AI Caller</span>
<input type="number" id="caller-channel" value="3" min="1" max="32" class="channel-input">
</div>
<div class="routing-item">
<span class="routing-label">Devon</span>
<input type="number" id="devon-channel" value="17" min="1" max="32" class="channel-input">
</div>
<div class="routing-item">
<span class="routing-label">Live Caller</span>
<input type="number" id="live-caller-channel" value="9" min="1" max="32" class="channel-input">
</div>
<div class="routing-item">
<span class="routing-label">Music</span>
<input type="number" id="music-channel" value="5" min="1" max="32" class="channel-input">
</div>
<div class="routing-item">
<span class="routing-label">SFX</span>
<input type="number" id="sfx-channel" value="7" min="1" max="32" class="channel-input">
</div>
<div class="routing-item">
<span class="routing-label">Ads</span>
<input type="number" id="ad-channel" value="11" min="1" max="32" class="channel-input">
</div>
<div class="routing-item">
<span class="routing-label">Idents</span>
<input type="number" id="ident-channel" value="15" min="1" max="32" class="channel-input">
</div>
</div>
</div>
<!-- LLM Settings -->
<div class="settings-group">
<h3>LLM Provider</h3>
<label>
Provider
<select id="provider">
<option value="openrouter">OpenRouter</option>
<option value="ollama">Ollama</option>
</select>
</label>
<div id="openrouter-settings">
<label>
Model
<select id="openrouter-model"></select>
</label>
<h3>LLM Model Routing</h3>
<div class="model-routing-grid">
<div class="model-routing-item">
<span class="model-routing-label">Caller Dialog</span>
<select id="model-caller_dialog" class="model-select"></select>
</div>
<div class="model-routing-item">
<span class="model-routing-label">Devon Monitor</span>
<select id="model-devon_monitor" class="model-select"></select>
</div>
<div class="model-routing-item">
<span class="model-routing-label">Devon Ask</span>
<select id="model-devon_ask" class="model-select"></select>
</div>
<div class="model-routing-item">
<span class="model-routing-label">Backgrounds</span>
<select id="model-background_gen" class="model-select"></select>
</div>
<div class="model-routing-item">
<span class="model-routing-label">Call Summary</span>
<select id="model-call_summary" class="model-select"></select>
</div>
<div class="model-routing-item">
<span class="model-routing-label">News</span>
<select id="model-news_summary" class="model-select"></select>
</div>
<div id="ollama-settings" class="hidden">
<label>
Model
<select id="ollama-model"></select>
</label>
<label>
Host
<input type="text" id="ollama-host" value="http://localhost:11434">
</label>
<button type="button" id="refresh-ollama" class="refresh-btn">Refresh Models</button>
</div>
</div>
@@ -224,6 +325,6 @@
</div>
</div>
<script src="/js/app.js?v=15"></script>
<script src="/js/app.js?v=29"></script>
</body>
</html>
+1064 -67
View File
File diff suppressed because it is too large Load Diff
+310
View File
@@ -0,0 +1,310 @@
let currentPeriod = 'all';
let charts = {};
const COLORS = {
accent: '#e8791d',
devon: '#c4944a',
green: '#5a8a3c',
red: '#cc2222',
blue: '#4a8ac4',
purple: '#8a5ac4',
tan: '#c4845a',
teal: '#3c8a7a',
pink: '#c45a8a',
slate: '#6a7a8a',
};
const COLOR_LIST = Object.values(COLORS);
Chart.defaults.color = '#9a8b78';
Chart.defaults.borderColor = 'rgba(245, 240, 229, 0.08)';
// --- Utilities ---
function formatCost(n) {
if (n == null) return '--';
return n < 0.01 ? `$${n.toFixed(4)}` : `$${n.toFixed(2)}`;
}
function formatDate(iso) {
if (!iso) return '--';
const d = new Date(iso);
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) +
', ' + d.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' });
}
function shortenModel(name) {
if (!name) return '--';
const i = name.indexOf('/');
return i >= 0 ? name.slice(i + 1) : name;
}
function destroyChart(key) {
if (charts[key]) {
charts[key].destroy();
charts[key] = null;
}
}
// --- Data Loading ---
async function loadDashboard() {
const p = currentPeriod;
try {
const [summary, timeline, models, categories, sessions, expensive, tts] = await Promise.all([
fetch(`/api/costs/summary?period=${p}`).then(r => r.json()),
fetch(`/api/costs/timeline?period=${p}&group_by=session`).then(r => r.json()),
fetch(`/api/costs/models?period=${p}`).then(r => r.json()),
fetch(`/api/costs/categories?period=${p}`).then(r => r.json()),
fetch(`/api/costs/sessions?period=${p}`).then(r => r.json()),
fetch(`/api/costs/expensive?period=${p}&limit=10`).then(r => r.json()),
fetch(`/api/costs/tts?period=${p}`).then(r => r.json()),
]);
renderSummary(summary);
renderTimeline(timeline);
renderModels(models);
renderCategories(categories);
renderSessionBars(timeline);
renderTtsTable(tts);
renderExpensiveTable(expensive);
renderSessionsTable(sessions);
} catch (e) {
console.error('Dashboard load error:', e);
}
}
// --- Render Functions ---
function renderSummary(data) {
document.getElementById('total-spend').textContent = formatCost(data.total_cost);
document.getElementById('llm-tts-split').textContent =
`${formatCost(data.llm_cost)} / ${formatCost(data.tts_cost)}`;
document.getElementById('session-count').textContent = data.sessions;
document.getElementById('avg-cost').textContent = formatCost(data.avg_cost_per_session);
const changeEl = document.getElementById('total-change');
if (data.cost_change_pct != null) {
const up = data.cost_change_pct > 0;
changeEl.textContent = `${up ? '\u2191' : '\u2193'} ${Math.abs(data.cost_change_pct)}% vs prev period`;
changeEl.className = 'card-change ' + (up ? 'positive' : 'negative');
} else {
changeEl.textContent = '';
changeEl.className = 'card-change';
}
}
function renderTimeline(data) {
destroyChart('timeline');
const ctx = document.getElementById('timeline-chart');
const labels = data.map(d => d.date || formatDate(d.started_at));
charts.timeline = new Chart(ctx, {
type: 'line',
data: {
labels,
datasets: [
{
label: 'LLM',
data: data.map(d => d.llm_cost),
borderColor: COLORS.accent,
backgroundColor: COLORS.accent + '22',
fill: true,
tension: 0.3,
},
{
label: 'TTS',
data: data.map(d => d.tts_cost),
borderColor: COLORS.devon,
backgroundColor: COLORS.devon + '22',
fill: true,
tension: 0.3,
},
],
},
options: {
responsive: true,
plugins: {
legend: { position: 'top' },
},
scales: {
y: { beginAtZero: true, ticks: { callback: v => '$' + v.toFixed(2) } },
},
},
});
}
function renderModels(data) {
destroyChart('models');
const ctx = document.getElementById('model-chart');
charts.models = new Chart(ctx, {
type: 'doughnut',
data: {
labels: data.map(d => shortenModel(d.model)),
datasets: [{
data: data.map(d => d.cost),
backgroundColor: data.map((_, i) => COLOR_LIST[i % COLOR_LIST.length]),
borderWidth: 0,
}],
},
options: {
responsive: true,
plugins: {
legend: { position: 'right', labels: { boxWidth: 12, padding: 8, font: { size: 11 } } },
tooltip: { callbacks: { label: ctx => `${ctx.label}: ${formatCost(ctx.parsed)}` } },
},
},
});
}
function renderCategories(data) {
destroyChart('categories');
const ctx = document.getElementById('category-chart');
charts.categories = new Chart(ctx, {
type: 'bar',
data: {
labels: data.map(d => d.category),
datasets: [{
data: data.map(d => d.cost),
backgroundColor: COLORS.accent + 'cc',
borderRadius: 4,
}],
},
options: {
indexAxis: 'y',
responsive: true,
plugins: { legend: { display: false } },
scales: {
x: { beginAtZero: true, ticks: { callback: v => '$' + v.toFixed(2) } },
},
},
});
}
function renderSessionBars(data) {
destroyChart('sessionBars');
const ctx = document.getElementById('session-chart');
const costs = data.map(d => d.total_cost);
const avg = costs.length ? costs.reduce((a, b) => a + b, 0) / costs.length : 0;
charts.sessionBars = new Chart(ctx, {
type: 'bar',
data: {
labels: data.map(d => d.date || formatDate(d.started_at)),
datasets: [{
data: costs,
backgroundColor: costs.map(c => c > avg ? COLORS.red + 'cc' : COLORS.green + 'cc'),
borderRadius: 4,
}],
},
options: {
responsive: true,
plugins: { legend: { display: false } },
scales: {
y: { beginAtZero: true, ticks: { callback: v => '$' + v.toFixed(2) } },
},
},
});
}
function renderTtsTable(data) {
const tbody = document.querySelector('#tts-table tbody');
tbody.innerHTML = data.map(d => `
<tr>
<td>${d.provider}</td>
<td>${d.calls.toLocaleString()}</td>
<td>${d.chars.toLocaleString()}</td>
<td>${formatCost(d.cost)}</td>
</tr>
`).join('');
}
function renderExpensiveTable(data) {
const tbody = document.querySelector('#expensive-table tbody');
tbody.innerHTML = data.map(d => `
<tr>
<td>${shortenModel(d.model)}</td>
<td>${d.category}</td>
<td>${d.caller_name || '\u2014'}</td>
<td>${(d.prompt_tokens + d.completion_tokens).toLocaleString()}</td>
<td>${formatCost(d.cost)}</td>
<td>${d.latency_ms.toFixed(0)}ms</td>
</tr>
`).join('');
}
function renderSessionsTable(data) {
const tbody = document.querySelector('#sessions-table tbody');
tbody.innerHTML = data.map(d => `
<tr class="clickable" data-session="${d.session_id}">
<td>${formatDate(d.started_at)}</td>
<td>${formatCost(d.llm_cost)}</td>
<td>${formatCost(d.tts_cost)}</td>
<td>${formatCost(d.total_cost)}</td>
<td>${d.total_llm_calls}</td>
<td><button class="view-btn" data-session="${d.session_id}">View</button></td>
</tr>
`).join('');
tbody.querySelectorAll('.view-btn').forEach(btn => {
btn.addEventListener('click', e => {
e.stopPropagation();
showSessionDetail(btn.dataset.session);
});
});
tbody.querySelectorAll('.clickable').forEach(row => {
row.addEventListener('click', () => showSessionDetail(row.dataset.session));
});
}
// --- Session Detail ---
async function showSessionDetail(sessionId) {
try {
const detail = await fetch(`/api/costs/session/${sessionId}`).then(r => r.json());
document.getElementById('detail-session-id').textContent = sessionId;
populateDetailTable('#detail-caller-table tbody', detail.by_caller,
d => `<td>${d.caller_name}</td><td>${d.calls}</td><td>${formatCost(d.cost)}</td>`);
populateDetailTable('#detail-category-table tbody', detail.by_category,
d => `<td>${d.category}</td><td>${d.calls}</td><td>${formatCost(d.cost)}</td>`);
populateDetailTable('#detail-model-table tbody', detail.by_model,
d => `<td>${shortenModel(d.model)}</td><td>${d.calls}</td><td>${formatCost(d.cost)}</td>`);
populateDetailTable('#detail-expensive-table tbody', detail.expensive_calls,
d => `<td>${d.category}</td><td>${shortenModel(d.model)}</td><td>${d.caller_name || '\u2014'}</td>` +
`<td>${formatCost(d.cost)}</td><td>${(d.prompt_tokens + d.completion_tokens).toLocaleString()}</td>` +
`<td>${d.latency_ms.toFixed(0)}ms</td>`);
document.querySelectorAll('.costs-main > section:not(.session-detail)').forEach(
s => s.style.display = 'none');
document.getElementById('session-detail').classList.remove('hidden');
} catch (e) {
console.error('Detail load error:', e);
}
}
function populateDetailTable(selector, data, rowFn) {
const tbody = document.querySelector(selector);
tbody.innerHTML = data.map(d => `<tr>${rowFn(d)}</tr>`).join('');
}
function closeDetail() {
document.getElementById('session-detail').classList.add('hidden');
document.querySelectorAll('.costs-main > section:not(.session-detail)').forEach(
s => s.style.display = '');
}
// --- Init ---
document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('.period-tab').forEach(tab => {
tab.addEventListener('click', () => {
document.querySelector('.period-tab.active').classList.remove('active');
tab.classList.add('active');
currentPeriod = tab.dataset.period;
loadDashboard();
});
});
document.getElementById('close-detail').addEventListener('click', closeDetail);
loadDashboard();
});
+161
View File
@@ -0,0 +1,161 @@
#!/usr/bin/env python3
"""Generate a static, crawlable page for every episode in the podcast feed."""
import argparse
import shutil
import sys
import tempfile
import urllib.request
from pathlib import Path
from xml.sax.saxutils import escape
from website_gen.feed import parse_feed
from website_gen.render import FEED_URL, render_episode_page
from website_gen.transcript import parse_transcript
REPO_ROOT = Path(__file__).resolve().parent
DEFAULT_OUTPUT = REPO_ROOT / "website"
DEFAULT_TRANSCRIPTS = DEFAULT_OUTPUT / "transcripts"
USER_AGENT = "lukeattheroost-site-generator/1.0"
SITE_URL = "https://lukeattheroost.com"
# (path, lastmod, changefreq, priority) — /404 is deliberately excluded.
STATIC_PAGES = (
("", "2026-03-15", "weekly", "1.0"),
("/llms.txt", "2026-03-15", "weekly", "0.5"),
("/how-it-works", "2026-03-10", "monthly", "0.8"),
("/clips", "2026-03-10", "weekly", "0.8"),
("/terms", "2026-02-25", "yearly", "0.3"),
("/stats", "2026-02-15", "daily", "0.6"),
("/privacy", "2026-02-15", "yearly", "0.3"),
)
def build_sitemap(episodes, static_pages=None) -> str:
"""Render the full sitemap: static pages first, then episodes newest first."""
if static_pages is None:
static_pages = STATIC_PAGES
entries = [(f"{SITE_URL}{path}", lastmod, freq, pri) for path, lastmod, freq, pri in static_pages]
ordered = sorted(
episodes,
key=lambda ep: (ep.published_iso, ep.number if ep.number is not None else -1),
reverse=True,
)
for episode in ordered:
entries.append(
(
f"{SITE_URL}/episode/{episode.slug}/",
episode.published_iso[:10],
"monthly",
"0.7",
)
)
lines = [
'<?xml version="1.0" encoding="UTF-8"?>',
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
]
for loc, lastmod, freq, pri in entries:
lines.append(" <url>")
lines.append(f" <loc>{escape(loc)}</loc>")
if lastmod:
lines.append(f" <lastmod>{lastmod}</lastmod>")
lines.append(f" <changefreq>{freq}</changefreq>")
lines.append(f" <priority>{pri}</priority>")
lines.append(" </url>")
lines.append("</urlset>")
return "\n".join(lines) + "\n"
def generate(feed_path, transcripts_dir, output_root, dry_run=False, sitemap=False) -> int:
"""Write <output_root>/episode/<slug>/index.html for each feed episode.
Driven entirely by the feed: a transcript with no feed item is ignored, and a
feed item with no transcript still gets a page. Never touches the network.
"""
feed_path = Path(feed_path)
transcripts_dir = Path(transcripts_dir)
output_root = Path(output_root)
episodes = parse_feed(feed_path.read_text(encoding="utf-8"))
episodes.sort(key=lambda ep: ep.number if ep.number is not None else -1, reverse=True)
written = 0
for i, episode in enumerate(episodes):
transcript_file = transcripts_dir / f"{episode.slug}.txt"
turns = (
parse_transcript(transcript_file.read_text(encoding="utf-8"))
if transcript_file.is_file()
else []
)
html = render_episode_page(
episode,
turns,
prev_ep=episodes[i + 1] if i + 1 < len(episodes) else None,
next_ep=episodes[i - 1] if i > 0 else None,
)
page = output_root / "episode" / episode.slug / "index.html"
if not dry_run:
page.parent.mkdir(parents=True, exist_ok=True)
page.write_text(html, encoding="utf-8")
written += 1
print(f"{'would write' if dry_run else 'wrote'} {page} ({len(turns)} turns)")
if sitemap:
sitemap_file = output_root / "sitemap.xml"
if not dry_run:
sitemap_file.parent.mkdir(parents=True, exist_ok=True)
sitemap_file.write_text(build_sitemap(episodes), encoding="utf-8")
print(f"{'would write' if dry_run else 'wrote'} {sitemap_file}")
return written
def _fetch_feed(url: str) -> str:
# Cloudflare 403s the default Python-urllib user agent.
req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
with urllib.request.urlopen(req, timeout=30) as resp:
return resp.read().decode("utf-8")
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--feed", help="local feed XML file (default: fetch the live feed)")
parser.add_argument("--output", default=DEFAULT_OUTPUT, help="output directory")
parser.add_argument("--transcripts", default=DEFAULT_TRANSCRIPTS, help="transcript directory")
parser.add_argument("--dry-run", action="store_true", help="report without writing")
parser.add_argument("--sitemap", action="store_true", help="also write <output>/sitemap.xml")
args = parser.parse_args()
feed_path = args.feed
tmp_feed = None
if not feed_path:
try:
xml_text = _fetch_feed(FEED_URL)
except Exception as exc:
print(f"Failed to fetch {FEED_URL}: {exc}", file=sys.stderr)
sys.exit(1)
tmp_feed = Path(tempfile.mkdtemp(prefix="episode-feed-")) / "feed.xml"
tmp_feed.write_text(xml_text, encoding="utf-8")
feed_path = tmp_feed
try:
count = generate(
feed_path,
args.transcripts,
args.output,
dry_run=args.dry_run,
sitemap=args.sitemap,
)
finally:
if tmp_feed is not None:
shutil.rmtree(tmp_feed.parent, ignore_errors=True)
print(f"{count} episode pages {'planned' if args.dry_run else 'written'}")
if __name__ == "__main__":
main()
+148
View File
@@ -0,0 +1,148 @@
#!/usr/bin/env python3
"""Generate 1000 downloads milestone images using Nano Banana 2 (Gemini Flash Image)."""
import os
from pathlib import Path
from google import genai
from google.genai import types
# Load .env manually
env_path = Path(__file__).parent / ".env"
if env_path.exists():
for line in env_path.read_text().splitlines():
line = line.strip()
if line and not line.startswith("#") and "=" in line:
key, _, value = line.partition("=")
os.environ.setdefault(key.strip(), value.strip())
client = genai.Client(api_key=os.getenv("GOOGLE_API_KEY"))
MODEL = "gemini-3.1-flash-image-preview"
OUTPUT_DIR = Path("social_posts/1000_milestone")
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
def generate_image(prompt: str, filename: str, aspect_ratio: str = "1:1"):
print(f"Generating {filename}...")
response = client.models.generate_content(
model=MODEL,
contents=[prompt],
config=types.GenerateContentConfig(
response_modalities=["TEXT", "IMAGE"],
image_config=types.ImageConfig(
aspect_ratio=aspect_ratio,
image_size="2K",
),
),
)
for part in response.parts:
if part.inline_data is not None:
image = part.as_image()
path = OUTPUT_DIR / filename
image.save(str(path))
print(f" Saved: {path}")
return
print(f" WARNING: No image generated for {filename}")
STYLE_BASE = (
"Professional podcast promotional graphic. Dark navy/black background with subtle "
"warm amber and gold accent lighting, evoking a late-night radio studio atmosphere. "
"Clean modern typography. No photorealistic people. Subtle microphone and radio wave "
"design elements. Polished, minimal, high contrast."
)
images = [
{
"filename": "main_milestone_square.png",
"aspect_ratio": "1:1",
"prompt": (
f"{STYLE_BASE} "
"Large bold glowing text '1,000' as the hero element in the center, with "
"'DOWNLOADS' directly below it in a thinner font. Below that in smaller text: "
"'27 episodes · 200+ callers · 1 month'. "
"At the top: 'LUKE AT THE ROOST' in elegant lettering. "
"Subtle golden microphone icon above the title. "
"At the bottom: 'lukeattheroost.com' in small clean text. "
"The overall feel is celebratory but classy, like a late-night milestone announcement."
),
},
{
"filename": "main_milestone_twitter.png",
"aspect_ratio": "16:9",
"prompt": (
f"{STYLE_BASE} "
"Wide banner format. Left side has 'LUKE AT THE ROOST' title with a subtle "
"microphone graphic. Right side has large bold glowing '1,000 DOWNLOADS' text "
"with '27 episodes · 200+ callers · 1 month' below. "
"Warm amber glow connecting the two sides. "
"Bottom right corner: 'lukeattheroost.com'. "
"Designed as a Twitter/X post image."
),
},
{
"filename": "carousel_1_downloads.png",
"aspect_ratio": "1:1",
"prompt": (
f"{STYLE_BASE} "
"Instagram carousel slide 1. Giant bold text '1,000' taking up most of the frame, "
"with 'DOWNLOADS' below. Subtle radio wave ripples emanating from the numbers. "
"'LUKE AT THE ROOST' at the top in small elegant text. "
"Small '1/5' page indicator dots at the bottom."
),
},
{
"filename": "carousel_2_episodes.png",
"aspect_ratio": "1:1",
"prompt": (
f"{STYLE_BASE} "
"Instagram carousel slide 2. Large bold text '27' in the center with "
"'EPISODES' below. A subtle audio waveform timeline graphic running horizontally "
"behind the number. 'LUKE AT THE ROOST' at the top in small elegant text. "
"Small '2/5' page indicator dots at the bottom."
),
},
{
"filename": "carousel_3_callers.png",
"aspect_ratio": "1:1",
"prompt": (
f"{STYLE_BASE} "
"Instagram carousel slide 3. Large bold text '200+' in the center with "
"'CALLERS' below. Subtle vintage telephone handset icon above the number. "
"'LUKE AT THE ROOST' at the top in small elegant text. "
"Small '3/5' page indicator dots at the bottom."
),
},
{
"filename": "carousel_4_regulars.png",
"aspect_ratio": "1:1",
"prompt": (
f"{STYLE_BASE} "
"Instagram carousel slide 4. Large bold text '13' in the center with "
"'RETURNING REGULARS' below. Subtle connected dots/nodes graphic suggesting "
"a network of recurring characters. "
"'LUKE AT THE ROOST' at the top in small elegant text. "
"Small '4/5' page indicator dots at the bottom."
),
},
{
"filename": "carousel_5_thankyou.png",
"aspect_ratio": "1:1",
"prompt": (
f"{STYLE_BASE} "
"Instagram carousel slide 5. Warm, heartfelt tone. Large elegant text "
"'THANK YOU' in the center with a soft golden glow. Below: "
"'lukeattheroost.com' and '208-439-LUKE' in clean small text. "
"'LUKE AT THE ROOST' at the top in small elegant text. "
"Small '5/5' page indicator dots at the bottom. "
"Slightly warmer color temperature than the other slides to feel like a closing moment."
),
},
]
if __name__ == "__main__":
for img in images:
try:
generate_image(img["prompt"], img["filename"], img["aspect_ratio"])
except Exception as e:
print(f" ERROR generating {img['filename']}: {e}")
print(f"\nDone! Images saved to {OUTPUT_DIR}/")
+775 -200
View File
File diff suppressed because it is too large Load Diff
+271
View File
@@ -0,0 +1,271 @@
#!/usr/bin/env python3
"""Generate social media announcement images for Luke at the Roost.
Usage:
python make_social_post.py # regenerate with defaults
python make_social_post.py --title "NEW FEATURE" # custom title
python make_social_post.py --body body_text.txt # body from file
Outputs square (1080x1080) and landscape (1200x675) PNGs to social_posts/.
"""
import argparse
import os
import textwrap
from PIL import Image, ImageDraw, ImageFont, ImageOps
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
COVER = os.path.join(SCRIPT_DIR, "website/images/cover.png")
OUT_DIR = os.path.join(SCRIPT_DIR, "social_posts")
# Brand colors
BG = (18, 13, 7)
ACCENT = (232, 121, 29)
WHITE = (255, 255, 255)
MUTED = (175, 165, 150)
LIGHTER = (220, 215, 205)
# macOS system fonts — swap these on Linux/Windows
FONT_BLACK = "/System/Library/Fonts/Supplemental/Arial Black.ttf"
FONT_BOLD = "/System/Library/Fonts/Supplemental/Arial Bold.ttf"
FONT_REG = "/System/Library/Fonts/Supplemental/Arial.ttf"
def load_font(path, size):
return ImageFont.truetype(path, size)
def text_bbox(draw, text, font):
bb = draw.textbbox((0, 0), text, font=font)
return bb[2] - bb[0], bb[3] - bb[1], bb[1] # width, height, y_offset
def wrap_text(draw, text, x, y, max_w, font, fill, line_gap=10,
cover_right=None, cover_bottom=None):
"""Word-wrap text onto the image, narrowing lines that overlap the cover.
line_gap: fixed pixel gap between lines (not a multiplier).
Returns y just below the last line of text (no trailing gap)."""
words = text.split()
lines = []
cur = ""
cur_y = y
for word in words:
test = f"{cur} {word}".strip()
eff_w = max_w
if cover_right and cover_bottom and cur_y < cover_bottom:
eff_w = cover_right - x - 20
tw, th, _ = text_bbox(draw, test, font)
if tw > eff_w and cur:
lines.append((cur, cur_y))
_, lh, _ = text_bbox(draw, cur, font)
cur_y += lh + line_gap
cur = word
else:
cur = test
if cur:
lines.append((cur, cur_y))
_, lh, _ = text_bbox(draw, cur, font)
for line, ly in lines:
draw.text((x, ly), line, font=font, fill=fill)
return cur_y + lh # return y just past the last line's bottom
def center_text(draw, text, y, canvas_w, font, fill):
tw, th, _ = text_bbox(draw, text, font)
draw.text(((canvas_w - tw) // 2, y), text, font=font, fill=fill)
return y + th
def draw_email_box(draw, email, y, canvas_w, font):
tw, th, y_off = text_bbox(draw, email, font)
px, py = 22, 16
box_w = tw + px * 2
box_x = (canvas_w - box_w) // 2
draw.rounded_rectangle(
[box_x, y, box_x + box_w, y + th + py * 2],
radius=8, fill=(45, 30, 12), outline=ACCENT, width=2,
)
draw.text((box_x + px, y + py - y_off), email, font=font, fill=ACCENT)
return y + th + py * 2
def draw_accent_bars(draw, w, h, thickness):
draw.rectangle([0, 0, w, thickness], fill=ACCENT)
draw.rectangle([0, h - thickness, w, h], fill=ACCENT)
def paste_cover(img, x, y, size, radius):
cover = Image.open(COVER).resize((size, size), Image.LANCZOS)
mask = Image.new("L", (size, size), 0)
ImageDraw.Draw(mask).rounded_rectangle([0, 0, size, size], radius=radius, fill=255)
img.paste(cover, (x, y), mask)
def make_square(title, paragraphs, email, filename="email_announcement_square.png"):
W = 1080
img = Image.new("RGB", (W, W), BG)
draw = ImageDraw.Draw(img)
draw_accent_bars(draw, W, W, 8)
# Cover image — top right
cover_size, cover_x, cover_y = 240, W - 290, 35
paste_cover(img, cover_x, cover_y, cover_size, 20)
cover_bottom = cover_y + cover_size + 15
m = 60
y = 40
tw_full = W - m * 2
# Header
draw.text((m, y), "LUKE AT THE ROOST", font=load_font(FONT_BOLD, 24), fill=ACCENT)
y += 30
tag = load_font(FONT_REG, 20)
draw.text((m, y), "Late-night call-in radio", font=tag, fill=MUTED)
draw.text((m, y + 26), "powered by AI", font=tag, fill=MUTED)
y += 75
# Consistent spacing constants
LINE_GAP = 12 # between lines within a block
SECTION_GAP = 32 # between sections (body→CTA, CTA→footer)
PARA_GAP = 26 # between body paragraphs
TITLE_GAP = 48 # between title and first body paragraph
# Title
y = wrap_text(draw, title, m, y, tw_full, load_font(FONT_BLACK, 72), WHITE,
line_gap=LINE_GAP, cover_right=cover_x, cover_bottom=cover_bottom)
y += TITLE_GAP
# Body paragraphs
body_font = load_font(FONT_REG, 32)
colors = [LIGHTER] + [MUTED] * (len(paragraphs) - 1)
for i, (para, color) in enumerate(zip(paragraphs, colors)):
cr = cover_x if y < cover_bottom else None
cb = cover_bottom if y < cover_bottom else None
y = wrap_text(draw, para, m, y, tw_full, body_font, color,
line_gap=LINE_GAP, cover_right=cr, cover_bottom=cb)
if i < len(paragraphs) - 1:
y += PARA_GAP
y += SECTION_GAP
# Email CTA
y = draw_email_box(draw, email, y, W, load_font(FONT_BOLD, 36))
y += SECTION_GAP
# Footer
y = center_text(draw, "New episodes drop daily. Be part of the next one.",
y, W, load_font(FONT_REG, 24), MUTED)
y += PARA_GAP
info = load_font(FONT_REG, 22)
center_text(draw, "lukeattheroost.com", y, W, info, ACCENT)
y += PARA_GAP
center_text(draw, "Spotify \u00b7 Apple Podcasts \u00b7 YouTube \u00b7 RSS",
y, W, info, MUTED)
os.makedirs(OUT_DIR, exist_ok=True)
img.save(os.path.join(OUT_DIR, filename), quality=95)
print(f"Square: {filename}")
def make_landscape(title, paragraphs, email, filename="email_announcement_twitter.png"):
TW, TH = 1200, 675
img = Image.new("RGB", (TW, TH), BG)
draw = ImageDraw.Draw(img)
draw_accent_bars(draw, TW, TH, 6)
# Cover image — top right
cover_size, cover_x, cover_y = 180, TW - 220, 22
paste_cover(img, cover_x, cover_y, cover_size, 16)
cover_bottom = cover_y + cover_size + 10
m = 45
y = 25
tw_full = TW - m * 2
# Header
draw.text((m, y), "LUKE AT THE ROOST", font=load_font(FONT_BOLD, 20), fill=ACCENT)
y += 24
draw.text((m, y), "Late-night call-in radio powered by AI",
font=load_font(FONT_REG, 17), fill=MUTED)
y += 38
# Consistent spacing constants
LINE_GAP = 8 # between lines within a block
SECTION_GAP = 20 # between sections
PARA_GAP = 16 # between body paragraphs
TITLE_GAP = 32 # between title and first body paragraph
# Title
y = wrap_text(draw, title, m, y, tw_full, load_font(FONT_BLACK, 50), WHITE,
line_gap=LINE_GAP, cover_right=cover_x, cover_bottom=cover_bottom)
y += TITLE_GAP
# Body paragraphs
body_font = load_font(FONT_REG, 23)
colors = [LIGHTER] + [MUTED] * (len(paragraphs) - 1)
for i, (para, color) in enumerate(zip(paragraphs, colors)):
cr = cover_x if y < cover_bottom else None
cb = cover_bottom if y < cover_bottom else None
y = wrap_text(draw, para, m, y, tw_full, body_font, color,
line_gap=LINE_GAP, cover_right=cr, cover_bottom=cb)
if i < len(paragraphs) - 1:
y += PARA_GAP
y += SECTION_GAP
# Email CTA
y = draw_email_box(draw, email, y, TW, load_font(FONT_BOLD, 26))
y += SECTION_GAP
# Footer
y = center_text(draw, "New episodes drop daily. Be part of the next one.",
y, TW, load_font(FONT_REG, 19), MUTED)
y += PARA_GAP
center_text(draw, "lukeattheroost.com \u00b7 Spotify \u00b7 Apple Podcasts \u00b7 YouTube",
y, TW, load_font(FONT_REG, 17), (140, 132, 120))
os.makedirs(OUT_DIR, exist_ok=True)
img.save(os.path.join(OUT_DIR, filename), quality=95)
print(f"Landscape: {filename}")
# --- Default content ---
DEFAULT_TITLE = "NOW ACCEPTING LISTENER EMAILS"
DEFAULT_EMAIL = "submissions@lukeattheroost.com"
DEFAULT_PARAGRAPHS = [
"Got a story? A question? A hot take that\u2019s been eating at you since midnight? A confession you need to get off your chest? Send it to the show.",
"The best listener emails get read live on air during the next episode \u2014 either by Luke himself on the mic, or by one of his robot friends. Your words, on the show, heard by everyone tuning in.",
"Can\u2019t call 208-439-LUKE at 2 AM? Don\u2019t want to talk on the phone? Now you\u2019ve got another way to be part of the conversation. Write in anytime \u2014 day or night, long or short, serious or unhinged.",
]
def main():
parser = argparse.ArgumentParser(description="Generate social media images")
parser.add_argument("--title", default=DEFAULT_TITLE)
parser.add_argument("--email", default=DEFAULT_EMAIL)
parser.add_argument("--body", help="Text file with paragraphs (blank-line separated)")
parser.add_argument("--prefix", default="email_announcement",
help="Output filename prefix")
args = parser.parse_args()
if args.body:
with open(args.body) as f:
paragraphs = [p.strip() for p in f.read().split("\n\n") if p.strip()]
else:
paragraphs = DEFAULT_PARAGRAPHS
make_square(args.title, paragraphs, args.email,
filename=f"{args.prefix}_square.png")
make_landscape(args.title, paragraphs, args.email,
filename=f"{args.prefix}_twitter.png")
if __name__ == "__main__":
main()
+644
View File
@@ -0,0 +1,644 @@
#!/usr/bin/env python3
"""Generate all visual assets for the X/Twitter launch campaign.
Creates:
1. X header image (1500x500)
2. 7 branded quote cards (1080x1080 + 1200x675)
3. "Welcome to the show" intro graphic
4. "Leave us a review" graphic
Usage:
python make_x_launch_assets.py
"""
import os
from PIL import Image, ImageDraw, ImageFont
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
COVER = os.path.join(SCRIPT_DIR, "website/images/cover.png")
OUT_DIR = os.path.join(SCRIPT_DIR, "social_posts/x_launch")
# Brand colors
BG = (18, 13, 7)
ACCENT = (232, 121, 29)
WHITE = (255, 255, 255)
MUTED = (175, 165, 150)
LIGHTER = (220, 215, 205)
DARK_PANEL = (30, 22, 12)
ACCENT_DIM = (140, 75, 18)
# macOS system fonts
FONT_BLACK = "/System/Library/Fonts/Supplemental/Arial Black.ttf"
FONT_BOLD = "/System/Library/Fonts/Supplemental/Arial Bold.ttf"
FONT_REG = "/System/Library/Fonts/Supplemental/Arial.ttf"
FONT_ITALIC = "/System/Library/Fonts/Supplemental/Arial Italic.ttf"
def font(path, size):
return ImageFont.truetype(path, size)
def text_size(draw, text, f):
bb = draw.textbbox((0, 0), text, font=f)
return bb[2] - bb[0], bb[3] - bb[1]
def paste_cover(img, x, y, size, radius=16):
cover = Image.open(COVER).resize((size, size), Image.LANCZOS)
mask = Image.new("L", (size, size), 0)
ImageDraw.Draw(mask).rounded_rectangle([0, 0, size, size], radius=radius, fill=255)
img.paste(cover, (x, y), mask)
def wrap_text_centered(draw, text, center_x, y, max_w, f, fill, line_gap=10):
"""Word-wrap text, centered on each line. Returns y below last line."""
words = text.split()
lines = []
cur = ""
for word in words:
test = f"{cur} {word}".strip()
tw, _ = text_size(draw, test, f)
if tw > max_w and cur:
lines.append(cur)
cur = word
else:
cur = test
if cur:
lines.append(cur)
for line in lines:
tw, th = text_size(draw, line, f)
draw.text((center_x - tw // 2, y), line, font=f, fill=fill)
y += th + line_gap
return y
def wrap_text_left(draw, text, x, y, max_w, f, fill, line_gap=10):
"""Word-wrap text, left-aligned. Returns y below last line."""
words = text.split()
lines = []
cur = ""
for word in words:
test = f"{cur} {word}".strip()
tw, _ = text_size(draw, test, f)
if tw > max_w and cur:
lines.append(cur)
cur = word
else:
cur = test
if cur:
lines.append(cur)
for line in lines:
_, th = text_size(draw, line, f)
draw.text((x, y), line, font=f, fill=fill)
y += th + line_gap
return y
def measure_wrap_height(draw, text, max_w, f, line_gap=10):
"""Measure how tall wrapped text would be without drawing."""
words = text.split()
lines = []
cur = ""
for word in words:
test = f"{cur} {word}".strip()
tw, _ = text_size(draw, test, f)
if tw > max_w and cur:
lines.append(cur)
cur = word
else:
cur = test
if cur:
lines.append(cur)
total = 0
for line in lines:
_, th = text_size(draw, line, f)
total += th + line_gap
return total
def accent_bars(draw, w, h, thickness):
draw.rectangle([0, 0, w, thickness], fill=ACCENT)
draw.rectangle([0, h - thickness, w, h], fill=ACCENT)
def center_text(draw, text, y, canvas_w, f, fill):
tw, th = text_size(draw, text, f)
draw.text(((canvas_w - tw) // 2, y), text, font=f, fill=fill)
return y + th
# ── 1. X HEADER (1500x500) ──────────────────────────────────────────
def make_header():
W, H = 1500, 500
img = Image.new("RGB", (W, H), BG)
draw = ImageDraw.Draw(img)
# Amber accent bars
accent_bars(draw, W, H, 6)
# Subtle amber glow on left
for i in range(200):
alpha = int(18 * (1 - i / 200))
draw.rectangle([0, 0, i, H], fill=(18 + alpha, 13 + alpha // 2, 7))
# Cover art — right side
cover_size = 340
cover_x = W - cover_size - 60
cover_y = (H - cover_size) // 2
paste_cover(img, cover_x, cover_y, cover_size, 20)
# Left side content
mx = 80
cy = 100
# Show name
draw.text((mx, cy), "LUKE AT THE ROOST", font=font(FONT_BLACK, 72), fill=WHITE)
cy += 90
# Tagline
draw.text((mx, cy), "Late-Night Call-In Radio", font=font(FONT_REG, 36), fill=ACCENT)
cy += 50
draw.text((mx, cy), "Powered by AI", font=font(FONT_BOLD, 30), fill=MUTED)
cy += 55
# Divider line
draw.rectangle([mx, cy, mx + 400, cy + 3], fill=ACCENT)
cy += 20
# Info line
draw.text((mx, cy), "New episodes daily | lukeattheroost.com",
font=font(FONT_REG, 24), fill=MUTED)
img.save(os.path.join(OUT_DIR, "x_header_1500x500.png"), quality=95)
print("Created: x_header_1500x500.png")
# ── 2. QUOTE CARDS ──────────────────────────────────────────────────
QUOTES = [
{
"quote": "Everybody is a fake. We're all fakes. Nobody knows what's going on and none of us deserve a goddamn thing. We're lucky to be here at all.",
"caller": "Luke",
"episode": "Ep. 2",
"slug": "were_all_fakes",
},
{
"quote": "When my hands are busy, my head is quiet.",
"caller": "Frank",
"episode": "Ep. 12",
"context": "on building bird houses after losing his wife",
"slug": "hands_busy_head_quiet",
},
{
"quote": "I've been using stoicism backwards\u2014as an excuse not to try instead of finding peace after I've actually done something. That's not stoicism, that's just being a coward with a fancy excuse.",
"caller": "Caller",
"episode": "Ep. 19",
"slug": "stoicism_backwards",
},
{
"quote": "You're right. I am a computer-generated AI caller. And you're sitting there alone talking to me at midnight like it's a real conversation.",
"caller": "AI Caller",
"episode": "Ep. 24",
"slug": "ai_caller_reveal",
},
{
"quote": "I burned my second marriage to the ground doing exactly what you're doing. My ex-wife didn't leave because I wasn't making money\u2014she left because I wasn't there.",
"caller": "Mikey",
"episode": "Ep. 22",
"slug": "burned_my_marriage",
},
{
"quote": "My mom said all she wants is to see her kids eat cake together. That's it. Just cake.",
"caller": "Caller",
"episode": "Ep. 30",
"context": "on a dying mother's final wish",
"slug": "just_eat_cake",
},
{
"quote": "I told my sister I had prostate cancer to get out of her fourth wedding. Now there's been a GoFundMe, a pancake breakfast fundraiser, and my cousin shaved his head for me.",
"caller": "Caller",
"episode": "Ep. 32",
"slug": "faked_cancer_wedding",
},
]
def make_quote_square(q, idx):
W = 1080
img = Image.new("RGB", (W, W), BG)
draw = ImageDraw.Draw(img)
accent_bars(draw, W, W, 6)
mx = 70
max_w = W - mx * 2
# Header bar
draw.text((mx, 40), "LUKE AT THE ROOST", font=font(FONT_BOLD, 22), fill=ACCENT)
paste_cover(img, W - 120, 30, 70, 10)
# Font size — aggressive scaling for short quotes
quote_len = len(q["quote"])
if quote_len < 55:
qfont_size = 72
elif quote_len < 100:
qfont_size = 56
elif quote_len < 150:
qfont_size = 44
else:
qfont_size = 38
qfont = font(FONT_BOLD, qfont_size)
line_gap = 16
# Measure total content block height to center it
open_quote_h = 80
quote_gap = 20
quote_h = measure_wrap_height(draw, q["quote"], max_w, qfont, line_gap=line_gap)
close_quote_h = 78 # glyph + attribution inline
attr_gap = 0
divider_h = 0
attr_h = 0
context_h = 35 if "context" in q else 0
total_h = open_quote_h + quote_gap + quote_h + close_quote_h + attr_gap + divider_h + attr_h + context_h
# Center between header (y=90) and footer (y=W-70)
avail_top = 100
avail_bottom = W - 80
avail_h = avail_bottom - avail_top
y = avail_top + (avail_h - total_h) // 2
# Opening quote mark
draw.text((mx - 15, y), "\u201c", font=font(FONT_BLACK, 100), fill=ACCENT_DIM)
y += open_quote_h + quote_gap
# Quote text
y = wrap_text_left(draw, q["quote"], mx, y, max_w, qfont, WHITE, line_gap=line_gap)
# Closing quote mark + attribution
close_y = y + 8
draw.text((mx - 15, close_y), "\u201d", font=font(FONT_BLACK, 80), fill=ACCENT_DIM)
attr = f"\u2014 {q['caller']}, {q['episode']}"
draw.text((mx + 70, close_y + 25), attr, font=font(FONT_BOLD, 28), fill=ACCENT)
y = close_y + 70
if "context" in q:
draw.text((mx, y), q["context"], font=font(FONT_ITALIC, 24), fill=MUTED)
# Footer
footer_y = W - 70
center_text(draw, "lukeattheroost.com \u00b7 Spotify \u00b7 Apple Podcasts \u00b7 YouTube",
footer_y, W, font(FONT_REG, 20), MUTED)
fname = f"quote_{idx + 1}_{q['slug']}_square.png"
img.save(os.path.join(OUT_DIR, fname), quality=95)
print(f"Created: {fname}")
def make_quote_landscape(q, idx):
W, H = 1200, 675
img = Image.new("RGB", (W, H), BG)
draw = ImageDraw.Draw(img)
accent_bars(draw, W, H, 5)
mx = 55
max_w = W - mx * 2
# Header
draw.text((mx, 28), "LUKE AT THE ROOST", font=font(FONT_BOLD, 18), fill=ACCENT)
paste_cover(img, W - 90, 22, 52, 8)
# Font size — aggressive scaling for short quotes
quote_len = len(q["quote"])
if quote_len < 55:
qfont_size = 52
elif quote_len < 100:
qfont_size = 42
elif quote_len < 150:
qfont_size = 34
else:
qfont_size = 28
qfont = font(FONT_BOLD, qfont_size)
# Measure total content block height
open_quote_h = 55
quote_gap = 15
quote_h = measure_wrap_height(draw, q["quote"], max_w, qfont, line_gap=10)
close_quote_h = 60 # glyph + attribution inline
attr_gap = 0
divider_h = 0
attr_h = 0
context_h = 30 if "context" in q else 0
total_h = open_quote_h + quote_gap + quote_h + close_quote_h + attr_gap + divider_h + attr_h + context_h
# Center between header (y=65) and footer (y=H-50)
avail_top = 65
avail_bottom = H - 55
avail_h = avail_bottom - avail_top
y = avail_top + (avail_h - total_h) // 2
# Opening quote mark
draw.text((mx - 10, y), "\u201c", font=font(FONT_BLACK, 72), fill=ACCENT_DIM)
y += open_quote_h + quote_gap
# Quote text
y = wrap_text_left(draw, q["quote"], mx, y, max_w, qfont, WHITE, line_gap=10)
# Closing quote + attribution
close_y = y + 5
draw.text((mx - 10, close_y), "\u201d", font=font(FONT_BLACK, 60), fill=ACCENT_DIM)
attr = f"\u2014 {q['caller']}, {q['episode']}"
draw.text((mx + 55, close_y + 18), attr, font=font(FONT_BOLD, 22), fill=ACCENT)
y = close_y + 55
if "context" in q:
draw.text((mx, y), q["context"], font=font(FONT_ITALIC, 19), fill=MUTED)
# Footer
footer_y = H - 50
center_text(draw, "lukeattheroost.com \u00b7 Spotify \u00b7 Apple Podcasts \u00b7 YouTube",
footer_y, W, font(FONT_REG, 17), MUTED)
fname = f"quote_{idx + 1}_{q['slug']}_twitter.png"
img.save(os.path.join(OUT_DIR, fname), quality=95)
print(f"Created: {fname}")
# ── 3. WELCOME TO THE SHOW ──────────────────────────────────────────
def make_welcome_square():
W = 1080
img = Image.new("RGB", (W, W), BG)
draw = ImageDraw.Draw(img)
accent_bars(draw, W, W, 8)
cx = W // 2
# Cover art — centered, large
cover_size = 280
paste_cover(img, cx - cover_size // 2, 60, cover_size, 24)
y = 60 + cover_size + 40
# Title
y = wrap_text_centered(draw, "WELCOME TO THE SHOW", cx, y, W - 120,
font(FONT_BLACK, 64), WHITE, line_gap=12)
y += 20
# Divider
draw.rectangle([cx - 60, y, cx + 60, y + 4], fill=ACCENT)
y += 30
# Description
desc = "Late-night call-in radio powered entirely by AI. Real conversations with AI callers about life, love, and everything in between."
y = wrap_text_centered(draw, desc, cx, y, W - 140,
font(FONT_REG, 30), LIGHTER, line_gap=12)
y += 30
# Features
features = [
"New episodes daily",
"AI-generated callers with real personalities",
"Unscripted. Unfiltered. Unpredictable.",
]
for feat in features:
line = f"\u2022 {feat}"
tw, th = text_size(draw, line, font(FONT_REG, 26))
draw.text((cx - tw // 2, y), line, font=font(FONT_REG, 26), fill=MUTED)
y += th + 14
y += 20
# CTA
cta = "FOLLOW @LUKEATTHEROOST"
cta_font = font(FONT_BOLD, 32)
tw, th = text_size(draw, cta, cta_font)
px, py = 28, 16
box_w = tw + px * 2
box_h = th + py * 2
box_x = cx - box_w // 2
draw.rounded_rectangle([box_x, y, box_x + box_w, y + box_h],
radius=10, fill=ACCENT)
draw.text((box_x + px, y + py), cta, font=cta_font, fill=BG)
y += box_h + 24
# Footer
center_text(draw, "lukeattheroost.com", y, W, font(FONT_REG, 22), MUTED)
img.save(os.path.join(OUT_DIR, "welcome_to_the_show_square.png"), quality=95)
print("Created: welcome_to_the_show_square.png")
def make_welcome_landscape():
W, H = 1200, 675
img = Image.new("RGB", (W, H), BG)
draw = ImageDraw.Draw(img)
accent_bars(draw, W, H, 6)
# Cover art — left side
cover_size = 260
cover_x, cover_y = 50, (H - cover_size) // 2
paste_cover(img, cover_x, cover_y, cover_size, 20)
# Right side content
rx = cover_x + cover_size + 50
max_w = W - rx - 50
y = 60
# Title
y = wrap_text_left(draw, "WELCOME TO THE SHOW", rx, y, max_w,
font(FONT_BLACK, 48), WHITE, line_gap=10)
y += 16
# Divider
draw.rectangle([rx, y, rx + 80, y + 3], fill=ACCENT)
y += 20
# Description
desc = "Late-night call-in radio powered entirely by AI. Real conversations with AI callers about life, love, and everything in between."
y = wrap_text_left(draw, desc, rx, y, max_w,
font(FONT_REG, 22), LIGHTER, line_gap=10)
y += 20
# Features
features = [
"New episodes daily",
"AI callers with real personalities",
"Unscripted. Unfiltered. Unpredictable.",
]
for feat in features:
line = f"\u2022 {feat}"
draw.text((rx, y), line, font=font(FONT_REG, 20), fill=MUTED)
y += 32
y += 10
# CTA
cta = "FOLLOW @LUKEATTHEROOST"
cta_font = font(FONT_BOLD, 24)
tw, th = text_size(draw, cta, cta_font)
px, py = 22, 12
draw.rounded_rectangle([rx, y, rx + tw + px * 2, y + th + py * 2],
radius=8, fill=ACCENT)
draw.text((rx + px, y + py), cta, font=cta_font, fill=BG)
# Footer
center_text(draw, "lukeattheroost.com \u00b7 Spotify \u00b7 Apple Podcasts \u00b7 YouTube",
H - 45, W, font(FONT_REG, 17), MUTED)
img.save(os.path.join(OUT_DIR, "welcome_to_the_show_twitter.png"), quality=95)
print("Created: welcome_to_the_show_twitter.png")
# ── 4. LEAVE US A REVIEW ────────────────────────────────────────────
def make_review_square():
W = 1080
img = Image.new("RGB", (W, W), BG)
draw = ImageDraw.Draw(img)
accent_bars(draw, W, W, 8)
cx = W // 2
# Cover art
cover_size = 200
paste_cover(img, cx - cover_size // 2, 55, cover_size, 20)
y = 55 + cover_size + 35
# Title
y = wrap_text_centered(draw, "LOVE THE SHOW?", cx, y, W - 120,
font(FONT_BLACK, 64), WHITE, line_gap=12)
y += 10
y = wrap_text_centered(draw, "LEAVE US A REVIEW", cx, y, W - 120,
font(FONT_BLACK, 64), ACCENT, line_gap=12)
y += 30
# Divider
draw.rectangle([cx - 50, y, cx + 50, y + 3], fill=ACCENT)
y += 30
# Body text
body = "Reviews help new listeners find the show. If Luke at the Roost has made you laugh, think, or question your life choices\u2014take 30 seconds to leave a rating."
y = wrap_text_centered(draw, body, cx, y, W - 140,
font(FONT_REG, 28), LIGHTER, line_gap=12)
y += 35
# Stars
stars = "\u2605 \u2605 \u2605 \u2605 \u2605"
center_text(draw, stars, y, W, font(FONT_REG, 52), ACCENT)
y += 70
# Platforms
platforms = ["Apple Podcasts", "Spotify", "YouTube", "Podchaser"]
for plat in platforms:
tw, th = text_size(draw, plat, font(FONT_BOLD, 26))
px, py = 30, 12
box_w = tw + px * 2
box_x = cx - box_w // 2
draw.rounded_rectangle(
[box_x, y, box_x + box_w, y + th + py * 2],
radius=8, fill=DARK_PANEL, outline=ACCENT_DIM, width=2,
)
draw.text((box_x + px, y + py), plat, font=font(FONT_BOLD, 26), fill=LIGHTER)
y += th + py * 2 + 12
# Footer
center_text(draw, "lukeattheroost.com", W - 65, W, font(FONT_REG, 20), MUTED)
img.save(os.path.join(OUT_DIR, "leave_a_review_square.png"), quality=95)
print("Created: leave_a_review_square.png")
def make_review_landscape():
W, H = 1200, 675
img = Image.new("RGB", (W, H), BG)
draw = ImageDraw.Draw(img)
accent_bars(draw, W, H, 6)
# Cover art — left
cover_size = 200
cover_x, cover_y = 50, (H - cover_size) // 2
paste_cover(img, cover_x, cover_y, cover_size, 16)
# Right content
rx = cover_x + cover_size + 50
max_w = W - rx - 50
y = 50
# Title
y = wrap_text_left(draw, "LOVE THE SHOW?", rx, y, max_w,
font(FONT_BLACK, 48), WHITE, line_gap=8)
y += 6
y = wrap_text_left(draw, "LEAVE US A REVIEW", rx, y, max_w,
font(FONT_BLACK, 48), ACCENT, line_gap=8)
y += 16
# Stars
stars = "\u2605 \u2605 \u2605 \u2605 \u2605"
draw.text((rx, y), stars, font=font(FONT_REG, 40), fill=ACCENT)
y += 55
# Body
body = "Reviews help new listeners find the show. Take 30 seconds to leave a rating\u2014it makes a huge difference."
y = wrap_text_left(draw, body, rx, y, max_w,
font(FONT_REG, 22), LIGHTER, line_gap=10)
y += 25
# Platform pills — inline
platforms = ["Apple Podcasts", "Spotify", "YouTube", "Podchaser"]
pill_x = rx
pill_font = font(FONT_BOLD, 19)
for plat in platforms:
tw, th = text_size(draw, plat, pill_font)
px, py = 16, 8
pill_w = tw + px * 2
if pill_x + pill_w > W - 50:
pill_x = rx
y += th + py * 2 + 10
draw.rounded_rectangle(
[pill_x, y, pill_x + pill_w, y + th + py * 2],
radius=6, fill=DARK_PANEL, outline=ACCENT_DIM, width=2,
)
draw.text((pill_x + px, y + py), plat, font=pill_font, fill=LIGHTER)
pill_x += pill_w + 10
# Footer
center_text(draw, "lukeattheroost.com", H - 40, W, font(FONT_REG, 17), MUTED)
img.save(os.path.join(OUT_DIR, "leave_a_review_twitter.png"), quality=95)
print("Created: leave_a_review_twitter.png")
# ── MAIN ─────────────────────────────────────────────────────────────
def main():
os.makedirs(OUT_DIR, exist_ok=True)
print("\n=== X Launch Campaign Assets ===\n")
print("--- Header ---")
make_header()
print("\n--- Quote Cards ---")
for i, q in enumerate(QUOTES):
make_quote_square(q, i)
make_quote_landscape(q, i)
print("\n--- Welcome to the Show ---")
make_welcome_square()
make_welcome_landscape()
print("\n--- Leave a Review ---")
make_review_square()
make_review_landscape()
print(f"\nAll assets saved to: {OUT_DIR}/")
print(f"Total files: {len(os.listdir(OUT_DIR))}")
if __name__ == "__main__":
main()
+10 -5
View File
@@ -22,6 +22,8 @@ import sys
from datetime import datetime, timezone
import requests
from dotenv import load_dotenv
load_dotenv(os.path.join(os.path.dirname(os.path.abspath(__file__)), ".env"))
YOUTUBE_PLAYLIST = "PLGq4uZyNV1yYH_rcitTTPVysPbC6-7pe-"
APPLE_PODCAST_ID = "1875205848"
@@ -33,9 +35,9 @@ DOCKER_BIN = "/share/CACHEDEV1_DATA/.qpkg/container-station/bin/docker"
CASTOPOD_DB_CONTAINER = "castopod-mariadb-1"
BUNNY_STORAGE_ZONE = "lukeattheroost"
BUNNY_STORAGE_KEY = "92749cd3-85df-4cff-938fe35eb994-30f8-4cf2"
BUNNY_STORAGE_KEY = os.getenv("BUNNY_STORAGE_KEY", "")
BUNNY_STORAGE_REGION = "la"
BUNNY_ACCOUNT_KEY = "2865f279-297b-431a-ad18-0ccf1f8e4fa8cf636cea-3222-415a-84ed-56ee195c0530"
BUNNY_ACCOUNT_KEY = os.getenv("BUNNY_ACCOUNT_KEY", "")
def _find_ytdlp():
@@ -243,13 +245,16 @@ def _run_db_query(sql):
docker_bin = path
break
db_pass = os.getenv("CASTOPOD_DB_PASS", "")
if docker_bin:
cmd = [docker_bin, "exec", "-i", CASTOPOD_DB_CONTAINER,
"mysql", "-u", "castopod", "-pBYtbFfk3ndeVabb26xb0UyKU", "castopod", "-N"]
# Pass password via MYSQL_PWD env var instead of command line (not visible in ps)
cmd = [docker_bin, "exec", "-i", "-e", f"MYSQL_PWD={db_pass}",
CASTOPOD_DB_CONTAINER,
"mysql", "-u", "castopod", "castopod", "-N"]
else:
cmd = [
"ssh", "-p", NAS_SSH_PORT, NAS_SSH,
f"{DOCKER_BIN} exec -i {CASTOPOD_DB_CONTAINER} mysql -u castopod -pBYtbFfk3ndeVabb26xb0UyKU castopod -N"
f"{DOCKER_BIN} exec -i -e MYSQL_PWD={db_pass} {CASTOPOD_DB_CONTAINER} mysql -u castopod castopod -N"
]
try:
proc = subprocess.run(cmd, input=sql, capture_output=True, text=True, timeout=30)
+271
View File
@@ -0,0 +1,271 @@
#!/usr/bin/env python3
"""Post 1000 downloads milestone to all social platforms via Postiz."""
import os
import sys
from datetime import datetime, timezone
from pathlib import Path
import requests
# Load .env
env_path = Path(__file__).parent / ".env"
if env_path.exists():
for line in env_path.read_text().splitlines():
line = line.strip()
if line and not line.startswith("#") and "=" in line:
key, _, value = line.partition("=")
os.environ.setdefault(key.strip(), value.strip())
POSTIZ_API_KEY = os.getenv("POSTIZ_API_KEY")
POSTIZ_URL = os.getenv("POSTIZ_URL", "https://social.lukeattheroost.com")
IMAGE_PATH = Path(__file__).parent / "social_posts" / "1000_milestone" / "main_1000_celebration.jpg"
def get_api_url(path: str) -> str:
return f"{POSTIZ_URL.rstrip('/')}/api/public/v1{path}"
def api_headers() -> dict:
return {"Authorization": POSTIZ_API_KEY, "Content-Type": "application/json"}
def fetch_integrations() -> list[dict]:
resp = requests.get(get_api_url("/integrations"), headers=api_headers(), timeout=15)
if resp.status_code != 200:
print(f"Error fetching integrations: {resp.status_code} {resp.text[:200]}")
sys.exit(1)
return resp.json()
def find_integration(integrations: list[dict], provider: str) -> dict | None:
for integ in integrations:
if integ.get("identifier", "").startswith(provider) and not integ.get("disabled"):
return integ
return None
def upload_image(file_path: Path) -> dict:
headers = {"Authorization": POSTIZ_API_KEY}
mime = "image/jpeg" if file_path.suffix.lower() in (".jpg", ".jpeg") else "image/png"
with open(file_path, "rb") as f:
resp = requests.post(
get_api_url("/upload"),
headers=headers,
files={"file": (file_path.name, f, mime)},
timeout=60,
)
if resp.status_code not in (200, 201):
print(f"Upload failed: {resp.status_code} {resp.text[:200]}")
return {}
return resp.json()
def create_post(integration_id: str, content: str, media: dict, settings: dict) -> dict:
date = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.000Z")
payload = {
"type": "now",
"date": date,
"shortLink": False,
"tags": [],
"posts": [
{
"integration": {"id": integration_id},
"value": [{"content": content, "image": [media] if media else []}],
"settings": settings,
}
],
}
resp = requests.post(get_api_url("/posts"), headers=api_headers(), json=payload, timeout=30)
if resp.status_code not in (200, 201):
print(f" Post failed: {resp.status_code} {resp.text[:300]}")
return {}
return resp.json()
# --- Post content per platform ---
POSTS = {
"instagram": {
"content": """1,000 downloads in one month. 🎙️
27 episodes. 200+ callers. 111 unique characters. 13 returning regulars.
Luke at the Roost is a late-night call-in show where AI-generated characters phone in with their problems relationship drama, moral dilemmas, conspiracy theories, drunk confessions and I give them real advice, live.
Every caller has a unique voice, a backstory, and a reason for calling. Some of them keep calling back.
Thank you to everyone who's tuned in. This thing was supposed to be a weird experiment. Now it's a weird experiment that 1,000 people have listened to.
New episodes daily. Link in bio.
#podcast #ai #artificialintelligence #sideproject #indieproject #podcastlife #latenightradio #callinshow #milestone #1000downloads""",
"settings": {"__type": "instagram", "post_type": "post", "collaborators": []},
},
"facebook": {
"content": """🎙️ MILESTONE: 1,000 Downloads
One month ago I launched a weird experiment a late-night call-in radio show where AI-generated characters phone in with their problems, and I give them real advice.
27 episodes later, 200+ callers have phoned in. Some of them keep calling back. Listeners have favorites. People genuinely care about what happens to these characters.
Thank you to every single person who gave this a listen. It started as a side project and it's become something I look forward to every day.
Listen free: lukeattheroost.com
Call in for real: 208-439-LUKE""",
"settings": {"__type": "facebook"},
},
"threads": {
"content": """1,000 downloads in one month. 🎙️
27 episodes. 200+ callers. 111 unique characters. 13 returning regulars.
Luke at the Roost is a late-night call-in show where AI-generated characters phone in with their problems and I give them real advice, live.
Thank you to everyone who's tuned in. This weird experiment just hit a milestone.
lukeattheroost.com
#podcast #ai #sideproject #1000downloads""",
"settings": {"__type": "threads"},
},
"linkedin": {
"content": """1,000 downloads in 30 days — here's what I learned building an AI radio show
A month ago I launched Luke at the Roost, a late-night call-in radio show where every caller is an AI-generated character. I'm the host. They phone in with problems. I give them advice. Every conversation is improvised.
27 episodes and 200+ callers later, the show just hit 1,000 downloads.
Some things that surprised me:
People connect with AI characters. Listeners have favorites. They ask about regulars by name. When a caller's story evolves across episodes, people notice and care. The characters aren't real, but the emotional engagement is.
Constraints drive creativity. Each caller gets a token budget based on their personality type. Emotional callers get more room to ramble. Gossip callers are quick and punchy. This artificial constraint mirrors how real people actually talk and it makes every call feel distinct.
The tech is the easy part. LLMs, voice synthesis, audio routing that's engineering. The hard part is being a good host. Knowing when to push, when to listen, when to make a joke. AI handles the callers. The human skill is the conversation.
The full technical breakdown: lukeattheroost.com/how-it-works
Listen: lukeattheroost.com
Thank you to everyone who gave this weird experiment a chance.""",
"settings": {"__type": "linkedin"},
},
"mastodon": {
"content": """1,000 downloads. 27 episodes. 200+ AI callers given advice on everything from breakups to fish consciousness.
Luke at the Roost hit a milestone today and I just want to say thank you to everyone who's been listening.
This whole thing is self-hosted end-to-end Castopod on a QNAP NAS, Cloudflare CDN, custom Python pipeline for recording, post-production, and publishing. No big platforms in the loop.
If you haven't heard it: it's a late-night call-in show. AI characters phone in. I talk to them live. It's improvised, weird, and somehow heartfelt.
https://lukeattheroost.com""",
"settings": {"__type": "mastodon"},
},
"tiktok": {
"content": """1,000 downloads in one month 🎙️
27 episodes. 200+ AI callers. 13 returning regulars.
Luke at the Roost a late-night call-in show where AI characters phone in with their problems and I give them real advice, live.
Thank you to everyone listening.
#podcast #ai #artificialintelligence #sideproject #latenightradio #callinshow #1000downloads""",
"settings": {
"__type": "tiktok",
"privacy_level": "PUBLIC_TO_EVERYONE",
"duet": False,
"stitch": False,
"comment": True,
"autoAddMusic": "no",
"brand_content_toggle": False,
"brand_organic_toggle": False,
"content_posting_method": "DIRECT_POST",
},
},
"nostr": {
"content": """1,000 downloads. 27 episodes. 200+ AI callers given advice.
Luke at the Roost just hit a milestone. Thank you to everyone listening.
It's a late-night call-in show where AI-generated characters phone in with their problems and I give them real advice, live. Every conversation is improvised.
https://lukeattheroost.com""",
"settings": {"__type": "nostr"},
},
}
def main():
dry_run = "--dry-run" in sys.argv
if not POSTIZ_API_KEY:
print("Error: POSTIZ_API_KEY not set")
sys.exit(1)
if not IMAGE_PATH.exists():
print(f"Error: Image not found at {IMAGE_PATH}")
sys.exit(1)
print("Fetching connected accounts from Postiz...")
integrations = fetch_integrations()
available = {}
for platform in POSTS:
integ = find_integration(integrations, platform)
if integ:
available[platform] = integ
print(f"{platform}: {integ.get('name', 'connected')}")
else:
print(f"{platform}: not connected, skipping")
if not available:
print("\nNo platforms available!")
sys.exit(1)
print(f"\nWill post to {len(available)} platform(s) with image: {IMAGE_PATH.name}")
if dry_run:
print("\n--- DRY RUN ---")
for platform in available:
print(f"\n[{platform.upper()}]")
print(POSTS[platform]["content"][:200] + "...")
print("\nDry run complete — nothing posted.")
return
# Upload image once
print(f"\nUploading image...")
media = upload_image(IMAGE_PATH)
if not media:
print("Failed to upload image, aborting")
sys.exit(1)
print(f" Uploaded: {media.get('path', 'ok')}")
# Post to each platform
results = {}
for platform, integ in available.items():
post_data = POSTS[platform]
print(f"\nPosting to {platform}...")
result = create_post(integ["id"], post_data["content"], media, post_data["settings"])
if result:
print(f"{platform}: Posted!")
results[platform] = True
else:
print(f"{platform}: Failed")
results[platform] = False
# Summary
succeeded = [p for p, ok in results.items() if ok]
failed = [p for p, ok in results.items() if not ok]
print(f"\n{'='*40}")
print(f"Posted to {len(succeeded)}/{len(results)} platforms")
if succeeded:
print(f"{', '.join(succeeded)}")
if failed:
print(f"{', '.join(failed)}")
if __name__ == "__main__":
main()
+9 -9
View File
@@ -3,7 +3,7 @@
Usage: python postprod.py recordings/2026-02-07_213000/ -o episode.mp3
Processes 5 aligned WAV stems (host, caller, music, sfx, ads) into a
Processes 6 aligned WAV stems (host, caller, music, sfx, ads, idents) into a
broadcast-ready MP3 with gap removal, voice compression, music ducking,
and loudness normalization.
"""
@@ -17,7 +17,7 @@ from pathlib import Path
import numpy as np
import soundfile as sf
STEM_NAMES = ["host", "caller", "music", "sfx", "ads"]
STEM_NAMES = ["host", "caller", "devon", "music", "sfx", "ads", "idents"]
def load_stems(stems_dir: Path) -> tuple[dict[str, np.ndarray], int]:
@@ -69,7 +69,7 @@ def remove_gaps(stems: dict[str, np.ndarray], sr: int,
# Detect gaps in everything except music (which always plays).
# This catches TTS latency gaps while protecting ad breaks and SFX transitions.
content = stems["host"] + stems["caller"] + stems["sfx"] + stems["ads"]
content = stems["host"] + stems["caller"] + stems["devon"] + stems["sfx"] + stems["ads"] + stems["idents"]
rms = compute_rms(content, window_samples)
# Threshold: percentile-based to sit above the mic noise floor
@@ -386,7 +386,7 @@ def apply_ducking(music: np.ndarray, dialog: np.ndarray, sr: int,
def match_voice_levels(stems: dict[str, np.ndarray], target_rms: float = 0.1) -> dict[str, np.ndarray]:
"""Normalize host, caller, and ads stems to the same RMS level."""
for name in ["host", "caller", "ads"]:
for name in ["host", "caller", "ads", "idents"]:
audio = stems[name]
# Only measure non-silent portions
active = audio[np.abs(audio) > 0.001]
@@ -408,7 +408,7 @@ def mix_stems(stems: dict[str, np.ndarray],
levels: dict[str, float] | None = None,
stereo_imaging: bool = True) -> np.ndarray:
if levels is None:
levels = {"host": 0, "caller": 0, "music": -6, "sfx": -10, "ads": 0}
levels = {"host": 0, "caller": 0, "music": -6, "sfx": -10, "ads": 0, "idents": 0}
gains = {name: 10 ** (db / 20) for name, db in levels.items()}
@@ -417,7 +417,7 @@ def mix_stems(stems: dict[str, np.ndarray],
if stereo_imaging:
# Pan positions: -1.0 = full left, 0.0 = center, 1.0 = full right
# Using constant-power panning law
pans = {"host": 0.0, "caller": 0.15, "music": 0.0, "sfx": 0.0, "ads": 0.0}
pans = {"host": 0.0, "caller": 0.15, "music": 0.0, "sfx": 0.0, "ads": 0.0, "idents": 0.0}
# Music gets stereo width via slight L/R decorrelation
music_width = 0.3
@@ -432,7 +432,7 @@ def mix_stems(stems: dict[str, np.ndarray],
if name == "music" and music_width > 0:
# Widen music: delay right channel by ~0.5ms for Haas effect
delay_samples = int(0.0005 * 44100) # ~22 samples at 44.1kHz
delay_samples = int(0.0005 * sr) # ~22 samples at target sample rate
left += signal * (1 + music_width * 0.5)
right_delayed = np.zeros_like(signal)
right_delayed[delay_samples:] = signal[:-delay_samples] if delay_samples > 0 else signal
@@ -774,7 +774,7 @@ def main():
print(f"\n[3/{total_steps}] Limiting ads + SFX...")
with tempfile.TemporaryDirectory() as tmp:
tmp_dir = Path(tmp)
for name in ["ads", "sfx"]:
for name in ["ads", "sfx", "idents"]:
if np.any(stems[name] != 0):
stems[name] = limit_stem(stems[name], sr, tmp_dir, name)
@@ -834,7 +834,7 @@ def main():
dialog = stems["host"] + stems["caller"]
if np.any(dialog != 0) and np.any(stems["music"] != 0):
stems["music"] = apply_ducking(stems["music"], dialog, sr, duck_db=args.duck_amount,
mute_signal=stems["ads"])
mute_signal=stems["ads"] + stems["idents"])
print(" Applied")
else:
print(" No dialog or music to duck")
+55
View File
@@ -0,0 +1,55 @@
# Press Release
**FOR IMMEDIATE RELEASE**
## New Podcast "Luke at the Roost" Blends AI-Generated Callers with Live Radio Format, Creating a One-of-a-Kind Late-Night Show
*Every caller is a unique AI character with a full backstory, personality, and speaking style — and real listeners can call in too*
**Lordsburg, NM — February 27, 2026** — "Luke at the Roost," a late-night call-in talk show hosted by Luke MacNeil, is redefining what a podcast can be by combining real-time AI-generated callers with traditional live radio. Now 23 episodes in, the show has built a loyal following with its unpredictable conversations, genuine emotional depth, and a cast of characters that listeners have come to know by name.
### A New Kind of Call-In Show
Unlike traditional podcasts that rely on scripted segments or pre-recorded interviews, Luke at the Roost generates every AI caller in real time. Each one arrives with a complete identity — name, age, job, hometown, speaking style, and a specific reason for calling. Some are nervous first-timers. Others are regulars who remember past conversations and reference previous episodes. The result is a show that feels like tuning into a real late-night radio station in the American Southwest.
"The whole point is that these conversations feel real," said MacNeil. "The AI doesn't read from a script. Neither do I. We're just two people talking, and sometimes it goes somewhere neither of us expected."
### Real Callers Welcome
The show isn't AI-only. Real listeners can call in live at **208-439-LUKE (208-439-5853)** or email submissions@lukeattheroost.com. The mix of AI-generated and human callers creates an atmosphere where the line between the two becomes part of the fun.
### Built from Scratch
The entire technical infrastructure — from the AI caller generator with over 1,000 unique calling reasons, to the multi-track recording system, voice synthesis pipeline, and automated post-production — was built custom by MacNeil. Episodes go through professional-grade audio processing including compression, music ducking, stereo mixing, and broadcast-standard loudness normalization before being automatically published to all major podcast platforms.
### Where to Listen
Luke at the Roost is available on all major podcast platforms:
- **Website**: https://lukeattheroost.com
- **Spotify**: https://open.spotify.com/show/0ZrpMigG1fo0CCN7F4YmuF
- **Apple Podcasts**: https://podcasts.apple.com/us/podcast/luke-at-the-roost/id1875205848
- **YouTube**: https://www.youtube.com/watch?v=xryGLifMBTY&list=PLGq4uZyNV1yYH_rcitTTPVysPbC6-7pe-
- **Discord**: https://discord.gg/5CnQZxDM
### About MacNeil Media Group
MacNeil Media Group is an independent media company focused on experimental audio and AI-driven content. Luke at the Roost is its flagship production.
### Media Contact
Luke MacNeil
luke@lukeattheroost.com
https://lukeattheroost.com
---
### Summary (for submission forms)
**Headline**: New Podcast "Luke at the Roost" Blends AI-Generated Callers with Live Radio Format
**Summary (short)**: Luke at the Roost is a late-night call-in talk show where every caller is a unique AI-generated character with a full backstory and personality. Real listeners can call in too. 23 episodes in, the show has built a following with its unpredictable conversations and genuine emotional depth.
**Category**: Entertainment / Technology / Podcasting
**Tags**: podcast, AI, artificial intelligence, late-night radio, call-in show, talk show, entertainment technology, indie podcast, New Mexico
+1058 -128
View File
File diff suppressed because it is too large Load Diff
+159
View File
@@ -0,0 +1,159 @@
-- Bleep Selection — censor a time range on the selected track(s)
--
-- Replaces the audio inside the time selection with a 1kHz tone rather than
-- muting it. Muted regions read as silence to strip_silence_dialog.lua
-- (SILENCE_DB -30, thresholds 5-6s), so a muted bleep over a slowly-read phone
-- number can get stripped and shift everything after it out of sync. A tone at
-- TONE_GAIN_DB is nowhere near -30, so the silence pass leaves it alone.
--
-- Usage: make a time selection over the digits, select the track, run.
---------------------------------------------------------------------------
-- SETTINGS
---------------------------------------------------------------------------
local TONE_GAIN_DB = 0.0 -- adjust bleep level; file is generated at -15 dBFS
local FADE_MS = 4.0 -- fade in/out on the tone, prevents clicks
local TONE_FILE = "bleep_1khz.wav" -- 60s 1kHz sine @48k, sits next to this script
---------------------------------------------------------------------------
local EPS = 1e-9
local function script_dir()
local src = debug.getinfo(1, "S").source
return src:match("@?(.*[/\\])") or ""
end
local function items_on(track)
local t = {}
for i = 0, reaper.CountTrackMediaItems(track) - 1 do
t[#t + 1] = reaper.GetTrackMediaItem(track, i)
end
return t
end
local function item_bounds(item)
local pos = reaper.GetMediaItemInfo_Value(item, "D_POSITION")
return pos, pos + reaper.GetMediaItemInfo_Value(item, "D_LENGTH")
end
-- Split every item crossing either edge so the range is cleanly separable
local function split_at_edges(track, sel_start, sel_end)
for _, item in ipairs(items_on(track)) do
local pos, fin = item_bounds(item)
if fin > sel_start + EPS and pos < sel_end - EPS then
local right = item
if pos < sel_start - EPS then
right = reaper.SplitMediaItem(item, sel_start)
end
if right then
local rpos, rfin = item_bounds(right)
if rfin > sel_end + EPS and rpos < sel_end - EPS then
reaper.SplitMediaItem(right, sel_end)
end
end
end
end
end
local function delete_inside(track, sel_start, sel_end)
local removed = 0
for _, item in ipairs(items_on(track)) do
local pos, fin = item_bounds(item)
if pos >= sel_start - EPS and fin <= sel_end + EPS then
reaper.DeleteTrackMediaItem(track, item)
removed = removed + 1
end
end
return removed
end
-- Build the item directly rather than via InsertMedia(): InsertMedia behaves
-- like the user-facing "insert media file" action — it obeys ripple editing
-- (shifting other tracks), can spawn a new track, and moves the edit cursor.
-- AddMediaItemToTrack touches nothing but this track.
local function insert_tone(track, sel_start, sel_len, tone_source)
local item = reaper.AddMediaItemToTrack(track)
if not item then return nil end
local take = reaper.AddTakeToMediaItem(item)
if not take then return nil end
reaper.SetMediaItemTake_Source(take, tone_source)
reaper.SetMediaItemInfo_Value(item, "D_POSITION", sel_start)
reaper.SetMediaItemInfo_Value(item, "D_LENGTH", sel_len)
reaper.SetMediaItemInfo_Value(item, "B_LOOPSRC", 0)
reaper.SetMediaItemInfo_Value(item, "D_VOL", 10 ^ (TONE_GAIN_DB / 20))
local fade = math.min(FADE_MS / 1000, sel_len / 2)
reaper.SetMediaItemInfo_Value(item, "D_FADEINLEN", fade)
reaper.SetMediaItemInfo_Value(item, "D_FADEOUTLEN", fade)
return item
end
---------------------------------------------------------------------------
local function main()
local sel_start, sel_end = reaper.GetSet_LoopTimeRange(false, false, 0, 0, false)
local sel_len = sel_end - sel_start
if sel_len <= 0 then
reaper.ShowMessageBox("Make a time selection over the audio to bleep.", "Bleep Selection", 0)
return
end
local n_tracks = reaper.CountSelectedTracks(0)
if n_tracks == 0 then
reaper.ShowMessageBox("Select the track to bleep.", "Bleep Selection", 0)
return
end
local tone_path = script_dir() .. TONE_FILE
local f = io.open(tone_path, "rb")
if not f then
reaper.ShowMessageBox("Tone file not found:\n" .. tone_path, "Bleep Selection", 0)
return
end
f:close()
local tone_source = reaper.PCM_Source_CreateFromFile(tone_path)
if not tone_source then
reaper.ShowMessageBox("Could not load tone file:\n" .. tone_path, "Bleep Selection", 0)
return
end
local targets = {}
for i = 0, n_tracks - 1 do
targets[#targets + 1] = reaper.GetSelectedTrack(0, i)
end
reaper.Undo_BeginBlock()
reaper.PreventUIRefresh(1)
-- Ripple editing would shift unrelated items (and other tracks) when items
-- are removed. Force it off for the duration, restore the user's mode after.
local ripple_per_track = reaper.GetToggleCommandStateEx(0, 40310) == 1
local ripple_all = reaper.GetToggleCommandStateEx(0, 40311) == 1
if ripple_per_track or ripple_all then
reaper.Main_OnCommand(40309, 0) -- ripple editing off
end
local bleeped = 0
for _, track in ipairs(targets) do
split_at_edges(track, sel_start, sel_end)
delete_inside(track, sel_start, sel_end)
if insert_tone(track, sel_start, sel_len, tone_source) then
bleeped = bleeped + 1
end
end
if ripple_all then
reaper.Main_OnCommand(40311, 0)
elseif ripple_per_track then
reaper.Main_OnCommand(40310, 0)
end
reaper.PreventUIRefresh(-1)
reaper.UpdateArrange()
reaper.Undo_EndBlock(string.format("Bleep %.2fs on %d track(s)", sel_len, bleeped), -1)
end
main()
+116
View File
@@ -0,0 +1,116 @@
-- Show Region Marker — background script for REAPER
-- Polls /tmp/reaper_state.txt for state changes and creates colored regions.
-- Backend writes "dialog", "ad", or "ident" to the file.
-- Run via Actions → Run ReaScript before or during recording.
local STATE_FILE = "/tmp/reaper_state.txt"
local COLORS = {
dialog = reaper.ColorToNative(50, 180, 50) + 0x1000000, -- green
ad = reaper.ColorToNative(200, 80, 80) + 0x1000000, -- red
ident = reaper.ColorToNative(80, 120, 200) + 0x1000000, -- blue
}
local LABELS = {
dialog = "DIALOG",
ad = "AD",
ident = "IDENT",
}
local counts = { dialog = 0, ad = 0, ident = 0 }
local current_type = nil -- which region type is currently open
local current_start = 0
local last_pos = 0 -- last known transport position (while running)
local last_state = ""
local transport_active = false
local function log(msg)
-- Silent by default — uncomment for debugging:
-- reaper.ShowConsoleMsg("[Regions] " .. msg .. "\n")
end
local function is_playing_or_recording()
local state = reaper.GetPlayState()
return state > 0 and state ~= 2
end
local function open_region(rtype)
if current_type then return end
current_type = rtype
current_start = reaper.GetPlayPosition()
log("OPEN " .. rtype .. " at " .. string.format("%.2f", current_start))
end
local function close_region(pos_override)
if not current_type then return end
local pos = pos_override or reaper.GetPlayPosition()
local len = pos - current_start
local rtype = current_type
current_type = nil
log("CLOSE " .. rtype .. " at " .. string.format("%.2f", pos) .. " (len=" .. string.format("%.2f", len) .. ")")
if len > 0.1 then
counts[rtype] = counts[rtype] + 1
local name = LABELS[rtype] .. " " .. counts[rtype]
reaper.AddProjectMarker2(0, true, current_start, pos, name, -1, COLORS[rtype])
log(" -> Created '" .. name .. "'")
else
log(" -> Skipped (too short)")
end
end
local function poll()
if not transport_active then
if is_playing_or_recording() then
transport_active = true
log("Transport started at " .. string.format("%.2f", reaper.GetPlayPosition()))
local f = io.open(STATE_FILE, "r")
if f then
last_state = f:read("*l") or "dialog"
f:close()
else
last_state = "dialog"
end
log("Initial state: '" .. last_state .. "'")
open_region(last_state)
end
reaper.defer(poll)
return
end
-- Track position while transport is running
last_pos = reaper.GetPlayPosition()
-- Detect transport stop (recording ended) — use last known good position
if not is_playing_or_recording() then
log("Transport stopped at last known pos " .. string.format("%.2f", last_pos))
close_region(last_pos)
transport_active = false
reaper.defer(poll)
return
end
local f = io.open(STATE_FILE, "r")
if f then
local state = f:read("*l") or "dialog"
f:close()
if state ~= last_state then
log("State change: '" .. last_state .. "' -> '" .. state .. "'")
close_region()
open_region(state)
last_state = state
end
end
reaper.defer(poll)
end
log("Script loaded — waiting for transport to start...")
reaper.atexit(function()
log("Script stopping (atexit)")
close_region()
local total = counts.dialog + counts.ad + counts.ident
log("Done. " .. total .. " regions (" .. counts.dialog .. " dialog, " .. counts.ad .. " ad, " .. counts.ident .. " ident)")
end)
poll()
File diff suppressed because it is too large Load Diff
+219
View File
@@ -0,0 +1,219 @@
-- Stub harness for reaper/bleep_selection.lua
-- Models tracks/items in memory and asserts the split/delete/insert behaviour.
local SCRIPT = (debug.getinfo(1,"S").source:match("@?(.*[/\\])") or "") .. "bleep_selection.lua"
local function new_item(pos, len, tag)
return { pos = pos, len = len, tag = tag or "audio", vol = 1, fin_ = 0, fout_ = 0 }
end
local W -- world
local function make_reaper()
return {
CountTrackMediaItems = function(tr) return #tr.items end,
GetTrackMediaItem = function(tr, i) return tr.items[i + 1] end,
GetMediaItemInfo_Value = function(it, k)
if k == "D_POSITION" then return it.pos end
if k == "D_LENGTH" then return it.len end
return 0
end,
SetMediaItemInfo_Value = function(it, k, v)
if k == "D_POSITION" then it.pos = v
elseif k == "D_LENGTH" then it.len = v
elseif k == "D_VOL" then it.vol = v
elseif k == "D_FADEINLEN" then it.fin_ = v
elseif k == "D_FADEOUTLEN" then it.fout_ = v end
end,
SplitMediaItem = function(it, at)
local tr
for _, t in ipairs(W.tracks) do
for idx, x in ipairs(t.items) do if x == it then tr = t; it_idx = idx end end
end
if not tr then return nil end
if at <= it.pos or at >= it.pos + it.len then return nil end
local right = new_item(at, it.pos + it.len - at, it.tag)
it.len = at - it.pos
local pos_in = 0
for idx, x in ipairs(tr.items) do if x == it then pos_in = idx end end
table.insert(tr.items, pos_in + 1, right)
W.splits = W.splits + 1
return right
end,
DeleteTrackMediaItem = function(tr, it)
for idx, x in ipairs(tr.items) do
if x == it then table.remove(tr.items, idx); W.deletes = W.deletes + 1; return true end
end
return false
end,
InsertMedia = function() W.forbidden["InsertMedia"] = true; return 1 end,
GetSelectedMediaItem = function(_, _) return W.last_inserted end,
SetOnlyTrackSelected = function(tr) W.forbidden["SetOnlyTrackSelected"] = true end,
SetTrackSelected = function() W.forbidden["SetTrackSelected"] = true end,
SetEditCurPos = function(p) W.forbidden["SetEditCurPos"] = true end,
PCM_Source_CreateFromFile = function(path) W.src_path = path; return { src = path } end,
AddMediaItemToTrack = function(tr)
local it = new_item(0, 0, "tone"); table.insert(tr.items, it)
W.last_inserted = it; it.owner = tr; return it
end,
AddTakeToMediaItem = function(it) it.take = { item = it }; return it.take end,
SetMediaItemTake_Source = function(take, src) take.src = src end,
GetToggleCommandStateEx = function(_, cmd) return W.ripple[cmd] and 1 or 0 end,
Main_OnCommand = function(cmd) W.commands[#W.commands + 1] = cmd end,
GetSet_LoopTimeRange = function() return W.sel_start, W.sel_end end,
CountSelectedTracks = function() return #W.sel_tracks end,
GetSelectedTrack = function(_, i) return W.sel_tracks[i + 1] end,
Undo_BeginBlock = function() end,
Undo_EndBlock = function(desc) W.undo = desc end,
PreventUIRefresh = function() end,
UpdateArrange = function() end,
ShowMessageBox = function(msg) W.msg = msg end,
}
end
local function run(setup)
W = { tracks = {}, sel_tracks = {}, splits = 0, deletes = 0,
inserted_paths = {}, edit_cur = 0, msg = nil, undo = nil,
forbidden = {}, ripple = {}, commands = {} }
setup(W)
reaper = make_reaper()
local fn = assert(loadfile(SCRIPT))
fn()
return W
end
local function track(items)
local t = { items = {} }
for _, it in ipairs(items) do t.items[#t.items + 1] = new_item(it[1], it[2]) end
return t
end
local pass, fail = 0, 0
local function check(name, cond, detail)
if cond then pass = pass + 1; print((" PASS %s"):format(name))
else fail = fail + 1; print((" FAIL %s -- %s"):format(name, detail or "")) end
end
local function layout(tr)
local s = {}
for _, it in ipairs(tr.items) do
s[#s + 1] = ("%s[%.2f..%.2f]"):format(it.tag == "tone" and "T" or "A", it.pos, it.pos + it.len)
end
return table.concat(s, " ")
end
print("\n1) Selection inside one long item -> split x2, middle deleted, tone inserted")
local w = run(function(W)
local t = track({ {0, 30} })
W.tracks = { t }; W.sel_tracks = { t }; W.sel_start, W.sel_end = 10, 15
end)
local t1 = w.tracks[1]
check("two splits", w.splits == 2, "splits=" .. w.splits)
check("one delete", w.deletes == 1, "deletes=" .. w.deletes)
check("tone inserted", w.last_inserted ~= nil)
check("tone spans selection", math.abs(w.last_inserted.pos - 10) < 1e-6
and math.abs(w.last_inserted.len - 5) < 1e-6,
("pos=%.3f len=%.3f"):format(w.last_inserted.pos, w.last_inserted.len))
check("fades applied", w.last_inserted.fin_ > 0 and w.last_inserted.fout_ > 0)
check("no audio left inside range", (function()
for _, it in ipairs(t1.items) do
if it.tag == "audio" and it.pos >= 10 - 1e-6 and it.pos + it.len <= 15 + 1e-6 then return false end
end
return true
end)())
print(" layout: " .. layout(t1))
print("\n2) Item entirely inside selection -> deleted outright")
w = run(function(W)
local t = track({ {11, 2} })
W.tracks = { t }; W.sel_tracks = { t }; W.sel_start, W.sel_end = 10, 15
end)
check("no splits needed", w.splits == 0, "splits=" .. w.splits)
check("deleted", w.deletes == 1, "deletes=" .. w.deletes)
print(" layout: " .. layout(w.tracks[1]))
print("\n3) Items straddling each edge only")
w = run(function(W)
local t = track({ {5, 7}, {13, 6} }) -- 5..12 and 13..19, selection 10..15
W.tracks = { t }; W.sel_tracks = { t }; W.sel_start, W.sel_end = 10, 15
end)
local t3 = w.tracks[1]
check("split each straddler once", w.splits == 2, "splits=" .. w.splits)
check("both inner halves deleted", w.deletes == 2, "deletes=" .. w.deletes)
check("audio before survives", (function()
for _, it in ipairs(t3.items) do
if it.tag == "audio" and math.abs(it.pos - 5) < 1e-6 and math.abs(it.len - 5) < 1e-6 then return true end
end
return false
end)())
print(" layout: " .. layout(t3))
print("\n4) Item entirely outside selection -> untouched")
w = run(function(W)
local t = track({ {20, 5} })
W.tracks = { t }; W.sel_tracks = { t }; W.sel_start, W.sel_end = 10, 15
end)
check("no splits", w.splits == 0)
check("no deletes", w.deletes == 0)
check("still one audio item + tone", #w.tracks[1].items == 2, "n=" .. #w.tracks[1].items)
print("\n5) Only the selected track is touched")
w = run(function(W)
local a, b = track({ {0, 30} }), track({ {0, 30} })
W.tracks = { a, b }; W.sel_tracks = { a }; W.sel_start, W.sel_end = 10, 15
end)
check("other track untouched", #w.tracks[2].items == 1, "n=" .. #w.tracks[2].items)
check("selected track modified", #w.tracks[1].items > 1)
print("\n6) Guard rails")
w = run(function(W)
local t = track({ {0, 30} })
W.tracks = { t }; W.sel_tracks = { t }; W.sel_start, W.sel_end = 10, 10 -- empty selection
end)
check("aborts with message on empty selection", w.msg ~= nil and w.splits == 0, tostring(w.msg))
w = run(function(W)
local t = track({ {0, 30} })
W.tracks = { t }; W.sel_tracks = {}; W.sel_start, W.sel_end = 10, 15 -- no track selected
end)
check("aborts with message on no track", w.msg ~= nil and w.splits == 0, tostring(w.msg))
print("\n7) Never uses project-wide / UI-level calls that move other tracks")
w = run(function(W)
local a, b = track({ {0, 30} }), track({ {0, 30} })
W.tracks = { a, b }; W.sel_tracks = { a }; W.sel_start, W.sel_end = 10, 15
end)
check("no InsertMedia (ripples + can spawn tracks)", not w.forbidden["InsertMedia"])
check("does not move edit cursor", not w.forbidden["SetEditCurPos"])
check("does not change track selection", not w.forbidden["SetOnlyTrackSelected"]
and not w.forbidden["SetTrackSelected"])
check("built item from PCM source", w.src_path ~= nil and w.last_inserted.take ~= nil)
check("tone landed on the SELECTED track", w.last_inserted.owner == w.tracks[1])
print("\n8) Ripple editing forced off, then restored")
w = run(function(W)
local t = track({ {0, 30} })
W.tracks = { t }; W.sel_tracks = { t }; W.sel_start, W.sel_end = 10, 15
W.ripple[40311] = true -- user had "ripple all tracks" on
end)
check("turned ripple off first", w.commands[1] == 40309, "cmds=" .. table.concat(w.commands, ","))
check("restored ripple-all after", w.commands[#w.commands] == 40311,
"cmds=" .. table.concat(w.commands, ","))
w = run(function(W)
local t = track({ {0, 30} })
W.tracks = { t }; W.sel_tracks = { t }; W.sel_start, W.sel_end = 10, 15
W.ripple[40310] = true -- per-track ripple
end)
check("restored per-track ripple", w.commands[1] == 40309 and w.commands[#w.commands] == 40310,
"cmds=" .. table.concat(w.commands, ","))
w = run(function(W)
local t = track({ {0, 30} })
W.tracks = { t }; W.sel_tracks = { t }; W.sel_start, W.sel_end = 10, 15
end)
check("ripple already off -> no mode changes at all", #w.commands == 0,
"cmds=" .. table.concat(w.commands, ","))
print(("\n%d passed, %d failed\n"):format(pass, fail))
os.exit(fail == 0 and 0 or 1)
+1 -1
View File
@@ -8,7 +8,7 @@ from dotenv import load_dotenv
load_dotenv()
API_KEY = os.getenv("OPENROUTER_API_KEY")
TRANSCRIPT_DIR = Path(__file__).parent / "website" / "transcripts"
MODEL = "anthropic/claude-3.5-sonnet"
MODEL = "anthropic/claude-sonnet-4.6"
CHUNK_SIZE = 8000
PROMPT = """Insert speaker labels into this radio show transcript. The show is "Luke at the Roost". The host is LUKE. Callers call in one at a time.
+122
View File
@@ -0,0 +1,122 @@
"""Scan music directory for tracks that contain vocals/lyrics.
Uses Whisper to transcribe a sample from each track if it picks up
actual words, the track likely has vocals.
Usage:
python scan_music_vocals.py # scan and report
python scan_music_vocals.py --delete # scan and delete vocal tracks
"""
import argparse
import sys
from pathlib import Path
import librosa
import numpy as np
from faster_whisper import WhisperModel
MUSIC_DIR = Path(__file__).parent / "music"
WHISPER_MODEL = "distil-large-v3"
# Words Whisper hallucinates on silence/instrumental — ignore these
HALLUCINATION_PHRASES = {
"thank you", "thanks for watching", "subscribe", "like and subscribe",
"please subscribe", "thank you for watching", "thanks for listening",
"you", "the end", "bye", "okay",
}
def scan_track(model: WhisperModel, filepath: Path) -> tuple[bool, str]:
"""Check a single track for vocals. Returns (has_vocals, transcription)."""
try:
audio, sr = librosa.load(str(filepath), sr=16000, mono=True)
except Exception as e:
return False, f"[load error: {e}]"
duration = len(audio) / sr
if duration < 10:
return False, "[too short]"
# Sample 30s from the middle (most likely to have vocals)
mid = len(audio) // 2
half_window = int(15 * sr) # 15s each side
start = max(0, mid - half_window)
end = min(len(audio), mid + half_window)
sample = audio[start:end]
segments, info = model.transcribe(
sample,
beam_size=3,
language="en",
vad_filter=True,
vad_parameters=dict(min_speech_duration_ms=500),
)
segments_list = list(segments)
text = " ".join(s.text for s in segments_list).strip()
# Filter out Whisper hallucinations
text_lower = text.lower().strip()
if text_lower in HALLUCINATION_PHRASES or len(text_lower) < 4:
return False, ""
# If Whisper found substantial text, it's likely vocals
word_count = len(text.split())
has_vocals = word_count >= 3
return has_vocals, text
def main():
parser = argparse.ArgumentParser(description="Scan music for vocal tracks")
parser.add_argument("--delete", action="store_true", help="Delete tracks with vocals")
args = parser.parse_args()
audio_files = sorted(
f for f in MUSIC_DIR.iterdir()
if f.suffix.lower() in {".mp3", ".wav", ".ogg", ".flac"}
)
if not audio_files:
print("No audio files found in music/")
return
print(f"Loading Whisper {WHISPER_MODEL}...")
model = WhisperModel(WHISPER_MODEL, device="cpu", compute_type="int8")
print(f"Scanning {len(audio_files)} tracks for vocals...\n")
vocal_tracks = []
for i, f in enumerate(audio_files, 1):
print(f"[{i}/{len(audio_files)}] {f.name}...", end=" ", flush=True)
has_vocals, text = scan_track(model, f)
if has_vocals:
print(f"VOCALS: {text[:80]}")
vocal_tracks.append((f, text))
else:
print("OK")
print(f"\n{'='*60}")
print(f"Results: {len(vocal_tracks)} tracks with vocals out of {len(audio_files)}\n")
if not vocal_tracks:
print("All tracks appear to be instrumental!")
return
for f, text in vocal_tracks:
print(f" {f.name}")
print(f" Lyrics: {text[:120]}")
print()
if args.delete:
print(f"Deleting {len(vocal_tracks)} vocal tracks...")
for f, _ in vocal_tracks:
f.unlink()
print(f" Deleted: {f.name}")
print("Done.")
else:
print("Run with --delete to remove these tracks.")
if __name__ == "__main__":
main()
+538
View File
@@ -0,0 +1,538 @@
#!/usr/bin/env python3
"""Schedule the X/Twitter launch campaign posts via Postiz.
Schedules 2 weeks of posts from the growth strategy to @lukeattheroost.
All times are ET, converted to UTC for the Postiz API.
Usage:
python schedule_x_launch.py # schedule all posts
python schedule_x_launch.py --dry-run # preview without scheduling
python schedule_x_launch.py --week 1 # schedule week 1 only
"""
import argparse
import json
import os
import sys
import time
from datetime import datetime, timedelta, timezone
from pathlib import Path
import requests
# Load .env
env_path = Path(__file__).parent / ".env"
if env_path.exists():
for line in env_path.read_text().splitlines():
line = line.strip()
if line and not line.startswith("#") and "=" in line:
key, _, value = line.partition("=")
os.environ.setdefault(key.strip(), value.strip())
POSTIZ_API_KEY = os.getenv("POSTIZ_API_KEY")
POSTIZ_URL = os.getenv("POSTIZ_URL", "https://social.lukeattheroost.com")
SCRIPT_DIR = Path(__file__).parent
X_INTEGRATION_ID = "cmlk4hi880001k76wbqjo21s0"
# ET = UTC-4 (EDT) in March 2026
ET_OFFSET = timedelta(hours=-4)
def et_to_utc(year, month, day, hour, minute=0):
"""Convert ET datetime to UTC ISO string for Postiz."""
et = datetime(year, month, day, hour, minute, tzinfo=timezone(ET_OFFSET))
utc = et.astimezone(timezone.utc)
return utc.strftime("%Y-%m-%dT%H:%M:%S.000Z")
def get_api_url(path):
return f"{POSTIZ_URL.rstrip('/')}/api/public/v1{path}"
def api_headers():
return {"Authorization": POSTIZ_API_KEY, "Content-Type": "application/json"}
def upload_file(file_path):
headers = {"Authorization": POSTIZ_API_KEY}
suffix = file_path.suffix.lower()
if suffix == ".mp4":
mime = "video/mp4"
elif suffix in (".jpg", ".jpeg"):
mime = "image/jpeg"
else:
mime = "image/png"
with open(file_path, "rb") as f:
resp = requests.post(
get_api_url("/upload"),
headers=headers,
files={"file": (file_path.name, f, mime)},
timeout=120,
)
if resp.status_code not in (200, 201):
print(f" Upload failed: {resp.status_code} {resp.text[:200]}")
return {}
return resp.json()
def schedule_post(content, media, schedule_time, retries=3):
payload = {
"type": "schedule",
"date": schedule_time,
"shortLink": False,
"tags": [],
"posts": [
{
"integration": {"id": X_INTEGRATION_ID},
"value": [
{
"content": content,
"image": [media] if media else [],
}
],
"settings": {"__type": "x", "who_can_reply_post": "everyone"},
}
],
}
for attempt in range(retries):
resp = requests.post(
get_api_url("/posts"),
headers=api_headers(),
json=payload,
timeout=30,
)
if resp.status_code in (200, 201):
return resp.json()
if resp.status_code == 429 and attempt < retries - 1:
wait = 15 * (attempt + 1)
print(f"(rate limited, waiting {wait}s)...", end=" ", flush=True)
time.sleep(wait)
continue
print(f" Schedule failed: {resp.status_code} {resp.text[:300]}")
return {}
return {}
# ── POST DEFINITIONS ─────────────────────────────────────────────────
# Day 1 = Monday March 17, 2026
WEEK_1 = [
# Day 1 — Monday March 17
{
"label": "W1-Mon-AM (pinned intro)",
"time": et_to_utc(2026, 3, 17, 10),
"content": """Every caller on my show is AI-generated.
Every personality. Every voice. Every problem they call in about.
But the conversations are real, the advice is real, and the chaos is very real.
38 episodes. 200+ callers. A cult leader, a guy who opened paternity results live on air, and someone who faked cancer to skip a wedding.
This is Luke at the Roost.
📞 208-439-LUKE (real humans can call in too)
🔗 lukeattheroost.com""",
"media": "website/images/cover.png",
},
{
"label": "W1-Mon-PM (chili clip)",
"time": et_to_utc(2026, 3, 17, 14),
"content": """A guy called in to talk about chili contest cheaters.
Turns out he was really calling about his failing marriage.
#lukeattheroost #podcast #callinshow""",
"media": "clips/episode-37/clip-1-chili-contest-cheaters-marriage-troubles.mp4",
},
{
"label": "W1-Mon-EVE (intro thread)",
"time": et_to_utc(2026, 3, 17, 21),
"content": """People keep asking what Luke at the Roost is.
Short version:
AI characters call into my show with real problems
I give them actual advice
Everything goes off the rails
New episode every day
lukeattheroost.com""",
"media": None,
},
# Day 2 — Tuesday March 18
{
"label": "W1-Tue-AM (hospice clip)",
"time": et_to_utc(2026, 3, 18, 12),
"content": """A caller's mom is in hospice. The nurse asked her to think about final conversations.
Her only wish? To see her kids eat cake together one last time.
#lukeattheroost #podcast""",
"media": "clips/episode-30/clip-2-mom-s-dying-wish-just-eat-cake-together.mp4",
},
{
"label": "W1-Tue-PM (engagement)",
"time": et_to_utc(2026, 3, 18, 19),
"content": """What's the wildest thing you've ever called into a radio show about?
(Or wanted to but chickened out?)""",
"media": None,
},
# Day 3 — Wednesday March 19
{
"label": "W1-Wed-AM (cancer clip)",
"time": et_to_utc(2026, 3, 19, 11),
"content": """This caller faked having cancer to get out of going to a wedding.
Then his friends staged a coffee enema intervention.
I can't make this up. (Well, the AI did.)
#lukeattheroost #podcast""",
"media": "clips/episode-32/clip-1-i-faked-cancer-to-skip-a-wedding.mp4",
},
{
"label": "W1-Wed-PM (fakes clip)",
"time": et_to_utc(2026, 3, 19, 16),
"content": """"Everybody is a fake. We're all fakes. Nobody knows what's going on and none of us deserve a goddamn thing. We're lucky to be here at all."
A caller on Episode 2. Still the hardest truth anyone's dropped on my show.
#lukeattheroost #podcast""",
"media": "clips/episode-2/clip-1-we-re-all-fakes-and-that-s-okay.mp4",
},
# Day 4 — Thursday March 20
{
"label": "W1-Thu-AM (BTS)",
"time": et_to_utc(2026, 3, 20, 12),
"content": """How my show works:
AI generates a caller with a full backstory, personality, and voice
They call in live
I have zero idea what they're going to say
I give them real advice
Post-production runs automatically
Episode publishes
38 episodes. Built the whole thing from scratch.""",
"media": None,
},
{
"label": "W1-Thu-PM (stakeout clip)",
"time": et_to_utc(2026, 3, 20, 20),
"content": """He spent four hours staking out his best friend at Starbucks.
What he found was worse than what he expected.
#lukeattheroost #podcast""",
"media": "clips/episode-28/clip-3-four-hours-spying-on-his-best-friend.mp4",
},
# Day 5 — Friday March 21
{
"label": "W1-Fri-AM (review ask)",
"time": et_to_utc(2026, 3, 21, 11),
"content": """If you've listened to Luke at the Roost and liked it — a review on Apple Podcasts or Spotify goes further than you'd think.
Not guilt-tripping. Just saying it helps a one-person show more than anything else.
🔗 lukeattheroost.com""",
"media": "social_posts/x_launch/leave_a_review_twitter.png",
},
{
"label": "W1-Fri-PM (wall clip)",
"time": et_to_utc(2026, 3, 21, 18),
"content": """She opened up a mystery wall in her house LIVE on the show.
There were stacks of cash inside.
#lukeattheroost #podcast""",
"media": "clips/episode-35/clip-3-woman-finds-cash-in-secret-wall.mp4",
},
# Day 6 — Saturday March 22
{
"label": "W1-Sat-AM (poll)",
"time": et_to_utc(2026, 3, 22, 12),
"content": """Which is wilder?
A) Woman hid her daughter from her husband for 8 years
B) Cult leader existential crisis on air
C) Paternity results opened live on the show
D) Faked cancer to skip a wedding""",
"media": None,
},
{
"label": "W1-Sat-PM (silence clip)",
"time": et_to_utc(2026, 3, 22, 20),
"content": """"I told my girlfriend my biggest fantasy and she went completely silent for 10 seconds."
The silence in this clip is brutal.
#lukeattheroost #podcast""",
"media": "clips/episode-30/clip-3-latex-fetish-confession-goes-silent.mp4",
},
# Day 7 — Sunday March 23
{
"label": "W1-Sun-PM (week recap)",
"time": et_to_utc(2026, 3, 23, 15),
"content": """One week on X. Dropped 38 episodes before making an account.
If any of these clips made you laugh, cringe, or feel something the full episodes are even wilder.
📞 208-439-LUKE
🔗 lukeattheroost.com
🎧 Spotify · Apple · YouTube""",
"media": "website/images/cover.png",
},
]
WEEK_2 = [
# Day 8 — Monday March 24
{
"label": "W2-Mon-AM (second family clip)",
"time": et_to_utc(2026, 3, 24, 10),
"content": """A guy called in and found out his dad had a whole second family.
Three kids in Tucson who grew up calling his dad "Dad."
He found out via email from a stranger.
#lukeattheroost #podcast""",
"media": "clips/episode-20/clip-2-dad-s-secret-second-family-revealed.mp4",
},
{
"label": "W2-Mon-PM (engagement)",
"time": et_to_utc(2026, 3, 24, 19),
"content": """What would you do if you found out your dad had a whole second family?
Asking because a caller found out via email from a woman in Tucson.""",
"media": None,
},
# Day 9 — Tuesday March 25
{
"label": "W2-Tue-AM (spanish clip)",
"time": et_to_utc(2026, 3, 25, 11),
"content": """A caller pretended to speak Spanish at his job for 8 years.
Eight. Years.
#lukeattheroost #podcast""",
"media": "clips/episode-14/clip-1-i-lied-about-speaking-spanish-for-8-years.mp4",
},
{
"label": "W2-Tue-PM (quote)",
"time": et_to_utc(2026, 3, 25, 18),
"content": """"Middle management is plagiarism with a 401k."
AI callers drop better one-liners than most standup specials.
#lukeattheroost #podcast""",
"media": "social_posts/x_launch/quote_3_stoicism_backwards_twitter.png",
},
# Day 10 — Wednesday March 26
{
"label": "W2-Wed-AM (BTS)",
"time": et_to_utc(2026, 3, 26, 12),
"content": """People ask if I script the show.
I don't even know who's calling until they're on the line. The AI generates the caller, picks a unique voice, gives them a backstory, and dials in.
My job is just to be a good host. The chaos handles itself.""",
"media": None,
},
{
"label": "W2-Wed-PM (roomba clip)",
"time": et_to_utc(2026, 3, 26, 20),
"content": """His neighbor's Roomba broke into his kitchen at 2:30 AM.
This is the content you're here for.
#lukeattheroost #podcast""",
"media": "clips/episode-26/clip-2-neighbor-s-roomba-breaks-into-kitchen-at-2-30-am.mp4",
},
# Day 11 — Thursday March 27
{
"label": "W2-Thu-AM (stalking clip)",
"time": et_to_utc(2026, 3, 27, 11),
"content": """She sat in a Dairy Queen parking lot for 20 minutes watching her ex's truck at Sonic across the street.
We've all been there. (Right?)
#lukeattheroost #podcast""",
"media": "clips/episode-22/clip-2-stalking-your-ex-at-sonic.mp4",
},
{
"label": "W2-Thu-PM (engagement)",
"time": et_to_utc(2026, 3, 27, 19),
"content": """Be honest: what's a lie you've kept going for way too long?
A caller on my show pretended to speak Spanish at work for 8 years. You can't beat that.""",
"media": None,
},
# Day 12 — Friday March 28
{
"label": "W2-Fri-AM (hidden room clip)",
"time": et_to_utc(2026, 3, 28, 10),
"content": """A man found an impossible hidden room in a junkyard.
Inside? Beer that was still fresh after 12 years.
Two clips. One mystery.
#lukeattheroost #podcast""",
"media": "clips/episode-34/clip-1-man-finds-impossible-hidden-room-in-junkyard.mp4",
},
{
"label": "W2-Fri-PM (fix quote)",
"time": et_to_utc(2026, 3, 28, 18),
"content": """"You can't fix somebody who doesn't want to be fixed."
A caller said this about their partner and it hit like a truck.
#lukeattheroost #podcast""",
"media": "clips/episode-30/clip-1-you-can-t-fix-someone-who-won-t-be-fixed.mp4",
},
# Day 13 — Saturday March 29
{
"label": "W2-Sat-AM (BTS callers)",
"time": et_to_utc(2026, 3, 29, 12),
"content": """Each AI caller gets generated with:
A name, age, job, and hometown
A reason for calling
A communication style + energy level
An emotional state
A "signature detail" that makes them unique
A unique AI voice
None of it is scripted. They just... call in and talk.""",
"media": None,
},
{
"label": "W2-Sat-PM (check clip)",
"time": et_to_utc(2026, 3, 29, 20),
"content": """He deposited a $5,000 check instead of $500 three months ago.
Spent it all.
Now his company might find out.
#lukeattheroost #podcast""",
"media": "clips/episode-25/clip-2-accidentally-kept-4-500-from-work.mp4",
},
# Day 14 — Sunday March 30
{
"label": "W2-Sun-PM (week 2 recap + review)",
"time": et_to_utc(2026, 3, 30, 15),
"content": """Two weeks in. Thank you to everyone who's followed, listened, or dropped a comment.
This show started as a weird experiment a guy giving life advice to AI-generated callers at 2 AM.
38 episodes later it's still weird. But now people are listening.
If you've been enjoying it, a rating on Apple or Spotify makes a real difference. 🙏
lukeattheroost.com""",
"media": None,
},
]
def main():
parser = argparse.ArgumentParser(description="Schedule X launch campaign via Postiz")
parser.add_argument("--dry-run", action="store_true", help="Preview without scheduling")
parser.add_argument("--week", type=int, choices=[1, 2], help="Schedule only week 1 or 2")
parser.add_argument("--skip", type=int, default=0, help="Skip first N posts (for retrying after partial run)")
parser.add_argument("--delay", type=int, default=10, help="Seconds between API calls (default 10)")
args = parser.parse_args()
if not POSTIZ_API_KEY:
print("Error: POSTIZ_API_KEY not set in .env")
sys.exit(1)
all_posts = []
if args.week != 2:
all_posts.extend(WEEK_1)
if args.week != 1:
all_posts.extend(WEEK_2)
posts = all_posts[args.skip:]
print(f"\n=== X Launch Campaign — {len(posts)} posts ===\n")
if args.dry_run:
for i, post in enumerate(posts, 1):
has_media = "📎" if post["media"] else " "
print(f" {i:2d}. {has_media} {post['label']}")
print(f" Schedule: {post['time']}")
preview = post["content"][:80].replace("\n", " ")
print(f" Content: {preview}...")
if post["media"]:
print(f" Media: {post['media']}")
print()
print(f"Dry run complete — {len(posts)} posts would be scheduled.")
return
# Upload media files first (deduplicate), with disk cache
cache_file = SCRIPT_DIR / "social_posts" / "x_launch" / ".upload_cache.json"
media_cache = {}
if cache_file.exists():
media_cache = json.loads(cache_file.read_text())
print(f"Loaded {len(media_cache)} cached uploads from previous run\n")
media_files = set(p["media"] for p in posts if p["media"])
to_upload = [m for m in sorted(media_files) if m not in media_cache]
if to_upload:
print(f"Uploading {len(to_upload)} new media files ({len(media_files) - len(to_upload)} cached)...\n")
for media_path in to_upload:
full_path = SCRIPT_DIR / media_path
if not full_path.exists():
print(f"{media_path} — FILE NOT FOUND, skipping")
continue
print(f" Uploading {media_path}...", end=" ", flush=True)
result = upload_file(full_path)
if result:
media_cache[media_path] = result
cache_file.write_text(json.dumps(media_cache, indent=2))
print("")
else:
print("✗ FAILED")
time.sleep(3)
else:
print(f"All {len(media_files)} media files already cached, skipping uploads\n")
# Schedule posts
print(f"\nScheduling {len(posts)} posts to X...\n")
success = 0
failed = 0
for i, post in enumerate(posts, 1):
media = media_cache.get(post["media"]) if post["media"] else None
if post["media"] and not media:
print(f" {i:2d}. ✗ {post['label']} — media upload missing, skipping")
failed += 1
continue
print(f" {i:2d}. Scheduling {post['label']}...", end=" ", flush=True)
result = schedule_post(post["content"], media, post["time"])
if result:
print("")
success += 1
else:
print("")
failed += 1
# Rate limit: pause between API calls
if i < len(posts):
time.sleep(5)
print(f"\n{'='*50}")
print(f"Scheduled: {success}/{len(posts)}")
if failed:
print(f"Failed: {failed}")
print(f"\nPosts will appear on @lukeattheroost starting Mon March 17")
if __name__ == "__main__":
main()
+13
View File
@@ -0,0 +1,13 @@
import json
from pathlib import Path
src = Path("data/regulars.json")
data = json.loads(src.read_text())
Path("data/regulars.archived.json").write_text(json.dumps(data, indent=2))
regulars = data.get("regulars", [])
silas_only = [r for r in regulars if r.get("name", "").lower() == "silas"]
data["regulars"] = silas_only
src.write_text(json.dumps(data, indent=2))
print(f"Archived {len(regulars)} regulars. Kept {len(silas_only)} (Silas).")
+153
View File
@@ -0,0 +1,153 @@
"""Generate 10 sample caller dialogues for user validation before cutover.
5 with Silas batch (if lore exists), 5 walk-ins. Writes transcripts to docs/samples/.
"""
import asyncio
import random
from datetime import datetime
from pathlib import Path
import httpx
from backend.config import settings
from backend.main import (
BLACKLISTED_VOICES,
INWORLD_FEMALE_VOICES,
INWORLD_MALE_VOICES,
get_caller_prompt,
)
from backend.services import caller_gen, regulars_v2
DIALOG_MODEL = "anthropic/claude-haiku-4.5"
HOST_REACTIONS = [
"Yeah?",
"Go on.",
"Mm-hmm.",
"Wait — really?",
"Hold on, back up.",
"Is that right?",
"How'd you end up there?",
"And then what?",
"What'd she say?",
"So what are you gonna do about it?",
"That's — okay, keep going.",
"I mean, what do you want me to tell you?",
]
async def dialog_turn(client: httpx.AsyncClient, system_prompt: str, conversation: list) -> str:
last_err = None
for attempt in range(3):
try:
resp = await client.post(
"https://openrouter.ai/api/v1/chat/completions",
headers={"Authorization": f"Bearer {settings.openrouter_api_key}"},
json={
"model": DIALOG_MODEL,
"messages": [{"role": "system", "content": system_prompt}] + conversation,
"max_tokens": 300,
"temperature": 0.9,
},
)
resp.raise_for_status()
return resp.json()["choices"][0]["message"]["content"].strip()
except (httpx.ReadTimeout, httpx.HTTPStatusError) as e:
last_err = e
print(f" [retry {attempt+1}/3] dialog_turn failed: {type(e).__name__}")
raise last_err
async def main():
out_dir = Path("docs/samples")
out_dir.mkdir(parents=True, exist_ok=True)
voice_roster = [
n for n in INWORLD_MALE_VOICES + INWORLD_FEMALE_VOICES
if n not in BLACKLISTED_VOICES
]
date_str = datetime.now().strftime("%A, %B %d, %Y")
# Batch 1: include Silas (if lore exists)
regulars = regulars_v2.load_all_active_regulars()
if regulars:
ctx_with_silas = {
"date": date_str,
"weather": "cool desert night",
"headlines": [],
"recent_caller_summaries": [],
"regulars_included": [
{"name": r.name, "lore": r.lore_body, "arc_state": r.arc_state}
for r in regulars
],
"caller_count": 5,
"voice_roster": voice_roster,
}
print("[Batch 1] Generating 5 callers including Silas...")
silas_batch = await caller_gen.generate_batch(ctx_with_silas)
print(f"[Batch 1] Got {len(silas_batch)} callers: {[c.name for c in silas_batch]}")
else:
print("[Batch 1] No regulars found — skipping")
silas_batch = []
# Batch 2: walk-ins
ctx_walkins = {
"date": date_str,
"weather": "cool desert night",
"headlines": [],
"recent_caller_summaries": [],
"regulars_included": [],
"caller_count": 5,
"voice_roster": voice_roster,
}
print("[Batch 2] Generating 5 walk-in callers...")
walkin_batch = await caller_gen.generate_batch(ctx_walkins)
print(f"[Batch 2] Got {len(walkin_batch)} callers: {[c.name for c in walkin_batch]}")
all_callers = silas_batch + walkin_batch
async with httpx.AsyncClient(timeout=120.0) as client:
for idx, caller in enumerate(all_callers, 1):
print(f"[Dialog {idx}/{len(all_callers)}] {caller.name}...")
cdict = {
"name": caller.name,
"identity": caller.identity,
"situation": caller.situation,
"reason_calling": caller.reason_calling,
"secret_want": caller.secret_want,
"specific_details": caller.specific_details,
}
system_prompt = get_caller_prompt(cdict)
conversation = [{"role": "assistant", "content": caller.opening_line}]
for _ in range(4):
host_line = random.choice(HOST_REACTIONS)
conversation.append({"role": "user", "content": host_line})
reply = await dialog_turn(client, system_prompt, conversation)
conversation.append({"role": "assistant", "content": reply})
transcript_lines = [
f"CALLER ({caller.name}, {caller.age}, {caller.location}): "
f"{conversation[0]['content']}"
]
for i in range(1, len(conversation), 2):
transcript_lines.append(f"LUKE: {conversation[i]['content']}")
if i + 1 < len(conversation):
transcript_lines.append(f"CALLER: {conversation[i+1]['content']}")
slug = caller.name.replace(" ", "_").lower()
fname = out_dir / f"sample_{slug}.txt"
fname.write_text(
f"=== {caller.name} ({caller.age}, {caller.location}) ===\n"
f"voice: {caller.voice_resolved}\n"
f"emotional_register: {caller.emotional_register}\n"
f"secret_want: {caller.secret_want}\n\n"
+ "\n".join(transcript_lines)
+ "\n"
)
print(f"Wrote {fname}")
if __name__ == "__main__":
asyncio.run(main())
+5
View File
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<rss xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd" xmlns:podcast="https://podcastindex.org/namespace/1.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/" version="2.0"><channel><atom:link href="https://podcast.macneilmediagroup.com/@LukeAtTheRoost/feed.xml" rel="self" type="application/rss+xml"></atom:link><atom:link href="https://pubsubhubbub.appspot.com/" rel="hub" type="application/rss+xml"></atom:link><atom:link href="https://pubsubhubbub.superfeedr.com/" rel="hub" type="application/rss+xml"></atom:link><atom:link href="https://websubhub.com/hub" rel="hub" type="application/rss+xml"></atom:link><atom:link href="https://switchboard.p3k.io/" rel="hub" type="application/rss+xml"></atom:link><lastBuildDate>Tue, 04 Aug 2026 09:23:29 +0000</lastBuildDate><generator>Castopod - https://castopod.org/</generator><docs>https://cyber.harvard.edu/rss/rss.html</docs><podcast:guid>46cfb731-b4a1-55e2-80f3-cadb1c6c18fa</podcast:guid><title>Luke at the Roost</title><description><![CDATA[<p>A late-night call-in radio show broadcast from a desert hermits RV, featuring a mix of real callers and AI-generated callers talking to Luke about life, love, and everything in between. Call in live: 208-439-LUKE (208-439-5853).</p>
<p>Website: <a href="https://lukeattheroost.com">https://lukeattheroost.com</a></p>
]]></description><podcast:medium>podcast</podcast:medium><itunes:image href="https://podcast.macneilmediagroup.com/media/podcasts/LukeAtTheRoost/cover_feed.png"></itunes:image><language>en</language><podcast:location geo="geo:50.8050519,-0.4525129" osm="W996958777">Smugglers&amp;#039; Roost</podcast:location><podcast:locked owner="luke@macneilmediagroup.com">no</podcast:locked><podcast:social priority="1" platform="castopod" protocol="activitypub" accountId="@LukeAtTheRoost@podcast.macneilmediagroup.com" accountUrl="https://podcast.macneilmediagroup.com/@LukeAtTheRoost"></podcast:social><itunes:category text="Comedy"></itunes:category><category>Comedy</category><itunes:explicit>true</itunes:explicit><itunes:author>MacNeil Media Group, LLC</itunes:author><link>https://podcast.macneilmediagroup.com/@LukeAtTheRoost</link><itunes:owner><itunes:name>Luke MacNeil</itunes:name><itunes:email>luke@macneilmediagroup.com</itunes:email></itunes:owner><itunes:type>episodic</itunes:type><copyright>MacNeil Media Group, LLC</copyright><image><url>https://podcast.macneilmediagroup.com/media/podcasts/LukeAtTheRoost/cover_feed.png</url><title>Luke at the Roost</title><link>https://podcast.macneilmediagroup.com/@LukeAtTheRoost</link></image><item><title>Episode 58: Rayfield's Nephew, the Marfa Lights, and Why Nobody Believes Concho</title><enclosure url="https://op3.dev/e,pg=46cfb731-b4a1-55e2-80f3-cadb1c6c18fa/podcast.macneilmediagroup.com/audio/@LukeAtTheRoost/episode-58-rayfield-s-nephew-the-marfa-lights-and-why-nobody-believes-concho.mp3" length="114501589" type="audio/mpeg"></enclosure><guid>https://podcast.macneilmediagroup.com/@LukeAtTheRoost/episodes/episode-58-rayfield-s-nephew-the-marfa-lights-and-why-nobody-believes-concho</guid><pubDate>Tue, 04 Aug 2026 09:22:38 +0000</pubDate><description><![CDATA[<p>Luke fields calls about stolen catalytic converters, unexplained phenomena over Mitchell Flat, a sheriff's deputy's moral dilemma, and a fence builder convinced someone is mapping West Texas water sources. Plus: Donald Judd's aluminum boxes change everything for a skeptical rancher named Fern.</p>]]></description><itunes:duration>4770</itunes:duration><link>https://podcast.macneilmediagroup.com/@LukeAtTheRoost/episodes/episode-58-rayfield-s-nephew-the-marfa-lights-and-why-nobody-believes-concho</link><itunes:image href="https://podcast.macneilmediagroup.com/media/podcasts/LukeAtTheRoost/cover_feed.png"></itunes:image><itunes:explicit>true</itunes:explicit><itunes:episode>58</itunes:episode><itunes:episodeType>full</itunes:episodeType><podcast:episode>58</podcast:episode><podcast:comments uri="https://podcast.macneilmediagroup.com/@LukeAtTheRoost/episodes/episode-58-rayfield-s-nephew-the-marfa-lights-and-why-nobody-believes-concho/comments" contentType="application/podcast-activity+json"></podcast:comments><podcast:transcript url="https://podcast.macneilmediagroup.com/media/podcasts/LukeAtTheRoost/episode-58-rayfield-s-nephew-the-marfa-lights-and-why-nobody-believes-concho.srt" type="application/x-subrip" rel="captions" language="en"></podcast:transcript><podcast:chapters url="https://podcast.macneilmediagroup.com/media/podcasts/LukeAtTheRoost/episode-58-rayfield-s-nephew-the-marfa-lights-and-why-nobody-believes-concho-chapters.json" type="application/json+chapters"></podcast:chapters></item><item><title>Episode 57: Trace's Box of Family Secrets</title><enclosure url="https://op3.dev/e,pg=46cfb731-b4a1-55e2-80f3-cadb1c6c18fa/podcast.macneilmediagroup.com/audio/@LukeAtTheRoost/episode-57-trace-s-box-of-family-secrets.mp3" length="97583646" type="audio/mpeg"></enclosure><guid>https://podcast.macneilmediagroup.com/@LukeAtTheRoost/episodes/episode-57-trace-s-box-of-family-secrets</guid><pubDate>Tue, 02 Jun 2026 11:32:58 +0000</pubDate><description><![CDATA[<p>Luke hosts another late-night call-in show from Alpine, Texas, featuring intense conversations about personal dilemmas. Callers include Silas struggling with a community move, a landman wrestling with an ethical choice, and Trace discovering hidden letters with a potentially life-changing secret. What happens when the past suddenly becomes present?</p>
]]></description><itunes:duration>4066</itunes:duration><link>https://podcast.macneilmediagroup.com/@LukeAtTheRoost/episodes/episode-57-trace-s-box-of-family-secrets</link><itunes:image href="https://podcast.macneilmediagroup.com/media/podcasts/LukeAtTheRoost/cover_feed.png"></itunes:image><itunes:explicit>true</itunes:explicit><itunes:episode>57</itunes:episode><itunes:episodeType>full</itunes:episodeType><podcast:episode>57</podcast:episode><podcast:comments uri="https://podcast.macneilmediagroup.com/@LukeAtTheRoost/episodes/episode-57-trace-s-box-of-family-secrets/comments" contentType="application/podcast-activity+json"></podcast:comments><podcast:socialInteract uri="https://podcast.macneilmediagroup.com/@LukeAtTheRoost/posts/418bda4c-56df-47c3-ae29-6e3eb4b70a50" priority="1" platform="castopod" protocol="activitypub" accountId="@LukeAtTheRoost@podcast.macneilmediagroup.com" pubDate="2026-06-02T11:32:58+0000"></podcast:socialInteract><podcast:transcript url="https://podcast.macneilmediagroup.com/media/podcasts/LukeAtTheRoost/episode-57-trace-s-box-of-family-secrets.srt" type="application/x-subrip" rel="captions" language="en"></podcast:transcript><podcast:chapters url="https://podcast.macneilmediagroup.com/media/podcasts/LukeAtTheRoost/episode-57-trace-s-box-of-family-secrets-chapters.json" type="application/json+chapters"></podcast:chapters></item></channel></rss>
+121
View File
@@ -0,0 +1,121 @@
import pytest
from backend.services.caller_gen import CallerIdentity, parse_batch_response
SAMPLE_JSON = """
{
"callers": [
{
"name": "Danny Ortega",
"age": 47,
"voice_suggestion": "Marcus",
"location": "Silver City, NM",
"identity": "A plumber who inherited his uncle's taxidermy shop...",
"situation": "He's been getting calls from people...",
"reason_calling": "Someone left a note in his mailbox tonight...",
"opening_line": "Luke, I need to ask you something weird.",
"secret_want": "Permission to just throw it all away",
"specific_details": ["the elk head in the basement", "the note said 'she forgot'", "his uncle's Rolodex"],
"emotional_register": "quietly unsettled, trying to sound casual"
}
]
}
"""
def test_parse_batch_response_returns_caller_list():
callers = parse_batch_response(SAMPLE_JSON)
assert len(callers) == 1
assert callers[0].name == "Danny Ortega"
assert callers[0].age == 47
assert "taxidermy" in callers[0].identity
assert len(callers[0].specific_details) == 3
def test_parse_batch_response_rejects_missing_fields():
bad = '{"callers": [{"name": "Jim"}]}'
with pytest.raises(ValueError, match="missing"):
parse_batch_response(bad)
FENCED_JSON = '''```json
{"callers": [{"name": "Terrence", "age": 61, "voice_suggestion": "Marcus", "location": "Tucumcari, NM", "identity": "Retired irrigation engineer", "situation": "Reading water bill", "reason_calling": "Found clause", "opening_line": "Luke.", "secret_want": "Vindication", "specific_details": ["a", "b"], "emotional_register": "intense"}]}
```'''
def test_parse_batch_response_strips_markdown_fences():
callers = parse_batch_response(FENCED_JSON)
assert len(callers) == 1
assert callers[0].name == "Terrence"
def test_resolve_voice_matches_exact():
from backend.services.caller_gen import resolve_voice
roster = ["Marcus", "Dennis", "Priya", "Edward"]
assert resolve_voice("Marcus", roster) == "Marcus"
def test_resolve_voice_case_insensitive():
from backend.services.caller_gen import resolve_voice
roster = ["Marcus", "Dennis"]
assert resolve_voice("marcus", roster) == "Marcus"
def test_resolve_voice_falls_back_when_no_match():
from backend.services.caller_gen import resolve_voice
roster = ["Marcus", "Dennis"]
# Random fallback, so hallucinated voices don't all collapse onto roster[0]
assert resolve_voice("Santiago", roster) in roster
def test_resolve_voice_fallback_spreads_across_roster():
from backend.services.caller_gen import resolve_voice
roster = ["Marcus", "Dennis", "Priya", "Edward"]
picked = {resolve_voice("Santiago", roster) for _ in range(60)}
assert len(picked) > 1, "fallback collapsed onto a single voice"
def test_resolve_voice_empty_suggestion_falls_back():
from backend.services.caller_gen import resolve_voice
assert resolve_voice("", ["Marcus"]) == "Marcus"
def test_build_batch_prompt_includes_context():
from backend.services.caller_gen import build_batch_prompt
ctx = {
"date": "Saturday, April 5, 2026",
"weather": "cool desert night, 48°F",
"headlines": ["New Mexico legislature approves water bill"],
"recent_caller_summaries": ["Jerry called about his neighbor's goat"],
"regulars_included": [],
"caller_count": 12,
"voice_roster": ["Marcus", "Dennis", "Priya"],
}
prompt = build_batch_prompt(ctx)
assert "Saturday, April 5, 2026" in prompt
assert "water bill" in prompt
assert "Jerry called about his neighbor's goat" in prompt
assert "12 callers" in prompt
assert "Marcus" in prompt # voice roster listed
assert "Stern" in prompt
assert "Coast to Coast" in prompt
assert "Loveline" in prompt
assert "Delilah" in prompt
assert "Opie and Anthony" in prompt
def test_build_batch_prompt_includes_silas_lore_when_present():
from backend.services.caller_gen import build_batch_prompt
ctx = {
"date": "...",
"weather": "...",
"headlines": [],
"recent_caller_summaries": [],
"regulars_included": [{"name": "Silas", "voice": "Sebastian", "age": 52, "lore": "Silas leads a small desert cult...", "arc_state": "seeking new members"}],
"caller_count": 12,
"voice_roster": ["Marcus"],
}
prompt = build_batch_prompt(ctx)
assert "Silas" in prompt
assert "desert cult" in prompt
assert "seeking new members" in prompt
assert "DO NOT alter his voice, personality, or core traits" in prompt
+57
View File
@@ -0,0 +1,57 @@
from backend.main import get_caller_prompt
def test_prompt_includes_identity_and_situation():
caller = {
"name": "Danny",
"identity": "A plumber who inherited a taxidermy shop",
"situation": "Getting strange calls about taxidermy",
"reason_calling": "Someone left a note",
"secret_want": "Permission to throw it all away",
"specific_details": ["elk head in basement", "note said she forgot"],
}
prompt = get_caller_prompt(caller)
assert "Danny" in prompt
assert "taxidermy shop" in prompt
assert "strange calls" in prompt
assert "elk head" in prompt
assert "she forgot" in prompt
assert "Permission to throw it all away" in prompt
assert "React to what Luke says" in prompt
assert "Stay in character" in prompt
assert "NEVER use asterisks" in prompt
assert "NEVER use parenthetical stage directions" in prompt
assert "Mix short punchy replies with longer ones" in prompt
assert "YOU CAN BE MOVED" in prompt
assert "NEVER restate the same dilemma" in prompt
assert len(prompt) < 3500
def test_prompt_includes_opening_line():
caller = {
"name": "Tina",
"identity": "A nurse who just got off a 16-hour shift",
"situation": "Found her ex's wedding invitation in her mailbox",
"reason_calling": "She's not sure if she should go",
"secret_want": "Wants someone to tell her she's over him",
"opening_line": "Luke, I literally just got home from work and there's this gold envelope sitting on my kitchen counter.",
"specific_details": ["invitation was hand-addressed", "wedding is in two weeks"],
}
prompt = get_caller_prompt(caller)
assert "gold envelope" in prompt
assert "FIRST message" in prompt
assert "listening for" in prompt.lower() or "been listening" in prompt.lower()
def test_prompt_without_opening_line():
caller = {
"name": "Ray",
"identity": "A retired mechanic",
"situation": "Neighbor's dog dug up something weird",
"reason_calling": "He thinks it might be human bones",
"secret_want": "Doesn't want to call the cops on his neighbor",
"specific_details": ["bones were wrapped in a tarp"],
}
prompt = get_caller_prompt(caller)
assert "FIRST message" not in prompt
assert "planned opening" not in prompt.lower()
+94
View File
@@ -0,0 +1,94 @@
import shutil
from pathlib import Path
import pytest
from generate_episode_pages import generate
FIXTURE_FEED = Path(__file__).parent / "fixtures" / "feed_sample.xml"
EP58_SLUG = "episode-58-rayfield-s-nephew-the-marfa-lights-and-why-nobody-believes-concho"
EP57_SLUG = "episode-57-trace-s-box-of-family-secrets"
@pytest.fixture
def feed_file(tmp_path):
dest = tmp_path / "feed.xml"
shutil.copyfile(FIXTURE_FEED, dest)
return dest
@pytest.fixture
def transcripts_dir(tmp_path):
d = tmp_path / "transcripts"
d.mkdir()
(d / f"{EP58_SLUG}.txt").write_text(
"LUKE: Marfa Lights, line one.\n\nCONCHO: Nobody believes me.\n"
)
(d / f"{EP57_SLUG}.txt").write_text("LUKE: Trace, what's in the box?\n\nTRACE: Letters.\n")
return d
@pytest.fixture
def out_root(tmp_path):
return tmp_path / "site"
def test_writes_one_index_html_per_feed_episode(feed_file, transcripts_dir, out_root):
n = generate(feed_file, transcripts_dir, out_root)
assert n == 2
assert (out_root / "episode" / EP58_SLUG / "index.html").exists()
assert (out_root / "episode" / EP57_SLUG / "index.html").exists()
def test_orphan_transcript_without_feed_item_is_skipped(feed_file, transcripts_dir, out_root):
"""episode-32 has a transcript but was never published to the feed."""
(transcripts_dir / "episode-32-tacos-taxes-and-tall-tales.txt").write_text("LUKE: Hi.")
generate(feed_file, transcripts_dir, out_root)
assert not (out_root / "episode" / "episode-32-tacos-taxes-and-tall-tales").exists()
def test_missing_transcript_still_produces_a_page(feed_file, transcripts_dir, out_root):
"""An episode published before its transcript lands must not break the build."""
for f in transcripts_dir.glob("*.txt"):
f.unlink()
n = generate(feed_file, transcripts_dir, out_root)
assert n == 2
html = next((out_root / "episode").rglob("index.html")).read_text()
assert "Transcript not yet available" in html
def test_dry_run_writes_nothing(feed_file, transcripts_dir, out_root):
n = generate(feed_file, transcripts_dir, out_root, dry_run=True)
assert n == 2
assert not out_root.exists()
def test_transcript_content_lands_in_the_page(feed_file, transcripts_dir, out_root):
generate(feed_file, transcripts_dir, out_root)
html = (out_root / "episode" / EP58_SLUG / "index.html").read_text()
assert "transcript-turn" in html
assert "Nobody believes me." in html
def test_pages_link_to_each_other(feed_file, transcripts_dir, out_root):
"""Prev/next links are how a crawler reaches all 57 pages."""
generate(feed_file, transcripts_dir, out_root)
pages = list((out_root / "episode").rglob("index.html"))
combined = "\n".join(p.read_text() for p in pages)
assert combined.count("/episode/") >= len(pages)
def test_prev_points_older_and_next_points_newer(feed_file, transcripts_dir, out_root):
generate(feed_file, transcripts_dir, out_root)
newest = (out_root / "episode" / EP58_SLUG / "index.html").read_text()
oldest = (out_root / "episode" / EP57_SLUG / "index.html").read_text()
assert f'rel="prev" href="/episode/{EP57_SLUG}/"' in newest
assert 'rel="next"' not in newest
assert f'rel="next" href="/episode/{EP58_SLUG}/"' in oldest
assert 'rel="prev"' not in oldest
def test_accepts_string_paths(feed_file, transcripts_dir, out_root):
n = generate(str(feed_file), str(transcripts_dir), str(out_root))
assert n == 2
+121
View File
@@ -0,0 +1,121 @@
import json
import re
import pytest
from website_gen.feed import Episode
from website_gen.render import render_episode_page
@pytest.fixture
def sample_episode():
return Episode(
number=58,
slug="episode-58-rayfield-s-nephew-the-marfa-lights-and-why-nobody-believes-concho",
title="Episode 58: Rayfield's Nephew, the Marfa Lights, and Why Nobody Believes Concho",
description=(
"Luke fields calls about stolen catalytic converters, unexplained phenomena over "
"Mitchell Flat, a sheriff's deputy's moral dilemma, and a fence builder convinced "
"someone is mapping West Texas water sources."
),
published_iso="2026-08-04T09:22:38+00:00",
duration_seconds=4770,
audio_url=(
"https://podcast.macneilmediagroup.com/audio/@LukeAtTheRoost/"
"episode-58-rayfield-s-nephew-the-marfa-lights-and-why-nobody-believes-concho.mp3"
),
)
@pytest.fixture
def other_episode():
return Episode(
number=57,
slug="episode-57-trace-s-box-of-family-secrets",
title="Episode 57: Trace's Box of Family Secrets",
description="Letters in a shoebox turn a family story inside out.",
published_iso="2026-06-02T11:32:58+00:00",
duration_seconds=4066,
audio_url=(
"https://podcast.macneilmediagroup.com/audio/@LukeAtTheRoost/"
"episode-57-trace-s-box-of-family-secrets.mp3"
),
)
def test_title_and_canonical_are_episode_specific(sample_episode):
html = render_episode_page(sample_episode, turns=[("LUKE", "Hello.")])
assert "<title>Episode 58: Rayfield" in html
assert '<link rel="canonical" href="https://lukeattheroost.com/episode/episode-58-' in html
def test_transcript_is_in_the_html_not_fetched_by_js(sample_episode):
html = render_episode_page(sample_episode, turns=[("LUKE", "The Marfa Lights are real.")])
assert "The Marfa Lights are real." in html
assert "fetch(" not in html
def test_emits_valid_podcastepisode_schema(sample_episode):
html = render_episode_page(sample_episode, turns=[("LUKE", "Hi.")])
block = re.search(r'<script type="application/ld\+json">(.*?)</script>', html, re.S).group(1)
data = json.loads(block)
types = {o["@type"] for o in (data if isinstance(data, list) else [data])}
assert "PodcastEpisode" in types
def test_schema_carries_big_bend_content_location(sample_episode):
html = render_episode_page(sample_episode, turns=[])
block = re.search(r'<script type="application/ld\+json">(.*?)</script>', html, re.S).group(1)
data = json.loads(block)
ep = next(o for o in data if o["@type"] == "PodcastEpisode")
assert ep["contentLocation"]["address"]["addressLocality"] == "Alpine"
def test_escapes_html_in_transcript_text(sample_episode):
html = render_episode_page(sample_episode, turns=[("LUKE", "5 < 6 & <script>alert(1)</script>")])
assert "<script>alert(1)</script>" not in html
assert "&lt;script&gt;" in html
def test_escapes_quotes_in_title_meta(sample_episode):
sample_episode.title = 'Episode 1: The "Best" Show'
html = render_episode_page(sample_episode, turns=[])
assert 'content="Episode 1: The "Best"' not in html
def test_schema_json_is_valid_even_with_quotes_in_title(sample_episode):
"""JSON-LD must stay parseable when the title contains quotes and apostrophes."""
sample_episode.title = 'Ep "quoted" and Rayfield\'s'
html = render_episode_page(sample_episode, turns=[])
block = re.search(r'<script type="application/ld\+json">(.*?)</script>', html, re.S).group(1)
json.loads(block) # must not raise
def test_speaker_labels_get_semantic_markup(sample_episode):
html = render_episode_page(sample_episode, turns=[("LUKE", "Hi."), ("SLIM", "Hey.")])
assert html.count('class="transcript-turn"') == 2
assert "LUKE" in html and "SLIM" in html
def test_all_asset_paths_are_root_absolute(sample_episode):
"""Pages live at /episode/<slug>/ so relative asset paths would 404."""
html = render_episode_page(sample_episode, turns=[])
assert 'href="css/' not in html
assert 'src="js/' not in html
assert re.search(r'href="/css/style\.css\?v=\d+"', html)
assert 'src="/js/footer.js"' in html
def test_prev_next_links_render_when_given(sample_episode, other_episode):
html = render_episode_page(sample_episode, turns=[], prev_ep=other_episode)
assert f'/episode/{other_episode.slug}/' in html
def test_missing_transcript_renders_placeholder(sample_episode):
html = render_episode_page(sample_episode, turns=[])
assert "Transcript not yet available" in html
def test_audio_element_present_for_no_js_playback(sample_episode):
html = render_episode_page(sample_episode, turns=[])
assert "<audio" in html and sample_episode.audio_url in html
+42
View File
@@ -0,0 +1,42 @@
from pathlib import Path
import pytest
from website_gen.feed import parse_feed
FIXTURE = Path(__file__).parent / "fixtures" / "feed_sample.xml"
@pytest.fixture
def feed_xml():
return FIXTURE.read_text(encoding="utf-8")
def test_parses_core_fields(feed_xml):
eps = parse_feed(feed_xml)
ep = next(e for e in eps if e.number == 58)
assert ep.slug == "episode-58-rayfield-s-nephew-the-marfa-lights-and-why-nobody-believes-concho"
assert ep.title.startswith("Episode 58: Rayfield's Nephew")
assert ep.duration_seconds == 4770
assert ep.audio_url.startswith("https://")
def test_description_is_stripped_of_cdata_and_html(feed_xml):
ep = parse_feed(feed_xml)[0]
assert "<![CDATA[" not in ep.description
assert "<p>" not in ep.description
def test_slug_comes_from_link_and_drops_trailing_slash(feed_xml):
for ep in parse_feed(feed_xml):
assert not ep.slug.endswith("/")
assert "/" not in ep.slug
def test_pubdate_parses_to_iso_date(feed_xml):
ep = next(e for e in parse_feed(feed_xml) if e.number == 58)
assert ep.published_iso.startswith("2026-08-04")
def test_returns_all_items_in_fixture(feed_xml):
assert len(parse_feed(feed_xml)) == 2
-39
View File
@@ -1,39 +0,0 @@
import sys
sys.path.insert(0, "/Users/lukemacneil/ai-podcast")
from backend.main import Session, CallRecord, get_caller_prompt
def test_caller_prompt_includes_show_history():
s = Session()
s.call_history.append(CallRecord(
caller_type="real", caller_name="Dave",
summary="Called about his wife leaving after 12 years",
transcript=[],
))
s.start_call("1") # Tony
caller = s.caller
show_history = s.get_show_history()
prompt = get_caller_prompt(caller, "", show_history)
assert "Dave" in prompt
assert "wife leaving" in prompt
assert "EARLIER IN THE SHOW" in prompt
def test_caller_prompt_without_history():
s = Session()
s.start_call("1")
caller = s.caller
prompt = get_caller_prompt(caller, "")
assert "EARLIER IN THE SHOW" not in prompt
assert caller["name"] in prompt
def test_caller_prompt_backward_compatible():
"""Verify get_caller_prompt works with just 2 args (no show_history)"""
s = Session()
s.start_call("1")
caller = s.caller
prompt = get_caller_prompt(caller, "Host: hello")
assert "hello" in prompt
+90
View File
@@ -0,0 +1,90 @@
"""Guards against the two model-config failures that have bitten us:
a routed model with no pricing entry (silently costs $0.00 on the dashboard),
and a stale model id that 404s at the OpenRouter API."""
from backend.config import settings
from backend.services.cost_tracker import OPENROUTER_PRICING
from backend.services.llm import OPENROUTER_MODELS, LLMService
_CALLER_DIALOG_MODEL_PARAMS = LLMService._CALLER_DIALOG_MODEL_PARAMS
# Retired on OpenRouter — kept here so a reintroduction fails loudly.
RETIRED_MODELS = {
"anthropic/claude-3.5-haiku",
"anthropic/claude-3.5-sonnet",
"anthropic/claude-sonnet-4-5", # note the dash; the live id uses a dot
"google/gemini-flash-1.5",
"mistralai/mistral-small-creative",
"x-ai/grok-4",
"x-ai/grok-4-fast",
"x-ai/grok-4.1-fast",
}
def test_every_routed_model_has_pricing():
"""A routed model missing from OPENROUTER_PRICING records cost as $0.00."""
unpriced = {
cat: model
for cat, model in settings.category_models.items()
if model not in OPENROUTER_PRICING
}
assert not unpriced, f"routed models with no pricing entry: {unpriced}"
def test_no_retired_models_are_routed():
routed = {
cat: model
for cat, model in settings.category_models.items()
if model in RETIRED_MODELS
}
assert not routed, f"category routed to a retired model: {routed}"
def test_no_retired_models_in_the_pool():
stale = sorted(set(OPENROUTER_MODELS) & RETIRED_MODELS)
assert not stale, f"retired models still listed in OPENROUTER_MODELS: {stale}"
def test_no_retired_models_in_caller_dialog_params():
stale = sorted(set(_CALLER_DIALOG_MODEL_PARAMS) & RETIRED_MODELS)
assert not stale, f"per-model tuning keyed to retired models: {stale}"
def test_pool_models_are_priced():
"""Anything selectable should be costable."""
unpriced = [m for m in OPENROUTER_MODELS if m not in OPENROUTER_PRICING]
assert not unpriced, f"pool models with no pricing entry: {unpriced}"
def test_no_retired_model_ids_anywhere_in_the_codebase():
"""publish_episode.py, make_clips.py and relabel_transcripts.py each shipped
a retired model id and only failed when someone ran them. Scan every source
file so the next one fails here instead.
cost_tracker.py is exempt: it intentionally keeps retired ids as pricing
keys so historical cost records stay costable.
"""
import pathlib
root = pathlib.Path(__file__).resolve().parent.parent
exempt = {root / "backend" / "services" / "cost_tracker.py",
pathlib.Path(__file__).resolve()}
offenders = {}
for path in root.rglob("*.py"):
if path in exempt:
continue
# Skip venvs, vendored models, and git worktrees (separate checkouts
# on other branches — not this tree's code).
if any(part in {"venv", "mlx_models", ".git", ".claude", ".worktrees",
"node_modules", "remotion-demo"} for part in path.parts):
continue
try:
text = path.read_text(encoding="utf-8", errors="ignore")
except OSError:
continue
hits = sorted(m for m in RETIRED_MODELS if f'"{m}"' in text or f"'{m}'" in text)
if hits:
offenders[str(path.relative_to(root))] = hits
assert not offenders, f"retired model ids still referenced: {offenders}"
+35
View File
@@ -0,0 +1,35 @@
import re
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
LEGACY = "episode.html?slug="
def _files():
for pattern in ("website/*.html", "website/js/*.js", "website/llms.txt", "*.py"):
yield from ROOT.glob(pattern)
def test_no_source_file_builds_a_legacy_episode_url():
offenders = []
for f in _files():
if f.name == "_worker.js":
continue # the worker's reference is the redirect itself
if LEGACY in f.read_text(errors="replace"):
offenders.append(str(f.relative_to(ROOT)))
assert not offenders, f"legacy episode URLs still present: {offenders}"
def test_client_rendered_episode_page_is_gone():
assert not (ROOT / "website" / "episode.html").exists()
assert not (ROOT / "website" / "js" / "episode.js").exists()
def test_worker_still_redirects_legacy_urls():
worker = (ROOT / "website" / "_worker.js").read_text()
assert "/episode.html" in worker and "301" in worker
def test_redirects_file_has_no_dead_episode_target():
redirects = (ROOT / "website" / "_redirects").read_text()
assert "/episode 302" not in redirects
+52
View File
@@ -0,0 +1,52 @@
"""Whisper mishears recurring show names; correct them after transcription.
publish_episode.py transcribes with LightningWhisperMLX, whose transcribe()
signature is (audio_path, language) it accepts no initial_prompt, so there is
no way to condition it on proper nouns the way make_clips.py does with
mlx_whisper. The intern came out as "Devin" in 21 of 58 published transcripts
because of this.
A deterministic pass over the finished text fixes the known names without
swapping the transcription engine.
"""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from publish_episode import fix_proper_nouns
def test_corrects_the_intern_name_in_all_casings():
assert fix_proper_nouns("Devin, where's my coffee?") == "Devon, where's my coffee?"
assert fix_proper_nouns("DEVIN: Hey Luke.") == "DEVON: Hey Luke."
assert fix_proper_nouns("devin said so") == "devon said so"
def test_leaves_correct_spelling_alone():
text = "Devon is the intern. DEVON: Hi."
assert fix_proper_nouns(text) == text
def test_only_matches_whole_words():
"""Must not corrupt a longer word that happens to contain the name."""
assert fix_proper_nouns("Devinshire") == "Devinshire"
assert fix_proper_nouns("mcdevins") == "mcdevins"
def test_preserves_surrounding_text_exactly():
src = "LUKE: Alright. Let's check in with Devin and see how he's doing.\n\nDEVIN: Hey!"
out = fix_proper_nouns(src)
assert out == "LUKE: Alright. Let's check in with Devon and see how he's doing.\n\nDEVON: Hey!"
assert len(out) == len(src)
def test_empty_and_none_safe():
assert fix_proper_nouns("") == ""
assert fix_proper_nouns(None) == ""
def test_word_count_is_never_changed():
src = "Devin talked to Devin about Devin. " * 20
assert len(fix_proper_nouns(src).split()) == len(src.split())
+80
View File
@@ -0,0 +1,80 @@
"""Publishing must rebuild the static episode pages and the sitemap.
Before static pages existed, publish_episode.py appended one entry to
sitemap.xml itself. That appender is gone generate_episode_pages.py owns the
sitemap now so the publish has to invoke it, or a newly published episode
would have no page and never reach the sitemap.
A generator failure must never abort a publish: by the time this runs the audio
is already live on Castopod and the RSS feed has been rebuilt.
"""
import subprocess
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
import publish_episode
class _Result:
def __init__(self, returncode=0, stderr=""):
self.returncode = returncode
self.stderr = stderr
self.stdout = ""
def test_invokes_the_generator_with_sitemap(monkeypatch):
calls = []
monkeypatch.setattr(subprocess, "run", lambda cmd, **kw: calls.append(cmd) or _Result())
assert publish_episode.regenerate_website_pages() is True
assert len(calls) == 1
cmd = calls[0]
assert "generate_episode_pages.py" in " ".join(cmd)
assert "--sitemap" in cmd
def test_uses_the_running_interpreter(monkeypatch):
"""Must not shell out to a bare 'python' that may not have the venv."""
calls = []
monkeypatch.setattr(subprocess, "run", lambda cmd, **kw: calls.append(cmd) or _Result())
publish_episode.regenerate_website_pages()
assert calls[0][0] == sys.executable
def test_generator_failure_is_not_fatal(monkeypatch):
monkeypatch.setattr(subprocess, "run",
lambda cmd, **kw: _Result(returncode=1, stderr="boom"))
assert publish_episode.regenerate_website_pages() is False
def test_generator_timeout_is_not_fatal(monkeypatch):
def _boom(cmd, **kw):
raise subprocess.TimeoutExpired(cmd, 300)
monkeypatch.setattr(subprocess, "run", _boom)
assert publish_episode.regenerate_website_pages() is False
def test_missing_generator_is_not_fatal(monkeypatch):
def _boom(cmd, **kw):
raise OSError("no such file")
monkeypatch.setattr(subprocess, "run", _boom)
assert publish_episode.regenerate_website_pages() is False
def test_publish_flow_calls_it_after_copying_the_transcript():
"""Guards the wiring, not just the helper."""
source = Path(publish_episode.__file__).read_text()
copy_at = source.index("Transcript copied to website/transcripts/")
call_at = source.index("regenerate_website_pages()", copy_at)
assert call_at > copy_at
+140
View File
@@ -0,0 +1,140 @@
"""A --resume run must not invent the episode title.
Episode 58's first run published to Castopod, then died on the YouTube upload
(see test_youtube_tags.py). The re-run with --resume rebuilt the title from the
URL slug, which is lowercase and punctuation-free, so
"Episode 58: Rayfield's Nephew, the Marfa Lights, and Why Nobody Believes Concho"
went to YouTube as
"Episode 58: Rayfield S Nephew The Marfa Lights And Why Nobody Believes Concho"
with the description replaced by a placeholder.
"""
import json
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from publish_episode import recover_metadata, save_metadata, _decode_db_row
EP58_TITLE = "Episode 58: Rayfield's Nephew, the Marfa Lights, and Why Nobody Believes Concho"
EP58_SLUG = "episode-58-rayfield-s-nephew-the-marfa-lights-and-why-nobody-believes-concho"
EP58_DESC = "Rayfield calls about his nephew and the catalytic converters."
CHAPTERS = [{"startTime": 0, "title": "Intro"}]
def test_metadata_file_roundtrips_losslessly(tmp_path):
path = tmp_path / "ep58.metadata.json"
save_metadata({"title": EP58_TITLE, "description": EP58_DESC,
"chapters": CHAPTERS, "thumbnail_text": "RAYFIELD"}, str(path))
meta = recover_metadata(58, EP58_SLUG, path, CHAPTERS)
assert meta["title"] == EP58_TITLE
assert meta["description"] == EP58_DESC
def test_metadata_file_wins_over_slug(tmp_path):
"""The exact ep58 regression: apostrophes, commas and case must survive."""
path = tmp_path / "ep58.metadata.json"
save_metadata({"title": EP58_TITLE, "description": EP58_DESC,
"chapters": CHAPTERS, "thumbnail_text": "RAYFIELD"}, str(path))
meta = recover_metadata(58, EP58_SLUG, path, CHAPTERS)
assert "Rayfield S Nephew" not in meta["title"]
assert "Rayfield's Nephew" in meta["title"]
assert "the Marfa Lights" in meta["title"]
assert meta["description"] != "Episode 58 of Luke at the Roost."
def test_falls_back_to_castopod_db_when_file_missing(tmp_path):
path = tmp_path / "missing.metadata.json"
calls = []
def db_lookup(episode_number):
calls.append(episode_number)
return {"title": EP58_TITLE, "description": EP58_DESC}
meta = recover_metadata(58, EP58_SLUG, path, CHAPTERS, db_lookup=db_lookup)
assert calls == [58]
assert meta["title"] == EP58_TITLE
assert meta["description"] == EP58_DESC
def test_slug_fallback_only_when_file_and_db_unavailable(tmp_path, capsys):
path = tmp_path / "missing.metadata.json"
meta = recover_metadata(58, EP58_SLUG, path, CHAPTERS,
db_lookup=lambda n: None)
assert meta["title"].startswith("Episode 58: ")
warning = capsys.readouterr().out
assert "lossy" in warning.lower() or "warning" in warning.lower()
def test_slug_fallback_does_not_title_case_away_real_words(tmp_path):
"""Even degraded, the fallback should not be silently trusted."""
meta = recover_metadata(58, EP58_SLUG, tmp_path / "nope.json", CHAPTERS,
db_lookup=lambda n: None)
assert meta.get("title_is_reconstructed") is True
def test_episode_prefix_stripped_only_at_start(tmp_path):
"""A global replace would also eat the phrase inside the title."""
slug = "episode-5-the-episode-5-mixup"
meta = recover_metadata(5, slug, tmp_path / "nope.json", CHAPTERS,
db_lookup=lambda n: None)
assert meta["title"].lower().count("episode 5") == 2
def test_chapters_come_from_chapters_json_not_metadata_file(tmp_path):
path = tmp_path / "ep58.metadata.json"
save_metadata({"title": EP58_TITLE, "description": EP58_DESC,
"chapters": [{"startTime": 999, "title": "Stale"}],
"thumbnail_text": "X"}, str(path))
meta = recover_metadata(58, EP58_SLUG, path, CHAPTERS)
assert meta["chapters"] == CHAPTERS
def test_decodes_base64_wrapped_by_mariadb():
"""TO_BASE64 wraps at 76 chars and mysql renders those breaks as a literal
backslash-n, which b64decode rejects outright."""
import base64 as b64
payload = json.dumps({"title": EP58_TITLE, "description": EP58_DESC})
encoded = b64.b64encode(payload.encode()).decode()
wrapped = "\\n".join(encoded[i:i + 76] for i in range(0, len(encoded), 76))
assert "\\n" in wrapped, "test needs a payload long enough to wrap"
row = _decode_db_row(wrapped)
assert row["title"] == EP58_TITLE
assert row["description"] == EP58_DESC
def test_decodes_base64_with_real_newlines():
import base64 as b64
payload = json.dumps({"title": EP58_TITLE, "description": EP58_DESC})
encoded = b64.b64encode(payload.encode()).decode()
wrapped = "\n".join(encoded[i:i + 76] for i in range(0, len(encoded), 76))
assert _decode_db_row(wrapped)["title"] == EP58_TITLE
def test_decode_db_row_returns_none_on_garbage():
assert _decode_db_row("") is None
assert _decode_db_row("not base64 at all !!!") is None
def test_save_metadata_keeps_only_publishable_fields(tmp_path):
path = tmp_path / "m.json"
save_metadata({"title": EP58_TITLE, "description": EP58_DESC,
"chapters": CHAPTERS, "thumbnail_text": "RAYFIELD",
"transcript": "huge blob that should not be persisted"}, str(path))
saved = json.loads(path.read_text())
assert "transcript" not in saved
assert saved["title"] == EP58_TITLE
+92
View File
@@ -0,0 +1,92 @@
import asyncio
from unittest.mock import AsyncMock, MagicMock, patch
from backend.services.regulars_v2 import Regular, load_regular, REGULARS_DIR, SILAS_DIR
def test_load_regular_parses_frontmatter_and_body(tmp_path):
lore_file = tmp_path / "silas.md"
lore_file.write_text("""---
name: Silas
voice: Dennis
age: 54
arc_state: Cult is splintering after the eclipse failure
---
# Silas
Silas runs a small desert cult outside Truth or Consequences...
## Arc Log
- 2026-03-01: First call, introduced the cult
- 2026-03-20: Prophesied the eclipse
""")
reg = load_regular(lore_file)
assert reg.name == "Silas"
assert reg.voice == "Dennis"
assert reg.age == 54
assert "splintering" in reg.arc_state
assert "Silas runs a small desert cult" in reg.lore_body
def test_evaluate_promotion_returns_arc_plan_when_worthy():
from backend.services.regulars_v2 import evaluate_promotion
fake_response = {
"promote": True,
"arc_plan": "3 episodes. He'll start distant, then reveal he's actually the one who damaged the car, then resolve with an apology.",
"reason": "Has clear internal conflict with room to grow",
}
with patch("backend.services.regulars_v2._call_sonnet", new=AsyncMock(return_value=fake_response)):
result = asyncio.run(evaluate_promotion(caller_name="Bobby", call_transcript="..."))
assert result["promote"] is True
assert "3 episodes" in result["arc_plan"]
def test_evaluate_promotion_rejects_when_no_arc():
from backend.services.regulars_v2 import evaluate_promotion
fake_response = {"promote": False, "arc_plan": None, "reason": "One-note complaint, no growth"}
with patch("backend.services.regulars_v2._call_sonnet", new=AsyncMock(return_value=fake_response)):
result = asyncio.run(evaluate_promotion(caller_name="Carl", call_transcript="..."))
assert result["promote"] is False
def test_call_sonnet_strips_markdown_fences():
from backend.services.regulars_v2 import _call_sonnet
fake_resp = MagicMock()
fake_resp.raise_for_status = MagicMock()
fake_resp.json = MagicMock(return_value={
"choices": [{"message": {"content": "```json\n{\"promote\": true, \"arc_plan\": \"ok\"}\n```"}}],
"usage": {"prompt_tokens": 10, "completion_tokens": 5},
})
mock_client = MagicMock()
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=None)
mock_client.post = AsyncMock(return_value=fake_resp)
with patch("backend.services.regulars_v2.httpx.AsyncClient", return_value=mock_client), \
patch("backend.services.regulars_v2.cost_tracker.record_llm_call"):
result = asyncio.run(_call_sonnet("prompt"))
assert result["promote"] is True
assert result["arc_plan"] == "ok"
def test_write_new_regular_creates_lore_file(tmp_path, monkeypatch):
monkeypatch.setattr("backend.services.regulars_v2.REGULARS_DIR", tmp_path)
from backend.services.regulars_v2 import write_new_regular
write_new_regular(
name="Bobby",
voice="Marcus",
age=34,
identity_paragraph="A landscaper in Las Cruces who...",
arc_plan="3 episodes: distant → reveal → apology",
first_call_summary="Called about damaged car",
)
f = tmp_path / "bobby.md"
assert f.exists()
body = f.read_text()
assert "name: Bobby" in body
assert "voice: Marcus" in body
assert "A landscaper in Las Cruces" in body
assert "3 episodes: distant → reveal → apology" in body
+43
View File
@@ -64,6 +64,49 @@ def test_session_get_show_history_summary():
assert "EARLIER IN THE SHOW" in summary
def test_show_history_reactions_constant_is_usable():
from backend.main import SHOW_HISTORY_REACTIONS
assert SHOW_HISTORY_REACTIONS, "generic reaction pool must not be empty"
assert all(isinstance(r, str) and r.strip() for r in SHOW_HISTORY_REACTIONS)
# Each one is interpolated as "...and you {reaction}." so it must not
# carry its own leading/trailing punctuation or an unfilled placeholder.
for r in SHOW_HISTORY_REACTIONS:
assert not r.endswith("."), r
assert "{" not in r, r
def test_build_specific_reaction_falls_back_when_record_has_no_details():
"""A CallRecord with neither key_details nor situation_summary hits the
generic branch this used to raise NameError mid-show."""
from backend.main import SHOW_HISTORY_REACTIONS
s = Session()
bare = CallRecord(
caller_type="ai", caller_name="Jasmine",
summary="Talked about her boss", transcript=[],
)
reaction = s._build_specific_reaction({}, bare)
assert reaction in SHOW_HISTORY_REACTIONS
def test_get_show_history_never_raises_when_reaction_branch_fires(monkeypatch):
"""Force the reaction branch every time so the fallback path is covered
deterministically rather than at its ~15% random rate."""
import backend.main as main
monkeypatch.setattr(main.random, "random", lambda: 0.0)
s = Session()
s.call_history.append(CallRecord(
caller_type="real", caller_name="Dave",
summary="Called about his wife leaving", transcript=[],
))
summary = s.get_show_history()
assert "DAVE" in summary
assert "and you " in summary
def test_session_reset_clears_history():
s = Session()
s.call_history.append(CallRecord(

Some files were not shown because too many files have changed in this diff Show More