From cfc8bb7dc6c754291f6e4d62abc80dbd89bb00dc Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 26 Jul 2026 23:29:16 +0000 Subject: [PATCH 1/3] Only reply out loud when the cast is actually addressed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A 2.5h stream on v3.7.0 cost $4 on Opus — about 5x the README's estimate. The per-message price was not the story; the message *count* was. onSpeech treated any speech within 120s of a cast message as an answer to it, but a streamer talks almost continuously — narrating a fight, thinking out loud, talking to a co-op partner. That fired an extra generation after nearly every cast message, roughly doubling spend, and made the cast read as interrupting rather than listening. A voice reply now needs the speech to be addressed: a ghost named out loud, or a prompt answer to a question one just asked. 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 no longer buys its own API call. The question rule is on a deliberately short fuse (voiceAnswerWindowSeconds, 30s): the "curious" archetype ends a lot of messages with "?", so a generous window would match nearly everything and undo the point. Name matching is whole-word, so "wispy" doesn't summon Wisp — and the correction map already works to keep ghost names intact through transcription, which is what makes this signal reliable. cadence.voiceReplyRequiresAddress (default true) restores the old behaviour when false. voiceReplyDelaySeconds and voiceReplyWindowSeconds were hardcoded; both are now config. All hot-appliable. Also surfaces the two numbers that would have diagnosed this in minutes: hover the cost pill for the share of input served from cache and the average output tokens per message (a one-line chat message is ~60; far above that is thinking doing the spending). cache_read_input_tokens was already collected and merged into a single total, which hid it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SK871Ay2eiEzxTPorRufLP --- README.md | 11 +++++ config.example.json | 4 ++ public/dashboard.html | 9 +++- public/settings.html | 5 +++ src/loop.js | 53 +++++++++++++++++++---- src/server.js | 11 ++++- src/speech.js | 21 ++++++++++ test/loop.test.js | 98 +++++++++++++++++++++++++++++++++++++++++++ 8 files changed, 200 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index ee72b01..d4a9178 100644 --- a/README.md +++ b/README.md @@ -171,6 +171,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 +187,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..b0c4793 100644 --- a/config.example.json +++ b/config.example.json @@ -140,6 +140,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/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..209f183 100644 --- a/public/settings.html +++ b/public/settings.html @@ -244,6 +244,10 @@

🎙️ Voice awareness

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

💬 Cadence & energy

