Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .github/workflows/auto-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
150 changes: 150 additions & 0 deletions .github/workflows/beta-release.yml
Original file line number Diff line number Diff line change
@@ -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).
6 changes: 6 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<topic>` or `feature/<topic>`. 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:
Expand Down
18 changes: 18 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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). |
Expand Down Expand Up @@ -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). |
Expand All @@ -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
Expand Down
5 changes: 5 additions & 0 deletions config.example.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
},
"app": {
"autoUpdate": true,
"updateChannel": "auto",
"costMeter": true,
"autoPause": true,
"autoPauseMinutes": 10,
Expand Down Expand Up @@ -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,
Expand Down
21 changes: 21 additions & 0 deletions electron/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down
9 changes: 8 additions & 1 deletion public/dashboard.html
Original file line number Diff line number Diff line change
Expand Up @@ -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 ----------
Expand Down
Loading