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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,4 +32,4 @@ jobs:
run: python -m ruff check src tests

- name: Test
run: python -m pytest -q
run: python -m pytest -q --cov=src --cov-fail-under=72
16 changes: 8 additions & 8 deletions PLAN-v3.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,49 +143,49 @@ Do these on a test stream or with the booth open before relying on the features.

### Phase 2: Robustness and code health

- [ ] **V3-P2-01 🟡 Atomic writes for booth and identity** (F-08)
- [x] **V3-P2-01 🟡 Atomic writes for booth and identity** (F-08)
- *Do:* use `atomic_write_text()` in `save_booth()` and `save_identity()`; log (don't swallow silently) a corrupt file and keep a `.bad` copy like memory does.
- *Files:* `src/booth.py`, `src/identity.py`, tests
- *Acceptance:* a test that makes `os.replace` raise leaves the old file intact; a corrupt `booth.json` is logged and copied to `booth.json.bad`.
- *Depends on:* none

- [ ] **V3-P2-02 🟡 Clean up Piper temp files after interrupts** (F-10)
- [x] **V3-P2-02 🟡 Clean up Piper temp files after interrupts** (F-10)
- *Do:* when a persistent request is abandoned, record its `out_path` in a "pending deletes" set; delete it when its ready-line arrives or on the next synth/close. Optionally write temps into `data/tmp/` and clear that folder on start.
- *Files:* `src/tts/piper.py`, `tests/test_piper_pipes.py`
- *Acceptance:* a fake Piper that writes the file after the interrupt leaves no stray WAVs after the next synth.
- *Depends on:* none

- [ ] **V3-P2-03 🟡 Keep the manual mood across restarts** (F-11)
- [x] **V3-P2-03 🟡 Keep the manual mood across restarts** (F-11)
- *Do:* store `mood_override` separately in `BoothState`; profiles set the base mood, and a manual change sets the override until the next profile switch.
- *Files:* `src/booth.py`, `src/orchestrator.py`, `tests/test_phase4.py`
- *Acceptance:* profile "Focus game" + manual `hype` → restart → mood is still `hype`; switching profile clears the override.
- *Depends on:* V3-P2-01

- [ ] **V3-P2-04 🟡 Better shoutout lines** (F-12)
- [x] **V3-P2-04 🟡 Better shoutout lines** (F-12)
- *Do:* say "go check out X" style lines (configurable `chat.shoutout_line`, with `{user}`), use "welcome in" only when the target is the last raider, and let `chat.shoutout_speak` (default true) control TTS.
- *Files:* `src/orchestrator.py`, `src/settings.py`, `config.yaml`, `tests/test_shoutout.py`
- *Acceptance:* `!so friend` with no raid posts the configured line; a raider shoutout keeps the welcome; with `shoutout_speak: false` nothing is spoken.
- *Depends on:* V3-P1-02

- [ ] **V3-P2-05 🟡 Remove hardcoded "Pixel"** (F-13)
- [x] **V3-P2-05 🟡 Remove hardcoded "Pixel"** (F-13)
- *Do:* derive the control command from the persona name (`!<name lowercase>`) and keep `!pixel` as an alias via `chat.control_command`; use `personaName` in the dashboard hint; pass the persona name into the smalltalk fallback everywhere.
- *Files:* `src/policy.py`, `src/orchestrator.py`, `src/settings.py`, `src/web/static/app.js`, `config.yaml`, tests
- *Acceptance:* with `name: Nova` and `control_command` unset, `!nova mute` works and `!pixel mute` still works; no user-visible "Pixel" remains in the UI when the persona is renamed.
- *Depends on:* none

- [ ] **V3-P2-06 🟡 Split the orchestrator** (F-15)
- [x] **V3-P2-06 🟡 Split the orchestrator** (F-15)
- *Do:* move code without changing behaviour into `src/core/chat_flow.py` (pick/rate/decide/perform), `src/core/event_flow.py` (batching, priority, react), `src/core/voice_flow.py` (utterance → reply), `src/core/twitch_link.py` (connect/disconnect/watchdog) and `src/core/queue.py`. Keep `Orchestrator` as a thin façade so `main.py` and tests don't change.
- *Files:* `src/orchestrator.py`, new `src/core/*`
- *Acceptance:* all existing tests pass unchanged; `orchestrator.py` is under 500 lines; ruff clean.
- *Depends on:* Phase 1 done (to avoid merge pain)

- [ ] **V3-P2-07 🟡 Hermetic tests and a coverage floor** (F-16)
- [x] **V3-P2-07 🟡 Hermetic tests and a coverage floor** (F-16)
- *Do:* make `conftest.py` set `COHOST_SKIP_SECRET_CHECK=1`, point `ROOT`-based paths at `tmp_path` via a fixture, and never read the real `config.yaml`/`persona.yaml`. Add `--cov=src --cov-fail-under=<current-2>` to CI.
- *Files:* `tests/conftest.py`, `.github/workflows/ci.yml`, `pyproject.toml`
- *Acceptance:* tests pass with a deliberately broken `config.yaml` in the repo root; CI fails if coverage drops.
- *Depends on:* V3-P1-01

- [ ] **V3-P2-08 🟡 Configurable logging** (F-17)
- [x] **V3-P2-08 🟡 Configurable logging** (F-17)
- *Do:* add `logging.level` (default INFO) and `logging.debug_modules` (e.g. `["cohost.mic","cohost.tts"]`), and a dashboard "Debug log for 10 min" toggle (token-protected POST).
- *Files:* `src/logsetup.py`, `src/settings.py`, `config.yaml`, `src/main.py`, `src/web/static/*`
- *Acceptance:* setting `debug_modules: [cohost.mic]` produces mic debug lines only; the toggle reverts after 10 minutes.
Expand Down
10 changes: 10 additions & 0 deletions config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,11 @@ chat:
mod_commands:
- "!so"
shoutout_cooldown_seconds: 120
# Posted for !so. {user} is the login. A shoutout of the last raider still says "welcome in".
shoutout_line: "go check out {user}"
shoutout_speak: true
# Empty derives !<persona name>. !pixel always stays as an alias.
control_command: ""
# Pending viewer lines older than this are dropped (streamer lines and commands stay).
max_age_seconds: 45
# First-time chatters get a short typed welcome instead of a spoken reply.
Expand Down Expand Up @@ -131,6 +136,11 @@ events:
overlay:
alert_hold_seconds: 6

logging:
level: INFO
# Logger names that stay at DEBUG, for example ["cohost.mic", "cohost.tts"].
debug_modules: []

safety:
# Pixel strips links from chat posts unless this is true.
allow_links: false
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ requires-python = ">=3.11"

[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "--cov=src --cov-fail-under=72"

[tool.ruff]
line-length = 110
Expand Down
34 changes: 30 additions & 4 deletions src/booth.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,17 @@
from __future__ import annotations

import json
import logging
import shutil
from dataclasses import asdict, dataclass
from pathlib import Path

from src.settings import atomic_write_text

log = logging.getLogger("cohost.booth")

MOODS = ("chill", "normal", "hype")


@dataclass
class BoothState:
Expand All @@ -12,12 +20,28 @@ class BoothState:
post_voice_replies_to_chat: bool = True
mood: str = "normal"
profile: str = ""
mood_override: str = ""

def clamped(self) -> BoothState:
if self.mood not in ("chill", "normal", "hype"):
if self.mood not in MOODS:
self.mood = "normal"
if self.mood_override not in ("", *MOODS):
self.mood_override = ""
return self

def effective_mood(self) -> str:
"""Manual override wins until the next profile switch."""
return self.mood_override or self.mood


def _preserve_bad(path: Path) -> None:
bad = path.with_name(path.name + ".bad")
try:
shutil.copy2(path, bad)
except Exception:
log.exception("could not copy corrupt %s", path.name)
log.error("%s is unreadable; kept a copy at %s", path.name, bad.name)


def load_booth(path: Path, fallback_mute: bool = False, fallback_post: bool = True) -> BoothState:
state = BoothState(tts_mute=fallback_mute, post_voice_replies_to_chat=fallback_post)
Expand All @@ -31,11 +55,13 @@ def load_booth(path: Path, fallback_mute: bool = False, fallback_post: bool = Tr
)
state.mood = str(data.get("mood") or state.mood)
state.profile = str(data.get("profile") or "")
state.mood_override = str(data.get("mood_override") or "")
except Exception:
pass
log.exception("booth.json is unreadable")
_preserve_bad(path)
return state.clamped()


def save_booth(path: Path, state: BoothState) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(asdict(state.clamped()), ensure_ascii=False, indent=2), encoding="utf-8")
body = json.dumps(asdict(state.clamped()), ensure_ascii=False, indent=2)
atomic_write_text(path, body + "\n")
1 change: 1 addition & 0 deletions src/core/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Flows moved out of the orchestrator facade."""
Loading
Loading