+
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'); +}); From ce6eb537206b4328c38e83fce260f83eb5128d68 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 26 Jul 2026 23:43:27 +0000 Subject: [PATCH 2/3] Add a beta release line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Test builds can now be produced from any branch without merging and without anyone compiling: Actions -> Beta Release -> pick a branch. It tags vX.Y.Z-beta.N off the newest stable tag, builds the Windows installer, and publishes a GitHub prerelease. Isolation from the stable line is structural rather than procedural: the -beta.N suffix makes electron-builder write the update feed as beta.yml, and a stable install only ever polls latest.yml, so it cannot be offered a test build. Two pre-existing paths had to be fixed first, or the first beta tag would have broken stable releases: - auto-release.yml picked the highest v* tag to bump from. A beta tag sorts ABOVE the release it precedes, and splitting 3.8.0-beta.1 on "." puts "0-beta.1" into $patch — verified to fail with "invalid arithmetic operator", which under Actions' bash -e fails the job. Every stable release would have been blocked until someone deleted the tag. Now filtered to ^v[0-9]+\.[0-9]+\.[0-9]+$. - release.yml triggers on tags v*, which matches beta tags too. Publishing one there would produce a non-prerelease carrying latest.yml — pushing a test build to every stable install. Its job is now guarded against prerelease tags. Beta numbering sorts numerically, so beta.10 follows beta.9 rather than beta.1. Verified against a scratch repo seeded with the real tag history. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SK871Ay2eiEzxTPorRufLP --- .github/workflows/auto-release.yml | 6 +- .github/workflows/beta-release.yml | 146 +++++++++++++++++++++++++++++ .github/workflows/release.yml | 6 ++ CLAUDE.md | 2 + CONTRIBUTING.md | 15 +++ 5 files changed, 174 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/beta-release.yml 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..fa2a67b --- /dev/null +++ b/.github/workflows/beta-release.yml @@ -0,0 +1,146 @@ +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. When v3.8.0 ships for +# real it supersedes every one of its betas by semver, so a tester is carried +# onto the stable build rather than stranded on a beta. +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. To go back to stable, 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..a9b359b 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. 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..a92cf78 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -20,6 +20,21 @@ 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`. When the real `vX.Y.Z` ships it supersedes its +own betas by semver, carrying testers onto the stable build rather than stranding them. + +> 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 From 83fccea6e2ce547913b8cb27ed21ba9ea506f4bf Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 26 Jul 2026 23:49:17 +0000 Subject: [PATCH 3/3] Let the app choose which builds the updater follows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Correcting something I got wrong when adding the beta line: I described a stable release as superseding its own betas by semver, carrying testers home. It does not. electron-updater follows a channel *file*, not a version comparison — 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. Installing a beta was therefore a one-way door. app.updateChannel (Settings -> App, advanced): - auto (default) — follow whatever build is running. This is exactly the previous behaviour: stable installs read latest.yml, beta builds read beta.yml. Nothing changes for anyone who never touches a beta. - stable — the way back. Forces latest.yml, and is the only case that sets allowDowngrade, because returning from 3.8.0-beta.5 to 3.7.0 is a version decrease and the escape hatch would otherwise silently do nothing. - beta — opt in without hunting for an installer. allowDowngrade is deliberately scoped to "explicitly chose stable while running a prerelease" so a normal install can never be walked backwards. An unrecognised or missing value falls back to auto rather than erroring. Verified the decision matrix across every setting x build combination: allowDowngrade is true in exactly one of them. Not verifiable here — this needs a packaged Windows build to exercise electron-updater for real, which is precisely what the beta line now makes possible. The first beta is the test of the beta machinery. Release-Bump: minor Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SK871Ay2eiEzxTPorRufLP --- .github/workflows/beta-release.yml | 12 ++++++++---- CLAUDE.md | 2 +- CONTRIBUTING.md | 7 +++++-- README.md | 1 + config.example.json | 1 + electron/main.js | 21 +++++++++++++++++++++ public/settings.html | 9 +++++++++ 7 files changed, 46 insertions(+), 7 deletions(-) diff --git a/.github/workflows/beta-release.yml b/.github/workflows/beta-release.yml index fa2a67b..cfe22e3 100644 --- a/.github/workflows/beta-release.yml +++ b/.github/workflows/beta-release.yml @@ -13,9 +13,13 @@ name: Beta Release # 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. When v3.8.0 ships for -# real it supersedes every one of its betas by semver, so a tester is carried -# onto the stable build rather than stranded on a beta. +# 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: @@ -143,4 +147,4 @@ jobs: - 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. To go back to stable, install the latest normal release from the [releases page](https://github.com/${{ github.repository }}/releases/latest) over the top. + - 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/CLAUDE.md b/CLAUDE.md index a9b359b..acb271b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -33,7 +33,7 @@ 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. 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. +**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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a92cf78..f05da00 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -25,8 +25,11 @@ workflow, pick a branch. It tags `vX.Y.Z-beta.N` (climbing from the newest *stab 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`. When the real `vX.Y.Z` ships it supersedes its -own betas by semver, carrying testers onto the stable build rather than stranding them. +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 diff --git a/README.md b/README.md index d4a9178..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). | diff --git a/config.example.json b/config.example.json index b0c4793..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, 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/settings.html b/public/settings.html index 209f183..3458bf6 100644 --- a/public/settings.html +++ b/public/settings.html @@ -382,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.

+