"""`python -m bolt_pet --doctor` — is this install actually going to work? Written after a week of debugging things a preflight would have shown in one command: a missing port publish, a wrong reverse-proxy header, a venv whose python was a zero-byte file, an OCR engine that was never installed. Every one of those presented as "the pet is being weird" and took a conversation to find. Each check is independent and reports one of three things — ok, a warning (works, but degraded), or a failure (this will not do what you expect) — and says *what to do about it* rather than just what is wrong. Nothing here raises: a doctor that crashes on a broken install is diagnosing the wrong patient. Deliberately does not open the mic or call a paid API by default. It checks that the door is unlocked, not that the room is furnished; `--deep` is there when you want it to actually knock. """ from __future__ import annotations import importlib import shutil import sys from dataclasses import dataclass from typing import Callable, Optional from . import config OK, WARN, FAIL = "ok", "warn", "fail" _MARKS = {OK: " ok ", WARN: " warn ", FAIL: " FAIL "} @dataclass class Check: name: str status: str detail: str = "" fix: str = "" def line(self) -> str: text = f"[{_MARKS[self.status]}] {self.name:22} {self.detail}" if self.fix and self.status != OK: text += f"\n{'':32}→ {self.fix}" return text def _module(name: str) -> bool: try: importlib.import_module(name) return True except Exception: return False # ── the checks ────────────────────────────────────────────────────────────── def check_config() -> Check: missing = config.missing_config() if missing: return Check("server config", FAIL, f"missing {', '.join(missing)}", "set them in .env — without these the controller exits at startup") return Check("server config", OK, f"{config.SERVER_URL} as {config.SESSION_ID}") def check_server(deep: bool = False) -> Check: if not config.SERVER_URL: return Check("server", FAIL, "no BOLT_SERVER_URL", "cp .env.example .env and set BOLT_SERVER_URL to your Bolt server") if not deep: return Check("server", OK, f"{config.SERVER_URL} (not contacted; --deep to try)") try: from . import server_client health = server_client.check_health(timeout=8) return Check("server", OK, f"reachable — {health}") except Exception as exc: return Check("server", FAIL, f"unreachable: {exc}", "check the URL, the key, and that the container is up") def check_streaming_endpoint(deep: bool = False) -> Check: """The streamed-reply endpoint is newer than some deployed servers.""" if not config.STREAMING_REPLIES: return Check("streamed replies", WARN, "disabled (STREAMING_REPLIES=false)") if not deep: return Check("streamed replies", OK, "enabled (endpoint not probed)") try: import requests response = requests.post( f"{config.SERVER_URL}/desk/converse_stream", json={"session_id": config.SESSION_ID, "text": ""}, headers={"X-Desk-Api-Key": config.API_KEY}, timeout=8, stream=True, ) if response.status_code == 404: return Check("streamed replies", WARN, "server has no /desk/converse_stream", "update the server, or set STREAMING_REPLIES=false to skip the probe") return Check("streamed replies", OK, f"endpoint answered {response.status_code}") except Exception as exc: return Check("streamed replies", WARN, f"probe failed: {exc}") def check_microphone(deep: bool = False) -> Check: if not _module("sounddevice"): return Check("microphone", FAIL, "sounddevice is not installed", "pip install -r requirements.txt") try: import sounddevice as sd devices = [d for d in sd.query_devices() if d.get("max_input_channels", 0) > 0] if not devices: return Check("microphone", FAIL, "no input devices", "on Linux check PipeWire/PulseAudio is running as your user") chosen = config.MIC_DEVICE or "system default" if not deep: return Check("microphone", OK, f"{len(devices)} input device(s), using {chosen}") with sd.InputStream(samplerate=config.SAMPLE_RATE, channels=1, dtype="int16", device=config.MIC_DEVICE, blocksize=config.FRAME_LEN): pass return Check("microphone", OK, f"opened at {config.SAMPLE_RATE} Hz ({chosen})") except Exception as exc: return Check("microphone", FAIL, f"could not open: {exc}", "don't run the pet as root — PortAudio can't reach your PipeWire socket") def check_wake_model() -> Check: from pathlib import Path path = Path(config.WAKE_MODEL_PATH) if not path.exists(): return Check("wake word", FAIL, f"{path.name} is missing", "it ships in the project root; check WAKE_MODEL_FILE") if not _module("openwakeword"): return Check("wake word", FAIL, "openwakeword is not installed", "pip install -r requirements.txt") return Check("wake word", OK, f"{path.name}, threshold {config.WAKE_WORD_THRESHOLD}") def check_stt() -> Check: if not config.DEEPGRAM_API_KEY: return Check("speech-to-text", FAIL, "no DEEPGRAM_API_KEY", "set it in .env — nothing you say can be transcribed without it") if config.STT_STREAMING and not _module("websocket"): return Check("speech-to-text", WARN, "streaming on, but websocket-client is missing", "pip install websocket-client — it falls back to one-shot uploads") mode = "streaming" if config.STT_STREAMING else "one-shot" return Check("speech-to-text", OK, f"Deepgram {config.DEEPGRAM_MODEL}, {mode}") def check_tts() -> Check: if not (config.ELEVENLABS_API_KEY and config.ELEVENLABS_VOICE_ID): if _module("pyttsx3"): return Check("text-to-speech", WARN, "no ElevenLabs key/voice — offline voice only", "set ELEVENLABS_API_KEY and ELEVENLABS_VOICE_ID for the real voice") return Check("text-to-speech", FAIL, "no ElevenLabs config and no pyttsx3 fallback") return Check("text-to-speech", OK, f"ElevenLabs {config.ELEVENLABS_MODEL_ID}, voice …{config.ELEVENLABS_VOICE_ID[-6:]}") def check_dialogue() -> Check: if not config.DIALOGUE: return Check("multi-voice scenes", WARN, "disabled (DIALOGUE=false)") from . import dialogue cast = dialogue.parse_voice_map(config.DIALOGUE_VOICES) if not cast: return Check("multi-voice scenes", WARN, "no cast configured — only 'self' works", 'set DIALOGUE_VOICES=narrator:,villain:') return Check("multi-voice scenes", OK, f"{len(cast)} voice(s): {', '.join(sorted(cast))}") def check_screen_text() -> Check: if not config.SCREEN_TEXT: return Check("screen reading", WARN, "disabled (SCREEN_TEXT=false)") if not _module("mss"): return Check("screen reading", WARN, "mss is not installed — petctl read will decline", "pip install mss (and note it cannot capture on Wayland)") engine = "pytesseract" if _module("pytesseract") else ( "rapidocr" if _module("rapidocr_onnxruntime") else "") if not engine: return Check("screen reading", WARN, "no OCR engine", "pip install pytesseract && apt install tesseract-ocr, " "or pip install rapidocr-onnxruntime") if engine == "pytesseract" and not shutil.which("tesseract"): return Check("screen reading", WARN, "pytesseract is installed but tesseract is not", "apt install tesseract-ocr") return Check("screen reading", OK, f"mss + {engine}") def check_hotkey() -> Check: if not _module("pynput"): return Check("push-to-talk", WARN, "pynput is not installed", "the wake word still works; pip install pynput for the hotkey") import os if os.environ.get("WAYLAND_DISPLAY") and not os.environ.get("DISPLAY"): return Check("push-to-talk", WARN, "Wayland session — global hotkeys usually blocked", "use the wake word, or click the pet") return Check("push-to-talk", OK, config.PUSH_TO_TALK_HOTKEY) def check_sprites() -> Check: from pathlib import Path root = Path(__file__).resolve().parent / "assets" / "sprites" if not root.exists(): return Check("sprites", WARN, "no art — the placeholder blob will be drawn", "python scripts/generate_bolt_sprites.py") counts = {d.name: len(list(d.glob("*.png"))) for d in sorted(root.iterdir()) if d.is_dir()} empty = [name for name, count in counts.items() if count == 0] if empty: return Check("sprites", WARN, f"no frames for: {', '.join(empty)}", "python scripts/generate_bolt_sprites.py") return Check("sprites", OK, ", ".join(f"{n} {c}" for n, c in counts.items())) def check_latency() -> Check: """The setting most likely to make it feel slow, and the least obvious.""" silence = config.SILENCE_END_SEC if silence >= 1.5: return Check("turn latency", WARN, f"VAD_SILENCE_END_SEC={silence:g}s of dead air per turn", "0.8-1.0 feels markedly snappier; it is pure wait before anything starts") return Check("turn latency", OK, f"silence timeout {silence:g}s, " f"streaming {'on' if config.STREAMING_REPLIES else 'off'}") CHECKS: tuple[tuple[str, Callable], ...] = ( ("config", check_config), ("server", check_server), ("stream", check_streaming_endpoint), ("mic", check_microphone), ("wake", check_wake_model), ("stt", check_stt), ("tts", check_tts), ("dialogue", check_dialogue), ("screen", check_screen_text), ("hotkey", check_hotkey), ("sprites", check_sprites), ("latency", check_latency), ) def run(deep: bool = False) -> list[Check]: results = [] for _name, check in CHECKS: try: try: results.append(check(deep)) except TypeError: results.append(check()) except Exception as exc: # a broken check must not hide the others results.append(Check(_name, FAIL, f"the check itself failed: {exc}")) return results def main(argv: Optional[list] = None) -> int: argv = list(argv if argv is not None else sys.argv[1:]) deep = "--deep" in argv print(f"Bolt pet preflight{' (deep: contacting the server and opening the mic)' if deep else ''}\n") results = run(deep=deep) for check in results: print(check.line()) failures = [c for c in results if c.status == FAIL] warnings = [c for c in results if c.status == WARN] print() if failures: print(f"{len(failures)} problem(s) will stop this working. Fix those first.") elif warnings: print(f"Ready. {len(warnings)} thing(s) degraded but working.") else: print("Everything checks out.") return 1 if failures else 0