diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..4d89508 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,25 @@ +# Changelog + +Version `0.2.0` in `pyproject.toml` matches the `v0.2.0` tag. The owner pushes that tag. + +## 0.2.0 + +PLAN-v3. + +- #8 PLAN-v3 Phase 1: pin the web stack and fix the first-wave bugs +- #9 PLAN-v3 Phase 2: harden local state and split the orchestrator +- #10 PLAN-v3 Phase 3: stream-day hype, recaps, clips, and questions +- #11 V3 Phase 4: booth quality-of-life +- Docs for every config key, `requirements-extras.txt`, and this changelog + +## 0.1.0 + +PLAN-v2. + +- #1 Phase 0: hermetic tests, pinned deps, and CI +- #2 Phase 1: shoutouts, EventSub scopes, Piper pipes, output guard +- #3 Phase 2: booth lock, memory retention, and safe downloads +- #4 Cursor/phase 1 fixes 0e13 +- #5 Phase 3: keep the mic, chat, and model paths from stalling +- #6 Phase 4: rewards, recap, queue controls, and profiles +- #7 Phase 5: tests, dashboard sync, docs, and backup diff --git a/PLAN-v3.md b/PLAN-v3.md index 74c67a6..c05455a 100644 --- a/PLAN-v3.md +++ b/PLAN-v3.md @@ -286,19 +286,19 @@ Do these on a test stream or with the booth open before relying on the features. ### Phase 5: Docs and packaging -- [ ] **V3-P5-01 🟡 README refresh** +- [x] **V3-P5-01 🟡 README refresh** - *Do:* document duck app lists (V3-P1-07), wake-word tuning, the action scopes list, IPv6 note, recap/session behaviour, custom commands, the control key, and the owner checklist. Fix "endpoint ducking" and "`!so` shouts out the last raider" (it also takes a name). - *Files:* `README.md`, `config.yaml` comments - *Acceptance:* every config key in `settings.py` is mentioned in README or `config.yaml` comments (add a test that greps for them). - *Depends on:* the features you chose -- [ ] **V3-P5-02 🟡 Optional extras file** +- [x] **V3-P5-02 🟡 Optional extras file** - *Do:* add `requirements-extras.txt` with pinned `pycaw` and `openwakeword`; `start.bat --extras` installs it. Health shows "installed / not installed" for each extra. - *Files:* `requirements-extras.txt`, `start.bat`, `src/health.py`, `README.md`, `tests/test_start_bat.py` - *Acceptance:* `start.bat --extras` installs both; without the flag nothing extra is installed. - *Depends on:* none -- [ ] **V3-P5-03 🟡 Release tag and changelog** +- [x] **V3-P5-03 🟡 Release tag and changelog** - *Do:* add `CHANGELOG.md` (v0.1 = PLAN-v2 work, v0.2 = this plan), bump `pyproject.toml` version, tag `v0.2.0` after Phase 1–2 merge (owner pushes the tag). - *Files:* `CHANGELOG.md`, `pyproject.toml` - *Acceptance:* changelog lists each merged PR; version matches the tag. diff --git a/README.md b/README.md index a6ecb20..8f4a4a2 100644 --- a/README.md +++ b/README.md @@ -100,7 +100,10 @@ Tick these on [Twitch Token Generator](https://twitchtokengenerator.com/) (Custo | Polls | `channel:read:polls` or `channel:manage:polls` | event token | | Predictions | `channel:read:predictions` or `channel:manage:predictions` | event token | | `!so` / shoutout button | `moderator:manage:shoutouts` | event token | +| Shoutout received and sent | `moderator:read:shoutouts` | event token | +| Ad breaks | `channel:read:ads` | event token | | Remember this (stream marker) | `channel:manage:broadcast` | event token | +| Clip button | `clips:edit` | event token | Copy the page’s Client ID into `TWITCH_CLIENT_ID`. It has to be the app that issued the event token. @@ -122,7 +125,7 @@ These optional feeds are **off** in the shipped `config.yaml`: bits, channel poi Pixel's spoken audio can go to a virtual cable (VB-Audio, VoiceMeeter). The device list labels those outputs; pick one in the booth. The default output is not changed until you pick it. -Music ducking is off. Preferred setup: music on its own OBS track, sidechained to the cohost cable. `audio.duck_enabled: true` is per-app session ducking: on Windows, pycaw lowers other apps' session volume while Pixel talks. `speaker_device` may stay null. Pixel's own process is never ducked. `audio.duck_skip` (default `obs64.exe` and `Discord.exe`) is also left alone. An empty `audio.duck_apps` list ducks every other app; a non-empty list ducks only those process names. The pre-duck levels are stored in `data/duck.json` and restored on the next launch, and again if the process exits while ducked. Without pycaw the booth stays on the OBS sidechain and does not change the volume. +Music ducking is off. Preferred setup: music on its own OBS track, sidechained to the cohost cable. `audio.duck_enabled: true` is per-app session ducking: on Windows, pycaw lowers other apps' session volume while Pixel talks. This is other apps' session volume, not Windows endpoint (device) volume. `speaker_device` may stay null. Pixel's own process is never ducked. `audio.duck_skip` (default `obs64.exe` and `Discord.exe`) is also left alone. An empty `audio.duck_apps` list ducks every other app; a non-empty list ducks only those process names. The pre-duck levels are stored in `data/duck.json` and restored on the next launch, and again if the process exits while ducked. Without pycaw the booth stays on the OBS sidechain and does not change the volume. Health reports `extras.pycaw` as `installed` or `not installed`. Edit `persona.yaml` anytime, then click **Reload persona**. No restart needed. @@ -181,11 +184,17 @@ GitHub Actions runs that lint and the test suite on `windows-latest` with Python Logs go to `logs/cohost.log` (rotating, about 2 MB, three backups). That directory is gitignored. -Optional extras are not installed by either file. Add them yourself when you turn the feature on: +`requirements-extras.txt` pins `pycaw` and `openwakeword`. `start.bat` does not install it unless you pass `--extras`. Without that flag, nothing from the extras file is installed. `--extras` and `--no-browser` can be used together, in either order. -- `pycaw` — per-app session ducking when `audio.duck_enabled: true` +```bat +start.bat --extras +``` + +- `pycaw` — per-app session ducking when `audio.duck_enabled: true` (Windows) - `openwakeword` — local wake word when `audio.wake_word: true` +`/api/health` reports each extra as `installed` or `not installed`. + To bump a pin: upgrade that package in the project venv (`python -m pip install --upgrade some-package`), run `python -m pip freeze`, and copy the new `name==version` into `requirements.txt` if the app imports it, or into `requirements-dev.txt` if it is only `pytest` or `ruff`. Leave `pytest` and `ruff` out of `requirements.txt`. Run the tests before you commit. Do not commit `.env`. ## Config @@ -199,14 +208,14 @@ To bump a pin: upgrade that package in the project venv (`python -m pip install ## How hybrid chat works - Your mic always wins: a new streamer utterance interrupts pending chat speech. -- Link-only lines and the bot's own messages are ignored. The only commands that get through are `!so`, `!pixel`, and `!ask`. +- Link-only lines and the bot's own messages are ignored. IRC keeps `chat.commands`, the control command, and every `chat.custom_commands` name. Other `!` lines are dropped. - Mentions, greetings, and questions are spoken. Other chat is typed only. - Replies to a specific viewer are posted as `@username …`. TTS speaks the name without the @, and strips links and emotes. - Conversation memory is per person: the streamer (mic + their Twitch login) vs each chatter. Set **Display name** and **Twitch login** on the dashboard. Stored in `data/streamer.json`. **Clear memory** resets threads. - Follows, new subs, resubs, gifted subs, and incoming raids can be announced (EventSub). Follows are batched so a follow train is one line. Gift bombs are one thank-you, not twenty. Alerts wait if you are talking on mic. - Other chat is sampled on an interval with a global cooldown plus a per-user cooldown. - Twitch IRC auto-reconnects if the socket drops. EventSub reconnects separately; chat still works if EventSub is missing scopes. -- Mentions, greetings, questions, and `!pixel` / `!ask` are spoken. Ambient chat is typed only. `!so` shouts out the last raider. Other `!` commands stay ignored. Chat vs speak is decided in code, not by asking the model for JSON. +- Mentions, greetings, questions, and `!pixel` / `!ask` are spoken. Ambient chat is typed only. `!so some_login` shouts that channel out. `!so` with no name shouts out the last raider. A `chat.custom_commands` entry posts its fixed text and does not call the model. Chat vs speak is decided in code, not by asking the model for JSON. Voice replies can be echoed to chat (`chat.post_voice_replies_to_chat`). Edit `persona.yaml` for Pixel's voice; **Reload persona** picks it up without a restart. @@ -237,7 +246,32 @@ Wake word needs `openwakeword` and never replaces hold-to-talk. Channel-point re ## Profiles -`config.yaml` has two booth presets. **Just chatting** is hype, a short cooldown, events spoken, voice replies posted, 12 replies a minute. **Focus game** is chill, a long cooldown, events typed, voice replies not posted, 3 replies a minute. The dashboard dropdown calls `POST /api/profile` and stores the name in `data/booth.json`. +`config.yaml` has two booth presets. **Just chatting** is hype, a short cooldown, events spoken, voice replies posted, 12 replies a minute. **Focus game** is chill, a long cooldown, events typed, voice replies not posted, 3 replies a minute. A profile may also set `tts_voice`, `length_scale`, and `persona_file`. The dashboard dropdown calls `POST /api/profile` and stores the name in `data/booth.json`. Switching voice reuses the usual download progress line. An unknown `tts_voice` is rejected when the config loads. + +## Wake word + +`audio.wake_word` stays false until you install the extras (`start.bat --extras`) and set it true. `audio.wake_models` lists openwakeword names; the shipped model is `hey_jarvis`. `audio.wake_threshold` is a score from 0 to 1 (shipped `0.5`). Raise it if normal speech false-triggers, and lower it if a clear "hey jarvis" is missed. Wake arms one utterance. Hold-to-talk and **Start listening** still work. The booth broadcasts the wake score when the model is loaded, including while listen is off. Detections are also written to `logs/cohost.log`. + +## Custom commands + +`chat.custom_commands` maps a command to fixed text, for example `"!lurk": "enjoy the lurk {user}"`. `{user}` is the chatter. Pixel posts that text, waits `chat.custom_command_cooldown_seconds` before the same command can fire again, and does not call the model or shout anyone out. The command is added to the IRC allowlist. The shipped map is empty. + +## Sessions and recaps + +Going live (`stream.online`) rotates the memory session id. Going offline writes `data/recaps/YYYY-MM-DD-HHMM.md` for the session that just ended. **Build recap** writes `data/recaps/YYYY-MM-DD.md` from the current session. **Add vibe** asks the model for one paragraph, bounded by `ollama.chat_timeout_seconds`. Every remembered chat, transcript, and alert line is also appended to `data/sessions/.jsonl`. **Download transcript** saves that file. **Forget** removes that viewer from memory and from the session logs. `memory.retention_days` drops old memory and old session files. + +## Owner checklist + +These still need your machine, your Twitch login, and a live or test stream. They are not covered by the automated suite. + +1. Turn on GitHub secret scanning and push protection. `git log --all -- .env` should be empty. +2. Connect alerts and confirm each feed you use says `ok`. Chat needs `chat:read` and `chat:edit`. Alerts need the scopes in the table above. +3. With speakers, talk over Pixel. She should stop. She should not stop from speaker bleed alone. Adjust `audio.echo_gate_ratio` if she does. +4. With the wake extras installed, say "hey jarvis" and then talk normally. Count hits and false triggers in the wake score and `logs/cohost.log`. +5. Install `pycaw`, play music in another app, and enable **Duck output** with a speaker device only if you want one. Music should dip and return. Kill the booth mid-sentence and relaunch: volume comes back from `data/duck.json`. +6. Add the overlay in OBS (`?hide=idle&position=bottom`, plus `?alerts=0`, `?theme=`, and `?fontsize=`). Check captions, the nameplate, and an alert flash. +7. Turn on channel points, polls, and predictions one at a time. `hydrate` should speak. An unmapped reward should skip as `reward ignored`. A poll and a prediction should get one line at the start and one at the end. A first-time chatter should get a typed welcome. +8. Shout out the last raider, and press **Remember this** while live. A missing scope should show on the button. ## Security @@ -264,9 +298,110 @@ On a Stream Deck Website button, set the URL to `http://127.0.0.1:8080/api/inter powershell -File scripts\backup.ps1 ``` -`start.bat` prefers `py -3.11`, then `python`, and requires 3.11 or newer. It reads `server.host` and `server.port`, skips pip when `requirements.txt` has not changed, and opens the booth unless you pass `--no-browser`. If creating the venv, installing dependencies, or reading the config fails, the window stays open and the next launch tries the install again. The install stamp is written only after pip succeeds. Binding anything other than localhost requires `server.allow_remote: true`. +`start.bat` prefers `py -3.11`, then `python`, and requires 3.11 or newer. It reads `server.host` and `server.port`, skips pip when `requirements.txt` has not changed, and opens the booth unless you pass `--no-browser`. Pass `--extras` to also install `requirements-extras.txt`. If creating the venv, installing dependencies, or reading the config fails, the window stays open and the next launch tries the install again. The install stamp is written only after pip succeeds. Binding anything other than localhost requires `server.allow_remote: true`. -The booth only accepts `Host` values of `127.0.0.1`, `localhost`, `::1`, and `[::1]`, including the configured port. A page on another site cannot POST into it: writes need `Content-Type: application/json` and the `X-Cohost-Token` from `GET /api/session`, and a foreign `Origin` is rejected. The OBS overlay stays read-only. Each person in the memory panel has **Forget**, which deletes that viewer from `data/memory.json`. Viewer lines are kept for `memory.retention_days` (14). Piper and the three listed voices are checksummed; a partial download stays as a `.part` file until it finishes. +The booth only accepts `Host` values of `127.0.0.1`, `localhost`, `::1`, and `[::1]`, including the configured port. Starlette 1.6 split `Host` on the first colon, so `[::1]:8080` was rejected; the allow list keeps both the bare IPv6 names and the same names with the port. A page on another site cannot POST into it: writes need `Content-Type: application/json` and the `X-Cohost-Token` from `GET /api/session`, and a foreign `Origin` is rejected. The OBS overlay stays read-only. Each person in the memory panel has **Forget**, which deletes that viewer from `data/memory.json`. Viewer lines are kept for `memory.retention_days` (14). Piper and the three listed voices are checksummed; a partial download stays as a `.part` file until it finishes. + +## Config reference + +Every key `settings.py` reads. Shipped values live in `config.yaml`. + +| Key | What it does | +|---|---| +| `server.host` | Bind address. Stay on localhost. | +| `server.port` | Booth port. | +| `server.allow_remote` | Must be true before a non-loopback bind is allowed. | +| `ollama.host` | Local Ollama URL. | +| `ollama.model` | Chat and voice model. | +| `ollama.timeout_seconds` | Overall Ollama request timeout. | +| `ollama.num_ctx` | Context length sent to Ollama. | +| `ollama.num_predict_chat` | Max tokens for a chat reply. | +| `ollama.num_predict_voice` | Max tokens for a spoken reply. | +| `ollama.keep_alive` | How long Ollama keeps the model loaded. | +| `ollama.chat_timeout_seconds` | Bound for chat replies, alert replies, and the recap vibe. | +| `whisper.model_size` | faster-whisper size. | +| `whisper.device` | `auto`, `cuda`, or `cpu`. | +| `whisper.language` | Transcription language. | +| `whisper.compute_type_cuda` | Compute type when Whisper is on CUDA. | +| `whisper.compute_type_cpu` | Compute type when Whisper is on CPU. | +| `vram.profile` | `snappy` or `quality`. | +| `vram.warn_mb` | GPU memory used before the booth warns. | +| `vram.quality_model` | Model used when quality mode replaces the 3B default. | +| `tts.enabled` | Piper on or off. | +| `tts.mute` | Start muted. | +| `tts.voice` | Piper voice id. | +| `tts.length_scale` | Piper speaking rate. | +| `tts.piper_dir` | Where Piper and the voices are stored. | +| `tts.max_sentence_chars` | Longest sentence sent to Piper at once. | +| `tts.voices` | Voices the booth will download and offer. | +| `audio.sample_rate` | Mic sample rate. | +| `audio.mic_device` | Input index, or null for the default. | +| `audio.speaker_device` | Output index, or null for the default. | +| `audio.mic_device_name` | Input name, re-resolved if the index moves. | +| `audio.speaker_device_name` | Output name, re-resolved if the index moves. | +| `audio.silence_ms` | Trailing silence that ends an utterance. | +| `audio.min_utterance_ms` | Shortest utterance that is kept. | +| `audio.energy_threshold` | RMS level that counts as speech. | +| `audio.max_utterance_seconds` | Longest utterance before it is cut. | +| `audio.duck_enabled` | Per-app session ducking. Off unless you opt in. | +| `audio.duck_gain` | Volume multiplier while ducked. | +| `audio.duck_apps` | Allow-list of process names. Empty ducks every other app. | +| `audio.duck_skip` | Process names never ducked. | +| `audio.barge_in` | Talking over Pixel stops her. | +| `audio.echo_gate` | Ignore speaker bleed unless you are louder or holding PTT. | +| `audio.echo_gate_ratio` | How much louder than the bleed counts as you. | +| `audio.wake_word` | Local wake word. Off by default. | +| `audio.wake_models` | openwakeword model names. | +| `audio.wake_threshold` | Score from 0 to 1 that counts as a wake. | +| `chat.buffer_size` | Recent chat lines kept for context. | +| `chat.cooldown_seconds` | Global pause between chat replies. | +| `chat.max_replies_per_minute` | Cap on chat replies. | +| `chat.scan_interval_seconds` | How often pending chat is reconsidered. | +| `chat.mention_boost` | Mentions use a shorter cooldown. | +| `chat.post_voice_replies_to_chat` | Echo spoken replies into chat. | +| `chat.chat_max_chars` | Longest chat post. | +| `chat.per_user_cooldown_seconds` | Pause before answering the same viewer again. | +| `chat.auto_reconnect` | Reconnect IRC after a drop. | +| `chat.min_send_gap_seconds` | Minimum gap between outgoing chat lines. | +| `chat.commands` | IRC commands that are not dropped. | +| `chat.mod_commands` | Commands only mods and the broadcaster may run. | +| `chat.custom_commands` | Command to fixed text. No model. | +| `chat.custom_command_cooldown_seconds` | Per-command pause for those fixed replies. | +| `chat.shoutout_cooldown_seconds` | Local pause between shoutouts. | +| `chat.shoutout_line` | Chat text for `!so`. `{user}` is the login. | +| `chat.shoutout_speak` | Also speak the shoutout line. | +| `chat.control_command` | Extra booth command. Empty uses the persona's first name. `!pixel` stays. | +| `chat.max_age_seconds` | Drop stale viewer lines. Streamer lines and commands stay. | +| `chat.welcome_first_time` | Type a welcome for a first-time chatter. | +| `chat.control_cooldown_seconds` | Pause between mute, mood, and stop commands. | +| `chat.ask_queue` | Hold `!ask` in the Questions list. | +| `chat.clip_cooldown_seconds` | Pause between clip commands. | +| `chat.ambient_context_lines` | Recent lines shown to the model for ambient chat. | +| `safety.allow_links` | Keep links in Pixel's chat posts. | +| `memory.chat_context_lines` | Chat lines recalled into a prompt. | +| `memory.events` | Event rows kept in memory. | +| `memory.viewers` | Viewer rows kept in memory. | +| `memory.thread_turns` | Turns kept per thread. | +| `memory.catchphrase_seconds` | How long before a catchphrase may repeat. | +| `memory.retention_days` | How long viewer lines and session files are kept. | +| `events.enabled` | EventSub alerts on or off. | +| `events.announce_follows` | Speak or type follows. | +| `events.follow_batch_seconds` | Window that collapses a follow train into one line. | +| `events.speak` | Speak alerts. False types them. | +| `events.post_to_chat` | Also post alerts in chat. | +| `events.bits` | Cheer feed. Off until you opt in. | +| `events.channel_points` | Redemption feed. Off until you opt in. | +| `events.hype_train` | Hype train feed. Off until you opt in. | +| `events.polls` | Poll feed. Off until you opt in. | +| `events.predictions` | Prediction feed. Off until you opt in. | +| `events.ad_breaks` | Ad break feed. Off until you opt in. | +| `events.shoutouts` | Shoutout and raid-out lines. Off until you opt in. | +| `events.idle_on_offline` | Stop listening when the stream goes offline. | +| `events.channel_point_rewards` | Reward title to `speak`, `chat`, or `ignore`. | +| `profiles` | Named booth presets. | +| `overlay.alert_hold_seconds` | How long an overlay alert stays up. | +| `logging.level` | Root log level. | +| `logging.debug_modules` | Logger names that stay at DEBUG. | ## Out of scope diff --git a/config.yaml b/config.yaml index 3bcd8f1..0360981 100644 --- a/config.yaml +++ b/config.yaml @@ -1,4 +1,5 @@ server: + # server.host stays on localhost. IPv6 loopback is ::1; the booth also accepts Host: [::1]:port. host: 127.0.0.1 port: 8080 # The booth has no auth. Stay on localhost unless you set this true on purpose. @@ -67,7 +68,8 @@ audio: barge_in: true echo_gate: true echo_gate_ratio: 2.5 - # Local wake word via openwakeword. Off by default, and never the only way to talk. + # audio.wake_word uses openwakeword and never replaces hold-to-talk. + # audio.wake_models names the models. audio.wake_threshold is 0 to 1; raise it when a phrase false-triggers. wake_word: false wake_models: - hey_jarvis diff --git a/pyproject.toml b/pyproject.toml index 208f5f2..885b786 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "local-twitch-cohost" -version = "0.1.0" +version = "0.2.0" description = "Local Twitch cohost" readme = "README.md" requires-python = ">=3.11" diff --git a/requirements-extras.txt b/requirements-extras.txt new file mode 100644 index 0000000..6df0686 --- /dev/null +++ b/requirements-extras.txt @@ -0,0 +1,4 @@ +# Optional extras. start.bat installs this only when you pass --extras. +# pycaw is Windows-only session ducking. openwakeword is the local wake word. +pycaw==20260927 +openwakeword==0.6.0 diff --git a/src/health.py b/src/health.py index be95887..6ff38b7 100644 --- a/src/health.py +++ b/src/health.py @@ -1,6 +1,7 @@ from __future__ import annotations import asyncio +import importlib.util import subprocess import sys import time @@ -119,6 +120,7 @@ async def snapshot(settings: Settings, orch) -> dict[str, Any]: "snappy": "Whisper CUDA + qwen2.5:3b", "quality": "Whisper CPU + qwen2.5:7b-instruct-q4_K_M", }, + "extras": extra_status(), "timings": getattr(orch, "timings", {}), "timings_p50": _timing_percentile(orch, 50), "timings_p95": _timing_percentile(orch, 95), @@ -154,6 +156,14 @@ async def snapshot(settings: Settings, orch) -> dict[str, Any]: } +def extra_status() -> dict[str, str]: + """Whether the optional Windows extras are importable.""" + status: dict[str, str] = {} + for name in ("pycaw", "openwakeword"): + status[name] = "installed" if importlib.util.find_spec(name) is not None else "not installed" + return status + + def _percentile(values: list[int], pct: float) -> int: ordered = sorted(int(item) for item in values) if len(ordered) == 1: diff --git a/start.bat b/start.bat index 1e42f4b..035586c 100644 --- a/start.bat +++ b/start.bat @@ -3,7 +3,14 @@ setlocal cd /d "%~dp0" set NOBROWSER=0 +set EXTRAS=0 +:parse_args +if "%~1"=="" goto args_done if /I "%~1"=="--no-browser" set NOBROWSER=1 +if /I "%~1"=="--extras" set EXTRAS=1 +shift +goto parse_args +:args_done set "PY=" py -3.11 -c "import sys; raise SystemExit(0 if sys.version_info >= (3, 11) else 1)" >nul 2>nul @@ -48,6 +55,25 @@ if errorlevel 1 ( ) ) +if "%EXTRAS%"=="1" ( + .venv\Scripts\python.exe -c "import hashlib,pathlib,sys; p=pathlib.Path('requirements-extras.txt'); h=hashlib.sha256(p.read_bytes()).hexdigest(); s=pathlib.Path(r'.venv\requirements-extras.stamp'); sys.exit(0 if s.exists() and s.read_text(encoding='utf-8').strip()==h else 1)" + if errorlevel 1 ( + echo Installing optional extras... + .venv\Scripts\python.exe -m pip install -r requirements-extras.txt + if errorlevel 1 ( + echo pip install of extras failed. Fix the error above and run start.bat --extras again. + pause + exit /b 1 + ) + .venv\Scripts\python.exe -c "import hashlib,pathlib; p=pathlib.Path('requirements-extras.txt'); pathlib.Path(r'.venv\requirements-extras.stamp').write_text(hashlib.sha256(p.read_bytes()).hexdigest(), encoding='utf-8')" + if errorlevel 1 ( + echo Could not record the extras install stamp. The next launch with --extras will retry pip. + pause + exit /b 1 + ) + ) +) + for /f "tokens=1,2" %%a in ('.venv\Scripts\python.exe -c "from src.settings import load_settings; s=load_settings(); print(s.host, s.port)"') do ( set HOST=%%a set PORT=%%b diff --git a/tests/test_start_bat.py b/tests/test_start_bat.py index 58f2ace..a9eff27 100644 --- a/tests/test_start_bat.py +++ b/tests/test_start_bat.py @@ -17,6 +17,14 @@ def test_start_bat_fails_loudly_and_stamps_only_after_pip(): assert install_at < error_at < stamp_at assert "Failed to create the virtualenv." in text assert "Could not read server.host and server.port" in text + assert "set EXTRAS=0" in text + gate = text.index('if "%EXTRAS%"=="1"') + extras_pip = text.index("pip install -r requirements-extras.txt") + assert gate < extras_pip + assert text.index("pip install -r requirements.txt") < gate + extras = (ROOT / "requirements-extras.txt").read_text(encoding="utf-8") + assert "pycaw==20260927" in extras + assert "openwakeword==0.6.0" in extras def test_shipped_config_keeps_optional_feeds_off(): diff --git a/tests/test_v3_phase5.py b/tests/test_v3_phase5.py new file mode 100644 index 0000000..431898f --- /dev/null +++ b/tests/test_v3_phase5.py @@ -0,0 +1,73 @@ +import asyncio +import importlib.util +import re +from pathlib import Path + +from src.health import extra_status, snapshot +from tests.test_orchestrator_policy import make_orch + +ROOT = Path(__file__).resolve().parents[1] + + +def _config_keys() -> list[str]: + text = (ROOT / "src" / "settings.py").read_text(encoding="utf-8") + found = re.findall(r'(?:_get|_command_list)\(\s*data\s*,\s*"([^"]+)"', text, re.S) + return list(dict.fromkeys(found)) + + +def test_every_config_key_is_documented(): + docs = "\n".join( + (ROOT / name).read_text(encoding="utf-8") for name in ("README.md", "config.yaml") + ) + missing = [key for key in _config_keys() if key not in docs] + assert missing == [] + assert _config_keys() + + +def test_readme_covers_phase5_topics(): + readme = (ROOT / "README.md").read_text(encoding="utf-8") + assert "endpoint ducking" not in readme.lower() + assert "not Windows endpoint" in readme + assert "audio.duck_apps" in readme + assert "audio.wake_threshold" in readme + assert "clips:edit" in readme + assert "[::1]" in readme + assert "chat.custom_commands" in readme + assert "COHOST_CONTROL_KEY" in readme + assert "YYYY-MM-DD-HHMM.md" in readme + assert "!so` with no name shouts out the last raider" in readme + assert "Owner checklist" in readme + + +def test_health_reports_optional_extras(tmp_path, settings, monkeypatch): + assert extra_status() == {"pycaw": "not installed", "openwakeword": "not installed"} + + def find_spec(name): + return object() if name == "pycaw" else None + + monkeypatch.setattr(importlib.util, "find_spec", find_spec) + assert extra_status() == {"pycaw": "installed", "openwakeword": "not installed"} + + async def run(): + orch = make_orch(tmp_path, settings) + + class _Wake: + def status(self): + return {"enabled": False} + + orch.mic.wake = _Wake() + health = await snapshot(settings, orch) + assert health["extras"]["pycaw"] == "installed" + assert health["extras"]["openwakeword"] == "not installed" + + asyncio.run(run()) + + +def test_changelog_lists_merged_prs_and_matches_version(): + changelog = (ROOT / "CHANGELOG.md").read_text(encoding="utf-8") + pyproject = (ROOT / "pyproject.toml").read_text(encoding="utf-8") + assert 'version = "0.2.0"' in pyproject + assert "0.2.0" in changelog + assert "v0.2.0" in changelog + for number in range(1, 12): + assert f"#{number} " in changelog