41 Commits
Author SHA1 Message Date
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
48 changed files with 5557 additions and 8110 deletions
+6
View File
@@ -59,3 +59,9 @@ upload-history.json
# Claude settings (local)
.claude/
# Git worktrees
.worktrees/
# Scratch/smoke tests (not committed)
scratch/
+48 -43
View File
@@ -1,20 +1,5 @@
# 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`
@@ -42,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`
@@ -58,13 +42,14 @@ Required in `.env`:
- `generate_with_tools()` in llm.py supports OpenRouter function calling for the intern feature
## Caller Generation System
- **CallerBackground dataclass**: Structured output from LLM background generation (JSON mode). Fields: name, age, gender, job, location, reason_for_calling, pool_name, communication_style, energy_level, emotional_state, signature_detail, situation_summary, natural_description, seeds, verbal_fluency, calling_from.
- **Voice-personality matching**: `_match_voices_to_styles()` runs after background generation. 68 voice profiles in `VOICE_PROFILES` (tts.py), 18 style-to-voice mappings in `STYLE_VOICE_PREFERENCES` (main.py). Soft matching — scores voices against style preferences.
- **Adaptive call shapes**: `SHAPE_STYLE_AFFINITIES` maps communication styles to shape weight multipliers. Consecutive shape repeats are dampened.
- **Inter-caller awareness**: Thematic matching in `get_show_history()` scores previous callers by keyword/category overlap. Adaptive reaction frequency (60%/35%/15%). Show energy tracking via `_get_show_energy()`.
- **Caller memory**: Returning callers store structured backgrounds, key moments, arc status, and relationships with other regulars. `RegularCallerService` has `add_relationship()` and expanded `update_after_call()`.
- **Show pacing**: `_sort_caller_queue()` sorts presentation order by energy alternation, topic variety, shape variety.
- **Call quality signals**: `_assess_call_quality()` captures exchange count, response length, host engagement, shape target hit, natural ending.
- **Two-stage pipeline**: (1) batch identity pregen via Sonnet 4.6 at session start, (2) live dialog via Haiku 4.5 per turn. Cost ~$1/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 Haiku 4.5 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
@@ -78,8 +63,8 @@ Required in `.env`:
## 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 call shape, energy level, emotional state, signature detail, situation summary during active calls
- **Caller buttons**: Energy dots (colored by level) and shape badges on each button
- **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
@@ -88,24 +73,44 @@ Required in `.env`:
- **Analytics**: Cloudflare Web Analytics (enable in Cloudflare dashboard, no code changes needed)
- **Deploy**: `npx wrangler pages deploy website/ --project-name=lukeattheroost --branch=main`
## Git Push
- If `mmgnas` times out, use the 10g hostname:
```bash
GIT_SSH_COMMAND="ssh -o HostName=mmgnas-10g -p 2222 -i ~/.ssh/gitea_mmgnas" git push origin main
```
## Hetzner VPS
- **IP**: `46.225.164.41`
- **SSH**: `ssh root@46.225.164.41` (uses default key `~/.ssh/id_rsa`)
- **Specs**: 2 CPU, 4GB RAM, 38GB disk (~33GB free)
- **Mail**: `docker-mailserver` at `/opt/mailserver/`
- **Manage accounts**: `docker exec mailserver setup email add/del/list`
- **Available for future services** — has headroom for lightweight containers. Not suitable for storage-heavy services (e.g. Castopod with daily episodes) without a disk upgrade or attached volume.
## 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 must be verified against existing episodes
- Episode numbering: check Castopod for the latest episode number, don't hardcode
## Episodes Published
- Episode 6 published 2026-02-08 (podcast6.mp3, ~31 min)
## 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
+3 -2
View File
@@ -1,10 +1,11 @@
{
"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,
+1 -2
View File
@@ -34,9 +34,8 @@ class Settings(BaseSettings):
ollama_host: str = "http://localhost:11434"
# Per-category model routing
# caller_dialog is overridden by style_matched routing (see Session.caller_model_map)
category_models: dict = {
"caller_dialog": "x-ai/grok-4.1-fast", # fallback if style_matched disabled ($0.20/$0.50)
"caller_dialog": "anthropic/claude-haiku-4.5", # live caller dialog ($0.80/$4)
"devon_ask": "x-ai/grok-4.1-fast", # Devon matches show energy, cheap ($0.20/$0.50)
"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)
+501 -6930
View File
File diff suppressed because it is too large Load Diff
+189
View File
@@ -0,0 +1,189 @@
from dataclasses import dataclass
from typing import Optional
import json
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; deterministic fallback to first roster entry."""
if not suggestion or not roster:
return roster[0] if roster else ""
lower_map = {v.lower(): v for v in roster}
return lower_map.get(suggestion.lower(), roster[0])
BATCH_SYSTEM_PROMPT = """You are writing a roster of callers for Luke's late-night radio show in New Mexico.
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).
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 conspiracy/pattern-seers, 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.
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("")
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
]
+28
View File
@@ -3,9 +3,12 @@
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:
@@ -95,6 +98,7 @@ def _calc_tts_cost(provider: str, char_count: int) -> float:
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()
@@ -137,6 +141,16 @@ class CostTracker:
)
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
@@ -165,6 +179,15 @@ class CostTracker:
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:
@@ -369,6 +392,11 @@ class CostTracker:
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()
+40 -15
View File
@@ -391,9 +391,14 @@ class InternService:
"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
# Include Devon's own recent conversation history (current show only)
if self._devon_history:
messages.extend(self._devon_history[-10:])
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})
@@ -437,6 +442,11 @@ class InternService:
"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."""
@@ -549,6 +559,7 @@ class InternService:
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:
@@ -563,6 +574,24 @@ class InternService:
)
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():
@@ -588,29 +617,25 @@ class InternService:
def _clean_for_tts(text: str) -> str:
if not text:
return ""
# Strip stage directions BEFORE markdown processing
# Parenthetical: (laughs), (sighs nervously), (clears throat), etc.
text = re.sub(r'\s*\([^)]{1,40}\)\s*', ' ', text)
# Multi-word asterisk stage directions: *sighs deeply*, *nervous laughter*
text = re.sub(r'\s*\*\w+\s[^*]{1,30}\*\s*', ' ', text)
# Single-word asterisk stage directions (known action words only)
_actions = r'(?:laughs?|sighs?|pauses?|smiles?|chuckles?|grins?|nods?|shrugs?|frowns?|coughs?|gasps?|whispers?|mumbles?|gulps?|blinks?|winces?|crying|sobbing)'
text = re.sub(r'\s*\*' + _actions + r'\*\s*', ' ', text, flags=re.IGNORECASE)
# Remove markdown formatting (after stage directions are stripped)
# 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)
# Collapse whitespace
text = re.sub(r'\s+', ' ', text).strip()
# Remove quotes that TTS reads awkwardly
text = text.replace('"', '').replace('"', '').replace('"', '')
# 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
+27 -6
View File
@@ -58,7 +58,7 @@ class LLMService:
@property
def client(self) -> httpx.AsyncClient:
if self._client is None or self._client.is_closed:
self._client = httpx.AsyncClient(timeout=10.0)
self._client = httpx.AsyncClient(timeout=30.0)
return self._client
def update_settings(
@@ -317,7 +317,7 @@ class LLMService:
if model == self.openrouter_model:
continue # Already tried
print(f"[LLM] Falling back to {model}...")
result = await self._call_openrouter_once(messages, model, timeout=8.0, max_tokens=max_tokens, category=category, caller_name=caller_name)
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
@@ -325,18 +325,39 @@ class LLMService:
print("[LLM] All models failed, using canned response")
return "Sorry, I totally blanked out for a second. What were you saying?"
async def _call_openrouter_once(self, messages: list[dict], model: str, timeout: float = 10.0, max_tokens: Optional[int] = None, response_format: Optional[dict] = None, category: str = "unknown", caller_name: str = "") -> str | None:
# 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.1-fast": {"temperature": 0.7, "frequency_penalty": 0.2, "presence_penalty": 0.1},
"x-ai/grok-4": {"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 500,
"temperature": 0.65,
"temperature": params["temperature"],
"top_p": 0.9,
"frequency_penalty": 0.3,
"presence_penalty": 0.15,
"frequency_penalty": params["frequency_penalty"],
"presence_penalty": params["presence_penalty"],
}
if response_format:
payload["response_format"] = response_format
+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
+50 -14
View File
@@ -128,6 +128,11 @@ INWORLD_SPEED_OVERRIDES = {
"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
@@ -710,7 +715,33 @@ def _detect_speech_rate(text: str, base_speed: float) -> float:
return base_speed
async def generate_speech_inworld(text: str, voice_id: str) -> tuple[np.ndarray, int]:
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
@@ -727,9 +758,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")
base_speed = INWORLD_SPEED_OVERRIDES.get(voice, DEFAULT_INWORLD_SPEED)
speed = _detect_speech_rate(text, base_speed)
print(f"[Inworld TTS] Voice: {voice}, Speed: {speed:.2f} (base {base_speed}), 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 = {
@@ -740,6 +772,8 @@ async def generate_speech_inworld(text: str, voice_id: str) -> tuple[np.ndarray,
"text": text,
"voiceId": voice,
"modelId": "inworld-tts-1.5-max",
"temperature": temperature,
"applyTextNormalization": "ON",
"audioConfig": {
"audioEncoding": "LINEAR16",
"sampleRateHertz": 48000,
@@ -797,14 +831,14 @@ def pick_caller_tts_provider() -> str | None:
_TTS_PROVIDERS = {
"kokoro": lambda text, vid: generate_speech_kokoro(text, vid),
"f5tts": lambda text, vid: generate_speech_f5tts(text, vid),
"inworld": lambda text, vid: generate_speech_inworld(text, vid),
"chattts": lambda text, vid: generate_speech_chattts(text, vid),
"styletts2": lambda text, vid: generate_speech_styletts2(text, vid),
"bark": lambda text, vid: generate_speech_bark(text, vid),
"vits": lambda text, vid: generate_speech_vits(text, vid),
"elevenlabs": lambda text, vid: generate_speech_elevenlabs(text, vid),
"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
@@ -816,7 +850,8 @@ async def generate_speech(
voice_id: str,
phone_quality: str = "normal",
apply_filter: bool = True,
provider_override: str = None
provider_override: str = None,
emotional_register: str = "",
) -> bytes:
"""
Generate speech from text with automatic retry on failure.
@@ -827,6 +862,7 @@ async def generate_speech(
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)
@@ -845,7 +881,7 @@ async def generate_speech(
async with asyncio.timeout(20):
for attempt in range(TTS_MAX_RETRIES):
try:
audio, sample_rate = await gen_fn(text, voice_id)
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}")
+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
}
]
}
-266
View File
@@ -1,167 +1,5 @@
{
"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",
@@ -209,110 +47,6 @@
],
"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
}
]
}
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
+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.
+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;
}
}
+28 -219
View File
@@ -108,11 +108,24 @@ header button {
transition: all 0.2s;
}
header button:hover {
header button:hover, .header-link-btn:hover {
background: #3a2e1f;
border-color: rgba(232, 121, 29, 0.3);
}
.header-link-btn {
background: var(--bg-light);
color: var(--text);
border: 1px solid rgba(232, 121, 29, 0.15);
padding: 8px 16px;
border-radius: var(--radius-sm);
cursor: pointer;
transition: all 0.2s;
text-decoration: none;
font-size: inherit;
font-family: inherit;
}
.theme-bar {
display: flex;
align-items: center;
@@ -322,25 +335,6 @@ section h2 {
position: relative;
}
.energy-dot {
width: 8px;
height: 8px;
border-radius: 50%;
display: inline-block;
flex-shrink: 0;
}
.shape-badge {
font-size: 0.6rem;
background: rgba(232, 121, 29, 0.25);
color: var(--accent);
padding: 1px 4px;
border-radius: 3px;
font-weight: bold;
letter-spacing: 0.5px;
flex-shrink: 0;
}
.caller-btn:hover {
border-color: var(--accent);
background: #2a1e10;
@@ -439,20 +433,19 @@ section h2 {
font-weight: 600;
}
.info-badge.shape {
background: rgba(232, 121, 29, 0.2);
color: var(--accent);
.caller-identity {
font-size: 0.85rem;
color: var(--text);
font-weight: 600;
margin-bottom: 4px;
line-height: 1.3;
}
.info-badge.energy {
color: white;
font-size: 0.7rem;
}
.info-badge.emotion {
background: rgba(154, 139, 120, 0.2);
.caller-situation {
font-size: 0.8rem;
color: var(--text-muted);
font-style: italic;
margin-bottom: 4px;
line-height: 1.3;
}
.caller-signature {
@@ -462,90 +455,13 @@ section h2 {
font-style: italic;
}
.caller-situation {
font-size: 0.8rem;
color: var(--text-muted);
.caller-secret-want {
font-size: 0.75rem;
color: #d4a030;
font-style: italic;
line-height: 1.3;
}
/* Caller model indicator */
.info-badge.model {
background: rgba(100, 140, 220, 0.2);
color: #7ab0e8;
font-size: 0.7rem;
cursor: pointer;
}
.caller-model-override {
font-size: 0.7rem;
padding: 2px 4px;
background: var(--bg);
color: var(--text);
border: 1px solid rgba(100, 140, 220, 0.3);
border-radius: 4px;
max-width: 140px;
}
/* Caller button model badge */
.model-tag {
font-size: 0.55rem;
color: #7ab0e8;
background: rgba(100, 140, 220, 0.15);
padding: 0 3px;
border-radius: 2px;
font-weight: 700;
letter-spacing: 0.3px;
flex-shrink: 0;
}
/* Caller Models settings section */
.caller-model-row {
margin-bottom: 8px;
}
.caller-model-row label {
margin-bottom: 0;
}
.cm-pool-input {
font-size: 0.8rem;
}
.cm-style-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 4px;
margin-bottom: 8px;
max-height: 200px;
overflow-y: auto;
}
.cm-style-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 4px;
background: rgba(255, 255, 255, 0.05);
border-radius: 4px;
padding: 3px 6px;
}
.cm-style-name {
font-size: 0.7rem;
color: var(--text-muted);
white-space: nowrap;
}
.cm-style-select {
font-size: 0.7rem;
padding: 2px 3px;
background: var(--bg);
color: var(--text);
border: 1px solid rgba(232, 121, 29, 0.15);
border-radius: 4px;
max-width: 110px;
}
.caller-background-full {
margin-top: 8px;
font-size: 0.75rem;
@@ -1934,110 +1850,3 @@ button:focus-visible {
.log-toggle-btn:hover {
color: var(--text);
}
/* Preflight */
.preflight-btn {
background: rgba(90, 138, 60, 0.15);
color: var(--accent-green);
border: 1px solid rgba(90, 138, 60, 0.3);
}
.preflight-btn:hover {
background: rgba(90, 138, 60, 0.25);
}
.preflight-content {
max-width: 700px;
}
.preflight-status {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 16px;
border-radius: var(--radius-sm);
margin-bottom: 16px;
font-weight: 700;
font-size: 1.1rem;
}
.preflight-status.pass { background: rgba(90, 138, 60, 0.15); color: var(--accent-green); }
.preflight-status.warn { background: rgba(232, 169, 29, 0.15); color: #e8a91d; }
.preflight-status.fail { background: rgba(204, 34, 34, 0.15); color: var(--accent-red); }
.preflight-status.loading { background: rgba(232, 121, 29, 0.1); color: var(--text-muted); }
.preflight-checks {
display: flex;
flex-direction: column;
gap: 12px;
max-height: 60vh;
overflow-y: auto;
}
.preflight-check {
background: var(--bg);
border: 1px solid rgba(232, 121, 29, 0.1);
border-radius: var(--radius-sm);
padding: 12px 16px;
}
.preflight-check-header {
display: flex;
justify-content: space-between;
align-items: center;
cursor: pointer;
user-select: none;
}
.preflight-check-name {
font-weight: 600;
font-size: 0.95rem;
}
.preflight-check-badge {
font-size: 0.75rem;
font-weight: 700;
padding: 2px 8px;
border-radius: 4px;
text-transform: uppercase;
}
.preflight-check-badge.pass { background: rgba(90, 138, 60, 0.2); color: var(--accent-green); }
.preflight-check-badge.warn { background: rgba(232, 169, 29, 0.2); color: #e8a91d; }
.preflight-check-badge.fail { background: rgba(204, 34, 34, 0.2); color: var(--accent-red); }
.preflight-check-badge.skip { background: rgba(154, 139, 120, 0.2); color: var(--text-muted); }
.preflight-check-details {
margin-top: 10px;
font-size: 0.85rem;
color: var(--text-muted);
display: none;
}
.preflight-check.open .preflight-check-details {
display: block;
}
.preflight-table {
width: 100%;
border-collapse: collapse;
margin-top: 8px;
}
.preflight-table th {
text-align: left;
color: var(--text-muted);
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
padding: 4px 8px;
border-bottom: 1px solid rgba(232, 121, 29, 0.1);
}
.preflight-table td {
padding: 4px 8px;
font-size: 0.8rem;
color: var(--text);
border-bottom: 1px solid rgba(232, 121, 29, 0.05);
}
.preflight-table tr.mismatch td { color: var(--accent-red); }
.preflight-table tr.connected td { color: var(--accent-green); }
.preflight-test-btn {
background: rgba(232, 121, 29, 0.15);
color: var(--accent);
border: 1px solid rgba(232, 121, 29, 0.3);
}
.preflight-test-btn:hover { background: rgba(232, 121, 29, 0.25); }
.preflight-test-btn.loading { opacity: 0.6; pointer-events: none; }
+5 -56
View File
@@ -15,8 +15,8 @@
<button id="rec-btn" class="rec-btn" title="Record stems for post-production">REC</button>
<button id="new-session-btn" class="new-session-btn">New Session</button>
<button id="export-session-btn">Export</button>
<button id="preflight-btn" class="preflight-btn">Preflight</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>
@@ -72,15 +72,10 @@
</div>
<div id="call-status" class="call-status">No active call</div>
<div id="caller-info-panel" class="caller-info-panel hidden">
<div class="caller-info-row">
<span id="caller-shape-badge" class="info-badge shape"></span>
<span id="caller-energy-badge" class="info-badge energy"></span>
<span id="caller-emotion" class="info-badge emotion"></span>
<span id="caller-model-badge" class="info-badge model"></span>
<select id="caller-model-override" class="caller-model-override hidden"></select>
</div>
<div id="caller-signature" class="caller-signature"></div>
<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>
@@ -288,36 +283,6 @@
</div>
</div>
<!-- Caller Model Routing -->
<div class="settings-group">
<h3>Caller Models</h3>
<div class="caller-model-row">
<label>
Strategy
<select id="cm-strategy">
<option value="single">Single Model</option>
<option value="cycle">Cycle Models</option>
<option value="style_matched">Style-Matched</option>
</select>
</label>
</div>
<div id="cm-pool-section" class="hidden">
<label>
Model Pool
<input type="text" id="cm-pool" class="cm-pool-input" placeholder="x-ai/grok-4, deepseek/deepseek-v3.2, ...">
</label>
</div>
<div id="cm-style-map" class="hidden">
<div class="cm-style-grid" id="cm-style-grid"></div>
</div>
<div class="caller-model-row">
<label>
Fallback Model
<select id="cm-fallback" class="model-select"></select>
</label>
</div>
</div>
<!-- TTS Settings -->
<div class="settings-group">
<h3>TTS Provider</h3>
@@ -358,24 +323,8 @@
</div>
</div>
</div>
<!-- Preflight Modal -->
<div id="preflight-modal" class="modal hidden">
<div class="modal-content preflight-content">
<h2>Show Preflight</h2>
<div id="preflight-status" class="preflight-status loading">
<span class="preflight-status-icon">...</span>
<span class="preflight-status-text">Running checks...</span>
</div>
<div id="preflight-checks" class="preflight-checks"></div>
<div class="modal-buttons">
<button id="preflight-test-btn" class="preflight-test-btn">Test Responses</button>
<button id="preflight-rerun-btn">Re-run</button>
<button id="close-preflight">Close</button>
</div>
</div>
</div>
</div>
<script src="/js/app.js?v=27"></script>
<script src="/js/app.js?v=28"></script>
</body>
</html>
+38 -401
View File
@@ -131,7 +131,6 @@ document.addEventListener('DOMContentLoaded', async () => {
initEventListeners();
initClock();
loadShowTheme();
loadCallerModels();
loadVoicemails();
setInterval(loadVoicemails, 30000);
loadEmails();
@@ -356,27 +355,6 @@ function initEventListeners() {
else if (e.key === 'Escape') e.target.blur();
});
// Caller Models
document.getElementById('cm-strategy')?.addEventListener('change', () => {
callerModelSettings.strategy = document.getElementById('cm-strategy').value;
updateCallerModelUI();
});
document.getElementById('caller-model-badge')?.addEventListener('click', () => {
const sel = document.getElementById('caller-model-override');
if (!sel || !currentCaller) return;
sel.classList.toggle('hidden');
if (!sel.classList.contains('hidden')) {
const current = callerModelAssignments[currentCaller.key];
if (current) sel.value = current;
}
});
document.getElementById('caller-model-override')?.addEventListener('change', (e) => {
if (currentCaller && e.target.value) {
overrideCallerModel(currentCaller.key, e.target.value);
e.target.classList.add('hidden');
}
});
// Settings
document.getElementById('settings-btn')?.addEventListener('click', async () => {
document.getElementById('settings-modal')?.classList.remove('hidden');
@@ -392,17 +370,6 @@ function initEventListeners() {
});
document.getElementById('refresh-ollama')?.addEventListener('click', refreshOllamaModels);
// Preflight
document.getElementById('preflight-btn')?.addEventListener('click', () => {
document.getElementById('preflight-modal')?.classList.remove('hidden');
runPreflight(false);
});
document.getElementById('preflight-test-btn')?.addEventListener('click', () => runPreflight(true));
document.getElementById('preflight-rerun-btn')?.addEventListener('click', () => runPreflight(false));
document.getElementById('close-preflight')?.addEventListener('click', () => {
document.getElementById('preflight-modal')?.classList.add('hidden');
});
// Wrap-up button
document.getElementById('wrapup-btn')?.addEventListener('click', wrapUp);
@@ -637,21 +604,7 @@ async function loadCallers() {
btn.dataset.key = caller.key;
let html = '';
if (caller.energy_level) {
const energyColors = { low: '#4a7ab5', medium: '#5a8a3c', high: '#e8791d', very_high: '#cc2222' };
const color = energyColors[caller.energy_level] || '#9a8b78';
html += `<span class="energy-dot" style="background:${color}" title="${caller.energy_level} energy"></span>`;
}
html += caller.returning ? `<span class="caller-name">\u2605 ${caller.name}</span>` : `<span class="caller-name">${caller.name}</span>`;
if (caller.call_shape && caller.call_shape !== 'standard') {
const shapeLabels = {
escalating_reveal: 'ER', am_i_the_asshole: 'AITA', confrontation: 'VS',
celebration: '\u{1F389}', quick_hit: 'QH', bait_and_switch: 'B&S',
the_hangup: 'HU', reactive: 'RE'
};
const label = shapeLabels[caller.call_shape] || caller.call_shape.substring(0, 2).toUpperCase();
html += `<span class="shape-badge" title="${caller.call_shape.replace(/_/g, ' ')}">${label}</span>`;
}
// Shortcut label: 1-9 for first 9, 0 for 10th
if (idx < 10) {
const shortcutKey = idx === 9 ? '0' : String(idx + 1);
@@ -669,7 +622,6 @@ async function loadCallers() {
}
console.log('Loaded', data.callers.length, 'callers, session:', data.session_id);
updateCallerModelBadges();
} catch (err) {
console.error('loadCallers error:', err);
}
@@ -742,27 +694,20 @@ async function startCall(key, name) {
if (aiInfo) aiInfo.classList.remove('hidden');
if (aiName) aiName.textContent = name;
// Show caller info panel with structured data
// Show caller info panel with slim background data
const infoPanel = document.getElementById('caller-info-panel');
if (infoPanel && data.caller_info) {
const ci = data.caller_info;
const energyColors = { low: '#4a7ab5', medium: '#5a8a3c', high: '#e8791d', very_high: '#cc2222' };
const shapeBadge = document.getElementById('caller-shape-badge');
const energyBadge = document.getElementById('caller-energy-badge');
const emotionBadge = document.getElementById('caller-emotion');
const signature = document.getElementById('caller-signature');
const identity = document.getElementById('caller-identity');
const situation = document.getElementById('caller-situation');
if (shapeBadge) shapeBadge.textContent = (ci.call_shape || 'standard').replace(/_/g, ' ');
if (energyBadge) { energyBadge.textContent = (ci.energy_level || '').replace('_', ' '); energyBadge.style.background = energyColors[ci.energy_level] || '#9a8b78'; }
if (emotionBadge) emotionBadge.textContent = ci.emotional_state || '';
if (signature) signature.textContent = ci.signature_detail ? `"${ci.signature_detail}"` : '';
if (situation) situation.textContent = ci.situation_summary || '';
const signature = document.getElementById('caller-signature');
const secretWant = document.getElementById('caller-secret-want');
if (identity) identity.textContent = ci.identity || '';
if (situation) situation.textContent = ci.situation || '';
if (signature) signature.textContent = ci.signature ? `"${ci.signature}"` : '';
if (secretWant) secretWant.textContent = ci.secret_want ? `secretly wants: ${ci.secret_want}` : '';
infoPanel.classList.remove('hidden');
}
try {
showCallerModelBadge(callerModelAssignments[key] || data.model);
} catch(e) { console.error('[startCall] showCallerModelBadge error:', e); }
document.getElementById('caller-model-override')?.classList.add('hidden');
const bgEl = document.getElementById('caller-background');
if (bgEl && data.background) bgEl.textContent = data.background;
@@ -799,6 +744,14 @@ async function newSession() {
await hangup();
}
const btn = document.getElementById('new-session-btn');
const originalText = btn?.textContent;
if (btn) {
btn.disabled = true;
btn.textContent = 'Generating...';
}
try {
await fetch('/api/session/reset', { method: 'POST' });
conversationSince = 0;
@@ -809,9 +762,14 @@ async function newSession() {
// Reload callers to get new session ID
await loadCallers();
await loadShowTheme();
await loadCallerModels();
log('New session started - all callers have fresh backgrounds');
} finally {
if (btn) {
btn.disabled = false;
btn.textContent = originalText || 'New Session';
}
}
}
@@ -848,8 +806,6 @@ async function hangup() {
document.getElementById('caller-info-panel')?.classList.add('hidden');
const bgDetails2 = document.getElementById('caller-background-details');
if (bgDetails2) bgDetails2.classList.add('hidden');
showCallerModelBadge(null);
document.getElementById('caller-model-override')?.classList.add('hidden');
// Hide AI caller indicator
document.getElementById('ai-caller-info')?.classList.add('hidden');
@@ -1400,8 +1356,14 @@ async function loadShowTheme() {
async function setShowTheme() {
const input = document.getElementById('show-theme-input');
const setBtn = document.getElementById('set-theme-btn');
const theme = input.value.trim();
if (!theme) return;
const originalText = setBtn?.textContent;
if (setBtn) {
setBtn.disabled = true;
setBtn.textContent = 'Regenerating...';
}
try {
const res = await fetch('/api/show-theme', {
method: 'POST',
@@ -1411,11 +1373,19 @@ async function setShowTheme() {
const data = await res.json();
if (data.theme) {
input.classList.add('active');
document.getElementById('set-theme-btn').classList.add('hidden');
setBtn?.classList.add('hidden');
document.getElementById('clear-theme-btn').classList.remove('hidden');
}
// Backend regenerated backgrounds for unused callers — refresh the
// button list so we show the new names, not the pre-theme cache.
await loadCallers();
} catch (e) {
console.error('Failed to set show theme:', e);
} finally {
if (setBtn) {
setBtn.disabled = false;
setBtn.textContent = originalText || 'Set';
}
}
}
@@ -1431,194 +1401,13 @@ async function clearShowTheme() {
input.classList.remove('active');
document.getElementById('set-theme-btn').classList.remove('hidden');
document.getElementById('clear-theme-btn').classList.add('hidden');
await loadCallers();
} catch (e) {
console.error('Failed to clear show theme:', e);
}
}
// --- Caller Model Routing ---
const MODEL_ABBREVS = {
'claude-sonnet-4-5': 'Son', 'claude-haiku-4.5': 'Hai', 'claude-3-haiku': 'H3',
'grok-4': 'Grk', 'grok-4-fast': 'GrF',
'minimax-m2-her': 'MnM', 'mistral-small-creative': 'Mis',
'deepseek-v3.2': 'DSk', 'gemini-2.5-flash': 'Gem', 'gemini-flash-1.5': 'Gm1',
'gpt-4o-mini': '4oM', 'gpt-4o': '4o', 'llama-3.1-8b-instruct': 'Lla',
};
const CALLER_STYLES = [
'quiet_nervous', 'storyteller', 'deadpan', 'high_energy', 'confrontational',
'oversharer', 'philosopher', 'bragger', 'first_time', 'emotional',
'world_weary', 'conspiracy', 'comedian', 'angry_venting', 'sweet_earnest',
'mysterious', 'know_it_all', 'rambling',
];
let callerModelSettings = { strategy: 'single', pool: [], fallback: '', style_map: {} };
let callerModelAssignments = {}; // key -> model_id
function modelAbbrev(modelId) {
const name = (modelId || '').split('/').pop();
return MODEL_ABBREVS[name] || name.substring(0, 3).toUpperCase();
}
async function loadCallerModels() {
try {
const res = await fetch('/api/caller-models');
if (!res.ok) return;
const data = await res.json();
callerModelSettings = {
strategy: data.strategy || 'single',
pool: data.pool || [],
fallback: data.fallback || '',
style_map: data.map || data.style_map || {},
};
callerModelAssignments = data.assignments || {};
updateCallerModelUI();
updateCallerModelBadges();
} catch (e) {
console.error('Failed to load caller models:', e);
}
}
function updateCallerModelUI() {
const strategyEl = document.getElementById('cm-strategy');
if (strategyEl) strategyEl.value = callerModelSettings.strategy;
const poolSection = document.getElementById('cm-pool-section');
const styleMap = document.getElementById('cm-style-map');
if (poolSection) poolSection.classList.toggle('hidden', callerModelSettings.strategy === 'single');
if (styleMap) styleMap.classList.toggle('hidden', callerModelSettings.strategy !== 'style_matched');
const poolInput = document.getElementById('cm-pool');
if (poolInput) poolInput.value = callerModelSettings.pool.join(', ');
// Populate style map grid
const grid = document.getElementById('cm-style-grid');
if (grid && callerModelSettings.strategy === 'style_matched') {
grid.innerHTML = '';
for (const style of CALLER_STYLES) {
const item = document.createElement('div');
item.className = 'cm-style-item';
const label = style.replace(/_/g, ' ');
item.innerHTML = `<span class="cm-style-name">${label}</span>`;
const sel = document.createElement('select');
sel.className = 'cm-style-select';
sel.dataset.style = style;
const models = window._openrouterModels || callerModelSettings.pool;
for (const m of models) {
const opt = document.createElement('option');
opt.value = m;
opt.textContent = m.split('/').pop();
if (m === callerModelSettings.style_map[style]) opt.selected = true;
sel.appendChild(opt);
}
item.appendChild(sel);
grid.appendChild(item);
}
}
// Fallback dropdown
const fallbackEl = document.getElementById('cm-fallback');
if (fallbackEl) {
const currentVal = fallbackEl.value;
fallbackEl.innerHTML = '';
const models = callerModelSettings.pool.length > 0
? callerModelSettings.pool
: (window._openrouterModels || []);
for (const m of models) {
const opt = document.createElement('option');
opt.value = m;
opt.textContent = m.split('/').pop();
if (m === callerModelSettings.fallback) opt.selected = true;
fallbackEl.appendChild(opt);
}
if (!fallbackEl.value && currentVal) fallbackEl.value = currentVal;
}
}
function updateCallerModelBadges() {
document.querySelectorAll('.caller-btn').forEach(btn => {
const key = btn.dataset.key;
const model = callerModelAssignments[key];
let tag = btn.querySelector('.model-tag');
if (model) {
if (!tag) {
tag = document.createElement('span');
tag.className = 'model-tag';
btn.appendChild(tag);
}
tag.textContent = modelAbbrev(model);
tag.title = model;
} else if (tag) {
tag.remove();
}
});
}
function showCallerModelBadge(model) {
const badge = document.getElementById('caller-model-badge');
if (badge) {
badge.textContent = model ? `via ${modelAbbrev(model)}` : '';
badge.title = model || '';
badge.classList.toggle('hidden', !model);
}
}
function populateCallerModelOverride() {
const sel = document.getElementById('caller-model-override');
if (!sel) return;
sel.innerHTML = '';
const models = window._openrouterModels || [];
for (const m of models) {
const opt = document.createElement('option');
opt.value = m;
opt.textContent = m.split('/').pop();
sel.appendChild(opt);
}
}
async function overrideCallerModel(callerKey, modelId) {
try {
const res = await fetch(`/api/caller-models/${callerKey}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model: modelId })
});
if (!res.ok) throw new Error(res.status);
callerModelAssignments[callerKey] = modelId;
showCallerModelBadge(modelId);
updateCallerModelBadges();
log(`Model override: ${currentCaller?.name || callerKey}${modelAbbrev(modelId)}`);
} catch (err) {
log('Model override failed: ' + err.message);
}
}
async function saveCallerModels() {
const strategy = document.getElementById('cm-strategy')?.value || 'single';
const poolRaw = document.getElementById('cm-pool')?.value || '';
const pool = poolRaw.split(',').map(s => s.trim()).filter(Boolean);
const fallback = document.getElementById('cm-fallback')?.value || '';
const style_map = {};
document.querySelectorAll('.cm-style-select').forEach(sel => {
if (sel.value) style_map[sel.dataset.style] = sel.value;
});
try {
const res = await fetch('/api/caller-models', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ strategy, pool, fallback, map: style_map })
});
if (!res.ok) throw new Error(res.status);
callerModelSettings = { strategy, pool, fallback, style_map };
} catch (err) {
log('Caller model save failed: ' + err.message);
}
}
// --- Settings ---
async function loadSettings() {
try {
@@ -1675,7 +1464,6 @@ async function loadSettings() {
// Category model routing
const models = data.available_openrouter_models || [];
window._openrouterModels = models;
populateCallerModelOverride();
const categoryModels = data.category_models || {};
const categories = ['caller_dialog', 'devon_monitor', 'devon_ask', 'background_gen', 'call_summary', 'news_summary'];
for (const cat of categories) {
@@ -1709,9 +1497,6 @@ async function saveSettings() {
// Save audio devices
await saveAudioDevices();
// Save caller model routing
await saveCallerModels();
// Collect category model routing
const categoryModels = {};
const categories = ['caller_dialog', 'devon_monitor', 'devon_ask', 'background_gen', 'call_summary', 'news_summary'];
@@ -2431,151 +2216,3 @@ async function dismissDevonSuggestion() {
}
// --- Preflight ---
const PREFLIGHT_STATUS_ICONS = { pass: '✓', warn: '⚠', fail: '✗', skip: '—' };
const PREFLIGHT_CHECK_NAMES = {
model_diversity: 'Model Diversity',
theme_penetration: 'Theme Penetration',
voice_age_alignment: 'Voice-Age Alignment',
response_coherence: 'Response Coherence',
};
async function runPreflight(testResponses) {
const statusEl = document.getElementById('preflight-status');
const checksEl = document.getElementById('preflight-checks');
const testBtn = document.getElementById('preflight-test-btn');
statusEl.className = 'preflight-status loading';
statusEl.querySelector('.preflight-status-icon').textContent = '...';
statusEl.querySelector('.preflight-status-text').textContent = 'Running checks...';
checksEl.innerHTML = '';
if (testResponses && testBtn) testBtn.classList.add('loading');
try {
const url = '/api/show/preflight' + (testResponses ? '?test_responses=true' : '');
const data = await safeFetch(url, {}, 120000);
renderPreflightResults(data, statusEl, checksEl);
} catch (err) {
statusEl.className = 'preflight-status fail';
statusEl.querySelector('.preflight-status-icon').textContent = '✗';
statusEl.querySelector('.preflight-status-text').textContent = 'Error: ' + err.message;
} finally {
if (testBtn) testBtn.classList.remove('loading');
}
}
function renderPreflightResults(data, statusEl, checksEl) {
const overall = data.status || 'pass';
statusEl.className = 'preflight-status ' + overall;
statusEl.querySelector('.preflight-status-icon').textContent = PREFLIGHT_STATUS_ICONS[overall] || '✓';
statusEl.querySelector('.preflight-status-text').textContent =
overall === 'pass' ? 'All checks passed' :
overall === 'warn' ? 'Passed with warnings' : 'Issues found';
checksEl.innerHTML = '';
const checksObj = data.checks || {};
for (const [checkKey, check] of Object.entries(checksObj)) {
const card = document.createElement('div');
card.className = 'preflight-check';
const status = check.status || 'skip';
const name = PREFLIGHT_CHECK_NAMES[checkKey] || checkKey;
card.innerHTML = `
<div class="preflight-check-header">
<span class="preflight-check-name">${escapeHtml(name)}</span>
<span class="preflight-check-badge ${status}">${status.toUpperCase()}</span>
</div>
<div class="preflight-check-details">${renderCheckDetails(checkKey, check)}</div>
`;
card.querySelector('.preflight-check-header').addEventListener('click', () => {
card.classList.toggle('open');
});
checksEl.appendChild(card);
}
}
function renderCheckDetails(name, check) {
const d = check.details || {};
switch (name) {
case 'model_diversity': return renderModelDiversity(d);
case 'theme_penetration': return renderThemePenetration(d);
case 'voice_age_alignment': return renderVoiceAgeAlignment(d);
case 'response_coherence': return renderResponseCoherence(check);
default: return `<pre>${escapeHtml(JSON.stringify(d, null, 2))}</pre>`;
}
}
function renderModelDiversity(d) {
const callers = d.callers || [];
if (!callers.length) return '<p>No callers to check.</p>';
let html = `<table class="preflight-table">
<thead><tr><th>Caller</th><th>Style</th><th>Model</th></tr></thead><tbody>`;
for (const c of callers) {
html += `<tr><td>${escapeHtml(c.name || '')}</td><td>${escapeHtml(c.style || '')}</td><td>${escapeHtml(c.model || '')}</td></tr>`;
}
html += '</tbody></table>';
if (d.max_same_model_pct != null) {
html += `<p style="margin-top:8px">${d.max_same_model_pct}% on same model</p>`;
}
return html;
}
function renderThemePenetration(d) {
let html = '';
if (d.theme) html += `<p><strong>Theme:</strong> ${escapeHtml(d.theme)}</p>`;
if (d.connected?.length) {
html += `<p style="color:var(--accent-green);margin-top:6px">Connected: ${d.connected.map(n => escapeHtml(n)).join(', ')}</p>`;
}
if (d.not_connected?.length) {
html += `<p style="color:var(--text-muted);margin-top:4px">Not connected: ${d.not_connected.map(n => escapeHtml(n)).join(', ')}</p>`;
}
if (d.penetration_pct != null) {
html += `<p style="margin-top:6px">${d.penetration_pct}% penetration</p>`;
}
return html || '<p>No theme set.</p>';
}
function renderVoiceAgeAlignment(d) {
const callers = d.callers || [];
if (!callers.length) return '<p>No callers to check.</p>';
let html = `<table class="preflight-table">
<thead><tr><th>Caller</th><th>Age</th><th>Voice</th><th>Age Feel</th></tr></thead><tbody>`;
for (const c of callers) {
const cls = c.mismatch ? ' class="mismatch"' : '';
html += `<tr${cls}><td>${escapeHtml(c.name || '')}</td><td>${c.age || ''}</td><td>${escapeHtml(c.voice || '')}</td><td>${escapeHtml(c.age_feel || '')}</td></tr>`;
}
html += '</tbody></table>';
return html;
}
function renderResponseCoherence(check) {
if (check.status === 'skip') {
return '<p>Use <strong>Test Responses</strong> button to run this check.</p>';
}
const d = check.details || {};
const results = d.results || [];
if (!results.length) return '<p>No test results.</p>';
let html = `<table class="preflight-table">
<thead><tr><th>Caller</th><th>Model</th><th>R1</th><th>R2</th><th>Avg</th><th></th></tr></thead><tbody>`;
for (const c of results) {
const cls = c.pass ? '' : ' class="mismatch"';
if (c.error) {
html += `<tr class="mismatch"><td>${escapeHtml(c.name || '')}</td><td>${escapeHtml(c.model || '')}</td><td colspan="3">${escapeHtml(c.error)}</td><td>✗</td></tr>`;
} else {
html += `<tr${cls}><td>${escapeHtml(c.name || '')}</td><td>${escapeHtml(c.model || '')}</td><td>${c.r1_words || 0}</td><td>${c.r2_words || 0}</td><td>${c.word_count || 0}</td><td>${c.pass ? '✓' : '✗'}</td></tr>`;
if (c.snippet) {
html += `<tr><td colspan="6" style="color:var(--text-muted);font-size:0.75rem;padding-left:16px">${escapeHtml(c.snippet)}</td></tr>`;
}
}
}
html += '</tbody></table>';
const passed = results.filter(r => r.pass).length;
html += `<p style="margin-top:8px">${passed}/${results.length} callers passed (min ${50} words per response)</p>`;
return html;
}
+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();
});
+2 -2
View File
@@ -1249,10 +1249,10 @@ def generate_clip_video_remotion(
]
try:
result = subprocess.run(cmd, capture_output=True, text=True, cwd=str(REMOTION_DIR), timeout=180)
result = subprocess.run(cmd, capture_output=True, text=True, cwd=str(REMOTION_DIR), timeout=600)
except subprocess.TimeoutExpired:
props_path.unlink(missing_ok=True)
print(f" Remotion render timed out (180s)")
print(f" Remotion render timed out (600s)")
return False
props_path.unlink(missing_ok=True)
+201 -5
View File
@@ -204,12 +204,12 @@ TRANSCRIPT:
"Content-Type": "application/json"
},
json={
"model": "anthropic/claude-3.5-sonnet",
"model": "anthropic/claude-sonnet-4.6",
"messages": [{"role": "user", "content": full_prompt}],
"max_tokens": 8192,
"temperature": 0
},
timeout=120
timeout=300
)
except requests.exceptions.Timeout:
print(f" Warning: Speaker labeling timed out for chunk {i+1}, using raw text")
@@ -321,7 +321,7 @@ Respond with ONLY valid JSON, no markdown or explanation."""
"Content-Type": "application/json"
},
json={
"model": "anthropic/claude-3.5-haiku",
"model": "anthropic/claude-haiku-4.5",
"messages": [{"role": "user", "content": prompt}],
"temperature": 0.7
},
@@ -571,6 +571,107 @@ def save_chapters(metadata: dict, output_path: str):
print(f" Chapters saved to: {output_path}")
def save_metadata(metadata: dict, output_path: str):
"""Persist generated metadata so a --resume run can recover it losslessly.
The slug is not a safe source to rebuild a title from: it is lowercased and
stripped of punctuation, so apostrophes and commas cannot be recovered.
"""
keep = {k: metadata.get(k) for k in
("title", "description", "thumbnail_text", "chapters")}
with open(output_path, "w") as f:
json.dump(keep, f, indent=2)
print(f" Metadata saved to: {output_path}")
def _decode_db_row(output: str) -> dict | None:
"""Decode a TO_BASE64 payload from mysql batch output.
TO_BASE64 wraps every 76 characters and mysql renders those breaks as a
literal backslash-n, so the raw output is not directly decodable.
"""
cleaned = re.sub(r"\\n|\s", "", output or "")
if not cleaned:
return None
try:
return json.loads(base64.b64decode(cleaned).decode())
except Exception:
return None
def _fetch_episode_metadata_from_db(episode_number: int) -> dict | None:
"""Read back the title/description Castopod already stored for this episode.
Base64 keeps the round trip safe descriptions contain newlines and quotes
that mysql's batch output would otherwise escape.
"""
cmd = (f'{DOCKER_PATH} exec {MARIADB_CONTAINER} mysql --defaults-extra-file=/tmp/.my.cnf '
f'-u {DB_USER} {DB_NAME} -N -e '
f'''"SELECT TO_BASE64(JSON_OBJECT('title', title, 'description', description_markdown)) '''
f'FROM cp_episodes WHERE number = {episode_number} LIMIT 1;"')
success, output = run_ssh_command(cmd)
if not success:
return None
row = _decode_db_row(output)
return row if row and row.get("title") else None
def recover_metadata(episode_number: int, slug: str, metadata_path, chapters: list,
db_lookup=None) -> dict:
"""Rebuild metadata for a --resume run, preferring lossless sources.
1. the metadata file written by the original run
2. the title/description Castopod already stored
3. the slug which cannot round-trip punctuation or capitalization
"""
metadata_path = Path(metadata_path)
title = description = thumbnail_text = None
reconstructed = False
if metadata_path.exists():
try:
saved = json.loads(metadata_path.read_text())
title = saved.get("title")
description = saved.get("description")
thumbnail_text = saved.get("thumbnail_text")
if title:
print(f" Recovered metadata from {metadata_path.name}")
except (json.JSONDecodeError, OSError) as e:
print(f" Warning: could not read {metadata_path.name}: {e}")
if not title:
lookup = db_lookup or _fetch_episode_metadata_from_db
row = lookup(episode_number)
if row and row.get("title"):
title = row["title"]
description = description or row.get("description")
print(" Recovered title/description from Castopod")
if not title:
title_part = re.sub(rf"^episode-{episode_number}-", "", slug).replace("-", " ").title()
title = f"Episode {episode_number}: {title_part}"
reconstructed = True
print(" WARNING: rebuilding the title from the slug is lossy — "
"apostrophes, commas and capitalization cannot be recovered.")
print(f' WARNING: got "{title}" — pass --title to override.')
if not description:
description = f"Episode {episode_number} of Luke at the Roost."
if not thumbnail_text:
thumbnail_text = re.sub(rf"^Episode {episode_number}:\s*", "", title).upper()[:30]
meta = {
"title": title,
"description": description,
"chapters": chapters,
"thumbnail_text": thumbnail_text,
}
if reconstructed:
meta["title_is_reconstructed"] = True
return meta
def run_ssh_command(command: str, timeout: int = 30) -> tuple[bool, str]:
"""Run a command on the NAS via SSH."""
ssh_cmd = [
@@ -1272,8 +1373,24 @@ def _check_youtube_duplicate(youtube, title: str) -> str | None:
return None
# YouTube rejects the entire upload with `invalidTags` if the combined tag
# string exceeds 500 chars. Tags containing a space are quoted by YouTube and
# those quotes count, so budget conservatively and leave a small margin.
YOUTUBE_TAG_BUDGET = 480
YOUTUBE_MAX_TAGS = 25
def _youtube_tag_cost(tag: str) -> int:
return len(tag) + (2 if " " in tag else 0)
def _extract_youtube_tags(metadata: dict) -> list[str]:
"""Extract dynamic tags from episode metadata for YouTube SEO."""
"""Extract dynamic tags from episode metadata for YouTube SEO.
Base tags come first so they survive trimming; chapter titles fill whatever
budget is left. Anything that would bust the budget is skipped rather than
truncated, so no tag is ever emitted half-formed.
"""
base_tags = ["podcast", "Luke at the Roost", "talk radio", "call-in show",
"talk show", "comedy", "AI podcast", "late night radio", "advice"]
skip = {"intro", "outro", "opening", "closing", "wrap up", "wrap-up"}
@@ -1284,7 +1401,21 @@ def _extract_youtube_tags(metadata: dict) -> list[str]:
continue
if len(title) <= 50:
dynamic.append(title)
return (base_tags + dynamic)[:25]
tags: list[str] = []
used = 0
for tag in base_tags + dynamic:
tag = tag.replace("<", "").replace(">", "").strip()
if len(tag) < 3 or tag in tags:
continue
cost = _youtube_tag_cost(tag) + (1 if tags else 0) # +1 for the comma
if used + cost > YOUTUBE_TAG_BUDGET:
continue
tags.append(tag)
used += cost
if len(tags) >= YOUTUBE_MAX_TAGS:
break
return tags
def upload_to_youtube(audio_path: str, metadata: dict, chapters: list,
@@ -1311,6 +1442,10 @@ def upload_to_youtube(audio_path: str, metadata: dict, chapters: list,
video_path = Path(audio_path).with_suffix(".yt.mp4")
# Convert MP3 + cover art to MP4 (pad to 1920x1080 for YouTube compatibility)
# Skip if video already exists and is non-trivial size (>1MB)
if video_path.exists() and video_path.stat().st_size > 1_000_000:
print(f" Using existing video: {video_path} ({video_path.stat().st_size / 1_000_000:.0f}MB)")
else:
print(" Converting audio to video...")
result = subprocess.run([
"ffmpeg", "-y", "-loop", "1",
@@ -1446,6 +1581,7 @@ def main():
parser.add_argument("--title", "-t", help="Override generated title")
parser.add_argument("--description", help="Override generated description")
parser.add_argument("--session-data", "-s", help="Path to session export JSON (from /api/session/export)")
parser.add_argument("--resume", action="store_true", help="Resume a failed publish — skip transcription/Castopod, continue from CDN/YouTube/social")
args = parser.parse_args()
audio_path = Path(args.audio_file).expanduser().resolve()
@@ -1493,6 +1629,62 @@ def main():
episode_number = get_next_episode_number()
print(f"Episode number: {episode_number}")
# --- Resume path: skip transcription + Castopod, pick up from CDN/YouTube/social ---
if args.resume:
castopod_step = _get_step_details(episode_number, "castopod")
if not castopod_step:
print(f"Error: No Castopod data in publish_state.json for episode {episode_number}. Nothing to resume.")
_cleanup_mysql_auth()
lock_fp.close()
LOCK_FILE.unlink(missing_ok=True)
sys.exit(1)
episode = {"id": castopod_step["episode_id"], "slug": castopod_step["slug"]}
print(f"Resuming episode {episode_number}: id={episode['id']}, slug={episode['slug']}")
# Load existing metadata from files saved during first run
transcript_path = audio_path.with_suffix(".transcript.txt")
chapters_path = audio_path.with_suffix(".chapters.json")
metadata_path = audio_path.with_suffix(".metadata.json")
srt_path = audio_path.with_suffix(".srt")
# Build metadata from existing files; re-transcribe + gen only if missing
if chapters_path.exists():
print(" Loading existing chapters and metadata files")
with open(chapters_path) as f:
chapters_data = json.load(f)
chapters = (chapters_data if isinstance(chapters_data, list)
else chapters_data.get("chapters", []))
metadata = recover_metadata(episode_number, episode["slug"],
metadata_path, chapters)
else:
print("[1/5] Re-transcribing audio for metadata...")
transcript = transcribe_audio(str(audio_path))
metadata = generate_metadata(transcript, episode_number)
save_chapters(metadata, str(chapters_path))
save_metadata(metadata, str(metadata_path))
if not transcript_path.exists():
labeled_text = label_transcript_speakers(transcript["full_text"])
with open(transcript_path, "w") as f:
f.write(labeled_text)
if not srt_path.exists():
generate_srt(transcript["segments"], str(srt_path))
if args.title:
metadata["title"] = args.title
if args.description:
metadata["description"] = args.description
srt_path = audio_path.with_suffix(".srt")
direct_upload = os.path.getsize(str(audio_path)) > CLOUDFLARE_UPLOAD_LIMIT
chapters_uploaded = True
transcript_uploaded = True
yt_video_id = None
# Jump to CDN upload (step 3.7)
# (fall through to the common CDN/publish/YouTube/social code below)
else:
# --- Normal (non-resume) path ---
# Guard against duplicate publish
if not args.dry_run:
exists = _check_episode_exists_in_db(episode_number)
@@ -1543,6 +1735,10 @@ def main():
chapters_path = audio_path.with_suffix(".chapters.json")
save_chapters(metadata, str(chapters_path))
# Save metadata so --resume recovers the real title, not a slug rebuild
metadata_path = audio_path.with_suffix(".metadata.json")
save_metadata(metadata, str(metadata_path))
# Save transcript text file with LUKE:/CALLER: speaker labels
transcript_path = audio_path.with_suffix(".transcript.txt")
raw_text = transcript["full_text"]
+2 -1
View File
@@ -24,7 +24,8 @@ local last_state = ""
local transport_active = false
local function log(msg)
reaper.ShowConsoleMsg("[Regions] " .. msg .. "\n")
-- Silent by default — uncomment for debugging:
-- reaper.ShowConsoleMsg("[Regions] " .. msg .. "\n")
end
local function is_playing_or_recording()
+2 -1
View File
@@ -30,7 +30,8 @@ local BLOCK_SAMPLES = math.floor(SAMPLE_RATE * BLOCK_SEC)
local THRESHOLD = 10 ^ (SILENCE_DB / 20)
local MIN_VOICE_BLOCKS = math.ceil(MIN_VOICE_SEC / BLOCK_SEC)
local function log(msg)
reaper.ShowConsoleMsg("[PostProd] " .. msg .. "\n")
-- Silent by default — uncomment for debugging:
-- reaper.ShowConsoleMsg("[PostProd] " .. msg .. "\n")
end
---------------------------------------------------------------------------
+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())
+114
View File
@@ -0,0 +1,114 @@
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"]
# Deterministic fallback: return first from roster
assert resolve_voice("Santiago", roster) == "Marcus"
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", "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
+25
View File
@@ -0,0 +1,25 @@
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 len(prompt) < 3500
-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
+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
+79
View File
@@ -0,0 +1,79 @@
"""YouTube rejects the whole upload with `invalidTags` if the tag list busts
its 500-character budget. Any tag containing a space gets wrapped in quotes and
those quotes count, so the naive sum of tag lengths understates the real cost.
Episode 58 failed here after a 284 MB upload had already completed.
"""
import json
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from publish_episode import _extract_youtube_tags, YOUTUBE_TAG_BUDGET
def tag_cost(tags):
"""Mirror YouTube's accounting: quotes around multi-word tags, plus commas."""
return sum(len(t) + (2 if " " in t else 0) for t in tags) + max(0, len(tags) - 1)
# The real chapter titles from episode 58, which produced a 534-char tag list.
EP58_CHAPTERS = [
{"title": "Intro & Election Fraud Voicemail"},
{"title": "Rayfield: Nephew Stealing Catalytic Converters"},
{"title": "Suki & the Marfa Lights"},
{"title": "Aurora Toothbrush Sponsor"},
{"title": "Gus: Partner's Unlogged Stop at Meth House"},
{"title": "Merritt: Son Stealing from Family Store"},
{"title": "Concho: Mysterious Water Source Surveyors"},
{"title": "Desmond: Colleague's Research Misconduct"},
{"title": "Iron Heart Survival School Sponsor"},
{"title": "Fern: Donald Judd's Aluminum Boxes in Marfa"},
{"title": "Outro & Show Reflection"},
]
def test_episode_58_tags_fit_the_budget():
"""The exact input that broke the ep58 upload."""
tags = _extract_youtube_tags({"chapters": EP58_CHAPTERS})
assert tag_cost(tags) <= YOUTUBE_TAG_BUDGET, (
f"{tag_cost(tags)} chars > {YOUTUBE_TAG_BUDGET} budget: {tags}"
)
def test_pathological_long_titles_still_fit():
chapters = [{"title": "A" * 49 + f" {i}"} for i in range(40)]
tags = _extract_youtube_tags({"chapters": chapters})
assert tag_cost(tags) <= YOUTUBE_TAG_BUDGET, tag_cost(tags)
assert len(tags) <= 25
def test_no_chapters_still_returns_base_tags():
tags = _extract_youtube_tags({"chapters": []})
assert tags, "should still return the base SEO tags"
assert tag_cost(tags) <= YOUTUBE_TAG_BUDGET
def test_base_tags_are_prioritised_over_chapter_titles():
"""Base SEO tags matter more than chapter titles; they must survive trimming."""
chapters = [{"title": "B" * 48 + f" {i}"} for i in range(30)]
tags = _extract_youtube_tags({"chapters": chapters})
assert "podcast" in tags
assert "Luke at the Roost" in tags
def test_angle_brackets_are_stripped():
"""YouTube rejects tags containing < or >."""
chapters = [{"title": "Weird <script> Chapter"}]
tags = _extract_youtube_tags({"chapters": chapters})
assert not any("<" in t or ">" in t for t in tags), tags
def test_skips_intro_outro_and_short_titles():
chapters = [{"title": "Intro"}, {"title": "Outro"}, {"title": "ab"},
{"title": "A Real Chapter Title"}]
tags = _extract_youtube_tags({"chapters": chapters})
assert "Intro" not in tags and "Outro" not in tags and "ab" not in tags
assert "A Real Chapter Title" in tags
+20 -3
View File
@@ -58,9 +58,26 @@ export default {
return new Response("Feed unavailable", { status: 502 });
}
// Plausible analytics proxy (bypass ad blockers)
// Umami analytics proxy (bypass ad blockers)
if (url.pathname === "/api/send" && request.method === "POST") {
const body = await request.text();
const resp = await fetch("https://plausible.macneilmediagroup.com/api/send", {
method: "POST",
headers: {
"Content-Type": "application/json",
"User-Agent": request.headers.get("User-Agent") || "",
"X-Forwarded-For": request.headers.get("CF-Connecting-IP") || request.headers.get("X-Forwarded-For") || "",
},
body,
});
return new Response(resp.body, {
status: resp.status,
headers: { "Content-Type": resp.headers.get("Content-Type") || "text/plain" },
});
}
if (url.pathname === "/p/script") {
const resp = await fetch("https://plausible.macneilmediagroup.com/js/script.file-downloads.hash.outbound-links.pageview-props.revenue.tagged-events.js");
const resp = await fetch("https://plausible.macneilmediagroup.com/script.js");
return new Response(await resp.text(), {
headers: {
"Content-Type": "application/javascript",
@@ -71,7 +88,7 @@ export default {
if (url.pathname === "/p/event" && request.method === "POST") {
const body = await request.text();
const resp = await fetch("https://plausible.macneilmediagroup.com/api/event", {
const resp = await fetch("https://plausible.macneilmediagroup.com/api/send", {
method: "POST",
headers: {
"Content-Type": "application/json",
+118 -1
View File
@@ -1,4 +1,121 @@
[
{
"title": "Thinking in English First",
"description": "When she heard about her father's diagnosis, she processed it in English first\u2014before responding to her mom in Czech. It felt like a betrayal of who she used to be.",
"episode_number": 49,
"clip_file": "clip-5-thinking-in-english-first.mp4",
"youtube_id": "LGqpEAxe754",
"featured": false,
"thumbnail": "images/clips/clip-5-thinking-in-english-first.jpg"
},
{
"title": "The April Fool's Divorce Text Disaster",
"description": "She thought sending her husband a divorce text as an April Fool's joke would be funny. Then his mom got involved and the casseroles started arriving. For 11 days straight.",
"episode_number": 48,
"clip_file": "clip-2-the-april-fool-s-divorce-text-disaster.mp4",
"youtube_id": "L9XAQgZeQo0",
"featured": false,
"thumbnail": "images/clips/clip-2-the-april-fool-s-divorce-text-disaster.jpg"
},
{
"title": "Caller's Daughter on Adult Website Revealed",
"description": "A caller discovers his daughter's secret online activities in the worst possible way. The details just keep getting more uncomfortable.",
"episode_number": 47,
"clip_file": "clip-3-caller-s-daughter-on-adult-website-revealed.mp4",
"youtube_id": "LiH6gVDTHcg",
"featured": false,
"thumbnail": "images/clips/clip-3-caller-s-daughter-on-adult-website-revealed.jpg"
},
{
"title": "The Taint Explanation",
"description": "Luke's caller had to explain what a taint is on live radio and it went exactly as uncomfortably as you'd imagine. This is why we can't have nice things.",
"episode_number": 46,
"clip_file": "clip-2-the-taint-explanation.mp4",
"youtube_id": "7qAhmnwUE1c",
"featured": false,
"thumbnail": "images/clips/clip-2-the-taint-explanation.jpg"
},
{
"title": "Bear Traps and Pit of Snakes",
"description": "Luke's home security system is straight out of Home Alone but way more dangerous. Swinging logs? Check. Pit of snakes covered with dust? BOOM! \ud83d\udc0d",
"episode_number": 44,
"clip_file": "clip-2-bear-traps-and-pit-of-snakes.mp4",
"youtube_id": "XBSEu2bFUAI",
"featured": false,
"thumbnail": "images/clips/clip-2-bear-traps-and-pit-of-snakes.jpg"
},
{
"title": "Roommate's Age Play Kink Takes Over Kitchen",
"description": "Roommate's age play kink is now front and center in the shared kitchen. Baby bottles by the coffee maker? Pacifiers on the counter? This living situation just got complicated.",
"episode_number": 43,
"clip_file": "clip-3-roommate-s-age-play-kink-takes-over-kitchen.mp4",
"youtube_id": "5cF_O2Gm9yM",
"featured": false,
"thumbnail": "images/clips/clip-3-roommate-s-age-play-kink-takes-over-kitchen.jpg"
},
{
"title": "Day Trading Away the Marriage",
"description": "She's been pretending to commute for 12 hours a day while secretly day-trading their life savings away in motels. Two years of lies and $145K gone.",
"episode_number": 42,
"clip_file": "clip-1-day-trading-away-the-marriage.mp4",
"youtube_id": "JeZ9OLGfW8A",
"featured": false,
"thumbnail": "images/clips/clip-1-day-trading-away-the-marriage.jpg"
},
{
"title": "The Snot Boogie Beer Money Thief",
"description": "He showed up to the barbecue claiming he's 'not like other guys' then literally ran off with everyone's beer money. The audacity is unmatched.",
"episode_number": 42,
"clip_file": "clip-2-the-snot-boogie-beer-money-thief.mp4",
"youtube_id": "taBAo8_YMkA",
"featured": false,
"thumbnail": "images/clips/clip-2-the-snot-boogie-beer-money-thief.jpg"
},
{
"title": "Aliens Chasing in Ford Trucks",
"description": "Glowing Ford trucks with shadowy drivers that aren't quite human. This caller's alien encounter story is straight out of a fever dream.",
"episode_number": 41,
"clip_file": "clip-3-aliens-chasing-in-ford-trucks.mp4",
"youtube_id": "Io8CzDtKGfA",
"featured": false,
"thumbnail": "images/clips/clip-3-aliens-chasing-in-ford-trucks.jpg"
},
{
"title": "Doctor Prescribes Prostate Stimulation",
"description": "Dale's doctor hands him a prostate stimulation pamphlet after his cancer diagnosis. Luke's medical advice? 'Stick some stuff up there and see what works.'",
"episode_number": 40,
"clip_file": "clip-3-doctor-prescribes-prostate-stimulation.mp4",
"youtube_id": "nQtwJpocFpY",
"featured": false,
"thumbnail": "images/clips/clip-3-doctor-prescribes-prostate-stimulation.jpg"
},
{
"title": "Open Marriage Backfired Spectacularly",
"description": "He agreed to an open marriage and now his wife is living her best life while he sits home alone every night. This call is PAINFUL to listen to.",
"episode_number": 39,
"clip_file": "clip-1-open-marriage-backfired-spectacularly.mp4",
"youtube_id": "-K-t7iijfGs",
"featured": false,
"thumbnail": "images/clips/clip-1-open-marriage-backfired-spectacularly.jpg"
},
{
"title": "Cat Burglar Bringing Home Stolen Goods",
"description": "This caller's cat has been stealing from the neighbors and bringing home cash and car keys. Where is this criminal mastermind getting this stuff?",
"episode_number": 39,
"clip_file": "clip-2-cat-burglar-bringing-home-stolen-goods.mp4",
"youtube_id": "JvgJWxFCBZk",
"featured": false,
"thumbnail": "images/clips/clip-2-cat-burglar-bringing-home-stolen-goods.jpg"
},
{
"title": "Second Baby Shower Entitlement Rant",
"description": "A second baby shower with a full registry? This caller is NOT having it and goes OFF about the entitlement of expecting gifts for baby number two.",
"episode_number": 39,
"clip_file": "clip-3-second-baby-shower-entitlement-rant.mp4",
"youtube_id": "NKt8NjDHKcg",
"featured": false,
"thumbnail": "images/clips/clip-3-second-baby-shower-entitlement-rant.jpg"
},
{
"title": "Cult Leader Realizes He's Been Manipulating People",
"description": "Cult leader calls in having a full existential crisis about his 'shared intimacy nights' and the manipulation tactics he's been using on his followers.",
@@ -55,7 +172,7 @@
},
{
"title": "Started a Fight and Can't Stop Reading About Wars",
"description": "A caller starts a fight with their partner and spirals into an obsessive deep-dive on historical wars. Luke tries to untangle the connection.",
"description": "",
"episode_number": 31,
"clip_file": "clip-3-started-a-fight-and-can-t-stop-reading-about-wars.mp4",
"youtube_id": "D2iWnSGQeow",
+1 -2
View File
@@ -92,8 +92,7 @@
}
}]
</script>
<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>
<script defer data-website-id="ae816554-19c8-4e97-a2cb-d0226c27e9bc" data-host-url="/" src="/p/script"></script>
</head>
<body>
+66
View File
@@ -270,4 +270,70 @@
<changefreq>never</changefreq>
<priority>0.7</priority>
</url>
<url>
<loc>https://lukeattheroost.com/episode.html?slug=episode-39-st-patrick-s-day-chaos-and-caller-confessions</loc>
<lastmod>2026-03-18</lastmod>
<changefreq>never</changefreq>
<priority>0.7</priority>
</url>
<url>
<loc>https://lukeattheroost.com/episode.html?slug=episode-40-prostate-cancer-christmas-lights-and-potato-salad-betrayals</loc>
<lastmod>2026-03-19</lastmod>
<changefreq>never</changefreq>
<priority>0.7</priority>
</url>
<url>
<loc>https://lukeattheroost.com/episode.html?slug=episode-41-benny-s-creepy-vhs-tape-from-nowhere</loc>
<lastmod>2026-03-20</lastmod>
<changefreq>never</changefreq>
<priority>0.7</priority>
</url>
<url>
<loc>https://lukeattheroost.com/episode.html?slug=episode-42-peggy-s-day-trading-disaster-and-the-nonprofit-yacht</loc>
<lastmod>2026-03-21</lastmod>
<changefreq>never</changefreq>
<priority>0.7</priority>
</url>
<url>
<loc>https://lukeattheroost.com/episode.html?slug=episode-43-cousin-rufus-and-the-locksmith-s-wild-weekend</loc>
<lastmod>2026-03-23</lastmod>
<changefreq>never</changefreq>
<priority>0.7</priority>
</url>
<url>
<loc>https://lukeattheroost.com/episode.html?slug=episode-44-floyd-s-fence-and-the-cattle-conspiracy</loc>
<lastmod>2026-03-24</lastmod>
<changefreq>never</changefreq>
<priority>0.7</priority>
</url>
<url>
<loc>https://lukeattheroost.com/episode.html?slug=episode-45-potato-salad-aliens-and-the-dripping-faucet-of-doom</loc>
<lastmod>2026-03-26</lastmod>
<changefreq>never</changefreq>
<priority>0.7</priority>
</url>
<url>
<loc>https://lukeattheroost.com/episode.html?slug=episode-46-butchers-wreak-forever-and-other-late-night-confessions</loc>
<lastmod>2026-03-30</lastmod>
<changefreq>never</changefreq>
<priority>0.7</priority>
</url>
<url>
<loc>https://lukeattheroost.com/episode.html?slug=episode-47-clarence-randy-and-the-onlyfans-daughter</loc>
<lastmod>2026-03-30</lastmod>
<changefreq>never</changefreq>
<priority>0.7</priority>
</url>
<url>
<loc>https://lukeattheroost.com/episode.html?slug=episode-48-potato-salad-legacy-and-the-april-fool-s-apocalypse</loc>
<lastmod>2026-04-01</lastmod>
<changefreq>never</changefreq>
<priority>0.7</priority>
</url>
<url>
<loc>https://lukeattheroost.com/episode.html?slug=episode-49-silas-s-shared-intimacy-night-and-four-brave-souls</loc>
<lastmod>2026-04-05</lastmod>
<changefreq>never</changefreq>
<priority>0.7</priority>
</url>
</urlset>