diff --git a/README.md b/README.md
index 7645109..ee72b01 100644
--- a/README.md
+++ b/README.md
@@ -77,6 +77,15 @@ Quit Hype Ghost.
- **Settings → About** shows the app version, the honesty/privacy rules at a glance, where
your data lives, and **Reset to factory defaults** — wipes all settings and memory, then
re-runs the setup wizard.
+- **Game screen guide:** the cast learns how to *read* each game it watches — where the
+ health bar is, what a death or menu screen looks like, where your webcam and alerts sit —
+ and reuses it on later streams of that game. It writes this itself from screenshots it is
+ already being shown, so it costs no extra API calls, and it sits in the cached part of the
+ prompt. The point is reacting to game **state** ("your health is gone") rather than just
+ appearance. It's plain text at **Settings → Stream → Screen guide**: read it, and edit it
+ if it gets something wrong, because it's treated as truth on every message. Nothing is
+ learned under a category that isn't one game (`Just Chatting`, `Music`, …) — see
+ `memory.gameInfoSkip`.
- **Data folder:** settings, session memory, the cross-stream profile, and the app log
(`hype-ghost.log`, rotating) live in `%APPDATA%\Hype Ghost` by default. **Settings →
About → Move data folder…** relocates all of it to any folder you like (another drive, a
@@ -90,6 +99,33 @@ Quit Hype Ghost.
out loud *is* replying. Everything it hears also shows up as faint 🎙 lines in the deck
feed (toggle in Settings → Voice), so when the cast reacts oddly you can see exactly what
the transcription thought you said.
+- **Getting transcription accurate:** run **Settings → Voice → Mic check**. It hands you a
+ short script built from your ghosts' names, your channel, and anything in
+ `speech.vocabulary`; you read it out loud, and Hype Ghost lines it up against what
+ LocalVocal actually heard. You get your real word error rate and one-click **word fixes**
+ for the names it got wrong, applied to every transcript line from then on (live, no
+ restart). It is a measurement, not training — nothing is uploaded and no model is
+ modified. Tuning that helps *before* you get there, in order of impact:
+ - **Use a bigger, English-only model.** LocalVocal defaults to `tiny.en`. Moving to
+ `small.en` (or `medium.en` on a strong PC) is the single biggest accuracy win, and at
+ equal size the `.en` models beat the multilingual ones for English.
+ - **Trade latency for accuracy.** Most LocalVocal guides tune for snappy on-screen
+ captions; that's the wrong target here. The cast speaks on a cadence of *minutes*, so a
+ longer processing buffer costs you nothing and gives Whisper more context per chunk.
+ - **Turn partial transcriptions off.** They write half-finished duplicate lines into the
+ file Hype Ghost tails.
+ - **Fix the audio first.** Noise suppression *before* the Transcription filter in the
+ chain, and no game or music audio bleeding into the mic track — no model recovers from
+ a dirty input.
+ - **Fix mishears as they happen.** Click any 🎙 line in the deck feed, retype what you
+ actually said, press Enter — the words that changed become permanent corrections and
+ apply to the very next line, no save or restart. This is where the correction map
+ really grows: the mic check covers names you can predict up front, this covers whatever
+ a real stream throws at it. Re-running the mic check shows your past scores, so a
+ LocalVocal change can be judged by a number instead of a feeling.
+ - Note the trade-off with the OBS crash below: the CPU backend is the safe one, and it's
+ also what limits how big a model you can run. Move up model sizes until OBS starts
+ struggling, then step back one.
- **Party / co-op audio (second channel):** streaming alongside friends? Route their audio
(Discord/TeamSpeak/party chat) to a separate OBS source, add a *second* LocalVocal
Transcription filter to it, output to a **different** file/text source than your mic, and
@@ -118,7 +154,7 @@ Quit Hype Ghost.
| `stream.gameFromObs` | Fallback: guess the game from an OBS **Game/Window Capture** source's captured window when Twitch has no category (default true). |
| `talkingPoints` | Topics you want covered; the cast works one in occasionally when it fits naturally. |
| `anthropic.apiKey` | Anthropic API key (console.anthropic.com), or set `ANTHROPIC_API_KEY` env var. |
-| `anthropic.model` | `claude-sonnet-5` is the sweet spot; `claude-haiku-4-5` ~3x cheaper, `claude-opus-4-8` ~3x pricier and chattiest. |
+| `anthropic.model` | `claude-sonnet-5` is the sweet spot; `claude-haiku-4-5` ~3x cheaper, `claude-opus-5` ~1.7x pricier and chattiest. Any other model ID your key can reach also works — Settings → Brain → **Custom model ID…** (or just set it here), so a new release isn't gated behind an app update. It must be **vision-capable**, and the cost meter only prices the listed models: on anything else the deck shows a message count with no dollar figure. |
| `bot.name` / `bot.personality` | Legacy single-ghost fields, kept in sync with `cast[0]` for backward compatibility. |
| `obs.url` / `obs.password` | OBS WebSocket (default port 4455). |
| `obs.screenshotWidth` | Screenshot width in px (default 800). Image tokens scale with pixels (w×h÷750) — JPEG quality has no effect on tokens. |
@@ -128,6 +164,11 @@ Quit Hype Ghost.
| `transcript.mode` | `off`, `file` (tail LocalVocal's .txt/.srt output), or `textSource` (poll a text source over OBS WebSocket). |
| `transcript.showInFeed` | Echo what voice awareness hears into the deck feed as faint 🎙 lines (party audio as 🎧) so you can spot mishears the cast might be reacting to (default true). Deck only — never in the cast's chat history, the recap, or the on-stream overlay. |
| `transcript2.*` | *(Optional)* A **second** transcription channel for party/co-op audio (a separate audio device with its own LocalVocal filter). Same `mode`/`file`/`textSource`/`pollSeconds` as `transcript`, plus `label` — how the cast refers to those people (e.g. "my co-op squad"). Treated as *other people*, never as the streamer. |
+| `speech.dropHallucinations` | Discard speech-to-text filler — "Thank you.", "Thanks for watching!", "please subscribe", words stuck on repeat (default true). Whisper invents these during near-silence, which is most of a mic-only track: the pauses between sentences, breaths, keyboard, room tone. Matched whole-line only, so a real sentence containing those words survives. Applies to both channels. |
+| `speech.corrections` | Word fixes applied to every transcript line before the cast sees it: `[{ "from": "bacon", "to": "Beacon" }]`. Whole words only, case-insensitive; a blank `to` deletes the phrase. Build this from **Settings → Voice → Mic check**, or by hand. Applies live on save. |
+| `memory.gameInfoEvery` | How often (in cast messages) the cast refreshes its **screen guide** for the current game — what the HUD means, what a death screen looks like, where your overlay sits (default 8). Written as a tail section on generations that are already happening, so it costs no extra API calls. Stored per game in `game-notes.json` and reused on later streams; view and edit it at Settings → Stream. |
+| `memory.gameInfoSkip` | Categories that aren't a single game — `Just Chatting`, `Music`, `IRL` and friends, pre-filled with the common Twitch ones. No screen guide is learned, stored, or read under these, because one keyed to "Just Chatting" would blend every game played beneath it and describe none of them. Whole-name match, case- and space-insensitive (so `Music` skips but `Music Racer` doesn't). Add your own if a Window Capture reports something like a browser as the "game". |
+| `speech.vocabulary` | Extra proper nouns worth getting right — the game, in-jokes, regulars' names. Your ghosts' names and `twitch.channel` are included automatically. Used to build the mic-check script, and told to the cast so it reads a near-miss as the real word. |
| `cadence.soloSeconds` / `quietSeconds` | Average gap between messages when alone / when real viewers are present. |
| `cadence.jitter`, `burstChance`, `lullChance` | Natural rhythm: ±jitter on normal gaps, occasional quick bursts (0.3–0.6x) and long lulls (1.6–3x). |
| `cadence.minScreenshotGapSeconds` | Skip re-screenshotting on quick follow-ups (default 25). |
@@ -142,6 +183,13 @@ The dashboard's cost pill shows the real number live, and if OBS disappears for
the ghost **auto-pauses** so a forgotten tray app can't burn money overnight — it resumes
by itself when OBS is back (Settings → App).
+Current-generation models think by default, and thinking tokens bill at output rates — on a
+one-line chat message that is mostly waste, so the app pins **`effort: low`** for models that
+accept it (the 5-series and Opus 4.8). It doesn't turn thinking off: a single generation can
+also be merging session notes or the screen guide, and disabling it risks reasoning leaking
+into text that goes on the stream overlay. A **custom model ID** never receives the setting —
+there is no way to know whether an unknown model accepts it, so it may cost more per message.
+
## Privacy & security
Screenshots of your stream and (if enabled) your mic transcript go to the Anthropic API to
diff --git a/config.example.json b/config.example.json
index c50146c..b33fd0f 100644
--- a/config.example.json
+++ b/config.example.json
@@ -81,7 +81,30 @@
"memory": {
"enabled": true,
"updateEvery": 4,
- "profileEvery": 12
+ "profileEvery": 12,
+ "gameInfoEvery": 8,
+ "gameInfoSkip": [
+ "Just Chatting",
+ "IRL",
+ "Music",
+ "Art",
+ "Makers & Crafting",
+ "Food & Drink",
+ "Travel & Outdoors",
+ "Fitness & Health",
+ "Beauty & Body Art",
+ "Science & Technology",
+ "Software and Game Development",
+ "Talk Shows & Podcasts",
+ "Sports",
+ "ASMR",
+ "DJs",
+ "Watch Parties",
+ "Special Events",
+ "Politics",
+ "Animals, Aquariums, and Zoos",
+ "Pools, Hot Tubs, and Beaches"
+ ]
},
"twitch": {
"clientId": "",
@@ -104,6 +127,11 @@
"pollSeconds": 2,
"label": "the people the streamer is playing with (co-op partners / a Discord or party call)"
},
+ "speech": {
+ "dropHallucinations": true,
+ "vocabulary": [],
+ "corrections": []
+ },
"cadence": {
"soloSeconds": 120,
"quietSeconds": 480,
diff --git a/electron/main.js b/electron/main.js
index fd234e0..ccd9884 100644
--- a/electron/main.js
+++ b/electron/main.js
@@ -49,7 +49,7 @@ if (!gotLock) {
// Dev (npm start): the project folder, so hacking on it stays simple.
const defaultDataDir = app.isPackaged ? app.getPath('userData') : packageRoot;
const { dir: dataDir, custom: customDataDir } = resolveDataDir(defaultDataDir);
-const { configPath, notesPath, sessionPath, profilePath, logPath, skipPath } = dataFilePaths(dataDir);
+const { configPath, notesPath, sessionPath, profilePath, gameInfoPath, micCheckPath, logPath, skipPath } = dataFilePaths(dataDir);
// A tray-resident app has no visible console — keep a rotating file log next
// to the data for bug reports. Packaged only: dev has a real terminal.
@@ -182,6 +182,8 @@ if (gotLock) app.whenReady().then(() => {
notesPath,
sessionPath,
profilePath,
+ gameInfoPath,
+ micCheckPath,
publicDir: path.join(packageRoot, 'public'),
// Settings → About → Storage: shows where the data lives and moves it.
// Files are copied (originals stay as a fallback), the pointer is
diff --git a/public/dashboard.html b/public/dashboard.html
index 33640b9..75a8a3a 100644
--- a/public/dashboard.html
+++ b/public/dashboard.html
@@ -127,6 +127,14 @@
.heard.party { align-self: flex-start; }
.heard .ic { font-style: normal; flex: none; }
.heard .txt { overflow-wrap: anywhere; min-width: 0; }
+ /* Clickable because a mishear is fixable in place — see editHeard(). */
+ .heard { cursor: text; }
+ .heard:hover .txt { color: var(--dim); text-decoration: underline dotted; text-underline-offset: 3px; }
+ .heard .heardfix {
+ font: inherit; font-style: normal; flex: 1; min-width: 0; margin: 0;
+ padding: 2px 8px; border-radius: var(--r-sm);
+ border: 1px solid var(--accent-line); background: var(--glass-2); color: var(--text);
+ }
.heard .tm { font-style: normal; font-size: var(--fs-xs); flex: none; }
.moment-card { align-self: center; display: flex; align-items: center; gap: 10px; padding: 7px 15px; border-radius: var(--r-full); background: var(--glass); border: 1px solid var(--accent-line); font-size: var(--fs-sm); animation: pop .4s cubic-bezier(.2,1.3,.5,1); }
@keyframes pop { from { opacity: 0; transform: scale(.85); } to { opacity: 1; transform: none; } }
@@ -269,7 +277,8 @@
micOff:'off', micQuiet:'quiet', micWait:'waiting', party:'Party', partyQuiet:'quiet', partyWait:'waiting', chatQuiet:'quiet', chatActive:'active', lost:'Connection lost — reconnecting…',
msgs:'msgs', watching:'watching', idle:'idle', noMoments:'No moments yet — the cast flags clip-worthy plays here.',
previewBody:'Preview mode — your cast has no brain yet, so the room stays quiet.', previewCta:'Connect one in Settings',
- heardTip:'What voice awareness transcribed — the cast may be reacting to this. If it misheard you, that explains their reaction. (Deck only — never shown on stream.)' },
+ heardTip:'What voice awareness transcribed — the cast may be reacting to this. If it misheard you, that explains their reaction. Click to fix a mishear: the words you change are corrected from now on. (Deck only — never shown on stream.)',
+ fixSaved:'Learned:', fixNone:'Nothing changed — no correction saved.' },
es: { brain:'Cerebro', viewers:'Espectadores', mic:'Micro', chat:'Chat', cast:'El elenco', energy:'Energía',
chill:'Tranqui', balanced:'Normal', hype:'Máx', room:'Sala', modeAuto:'Auto', modeSolo:'Solo', modeViewers:'Público',
say:'Di algo', pause:'Pausar', resume:'Reanudar', moments:'Momentos', recap:'Exportar', send:'Enviar', you:'Tú',
@@ -279,7 +288,8 @@
micOff:'apagado', micQuiet:'silencio', micWait:'esperando', party:'Grupo', partyQuiet:'silencio', partyWait:'esperando', chatQuiet:'tranquilo', chatActive:'activo', lost:'Conexión perdida — reconectando…',
msgs:'msjs', watching:'mirando', idle:'inactivo', noMoments:'Aún no hay momentos — el elenco marcará jugadas destacadas aquí.',
previewBody:'Modo vista previa — tu elenco aún no tiene cerebro, así que la sala sigue en silencio.', previewCta:'Conéctalo en Ajustes',
- heardTip:'Lo que la escucha de voz transcribió — el elenco puede estar reaccionando a esto. Si te entendió mal, eso explica su reacción. (Solo en la consola — nunca en el stream.)' },
+ heardTip:'Lo que la escucha de voz transcribió — el elenco puede estar reaccionando a esto. Si te entendió mal, eso explica su reacción. Haz clic para corregir: las palabras que cambies se corregirán a partir de ahora. (Solo en la consola — nunca en el stream.)',
+ fixSaved:'Aprendido:', fixNone:'Sin cambios — no se guardó ninguna corrección.' },
};
let L = STR.en;
const T = (k) => (L[k] ?? STR.en[k] ?? k);
@@ -416,16 +426,66 @@
div.title = T('heardTip');
const time = new Date(h.ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
div.innerHTML = `${h.channel === 'party' ? '🎧' : '🎙️'}${time}`;
- div.querySelector('.txt').textContent = (h.channel === 'party' && h.label ? h.label + ' · ' : '') + '“' + h.text + '”';
+ setHeardText(div, h, h.text);
+ div.onclick = () => editHeard(div, h);
feed.appendChild(div);
feed.scrollTop = feed.scrollHeight;
}
- function addSystem(text) {
+ function setHeardText(div, h, text) {
+ const span = document.createElement('span');
+ span.className = 'txt';
+ span.textContent = (h.channel === 'party' && h.label ? h.label + ' · ' : '') + '“' + text + '”';
+ (div.querySelector('.txt') || div.querySelector('.heardfix')).replaceWith(span);
+ }
+
+ // Click a mishear, retype what you actually said, press Enter. The words that
+ // changed become permanent corrections and apply to the very next line — no
+ // save, no restart. This is how the correction map grows during a real
+ // stream, where the mishears are ones no setup script could predict.
+ function editHeard(div, h) {
+ if (div.querySelector('.heardfix')) return;
+ const input = document.createElement('input');
+ input.className = 'heardfix';
+ input.value = h.text;
+ div.querySelector('.txt').replaceWith(input);
+ input.focus();
+ input.select();
+
+ let settled = false;
+ const cancel = () => { if (!settled) { settled = true; setHeardText(div, h, h.text); } };
+ const commit = async () => {
+ if (settled) return;
+ const corrected = input.value.trim();
+ if (!corrected || corrected === h.text) return cancel();
+ settled = true;
+ const heard = h.text;
+ h.text = corrected;
+ setHeardText(div, h, corrected);
+ const r = await fetch('/api/speech/correct', {
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ heard, corrected }),
+ }).then((x) => x.json()).catch((e) => ({ ok: false, error: e.message }));
+ if (!r.ok) addSystem(r.error);
+ else if (!r.added.length) addSystem(T('fixNone'));
+ else addSystem(T('fixSaved') + ' ' + r.added.map((f) => `“${f.from}” → “${f.to}”`).join(', '), '✓');
+ };
+ input.onkeydown = (e) => {
+ e.stopPropagation();
+ if (e.key === 'Enter') commit();
+ else if (e.key === 'Escape') cancel();
+ };
+ input.onblur = cancel;
+ input.onclick = (e) => e.stopPropagation();
+ }
+
+ // ⚠ is the warning icon (DESIGN.md); a confirmed action passes ✓ instead so
+ // a success doesn't read as a problem.
+ function addSystem(text, icon = '⚠') {
clearEmpty();
const div = document.createElement('div');
div.className = 'sys';
- div.textContent = '⚠ ' + text;
+ div.textContent = icon + ' ' + text;
feed.appendChild(div);
feed.scrollTop = feed.scrollHeight;
}
diff --git a/public/settings.html b/public/settings.html
index 8167212..17765ce 100644
--- a/public/settings.html
+++ b/public/settings.html
@@ -44,6 +44,18 @@
.arche { display: flex; gap: 7px; flex-wrap: wrap; margin-top: 8px; }
#addGhost { margin-top: 2px; }
+ /* mic check */
+ .miccheck { border: 1px solid var(--border); border-radius: var(--r-lg); padding: 14px 16px; background: var(--glass-2); margin-top: 12px; }
+ .miccheck ol { margin: 10px 0; padding-left: 22px; display: flex; flex-direction: column; gap: 7px; }
+ .miccheck li { line-height: 1.5; }
+ .miccheck .heard { color: var(--dim); font-size: var(--fs-sm); word-break: break-word; }
+ .wer { display: flex; gap: 22px; align-items: baseline; flex-wrap: wrap; margin-bottom: 4px; }
+ .wer b { font-size: var(--fs-lg); }
+ .fixrow { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-top: 9px; }
+ .fixrow .from { color: var(--bad); }
+ .fixrow .to { color: var(--good); }
+ .fixrow button { padding: 5px 11px; }
+
.accent-row { display: flex; gap: 10px; margin-top: 6px; }
.accent-dot { width: 30px; height: 30px; border-radius: 50%; cursor: pointer; border: 2px solid transparent; }
.accent-dot.on { border-color: #fff; box-shadow: 0 0 0 2px var(--bg), 0 0 0 4px currentColor; }
@@ -118,6 +130,11 @@
Any model ID your API key can reach — a newer release, an older one you want to pin, or a dated snapshot. Exact string from Anthropic's model list; Test brain below confirms it works before you save. The cast needs vision to see your stream.
+
@@ -180,6 +197,17 @@
🎮 Stream info for the cast
+
+
+
+
+
+
+
+
+
+
How to read the screen in this game — where the health bar is, what a death screen looks like, where your webcam and alerts sit. The cast writes this itself from screenshots it's already being shown (no extra API calls), and refines it as it watches. It's kept per game and reused on future streams. Edit it if it gets something wrong: it's treated as truth on every message, so a bad line is worse than a blank one. Saves immediately — separate from the button below.
+
@@ -216,6 +244,38 @@
🎙️ Voice awareness
+
+
+
+
+
+
+
🎙️ Mic check & word fixes
+
Transcription garbles names worst — and names are what this room runs on. Read a short script out loud and Hype Ghost lines it up against what LocalVocal actually heard: you get your real error rate on your mic, and one-click fixes for the words it got wrong. Nothing is uploaded and no model is trained — this builds a find-and-replace list applied to the transcript before the cast ever sees it.
+
+
+
+
+
+
+
Read these out loud at your normal streaming volume — one line at a time, then press Finish.
+
+
Heard so far: …
+
+
+
+
+
+
+
+
+
+
One per line, what it hears = what you meant. Applied to every transcript line before the cast sees it — whole words only, case-insensitive. Leave the right side blank to drop a phrase entirely. Applies live on save.
+
+
+
+
Extra proper nouns worth getting right: the game, in-jokes, regulars' names. Your ghosts' names and Twitch channel are already included. These go into the mic-check script, and the cast is told to read a near-miss of one as the real word. Changing these restarts the app.
+
🎧 Second channel — party / co-op audio
@@ -411,6 +471,8 @@
Danger zone
const fields = [...document.querySelectorAll('[data-path]')];
let cfg = null, apiKeySaved = false, canRestart = false;
let palette = {}, archetypes = [], castData = [];
+ const CUSTOM_MODEL = '__custom';
+ let modelIds = []; // ids the cost meter has rates for
let ACCENTS = {}; // accent hex values come from /api/config
function applyAccent(name) {
@@ -540,7 +602,12 @@
Danger zone
cfg = data.config; apiKeySaved = data.apiKeySaved; canRestart = data.canRestart;
palette = data.palette || {}; archetypes = data.archetypes || []; castData = data.cast || [];
ACCENTS = data.accents || {};
- $('modelSelect').innerHTML = data.models.map((m) => ``).join('');
+ modelIds = data.models.map((m) => m.id);
+ // The catalog, plus an escape hatch: any model id the key can reach, so a
+ // release newer than this build (or an older one worth pinning) isn't
+ // gated behind an app update.
+ $('modelSelect').innerHTML = data.models.map((m) => ``).join('')
+ + ``;
for (const f of fields) {
const v = getPath(cfg, f.dataset.path);
if (f.dataset.type === 'bool') f.checked = v !== false;
@@ -555,6 +622,9 @@
setPath(cfg, f.dataset.path, v);
}
cfg.talkingPoints = $('talkingPoints').value.split('\n').map((s)=>s.trim()).filter(Boolean);
+ cfg.speech = { ...(cfg.speech || {}),
+ corrections: parseFixes($('corrections').value),
+ vocabulary: $('vocabulary').value.split('\n').map((s)=>s.trim()).filter(Boolean) };
// the cast (new source of truth) + mirror into bot/bot2 for back-compat
const roster = collectCast();
cfg.cast = roster;
@@ -751,6 +1036,12 @@
Danger zone
? { ...cfg.bot2, enabled: true, name: roster[1].name, personality: roster[1].personality }
: { ...cfg.bot2, enabled: false };
+ // The dropdown's own value is a sentinel when "Custom model ID…" is picked,
+ // so the real id has to be written after the generic field loop.
+ setPath(cfg, 'anthropic.model', chosenModel());
+ if (cfg.brain.provider === 'anthropic' && !cfg.anthropic.model) {
+ $('saveResult').className = 'result err'; $('saveResult').textContent = 'enter a model ID, or pick one from the list (Brain tab)'; setTab('brain'); return;
+ }
if (cfg.brain.provider === 'anthropic' && !cfg.anthropic.apiKey && !apiKeySaved) {
$('saveResult').className = 'result err'; $('saveResult').textContent = 'an API key is required (Brain tab)'; setTab('brain'); return;
}
diff --git a/public/setup.html b/public/setup.html
index 0b40e83..18d8472 100644
--- a/public/setup.html
+++ b/public/setup.html
@@ -185,6 +185,13 @@
Your room is ready!
$('apiKey').placeholder = apiKeySaved ? 'saved ✓ — leave blank to keep the current key' : 'sk-ant-…';
if (data.openaiKeySaved) $('oaiKey').placeholder = 'saved ✓ — leave blank to keep';
if (data.twitchSecretSaved) $('twSecret').placeholder = 'saved ✓ — leave blank to keep';
+ // A model set in Settings → Brain → "Custom model ID…" isn't in the catalog.
+ // Carry it as its own option so re-running the wizard keeps it instead of
+ // blanking it (an unmatched value would leave the select with no selection).
+ if (cfg.anthropic.model && !data.models.some((m) => m.id === cfg.anthropic.model)) {
+ $('model').insertAdjacentHTML('beforeend',
+ ``);
+ }
$('model').value = cfg.anthropic.model; $('provider').value = cfg.brain.provider;
$('oaiBase').value = cfg.brain.openaiBaseUrl; $('oaiModel').value = cfg.brain.openaiModel; $('oaiKey').value = cfg.brain.openaiApiKey;
toggleProvider();
diff --git a/src/brain.js b/src/brain.js
index 7355f60..6f0ead8 100644
--- a/src/brain.js
+++ b/src/brain.js
@@ -1,4 +1,9 @@
import Anthropic from '@anthropic-ai/sdk';
+import { supportsEffort } from './models.js';
+
+// Output budget reserved for thinking, on top of the room each response
+// section needs. See the maxTokens comment in generate().
+const THINKING_HEADROOM = 600;
// Real chat is mostly low-effort reactions, not questions. Each message rolls
// a style from this weighted pool so variety is enforced by code, not vibes.
@@ -36,11 +41,12 @@ export function energyTone(energy) {
const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
-// Pull a labeled tail section (---NOTES---, ---PROFILE---, ---MOMENT---) out of
-// the model's raw output, tolerant of dash count and stopping at the next label.
+// Pull a labeled tail section (---NOTES---, ---PROFILE---, ---MOMENT---,
+// ---GAMEINFO---) out of the model's raw output, tolerant of dash count and
+// stopping at the next label.
function extractSection(raw, label) {
const re = new RegExp(
- `-{2,}\\s*${label}\\s*-{2,}([\\s\\S]*?)(?=-{2,}\\s*(?:NOTES|PROFILE|MOMENT)\\s*-{2,}|$)`,
+ `-{2,}\\s*${label}\\s*-{2,}([\\s\\S]*?)(?=-{2,}\\s*(?:NOTES|PROFILE|MOMENT|GAMEINFO)\\s*-{2,}|$)`,
'i'
);
const m = re.exec(raw);
@@ -64,10 +70,11 @@ export class Brain {
* @param {Array<{name:string, personality:string}>} opts.personas 1–4 entries
* @param {string} opts.language
*/
- constructor({ brain, anthropic, personas, language }) {
+ constructor({ brain, anthropic, personas, language, vocabulary }) {
this.provider = brain.provider === 'openai' ? 'openai' : 'anthropic';
this.personas = personas;
this.language = language || 'English';
+ this.vocabulary = Array.isArray(vocabulary) ? vocabulary : [];
if (this.provider === 'anthropic') {
const apiKey = anthropic.apiKey || process.env.ANTHROPIC_API_KEY || '';
this.hasKey = Boolean(apiKey);
@@ -115,6 +122,14 @@ export class Brain {
`You may be given an auto-generated transcript of what the streamer said out loud on mic.`,
`Treat it as the streamer talking to chat: follow up naturally, never quote it verbatim`,
`or correct its errors.`,
+ // Speech-to-text fails hardest on proper nouns, and proper nouns are what
+ // this room runs on. Naming them lets the model recover the intended word
+ // from a near-miss instead of reacting to nonsense.
+ ...(this.vocabulary.length
+ ? [`That transcript comes from speech-to-text and garbles names worst. These names matter`,
+ `here: ${this.vocabulary.join(', ')}. If a transcript word is a near-miss for one of them,`,
+ `assume that is what the streamer said — silently, never announcing the fix.`]
+ : []),
``,
`You may ALSO be given a separate "party audio" transcript: OTHER people the streamer`,
`is playing with (co-op partners, a Discord or party call). These are DIFFERENT people`,
@@ -154,10 +169,19 @@ export class Brain {
* messages). A separate block with its own cache breakpoint, so a profile
* update doesn't bust the cached rules prefix above it.
*/
- buildContextBlock({ streamContext, profile } = {}) {
+ buildContextBlock({ streamContext, profile, gameInfo, gameInfoLabel } = {}) {
const parts = [];
if (streamContext) parts.push(`About this stream (from the streamer): ${streamContext}`);
if (profile) parts.push(`Viewer profile (long-term memory from previous streams):\n${profile}`);
+ // The visual primer for this game: what the HUD elements are and what the
+ // screen states mean. Lets the cast react to game *state* ("your health is
+ // gone") instead of only surface appearance ("big room").
+ if (gameInfo) {
+ parts.push(
+ `Reading the screen in ${gameInfoLabel || 'this game'} (learned from watching this stream — ` +
+ `use it to interpret the screenshot, and silently correct it if the screen clearly disagrees):\n${gameInfo}`
+ );
+ }
return parts.length ? parts.join('\n\n') : null;
}
@@ -167,7 +191,7 @@ export class Brain {
async generate({
history, screenshot, staleScreenshot, mode, trigger, transcript, partyTranscript, partyLabel,
notes, profile, updateNotes, updateProfile, flagMoments, energy, sceneName, streamContext,
- streamInfo, talkingPoint, allowExchange,
+ streamInfo, talkingPoint, allowExchange, gameInfo, updateGameInfo,
}) {
const names = this.personas.map((p) => p.name);
const historyText = history.length
@@ -228,16 +252,29 @@ export class Brain {
const notesInstruction = updateNotes
? `\n\nAfter the chat message(s), on a new line write exactly ---NOTES--- followed by updated session notes: plain text, under 100 words — current game/activity, notable events, topics discussed, running jokes.`
: '';
+ // Built from screenshots the cast is already being shown, on a slow
+ // cadence — no extra API call, and it corrects itself across a session
+ // instead of baking in whatever the first frame happened to be.
+ const gameInfoInstruction = updateGameInfo
+ ? `\n\nThen on a new line write exactly ---GAMEINFO--- followed by a guide to READING THE SCREEN in ${streamInfo?.game || 'this game'}: plain text, under 120 words. Where the HUD elements are and what they mean (health/resource bars, minimap, objective text, timers), what distinct screen states look like (menu, loading, death/defeat, victory), and where the streamer's own overlay sits (webcam, alerts, chat box). Merge with the existing guide below rather than starting over; correct anything it got wrong, and only state what you can actually see — write nothing you are guessing at.`
+ : '';
const profileInstruction = updateProfile
? `\n\nThen on a new line write exactly ---PROFILE--- followed by an updated viewer profile: plain text, under 150 words of LONG-TERM memory worth keeping across streams — per-game progress ("Hades: reached heat 16"), recurring jokes, facts about the streamer. Merge with the existing profile; drop stale trivia.`
: '';
- blocks.push({ text: `Room energy right now: ${energyTone(energy)}\n\n${situation}\n\nRecent chat:\n${historyText}\n\n${task}${pointInstruction}${momentInstruction}${notesInstruction}${profileInstruction}` });
+ blocks.push({ text: `Room energy right now: ${energyTone(energy)}\n\n${situation}\n\nRecent chat:\n${historyText}\n\n${task}${pointInstruction}${momentInstruction}${notesInstruction}${profileInstruction}${gameInfoInstruction}` });
const systemParts = [this.buildSystemPrompt()];
- const context = this.buildContextBlock({ streamContext, profile });
+ const context = this.buildContextBlock({ streamContext, profile, gameInfo, gameInfoLabel: streamInfo?.game });
if (context) systemParts.push(context);
- const maxTokens = 200 + (updateNotes ? 250 : 0) + (updateProfile ? 300 : 0) + (flagMoments ? 20 : 0);
+ // Current-generation models run adaptive thinking when `thinking` is
+ // omitted, and max_tokens caps thinking AND visible text together — so a
+ // budget sized for a one-line chat message gets spent on reasoning and the
+ // message truncates. Headroom is free when unused (a ceiling, not an
+ // allocation), and models that don't think simply never reach it.
+ const maxTokens =
+ THINKING_HEADROOM +
+ 200 + (updateNotes ? 250 : 0) + (updateProfile ? 300 : 0) + (flagMoments ? 20 : 0) + (updateGameInfo ? 250 : 0);
const { raw, usage } =
this.provider === 'anthropic'
? await this.callAnthropic(blocks, maxTokens, systemParts)
@@ -247,12 +284,14 @@ export class Brain {
const newNotes = extractSection(raw, 'NOTES');
const newProfile = extractSection(raw, 'PROFILE');
const moment = extractSection(raw, 'MOMENT');
- const msgPart = raw.split(/-{2,}\s*(?:NOTES|PROFILE|MOMENT)\s*-{2,}/i)[0];
+ const newGameInfo = extractSection(raw, 'GAMEINFO');
+ const msgPart = raw.split(/-{2,}\s*(?:NOTES|PROFILE|MOMENT|GAMEINFO)\s*-{2,}/i)[0];
return {
messages: this.parseMessages(msgPart),
notes: newNotes ? newNotes.slice(0, 1000) : null,
profile: newProfile ? newProfile.slice(0, 1500) : null,
moment: moment ? moment.replace(/^["']|["']$/g, '').slice(0, 60) : null,
+ gameInfo: newGameInfo ? newGameInfo.slice(0, 1200) : null,
usage,
};
}
@@ -286,11 +325,26 @@ export class Brain {
const response = await this.client.messages.create({
model: this.model,
max_tokens: maxTokens,
+ // "React to a screenshot in one line" is not a reasoning task, and effort
+ // defaults to `high` — on every message that is real money and real
+ // latency, and the voice reply has a 6s target to hit. Thinking stays
+ // *on* (a plain generation can still be a memory merge, and disabling it
+ // risks reasoning leaking into text that goes on the stream overlay);
+ // this only caps how deep it goes. Held constant rather than varied per
+ // call so the cached prefix stays byte-stable.
+ ...(supportsEffort(this.model) ? { output_config: { effort: 'low' } } : {}),
// Two cache breakpoints: the byte-stable rules prefix, then the slow-
- // moving context (stream context + viewer profile). Inert below the
- // model's minimum cacheable prefix; engages automatically (and
- // beneficially, at this cadence) once the prompt grows past it.
- system: systemParts.map((text) => ({ type: 'text', text, cache_control: { type: 'ephemeral' } })),
+ // moving context (stream context + viewer profile + game primer). Inert
+ // below the model's minimum cacheable prefix; engages automatically once
+ // the prompt grows past it.
+ //
+ // The 1-hour TTL, not the 5-minute default: with real viewers around the
+ // cadence is `quietSeconds` (8 minutes by default), so a 5-minute entry
+ // expires between messages and every call pays the write premium instead
+ // of ever reading. A 1h write costs 2x versus 1.25x, but survives the
+ // gaps — at an 8-minute cadence that's one write and six reads an hour
+ // rather than seven full-price calls.
+ system: systemParts.map((text) => ({ type: 'text', text, cache_control: { type: 'ephemeral', ttl: '1h' } })),
messages: [{ role: 'user', content }],
});
const raw = response.content.filter((b) => b.type === 'text').map((b) => b.text).join(' ').trim();
diff --git a/src/loop.js b/src/loop.js
index b8ea1c1..0cd77b4 100644
--- a/src/loop.js
+++ b/src/loop.js
@@ -289,6 +289,15 @@ export class GhostLoop {
const updateNotes = memory.enabled && (this.botMessageCount + 1) % memory.updateEvery === 0;
const updateProfile =
memory.enabled && (this.botMessageCount + 1) % (memory.profileEvery ?? 12) === 0;
+ // The visual primer only makes sense once we know what game this is, and
+ // only off a fresh frame — refreshing it from a stale screenshot would
+ // have the model describe a screen it can't currently see.
+ const gameInfo = this.hooks.getGameInfo ? this.hooks.getGameInfo() : '';
+ const updateGameInfo =
+ memory.enabled &&
+ Boolean(screenshot) &&
+ this.hooks.canLearnGame?.() &&
+ (this.botMessageCount + 1) % (memory.gameInfoEvery ?? 8) === 0;
const allowExchange = this.lastStreamerActivityAt > this.lastExchangeAt;
// Moment flags only make sense on a fresh frame, and no faster than once
// every 45s so a single big play doesn't spam the highlight reel.
@@ -325,6 +334,8 @@ export class GhostLoop {
streamInfo: this.hooks.getStreamInfo ? this.hooks.getStreamInfo() : undefined,
talkingPoint,
allowExchange,
+ gameInfo: gameInfo || undefined,
+ updateGameInfo,
});
if (result.usage) this.hooks.addUsage(result.usage);
@@ -345,6 +356,7 @@ export class GhostLoop {
}
if (result.notes) this.hooks.setNotes(result.notes);
if (result.profile) this.hooks.setProfile(result.profile);
+ if (result.gameInfo && this.hooks.setGameInfo) this.hooks.setGameInfo(result.gameInfo);
if (result.moment && this.hooks.onMoment) {
this.lastMomentAt = Date.now();
this.hooks.onMoment(result.moment);
diff --git a/src/models.js b/src/models.js
index 1fc6661..a908725 100644
--- a/src/models.js
+++ b/src/models.js
@@ -3,12 +3,31 @@
* $/MTok rates for the cost meter. The wizard and Settings build their
* dropdowns from this via GET /api/config, so adding a model is one edit.
*/
+// `effort` records whether the model accepts output_config.effort — it is GA on
+// the 5-series and Opus 4.6+, but errors on Haiku 4.5 and older, so it can only
+// be sent to models known to take it.
export const MODELS = [
- { id: 'claude-sonnet-5', label: 'Sonnet — recommended (~$0.20/hr)', inRate: 3, outRate: 15 },
- { id: 'claude-haiku-4-5', label: 'Haiku — budget (~$0.07/hr)', inRate: 1, outRate: 5 },
- { id: 'claude-opus-4-8', label: 'Opus — premium (~$0.60/hr)', inRate: 5, outRate: 25 },
+ { id: 'claude-sonnet-5', label: 'Sonnet — recommended (~$0.20/hr)', inRate: 3, outRate: 15, effort: true },
+ { id: 'claude-haiku-4-5', label: 'Haiku — budget (~$0.07/hr)', inRate: 1, outRate: 5, effort: false },
+ { id: 'claude-opus-5', label: 'Opus — premium (~$0.33/hr)', inRate: 5, outRate: 25, effort: true },
+ { id: 'claude-opus-4-8', label: 'Opus 4.8 — previous premium (~$0.33/hr)', inRate: 5, outRate: 25, effort: true },
];
+/** True when the cost meter has published rates for this model id. */
+export function isKnownModel(modelId) {
+ return MODELS.some((m) => modelId && modelId.startsWith(m.id));
+}
+
+/**
+ * Whether output_config.effort can be sent to this model. Unknown ids — the
+ * "Custom model ID…" path — deliberately answer false: sending a parameter an
+ * unknown model rejects would 400 every generation, and being wrong in that
+ * direction costs a little money rather than the whole feature.
+ */
+export function supportsEffort(modelId) {
+ return MODELS.find((m) => modelId && modelId.startsWith(m.id))?.effort === true;
+}
+
/**
* Cost of one API response in dollars, or null for models we have no rates
* for. Cache reads bill at 10% of the input rate, 5-minute cache writes at
diff --git a/src/server.js b/src/server.js
index 2d39911..22d65c8 100644
--- a/src/server.js
+++ b/src/server.js
@@ -11,9 +11,10 @@ import { MODELS, messageCost } from './models.js';
import { resolveCast, GHOST_COLORS, ARCHETYPES, ACCENT_COLORS } from './cast.js';
import { ObsCapture } from './obs.js';
import { Brain } from './brain.js';
-import { TwitchViewers, DecApi } from './twitch.js';
+import { TwitchViewers, DecApi, isGameCategory } from './twitch.js';
import { TwitchChat } from './twitchchat.js';
import { TranscriptFeed } from './transcript.js';
+import { collectTerms, buildMicCheckScript, deriveCorrections, wordErrorRate, compileCorrections, applyCorrections } from './speech.js';
import { SapiTts } from './tts.js';
import { GhostLoop } from './loop.js';
@@ -81,11 +82,22 @@ export function startServer(opts = {}) {
// UI font scale (Settings → App). Never below 1: text can grow, not shrink —
// and the 🤖 AI badge inherits the same guarantee.
const fontScale = () => Math.min(1.5, Math.max(1, Number(config.app.fontScale) || 1));
+ // Proper nouns speech-to-text garbles worst — cast names, the Twitch channel,
+ // plus anything the streamer added by hand. The mic check builds its reading
+ // script from these, and the brain is told to recover near-misses of them.
+ if (!isPlainObject(config.speech)) config.speech = { dropHallucinations: true, vocabulary: [], corrections: [] };
+ const speechTerms = () =>
+ collectTerms({
+ cast: personas,
+ twitchChannel: config.twitch?.channel,
+ vocabulary: config.speech.vocabulary,
+ });
const brain = new Brain({
brain: config.brain,
anthropic: config.anthropic,
personas,
language: config.bot.language,
+ vocabulary: speechTerms(),
});
// "Brain is configured" gates the wizard redirect and preview mode: the
// wizard can be finished without one (skip), and then the loop stays quiet.
@@ -177,6 +189,42 @@ export function startServer(opts = {}) {
}
} catch {}
+ // Per-game visual primer: how to READ this game's screen (HUD, screen
+ // states, where the streamer's overlay sits). Keyed by game name and kept
+ // across streams — the layout of a game doesn't change between sessions, so
+ // relearning it every night would be pure waste. Built incrementally by the
+ // brain from screenshots it is already being shown; see loop.js.
+ const gameInfoPath = opts.gameInfoPath ?? path.join(path.dirname(notesPath), 'game-notes.json');
+ let gameInfo = {};
+ try {
+ if (existsSync(gameInfoPath)) {
+ const parsed = JSON.parse(readFileSync(gameInfoPath, 'utf8'));
+ if (isPlainObject(parsed)) gameInfo = parsed;
+ const count = Object.keys(gameInfo).length;
+ if (count) console.log(`[memory] loaded screen guides for ${count} game(s).`);
+ }
+ } catch {
+ console.warn('[memory] game-notes.json unreadable — starting fresh.');
+ }
+ const gameKey = (name) => String(name ?? '').trim().toLowerCase();
+ const currentGame = () => state.streamInfo?.game || '';
+ // The category the guide may be keyed to — blank while the stream is under a
+ // non-game category, so "Just Chatting" never accrues a guide blending every
+ // game played beneath it. The cast is still told the category as usual; only
+ // the guide is gated.
+ const guideGame = () => (isGameCategory(currentGame(), config.memory?.gameInfoSkip) ? currentGame() : '');
+ const readGameInfo = () => gameInfo[gameKey(guideGame())] || '';
+ function writeGameInfo(game, text) {
+ const key = gameKey(game);
+ if (!key) return;
+ const trimmed = String(text ?? '').trim();
+ if (trimmed) gameInfo[key] = trimmed;
+ else delete gameInfo[key];
+ try {
+ writeFileSync(gameInfoPath, JSON.stringify(gameInfo, null, 2) + '\n');
+ } catch {}
+ }
+
// ---------- web server ----------
const app = express();
app.use(express.json());
@@ -235,6 +283,10 @@ export function startServer(opts = {}) {
'energy', 'talkingPoints', 'theme.', 'overlay.', 'moments.', 'memory.', 'stream.',
'app.costMeter', 'app.uiLanguage', 'app.fontScale', 'app.autoPause', 'app.autoPauseMinutes', 'app.autoUpdate',
'app.ttsVoice', 'app.ttsRate', 'app.ttsOutputDevice', 'transcript.showInFeed',
+ // The feeds read config.speech live, so accepting mic-check corrections
+ // mid-stream applies to the very next line. (speech.vocabulary is not hot:
+ // it is baked into the brain's cached system prompt at startup.)
+ 'speech.corrections', 'speech.dropHallucinations',
'cadence.soloSeconds', 'cadence.quietSeconds', 'cadence.jitter', 'cadence.burstChance',
'cadence.lullChance', 'cadence.replyDelaySeconds', 'cadence.minVoiceReplyGapSeconds',
'cadence.minScreenshotGapSeconds', 'cadence.minPartyNudgeGapSeconds',
@@ -321,7 +373,7 @@ export function startServer(opts = {}) {
// confirms before calling; this endpoint is the point of no return.
app.post('/api/factory-reset', (_req, res) => {
try {
- for (const p of [configPath, notesPath, sessionPath, profilePath, ...(opts.resetPaths ?? [])]) {
+ for (const p of [configPath, notesPath, sessionPath, profilePath, gameInfoPath, micCheckPath, ...(opts.resetPaths ?? [])]) {
try {
rmSync(p, { force: true });
} catch {}
@@ -502,6 +554,168 @@ export function startServer(opts = {}) {
}
});
+ // ---------- fix a mishear from the deck feed ----------
+ // The streamer sees a 🎙 line go by wrong, retypes it, and the words that
+ // changed become permanent corrections — applied to the very next line,
+ // since the feeds read config.speech live. This is where the correction map
+ // actually grows: the mic check covers the names we can predict, this covers
+ // everything real gameplay throws at it.
+ app.post('/api/speech/correct', (req, res) => {
+ const heard = String(req.body?.heard ?? '').trim();
+ const corrected = String(req.body?.corrected ?? '').trim();
+ if (!heard || !corrected) return res.json({ ok: false, error: 'nothing to compare' });
+ if (heard === corrected) return res.json({ ok: true, added: [] });
+
+ // The streamer is authoritative here, so neither the tracked-term gate nor
+ // the look-alike gate applies — they are telling us, not guessing.
+ const fixes = deriveCorrections(corrected, heard, [], { requireTerm: false, minSimilarity: 0 });
+ const existing = Array.isArray(config.speech.corrections) ? config.speech.corrections : [];
+ const seen = new Set(existing.map((c) => String(c?.from ?? '').toLowerCase()));
+ const added = fixes.filter((f) => !seen.has(f.from.toLowerCase()));
+ if (!added.length) return res.json({ ok: true, added: [] });
+
+ // Replace the array rather than mutating it, so TranscriptFeed's compiled
+ // cache (keyed on array identity) rebuilds on the next line.
+ config.speech.corrections = [...existing, ...added];
+ try {
+ writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n');
+ } catch (err) {
+ return res.status(500).json({ ok: false, error: err.message });
+ }
+ console.log('[speech] learned ' + added.map((f) => `"${f.from}" → "${f.to}"`).join(', '));
+ res.json({ ok: true, added });
+ });
+
+ // ---------- game screen guide (Settings → Stream) ----------
+ // Visible and editable on purpose: a wrong primer is worse than none, since
+ // it gets asserted as truth on every message until something corrects it.
+ app.get('/api/gameinfo', (req, res) => {
+ // ?game= reads a specific saved guide, so one written on a past stream can
+ // still be reviewed and corrected while playing something else.
+ const asked = String(req.query.game || '').trim();
+ const game = asked || guideGame();
+ const skipped = Boolean(currentGame()) && !isGameCategory(currentGame(), config.memory?.gameInfoSkip);
+ res.json({
+ game,
+ current: currentGame(),
+ skipped: skipped ? currentGame() : '',
+ text: gameInfo[gameKey(game)] || '',
+ games: Object.keys(gameInfo).sort(),
+ enabled: config.memory?.enabled !== false,
+ });
+ });
+
+ app.post('/api/gameinfo', (req, res) => {
+ const game = String(req.body?.game || guideGame()).trim();
+ if (!game) {
+ const skipped = currentGame();
+ return res.json({
+ ok: false,
+ error: skipped
+ ? `"${skipped}" isn't a single game, so no screen guide is kept for it (memory.gameInfoSkip). Set a game category to start one.`
+ : 'No game detected yet — set a Twitch category, or turn on "detect game from OBS" above.',
+ });
+ }
+ // Refuse a hand-written guide for a skipped category too: it could never be
+ // read back, so accepting it would just look broken.
+ if (!isGameCategory(game, config.memory?.gameInfoSkip)) {
+ return res.json({ ok: false, error: `"${game}" is on the skip list (memory.gameInfoSkip), so a guide for it would never be used.` });
+ }
+ writeGameInfo(game, String(req.body?.text ?? '').slice(0, 1200));
+ res.json({ ok: true, game, text: gameInfo[gameKey(game)] || '' });
+ });
+
+ // ---------- mic check (Settings → Voice) ----------
+ // Hand the streamer a short script, listen to what LocalVocal makes of it,
+ // and align the two. Because the reference text is known, the result is a
+ // measurement (word error rate) plus evidence-based correction candidates —
+ // no model is trained or touched, and the audio path is the live one, so
+ // what it measures is exactly what the cast will hear all stream.
+ let micCheck = null; // {script, terms, heard[], startedAt}
+ const MIC_CHECK_MAX_MS = 5 * 60 * 1000;
+
+ // Results are kept so a re-run answers "did that LocalVocal change help?"
+ // with a number instead of a feeling. Small and append-only.
+ const MIC_CHECK_HISTORY = 10;
+ const micCheckPath = opts.micCheckPath ?? path.join(path.dirname(notesPath), 'miccheck.json');
+ let micHistory = [];
+ try {
+ if (existsSync(micCheckPath)) {
+ const parsed = JSON.parse(readFileSync(micCheckPath, 'utf8'));
+ if (Array.isArray(parsed)) micHistory = parsed.slice(-MIC_CHECK_HISTORY);
+ }
+ } catch {
+ console.warn('[miccheck] history unreadable — starting fresh.');
+ }
+ function recordMicCheck(entry) {
+ micHistory = [...micHistory, entry].slice(-MIC_CHECK_HISTORY);
+ try {
+ writeFileSync(micCheckPath, JSON.stringify(micHistory, null, 2) + '\n');
+ } catch {}
+ }
+
+ app.get('/api/miccheck/history', (_req, res) => res.json({ history: micHistory }));
+
+ // Self-expiring: a check abandoned by closing the settings page must not keep
+ // suppressing voice replies (below) for the rest of the session.
+ function activeMicCheck() {
+ if (micCheck && Date.now() - micCheck.startedAt > MIC_CHECK_MAX_MS) micCheck = null;
+ return micCheck;
+ }
+
+ app.post('/api/miccheck/start', (_req, res) => {
+ if (config.transcript.mode === 'off') {
+ return res.json({ ok: false, error: 'Turn on voice awareness above (and save) before running a mic check.' });
+ }
+ const terms = speechTerms();
+ const { lines, terms: covered } = buildMicCheckScript({ terms });
+ micCheck = { script: lines, terms: covered, heard: [], startedAt: Date.now() };
+ res.json({ ok: true, script: lines, terms: covered });
+ });
+
+ app.get('/api/miccheck/progress', (_req, res) => {
+ const check = activeMicCheck();
+ res.json({ active: Boolean(check), heard: check ? check.heard : [] });
+ });
+
+ app.post('/api/miccheck/cancel', (_req, res) => {
+ micCheck = null;
+ res.json({ ok: true });
+ });
+
+ app.post('/api/miccheck/finish', (_req, res) => {
+ const check = activeMicCheck();
+ if (!check) return res.json({ ok: false, error: 'No mic check is running — start one first.' });
+ const { script, terms, heard } = check;
+ micCheck = null;
+ const reference = script.join(' ');
+ const transcribed = heard.join(' ');
+ if (!transcribed.trim()) {
+ return res.json({
+ ok: false,
+ error: 'Nothing came through. Check that OBS is running with the LocalVocal filter on your mic, and that the file/text source above is the one it writes to.',
+ });
+ }
+ const suggestions = deriveCorrections(reference, transcribed, terms);
+ // What the same reading would have scored with these corrections in place —
+ // so the streamer can see whether accepting them is actually worth it.
+ const projected = applyCorrections(transcribed, compileCorrections(suggestions));
+ const wer = wordErrorRate(reference, transcribed);
+ // Captured before the new entry lands, so the UI can say "was X% last time".
+ const previous = micHistory.length ? micHistory[micHistory.length - 1] : null;
+ recordMicCheck({ ts: Date.now(), wer, model: config.transcript.mode });
+ res.json({
+ ok: true,
+ reference,
+ heard: transcribed,
+ wer,
+ projectedWer: wordErrorRate(reference, projected),
+ suggestions,
+ previous,
+ history: micHistory,
+ });
+ });
+
const httpServer = createServer(app);
const wss = new WebSocketServer({ server: httpServer });
@@ -553,8 +767,11 @@ export function startServer(opts = {}) {
const transcriptFeed = new TranscriptFeed({
...config.transcript,
windowSeconds: config.cadence.transcriptWindowSeconds,
+ speech: config.speech,
obs,
onSpeech: (line) => {
+ const check = activeMicCheck();
+ if (check) check.heard.push(line);
state.lastHeard = { text: line, ts: Date.now() };
// Deck-only echo of what was transcribed, so mishears are visible the
// moment they happen. Never enters `history` (the brain already gets
@@ -564,7 +781,9 @@ export function startServer(opts = {}) {
broadcast({ type: 'heard', heard: { channel: 'mic', text: line, ts: Date.now() } });
}
broadcastState();
- loop.onSpeech();
+ // Reading the mic-check script is talking *at* the app, not to chat —
+ // replying to it would cost a generation and drown out the check.
+ if (!check) loop.onSpeech();
},
});
@@ -575,6 +794,7 @@ export function startServer(opts = {}) {
const partyFeed = new TranscriptFeed({
...config.transcript2,
windowSeconds: config.cadence.transcriptWindowSeconds,
+ speech: config.speech,
obs,
onSpeech: (line) => {
state.lastHeardParty = { text: line, ts: Date.now() };
@@ -590,6 +810,28 @@ export function startServer(opts = {}) {
},
});
+ // Voice configured but nothing has EVER arrived is almost always a wrong
+ // file path or a missing LocalVocal filter — and it looks exactly like the
+ // cast ignoring you, so say it out loud. Deliberately gated on "nothing
+ // ever", not "nothing lately": a streamer who simply hasn't talked yet is
+ // not a misconfiguration, and a false alarm here would be noise every stream.
+ const SILENT_WARN_MS = opts.silentWarnMs ?? 10 * 60_000; // overridable so tests don't wait 10 minutes
+ if (config.transcript.mode !== 'off') {
+ const startedAt = Date.now();
+ const silentCheck = setInterval(() => {
+ if (transcriptFeed.lastHeardAt) return clearInterval(silentCheck);
+ if (!obs.connected || Date.now() - startedAt < SILENT_WARN_MS) return;
+ clearInterval(silentCheck);
+ const where = config.transcript.mode === 'file'
+ ? `the file it writes to (currently ${config.transcript.file || 'not set'})`
+ : `the text source it writes to (currently "${config.transcript.textSource || 'not set'}")`;
+ const text = `Voice awareness is on, but nothing has come through in 10 minutes. Check that OBS has the LocalVocal filter on your mic source, and that Settings → Voice points at ${where}.`;
+ broadcast({ type: 'system', text });
+ console.warn('[transcript] ' + text);
+ }, Math.min(60_000, SILENT_WARN_MS));
+ silentCheck.unref?.();
+ }
+
// ---------- the ghost ----------
const loop = new GhostLoop({
config,
@@ -616,6 +858,16 @@ export function startServer(opts = {}) {
console.log('[memory] session notes updated:', sessionNotes.replace(/\s+/g, ' ').slice(0, 100) + '…');
},
onMoment: (label) => pushMoment(label),
+ getGameInfo: readGameInfo,
+ // Asked before spending output tokens on a guide the server would refuse
+ // to store — no game detected, or a category that isn't one game.
+ canLearnGame: () => Boolean(guideGame()),
+ setGameInfo: (next) => {
+ const game = guideGame();
+ if (!game) return; // nothing to key it to; don't write an orphan entry
+ writeGameInfo(game, next);
+ console.log(`[memory] screen guide updated for "${game}".`);
+ },
getProfile: () => profile,
setProfile: (next) => {
profile = next;
diff --git a/src/speech.js b/src/speech.js
new file mode 100644
index 0000000..918b74c
--- /dev/null
+++ b/src/speech.js
@@ -0,0 +1,420 @@
+/**
+ * Speech-to-text quality layer.
+ *
+ * LocalVocal (whisper.cpp inside OBS) is the transcriber and stays the
+ * transcriber — this module is everything we can do to the *text* it produces
+ * without running a second speech model on a machine that is already sharing
+ * a GPU with OBS and a game:
+ *
+ * 1. Drop Whisper's well-known hallucinations ("Thank you.", "Thanks for
+ * watching!", subtitle-credit lines, stuck repetition loops). These are
+ * triggered by near-silence, and a mic-only feed is mostly near-silence —
+ * pauses between sentences, breaths, keyboard clatter, fan and room tone.
+ * Left alone, each one looks like the streamer speaking and can trigger a
+ * voice reply to something nobody said.
+ * 2. Apply a per-streamer correction map — Whisper mangles proper nouns worst
+ * (ghost names, channel name, game titles), and those are exactly the words
+ * that matter here.
+ * 3. Build that map from evidence instead of guesswork: the "mic check" hands
+ * the streamer a short script to read, so we know the reference text and can
+ * align it against what actually came back.
+ *
+ * Note what this is NOT: it is not training or fine-tuning. A paragraph of
+ * audio is nowhere near enough to adapt an acoustic model, and it never
+ * touches the model. It is a measurement, and a lookup table built from it.
+ *
+ * Everything here is pure — no I/O, no config, no clock — so it is directly
+ * testable and safe to call per transcript line.
+ */
+
+const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+
+// Word boundaries that behave for unicode letters (\b does not).
+const WORD_START = '(?= 6) return true;
+ }
+ // A 2–3 word phrase tiled across the entire line ("no way no way no way …").
+ for (const unit of [2, 3]) {
+ if (norms.length < unit * 4 || norms.length % unit !== 0) continue;
+ const head = norms.slice(0, unit).join(' ');
+ let tiled = true;
+ for (let i = unit; i < norms.length; i += unit) {
+ if (norms.slice(i, i + unit).join(' ') !== head) { tiled = false; break; }
+ }
+ if (tiled) return true;
+ }
+ return false;
+}
+
+/** True when a line is almost certainly Whisper filler rather than speech. */
+export function isHallucination(text) {
+ const norm = normalizeForCompare(text);
+ if (!norm) return true;
+ if (isSoundEventOnly(text)) return true;
+ if (FILLER_LINES.has(norm)) return true;
+ if (FILLER_LINES.has(norm.replace(/\s+/g, ' '))) return true;
+ return isRepetitionLoop(norm.split(' '));
+}
+
+// ---------------------------------------------------------------------------
+// 2. Correction map
+// ---------------------------------------------------------------------------
+
+/**
+ * Turn [{from, to}] into compiled whole-word regexes. Compile once per
+ * corrections array (callers cache on array identity) — this is hot code.
+ * A blank `to` deletes the phrase, which is the escape hatch for a filler
+ * line the built-in hallucination list doesn't know about.
+ */
+export function compileCorrections(corrections) {
+ const compiled = [];
+ const list = Array.isArray(corrections) ? corrections.slice(0, MAX_CORRECTIONS) : [];
+ for (const entry of list) {
+ const from = String(entry?.from ?? '').trim();
+ if (!from || from.length > MAX_FROM_LEN) continue;
+ const to = String(entry?.to ?? '').trim();
+ // Internal whitespace matches loosely so a two-word `from` still hits when
+ // the transcriber spaced it differently.
+ const body = from.split(/\s+/).map(escapeRe).join('\\s+');
+ try {
+ compiled.push({ re: new RegExp(`${WORD_START}${body}${WORD_END}`, 'giu'), to });
+ } catch {
+ // An unrepresentable `from` is skipped rather than breaking the feed.
+ }
+ }
+ return compiled;
+}
+
+/** Apply compiled corrections; returns '' if the line was fully deleted. */
+export function applyCorrections(text, compiled) {
+ let out = String(text ?? '');
+ for (const { re, to } of compiled ?? []) {
+ re.lastIndex = 0;
+ out = out.replace(re, to);
+ }
+ return out.replace(/\s+/g, ' ').trim();
+}
+
+// ---------------------------------------------------------------------------
+// 3. Mic check — reference script, alignment, suggestions
+// ---------------------------------------------------------------------------
+
+/**
+ * The proper nouns this install cares about getting right. Ordinary words are
+ * deliberately excluded: a mishear of "the" costs nothing, a mishear of a
+ * ghost's name breaks a voice reply.
+ */
+export function collectTerms({ cast = [], twitchChannel = '', game = '', vocabulary = [] } = {}) {
+ const terms = [];
+ const seen = new Set();
+ const add = (value) => {
+ const term = String(value ?? '').trim();
+ if (!term || term.length > 40) return;
+ const key = term.toLowerCase();
+ if (seen.has(key)) return;
+ seen.add(key);
+ terms.push(term);
+ };
+ for (const member of cast) add(typeof member === 'string' ? member : member?.name);
+ add(twitchChannel);
+ add(game);
+ for (const word of Array.isArray(vocabulary) ? vocabulary : []) add(word);
+ return terms.slice(0, 10);
+}
+
+// Carrier sentences, written the way a streamer actually talks so the reading
+// voice matches the streaming voice. Each %s takes one term.
+const ONE_TERM = [
+ 'okay chat, %s is asking about the build again.',
+ 'shoutout to %s, thanks for hanging out tonight.',
+ 'hold on, %s just said something and i missed it.',
+ 'i swear %s says that every single stream.',
+ 'alright %s, you called it, that was rough.',
+];
+const TWO_TERM = [
+ '%s and %s, you two are menaces today.',
+ 'between %s and %s i have no idea who to believe.',
+];
+// Always included: a term-free line, so a baseline error rate exists even for
+// an install with no custom vocabulary at all.
+const CONTROL_LINES = [
+ 'let me check if this thing is actually hearing me at normal talking volume.',
+ 'that was a clutch play and i genuinely did not think it was going to work.',
+];
+
+/**
+ * A short script covering this install's proper nouns. Returns the lines to
+ * read plus the terms being measured. ~20 seconds of reading.
+ */
+export function buildMicCheckScript({ terms = [] } = {}) {
+ const pool = terms.slice(0, 6);
+ const lines = [CONTROL_LINES[0]];
+ let i = 0;
+ let oneIdx = 0;
+ let twoIdx = 0;
+ while (i < pool.length) {
+ if (pool.length - i >= 2 && twoIdx < TWO_TERM.length) {
+ lines.push(TWO_TERM[twoIdx++].replace('%s', pool[i]).replace('%s', pool[i + 1]));
+ i += 2;
+ } else {
+ lines.push(ONE_TERM[oneIdx++ % ONE_TERM.length].replace('%s', pool[i]));
+ i += 1;
+ }
+ }
+ lines.push(CONTROL_LINES[1]);
+ return { lines, terms: pool };
+}
+
+/**
+ * Word-level alignment (Levenshtein with backtrace). Returns the edit script
+ * as ops: 'equal' | 'sub' | 'del' (in reference, missing from heard) | 'ins'
+ * (heard but not in reference).
+ */
+export function alignWords(refNorms, hypNorms) {
+ const a = refNorms.slice(0, MAX_ALIGN_WORDS);
+ const b = hypNorms.slice(0, MAX_ALIGN_WORDS);
+ const n = a.length;
+ const m = b.length;
+ const d = Array.from({ length: n + 1 }, () => new Int32Array(m + 1));
+ for (let i = 0; i <= n; i++) d[i][0] = i;
+ for (let j = 0; j <= m; j++) d[0][j] = j;
+ for (let i = 1; i <= n; i++) {
+ for (let j = 1; j <= m; j++) {
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
+ d[i][j] = Math.min(d[i - 1][j - 1] + cost, d[i - 1][j] + 1, d[i][j - 1] + 1);
+ }
+ }
+ const ops = [];
+ let i = n;
+ let j = m;
+ while (i > 0 || j > 0) {
+ if (i > 0 && j > 0 && d[i][j] === d[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1)) {
+ ops.push({ op: a[i - 1] === b[j - 1] ? 'equal' : 'sub', ref: i - 1, hyp: j - 1 });
+ i--; j--;
+ } else if (i > 0 && d[i][j] === d[i - 1][j] + 1) {
+ ops.push({ op: 'del', ref: i - 1, hyp: -1 });
+ i--;
+ } else {
+ ops.push({ op: 'ins', ref: -1, hyp: j - 1 });
+ j--;
+ }
+ }
+ ops.reverse();
+ return ops;
+}
+
+/** Character edit distance, for judging whether two phrases are plausibly the same one. */
+function editDistance(a, b) {
+ if (a === b) return 0;
+ let prev = Array.from({ length: b.length + 1 }, (_, j) => j);
+ for (let i = 1; i <= a.length; i++) {
+ const row = [i];
+ for (let j = 1; j <= b.length; j++) {
+ row[j] = Math.min(prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1), prev[j] + 1, row[j - 1] + 1);
+ }
+ prev = row;
+ }
+ return prev[b.length];
+}
+
+/** 1 = identical, 0 = nothing in common. */
+export function similarity(a, b) {
+ const x = normalizeForCompare(a).replace(/\s+/g, '');
+ const y = normalizeForCompare(b).replace(/\s+/g, '');
+ if (!x || !y) return 0;
+ return 1 - editDistance(x, y) / Math.max(x.length, y.length);
+}
+
+/** Standard word error rate: (substitutions + deletions + insertions) / reference words. */
+export function wordErrorRate(reference, heard) {
+ const ref = tokenize(reference).map((t) => t.norm);
+ const hyp = tokenize(heard).map((t) => t.norm);
+ if (!ref.length) return hyp.length ? 1 : 0;
+ const errors = alignWords(ref, hyp).filter((o) => o.op !== 'equal').length;
+ return Math.min(1, errors / ref.length);
+}
+
+/**
+ * Derive correction candidates by aligning what was read against what came
+ * back. Only mismatches whose *reference* side contains a tracked term are
+ * offered — the point is fixing "beacon → bacon", not rewriting every filler
+ * word the transcriber fumbled. A mismatch the transcriber simply dropped
+ * (nothing heard) yields no suggestion: there is no text to rewrite.
+ *
+ * Two guards keep a sloppy reading from producing sloppy rules. A streamer who
+ * skips a line, ad-libs, or reads out of order makes the alignment collapse
+ * into one enormous mismatch — so segments past a few words are discarded, and
+ * both sides must actually resemble each other. A bad suggestion is worse than
+ * a missing one: it silently rewrites real speech for the rest of the stream.
+ */
+export function deriveCorrections(reference, heard, terms = [], options = {}) {
+ // The mic check derives rules from a script, so both gates apply. A fix the
+ // streamer typed themselves is authoritative — they are not guessing at what
+ // they said — so that path turns the gates off.
+ const { requireTerm = true, minSimilarity = MIN_SIMILARITY } = options;
+ const ref = tokenize(reference);
+ const hyp = tokenize(heard);
+ const refNorms = ref.map((t) => t.norm);
+ const ops = alignWords(refNorms, hyp.map((t) => t.norm));
+
+ const phrases = terms.map((t) => normalizeForCompare(t)).filter(Boolean);
+ // Individual words of multi-word terms count as hits too ("Knight" of
+ // "Hollow Knight"), minus the short connectives that would match anything.
+ const termWords = new Set();
+ for (const phrase of phrases) {
+ for (const word of phrase.split(' ')) {
+ if (word.length >= 4 || phrase.split(' ').length === 1) termWords.add(word);
+ }
+ }
+ // Where a multi-word term sits in the reference, so a mangle of one of its
+ // words widens to the whole name: "night → Knight" would rewrite every
+ // "good night" all stream, while "hollow night → Hollow Knight" cannot.
+ const spans = [];
+ for (const phrase of phrases) {
+ const words = phrase.split(' ');
+ if (words.length < 2) continue;
+ for (let s = 0; s + words.length <= refNorms.length; s++) {
+ if (words.every((w, k) => refNorms[s + k] === w)) spans.push([s, s + words.length - 1]);
+ }
+ }
+ const refToHyp = new Map();
+ for (const o of ops) if (o.ref >= 0 && o.hyp >= 0) refToHyp.set(o.ref, o.hyp);
+ const clean = (s) => s.replace(/[^\p{L}\p{N}\s'-]/gu, ' ').replace(/\s+/g, ' ').trim();
+
+ const suggestions = [];
+ const seen = new Set();
+ let k = 0;
+ while (k < ops.length) {
+ if (ops[k].op === 'equal') { k++; continue; }
+ // Group the consecutive mismatch into one segment, so a two-word name
+ // heard as three words becomes a single replacement.
+ const refIdx = [];
+ const hypIdx = [];
+ while (k < ops.length && ops[k].op !== 'equal') {
+ if (ops[k].ref >= 0) refIdx.push(ops[k].ref);
+ if (ops[k].hyp >= 0) hypIdx.push(ops[k].hyp);
+ k++;
+ }
+ if (!refIdx.length || !hypIdx.length) continue;
+ if (refIdx.length > MAX_SEGMENT_WORDS || hypIdx.length > MAX_SEGMENT_WORDS) continue;
+
+ let refStart = Math.min(...refIdx);
+ let refEnd = Math.max(...refIdx);
+ const span = spans.find(([s, e]) => refIdx.some((i) => i >= s && i <= e));
+ if (span) {
+ refStart = Math.min(refStart, span[0]);
+ refEnd = Math.max(refEnd, span[1]);
+ } else if (requireTerm && !refIdx.some((i) => termWords.has(refNorms[i]))) {
+ continue; // nothing worth protecting in this mismatch
+ }
+
+ // Widen the heard side to whatever aligned with the widened reference.
+ const hypRange = [...hypIdx];
+ for (let i = refStart; i <= refEnd; i++) {
+ const j = refToHyp.get(i);
+ if (j !== undefined) hypRange.push(j);
+ }
+ const from = clean(hyp.slice(Math.min(...hypRange), Math.max(...hypRange) + 1).map((t) => t.raw).join(' '));
+ const to = clean(ref.slice(refStart, refEnd + 1).map((t) => t.raw).join(' '));
+ if (!from || !to || normalizeForCompare(from) === normalizeForCompare(to)) continue;
+ if (similarity(from, to) < minSimilarity) continue;
+ const key = `${from.toLowerCase()}→${to.toLowerCase()}`;
+ if (seen.has(key)) continue;
+ seen.add(key);
+ suggestions.push({ from, to });
+ }
+ return suggestions.slice(0, 12);
+}
diff --git a/src/storage.js b/src/storage.js
index baf6e4a..2729f4b 100644
--- a/src/storage.js
+++ b/src/storage.js
@@ -17,7 +17,10 @@ export const POINTER_FILE = 'storage.json';
// Every data file the app writes, relative to its data dir. The log and the
// update-skip marker are listed so migration and factory reset carry/clear
// them with everything else.
-export const DATA_FILES = ['config.json', 'session-notes.txt', 'session.json', 'profile.md', 'hype-ghost.log', 'update-skip.json'];
+export const DATA_FILES = [
+ 'config.json', 'session-notes.txt', 'session.json', 'profile.md',
+ 'game-notes.json', 'miccheck.json', 'hype-ghost.log', 'update-skip.json',
+];
export function dataFilePaths(dir) {
return {
@@ -25,6 +28,8 @@ export function dataFilePaths(dir) {
notesPath: path.join(dir, 'session-notes.txt'),
sessionPath: path.join(dir, 'session.json'),
profilePath: path.join(dir, 'profile.md'),
+ gameInfoPath: path.join(dir, 'game-notes.json'),
+ micCheckPath: path.join(dir, 'miccheck.json'),
logPath: path.join(dir, 'hype-ghost.log'),
skipPath: path.join(dir, 'update-skip.json'),
};
diff --git a/src/transcript.js b/src/transcript.js
index 1a893ad..f60a70e 100644
--- a/src/transcript.js
+++ b/src/transcript.js
@@ -1,4 +1,5 @@
import { openSync, readSync, fstatSync, closeSync, existsSync, statSync } from 'node:fs';
+import { isHallucination, compileCorrections, applyCorrections } from './speech.js';
/**
* LocalVocal can write plain text or SRT subtitles. In SRT, only every third
@@ -25,9 +26,13 @@ const MAX_CHUNK = 256 * 1024; // safety cap on a single delta read
* session reusing the path) are detected by comparing the first bytes of the
* file, and reset the offset. A trailing line with no newline yet is held
* back until it completes, so half-written words never enter the transcript.
+ *
+ * Every line then passes through the speech-quality layer (`src/speech.js`):
+ * Whisper's silence hallucinations are dropped, and the streamer's correction
+ * map is applied, before anything reaches the cast or the deck feed.
*/
export class TranscriptFeed {
- constructor({ mode, file, textSource, pollSeconds, windowSeconds, obs, onSpeech }) {
+ constructor({ mode, file, textSource, pollSeconds, windowSeconds, obs, onSpeech, speech }) {
this.mode = mode || 'off';
this.file = file;
this.textSource = textSource;
@@ -35,6 +40,11 @@ export class TranscriptFeed {
this.windowMs = (windowSeconds ?? 120) * 1000;
this.obs = obs;
this.onSpeech = onSpeech || (() => {});
+ // Live reference to config.speech — hot config saves deep-assign into the
+ // same object, so corrections apply mid-stream without a relaunch.
+ this.speech = speech || {};
+ this.compiled = null; // corrections compiled once, keyed on array identity
+ this.compiledFrom = null;
this.entries = []; // {ts, text}
this.lastHeardAt = null;
// file-mode tail state
@@ -76,9 +86,25 @@ export class TranscriptFeed {
}
}
+ /** Compiled corrections, rebuilt only when the config array is swapped out. */
+ corrections() {
+ const list = this.speech?.corrections;
+ if (list !== this.compiledFrom) {
+ this.compiledFrom = list;
+ this.compiled = compileCorrections(list);
+ }
+ return this.compiled;
+ }
+
addLine(text) {
- const cleaned = String(text).trim();
- if (!cleaned || isSrtMetadata(cleaned)) return;
+ const raw = String(text).trim();
+ if (!raw || isSrtMetadata(raw)) return;
+ // Whisper filler invented during the near-silence between sentences would
+ // otherwise read as the streamer speaking — and a voice reply to something
+ // nobody said is worse than missing a line, so this is on by default.
+ if (this.speech?.dropHallucinations !== false && isHallucination(raw)) return;
+ const cleaned = applyCorrections(raw, this.corrections());
+ if (!cleaned) return; // a correction mapping to "" deletes the line
this.entries.push({ ts: Date.now(), text: cleaned });
this.lastHeardAt = Date.now();
this.prune();
diff --git a/src/twitch.js b/src/twitch.js
index 4f34eb1..4ed0b95 100644
--- a/src/twitch.js
+++ b/src/twitch.js
@@ -189,3 +189,21 @@ export class DecApi {
}
}
}
+
+/**
+ * Is this category actually a single game?
+ *
+ * Twitch categories are not all games — "Just Chatting", "Music", "IRL" and
+ * friends cover whole streams that may cut across several games or none. They
+ * matter because the per-game screen guide is keyed by category name: a guide
+ * accumulated under "Just Chatting" would blend every game played beneath it
+ * and end up describing none of them accurately. Matching is case-insensitive
+ * and whitespace-trimmed; the skip list is user-extendable in config, since a
+ * Window Capture can also report things like a browser or Discord.
+ */
+export function isGameCategory(name, skip = []) {
+ const key = (s) => String(s ?? '').trim().toLowerCase();
+ const category = key(name);
+ if (!category) return false;
+ return !(Array.isArray(skip) ? skip : []).some((entry) => key(entry) === category);
+}
diff --git a/test/brain.test.js b/test/brain.test.js
new file mode 100644
index 0000000..dd57f5f
--- /dev/null
+++ b/test/brain.test.js
@@ -0,0 +1,172 @@
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { Brain } from '../src/brain.js';
+
+const PERSONAS = [
+ { name: 'Beacon', personality: 'curious and goofy' },
+ { name: 'Wisp', personality: 'dry one-liners' },
+];
+
+function makeBrain(raw, opts = {}) {
+ const brain = new Brain({
+ brain: { provider: 'anthropic' },
+ anthropic: { apiKey: 'sk-test', model: 'claude-sonnet-5' },
+ personas: PERSONAS,
+ language: 'English',
+ ...opts,
+ });
+ // Stub the transport: everything under test is prompt assembly and parsing.
+ const calls = [];
+ brain.callAnthropic = async (blocks, maxTokens, systemParts) => {
+ calls.push({ blocks, maxTokens, systemParts });
+ return { raw, usage: { input_tokens: 1, output_tokens: 1 } };
+ };
+ return { brain, calls };
+}
+
+const baseArgs = { history: [], energy: 55, mode: 'solo', trigger: 'timer' };
+
+// Stubs one layer lower — the SDK client itself — so the real callAnthropic
+// runs and the outgoing request body can be asserted.
+function makeWiredBrain(model) {
+ const brain = new Brain({
+ brain: { provider: 'anthropic' },
+ anthropic: { apiKey: 'sk-test', model },
+ personas: PERSONAS,
+ language: 'English',
+ });
+ const sent = [];
+ brain.client = {
+ messages: {
+ create: async (params) => {
+ sent.push(params);
+ return { content: [{ type: 'text', text: 'Beacon: hi' }], usage: {} };
+ },
+ },
+ };
+ return { brain, sent };
+}
+
+test('parses NAME: lines and every piggybacked tail section', async () => {
+ const { brain } = makeBrain(
+ [
+ 'Beacon: oh no that was close',
+ 'Wisp: skill issue tbh',
+ '---NOTES---',
+ 'Playing Hollow Knight, stuck on Hornet.',
+ '---PROFILE---',
+ 'Hollow Knight: reached Hornet.',
+ '---GAMEINFO---',
+ 'Health is the mask row top-left. Death shows a grey "you died" wash.',
+ '---MOMENT---',
+ 'clutch Hornet dodge',
+ ].join('\n')
+ );
+ const out = await brain.generate({ ...baseArgs, updateNotes: true, updateProfile: true, updateGameInfo: true, flagMoments: true });
+
+ assert.deepEqual(out.messages, [
+ { speaker: 'Beacon', text: 'oh no that was close' },
+ { speaker: 'Wisp', text: 'skill issue tbh' },
+ ]);
+ assert.match(out.notes, /stuck on Hornet/);
+ assert.match(out.profile, /reached Hornet/);
+ assert.match(out.gameInfo, /mask row top-left/);
+ assert.equal(out.moment, 'clutch Hornet dodge');
+ // Tail sections must not leak into the chat messages.
+ assert.ok(!out.messages.some((m) => /GAMEINFO|NOTES/.test(m.text)));
+});
+
+test('gameInfo is null when the model does not emit the section', async () => {
+ const { brain } = makeBrain('Beacon: hi chat');
+ const out = await brain.generate({ ...baseArgs });
+ assert.equal(out.gameInfo, null);
+});
+
+test('the screen guide rides the cached context block, not the per-message turn', async () => {
+ const { brain, calls } = makeBrain('Beacon: hi', {});
+ await brain.generate({
+ ...baseArgs,
+ gameInfo: 'Health is the mask row top-left.',
+ streamInfo: { game: 'Hollow Knight' },
+ });
+ const [{ systemParts, blocks }] = calls;
+ // Second system part = the slow-moving context block, its own cache breakpoint.
+ assert.equal(systemParts.length, 2);
+ assert.match(systemParts[1], /mask row top-left/);
+ assert.match(systemParts[1], /Hollow Knight/);
+ // …and not repeated in the volatile user turn, which would defeat the point.
+ assert.ok(!blocks.some((b) => b.text && b.text.includes('mask row top-left')));
+});
+
+test('the guide is only requested on the update cadence, and budgeted for', async () => {
+ const { brain, calls } = makeBrain('Beacon: hi');
+ await brain.generate({ ...baseArgs, streamInfo: { game: 'Hades' } });
+ await brain.generate({ ...baseArgs, streamInfo: { game: 'Hades' }, updateGameInfo: true });
+
+ const askedOff = calls[0].blocks.some((b) => b.text && b.text.includes('---GAMEINFO---'));
+ const askedOn = calls[1].blocks.some((b) => b.text && b.text.includes('---GAMEINFO---'));
+ assert.equal(askedOff, false);
+ assert.equal(askedOn, true);
+ assert.ok(calls[1].maxTokens > calls[0].maxTokens, 'needs output budget for the guide');
+ assert.match(calls[1].blocks.at(-1).text, /Hades/);
+});
+
+test('context block is null when there is nothing slow-moving to cache', async () => {
+ const { brain } = makeBrain('Beacon: hi');
+ assert.equal(brain.buildContextBlock({}), null);
+ assert.match(brain.buildContextBlock({ gameInfo: 'HUD notes' }), /HUD notes/);
+});
+
+// The transcript is ASR output; naming the proper nouns lets the model recover
+// a near-miss instead of reacting to nonsense.
+test('vocabulary terms are named in the byte-stable system prompt', () => {
+ const { brain } = makeBrain('', { vocabulary: ['Beacon', 'emurray', 'Hollow Knight'] });
+ const prompt = brain.buildSystemPrompt();
+ assert.match(prompt, /Beacon, emurray, Hollow Knight/);
+ const { brain: bare } = makeBrain('');
+ assert.ok(!/near-miss/.test(bare.buildSystemPrompt()));
+});
+
+// Thinking shares max_tokens with the visible text on current-generation
+// models, so a budget sized for a one-line message truncates it away.
+test('every generation reserves output budget for thinking', async () => {
+ const { brain, sent } = makeWiredBrain('claude-sonnet-5');
+ await brain.generate({ ...baseArgs });
+ assert.ok(sent[0].max_tokens >= 600, `plain message budget too tight: ${sent[0].max_tokens}`);
+ await brain.generate({ ...baseArgs, updateNotes: true, updateProfile: true, updateGameInfo: true, streamInfo: { game: 'Hades' } });
+ assert.ok(sent[1].max_tokens > sent[0].max_tokens, 'tail sections still add their own room');
+});
+
+// effort is GA on the 5-series but errors on Haiku 4.5, so it can only go to
+// models known to accept it — and never to a custom id we know nothing about.
+test('effort is capped only for models that accept the parameter', async () => {
+ for (const model of ['claude-sonnet-5', 'claude-opus-5', 'claude-opus-4-8']) {
+ const { brain, sent } = makeWiredBrain(model);
+ await brain.generate({ ...baseArgs });
+ assert.deepEqual(sent[0].output_config, { effort: 'low' }, model);
+ }
+ for (const model of ['claude-haiku-4-5', 'claude-some-future-model', 'my-local-thing']) {
+ const { brain, sent } = makeWiredBrain(model);
+ await brain.generate({ ...baseArgs });
+ assert.equal('output_config' in sent[0], false, `${model} must not receive output_config`);
+ }
+});
+
+// Removed on the current generation: sending either is a 400 on every call.
+test('no sampling or explicit thinking parameters are ever sent', async () => {
+ const { brain, sent } = makeWiredBrain('claude-opus-5');
+ await brain.generate({ ...baseArgs });
+ for (const banned of ['temperature', 'top_p', 'top_k', 'thinking']) {
+ assert.equal(banned in sent[0], false, `${banned} must not be sent`);
+ }
+});
+
+// The 1h TTL is what makes caching pay off at this app's 8-minute cadence.
+test('both cache breakpoints carry the 1h TTL', async () => {
+ const { brain, sent } = makeWiredBrain('claude-sonnet-5');
+ await brain.generate({ ...baseArgs, streamContext: 'variety streamer' });
+ assert.equal(sent[0].system.length, 2);
+ for (const part of sent[0].system) {
+ assert.deepEqual(part.cache_control, { type: 'ephemeral', ttl: '1h' });
+ }
+});
diff --git a/test/models.test.js b/test/models.test.js
index b78fb07..dd92ee6 100644
--- a/test/models.test.js
+++ b/test/models.test.js
@@ -1,6 +1,6 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
-import { MODELS, messageCost } from '../src/models.js';
+import { MODELS, messageCost, isKnownModel } from '../src/models.js';
test('plain input/output tokens bill at the listed rates', () => {
// Sonnet: $3/MTok in, $15/MTok out
@@ -32,3 +32,23 @@ test('every catalog entry has the fields the UI and cost meter need', () => {
assert.equal(typeof m.outRate, 'number');
}
});
+
+// No catalog id may be a prefix of another, or messageCost's prefix match would
+// bill a custom/dated id at the wrong model's rates.
+test('catalog ids do not shadow each other by prefix', () => {
+ for (const a of MODELS) {
+ for (const b of MODELS) {
+ if (a !== b) assert.ok(!a.id.startsWith(b.id), `${a.id} shadowed by ${b.id}`);
+ }
+ }
+});
+
+// Drives the "Custom model ID…" hint: it warns the deck can't show a dollar
+// figure only when the id really has no rates.
+test('isKnownModel tracks what the cost meter can price', () => {
+ assert.equal(isKnownModel('claude-sonnet-5'), true);
+ assert.equal(isKnownModel('claude-opus-5-20260101'), true); // dated snapshot
+ assert.equal(isKnownModel('claude-opus-4-7'), false);
+ assert.equal(isKnownModel(''), false);
+ assert.equal(isKnownModel(undefined), false);
+});
diff --git a/test/speech.test.js b/test/speech.test.js
new file mode 100644
index 0000000..6a4853b
--- /dev/null
+++ b/test/speech.test.js
@@ -0,0 +1,165 @@
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import {
+ isHallucination, compileCorrections, applyCorrections, wordErrorRate,
+ deriveCorrections, collectTerms, buildMicCheckScript, normalizeForCompare, similarity,
+} from '../src/speech.js';
+
+// ---- hallucination filtering ----
+
+test('drops whisper filler that is the entire line', () => {
+ for (const line of ['Thank you.', 'thanks for watching!', 'Please subscribe to my channel', '[Music]', '♪♪♪', 'Bye.', '(upbeat music)']) {
+ assert.equal(isHallucination(line), true, `expected filler: ${line}`);
+ }
+});
+
+// The whole point of matching the entire line: streamers really do say these
+// words, just inside sentences. Dropping those would be worse than the bug.
+test('keeps real speech that merely contains a filler phrase', () => {
+ for (const line of ['thank you for the follow that is so nice', 'okay so the plan is we go left', 'bye bye little guy that was a rough end']) {
+ assert.equal(isHallucination(line), false, `expected speech: ${line}`);
+ }
+});
+
+test('drops decoder repetition loops but keeps ordinary repetition', () => {
+ assert.equal(isHallucination('yeah yeah yeah yeah yeah yeah yeah'), true);
+ assert.equal(isHallucination('no way no way no way no way'), true);
+ assert.equal(isHallucination('go go go we need to move'), false);
+});
+
+// ---- correction map ----
+
+test('applies corrections on whole words only, case-insensitively', () => {
+ const compiled = compileCorrections([{ from: 'bacon', to: 'Beacon' }]);
+ assert.equal(applyCorrections('lol bacon is right', compiled), 'lol Beacon is right');
+ assert.equal(applyCorrections('BACON said hi', compiled), 'Beacon said hi');
+ // substring must not match, or "baconator" becomes "Beaconator"
+ assert.equal(applyCorrections('baconator burger', compiled), 'baconator burger');
+});
+
+test('multi-word corrections tolerate different spacing', () => {
+ const compiled = compileCorrections([{ from: 'hyped host', to: 'Hype Ghost' }]);
+ assert.equal(applyCorrections('welcome to hyped host', compiled), 'welcome to Hype Ghost');
+});
+
+test('a blank target deletes the phrase, and an emptied line collapses to ""', () => {
+ const compiled = compileCorrections([{ from: 'uh', to: '' }]);
+ assert.equal(applyCorrections('uh okay uh sure', compiled), 'okay sure');
+ assert.equal(applyCorrections('uh', compiled), '');
+});
+
+test('malformed correction entries are skipped, not thrown', () => {
+ const compiled = compileCorrections([null, { from: '', to: 'x' }, { from: 'a'.repeat(500), to: 'y' }, { from: 'ok', to: 'okay' }]);
+ assert.equal(compiled.length, 1);
+ assert.equal(applyCorrections('ok', compiled), 'okay');
+ assert.deepEqual(compileCorrections(undefined), []);
+});
+
+test('regex metacharacters in a correction are matched literally', () => {
+ const compiled = compileCorrections([{ from: 'c++', to: 'C plus plus' }]);
+ assert.equal(applyCorrections('i write c++ daily', compiled), 'i write C plus plus daily');
+});
+
+// ---- measurement ----
+
+test('word error rate counts substitutions, deletions and insertions', () => {
+ assert.equal(wordErrorRate('hello there chat', 'hello there chat'), 0);
+ assert.equal(wordErrorRate('hello there chat', 'hello their chat'), 1 / 3);
+ assert.equal(wordErrorRate('hello there chat', 'hello chat'), 1 / 3);
+ assert.equal(wordErrorRate('hello there chat', 'hello there my chat'), 1 / 3);
+ // punctuation and casing are not errors
+ assert.equal(wordErrorRate('Hello, there chat!', 'hello there chat'), 0);
+});
+
+// ---- suggestion derivation ----
+
+test('suggests a fix only for mismatches that hit a tracked term', () => {
+ const reference = 'okay chat, Beacon is asking about the build again.';
+ const heard = 'okay chat bacon is asking about a build again';
+ const fixes = deriveCorrections(reference, heard, ['Beacon']);
+ assert.deepEqual(fixes, [{ from: 'bacon', to: 'Beacon' }]);
+});
+
+test('groups a multi-word mangle into one replacement', () => {
+ const fixes = deriveCorrections('shoutout to Hype Ghost tonight', 'shoutout to hyped host tonight', ['Hype Ghost']);
+ assert.deepEqual(fixes, [{ from: 'hyped host', to: 'Hype Ghost' }]);
+});
+
+// Nothing was heard in place of the name, so there is no text a
+// find-and-replace could rewrite — suggesting one would be a no-op.
+test('a dropped word yields no suggestion', () => {
+ assert.deepEqual(deriveCorrections('hey Wisp you there', 'hey you there', ['Wisp']), []);
+});
+
+// A streamer who skips a line or ad-libs collapses the alignment into one
+// enormous mismatch. Turning that into a find-and-replace rule would silently
+// rewrite real speech for the rest of the stream.
+test('a wildly divergent reading produces no suggestions', () => {
+ const reference = 'okay chat, Beacon is asking about the build again. shoutout to emurray, thanks for hanging out tonight.';
+ const heard = 'uh i totally lost my place there hang on let me find where i was on this thing';
+ assert.deepEqual(deriveCorrections(reference, heard, ['Beacon', 'emurray']), []);
+});
+
+test('unrelated words dragged together by a misalignment are rejected', () => {
+ // "Wisp" and "emurray" look nothing alike — pairing them is an alignment
+ // artifact, not a mishear.
+ assert.ok(similarity('whisp', 'emurray') < 0.45);
+ assert.ok(similarity('bacon', 'Beacon') > 0.7);
+ assert.ok(similarity('hollow night', 'Hollow Knight') > 0.7);
+});
+
+// "night → Knight" alone would rewrite every "good night" the streamer says.
+test('a partly-mangled multi-word name widens to the whole name', () => {
+ const fixes = deriveCorrections('okay chat, Hollow Knight is fun', 'okay chat hollow night is fun', ['Hollow Knight']);
+ assert.deepEqual(fixes, [{ from: 'hollow night', to: 'Hollow Knight' }]);
+});
+
+// Click-to-fix in the deck: the streamer retyped the line, so they are the
+// authority — the term gate and the look-alike gate both stand down, or an
+// explicit correction of an untracked word would be silently discarded.
+test('an explicit correction bypasses the term and similarity gates', () => {
+ const opts = { requireTerm: false, minSimilarity: 0 };
+ assert.deepEqual(
+ deriveCorrections('lets go to the shrine', 'lets go to the shine', [], opts),
+ [{ from: 'shine', to: 'shrine' }]
+ );
+ // Untracked and nothing like each other — still honored when typed by hand.
+ assert.deepEqual(
+ deriveCorrections('that was a gank', 'that was a gong', [], opts),
+ [{ from: 'gong', to: 'gank' }]
+ );
+ // …but the same pair is rejected on the mic-check path, which must not guess.
+ assert.deepEqual(deriveCorrections('that was a gank', 'that was a gong', []), []);
+});
+
+test('a clean reading yields no suggestions', () => {
+ assert.deepEqual(deriveCorrections('hey Wisp you there', 'hey wisp you there', ['Wisp']), []);
+});
+
+test('derived fixes actually repair the transcript when applied', () => {
+ const reference = 'alright Beacon, you called it, that was rough.';
+ const heard = 'alright bacon you called it that was rough';
+ const fixes = deriveCorrections(reference, heard, ['Beacon']);
+ const repaired = applyCorrections(heard, compileCorrections(fixes));
+ assert.ok(wordErrorRate(reference, repaired) < wordErrorRate(reference, heard));
+});
+
+// ---- terms + script ----
+
+test('collectTerms merges cast, channel and custom vocabulary without duplicates', () => {
+ const terms = collectTerms({
+ cast: [{ name: 'Beacon' }, { name: 'Wisp' }],
+ twitchChannel: 'emurray',
+ vocabulary: ['Hollow Knight', 'wisp'],
+ });
+ assert.deepEqual(terms, ['Beacon', 'Wisp', 'emurray', 'Hollow Knight']);
+});
+
+test('the mic-check script covers every term and works with none', () => {
+ const { lines, terms } = buildMicCheckScript({ terms: ['Beacon', 'Wisp', 'emurray'] });
+ const script = normalizeForCompare(lines.join(' '));
+ for (const term of terms) assert.ok(script.includes(normalizeForCompare(term)), `${term} missing from script`);
+ assert.ok(lines.length >= 3);
+ // No cast, no channel, no vocabulary — still a readable baseline script.
+ assert.ok(buildMicCheckScript({ terms: [] }).lines.length >= 2);
+});
diff --git a/test/storage.test.js b/test/storage.test.js
index 27e685c..d4def7a 100644
--- a/test/storage.test.js
+++ b/test/storage.test.js
@@ -3,7 +3,7 @@ import assert from 'node:assert/strict';
import { mkdirSync, mkdtempSync, readFileSync, writeFileSync, existsSync } from 'node:fs';
import path from 'node:path';
import os from 'node:os';
-import { POINTER_FILE, dataFilePaths, resolveDataDir, setDataDir, migrateDataFiles } from '../src/storage.js';
+import { POINTER_FILE, DATA_FILES, dataFilePaths, resolveDataDir, setDataDir, migrateDataFiles } from '../src/storage.js';
import { FileLog } from '../src/logfile.js';
const tmp = () => mkdtempSync(path.join(os.tmpdir(), 'hg-storage-'));
@@ -69,6 +69,8 @@ test('dataFilePaths covers every file factory reset must delete', () => {
notesPath: 'session-notes.txt',
sessionPath: 'session.json',
profilePath: 'profile.md',
+ gameInfoPath: 'game-notes.json',
+ micCheckPath: 'miccheck.json',
logPath: 'hype-ghost.log',
skipPath: 'update-skip.json',
};
@@ -95,3 +97,13 @@ test('FileLog never throws on unwritable targets', () => {
mkdirSync(log.file, { recursive: true }); // appendFileSync will fail: target is a directory
assert.doesNotThrow(() => log.append('error', ['lost line']));
});
+
+// "Move data folder…" copies exactly DATA_FILES, while the hosts hand
+// startServer the paths from dataFilePaths(). If the two drift, a moved folder
+// silently loses whatever was missing — the learned game guides and mic-check
+// history are the newest way to hit that.
+test('dataFilePaths and DATA_FILES cover the same files', () => {
+ const paths = dataFilePaths('/data');
+ const named = Object.values(paths).map((p) => path.basename(p)).sort();
+ assert.deepEqual(named, [...DATA_FILES].sort());
+});
diff --git a/test/transcript.test.js b/test/transcript.test.js
index e6ba39a..a47cd59 100644
--- a/test/transcript.test.js
+++ b/test/transcript.test.js
@@ -9,13 +9,14 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// Feeds are driven by calling pollFile() directly — start() is never called,
// so no intervals are created and tests control every tick.
-function makeFeed(file) {
+function makeFeed(file, speech) {
const heard = [];
const feed = new TranscriptFeed({
mode: 'file',
file,
pollSeconds: 999,
windowSeconds: 120,
+ speech,
onSpeech: (line) => heard.push(line),
});
return { feed, heard };
@@ -89,6 +90,57 @@ test('getWindow prunes entries older than the window and honors sinceTs', (t) =>
assert.equal(feed.getWindow(Date.now() + 1000), ''); // nothing newer than the future
});
+// The speech-quality layer sits between the tail and onSpeech, so a dropped
+// hallucination never reaches the deck feed, a voice reply, or the window.
+test('drops transcription hallucinations before they reach onSpeech', (t) => {
+ const file = withTmp(t);
+ const { feed, heard } = makeFeed(file, {});
+ writeFileSync(file, 'Thank you.\n[Music]\nokay that boss is actually unfair\n');
+ feed.pollFile();
+ assert.deepEqual(heard, ['okay that boss is actually unfair']);
+ assert.equal(feed.getWindow(), 'okay that boss is actually unfair');
+});
+
+test('hallucination filtering can be turned off', (t) => {
+ const file = withTmp(t);
+ const { feed, heard } = makeFeed(file, { dropHallucinations: false });
+ writeFileSync(file, 'Thank you.\n');
+ feed.pollFile();
+ assert.deepEqual(heard, ['Thank you.']);
+});
+
+test('applies word fixes before the line is stored or announced', (t) => {
+ const file = withTmp(t);
+ const { feed, heard } = makeFeed(file, { corrections: [{ from: 'bacon', to: 'Beacon' }] });
+ writeFileSync(file, 'lol bacon is right\n');
+ feed.pollFile();
+ assert.deepEqual(heard, ['lol Beacon is right']);
+ assert.equal(feed.getWindow(), 'lol Beacon is right');
+});
+
+// Accepting mic-check fixes is a hot config save: the server deep-assigns into
+// the same speech object the feed holds, so the next line must already use them
+// without an app relaunch.
+test('picks up corrections swapped in live, without reconstruction', (t) => {
+ const file = withTmp(t);
+ const speech = { corrections: [] };
+ const { feed, heard } = makeFeed(file, speech);
+ writeFileSync(file, 'hey bacon\n');
+ feed.pollFile();
+ speech.corrections = [{ from: 'bacon', to: 'Beacon' }];
+ appendFileSync(file, 'hey bacon again\n');
+ feed.pollFile();
+ assert.deepEqual(heard, ['hey bacon', 'hey Beacon again']);
+});
+
+test('a line deleted entirely by a fix never reaches onSpeech', (t) => {
+ const file = withTmp(t);
+ const { feed, heard } = makeFeed(file, { corrections: [{ from: 'subscribe now', to: '' }] });
+ writeFileSync(file, 'subscribe now\nreal speech here\n');
+ feed.pollFile();
+ assert.deepEqual(heard, ['real speech here']);
+});
+
// textSource mode must drive the exact same onSpeech path as file mode — the
// deck's "heard" feed echo and voice replies hang off that callback, so both
// transcription modes get identical behavior.
diff --git a/test/twitch.test.js b/test/twitch.test.js
index ee4a751..aaa1360 100644
--- a/test/twitch.test.js
+++ b/test/twitch.test.js
@@ -1,6 +1,6 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
-import { parseDecapiValue, parseDecapiViewers, DecApi } from '../src/twitch.js';
+import { parseDecapiValue, parseDecapiViewers, DecApi, isGameCategory } from '../src/twitch.js';
// DecAPI speaks plain text and returns errors as sentences with HTTP 200,
// so these parsers are the whole correctness story for the keyless path.
@@ -41,3 +41,33 @@ test('DecApi is inert without a channel or when disabled', () => {
assert.equal(new DecApi({ channel: '#Shroud' }).channel, 'shroud');
assert.equal(new DecApi({ channel: 'shroud' }).configured(), true);
});
+
+// The per-game screen guide is keyed by category name, so a category that
+// isn't one game ("Just Chatting") would accumulate a guide blending every
+// game played beneath it — describing none of them.
+test('isGameCategory rejects non-game categories, case and space insensitively', () => {
+ const skip = ['Just Chatting', 'Science & Technology'];
+ assert.equal(isGameCategory('Hollow Knight', skip), true);
+ assert.equal(isGameCategory('Just Chatting', skip), false);
+ assert.equal(isGameCategory(' just chatting ', skip), false, 'trimmed + case-folded');
+ assert.equal(isGameCategory('SCIENCE & TECHNOLOGY', skip), false);
+});
+
+test('isGameCategory treats a missing category as not learnable', () => {
+ assert.equal(isGameCategory('', ['Just Chatting']), false);
+ assert.equal(isGameCategory(null, []), false);
+ assert.equal(isGameCategory(' ', []), false);
+});
+
+// A partial match must not fire: these are real, distinct game categories.
+test('isGameCategory only matches whole category names', () => {
+ const skip = ['Music', 'Art'];
+ assert.equal(isGameCategory('Music Racer', skip), true);
+ assert.equal(isGameCategory('Art of Rally', skip), true);
+});
+
+test('isGameCategory copes with a missing or malformed skip list', () => {
+ assert.equal(isGameCategory('Hades'), true);
+ assert.equal(isGameCategory('Hades', null), true);
+ assert.equal(isGameCategory('Hades', 'nonsense'), true);
+});