diff --git a/.github/workflows/auto-release.yml b/.github/workflows/auto-release.yml index 478623c..d403675 100644 --- a/.github/workflows/auto-release.yml +++ b/.github/workflows/auto-release.yml @@ -54,7 +54,11 @@ jobs: # Refresh tags at the last moment: a queued run's checkout can # predate the previous run's tag push. git fetch origin --tags --quiet - latest=$(git tag --list 'v*' --sort=-v:refname | head -1) + # Stable tags only. A beta tag (v3.8.0-beta.1) sorts ABOVE the stable + # release it precedes, and splitting it on "." puts "0-beta.1" into + # $patch — which is a hard arithmetic error in the bump below, so a + # single beta tag would block every stable release until deleted. + latest=$(git tag --list 'v*' --sort=-v:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | head -1) latest=${latest:-v3.2.0} version=${latest#v} IFS=. read -r major minor patch <<< "$version" diff --git a/.github/workflows/beta-release.yml b/.github/workflows/beta-release.yml new file mode 100644 index 0000000..cfe22e3 --- /dev/null +++ b/.github/workflows/beta-release.yml @@ -0,0 +1,150 @@ +name: Beta Release + +# Builds a Windows installer from ANY branch and publishes it as a GitHub +# **prerelease**, so a change can be tested on a real stream without merging to +# main and without anyone compiling anything. +# +# Why this is safe for people running the stable app: the version carries a +# `-beta.N` suffix, so electron-builder writes the update feed as `beta.yml` +# instead of `latest.yml`. A stable install polls `latest.yml` and never sees +# these builds. Marking the release as a prerelease keeps it off the repo's +# "Latest release" badge too. +# +# Actions → Beta Release → Run workflow → pick the branch. +# +# Versions climb from the newest STABLE tag: on v3.7.0, the first beta of the +# next minor is v3.8.0-beta.1, then -beta.2, and so on. +# +# Note what a beta install does NOT do: come back on its own. electron-updater +# follows a channel file, not just the highest version, and a stable release +# publishes only latest.yml — so beta.yml keeps advertising the last beta and a +# tester is never offered the real v3.8.0. Leaving the channel is a deliberate +# act: Settings → App → "Which builds to update to" → Stable only. +on: + workflow_dispatch: + inputs: + ref: + description: "Branch or SHA to build (defaults to the branch you picked above)" + required: false + type: string + bump: + description: "Which stable release this beta is heading toward" + required: false + default: minor + type: choice + options: [patch, minor, major] + notes: + description: "What should be tested in this build?" + required: false + type: string + +permissions: + contents: write + +# Beta versions are derived from existing tags, so two concurrent runs would +# compute the same number and one would lose its tag push. +concurrency: + group: beta-release + cancel-in-progress: false + +jobs: + installer: + name: Build & publish beta installer + runs-on: windows-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v7 + with: + ref: ${{ inputs.ref || github.ref }} + fetch-depth: 0 # tags are needed to compute the next beta number + + - uses: actions/setup-node@v5 + with: + node-version: '24' + cache: npm + + # Full install (no --ignore-scripts): the Electron binary is needed + # to package the app. + - name: Install dependencies + run: npm ci + + # A beta is for testing a change, not for shipping something broken. + - name: Run tests + run: npm test + + - name: Compute the next beta version + id: version + shell: bash + env: + BUMP: ${{ inputs.bump || 'minor' }} + run: | + set -euo pipefail + git fetch origin --tags --quiet + + # Stable tags only — a previous beta must never become the base for + # the next one, or versions would ratchet away from the real line. + latest=$(git tag --list 'v*' --sort=-v:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | head -1 || true) + latest=${latest:-v3.2.0} + IFS=. read -r major minor patch <<< "${latest#v}" + + case "$BUMP" in + major) major=$((major + 1)); minor=0; patch=0 ;; + minor) minor=$((minor + 1)); patch=0 ;; + *) patch=$((patch + 1)) ;; + esac + base="$major.$minor.$patch" + + # Highest existing beta for this exact base, sorted numerically so + # beta.10 lands after beta.9 rather than before it. + n=$(git tag --list "v$base-beta.*" | sed -E 's/.*-beta\.([0-9]+)$/\1/' | sort -n | tail -1 || true) + n=$(( ${n:-0} + 1 )) + + version="$base-beta.$n" + echo "Building $version from $(git rev-parse --short HEAD) (previous stable: $latest)" + echo "version=$version" >> "$GITHUB_OUTPUT" + echo "tag=v$version" >> "$GITHUB_OUTPUT" + + # The tag is the version authority, exactly as on the stable line. + - name: Stamp version and tag the build + shell: bash + env: + TAG: ${{ steps.version.outputs.tag }} + VERSION: ${{ steps.version.outputs.version }} + run: | + set -euo pipefail + npm pkg set version="$VERSION" + git tag "$TAG" + git push origin "$TAG" + + # The `-beta.N` suffix is what makes electron-builder emit beta.yml + # rather than latest.yml — the whole isolation guarantee rests on it. + - name: Build the Windows installer + run: npx electron-builder --win --publish never + + - name: Publish the prerelease + uses: softprops/action-gh-release@v3 + with: + tag_name: ${{ steps.version.outputs.tag }} + name: Hype Ghost v${{ steps.version.outputs.version }} (beta) + prerelease: true + generate_release_notes: true + files: | + dist/*.exe + dist/*.exe.blockmap + dist/beta.yml + body: | + ## 🧪 Test build — not a release + + Built from `${{ inputs.ref || github.ref_name }}` at `${{ github.sha }}`. + + **[⬇ Hype-Ghost-Setup-${{ steps.version.outputs.version }}.exe](https://github.com/${{ github.repository }}/releases/download/${{ steps.version.outputs.tag }}/Hype-Ghost-Setup-${{ steps.version.outputs.version }}.exe)** — Windows SmartScreen will warn because the installer is unsigned: **More info → Run anyway**. + + ### What to test + + ${{ inputs.notes || '_(nothing specified)_' }} + + ### Notes + + - Installs over your existing app and **keeps your settings and memory** — the data folder is untouched. + - People on the stable build will never be offered this one: it publishes `beta.yml`, and a stable install only reads `latest.yml`. + - This build auto-updates to newer betas, and **will not return to stable on its own** — a stable release publishes only to the stable feed, so this install never sees it. To come back: **Settings → App → Which builds to update to → Stable only**, then relaunch (or just install the latest normal release from the [releases page](https://github.com/${{ github.repository }}/releases/latest) over the top). diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4e8edc4..c47f304 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -27,6 +27,12 @@ permissions: jobs: installer: name: Build installer & publish release + # Stable tags only. A hand-pushed beta tag (v3.8.0-beta.1) also matches the + # "v*" filter above, and publishing it here would produce a NON-prerelease + # release carrying latest.yml — which is the file every stable install + # polls, so it would push a test build to everyone. Betas go through + # beta-release.yml instead. + if: ${{ !contains(github.ref_name, '-') }} # Windows because it's the shipping target (tray, NSIS, %APPDATA% paths). runs-on: windows-latest timeout-minutes: 30 diff --git a/CLAUDE.md b/CLAUDE.md index c0777e0..acb271b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -33,6 +33,8 @@ npm run smoke **Branch off `main`, PR into `main`.** `main` is the only long-lived branch — the trunk and the released line the installer/auto-updater builds from; released states are pinned by `vX.Y.Z` tags, and older lines (1.x/2.x/the retired `v3` integration branch) live in `main`'s history. Feature branches are `claude/` or `feature/`. Releasing is automatic: merging app-code changes to `main` tags the next version (patch by default; a `Release-Bump: minor|major` trailer line in any PR commit raises it, `Release-Skip: true` suppresses it) and CI builds + publishes the installer. The version authority is the git tag — CI stamps `package.json` at build time. The Release workflow also remains manually runnable against an existing tag. +**Beta builds** come from `beta-release.yml` (Actions → Beta Release → pick a branch): it tags `vX.Y.Z-beta.N` off the newest *stable* tag, builds the installer, and publishes a GitHub prerelease — testable without merging, and invisible to stable installs because the `-beta.N` suffix makes electron-builder write `beta.yml` rather than the `latest.yml` they poll. The channel is a *file*, not a version comparison, so a beta install never drifts back to stable by itself — `app.updateChannel` (`auto` | `stable` | `beta`, Settings → App) is the escape hatch, and the only case where `allowDowngrade` is set. Anywhere the next stable version is computed, beta tags **must** be filtered out (`^v[0-9]+\.[0-9]+\.[0-9]+$`): they sort above the release they precede, and splitting `3.8.0-beta.1` on `.` puts `0-beta.1` where a number belongs, which is a fatal arithmetic error rather than a wrong answer. + ## Architecture The app is a local Express + WebSocket server with two hosts: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d3a59a7..f05da00 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -20,6 +20,24 @@ notes are generated from PR titles — label PRs `enhancement`/`bug` to sort the (`.github/release.yml`). Manual fallback: run the Release workflow from Actions against an existing tag, or push a `vX.Y.Z` tag by hand. +**Beta builds ship from any branch, without merging.** Actions → **Beta Release** → Run +workflow, pick a branch. It tags `vX.Y.Z-beta.N` (climbing from the newest *stable* tag), +builds the installer, and publishes a GitHub **prerelease** — so a change can be tested on +a real stream without anyone compiling anything. Stable installs are never offered these: +the `-beta.N` suffix makes electron-builder write the update feed as `beta.yml`, and a +stable install only ever reads `latest.yml`. A beta install does **not** find its way back +on its own — a stable release publishes only `latest.yml`, so `beta.yml` goes on advertising +the last beta. Leaving the channel is deliberate: Settings → App → *Which builds to update +to* → **Stable only** (`app.updateChannel`), which is also the only path allowed to step the +version down. + +> Beta tags are excluded wherever the next stable version is computed. They sort *above* +> the release they precede, and `3.8.0-beta.1` split on `.` puts `0-beta.1` where a number +> belongs — which is a fatal arithmetic error, not a wrong answer. Keep the +> `^v[0-9]+\.[0-9]+\.[0-9]+$` filter in `auto-release.yml`, and the prerelease guard on +> `release.yml`'s job (its `v*` tag trigger matches beta tags too, and publishing one there +> would write `latest.yml` — pushing a test build to everybody). + > The one rule worth remembering: **branch off `main`, PR into `main`.** ## Making a change diff --git a/README.md b/README.md index ee72b01..5b7c1af 100644 --- a/README.md +++ b/README.md @@ -143,6 +143,7 @@ Quit Hype Ghost. | `moments.saveReplay` | When a moment is flagged and OBS's **replay buffer** is running, save it — the ✨ becomes an actual clip on disk (default true; a no-op if the buffer is off). | | `theme.accent` | Your (the human's) accent color on the deck: `violet cyan emerald amber magenta` (default violet). | | `overlay.theme` / `overlay.reactions` | Overlay style (`cards` or `compact`) and whether a ✨ moment pop shows on the overlay. | +| `app.updateChannel` | Which builds the auto-updater follows: `auto` (default — matches the build you're running, the long-standing behaviour), `stable`, or `beta` (early test builds). Beta builds are published as GitHub prereleases and stable installs never see them. Note a beta install won't return to stable by itself — a stable release only publishes to the stable feed — so `stable` is the deliberate way back, and the only case allowed to step the version down. | | `app.autoUpdate` | Check GitHub for new versions at launch and update automatically (default true). | | `app.setupComplete` | Set by the setup wizard when you finish it (even via the brain step's "skip"). Once true, `/` opens the dashboard — without a brain that's **preview mode**: everything explorable, cast quiet, $0. | | `app.costMeter` | Show the live session cost readout in the deck's top bar (default true). | @@ -171,6 +172,10 @@ Quit Hype Ghost. | `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.voiceReplyRequiresAddress` | Only spend a voice reply when the cast is actually addressed — a ghost named out loud, or a prompt answer to a question one just asked (default true). Most stream talk is narration, and replying to all of it fires an extra generation after nearly every message; turning this off restores the old behaviour and roughly doubles cost on a talkative stream. Undirected speech still reaches the cast — it's in the transcript the next scheduled message reads. | +| `cadence.voiceReplyDelaySeconds` | How long after you stop talking a voice reply lands (default 8). | +| `cadence.voiceReplyWindowSeconds` | How recent the cast's last message must be for your speech to count as answering it (default 120). | +| `cadence.voiceAnswerWindowSeconds` | How soon after a ghost's *question* your speech still counts as answering it (default 30). Short on purpose: the curious archetype ends a lot of messages with "?", so a generous window would match nearly everything. | | `cadence.minScreenshotGapSeconds` | Skip re-screenshotting on quick follow-ups (default 25). | | `cadence.transcriptWindowSeconds` | How much recent mic speech the ghost hears (default 120). | | `memory.enabled` / `memory.updateEvery` | Rolling session memory: every N messages the model refreshes a <100-word stream summary, kept as context in every call. Persists across restarts (`session-notes.txt`, ignored after 6h). | @@ -183,6 +188,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). +**Message count, not per-message price, is usually what makes a bill surprising.** Three things +multiply it: the **energy dial** (100 shortens every gap to ~0.45x, more than doubling volume), +**voice replies** (see `cadence.voiceReplyRequiresAddress`), and the model itself (Opus is ~1.7x +Sonnet, ~5x Haiku). Hover the cost pill for the token split — the share of input served from +cache, and output-tokens-per-message. A one-line chat message is ~60 output tokens, so a number +far above that means thinking is doing the spending. + 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 diff --git a/config.example.json b/config.example.json index b33fd0f..514e626 100644 --- a/config.example.json +++ b/config.example.json @@ -34,6 +34,7 @@ }, "app": { "autoUpdate": true, + "updateChannel": "auto", "costMeter": true, "autoPause": true, "autoPauseMinutes": 10, @@ -140,6 +141,10 @@ "lullChance": 0.15, "replyDelaySeconds": 6, "minVoiceReplyGapSeconds": 35, + "voiceReplyRequiresAddress": true, + "voiceReplyDelaySeconds": 8, + "voiceReplyWindowSeconds": 120, + "voiceAnswerWindowSeconds": 30, "viewerPollSeconds": 60, "transcriptWindowSeconds": 120, "minScreenshotGapSeconds": 25, diff --git a/electron/main.js b/electron/main.js index ccd9884..f62c753 100644 --- a/electron/main.js +++ b/electron/main.js @@ -270,6 +270,27 @@ if (gotLock) app.whenReady().then(() => { // no meaning left and is dropped. const updateSkip = new UpdateSkip(skipPath); updateSkip.clearIfNotNewer(app.getVersion()); + + // Update channel (Settings → App). "auto" follows whichever build is + // running, which is exactly the previous behaviour: a stable install reads + // latest.yml, a beta build reads beta.yml. + // + // The explicit "stable" option exists because leaving the beta channel is + // otherwise a one-way door. A stable release publishes only latest.yml, so + // beta.yml goes on advertising the last beta and a tester is never offered + // the real release. Coming home is also a version *decrease* + // (3.8.0-beta.5 → 3.7.0), so without allowDowngrade the escape hatch would + // silently do nothing — and that flag is scoped to exactly this case so a + // normal install can never be walked backwards. + const running = app.getVersion(); + const onPrerelease = running.includes('-'); + const setting = ['stable', 'beta'].includes(appCfg.updateChannel) ? appCfg.updateChannel : 'auto'; + const wantsBeta = setting === 'beta' || (setting === 'auto' && onPrerelease); + autoUpdater.allowPrerelease = wantsBeta; + autoUpdater.channel = wantsBeta ? 'beta' : 'latest'; + autoUpdater.allowDowngrade = setting === 'stable' && onPrerelease; + console.log(`[update] running v${running}; channel "${setting}" → ${wantsBeta ? 'beta' : 'stable'} builds`); + autoUpdater.autoDownload = false; // decide *before* pulling ~80MB autoUpdater.on('update-available', (info) => { if (updateSkip.isSkipped(info.version)) { diff --git a/public/dashboard.html b/public/dashboard.html index 75a8a3a..39a130b 100644 --- a/public/dashboard.html +++ b/public/dashboard.html @@ -586,7 +586,14 @@ const el = $('cost'); const u = state.usage; if (!state.costMeter || !u || u.messages === 0) { el.textContent = ''; return; } el.textContent = u.costKnown ? `$${u.cost.toFixed(3)} · ${u.messages} ${T('msgs')}` : `${u.messages} ${T('msgs')}`; - el.title = `${(u.inputTokens||0).toLocaleString()} in + ${(u.outputTokens||0).toLocaleString()} out tokens`; + // The two numbers that explain a surprising bill: what share of input the + // prompt cache is actually serving, and output-per-message (which is where + // runaway thinking shows up — a one-line chat message is ~60 tokens). + const cached = u.cachedInputTokens || 0; + const cachePct = u.inputTokens ? Math.round((cached / u.inputTokens) * 100) : 0; + const outPer = Math.round((u.outputTokens || 0) / u.messages); + el.title = `${(u.inputTokens||0).toLocaleString()} in (${cachePct}% from cache) + ${(u.outputTokens||0).toLocaleString()} out` + + `\n~${outPer} output tokens per message`; } // ---------- session clock + countdown ---------- diff --git a/public/settings.html b/public/settings.html index 17765ce..3458bf6 100644 --- a/public/settings.html +++ b/public/settings.html @@ -244,6 +244,10 @@

🎙️ Voice awareness

+
+ + +
@@ -320,6 +324,7 @@

💬 Cadence & energy

+
@@ -377,6 +382,15 @@

🔧 App

+
+ + +

"Match this build" is the normal choice and changes nothing about how updates already work. Pick Stable only to leave the beta channel: a stable release only ever publishes to the stable feed, so a beta install is never offered it otherwise — this is how you come back, even though it means stepping the version down. Takes effect on the next launch.

+
diff --git a/src/loop.js b/src/loop.js index 0cd77b4..9d811fe 100644 --- a/src/loop.js +++ b/src/loop.js @@ -17,10 +17,15 @@ * getNotes()/setNotes(s) -> rolling session memory persistence * addUsage(usage) -> token usage for the cost meter */ +// The only import here on purpose: a pure text helper, no host coupling — the +// loop still reaches the outside world exclusively through `hooks`. +import { mentionsAny } from './speech.js'; + export class GhostLoop { - constructor({ config, brain, obs, transcriptFeed, partyFeed, hooks }) { + constructor({ config, brain, obs, transcriptFeed, partyFeed, hooks, castNames }) { this.config = config; this.brain = brain; + this.castNames = Array.isArray(castNames) ? castNames : []; // for "were they talking to us?" this.obs = obs; this.feed = transcriptFeed; this.partyFeed = partyFeed; // second LocalVocal channel: co-op / party audio @@ -161,26 +166,56 @@ export class GhostLoop { } /** - * The streamer said something on mic. If it lands after the ghost's latest - * message, treat it as a spoken answer: reply ~8s after they stop talking. - * Guards: once per bot message, a rate floor so continuous conversation - * can't run unbounded past the configured cadence, and a busy retry so a + * The streamer said something on mic. + * + * Most of what a streamer says is not aimed at the cast — narrating a fight, + * talking to a co-op partner, thinking out loud. Treating all of it as "they + * answered me" fires an extra generation after nearly every cast message, + * which roughly doubles API spend on a talkative stream and makes the cast + * read as interrupting rather than listening. + * + * So a voice reply now needs the speech to be *addressed* to them. Nothing is + * lost when it isn't: the transcript window is read by the next scheduled + * generation either way, so undirected talk still reaches the cast — it just + * doesn't buy its own API call. + * + * Guards, unchanged: once per bot message, a rate floor so continuous + * conversation can't outrun the configured cadence, and a busy retry so a * reply isn't silently dropped (and marked answered) mid-generation. */ - onSpeech() { + onSpeech(text = '') { this.lastStreamerActivityAt = Date.now(); - const minGapMs = (this.config.cadence.minVoiceReplyGapSeconds ?? 35) * 1000; + const c = this.config.cadence; + const minGapMs = (c.minVoiceReplyGapSeconds ?? 35) * 1000; + const windowMs = (c.voiceReplyWindowSeconds ?? 120) * 1000; const last = this.hooks.getHistory().at(-1); const answerable = last && last.role === 'bot' && last.id !== this.voiceRepliedTo && - Date.now() - last.ts < 120_000 && + Date.now() - last.ts < windowMs && Date.now() - this.lastVoiceReplyAt >= minGapMs; if (!answerable || this.paused) return; + if (c.voiceReplyRequiresAddress !== false && !this.isAddressedToCast(text, last)) return; clearTimeout(this.voiceReplyTimer); this.voiceBusyRetries = 0; - this.voiceReplyTimer = setTimeout(() => this.fireVoiceReply(), 8000); + this.voiceReplyTimer = setTimeout(() => this.fireVoiceReply(), (c.voiceReplyDelaySeconds ?? 8) * 1000); + } + + /** + * Was that line aimed at the cast? Two signals, both free and local: + * + * 1. A ghost named out loud — unambiguous, and the correction map already + * works to keep those names intact through transcription. + * 2. A prompt answer to a ghost's question. Deliberately on a short fuse: + * the "curious" archetype ends a lot of messages with "?", so a generous + * window would match nearly everything and undo the whole point. + */ + isAddressedToCast(text, last) { + if (mentionsAny(text, this.castNames)) return true; + const answerMs = (this.config.cadence.voiceAnswerWindowSeconds ?? 30) * 1000; + const askedSomething = /\?['")\]]*\s*$/.test((last?.text || '').trim()); + return askedSomething && Date.now() - last.ts <= answerMs; } fireVoiceReply() { diff --git a/src/server.js b/src/server.js index 22d65c8..d37259e 100644 --- a/src/server.js +++ b/src/server.js @@ -130,7 +130,10 @@ export function startServer(opts = {}) { tts: ttsState(), uiLanguage: config.app.uiLanguage || 'en', fontScale: fontScale(), - usage: { messages: 0, inputTokens: 0, outputTokens: 0, cost: 0, costKnown: true }, + // cachedInputTokens is tracked separately from inputTokens (which is the + // total): without the split there's no way to tell whether prompt caching + // is engaging, and output-per-message is what reveals runaway thinking. + usage: { messages: 0, inputTokens: 0, cachedInputTokens: 0, outputTokens: 0, cost: 0, costKnown: true }, }; const history = []; // {id, author, role: 'bot'|'streamer', text, ts} const MAX_HISTORY = 40; @@ -289,6 +292,8 @@ export function startServer(opts = {}) { 'speech.corrections', 'speech.dropHallucinations', 'cadence.soloSeconds', 'cadence.quietSeconds', 'cadence.jitter', 'cadence.burstChance', 'cadence.lullChance', 'cadence.replyDelaySeconds', 'cadence.minVoiceReplyGapSeconds', + 'cadence.voiceReplyRequiresAddress', 'cadence.voiceReplyDelaySeconds', + 'cadence.voiceReplyWindowSeconds', 'cadence.voiceAnswerWindowSeconds', 'cadence.minScreenshotGapSeconds', 'cadence.minPartyNudgeGapSeconds', ]; const isHotPath = (p) => HOT_PATHS.some((h) => (h.endsWith('.') ? p.startsWith(h) : p === h)); @@ -783,7 +788,7 @@ export function startServer(opts = {}) { broadcastState(); // 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(); + if (!check) loop.onSpeech(line); }, }); @@ -839,6 +844,7 @@ export function startServer(opts = {}) { obs, transcriptFeed, partyFeed, + castNames: personas.map((p) => p.name), hooks: { getMode: resolveMode, getStreamInfo: () => state.streamInfo, @@ -881,6 +887,7 @@ export function startServer(opts = {}) { state.usage.messages++; state.usage.inputTokens += (usage.input_tokens || 0) + (usage.cache_read_input_tokens || 0) + (usage.cache_creation_input_tokens || 0); + state.usage.cachedInputTokens += usage.cache_read_input_tokens || 0; state.usage.outputTokens += usage.output_tokens || 0; if (cost === null) state.usage.costKnown = false; else state.usage.cost += cost; diff --git a/src/speech.js b/src/speech.js index 918b74c..3bcdc3a 100644 --- a/src/speech.js +++ b/src/speech.js @@ -178,6 +178,27 @@ export function compileCorrections(corrections) { return compiled; } +/** + * Does this line name one of these, as a whole word? Used to tell speech that + * is actually aimed at the cast from speech that merely happened near it. + * Whole-word so "Wisp" doesn't fire on "wispy". + */ +export function mentionsAny(text, names = []) { + const line = String(text ?? ''); + if (!line.trim()) return false; + for (const name of Array.isArray(names) ? names : []) { + const trimmed = String(name ?? '').trim(); + if (!trimmed || trimmed.length > MAX_FROM_LEN) continue; + const body = trimmed.split(/\s+/).map(escapeRe).join('\\s+'); + try { + if (new RegExp(`${WORD_START}${body}${WORD_END}`, 'iu').test(line)) return true; + } catch { + // An unrepresentable name just never matches, rather than throwing. + } + } + return false; +} + /** Apply compiled corrections; returns '' if the line was fully deleted. */ export function applyCorrections(text, compiled) { let out = String(text ?? ''); diff --git a/test/loop.test.js b/test/loop.test.js index eea8d41..10ad554 100644 --- a/test/loop.test.js +++ b/test/loop.test.js @@ -55,3 +55,101 @@ test('with a brain, start() schedules the first message normally', () => { assert.ok(loop.nextMessageAt > Date.now()); loop.pause(); // clears the pending timer so the test runner can exit }); + +// ---- voice replies only when the cast was actually addressed ---- +// +// A streamer talks almost continuously — narrating, thinking out loud, talking +// to a co-op partner. Treating all of it as "they answered me" fires an extra +// generation after nearly every cast message, roughly doubling API spend on a +// chatty stream. Nothing is lost by skipping: undirected speech still reaches +// the cast through the transcript window on the next scheduled generation. + +function makeVoiceLoop({ cadence = {}, lastMessage } = {}) { + const spoken = []; + const loop = new GhostLoop({ + config: { energy: 55, cadence, app: {}, memory: { enabled: false } }, + brain: { ready: () => true }, + obs: null, + transcriptFeed: null, + partyFeed: null, + castNames: ['Beacon', 'Wisp'], + hooks: { + getMode: () => 'solo', + getHistory: () => (lastMessage ? [lastMessage] : []), + onMessage: () => {}, + onSystem: () => {}, + onState: () => {}, + getNotes: () => '', + setNotes: () => {}, + addUsage: () => {}, + }, + }); + loop.speak = (trigger) => spoken.push(trigger); + return { loop, spoken }; +} + +const botMsg = (text, agoMs = 1000) => ({ id: 'm1', role: 'bot', author: 'Beacon', text, ts: Date.now() - agoMs }); + +test('a ghost named out loud earns a voice reply', (t) => { + const { loop } = makeVoiceLoop({ lastMessage: botMsg('what is that thing?') }); + loop.onSpeech('beacon i have literally no idea'); + assert.ok(loop.voiceReplyTimer, 'reply should be scheduled'); + clearTimeout(loop.voiceReplyTimer); +}); + +test('undirected narration does not', (t) => { + const { loop } = makeVoiceLoop({ lastMessage: botMsg('nice, that looked rough.') }); + loop.onSpeech('okay so if i go left here i can probably grab the chest first'); + assert.equal(loop.voiceReplyTimer, null, 'no generation should be scheduled'); +}); + +test('answering a question promptly counts as addressed; answering late does not', (t) => { + const fresh = makeVoiceLoop({ lastMessage: botMsg('wait, is that the boss?', 5_000) }); + fresh.loop.onSpeech('yeah thats him'); + assert.ok(fresh.loop.voiceReplyTimer, 'a prompt answer is a reply'); + clearTimeout(fresh.loop.voiceReplyTimer); + + // The "curious" archetype ends a lot of messages with "?", so the window is + // deliberately short — otherwise this rule matches nearly everything. + const stale = makeVoiceLoop({ lastMessage: botMsg('wait, is that the boss?', 90_000) }); + stale.loop.onSpeech('yeah thats him'); + assert.equal(stale.loop.voiceReplyTimer, null, 'too late to be an answer'); +}); + +test('a statement the cast made is not a question', (t) => { + const { loop } = makeVoiceLoop({ lastMessage: botMsg('that was clean.') }); + loop.onSpeech('thanks, took me ages'); + assert.equal(loop.voiceReplyTimer, null); +}); + +test('name matching is whole-word and case-insensitive', (t) => { + const hit = makeVoiceLoop({ lastMessage: botMsg('hi') }); + hit.loop.onSpeech('WISP what do you think'); + assert.ok(hit.loop.voiceReplyTimer); + clearTimeout(hit.loop.voiceReplyTimer); + + const miss = makeVoiceLoop({ lastMessage: botMsg('hi') }); + miss.loop.onSpeech('the whole cave is wispy and weird'); + assert.equal(miss.loop.voiceReplyTimer, null, '"wispy" must not match "Wisp"'); +}); + +test('the old always-reply behaviour is still reachable', (t) => { + const { loop } = makeVoiceLoop({ + cadence: { voiceReplyRequiresAddress: false }, + lastMessage: botMsg('nice, that looked rough.'), + }); + loop.onSpeech('okay so if i go left here'); + assert.ok(loop.voiceReplyTimer, 'opt-out restores the previous behaviour'); + clearTimeout(loop.voiceReplyTimer); +}); + +test('being addressed cannot bypass the rate floor or the staleness window', (t) => { + const floored = makeVoiceLoop({ cadence: { minVoiceReplyGapSeconds: 600 }, lastMessage: botMsg('hi') }); + floored.loop.lastVoiceReplyAt = Date.now(); + floored.loop.onSpeech('beacon are you there'); + assert.equal(floored.loop.voiceReplyTimer, null, 'rate floor still wins'); + + const stale = makeVoiceLoop({ lastMessage: botMsg('hi', 10 * 60_000) }); + stale.loop.onSpeech('beacon are you there'); + assert.equal(stale.loop.voiceReplyTimer, null, 'stale message still wins'); +});