diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..b3bb6af --- /dev/null +++ b/.env.example @@ -0,0 +1,34 @@ +# Bolt Console — copy to .env and fill in the first two. + +# Your Bolt server and its desk API key (required). +BOLT_SERVER_URL=https://your-bolt-server.example.com +DESK_API_KEY= + +# Conversation id on the server. Default: pet-console-. Keep the +# "pet-" prefix: the server offers the filectl file tool to pet sessions. +#DESK_SESSION_ID= + +# ask = confirm shell commands, file writes and edits (reads never ask) +# auto = run everything without asking +#APPROVAL_MODE=ask + +# Seconds before a relayed shell command is killed. +#COMMAND_TIMEOUT_SECONDS=120 + +# Print sentences as they stream in (falls back automatically if unsupported). +#STREAMING_REPLIES=true + +# Tell Bolt the working directory and OS with each message. +#CONSOLE_CONTEXT=true + +# Files Bolt sends you with deliver_files land here. +#RECEIVE_FILES=true +#DELIVERED_FILES_DIR=~/Downloads/Bolt + +# Dictation (mic button / Ctrl+M): input device index or name (blank = default), +# and how long a pause ends a recording once you've said something. +#MIC_DEVICE= +#DICTATION_SILENCE_SECONDS=3 + +# ANSI colour (NO_COLOR in the environment also turns it off). +#COLOR=true diff --git a/.gitignore b/.gitignore index 10f47fd..e3b81bd 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,9 @@ __pycache__/ .pytest_cache/ .claude CLAUDE.md + +# PyInstaller output (build_bolt_console.py) and packaged releases +build/ +dist/ +Release/ +*.spec diff --git a/CLAUDE.md b/CLAUDE.md index 67a2e80..7e612a9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,587 +4,119 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## What this is -A desktop pet (PySide6 window) that is a voice/click UI on top of an external -Bolt server's desk API — same brain, memory, tools, and persona as that -server's Discord bot and Linux desk client. This repo has **no import -dependency** on the server repo; it's a standalone HTTP client configured via -its own `.env`. +A PySide6 desktop app (default) and a terminal REPL (`--cli`), both Claude Code-style, on top of an +external Bolt server's desk API. It uses the same +brain, memory, tools and persona as the server's Discord bot and its other desk clients. There is +**no import dependency** on the server repo: it's a standalone HTTP client configured through its +own `.env`. It was forked from Bolt-Pet with the desktop pet, wake word, STT/TTS, screen +context/OCR, notifications, auto-updater and self-restart removed. -Pipeline: `mic → openWakeWord ("thunderbolt", on-device) / push-to-talk / -click → record utterance → the server's own /desk/stt (same relay the -Android app uses — no local Deepgram account) → + active-window + screen- -layout context → POST /desk/converse → [server may relay a shell command to -run on this machine, or a `petctl` pseudo-command that moves/emotes the pet, -jumps it to another monitor, reads a screen's text back, or plays a -multi-voice scene instead] → reply (optionally tagged with a voice the -server picked for it) → the server's own /desk/tts, streaming (or offline -pyttsx3 fallback) → speakers`, with the pet sprite/speech bubble reflecting -state throughout, and playback interruptible by talking over it (barge-in). - -Because a relayed command's output goes back up the tool-result relay before -the final reply, a `petctl read` mid-turn means Bolt can look at a monitor and -then talk about what's on it in the same answer. - -Side channels that let the pet act between turns: the heartbeat (proactive -announcements), the desktop notification bridge, and autonomous wandering — -all suppressed while it's napping (quiet hours / fullscreen DND). +Pipeline: `typed line → + cwd/OS note → POST /desk/converse_stream (or /desk/converse) → [server +relays a command: filectl file op, or a shell command, after y/n approval] → /desk/tool_result → +… → reply printed`. ## Commands ```bash -# Setup + run (creates .venv and installs requirements.txt on first run) -./run.sh # macOS/Linux -run.bat # Windows - -# Preflight: is this install actually going to work? (config, mic, keys, sprites…) -python -m bolt_pet --doctor # shallow: no network, no mic -python -m bolt_pet --doctor --deep # contacts the server and opens the microphone - -# Run tests (no pytest config file — tests self-insert repo root via sys.path). -# QT_QPA_PLATFORM=offscreen avoids a QApplication segfault on headless/no-display hosts. -QT_QPA_PLATFORM=offscreen .venv/bin/pytest tests/ -.venv/bin/pytest tests/test_state.py::test_happy_path_transitions # single test - -# Redraw the pet's sprite frames (the committed PNGs are this script's output) -python scripts/generate_bolt_sprites.py # --out /tmp/x to preview first - -# Convert a grid sprite sheet into the per-frame-PNG convention sprite.py expects -python scripts/slice_spritesheet.py path/to/sheet.png assets/sprites/idle --cols 6 --rows 1 +run.bat / run-cli.bat / ./run.sh [--cli] # window / terminal; creates .venv; keeps the caller's cwd +python -m bolt_console --doctor [--deep] +python -m bolt_console -p "message" # one-shot +.venv/Scripts/python -m pytest tests # (.venv/bin on Linux/macOS) ``` -There is no lint/build step configured beyond pytest. `cp .env.example .env` -and fill in `BOLT_SERVER_URL` / `DESK_API_KEY` before running — without them -the controller logs a missing-config message and exits its thread instead of -starting. That's also all STT and normal-reply TTS need now (both go through -the server); `ELEVENLABS_VOICE_ID` picks the voice, and `ELEVENLABS_API_KEY` -is only for the one feature with no server endpoint — multi-voice -`dialoguectl` scenes, see dialogue.py below. +Runtime dependencies: `requests`, plus `PySide6` for the window only. `bolt_console.gui` is imported +lazily, so `--cli` works without Qt. The local venv is Python 3.9, so no `match`, no +runtime `X | Y` unions, and no builtin generics outside annotations. -## Architecture +## Architecture (`bolt_console/`) -- **`config.py`** — loads `.env` from the project root (not via python-dotenv; - a small hand-rolled parser matching the server repo's `desk_client/bolt_desk.py` - convention) into module-level constants. Everything else reads config from - here, never `os.environ` directly. -- **`state.py`** — `PetStateMachine`, pure logic with no Qt/audio imports (kept - that way deliberately for cheap unit testing). Enforces a transition table; - notably `IDLE -> TALKING` is legal directly (no LISTENING/THINKING leg) - because the heartbeat can make the pet speak proactively/unprompted. -- **`controller.py`** — `PetController(QObject)`, the pipeline orchestrator. - Runs on a background `QThread` (wired in `ui/app.py`) so audio I/O/network - never blocks the Qt event loop; communicates with the UI only through Qt - signals (`state_changed`, `said`, `log`, `action`, `napping`), never touches - a `QWidget` directly. Also drives the periodic heartbeat - (`_maybe_heartbeat`, gated by `HEARTBEAT_INTERVAL_SECONDS`) which lets the - server push proactive spoken announcements between user turns, and on the - same tick re-evaluates nap state, checks for delivered files, and drains - queued desktop notifications. It owns the live wake-word threshold - (`wake_threshold()` is passed to `listen_for_wake_word` as a *callable* so - the tray slider takes effect mid-listen) and the conversation `history`. -- **`server_client.py`** — HTTP client for the desk API, dependency-free - beyond `requests` so it's easy to mock in tests. `converse()` loops relaying - server-issued shell commands (`run_local_command`, executed via - `subprocess.run(shell=True)` as the desktop user, 30s default timeout) via - `/desk/tool_result` until the server sends a final `reply` (capped at - `_MAX_RELAY_HOPS`). This is the same "full desktop control" trust model as - the server repo's other desk clients — commands only ever originate from - the user's own voice/click requests in their own session. A final reply is - returned as a `Reply(text, voice_id, voice_name)` rather than a bare string, - because the server can tag it with a voice — see "Voices" below. - `list_outbox_files` - / `download_outbox_file` hit the same `/desk/files` and `/desk/files/` - endpoints the server's `deliver_files` tool queues onto — see `file_delivery.py`. -- **`file_delivery.py`** — the filesystem half of receiving files the server - queues via its `deliver_files` tool (`ai/desk_api.py` in the main tmn-api - repo — "send me that report" during a conversation spools the matched - workspace files, zipping multiple into one, onto the session's outbox). - `controller._check_deliveries` lists `/desk/files` and downloads anything - queued — right after a conversation/notification turn (the common case) - and once per heartbeat tick for anything queued out-of-band — saving each - under `DELIVERED_FILES_DIR` (default `~/Downloads/Bolt`). Downloading a - file dequeues it server-side, so it's only ever handed out once; `save()` - never overwrites an existing download, suffixing `" (1)"`, `" (2)"`, ... on - a name collision. `sanitize_filename()` reduces a server-supplied name to - its bare filename (`Path(...).name`), which is defense-in-depth against a - delivered name that's secretly a path, since a per-user desk API key means - the name isn't always coming from someone as trusted as the owner. Toggle - off entirely with `RECEIVE_FILES=false`. -- **`audio/`** — `mic.py` (energy-based VAD utterance capture, ported from the - server repo's `bolt_desk.py`), `wake_word.py` (openWakeWord `thunderbolt.onnx` - detection + `NearMissLog` for threshold tuning — see below), `stt.py` + - `stt_stream.py` (the server's own `/desk/stt` websocket relay — no local - Deepgram account; see below), `tts.py` (the server's own `/desk/tts`, - streaming by default — `stream_pcm()` + `play_stream()` start playback on - the first chunk; `chunks_to_int16()` carries odd bytes across HTTP chunk - boundaries, without which everything after the first split sample plays as - static — falling back to whole-clip PCM then offline `pyttsx3`; every entry - point takes an optional `voice_id` overriding `ELEVENLABS_VOICE_ID` — which - model to synthesize with is the server's call now, not this client's; - see `synthesize_dialogue()` further down for the one path that's still - ElevenLabs-direct), `barge_in.py` (two detectors behind one - `reset()`/`check()` shape, chosen by `BARGE_IN_MODE` via `make_detector`: - **wake** (default) scores every frame with the same openWakeWord model the - idle listener uses, so only the wake phrase cuts playback; **energy** is the - original N-consecutive-loud-frames rule, threshold ~4x the VAD one because - the mic hears the pet's own voice. Wake mode shares `_default_model` with - the idle listener — the two never run concurrently — and `reset()`s it on - detection so the tail of one reply can't count toward the next). Each - accepts an injectable stream/model/protocol so tests don't need real audio - hardware or a display. -- **`pet_actions.py`** — `petctl` pseudo-commands (`petctl move top-left`, - `petctl emote wave`, `say`/`wander`/`nap`, the screen verbs - `jump`/`monitors`/`read`, and `voice reset`). The desk API has no "move the pet" payload type - and this repo can't change the server, so these ride the existing - shell-command relay: `controller._handle_command` parses them and they never - reach `subprocess`; anything else is a real shell command exactly as before. - Pure parsing; the UI half is `PetWindow.apply_action`. Note `jump`'s target - is *not* validated here — which monitors exist is a runtime fact this pure - module doesn't have, so the spec passes through to `monitors.resolve()`. - Query verbs (`monitors`, `read`) are answered in `_handle_command` rather - than by `pet_actions.describe()`, because their output *is* the point: it - goes back up the tool-result relay for Bolt to use in his reply — as is - `voice reset`, which reports what it dropped since the server can't see - which voice is in use. `voice` only ever resets: picking one is the - server's job (`speak_as`, which it already knows how to use), so a - `petctl voice ` attempt is an error pointing back at that marker. -- **`self_restart.py`** — `petctl self_restart`, the pet restarting itself so - Bolt can *see* a code change he just made instead of waiting for a human to - restart it. Three problems shape it, and all three are the interesting part. - (1) The restart can't happen inline: killing the process mid-turn would drop - the HTTP tool relay before the result was posted, leaving the server to wait - out its timeout on a turn that can never finish — so the command only - *arms* it (`controller._arm_self_restart`) and - `controller._maybe_self_restart` fires it after the reply is spoken, the - same "only between turns" rule the updater follows. (2) A broken edit must - not be fatal, so `preflight()` imports the package in a **subprocess** - before arming — this process holds the old modules, so an in-process import - would pass on a file that no longer parses — and a SyntaxError comes back as - the command's output, in the same turn, with the pet still running. (3) The - reason has to outlive the process, so it's written to - `~/.cache/bolt-pet/restart_context.json` (never inside the repo Bolt is - editing) and read on the way back up by `controller._report_self_restart`, - which posts it to the server as an ordinary turn — that's what makes - "restart and check the sprites load" finish as a spoken sentence rather than - a silence. `check_loop_guard` refuses after `SELF_RESTART_MAX` restarts in - `SELF_RESTART_WINDOW_SECONDS`, so an edit-restart-crash cycle stops itself. - Off switch: `SELF_RESTART=false`. -- **`dialogue.py`** — `dialoguectl` pseudo-commands: a multi-voice *scene* - through ElevenLabs' Text to Dialogue endpoint (`audio/tts. - synthesize_dialogue`), checked in `_handle_command` between petctl and - filectl. Same single-line-JSON wire format as filectl and for the same - reason (the server's `command` marker captures only up to the next - newline), and it accepts the ElevenLabs field names (`inputs`/`voice_id`) - as well as its own (`lines`/`voice`) because the model has read that API - and copying its shape is the obvious thing to try. Voices are *named* - (`DIALOGUE_VOICES` maps names to ids) rather than pasted as raw ids, and - `self` resolves to whatever voice the pet is speaking with right now — - including a `speak_as` pick — so Bolt sounds like himself in his own - scenes. The API's limits (10 distinct voices, ~2000 characters) are - enforced *before* the request so a mistake comes back up the tool-result - relay as a sentence Bolt can act on rather than an HTTP 422 he can't see. - Unlike the normal reply path there is no streaming variant, so a scene is - whole-clip: `controller._play_dialogue` plays it with the same bubble, - transcript and barge-in handling a spoken reply gets, and returns to - THINKING afterwards (not IDLE) because the server is still waiting on the - tool result — that leg is why `state.py` allows TALKING -> THINKING. -- **`file_ops.py`** — `filectl` pseudo-commands, checked in `_handle_command` - right after petctl and before falling through to a real shell command. - Executing arbitrary commands already worked via the shell relay - (`run_local_command` — see `server_client.py` below); what filectl adds is - a *reliable* way to do the read/write/edit/list slice of that, since - getting the model to hand-roll a shell heredoc for multi-line content full - of quotes/`$`/backticks is failure-prone — and `list` exists as its own op - (rather than relying on the model shelling out to `ls`/`dir`) because this - project is cross-platform and the model shouldn't have to guess which - listing command applies on Windows vs. Linux vs. macOS; one glob-based op - (`pattern`, default `*`; `recursive` for `rglob` instead of `glob`) covers - all three. Wire format is `filectl ` where `` is a - **single-line** compact JSON object — - `{"op": "list"|"read"|"write"|"edit", "path": ..., ...}` — not a multi-line - marker block (an earlier design): the server relays this as the argument - to the ordinary `command` tool marker, and that marker's extractor - (`ai/agents/default.py` in the main repo) only captures up to the next - newline, so anything genuinely multi-line silently got truncated no matter - how the prompt worded it. JSON sidesteps that for free — `json.dumps` - already encodes embedded newlines as the two characters `\n`, not a real - line break, so multi-line file content still fits on the one physical line - the extractor sees. `edit` requires the old text to match exactly once — - same discipline as this project's own code-editing tool — and raises - rather than guessing if it's missing or ambiguous. This doesn't expand - what the server can do to this machine (a relayed shell command could - already overwrite anything the desktop user can write — see the security - notes below); it's a safer path to the same capability. Pure parsing - (`parse`) is separated from the filesystem I/O (`execute`), matching - pet_actions.py's parse/describe split. -- **`screen_context.py`** — active-window title (xprop/xdotool, Win32, - osascript) appended to each utterance via `context_for()`, plus - `is_fullscreen_active()` for do-not-disturb. Text only — the desk API takes - no images. Every probe is best-effort and returns None/False rather than - raising; the parsing is split into pure functions that are tested without a - display server. -- **`monitors.py`** — the screen layout, and resolving `petctl jump` targets - (a 1-based number, a name, `next`/`prev`/`primary`/`other`, or a direction - like `left`/`up` worked out from the actual geometry). Pure — no Qt, no - subprocess. The monitor list is *published by the UI* - (`PetWindow.publish_monitors` builds it from `QGuiApplication.screens()` and - emits it over a queued signal to `controller.set_monitors`), because the - controller and the window must agree on what "monitor 2" means: enumerating - with `xrandr` on one side and Qt's screen list on the other gives different - orderings on the same machine, and Bolt would announce one screen and land - on another. Qt is the single source of truth; `Monitor.index` is 0-based and - `.number` is the 1-based value used in every string a human or the model - sees. The controller resolves a jump to a concrete index *before* emitting - it, so the window can't re-resolve against a different list. -- **`screen_text.py`** — OCR, so Bolt can read what's on a monitor - (`petctl read [n|here|all]`). **Pull, not push**: nothing captures on its own - — the server has to ask, and the text goes back as that command's output. - That's deliberate; OCR of a 4K screen costs a second or two that would - otherwise be added to *every* utterance, and screen contents leaving the - machine should be a visible decision rather than a constant. Capture needs - `mss` (X11/Win32/macOS, **not** Wayland), recognition needs Tesseract or - RapidOCR; both are optional and soft-fail with a reason the way `hotkey.py` - does, and `read_monitor()` never raises because its return value is command - output. Engine selection takes injected probes so it's testable wherever. -- **`quiet.py`** — quiet-hours spec parsing (`23:00-08:00`, wraps midnight, - comma-separated). Napping suppresses *proactive* noise and wandering only; - wake word / click / push-to-talk still work. -- **`notifications.py`** — Linux/D-Bus notification bridge. Two latency/loss bugs - fixed 2026-08-02, both in `controller._maybe_heartbeat`: draining was wired to the - 60s heartbeat interval rather than the ~1.2s wake tick, and a heartbeat that landed - mid-conversation stamped its own clock *before* checking — burning the slot and - waiting another full interval, repeatedly, which is how a notification could go - unspoken for five or ten minutes. Draining now runs on every tick while IDLE, and the - heartbeat clock only advances when the heartbeat actually runs. Separately, the rate - limit used to *drop* notifications inside its window (a second text a minute later was - silently lost); the filter still gates at queue time but the limit is gone — a burst - is **batched into one turn** instead, same single round trip, no lost messages, capped - by `_MAX_PENDING_NOTIFICATIONS`. -- **`notifications.py` internals** — tails - `dbus-monitor`, parses Notify calls (pure `iter_notifications()`), filters - and rate-limits them (`NotificationGate`), and the controller forwards - survivors through `converse()`. Off by default — each one is a round trip. -- **`sudo_askpass.py`** — makes server-relayed `sudo` usable from a process - with no terminal, by pointing sudo's `SUDO_ASKPASS` at a GUI helper and - rewriting bare `sudo` to `sudo -A` (`add_askpass_flag`, a conservative regex - that skips anything already carrying a flag and anything inside quotes). - Prefers a real askpass binary and falls back to generating a - zenity/kdialog wrapper in `~/.cache/bolt-pet/askpass.sh`. Resolution order - is injectable (`is_executable`/`which`) so it's testable on a machine with a - different set installed. See the security notes — the dialog is the boundary. -- **`updater.py`** — self-update from the Gitea releases API. Polls - `/releases/latest` for a tag newer than - `bolt_pet.__version__` and moves the checkout to it with - `git fetch --tags` + `git checkout tags/`, so "downloading an update" - is just git and rolling back is one command. Three safety rules: a **dirty - working tree is skipped, never stashed** (silently discarding your - work-in-progress beats running an old version); everything after the - checkout — dependency install, then an **import smoke test in a - subprocess** (this process still has the old modules loaded, so importing - in-process would prove nothing) — is guarded, and any failure rolls back to - the exact ref that was live before, branch name or SHA; and the restart only - happens once the new code imports, so a broken release costs a log line - rather than a pet that won't start. Git goes through an injectable - `run(args) -> (code, output)` callable so apply/rollback is unit-tested - against a fake git; version comparison and release parsing are pure. - `controller._maybe_update` drives it from the wake-listener tick (so the pet - is IDLE and between turns by construction) and the actual `os.execv` happens - in `ui/app.py` *after* `app.exec()` returns — that ordering is what - guarantees the mic is released before the new process opens it. -- **`history.py`** — rolling transcript (`HISTORY_LIMIT` turns) behind the - tray's History window and click-to-copy on the bubble. -- **`hotkey.py`** — global push-to-talk via `pynput`; soft-fails with a logged - reason (Wayland, missing package, macOS permissions) since the wake word is - the primary trigger. -- **`audio/stt_stream.py`** / **`audio/stt.py`** — speech-to-text via the - server's `/desk/stt` websocket relay (audio up, Deepgram's JSON messages - down untouched — the same relay the Android app uses; no local Deepgram - account or API key). `stt_stream.py` is the opportunistic optimisation: - the one-shot path waits for the utterance to end, then sends the whole - clip and waits again; that second wait is dead time that grows with how - long you spoke, and streaming removes it — `record_utterance(on_frame=...)` - hands each captured frame to a `StreamingTranscriber` as it's captured, so - by the time the VAD decides you stopped the transcript is essentially - already there. `stt.py`'s `transcribe()` is the *guaranteed* fallback for - when that didn't produce anything: since there's no separate REST endpoint - server-side, it opens the exact same relay via `stt_stream.connect()` and - just feeds the whole buffered utterance in one go — deliberately - *unconditional*, not gated by `STT_STREAMING`/`available()` the way the - opportunistic path is, since there's nothing left to fall back to if that - connection fails. Three more deliberate limits on the streaming half: - `open()` returning **None is an ordinary outcome** (no websocket-client, no - network, streaming turned off) because the full audio is still buffered - and `controller._transcribe` just falls back to `stt.transcribe()`; the - **local VAD still decides when you stopped** rather than Deepgram's - endpointing, since barge-in, follow-up listening and the grace period are - all built on it and coupling them to the network is not a first-pass - change; and the socket is **per-utterance**, because holding one open - across an idle pet bills for silence and dies on the first blip. - Off: `STT_STREAMING=false`. -- **Streamed replies** — `server_client.converse_stream()` reads NDJSON from the desk - API's `/desk/converse_stream` and speaks each sentence as it arrives - (`controller._speak_stream_chunk`), so the wait is time-to-first-sentence instead of - the whole model call. `Reply.spoken` marks a reply whose sentences were already said: - the text is still carried, because the follow-up rule needs to see whether it ended on - a question, but it must not be read out again. A stream that fails *before* anything - was spoken falls back to `converse()` invisibly; one that fails after ends the turn - quietly rather than repeating the first half. Off: `STREAMING_REPLIES=false`. -- **`speech.py`** — everything the pet says, and the policy differences between - kinds of saying. There were four near-copies of this in `controller.py` (a - reply, a holding line, a streamed sentence, a dialogue scene), each repeating - the same dance — transition state, show the bubble, maybe record history, - reset barge-in, call TTS, reset barge-in *again*, resume state, decide - whether to keep the mic open — and they had already drifted apart: one forgot - to arm barge-in, another skipped the follow-up rule. Now the dance is - `Speaker.say()` and the differences are data on a frozen `Utterance` - (`record`, `resume`, `hold_talking`, `follow_up`, `interruptible`), built by - the four classmethods `reply`/`holding`/`stream_chunk`/`scene`. Notable - policies: a holding line is **not** recorded (it's filler; the transcript - should keep the answer) and **not** interruptible (cutting off "give me a - sec" strands the tool already running), and it resumes the state it - interrupted rather than dropping to IDLE, because the turn isn't over. A - streamed chunk stays TALKING so the sprite doesn't flicker between sentences. - Collaborators are injected, so all of this is tested without Qt, audio or a - real state machine. Two subtleties that are bugs waiting to happen: the - barge-in detector is read through a **callable**, not held (it's built after - the Speaker — it needs the mic stream — and swapped when the mode changes; two - copies drifting apart is invisible until the wake model starts hearing the - pet), and `last_detail` is captured *before* the post-playback reset, since - reading it after means every interruption reports zeroed counters. - `follow_up_decision()` is the mic-open rule as a pure function. -- **Lip-sync** — the mouth is driven by the audio, not a timer. - `audio/tts.level_of(frame)` reduces a PCM frame to 0..1 loudness on a **sqrt - curve** (speech sits well below peak most of the time, so a linear map leaves - the mouth barely open during normal talking), `envelope()` does the same for a - whole clip, and playback calls the `on_level` hook per chunk. That travels - `Speaker` → `controller.mouth` (a Signal) → `PetWindow.set_mouth`, and - `_mouth_frame()` indexes the talking frames directly — which works because - `generate_bolt_sprites.py` draws them as an **openness ramp** (closed first, - widest last) rather than an arbitrary loop. Levels going stale - (`_MOUTH_STALE_SECONDS`) hands control back to the ordinary animation, so - offline TTS — which has no envelope — degrades to the timed loop instead of - freezing the mouth mid-syllable. -- **`window_state.py`** — where the pet was left, so it starts there. - `~/.cache/bolt-pet/window.json`, atomic write on drag-end, every function - swallows its own errors (a corrupt state file must mean the default corner, - never a pet that won't start). `is_visible_on()` re-validates against the - *current* screen layout on load, because the common case for a stale position - is exactly the dangerous one: the pet was last on a monitor that is now - unplugged, and restoring it faithfully puts it somewhere unreachable. Takes - plain rectangles rather than importing Qt. Off: `PET_REMEMBER_POSITION=false`. -- **`doctor.py`** — `python -m bolt_pet --doctor`, a preflight, written after a - week of debugging things one command would have shown: a venv whose python - was a zero-byte file, an OCR engine never installed, a wrong proxy header. - Twelve independent checks, each reporting ok / warn (degraded but working) / - fail, and each saying **what to do about it** — `screen reading: warn` is - useless alone, `apt install tesseract-ocr` is the whole point. Nothing raises: - a doctor that crashes on a broken install is diagnosing the wrong patient, so - `run()` catches per-check and a failed check becomes a FAIL row rather than a - traceback. Shallow by default (no network, no mic) since it's the first thing - you reach for when the network is what's broken; `--deep` actually contacts - the server and opens the microphone. -- **`speech_text.py`** — sanitizes server replies before they're heard/shown. - `for_speech()` (called inside `tts.speak()`, so every path to the speakers is - covered) strips markdown, emoji, URLs and stray symbols the voice would read - literally ("asterisk asterisk"), turns bullet lists into full sentences, and - words a few symbols (`&` → "and"). `for_display()` is the looser version for - the speech bubble — markdown syntax gone, emoji kept. `is_question()` decides - whether a reply leaves the pet waiting on an answer: it tests the *spoken* - form (so a '?' inside a stripped code block or URL doesn't count) and only a - trailing one counts, since a question asked in passing isn't awaiting a - reply. `speech.follow_up_decision` uses it to keep listening without the - wake word, capped by `FOLLOW_UP_MAX_TURNS` so a server that ends every reply - with a question can't loop forever off mic noise. Pure string logic, no - Qt/audio imports. -- **`ui/`** — `app.py` wires `QApplication` + `PetWindow` + `PetTray` + the - history/tuner windows + the push-to-talk hotkey + the controller thread - together; `pet_window.py` is the frameless/translucent/always-on-top sprite - window + speech bubble (non-square frames are centered in the square - `PET_SIZE` window, see `paintEvent`), and also owns: - - **wandering** — a ~30fps timer walks the window toward a random on-screen - target every `PET_WANDER_INTERVAL_SECONDS` (randomized), suppressed - whenever the pet is non-IDLE, napping, dragged, or has a bubble up. A - commanded `petctl move` overrides all of that except the drag. - - **the walk cycle** — while actually travelling, `_animation_key()` swaps - the state animation for the side-view `walk/` frames (not a `PetState` — - see the sprites README). It is stepped by *distance travelled* - (`_WALK_PIXELS_PER_FRAME`), never by the animation timer, so the planted - paw tracks backwards at exactly the speed the window moves forwards; - `_advance_frame` deliberately no-ops while walking so the two can't - double-step it. The art is drawn facing right and `_oriented()` mirrors it - (cached per frame) when heading left. No `walk/` art → falls back to the - old coded bob rather than a placeholder blob. - - **emotes** — `emote_transform()` is pure maths (dx, dy, rotation, scale - from a 0..1 progress) kept out of `paintEvent` so the curves are unit - tested; every emote must return to the identity transform at progress 1.0 - or the pet ends up permanently askew. - - **shaped input / click-through** — `PET_SHAPED_INPUT` masks the window to - the sprite's opaque pixels so the square window's transparent corners stop - eating clicks (mask rebuilt only when the frame changes, and pinned to the - resting position so a bob/spin doesn't thrash it); `PET_CLICK_THROUGH` - makes the pet ignore the mouse entirely. - - **edge snapping** (`PET_EDGE_SNAP`) after a drag or a stroll, and **nap - dimming** (`set_napping`). - `sprite.py` loads `assets/sprites//*.png` (filename-sorted, looping — - the art is *generated* by `scripts/generate_bolt_sprites.py`, a Pillow - drawing of Bolt as a shepherd pup; edit the script and re-run it rather than - the committed PNGs, see `assets/sprites/README.md`) and falls - back to a procedurally-drawn placeholder blob per state if a folder has no - frames. It also loads `EXTRA_ANIMATIONS` — currently just `walk/` — keyed by - name rather than by `PetState`, with `has()` reporting whether a key is - backed by real art so callers can decline a placeholder instead of trotting - a blob across the desktop; `tray.py` is the system tray menu (talk now / mute / nap / wander / - click-through / history / wake-word tuning / use-default-voice / quit) — the - pet window has no title bar or taskbar entry; `history_window.py` and - `wake_tuner.py` are the two dialogs it opens. +- **`config.py`**: a small hand-written `.env` parser feeding module constants. Nothing else reads + `os.environ`. `SESSION_ID` defaults to `pet-console-`. The `pet-` prefix is deliberate: the + server advertises `filectl` to pet sessions, and `-console` keeps this a separate conversation + from a Bolt-Pet on the same machine. `new_session()` (New chat / `/new`) rotates to + `-` and remembers it in `~/.cache/bolt-console/session.json`. `DESK_SESSION_ID` + in `.env` overrides the remembered id. Tests must monkeypatch `config.STATE_FILE`. +- **`server_client.py`**: the desk protocol. `_relay()` is the shared command → tool_result loop. + `converse_stream()` hands each `say` sentence over as it arrives and finishes a tool call through + `_relay`. It raises `ServerError` only if nothing was shown yet, which lets the caller fall back to + `converse()` invisibly. `Reply.streamed` means "already on screen, don't print again". + `run_local_command` runs in `os.getcwd()` with stdin closed and `CREATE_NO_WINDOW`, because under + pythonw every cmd.exe would otherwise flash a console. +- **`tools.py`**: `Tools`, the `on_command` callable. It routes `filectl` to `file_ops`, answers + `petctl`/`dialoguectl` with `PET_UNAVAILABLE` (the server may still emit them), and treats + anything else as a shell command. Approval: reads and lists never ask. Shell, write and edit ask + via `ui.confirm` unless `mode == "auto"`, and "always" switches the session to auto. A decline + returns `DECLINED` to the model rather than raising, so the turn still completes. +- **`file_ops.py`**: `filectl {json}` list/read/write/edit. It is a **single-line** JSON wire format + because the server's `command` marker only captures to the first newline. `edit` must match + exactly once. Parsing (`parse`) is separate from I/O (`execute`). +- **`relay_json.py`**: lenient JSON for model-typed payloads. It tries strict parsing first, then + named repairs (fences, smart quotes, invalid backslash escapes, stray quotes, trailing commas, + Python literals, single quotes), and accepts a repair only if the result parses. Repairs are + always reported back to the model, never silent. The backslash repair only touches *invalid* + escapes, so `C:\temp` still parses as a tab (that's ambiguous, so it's left alone). +- **`ui.py`**: `Terminal` handles plain ANSI rendering, the spinner thread, tool-call/result lines, + diffs, `confirm` and `read_message`. All output goes through `_emit` under the same lock the + spinner draws under. The spinner redraws by erasing the current line, so output is always written + as whole lines (see `assistant_chunk`). Glyphs fall back to ASCII on non-UTF-8 streams. +- **`console.py`**: `Session` (one turn, delivered-file download, slash commands, `!` local shell, + the loop) and `run()` (argv). Collaborators are injected so tests can drive it. +- **`gui/`**: the window. `bridge.Bridge` implements the same surface as `ui.Terminal` + (tool_call, tool_result, confirm, assistant_chunk, …) with each call emitting a Qt signal, so the + window reuses `console.Session` and `Tools` unchanged. `window.TurnRunner` runs `Session.send` on a + QThread. `Bridge.confirm` blocks the worker on an Event until a ToolCard's Allow/Deny calls + `answer()`, and `close()` releases it as "no". `window.MainWindow` reacts only to Bridge signals. + `widgets.py` holds the transcript pieces (ToolCard, AssistantMessage, Composer, …). `theme.py` holds + the colours and the one stylesheet (widgets set object names and dynamic properties for it, then + `repolish()`). `icons.py` draws every icon with QPainter, so there are no asset files. + `markdown_html` swaps Qt's fixed font for code (Courier New on Windows) with the app's mono font. +- **`dictation.py`**: the mic button. It streams 16 kHz int16 to the server's `/desk/stt` + websocket (Bolt-Pet's route: the server relays to Deepgram) and buffers every frame. If the live + socket never opened or died, `_fallback()` sends the whole buffer once from an empty transcript, + so a half-live session's words don't come back twice. A healthy socket is never re-sent, even + when it heard nothing, because that would pay twice. Stops on click, on `silence_seconds` after + speech, or on `max_seconds`. No Qt and no numpy (the array module does RMS), so it's identical + in the Linux single file. The mic and socket are injected. The window turns the callbacks into + `DictationSignals`, and the composer is read-only while a recording rewrites it. PySide6 6.10 + needs `QTextCursor.MoveOperation.End`, not `cursor.End`. +- **Chats**: `server_client.list_chats()`/`create_chat()` wrap `GET`/`POST /desk/chats` (the server + mints `chat-…` ids; a chat is only listed once used). Switching is `config.set_session(id)`: a + plain reassignment of the `SESSION_ID` every call reads, persisted to `session.json`, and + deliberately not locked. The window refuses to switch mid-turn instead. `gui/chat_list_window.py` + runs list/create as `ChatTask`s on `QThreadPool.globalInstance()` and hands results back through + `ChatSignals`, never calling the network from a slot. The header New chat uses the same pool. + `Session.new_conversation()` (terminal `/new`) is blocking and falls back to a local id if the + server can't mint one. `chats.py` holds the Qt-free row helpers (the preview drops the + `[Bolt Console: …]` and `[on screen right now: …]` notes). +- **`file_delivery.py`**: saves files the server queues via `deliver_files`. The name is reduced to + its basename and never overwrites an existing file. `Session.check_deliveries` runs after every + turn. +- **`doctor.py`**: independent checks, each reporting ok/warn/fail plus what to do about it. A check + never raises. -### Voices (the server's `speak_as`) +## Testing conventions -Ask Bolt to talk like someone else, or in another language, and the *server* -does the picking: its desk-only `voice_search` marker browses the ElevenLabs -voice library, and `speak_as: ` on the final reply tags that reply -with the chosen voice (adding a Voice Library pick to the ElevenLabs account -first, so the id is usable by the time it reaches us). Nothing about that is -this repo's to decide — all the client owes it is actually speaking in the -voice it was handed: `converse()` returns it on `Reply`, `_apply_voice()` -records it, and `_speak()` passes it to `tts.speak(voice_id=...)`. +`tests/test_gui.py` sets `QT_QPA_PLATFORM=offscreen` itself and drives real turns through +`MainWindow` and its worker thread against the loopback server (`pump_until` processes events). +Offscreen Qt has **no font database**, so check the look by rendering with the real platform plus +`WA_DontShowOnScreen` and `grab()`, not offscreen. -Two things are decided *here*, though, because the server can't: +Tests cover logic with injected fakes (`FakeUI`, `FakeShell`, a `Terminal` writing to `StringIO` +with a scripted `input_fn`). `tests/test_console.py` is the end-to-end file: a real threaded HTTP +server on a loopback port driven through the real `server_client`, `Tools` and `Terminal`. Add a +case there when a bug crosses module boundaries. Tests that embed a path in JSON must use +`json.dumps(str(path))`, because Windows backslashes are otherwise invalid escapes. -- **The voice sticks** (`VOICE_STICKY`, default on). The server tags one - reply and strips the marker before storing the turn, so it never sees the - id again — "keep talking like that" would send it searching for a voice all - over again, and it'd likely land on a different one. Holding the id - client-side is what makes the rest of the conversation stay in that voice. - An untagged reply therefore never *changes* the voice; only a new - `speak_as`, `VOICE_STICKY=false`, or a reset does. -- **There's a way back.** Since the server was never told Bolt's own voice - id, it can't ask for it back with `speak_as` — so reverting is local: the - tray's **Use default voice** entry (enabled only while a picked voice is - in use, kept in sync by the `voice_changed` signal), a restart, or - `petctl voice reset`, which is what lets Bolt honour "go back to your - normal voice" out loud. That last one needs the server's pet prompt block - (`ai/desk_api.py`, `pet_tools`) to mention the verb, or the model never - emits it — the desk API's prompt is where petctl is advertised. +## Fabricated tool results -### Wake-word detection - -`audio/wake_word.py` uses a custom-trained openWakeWord model, -`thunderbolt.onnx` (ships in the project root), the same way the server -repo's `desk_client/bolt_desk.py` uses `bolt.onnx` for "hey bolt" — same -runtime (openWakeWord, ONNX inference framework), same per-frame -`predict()`/`reset()` loop. Every mic frame is scored; any class score at or -above `WAKE_WORD_THRESHOLD` (default `0.5`, in `.env`) counts as a -detection. Swap `WAKE_MODEL_FILE` to point at a differently-trained `.onnx` -model to change the wake phrase — everything downstream (STT, server call, -TTS) is unaffected. - -**openwakeword's `Model.reset()` is not enough to forget a detection.** It -clears the *prediction* buffer only; the rolling audio window the classifier -actually scores lives in `model.preprocessor` (`raw_data_buffer` — 10s of raw -audio — plus `melspectrogram_buffer` and a ~120-frame `feature_buffer`) and -`AudioFeatures` has no reset method at all. So after a detection the wake -phrase is still in the window, and the next frame fed to the model re-fires on -it. Symptom when this bites: the pet cuts itself off a word into every reply, -because wake-mode barge-in resumes feeding the model and instantly matches the -"thunderbolt" that *started* the turn. `wake_word.hard_reset(model)` restores -the preprocessor to its as-constructed (silence) state and is what both -`listen_for_wake_word` and `WakeWordBargeIn.reset()` call — use it, not -`reset()`, anywhere a detection needs to be genuinely forgotten. The blank -state is cached on the preprocessor object (not in an `id()`-keyed dict — -CPython reuses ids after GC), since rebuilding it costs an ONNX pass over 10s -of silence. - -The threshold is tunable at runtime: the tray's **Wake word tuning…** window -(`ui/wake_tuner.py`) shows the peak score seen and a rolling list of near -misses (frames within `WAKE_NEAR_MISS_MARGIN` *below* the threshold — i.e. -the times it nearly heard you), and its slider is read per frame because -`listen_for_wake_word` accepts a callable threshold. Set the threshold just -under the peak you can hit reliably, then persist it in `.env`. - -### Testing conventions - -`tests/` covers pure logic only (state machine, wake-word scoring loop, mic -VAD, HTTP client against mocks) — nothing there needs real audio hardware or -a display. Modules under test are written to accept fake streams/models/ -`on_command` callables specifically to keep tests hardware-free; follow that -pattern (inject a `Protocol`-typed collaborator) rather than mocking at the -`sounddevice`/`openwakeword` import boundary when adding new testable logic. -`test_controller.py`, `test_controller_features.py`, `test_wander.py` and -`test_pet_window_features.py` need a `QApplication`, which segfaults without -a display unless run with `QT_QPA_PLATFORM=offscreen`. - -Newer subsystems follow the same rule — the testable part is separated from -the part that needs hardware: dbus-monitor output is parsed by a pure -`iter_notifications(lines)`, xprop output by pure `parse_xprop_*` functions, -HTTP chunk reassembly by `chunks_to_int16`, emote motion by -`emote_transform`. Tests that touch the controller monkeypatch -`screen_context.context_for` / `is_fullscreen_active`, otherwise they shell -out to xprop on a headless box. - -**`test_pipeline_smoke.py` is the exception, deliberately.** Unit tests inject -a fake at the seam they care about, and a run of production bugs — notifications -sitting unspoken for minutes, the pet saying things twice, `[laughing]` read -aloud, a device command arriving as prose — got through with every one of them -green, because each was an *interaction* between two individually-correct -units. So that file stands up a real threaded HTTP server on a loopback port, -speaks the desk protocol at it, and drives whole turns through the real -`server_client` (including NDJSON streaming), the real controller and the real -state machine, faking only the mic stream and the speakers. When a bug crosses -a module boundary, add the case there; when it lives inside one module, the -pure-function pattern above is still the cheaper test. It is also worth -mutation-checking a new case — break the source line it is meant to catch and -confirm it actually goes red. +The model sometimes writes tool output as prose instead of emitting a command. The tool marker +syntax in `console.annotate()` is load-bearing: vague wording ("use filectl") measurably caused +this against the live server, and spelling out `command: ...` fixed it. `console.fabricated()` +flags a reply shaped like tool output when `Tools.calls` didn't move, and the next message carries +`CORRECTION`. A polluted session keeps copying its own history, so the remedy is New chat. ## Security notes -The server can relay a shell command back to this machine to execute as the -desktop user (see `server_client.run_local_command`). This is intentional -("full desktop control" for things like "open firefox" or disk checks) and -matches the trust model of the server repo's other desk clients. Keep -`DESK_API_KEY` private and don't expose the desk API port to the open -internet. - -`file_ops.py`'s `filectl` read/write/edit pseudo-commands ride that same -relay and are bound by the same trust model — no path is off-limits beyond -normal filesystem permissions for the desktop user, exactly like a relayed -`cat`/`sed`/`rm` already isn't. They don't grant the server anything a shell -command couldn't already do; they just make the read/write/edit path -reliable instead of relying on the model getting shell quoting right. - -`sudo_askpass.py` widens that further, by design: with `SUDO_ASKPASS_PROMPT` -on (the default), a relayed bare `sudo` is rewritten to `sudo -A` and the -password is collected in a desktop dialog, so commands can escalate to root -instead of hanging on a tty the pet doesn't have. The dialog is the security -boundary — it's the only thing between the server deciding to run `sudo` and -it running, so the prompt is deliberately not suppressible per-command and -those commands get their own longer timeout (`SUDO_COMMAND_TIMEOUT_SECONDS`) -rather than being made non-interactive. Set `SUDO_ASKPASS_PROMPT=false` to -take the capability away entirely; sudo commands then fail. Note that -`sudo -n` / `sudo -A` / `sudo -u …` in a relayed command are never rewritten, -so an explicit non-interactive sudo stays non-interactive. - -**Don't run the pet as root.** It needs no privileges of its own, PortAudio -can't reach the user's PipeWire socket from a root session (raw ALSA devices -reject the 16 kHz capture rate — `paInvalidSampleRate`), and every relayed -command would run unconstrained. - -Several features widen what leaves this machine, all switchable in `.env`: -`SCREEN_CONTEXT` appends the focused window's *title* to each utterance -(titles often contain file paths, document names, or subject lines), -`MONITOR_CONTEXT` appends the screen layout (sizes and names only — no -contents), and `NOTIFICATION_BRIDGE` (off by default) forwards matching -desktop notifications to the server. None of those send screenshots or -notification contents you haven't matched with `NOTIFICATION_FILTER`. - -`SCREEN_TEXT` is the biggest of them: `petctl read` OCRs a whole monitor and -sends the recognised text to the server — everything visible, not just the -focused window. Two things keep it honest. It's **pull-only**: no capture -happens unless the server explicitly asks, so it can't leak in the background -the way a per-turn annotation would, and each read is logged. And it is -strictly *not* a new capability — the shell relay could already run a -screenshot tool and pipe it through OCR — it just makes a thing the trust -model already allowed reliable, bounded (`SCREEN_TEXT_MAX_CHARS`) and -visible. It is nonetheless far easier to reach for than the shell route, so -if that trade isn't one you want, `SCREEN_TEXT=false` removes it and -`petctl read` starts reporting that it's disabled. Capture is `mss`-based and -therefore silently unavailable on Wayland. +Approved shell commands, writes and edits run as the user with no sandbox. `auto` mode removes the +prompt. That is the same "full desktop control" trust model as the server's other desk clients, +which is why `ask` is the default here. Keep `DESK_API_KEY` private. diff --git a/README.md b/README.md index eba3e18..9a5b293 100644 --- a/README.md +++ b/README.md @@ -1,250 +1,122 @@ -# Bolt Desktop Pet +# Bolt Console -A little animated pet that lives on your desktop and is just a face on top -of your Bolt server — same brain, memory, tools, and persona as Discord -chat and the Linux desk client. It talks to `ai/desk_api.py` on the server -exactly the way `desk_client/bolt_desk.py` does — including speech: STT and -TTS are the server's own `/desk/stt` and `/desk/tts`, the same endpoints the -Android app uses, so there's no separate Deepgram or ElevenLabs account to -set up for the pet to talk. Playback is cross-platform (no `mpv`/`ffplay`/ -`espeak-ng` subprocess calls — pure `sounddevice`). +A desktop app for Bolt, in the style of Claude Code (with a terminal version too). You type, Bolt +answers, and when he needs to look at or change something on this machine you see each step as it +happens: reading files, listing folders, editing, writing, running commands. Changes wait for your +**Allow** on the step itself, and edits show a diff first. + +It talks to the same Bolt server as Bolt-Pet, the Discord bot and the phone app, so it has the same +brain, memory, tools and persona. What it drops is the desktop pet around it: no sprite, no wake +word, no microphone, no speech, no screen reading. + +The window is PySide6 with its own dark theme. Header: working folder (click to change) and an +**Ask first / Auto** switch. Below that: the conversation, with each tool call as a card that you can +click to expand, and a composer at the bottom (Enter sends, Shift+Enter adds a new line, Ctrl+L +clears, Ctrl+O picks a folder). + +**Chats** (Ctrl+K) lists every conversation you've had with Bolt, from any device (console, pet, +phone), newest first. Open one to continue it. **New chat** (Ctrl+N, or the button in the Chats +dialog) asks the server for a fresh chat: new conversation history, but Bolt still remembers you, +because his memory belongs to you, not to a chat. Switching clears the window's transcript, since +the server has no endpoint for an old chat's messages, although Bolt still has them. The current +chat is remembered across restarts. The terminal version has `/chats`, `/chat ` and `/new`. + +**Dictation:** the mic button next to Send (or Ctrl+M) records you and types what you say into the box +as you talk. Click again or pause for 3 seconds to stop, and Esc cancels. The text isn't sent until you +press Enter, so you can fix it first. It uses the same speech-to-text as Bolt-Pet: the Bolt server's +`/desk/stt` relay to Deepgram, so you don't need a Deepgram account. The server does count it against +your credits like a chat turn. `MIC_DEVICE` in `.env` picks the microphone. + +The terminal version (`run-cli.bat` / `./run.sh --cli`) looks like this: ``` -mic → wake-phrase spotter ("thunderbolt") / hotkey / click → record utterance - → server-hosted STT (+ the focused window's title, for "what's this - error?") → POST /desk/converse on your Bolt server → [server may relay - a shell command back to run on THIS machine, or a `petctl` command - that moves or emotes the pet] → reply → server-hosted streaming TTS - → speakers - → shown in a speech bubble + the pet's sprite state (idle/listening/ - thinking/talking) updates the whole time +> what's taking up space in this repo? + +● List(C:\work\site, recursive) + ⎿ assets/ + assets/hero.mp4 48211332B + ... +212 lines +● Shell(du -sh assets/* | sort -h | tail -5) + Run this command? [y]es / [n]o / [a]lways this session y + ⎿ 48M assets/hero.mp4 + ... +● Most of it is one file: assets/hero.mp4 at 48 MB. ... ``` -Nothing is sent to the server until the wake phrase fires, you press the -push-to-talk hotkey, or you click the pet — plus, if you turn them on, the -heartbeat and the desktop-notification bridge. - -## Why a separate project instead of living in the tmn-api repo - -This runs on your desktop machine, not the server — same relationship as -`desk_client/` (Linux) or the Android app, both of which are just clients of -the desk API over HTTP. It has no import dependency on the server repo at -all, so it can be copied anywhere and configured with its own `.env`. - ## Setup -1. Copy this whole `bolt-pet/` folder to the machine you want the pet to run - on (if that isn't already this machine). -2. `cp .env.example .env` and fill in: - - `BOLT_SERVER_URL` + `DESK_API_KEY` — same as `desk_client/.env` on the - server side. Use the server's master `DESK_API_KEY`, or mint yourself a - personal one via the desk-only `api_key_generate` marker (see the main - repo's `CLAUDE.md` → "Per-user API keys"). That's it for STT — no - Deepgram account needed, it goes through the server's own `/desk/stt`. - - `ELEVENLABS_VOICE_ID` for TTS (optional — falls back to offline TTS via - `pyttsx3` if omitted or if the server call fails). No local ElevenLabs - API key needed for this either; `ELEVENLABS_API_KEY` is only for the - multi-voice `dialoguectl` scenes further down, the one feature the - server has no endpoint for. -3. Run it: - - macOS/Linux: `./run.sh` - - Windows: `run.bat` +```bash +cp .env.example .env # set BOLT_SERVER_URL and DESK_API_KEY +run.bat # Windows: the window +run-cli.bat # Windows: the terminal version +./run.sh [--cli] # macOS/Linux +``` - Both scripts create a local `.venv` and install `requirements.txt` on - first run. On Linux you'll also need system packages for audio: - `sudo apt install libportaudio2 espeak-ng`. - -The pet appears near the bottom-right of your screen. It wanders off on its -own now and then; drag it anywhere and it tucks itself flush against a nearby -screen edge. That position isn't saved across restarts (see Known -limitations). - -## Talking to it - -- Say **"thunderbolt"** — detected fully on-device by a custom-trained - openWakeWord model (`thunderbolt.onnx`, ships in the project root), the - same way the server repo's `desk_client/bolt_desk.py` detects "hey bolt" - with `bolt.onnx`. Matches the `DEFAULT_WAKE_WORD` already used for Bolt's - Discord voice channels, so it's the same word everywhere. -- Or press **Ctrl+Alt+Space** (`PUSH_TO_TALK_HOTKEY`) from anywhere — useful - in a noisy room where the wake word misfires. Needs `pynput` and a session - that allows global key hooks; most Wayland sessions don't, in which case it - logs why at startup and everything else still works. -- Or just **click the pet** once (a drag doesn't count as a click). -- **Talk over it** to cut a long answer short — the mic stays live while it - speaks, and barging in starts your next turn immediately (`BARGE_IN`). -- Right-click the tray icon for **Talk now**, **Mute mic**, **Nap**, - **Wander around**, **Click through the pet**, **History…**, **Wake word - tuning…**, **Use default voice** and **Quit** — the pet window itself has - no title bar or taskbar entry. -- **Click the speech bubble** to copy what it just said; the tray's - **History…** window keeps the last `HISTORY_LIMIT` turns. - -## What it does on its own - -- **Wanders** the desktop while idle (`PET_WANDER`), stands still while - listening/thinking/talking or while a bubble is up. -- **Moves and emotes on command.** Bolt can relay `petctl move top-left`, - `petctl emote wave|hop|spin|nod|shake`, `petctl say ...`, `petctl wander - on|off`, `petctl nap on|off`, `petctl voice reset`, and `dialoguectl` for a - multi-voice scene. These are intercepted here and never reach a shell. -- **Naps** during `QUIET_HOURS` (e.g. `23:00-08:00`) or while a fullscreen - app is focused (`DND_ON_FULLSCREEN`) — it dims, stops wandering, and makes - no proactive noise. It still answers when you speak to it. -- **Reacts to desktop notifications** if you turn on `NOTIFICATION_BRIDGE` - (Linux/D-Bus) and set a `NOTIFICATION_FILTER` regex — matching - notifications get forwarded to the server, so it can tell you the deploy - went green. Off by default: each one costs a round trip. - -## Speaking in another voice - -Ask for a different voice — "use a clearer voice", "talk like a pirate", "say -that in Japanese" — and Bolt searches the ElevenLabs voice library on the -server, picks one, and tags his reply with it (`speak_as`); the pet is what -actually speaks in it. A Voice Library pick is added to your ElevenLabs -account automatically the first time it's used. The server also synthesizes -with one fixed, multilingual-capable model for every request now (not a -flash/multilingual switch per reply) — nothing to configure on this side, -and non-English text or a picked voice no longer needs special-casing here. - -The new voice **stays on** for the rest of the conversation, because the -server tags a single reply and doesn't remember which voice it chose — so -"keep talking like that" would otherwise send it hunting for a voice again. -To get his own voice back: ask him ("use your normal voice" — he relays -`petctl voice reset`), use **Use default voice** in the tray menu (greyed -out unless a picked voice is active), or restart the pet. Set -`VOICE_STICKY=false` in `.env` if you'd rather each pick lasted exactly one -reply. - -## Multi-voice dialogue - -Ask for a scene — "do the argument between the two of them", "read that back -as a radio play" — and Bolt can relay a `dialoguectl` command that the pet -renders through ElevenLabs' Text to Dialogue endpoint: several voices in one -take, with delivery tags the v3 model acts on (`[cheerfully]`, `[whispering]`, -`[stuttering]`). One request per scene, so the voices actually react to each -other instead of sounding like clips glued together. - -Name the cast in `.env` (`DIALOGUE_VOICES=narrator:9BWts…,villain:IKne3…`); -the name `self` always means whatever voice the pet is currently using, so -Bolt sounds like himself in his own scenes — including after a `speak_as` -switch. Scenes show up in the speech bubble with the tags stripped, count as -normal speech for the transcript, and can be talked over like any other reply. -`DIALOGUE=false` turns the whole thing off on this device. - -## Wake-word detection - -`bolt_pet/audio/wake_word.py` feeds every mic frame into `thunderbolt.onnx` -via the openWakeWord runtime (ONNX inference) and treats any class score at -or above `WAKE_WORD_THRESHOLD` (default `0.5`) as a detection — the exact -same per-frame `predict()`/`reset()` pattern as `desk_client/bolt_desk.py`'s -main loop. Point `WAKE_MODEL_FILE` in `.env` at a different `.onnx` model to -change the wake phrase later without touching any other code. - -If it keeps ignoring you (or firing at the TV), open **Wake word tuning…** -from the tray: it shows the peak score while you talk and a rolling list of -near misses — frames that scored just under the threshold — and the slider -takes effect immediately, mid-listen. Set the threshold just below the peak -you can hit reliably, then write it into `.env` as `WAKE_WORD_THRESHOLD`. - -## Something not working? +The launchers create `.venv` on first run. Dependencies: `requests`, plus `PySide6` for the window +(the terminal version doesn't import Qt). Bolt works in the +directory you **launch from**, so you can put the launcher on your PATH and start it inside whatever +project you want him to work on. ```bash -python -m bolt_pet --doctor +run.bat --auto # don't ask before commands, writes and edits +run-cli.bat -p "summarise README.md" # one message, print the reply, exit +run-cli.bat --doctor [--deep] # check the install (--deep contacts the server) ``` -Checks the things that make the pet look broken in ways that don't point at -themselves — missing server config (the controller exits its thread at startup, -so the pet appears alive and simply never answers), no input device, no OCR -engine behind `petctl read`, a wake model that isn't where `.env` says, a -silence timeout long enough to feel like lag. Each line says what to do about -it, not just what's wrong. It doesn't touch the network or open the microphone -unless you add `--deep`, so it's safe to run when the network is the suspect. +## Using the terminal version -## Little things +| Input | What it does | +|---|---| +| any text | sent to Bolt | +| `!` | runs a shell command yourself. Nothing is sent to Bolt | +| `/cd `, `/pwd` | change or show the working directory | +| `/ask`, `/auto` | turn approval prompts on or off for this session | +| `/status` | server, session, approval mode, working directory | +| `/files` | download anything Bolt has queued for you | +| `/new` | start a fresh chat (same as New chat in the window) | +| `/chats`, `/chat ` | list your chats from every device; switch to number n (or a session id) | +| `/clear` | clear the screen. Bolt's memory lives on the server and is kept | +| `/help`, `/exit` | | +| line ending in `\` | keep typing on the next line | -The pet **remembers where you left it** — drag it somewhere deliberate and -that's where it starts next time. If that position is on a monitor you've since -unplugged it goes back to the default corner rather than restoring itself -somewhere off-screen. `PET_REMEMBER_POSITION=false` to always start in the -corner. +Ctrl+C stops the spinner and gives the prompt back. The server may still finish that turn on its +side. At an approval prompt, Ctrl+C means no. -Its **mouth moves with the actual audio** rather than flapping on a timer: the -PCM going to the speakers is reduced to a loudness per frame and that picks the -talking sprite, so the pet shuts up when the voice pauses. Offline `pyttsx3` -playback has no waveform to follow, so it falls back to the timed loop. +## How Bolt reaches this machine -## Project layout +The desk API has one tool channel: the server sends back a `command` string, the client runs it and +posts the output to `/desk/tool_result`, and this repeats until a final reply. The console decides +what each string means (`bolt_console/tools.py`): -``` -bolt_pet/ - config.py .env loading (same pattern as desk_client/bolt_desk.py) - state.py PetState enum + a small transition-checked state machine - server_client.py /desk/converse, /desk/tool_result, /desk/report_status - controller.py the pipeline: wake word -> STT -> server -> TTS, on a QThread - speech.py what the pet says and how each kind of saying behaves - speech_text.py strips markdown/emoji/URLs so the voice never says "asterisk" - pet_actions.py petctl move/emote/say/wander/nap parsing - screen_context.py active-window title + fullscreen detection - quiet.py quiet-hours schedule - notifications.py desktop notification bridge (Linux/D-Bus) - history.py rolling conversation transcript - hotkey.py global push-to-talk (pynput, optional) - window_state.py remembers where you left the pet - doctor.py `--doctor` preflight: is this install going to work? - audio/ - mic.py input stream + energy-based VAD utterance capture - wake_word.py openWakeWord thunderbolt.onnx detection (see above) - stt.py server /desk/stt relay (one-shot: whole utterance at once) - stt_stream.py server /desk/stt live websocket — transcribes while you speak - tts.py server /desk/tts streaming PCM, offline pyttsx3 fallback, - plus the loudness envelope that drives the mouth - barge_in.py "you started talking" detector, to cut playback short - ui/ - app.py wires QApplication + window + tray + controller thread together - pet_window.py frameless/translucent/always-on-top sprite window + speech bubble - sprite.py frame animation loader (see assets/sprites/README.md) - tray.py system tray menu - history_window.py conversation scrollback (copyable) - wake_tuner.py live wake-word threshold + near-miss log - assets/sprites/ Kenney robot-pack art (CC0) — see assets/sprites/README.md -scripts/ - slice_spritesheet.py cuts a grid sprite sheet into the per-frame convention -tests/ pure-logic unit tests (state machine, wake-phrase - matching, HTTP client against mocks) plus one - end-to-end smoke test that drives whole turns against - a real local HTTP server — nothing here needs real - audio hardware or a display +- **`filectl {json}`**: list, read, write or edit a file (`file_ops.py`). This is what makes file + editing reliable, because the model never has to hand-quote a shell heredoc. Edits must match + exactly once. Common JSON slips, such as a raw `C:\Users\...` path, are repaired and the model is + told about the repair. +- **`petctl …` / `dialoguectl …`**: the pet's body and voice. These are not run. Bolt is told + there's no pet here. +- **anything else**: a real shell command, run in the working directory, with a + `COMMAND_TIMEOUT_SECONDS` limit (default 120s). + +Reads and listings never ask. Shell commands, writes and edits ask first in `ask` mode (the +default). Edits show a diff before you answer. + +## Security + +Anything you approve runs as your user, with your permissions. In `auto` mode that means anything +the server asks for. Keep `DESK_API_KEY` private and don't expose the desk API to the open internet. +Each message includes your working directory and OS name (`CONSOLE_CONTEXT=false` to stop that). + +## Development + +```bash +QT_QPA_PLATFORM=offscreen .venv/bin/pytest tests/ # Windows: .venv\Scripts\pytest tests ``` -## Security notes +`tests/test_gui.py` drives turns through the real window and its worker thread against the same +loopback server. -Same as `desk_client/bolt_desk.py`: the server can relay a shell command -back to this machine ("full desktop control" — "open firefox", "how full is -my disk", etc.), which this client executes as your desktop user with a -30-second timeout (`COMMAND_TIMEOUT_SECONDS`). That's the same trust model -as the Linux desk client and the Android app — commands only ever originate -from your own voice/click requests in your own session. Keep `DESK_API_KEY` -private; don't expose the desk API port to the open internet. +`tests/test_console.py` runs whole turns against a real HTTP server on a loopback port. Only the +keyboard is faked. Everything else takes its collaborators as arguments, so no test needs a real +terminal or server. -`petctl` commands (move/emote/say/wander/nap) are handled inside the pet and -never reach a shell, so that channel can't run anything. - -Two features widen what leaves this machine, both off-switchable in `.env`: -`SCREEN_CONTEXT=true` (default) appends the focused window's *title* to what -you say — titles often contain file paths, document names or email subjects — -and `NOTIFICATION_BRIDGE=false` (default) can forward matching desktop -notifications. No screenshots or images are ever sent. - -## Known limitations / not-yet-done - -- Wandering is a straight walk to a random point — no Shimeji-style physics, - wall-climbing or falling. -- Push-to-talk and the notification bridge are platform-limited: the hotkey - needs a session that allows global key hooks (most Wayland setups don't), - and the notification bridge is Linux/D-Bus only. -- Barge-in listens through the same mic that hears the pet's own voice. It - wants headphones or a decent gap between speaker and mic; if playback - interrupts itself, raise `BARGE_IN_RMS_THRESHOLD` or set `BARGE_IN=false`. -- Screen context is the window *title* only — the desk API takes text, so - there's no screenshot understanding. +This project started as a copy of Bolt-Pet. See that repo for the voice/desktop-pet version. diff --git a/bolt_console/__init__.py b/bolt_console/__init__.py new file mode 100644 index 0000000..3f7b198 --- /dev/null +++ b/bolt_console/__init__.py @@ -0,0 +1,3 @@ +"""Bolt Console — a terminal client for Bolt's desk API.""" + +__version__ = "0.1.0" diff --git a/bolt_console/__main__.py b/bolt_console/__main__.py new file mode 100644 index 0000000..06682cb --- /dev/null +++ b/bolt_console/__main__.py @@ -0,0 +1,32 @@ +"""Entry point. + + python -m bolt_console the window + python -m bolt_console --cli the terminal REPL + python -m bolt_console -p MESSAGE one message in the terminal, then exit + python -m bolt_console --doctor [--deep] + (--auto works with the window and the terminal) +""" + +import sys + + +def main(argv=None): + args = list(sys.argv[1:] if argv is None else argv) + + if "--doctor" in args: + from bolt_console.doctor import main as doctor + + return doctor(args) + + if "--cli" in args or "-p" in args or "-h" in args or "--help" in args: + from bolt_console.console import run + + return run([a for a in args if a != "--cli"]) + + from bolt_console.gui.window import run + + return run(args) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/bolt_console/chats.py b/bolt_console/chats.py new file mode 100644 index 0000000..1abbbc9 --- /dev/null +++ b/bolt_console/chats.py @@ -0,0 +1,53 @@ +"""Chat-list helpers with no Qt, shared by the Chats dialog and the +terminal's /chats: turning what GET /desk/chats returns into readable rows.""" + +from __future__ import annotations + +import re +from datetime import datetime, timezone +from typing import Optional + +# Context notes clients append to what you typed. They're in the stored last +# message, but they aren't what you said, so they stay out of the preview. +# The preview may be cut off mid-note, so an unclosed one runs to the end. +_CONTEXT_NOTES = re.compile(r"\s*\[(?:Bolt Console:|on screen right now:)[^\]]*(?:\]|$)", re.IGNORECASE) + + +def clean_preview(text: str, limit: int = 110) -> str: + text = " ".join(_CONTEXT_NOTES.sub("", str(text or "")).split()) + return text if len(text) <= limit else text[: limit - 1].rstrip() + "…" + + +def relative_time(iso: str, now: Optional[datetime] = None) -> str: + """'just now', '12 min ago', '3 h ago', 'yesterday', '4 days ago', or a + date. Unparseable input comes back as it was.""" + try: + then = datetime.fromisoformat(str(iso).replace("Z", "+00:00")) + except (TypeError, ValueError): + return str(iso or "") + if then.tzinfo is None: + then = then.replace(tzinfo=timezone.utc) + now = now or datetime.now(timezone.utc) + seconds = (now - then).total_seconds() + if seconds < 60: + return "just now" + if seconds < 3600: + return f"{int(seconds // 60)} min ago" + if seconds < 86400: + return f"{int(seconds // 3600)} h ago" + days = int(seconds // 86400) + if days == 1: + return "yesterday" + if days < 7: + return f"{days} days ago" + return then.astimezone().strftime("%d %b %Y").lstrip("0") + + +def local_time(iso: str) -> str: + try: + then = datetime.fromisoformat(str(iso).replace("Z", "+00:00")) + if then.tzinfo is None: + then = then.replace(tzinfo=timezone.utc) + return then.astimezone().strftime("%A %d %B %Y, %H:%M") + except (TypeError, ValueError): + return str(iso or "") diff --git a/bolt_console/config.py b/bolt_console/config.py new file mode 100644 index 0000000..b4aefd1 --- /dev/null +++ b/bolt_console/config.py @@ -0,0 +1,162 @@ +"""Environment configuration for Bolt Console. + +Same lightweight ".env next to the script" pattern as desk_client/bolt_desk.py +in the main tmn-api repo, so this project can be copied anywhere (it does not +import anything from that repo) and configured the same way. +""" + +from __future__ import annotations + +import os +import platform +import sys +from pathlib import Path + +if getattr(sys, "frozen", False): + # PyInstaller build: the .env sits next to the .exe, not inside the bundle. + HERE = Path(sys.executable).resolve().parent +else: + HERE = Path(__file__).resolve().parent.parent # project root (one above bolt_console/) + + +def _load_env() -> None: + env_path = HERE / ".env" + if not env_path.exists(): + return + for line in env_path.read_text(encoding="utf-8").splitlines(): + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, _, value = line.partition("=") + os.environ.setdefault(key.strip(), value.strip()) + + +_load_env() + + +def _flag(name: str, default: str) -> bool: + return os.environ.get(name, default).lower() in ("1", "true", "yes", "on") + + +def _node_name() -> str: + try: + return platform.node() or "desktop" + except Exception: + return "desktop" + + +# ── server / identity ─────────────────────────────────────────────────────── +# The session id keeps the "pet-" prefix on purpose: the desk API advertises +# the `filectl` read/write/edit tool to pet sessions, and this console is +# built around that tool. "-console" keeps it a separate conversation from a +# Bolt-Pet running on the same machine. + +SERVER_URL = os.environ.get("BOLT_SERVER_URL", "").rstrip("/") +API_KEY = os.environ.get("DESK_API_KEY", "") +# +# "New chat" (GUI) / /new (terminal) rotates to a fresh id, remembered in +# STATE_FILE so a restart carries on the same conversation. DESK_SESSION_ID in +# .env pins the session and takes precedence over the remembered one. + +STATE_FILE = Path.home() / ".cache" / "bolt-console" / "session.json" +_BASE_SESSION = "pet-console-" + _node_name() + + +def _remembered_session() -> str: + try: + import json + + value = json.loads(STATE_FILE.read_text(encoding="utf-8")).get("session_id") + return str(value) if value else "" + except Exception: + return "" # missing or corrupt state means the default, never a crash + + +SESSION_ID = os.environ.get("DESK_SESSION_ID") or _remembered_session() or _BASE_SESSION + + +def set_session(session_id: str) -> str: + """Make *session_id* the current conversation and remember it for the + next start. A plain reassignment of the module attribute every + server_client call reads; not locked against an in-flight request, which + is fine for a rare, deliberate user action (the window blocks switching + while a turn is running anyway).""" + import json + + global SESSION_ID + SESSION_ID = session_id + try: + STATE_FILE.parent.mkdir(parents=True, exist_ok=True) + STATE_FILE.write_text(json.dumps({"session_id": SESSION_ID}), encoding="utf-8") + except OSError: + pass # switched for this run; it just won't survive a restart + return SESSION_ID + + +def new_session() -> str: + """A locally made fresh id — the fallback when the server can't mint one + (POST /desk/chats is the normal route; see Session.new_conversation).""" + import time + + return set_session(f"{_BASE_SESSION}-{time.strftime('%Y%m%d-%H%M%S')}") + + +def session_pinned() -> bool: + return bool(os.environ.get("DESK_SESSION_ID")) + +# ── relayed commands ──────────────────────────────────────────────────────── + +COMMAND_TIMEOUT_SECONDS = int(os.environ.get("COMMAND_TIMEOUT_SECONDS", "120")) + +# What happens when Bolt wants to change something on this machine: +# ask — shell commands and file writes/edits wait for y/n at the prompt +# (reads and directory listings never ask). +# auto — everything runs without asking, like the pet did. +# Toggle for the current session with /auto and /ask. +APPROVAL_MODE = os.environ.get("APPROVAL_MODE", "ask").strip().lower() + +# ── replies ───────────────────────────────────────────────────────────────── +# Print each sentence as the server produces it instead of waiting out the +# whole model call. Falls back to the ordinary request/response path when the +# stream fails before anything was printed. +STREAMING_REPLIES = _flag("STREAMING_REPLIES", "true") + +# Append a one-line note (working directory, OS) to each message, so "what's +# in this folder?" has a referent and Bolt knows there's no pet body here. +CONSOLE_CONTEXT = _flag("CONSOLE_CONTEXT", "true") + +# ANSI colour. Also off when NO_COLOR is set or stdout isn't a terminal. +COLOR = _flag("COLOR", "true") and "NO_COLOR" not in os.environ + +# ── file delivery ─────────────────────────────────────────────────────────── +# The server's deliver_files tool (ai/desk_api.py in the main tmn-api repo) +# queues files on this session — e.g. "send me that report" — for the client +# to fetch via GET /desk/files. Downloading a file dequeues it server-side, so +# each one lands here exactly once. + +RECEIVE_FILES = _flag("RECEIVE_FILES", "true") +DELIVERED_FILES_DIR = Path( + os.environ.get("DELIVERED_FILES_DIR") or str(Path.home() / "Downloads" / "Bolt") +).expanduser() + + +# ── dictation (the mic button) ────────────────────────────────────────────── +# Speech goes to the server's /desk/stt relay, like Bolt-Pet. MIC_DEVICE is an +# input index or name substring (blank = system default); a recording stops +# itself after DICTATION_SILENCE_SECONDS of quiet once you've said something. + +MIC_DEVICE = os.environ.get("MIC_DEVICE", "") +DICTATION_SILENCE_SECONDS = float(os.environ.get("DICTATION_SILENCE_SECONDS", "3")) + + +def is_configured() -> bool: + return bool(SERVER_URL and API_KEY) + + +def missing_config() -> list: + missing = [] + if not SERVER_URL: + missing.append("BOLT_SERVER_URL") + if not API_KEY: + missing.append("DESK_API_KEY") + return missing diff --git a/bolt_console/console.py b/bolt_console/console.py new file mode 100644 index 0000000..d60fd4b --- /dev/null +++ b/bolt_console/console.py @@ -0,0 +1,398 @@ +"""The REPL: read a line, send it to Bolt, show the tool calls and the reply. + +`Session` holds everything a turn needs and takes its collaborators as +arguments (the terminal, the tool dispatcher, the two converse functions), so +tests drive whole turns without a real server or a real terminal. +""" + +from __future__ import annotations + +import os +import platform +import re +import subprocess +import sys +from pathlib import Path +from typing import Callable, Optional + +from . import __version__, config, file_delivery, server_client +from .chats import clean_preview, relative_time +from .tools import Tools +from .ui import Terminal, enable_windows_ansi + +HELP = """\ +Commands + /help this list + /exit, /quit leave (Ctrl+D / Ctrl+Z also works) + /clear clear the screen (Bolt's memory lives on the server and is kept) + /cd change the working directory commands and relative paths use + /pwd show the working directory + /ask ask before shell commands, writes and edits (default) + /auto run everything without asking + /status server, session and settings + /files download anything Bolt has queued for you now + /new start a fresh chat (new history; Bolt still remembers you) + /chats list your chats with Bolt, from every device + /chat switch to chat number n from /chats, or a session id + ! run a shell command yourself, without sending it to Bolt + +End a line with \\ to keep typing on the next one.""" + + +def _os_label() -> str: + try: + return f"{platform.system()} {platform.release()}".strip() or "unknown OS" + except Exception: + return "unknown OS" + + +def annotate(text: str, cwd: str, os_label: str) -> str: + """The context note appended to each message: where "here" is, and that + this client is text-only.""" + # The exact marker syntax matters. Measured against the live server + # (2026-09-25): "use filectl and shell commands" made the model write + # `filectl: {...}` or a block as prose and then invent the + # output, on both /desk/converse and /desk/converse_stream; spelling out + # `command: ...` got a real relayed command on both. + return ( + f"{text}\n\n[Bolt Console: a text terminal on {os_label}; working directory {cwd}. " + "To act on this machine, use your command tool: " + '`command: filectl {"op": "list", "path": "..."}` for files (ops: list, read, write, edit), ' + "or `command: ` for anything else. The console runs it and sends back " + "the real output. Never write a command's output yourself. " + "There is no pet body, voice or screen here.]" + ) + + +# What a reply looks like when it narrates tool output instead of calling a +# tool: the model copies the shape of real results it has seen earlier in the +# conversation ("→ Command result: [filectl] wrote 1200 chars to …"). Seen +# live: three turns in a row of "wrote the files" with no command ever sent. +_FAKE_RESULT = re.compile( + r"(?:→|->)\s*(?:command|deliver files|tool)\s+result\s*:" + r"|\[filectl\]\s+(?:wrote|edited)\b" + r"|\[exit \d+\]", + re.IGNORECASE, +) + +FABRICATION_WARNING = ( + "Bolt described running commands, but none reached this app, so nothing was run " + "or written. The results in that reply are made up. Bolt will be told on your next message." +) + +CORRECTION = ( + "[Bolt Console: correction. Your previous reply described command results, but no " + "command reached this machine, so nothing ran and no files were written. Never write " + "results yourself; emit the actual command and wait for its real output.]" +) + + +def fabricated(text: str, commands_run: int) -> bool: + """A reply that reads like tool output when no tool ran this turn.""" + return commands_run == 0 and bool(_FAKE_RESULT.search(text or "")) + + +class Session: + def __init__( + self, + ui: Terminal, + tools: Optional[Tools] = None, + converse: Callable = server_client.converse, + converse_stream: Optional[Callable] = server_client.converse_stream, + list_files: Callable = server_client.list_outbox_files, + download_file: Callable = server_client.download_outbox_file, + create_chat: Callable = server_client.create_chat, + list_chats: Callable = server_client.list_chats, + ): + self.ui = ui + self.tools = tools or Tools(ui, mode=config.APPROVAL_MODE) + self._converse = converse + self._converse_stream = converse_stream if config.STREAMING_REPLIES else None + self._list_files = list_files + self._download_file = download_file + self._create_chat = create_chat + self._list_chats = list_chats + self._last_chats = [] # what /chats showed, so /chat 3 means that row + self._said = [] # sentences shown this turn + self._correct_next = False # tell Bolt his last reply was fiction + + # ── one turn ───────────────────────────────────────────────────────── + + def send(self, text: str) -> None: + message = annotate(text, os.getcwd(), _os_label()) if config.CONSOLE_CONTEXT else text + if self._correct_next: + message = f"{CORRECTION}\n\n{message}" + self._correct_next = False + self._said = [] + calls_before = self.tools.calls + self.ui.start_spinner() + try: + reply = self._ask(message) + except server_client.ServerError as exc: + self.ui.stop_spinner() + self.ui.error(f"Error: {exc}") + return + except KeyboardInterrupt: + self.ui.stop_spinner() + self.ui.warn("Interrupted. (The server may still finish that turn on its side.)") + return + self.ui.stop_spinner() + if reply.streamed: + self.ui.end_reply() + else: + self.ui.assistant(reply.text) + shown = " ".join(self._said + [reply.text]) + if fabricated(shown, self.tools.calls - calls_before): + self.ui.warn(FABRICATION_WARNING) + self._correct_next = True + self.check_deliveries() + + def _ask(self, message: str) -> server_client.Reply: + if self._converse_stream is not None: + try: + return self._converse_stream(message, on_say=self._say, on_command=self.tools) + except server_client.ServerError: + pass # nothing was shown yet; the plain path is invisible to the user + return self._converse(message, on_command=self.tools, on_say=self._holding) + + def _say(self, sentence: str) -> None: + self._said.append(sentence) + self.ui.assistant_chunk(sentence) + + def _holding(self, line: str) -> None: + self._said.append(line) + self.ui.holding(line) + + # ── delivered files ────────────────────────────────────────────────── + + def check_deliveries(self, announce_empty: bool = False) -> None: + if not config.RECEIVE_FILES: + if announce_empty: + self.ui.info("File delivery is off (RECEIVE_FILES=false).") + return + try: + queued = self._list_files() + except server_client.ServerError as exc: + self.ui.warn(f"Couldn't check for delivered files: {exc}") + return + if not queued and announce_empty: + self.ui.info("Nothing queued.") + for entry in queued: + file_id, name = str(entry.get("id") or ""), str(entry.get("name") or "") + if not file_id: + continue + try: + data = self._download_file(file_id) + path = file_delivery.save(config.DELIVERED_FILES_DIR, name, data) + except (server_client.ServerError, OSError) as exc: + self.ui.warn(f"Couldn't save delivered file {name!r}: {exc}") + continue + self.ui.line(self.ui.style(f"Received {name} -> {path}", "green")) + + # ── input dispatch ─────────────────────────────────────────────────── + + def handle(self, line: str) -> bool: + """Act on one line of input. Returns False when the user asked to leave.""" + text = line.strip() + if not text: + return True + if text.startswith("!"): + self._local_shell(text[1:].strip()) + return True + if text.startswith("/"): + return self._slash(text) + self.ui.line() + self.send(text) + return True + + def _local_shell(self, command: str) -> None: + if not command: + return + # The user's own command: live output, their terminal, nothing captured + # and nothing sent to Bolt. + try: + code = subprocess.run(command, shell=True, cwd=os.getcwd()).returncode + except KeyboardInterrupt: + self.ui.warn("Interrupted.") + return + if code: + self.ui.info(f"[exit {code}]") + + def _slash(self, text: str) -> bool: + name, _, arg = text[1:].partition(" ") + name, arg = name.lower(), arg.strip() + if name in ("exit", "quit", "q"): + return False + if name == "help": + self.ui.line(HELP) + elif name == "clear": + self.ui.line("\033[2J\033[H" if self.ui.color else "\n" * 3) + elif name == "cd": + self._cd(arg) + elif name == "pwd": + self.ui.line(os.getcwd()) + elif name in ("auto", "ask"): + self.tools.mode = name + self.ui.info( + "Auto mode: commands, writes and edits run without asking." + if name == "auto" else "Ask mode: commands, writes and edits need your OK." + ) + elif name == "status": + self.status() + elif name == "new": + self.ui.info(self.new_conversation()) + elif name == "chats": + self.show_chats() + elif name == "chat": + self.pick_chat(arg) + elif name == "files": + self.check_deliveries(announce_empty=True) + else: + self.ui.warn(f"Unknown command /{name} - try /help.") + return True + + def switch_to(self, session_id: str) -> str: + """Make *session_id* the current chat (new or picked from the list). + Server-side history follows the id; Bolt's memory is per user, so it + carries over either way. Returns a one-line note for the UI.""" + config.set_session(session_id) + self._correct_next = False # a correction belongs to the chat it was about + note = f"Chat: {session_id}" + if config.session_pinned(): + note += " (DESK_SESSION_ID in .env pins a different one after a restart)" + return note + + def show_chats(self) -> None: + try: + self._last_chats = self._list_chats() + except server_client.ServerError as exc: + self.ui.error(f"Error: {exc}") + return + if not self._last_chats: + self.ui.info("No chats yet.") + return + for number, chat in enumerate(self._last_chats, 1): + session_id = str(chat.get("session_id") or "") + mark = self.ui.style("*", "green") if session_id == config.SESSION_ID else " " + label = str(chat.get("label") or session_id) + when = relative_time(chat.get("last_active", "")) + self.ui.line(f"{mark}{number:>3} {self.ui.style(label, 'bold')} {self.ui.style(when, 'dim')}") + preview = clean_preview(chat.get("last_message", ""), 90) + if preview: + self.ui.line(" " + self.ui.style(preview, "dim")) + self.ui.info("/chat to switch, /new for a fresh one") + + def pick_chat(self, arg: str) -> None: + arg = arg.strip() + if not arg: + self.ui.warn("Usage: /chat or /chat ") + return + if arg.isdigit(): + index = int(arg) - 1 + if not 0 <= index < len(self._last_chats): + self.ui.warn("No such number; run /chats first.") + return + chat = self._last_chats[index] + session_id, label = str(chat.get("session_id")), str(chat.get("label") or "") + else: + session_id, label = arg, arg + self.ui.info(f"{self.switch_to(session_id)} ({label})") + + def new_conversation(self) -> str: + """A fresh chat: empty history, so nothing said earlier (including + invented tool results) is there to be copied. The server mints the id + (POST /desk/chats); if it can't, a local id still gives a clean slate. + Blocking — the window runs create_chat on a worker and calls + switch_to() itself instead.""" + try: + return "New chat. " + self.switch_to(self._create_chat()) + except server_client.ServerError as exc: + note = self.switch_to(config.new_session()) + return f"New chat (made locally; the server couldn't: {exc}). {note}" + + def _cd(self, arg: str) -> None: + target = Path(arg or str(Path.home())).expanduser() + try: + os.chdir(target) + except OSError as exc: + self.ui.error(f"cd: {exc}") + return + self.ui.info(os.getcwd()) + + def status(self) -> None: + rows = [ + ("server", config.SERVER_URL or "(not set)"), + ("session", config.SESSION_ID), + ("approval", self.tools.mode), + ("cwd", os.getcwd()), + ("streaming", "on" if self._converse_stream else "off"), + ("deliveries", str(config.DELIVERED_FILES_DIR) if config.RECEIVE_FILES else "off"), + ] + for key, value in rows: + self.ui.line(f" {self.ui.style(key.ljust(11), 'dim')}{value}") + + # ── the loop ───────────────────────────────────────────────────────── + + def banner(self) -> None: + ui = self.ui + ui.line(ui.style(f"{ui.glyphs.bullet} Bolt Console", "bold", "cyan") + ui.style(f" v{__version__}", "dim")) + ui.info(f" {config.SERVER_URL} | session {config.SESSION_ID} | approval: {self.tools.mode}") + ui.info(f" cwd: {os.getcwd()}") + ui.info(" /help for commands, /exit to leave") + ui.line() + + def loop(self) -> int: + self.banner() + while True: + try: + line = self.ui.read_message() + except EOFError: + self.ui.line() + return 0 + except KeyboardInterrupt: + # Ctrl+C at an empty prompt clears the line, like a shell. + self.ui.line() + continue + if not self.handle(line): + return 0 + + +def _usage() -> str: + return ( + "usage: python -m bolt_console [--auto] [-p MESSAGE] [--doctor [--deep]]\n" + " -p MESSAGE send one message, print the reply, exit\n" + " --auto don't ask before commands, writes and edits\n" + " --doctor check the install (--deep also contacts the server)" + ) + + +def run(argv: Optional[list] = None) -> int: + argv = list(sys.argv[1:] if argv is None else argv) + if "-h" in argv or "--help" in argv: + print(_usage()) + return 0 + + try: + sys.stdout.reconfigure(errors="replace") + except Exception: + pass + enable_windows_ansi() + + ui = Terminal(color=config.COLOR) + if not config.is_configured(): + ui.error("Missing " + ", ".join(config.missing_config()) + ". Copy .env.example to .env and fill them in.") + return 2 + + mode = "auto" if "--auto" in argv else config.APPROVAL_MODE + session = Session(ui, tools=Tools(ui, mode=mode)) + + if "-p" in argv: + index = argv.index("-p") + message = " ".join(argv[index + 1:]).strip() + if not message: + ui.error("-p needs a message") + return 2 + session.send(message) + return 0 + + return session.loop() diff --git a/bolt_console/dictation.py b/bolt_console/dictation.py new file mode 100644 index 0000000..2fa5c8c --- /dev/null +++ b/bolt_console/dictation.py @@ -0,0 +1,339 @@ +"""Dictation: speak into the mic, get text in the composer. + +Same route as Bolt-Pet: 16 kHz mono PCM streamed over a websocket to the Bolt +server's own `/desk/stt` relay, which forwards it to Deepgram and sends +Deepgram's JSON back untouched. No Deepgram account or key here — auth is the +desk API key — though the server does meter it against the same credits as a +chat turn. + +One `Dictation` is one recording: + + d = Dictation(on_update, on_level, on_done) + d.start() # mic opens, audio streams up, on_update(final, interim) as words arrive + d.stop() # or silence / the length cap stops it; on_done(text, error) follows + d.cancel() # stop and throw the result away + +Design points: + +- **Live, with a safety net.** Audio goes up as it's captured so the words + appear while you talk. Every frame is also buffered, and if the socket + never opened or died, stop() opens a fresh one and sends the whole buffer — + Bolt-Pet's one-shot fallback. +- **You decide when it's done**, with help: a click stops it, and so do + `silence_seconds` of quiet after you've said something, or `max_seconds`. +- **No Qt, no numpy.** Callbacks run on background threads; the window turns + them into signals. Audio is raw int16 bytes, and loudness is computed with + the array module. That keeps this identical in the single-file Linux app. +- **Everything with hardware or network is injected** (`connect`, + `open_stream`), so the whole flow is tested without a mic or a server. +""" + +from __future__ import annotations + +import array +import json +import math +import queue +import sys +import threading +import time +import urllib.parse +from typing import Callable, List, Optional + +from . import config + +SAMPLE_RATE = 16000 +BLOCK_FRAMES = 1280 # 80 ms, same chunking as Bolt-Pet +SPEECH_RMS = 500 # int16 RMS above this counts as talking + + +def unavailable_reason() -> Optional[str]: + """None if dictation can work here, otherwise a sentence saying why — + shown as the mic button's tooltip instead of a crash.""" + if not config.is_configured(): + return "Set BOLT_SERVER_URL and DESK_API_KEY to use dictation" + try: + import websocket # noqa: F401 (websocket-client) + except Exception: + return "Dictation needs websocket-client: pip install websocket-client" + try: + import sounddevice # noqa: F401 + except OSError: + return ("Dictation needs the PortAudio library (e.g. sudo apt install libportaudio2)" + if sys.platform.startswith("linux") else "PortAudio isn't available on this system") + except Exception: + return "Dictation needs sounddevice: pip install sounddevice" + return None + + +def mic_device(): + """MIC_DEVICE from .env: an index (as saved by Bolt-Pet's mic picker) or a + name substring; blank means the system default.""" + raw = str(getattr(config, "MIC_DEVICE", "") or "").strip() + if not raw: + return None + return int(raw) if raw.lstrip("-").isdigit() else raw + + +def connect(sample_rate: int = SAMPLE_RATE): + """Open the server's /desk/stt websocket for one recording.""" + import websocket + + query = urllib.parse.urlencode({ + "session_id": config.SESSION_ID, "encoding": "linear16", "sample_rate": sample_rate, + }) + url = config.SERVER_URL.replace("http", "ws", 1) + "/desk/stt?" + query + return websocket.create_connection(url, header=[f"X-Desk-Api-Key: {config.API_KEY}"], timeout=10) + + +def open_stream(callback: Callable[[bytes], None], sample_rate: int = SAMPLE_RATE): + """Start the default (or MIC_DEVICE) microphone, handing raw int16 mono + bytes to *callback* from PortAudio's thread. Returns something with + stop() and close().""" + import sounddevice as sd + + stream = sd.RawInputStream( + samplerate=sample_rate, channels=1, dtype="int16", blocksize=BLOCK_FRAMES, + device=mic_device(), callback=lambda data, _frames, _time, _status: callback(bytes(data)), + ) + stream.start() + return stream + + +def rms(chunk: bytes) -> float: + samples = array.array("h") + samples.frombytes(chunk[: len(chunk) - len(chunk) % 2]) + if not samples: + return 0.0 + return math.sqrt(sum(s * s for s in samples) / len(samples)) + + +def parse_result(message) -> tuple: + """(text, is_final) from a Deepgram results frame; ("", False) for + anything else. Never raises — it runs on the reader thread.""" + try: + if isinstance(message, bytes): + message = message.decode("utf-8", "ignore") + data = json.loads(message) + except (TypeError, ValueError): + return "", False + if not isinstance(data, dict) or data.get("type") not in (None, "Results"): + return "", False + alternatives = (data.get("channel") or {}).get("alternatives") or [] + if not alternatives: + return "", False + text = str((alternatives[0] or {}).get("transcript") or "").strip() + return text, bool(data.get("is_final") or data.get("speech_final")) + + +class Dictation: + def __init__( + self, + on_update: Callable[[str, str], None], + on_level: Callable[[float], None], + on_done: Callable[[str, Optional[str]], None], + *, + connect_fn: Callable = None, + open_stream_fn: Callable = None, + silence_seconds: float = 3.0, + max_seconds: float = 300.0, + sample_rate: int = SAMPLE_RATE, + on_stopping: Callable[[], None] = None, + ): + self._on_update, self._on_level, self._on_done = on_update, on_level, on_done + # Recording over (click, silence or cap), transcript not back yet. + self._on_stopping = on_stopping or (lambda: None) + self._connect = connect_fn or connect + self._open_stream = open_stream_fn or open_stream + self._silence_seconds = silence_seconds + self._max_seconds = max_seconds + self._rate = sample_rate + self._frames: "queue.Queue[bytes]" = queue.Queue() + self._buffer: List[bytes] = [] + self._finals: List[str] = [] + self._interim = "" + self._lock = threading.Lock() + self._stop = threading.Event() + self._cancelled = False + self._socket = None + self._socket_ok = False + self._stream = None + self._reader = None + self._pump = None + + # ── control (any thread) ───────────────────────────────────────────── + + def start(self) -> None: + """Open the mic (raises if that fails — nothing to record without it) + and try the live socket (failure there is fine: the fallback has the + audio).""" + try: + self._socket = self._connect(self._rate) + self._socket_ok = True + self._reader = threading.Thread(target=self._read_loop, name="stt-reader", daemon=True) + self._reader.start() + except Exception: + self._socket, self._socket_ok = None, False + try: + self._stream = self._open_stream(self._frames.put, self._rate) + except Exception: + self._close_socket() + raise + self._pump = threading.Thread(target=self._pump_loop, name="stt-pump", daemon=True) + self._pump.start() + + def stop(self) -> None: + self._stop.set() + + def cancel(self) -> None: + self._cancelled = True + self._stop.set() + + @property + def live(self) -> bool: + """Whether words are arriving as you speak (vs. only after stop).""" + return self._socket_ok + + # ── the pump: mic frames -> socket, buffer, level, auto-stop ───────── + + def _pump_loop(self) -> None: + started = time.monotonic() + heard_speech, last_speech = False, started + while not self._stop.is_set(): + try: + chunk = self._frames.get(timeout=0.1) + except queue.Empty: + chunk = None + now = time.monotonic() + if chunk: + self._buffer.append(chunk) + self._send(chunk) + loudness = rms(chunk) + self._safe(self._on_level, min(1.0, math.sqrt(loudness / 8000.0))) + if loudness >= SPEECH_RMS: + heard_speech, last_speech = True, now + if heard_speech and now - last_speech >= self._silence_seconds: + break + if now - started >= self._max_seconds: + break + self._finish() + + def _send(self, chunk: bytes) -> None: + if not self._socket_ok: + return + try: + self._socket.send_binary(chunk) + except Exception: + self._socket_ok = False # the fallback will send the buffer instead + + def _finish(self) -> None: + self._stop.set() + self._safe(self._on_stopping) + for step in ("stop", "close"): + try: + getattr(self._stream, step)() + except Exception: + pass + self._safe(self._on_level, 0.0) + if self._cancelled: + self._close_socket() + self._safe(self._on_done, "", None) + return + # Whatever was still queued when we stopped belongs to the recording. + while True: + try: + chunk = self._frames.get_nowait() + except queue.Empty: + break + self._buffer.append(chunk) + self._send(chunk) + + # Live socket healthy to the end: its transcript is the answer, even + # an empty one (silence) — re-sending would pay to transcribe it twice. + error = None + if self._socket_ok: + self._flush_socket() + else: + error = self._fallback() + text = self._text() + if not text and error is None: + error = "Didn't catch anything — try again a little closer to the mic." + self._safe(self._on_done, text, error) + + def _flush_socket(self) -> None: + """Deepgram flushes its last results after CloseStream; wait for them, + bounded so a hung socket can't hold the text hostage.""" + try: + self._socket.send(json.dumps({"type": "CloseStream"})) + except Exception: + self._socket_ok = False + if self._reader is not None: + self._reader.join(timeout=4) + self._close_socket() + + def _fallback(self) -> Optional[str]: + """The live socket never worked or died part-way: one fresh connection, + the whole buffer, from an empty transcript (a half-live session's words + would otherwise come back twice).""" + self._close_socket() + if self._reader is not None: + self._reader.join(timeout=1) + with self._lock: + self._finals, self._interim = [], "" + if not self._buffer: + return None + try: + self._socket = self._connect(self._rate) + except Exception as exc: + return f"Couldn't reach the transcription server: {exc}" + self._socket_ok = True + self._reader = threading.Thread(target=self._read_loop, name="stt-reader", daemon=True) + self._reader.start() + audio = b"".join(self._buffer) + for offset in range(0, len(audio), BLOCK_FRAMES * 2 * 8): + self._send(audio[offset:offset + BLOCK_FRAMES * 2 * 8]) + self._flush_socket() + return None + + def _close_socket(self) -> None: + socket, self._socket = self._socket, None + if socket is not None: + try: + socket.close() + except Exception: + pass + + # ── the reader: transcripts back ───────────────────────────────────── + + def _read_loop(self) -> None: + socket = self._socket + while socket is not None: + try: + message = socket.recv() + except Exception: + break + if not message: + break + text, is_final = parse_result(message) + with self._lock: + if is_final: + if text: + self._finals.append(text) + self._interim = "" + elif text: + self._interim = text + finals, interim = " ".join(self._finals), self._interim + if text: + self._safe(self._on_update, finals, interim) + + def _text(self) -> str: + with self._lock: + # An interim that never became final is still better than nothing. + return " ".join(self._finals + ([self._interim] if self._interim else [])).strip() + + @staticmethod + def _safe(callback, *args) -> None: + try: + callback(*args) + except Exception: + pass diff --git a/bolt_console/doctor.py b/bolt_console/doctor.py new file mode 100644 index 0000000..0423be3 --- /dev/null +++ b/bolt_console/doctor.py @@ -0,0 +1,163 @@ +"""`python -m bolt_console --doctor` — is this install actually going to work? + +Carried over from Bolt-Pet, where a missing port publish, a wrong reverse-proxy +header and a venv whose python was a zero-byte file all presented as "it's +being weird" and each 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 touch the network by default — it's the first thing you +reach for when the network is what's broken. `--deep` contacts the server. +""" + +from __future__ import annotations + +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 + + +# ── 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 console 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_python() -> Check: + version = ".".join(str(n) for n in sys.version_info[:3]) + if sys.version_info < (3, 9): + return Check("python", FAIL, version, "Python 3.9 or newer is required") + return Check("python", OK, f"{version} at {sys.executable}") + + +def check_approval() -> Check: + mode = config.APPROVAL_MODE + if mode == "auto": + return Check("approval", WARN, "auto — Bolt's commands, writes and edits run without asking", + "APPROVAL_MODE=ask to confirm each one (or /ask for one session)") + if mode != "ask": + return Check("approval", WARN, f"unknown APPROVAL_MODE={mode!r}, treated as ask", + "use ask or auto") + return Check("approval", OK, "ask before commands, writes and edits") + + +def check_deliveries() -> Check: + if not config.RECEIVE_FILES: + return Check("file delivery", WARN, "disabled (RECEIVE_FILES=false)") + directory = config.DELIVERED_FILES_DIR + probe = directory + while not probe.exists() and probe.parent != probe: + probe = probe.parent + import os + + if not os.access(str(probe), os.W_OK): + return Check("file delivery", FAIL, f"can't write under {probe}", + "set DELIVERED_FILES_DIR to a folder you can write to") + return Check("file delivery", OK, str(directory)) + + +CHECKS = ( + ("python", check_python), + ("config", check_config), + ("server", check_server), + ("stream", check_streaming_endpoint), + ("approval", check_approval), + ("deliveries", check_deliveries), +) + + +def run(deep: bool = False) -> list: + 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 Console preflight{' (deep: contacting the server)' 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 diff --git a/bolt_pet/file_delivery.py b/bolt_console/file_delivery.py similarity index 100% rename from bolt_pet/file_delivery.py rename to bolt_console/file_delivery.py diff --git a/bolt_pet/file_ops.py b/bolt_console/file_ops.py similarity index 95% rename from bolt_pet/file_ops.py rename to bolt_console/file_ops.py index 9e388ae..830a30c 100644 --- a/bolt_pet/file_ops.py +++ b/bolt_console/file_ops.py @@ -1,6 +1,5 @@ """Local file read/edit/write, intercepted from the server-relayed command -channel the same way pet_actions.py intercepts petctl (see server_client. -run_local_command / controller._handle_command). Whatever text the server's +channel before it reaches a shell (see tools.Tools.handle). Whatever text the server's "command" tool sends is just a string this repo is free to interpret before it ever reaches subprocess — a `filectl` pseudo-command is one such interpretation, giving the model a way to read/write/edit files on this @@ -12,7 +11,7 @@ run_local_command/subprocess.run does) — filectl doesn't add that ability, it only makes the read/write/edit slice of it reliable. It also doesn't expand what the server can already do to this machine: a relayed shell command could already overwrite any file the desktop user can write (see the -security notes in CLAUDE.md) — filectl is a safer *path* to the same +security notes in the README) — filectl is a safer *path* to the same capability, not a new capability. Wire format: `filectl `, where is a single-line, compact JSON @@ -42,8 +41,7 @@ _system_context for the exact instruction the model is given, including the "keep it one line" requirement. Pure parsing (parse) is separated from the filesystem I/O (execute) so the -syntax is unit-testable without touching disk, matching pet_actions.py's -parse/describe split. +syntax is unit-testable without touching disk. """ from __future__ import annotations @@ -73,7 +71,7 @@ HELP = ( class FileOpError(Exception): """Bad filectl syntax or a filesystem error — reported back to the - server as command output, exactly like ActionError in pet_actions.py.""" + server as command output.""" def is_file_command(command: str) -> bool: @@ -167,8 +165,7 @@ def _line_number(value, label: str) -> Optional[int]: def describe(action: dict) -> str: - """Text handed back to the server before execute() runs — mirrors - pet_actions.describe, used only for the log line.""" + """A one-line summary of the action, for logs.""" kind = action.get("action") if kind == "help": return "[filectl] help" diff --git a/bolt_console/gui/__init__.py b/bolt_console/gui/__init__.py new file mode 100644 index 0000000..4141173 --- /dev/null +++ b/bolt_console/gui/__init__.py @@ -0,0 +1,2 @@ +"""PySide6 front end. Imported only when the window is wanted, so the +terminal client works without Qt installed.""" diff --git a/bolt_console/gui/bridge.py b/bolt_console/gui/bridge.py new file mode 100644 index 0000000..e694fc3 --- /dev/null +++ b/bolt_console/gui/bridge.py @@ -0,0 +1,106 @@ +"""The seam between a turn running on a worker thread and the window. + +`Session` and `Tools` were written against a terminal-shaped UI (tool_call, +tool_result, confirm, assistant_chunk, …). `Bridge` is that same shape, but +every call becomes a Qt signal — delivered to the window on the GUI thread +through a queued connection — so the whole turn logic is shared, unchanged, +between the terminal and the window. + +The one call that has to *return* something is confirm(): the worker blocks on +an Event until the user clicks Allow/Deny in the window, which calls answer() +from the GUI thread. close() releases a pending wait so the app can quit mid +approval without hanging the worker. +""" + +from __future__ import annotations + +import threading + +from PySide6.QtCore import QObject, Signal + + +class Bridge(QObject): + busy = Signal(bool) + chunk = Signal(str) # one streamed sentence + reply_ended = Signal() # the streamed block is complete + reply = Signal(str) # a whole (non-streamed) reply + holding_line = Signal(str) # "give me a sec" while a tool runs + tool_started = Signal(int, str, str) # id, name, argument + tool_output = Signal(int, str, bool) # id, text, is_error + tool_diff = Signal(int, str, str) # id, old, new + approval_needed = Signal(int, str) # id, question + notice = Signal(str, str) # level (info/warn/error), text + + def __init__(self, parent=None): + super().__init__(parent) + self._tool_id = 0 + self._answer = "no" + self._answered = threading.Event() + self._closed = False + + # ── the terminal-shaped surface Session and Tools call ─────────────── + + def start_spinner(self, label: str = "Thinking") -> None: + self.busy.emit(True) + + def stop_spinner(self) -> None: + self.busy.emit(False) + + def assistant_chunk(self, sentence: str) -> None: + if sentence.strip(): + self.chunk.emit(sentence.strip()) + + def end_reply(self) -> None: + self.reply_ended.emit() + + def assistant(self, text: str) -> None: + if text.strip(): + self.reply.emit(text.strip()) + + def holding(self, text: str) -> None: + self.holding_line.emit(text.strip()) + + def tool_call(self, name: str, argument: str) -> None: + self._tool_id += 1 + self.tool_started.emit(self._tool_id, name, argument) + + def tool_result(self, text: str, error: bool = False) -> None: + self.tool_output.emit(self._tool_id, text, error) + + def diff(self, old: str, new: str) -> None: + self.tool_diff.emit(self._tool_id, old, new) + + def confirm(self, question: str) -> str: + if self._closed: + return "no" + self._answered.clear() + self._answer = "no" + self.approval_needed.emit(self._tool_id, question) + self._answered.wait() + return self._answer + + def info(self, text: str) -> None: + self.notice.emit("info", text) + + def warn(self, text: str) -> None: + self.notice.emit("warn", text) + + def error(self, text: str) -> None: + self.notice.emit("error", text) + + def line(self, text: str = "") -> None: + if text.strip(): + self.notice.emit("info", text.strip()) + + def style(self, text: str, *_names: str) -> str: + return text + + # ── called from the GUI thread ─────────────────────────────────────── + + def answer(self, value: str) -> None: + self._answer = value if value in ("yes", "no", "always") else "no" + self._answered.set() + + def close(self) -> None: + self._closed = True + self.answer("no") diff --git a/bolt_console/gui/chat_list_window.py b/bolt_console/gui/chat_list_window.py new file mode 100644 index 0000000..6db041b --- /dev/null +++ b/bolt_console/gui/chat_list_window.py @@ -0,0 +1,235 @@ +"""The Chats dialog: every conversation this user has with Bolt, across the +console, the pet and the phone, newest first, and a New Chat button. + +Network calls never run on the UI thread. Each one is a `ChatTask` on the +global QThreadPool that calls server_client synchronously and hands the +result back through `ChatSignals`, the same signal-based hand-off the turn +worker uses. The list is fetched when the dialog opens and on Refresh; there +is no polling. + +Picking or creating a chat doesn't switch anything here. The dialog emits +`chosen(session_id, label)` and the window does the switch, because the +window owns the transcript that has to be cleared along with it. +""" + +from __future__ import annotations + +from typing import Callable, Optional + +from PySide6.QtCore import QObject, QRunnable, QSize, Qt, QThreadPool, Signal +from PySide6.QtWidgets import ( + QDialog, QHBoxLayout, QLabel, QListWidget, QListWidgetItem, QPushButton, + QVBoxLayout, QWidget, +) + +from .. import config, server_client +from ..chats import clean_preview, local_time, relative_time +from . import icons, theme + +# ── off-thread calls ──────────────────────────────────────────────────────── + +class ChatSignals(QObject): + loaded = Signal(list) # chats, newest first + created = Signal(str) # the new session_id + failed = Signal(str, str) # operation ("list" / "create"), message + + +class ChatTask(QRunnable): + """Runs one server_client call on the thread pool and reports back.""" + + def __init__(self, operation: str, call: Callable, signals: ChatSignals): + super().__init__() + self.operation, self.call, self.signals = operation, call, signals + + def run(self) -> None: + try: + result = self.call() + except Exception as exc: # ServerError, or anything unexpected + self.signals.failed.emit(self.operation, str(exc)) + return + if self.operation == "list": + self.signals.loaded.emit(list(result)) + else: + self.signals.created.emit(str(result)) + + +def run_in_pool(operation: str, call: Callable, signals: ChatSignals) -> None: + QThreadPool.globalInstance().start(ChatTask(operation, call, signals)) + + +# ── the dialog ────────────────────────────────────────────────────────────── + +class ChatRow(QWidget): + """Label and time on the first line, the last message under it.""" + + def __init__(self, chat: dict, current: bool, parent=None): + super().__init__(parent) + outer = QVBoxLayout(self) + outer.setContentsMargins(14, 9, 14, 9) + outer.setSpacing(2) + top = QHBoxLayout() + top.setSpacing(8) + title = QLabel(str(chat.get("label") or chat.get("session_id") or "Untitled")) + title.setObjectName("ChatTitle") + top.addWidget(title, 1) + if current: + badge = QLabel("Current") + badge.setObjectName("Pill") + badge.setProperty("kind", "waiting") + top.addWidget(badge) + when = QLabel(relative_time(chat.get("last_active", ""))) + when.setObjectName("ChatTime") + top.addWidget(when) + outer.addLayout(top) + preview = QLabel(clean_preview(chat.get("last_message", "")) or "—") + preview.setObjectName("ChatPreview") + outer.addWidget(preview) + for widget in (title, when, preview): + widget.setAttribute(Qt.WA_TransparentForMouseEvents) + + +class ChatListDialog(QDialog): + """`chosen(session_id, label)` fires for a picked chat and for a newly + created one (label "New chat"); the window does the actual switch.""" + + chosen = Signal(str, str) + + def __init__(self, parent=None, list_chats: Callable = None, create_chat: Callable = None): + super().__init__(parent) + self.setObjectName("ChatDialog") + self.setWindowTitle("Chats") + self.setModal(True) + self.resize(560, 580) + self._list_chats = list_chats or server_client.list_chats + self._create_chat = create_chat or server_client.create_chat + self.signals = ChatSignals() + self.signals.loaded.connect(self._populate) + self.signals.created.connect(self._created) + self.signals.failed.connect(self._failed) + + col = QVBoxLayout(self) + col.setContentsMargins(18, 18, 18, 16) + col.setSpacing(12) + head = QHBoxLayout() + head.setSpacing(10) + head.addWidget(icons.IconLabel("bolt", theme.ACCENT_INK, 24, tile=theme.ACCENT)) + heading = QLabel("Chats") + heading.setObjectName("Wordmark") + head.addWidget(heading) + head.addStretch(1) + self.status = QLabel("") + self.status.setObjectName("Hint") + head.addWidget(self.status) + col.addLayout(head) + + sub = QLabel("Every conversation with Bolt, from any device. A new chat starts with " + "fresh history; Bolt still remembers you.") + sub.setObjectName("HeroSub") + sub.setWordWrap(True) + col.addWidget(sub) + + self.list = QListWidget() + self.list.setObjectName("ChatList") + self.list.setUniformItemSizes(False) + self.list.setVerticalScrollMode(QListWidget.ScrollPerPixel) + self.list.itemActivated.connect(self._open_item) # Enter / double-click + self.list.currentItemChanged.connect(lambda *_: self._sync_buttons()) + col.addWidget(self.list, 1) + + buttons = QHBoxLayout() + buttons.setSpacing(8) + self.refresh_button = QPushButton("Refresh") + self.refresh_button.setObjectName("Ghost") + self.refresh_button.clicked.connect(self.refresh) + buttons.addWidget(self.refresh_button) + buttons.addStretch(1) + self.new_button = QPushButton("New Chat") + self.new_button.setObjectName("Secondary") + self.new_button.clicked.connect(self.new_chat) + buttons.addWidget(self.new_button) + self.open_button = QPushButton("Open") + self.open_button.setObjectName("Primary") + self.open_button.clicked.connect(lambda: self._open_item(self.list.currentItem())) + buttons.addWidget(self.open_button) + col.addLayout(buttons) + for button in (self.refresh_button, self.new_button, self.open_button): + button.setCursor(Qt.PointingHandCursor) + self._busy = False + self._sync_buttons() + + # ── loading ────────────────────────────────────────────────────────── + + def showEvent(self, event) -> None: + super().showEvent(event) + theme.dark_title_bar(self) # same caption colour as the main window on Windows + self.refresh() + + def refresh(self) -> None: + if self._busy: + return + self._set_busy(True, "Loading…") + run_in_pool("list", self._list_chats, self.signals) + + def _populate(self, chats: list) -> None: + self._set_busy(False, "") + self.list.clear() + current = None + for chat in chats: + session_id = str(chat.get("session_id") or "") + if not session_id: + continue + is_current = session_id == config.SESSION_ID + item = QListWidgetItem() + item.setData(Qt.UserRole, session_id) + item.setData(Qt.UserRole + 1, str(chat.get("label") or session_id)) + item.setToolTip(f"{session_id}\nLast active {local_time(chat.get('last_active', ''))}") + row = ChatRow(chat, is_current) + item.setSizeHint(QSize(0, row.sizeHint().height())) + self.list.addItem(item) + self.list.setItemWidget(item, row) + if is_current: + current = item + if self.list.count() == 0: + self.status.setText("No chats yet") + self.list.setCurrentItem(current or self.list.item(0)) + self._sync_buttons() + + def _failed(self, operation: str, message: str) -> None: + self._set_busy(False, "") + what = "load chats" if operation == "list" else "start a new chat" + self.status.setText(f"Couldn't {what}") + self.status.setToolTip(message) + self.status.setStyleSheet(f"color: {theme.RED};") + + # ── actions ────────────────────────────────────────────────────────── + + def new_chat(self) -> None: + if self._busy: + return + self._set_busy(True, "Starting a new chat…") + run_in_pool("create", self._create_chat, self.signals) + + def _created(self, session_id: str) -> None: + # A new chat isn't listed until it's used, so there's nothing to show: + # hand it to the window and close. + self._set_busy(False, "") + self.chosen.emit(session_id, "New chat") + self.accept() + + def _open_item(self, item: Optional[QListWidgetItem]) -> None: + if item is None or self._busy: + return + self.chosen.emit(item.data(Qt.UserRole), item.data(Qt.UserRole + 1)) + self.accept() + + def _set_busy(self, busy: bool, status: str) -> None: + self._busy = busy + self.status.setText(status) + self.status.setToolTip("") + self.status.setStyleSheet("") + self._sync_buttons() + + def _sync_buttons(self) -> None: + self.refresh_button.setEnabled(not self._busy) + self.new_button.setEnabled(not self._busy) + self.open_button.setEnabled(not self._busy and self.list.currentItem() is not None) diff --git a/bolt_console/gui/icons.py b/bolt_console/gui/icons.py new file mode 100644 index 0000000..e318b15 --- /dev/null +++ b/bolt_console/gui/icons.py @@ -0,0 +1,219 @@ +"""Icons drawn with QPainter rather than shipped as files or borrowed from an +emoji font — crisp at any DPI, recolourable, and no assets to lose. + +`paint(painter, name, rect, colour)` draws into a rect; `icon(name, colour)` +wraps that as a QIcon; `IconLabel` is a tiny widget that shows one. +""" + +from __future__ import annotations + +from PySide6.QtCore import QBuffer, QByteArray, QIODevice, QPointF, QRectF, QSize, Qt +from PySide6.QtGui import QColor, QIcon, QImage, QPainter, QPainterPath, QPen, QPixmap +from PySide6.QtWidgets import QWidget + + +def _pen(colour: QColor, width: float) -> QPen: + pen = QPen(colour, width) + pen.setCapStyle(Qt.RoundCap) + pen.setJoinStyle(Qt.RoundJoin) + return pen + + +def paint(p: QPainter, name: str, rect: QRectF, colour) -> None: + colour = QColor(colour) + p.save() + p.setRenderHint(QPainter.Antialiasing) + # Everything below is drawn on a 24x24 grid, then scaled into *rect*. + side = min(rect.width(), rect.height()) + p.translate(rect.center().x() - side / 2, rect.center().y() - side / 2) + p.scale(side / 24.0, side / 24.0) + stroke = _pen(colour, 1.9) + + if name == "bolt": + path = QPainterPath() + for i, (x, y) in enumerate([(13.5, 2), (4.5, 13.5), (11, 13.5), (9.5, 22), + (19.5, 9.5), (13, 9.5), (13.5, 2)]): + path.moveTo(x, y) if i == 0 else path.lineTo(x, y) + p.setPen(Qt.NoPen) + p.setBrush(colour) + p.drawPath(path) + elif name == "send": + p.setPen(_pen(colour, 2.4)) + p.drawLine(QPointF(12, 19), QPointF(12, 5.5)) + p.drawLine(QPointF(6.5, 11), QPointF(12, 5.5)) + p.drawLine(QPointF(17.5, 11), QPointF(12, 5.5)) + elif name == "folder": + p.setPen(stroke) + path = QPainterPath() + path.moveTo(3, 7) + path.lineTo(3, 18.5) + path.quadTo(3, 20, 4.5, 20) + path.lineTo(19.5, 20) + path.quadTo(21, 20, 21, 18.5) + path.lineTo(21, 9.5) + path.quadTo(21, 8, 19.5, 8) + path.lineTo(11.5, 8) + path.lineTo(9.5, 5.5) + path.lineTo(4.5, 5.5) + path.quadTo(3, 5.5, 3, 7) + p.drawPath(path) + elif name == "shell": + p.setPen(stroke) + p.drawRoundedRect(QRectF(2.5, 4, 19, 16), 3, 3) + p.drawLine(QPointF(7, 10), QPointF(10, 12.5)) + p.drawLine(QPointF(10, 12.5), QPointF(7, 15)) + p.drawLine(QPointF(12.5, 15.5), QPointF(17, 15.5)) + elif name in ("read", "write", "edit"): + p.setPen(stroke) + page = QPainterPath() + page.moveTo(14, 2.5) + page.lineTo(6, 2.5) + page.quadTo(4.5, 2.5, 4.5, 4) + page.lineTo(4.5, 20) + page.quadTo(4.5, 21.5, 6, 21.5) + page.lineTo(18, 21.5) + page.quadTo(19.5, 21.5, 19.5, 20) + page.lineTo(19.5, 8) + page.closeSubpath() + p.drawPath(page) + p.drawLine(QPointF(14, 2.5), QPointF(14, 8)) + p.drawLine(QPointF(14, 8), QPointF(19.5, 8)) + if name == "read": + for y in (12, 15, 18): + p.drawLine(QPointF(8, y), QPointF(16, y)) + elif name == "write": + p.drawLine(QPointF(12, 11.5), QPointF(12, 18)) + p.drawLine(QPointF(8.75, 14.75), QPointF(15.25, 14.75)) + else: + p.drawLine(QPointF(8, 18), QPointF(15.5, 10.5)) + p.drawLine(QPointF(8, 18), QPointF(8, 15.5)) + p.drawLine(QPointF(8, 18), QPointF(10.5, 18)) + elif name == "list": + p.setPen(stroke) + for y in (6.5, 12, 17.5): + p.drawPoint(QPointF(4.5, y)) + p.drawLine(QPointF(8.5, y), QPointF(20, y)) + elif name == "blocked": + p.setPen(stroke) + p.drawEllipse(QRectF(3.5, 3.5, 17, 17)) + p.drawLine(QPointF(7, 17), QPointF(17, 7)) + elif name == "chevron": + p.setPen(stroke) + p.drawLine(QPointF(9, 6), QPointF(15, 12)) + p.drawLine(QPointF(15, 12), QPointF(9, 18)) + elif name == "chats": + p.setPen(stroke) + bubble = QPainterPath() + bubble.addRoundedRect(QRectF(3, 4, 18, 13), 4, 4) + p.drawPath(bubble) + p.drawLine(QPointF(8, 17), QPointF(7, 21)) + p.drawLine(QPointF(7, 21), QPointF(12, 17)) + for x in (8, 12, 16): + p.drawPoint(QPointF(x, 10.5)) + elif name == "mic": + p.setPen(stroke) + p.drawRoundedRect(QRectF(9, 3, 6, 11.5), 3, 3) + arc = QPainterPath() + arc.moveTo(5.5, 11) + arc.cubicTo(5.5, 19, 18.5, 19, 18.5, 11) + p.drawPath(arc) + p.drawLine(QPointF(12, 17), QPointF(12, 21)) + elif name == "stop": + p.setPen(Qt.NoPen) + p.setBrush(colour) + p.drawRoundedRect(QRectF(7, 7, 10, 10), 2, 2) + elif name == "plus": + p.setPen(_pen(colour, 2.1)) + p.drawLine(QPointF(12, 5), QPointF(12, 19)) + p.drawLine(QPointF(5, 12), QPointF(19, 12)) + elif name == "file": + paint(p, "read", QRectF(0, 0, 24, 24), colour) + p.restore() + + +def icon(name: str, colour, size: int = 32) -> QIcon: + pixmap = QPixmap(size, size) + pixmap.fill(Qt.transparent) + painter = QPainter(pixmap) + paint(painter, name, QRectF(0, 0, size, size), colour) + painter.end() + return QIcon(pixmap) + + +APP_ICON_SIZES = (16, 24, 32, 48, 64, 128, 256) + + +def app_icon_image(size: int) -> QImage: + """An amber rounded square with a dark bolt, at *size* pixels. The one + drawing behind both the window icon and the .exe's icon (write_ico).""" + image = QImage(size, size, QImage.Format_ARGB32_Premultiplied) + image.fill(Qt.transparent) + p = QPainter(image) + p.setRenderHint(QPainter.Antialiasing) + p.setPen(Qt.NoPen) + p.setBrush(QColor("#f5b83d")) + p.drawRoundedRect(QRectF(0, 0, size, size), size * 0.26, size * 0.26) + inset = size * 0.18 + paint(p, "bolt", QRectF(inset, inset, size - 2 * inset, size - 2 * inset), "#1a1305") + p.end() + return image + + +def app_icon() -> QIcon: + """The window/taskbar icon.""" + result = QIcon() + for size in APP_ICON_SIZES: + result.addPixmap(QPixmap.fromImage(app_icon_image(size))) + return result + + +def write_ico(path) -> None: + """Save the app icon as a multi-size Windows .ico (PNG-compressed entries, + which Windows Vista+ reads at every size), for the frozen .exe.""" + import struct + + blobs = [] + for size in APP_ICON_SIZES: + data = QByteArray() + buffer = QBuffer(data) + buffer.open(QIODevice.WriteOnly) + app_icon_image(size).save(buffer, "PNG") + buffer.close() + blobs.append((size, bytes(data))) + + header = struct.pack("= 256 else size # 0 means 256 in the ICO format + entries += struct.pack(" None: + self._name = name + if colour is not None: + self._colour = QColor(colour) + self.update() + + def paintEvent(self, _event) -> None: + p = QPainter(self) + p.setRenderHint(QPainter.Antialiasing) + rect = QRectF(self.rect()) + if self._tile is not None: + p.setPen(Qt.NoPen) + p.setBrush(QColor(self._tile)) + p.drawRoundedRect(rect, rect.width() * 0.28, rect.width() * 0.28) + pad = rect.width() * 0.22 + rect = rect.adjusted(pad, pad, -pad, -pad) + paint(p, self._name, rect, self._colour) diff --git a/bolt_console/gui/theme.py b/bolt_console/gui/theme.py new file mode 100644 index 0000000..60d8c81 --- /dev/null +++ b/bolt_console/gui/theme.py @@ -0,0 +1,196 @@ +"""Colours, fonts and the application stylesheet. + +One dark theme, tuned by hand: near-black charcoal surfaces a step apart, a +single warm accent (the bolt's amber), and colour used only where it carries +meaning — green for done, red for failed, amber for "needs you". +""" + +from __future__ import annotations + +import sys + +from PySide6.QtGui import QColor, QFont, QFontDatabase, QPalette + +BG = "#0e1014" # window +SURFACE = "#15181e" # cards, composer +SURFACE_HI = "#1b1f27" # hover / user bubble +BORDER = "#252a34" +BORDER_HI = "#343b48" +TEXT = "#e7e9ee" +TEXT_DIM = "#9aa1ae" +TEXT_FAINT = "#6b7280" +ACCENT = "#f5b83d" # the bolt +ACCENT_HI = "#ffc95c" +ACCENT_INK = "#1a1305" # text on accent +GREEN = "#4ec98a" +RED = "#f06a6a" +BLUE = "#7aa7ff" +CODE = "#e8c98f" # inline code in replies +DIFF_ADD_BG = "#12291d" +DIFF_DEL_BG = "#301719" + +RADIUS = 12 +COLUMN_WIDTH = 820 # the transcript's reading column + + +def _first_available(families, fallback: str) -> str: + installed = set(QFontDatabase.families()) + for family in families: + if family in installed: + return family + return fallback + + +def ui_font_family() -> str: + return _first_available( + ["Segoe UI Variable Text", "Inter", "SF Pro Text", "Segoe UI", "Cantarell", "Ubuntu"], + QFont().defaultFamily(), + ) + + +def mono_font_family() -> str: + return _first_available( + ["Cascadia Code", "Cascadia Mono", "JetBrains Mono", "SF Mono", "Consolas", + "DejaVu Sans Mono", "Menlo"], + "monospace", + ) + + +def mono_font(size: float = 9.5) -> QFont: + font = QFont(mono_font_family()) + font.setPointSizeF(size) + font.setStyleHint(QFont.Monospace) + return font + + +def palette() -> QPalette: + pal = QPalette() + for role, colour in ( + (QPalette.Window, BG), (QPalette.Base, SURFACE), (QPalette.AlternateBase, SURFACE_HI), + (QPalette.Text, TEXT), (QPalette.WindowText, TEXT), (QPalette.ButtonText, TEXT), + (QPalette.Button, SURFACE), (QPalette.Highlight, "#3a3222"), + (QPalette.HighlightedText, TEXT), (QPalette.Link, BLUE), + (QPalette.PlaceholderText, TEXT_FAINT), (QPalette.ToolTipBase, SURFACE_HI), + (QPalette.ToolTipText, TEXT), + ): + pal.setColor(role, QColor(colour)) + return pal + + +def stylesheet() -> str: + mono = mono_font_family() + return f""" +QWidget {{ color: {TEXT}; font-size: 10.5pt; }} +QMainWindow, #Root {{ background: {BG}; }} +QToolTip {{ background: {SURFACE_HI}; color: {TEXT}; border: 1px solid {BORDER_HI}; + border-radius: 6px; padding: 5px 8px; }} + +/* header */ +#Header {{ background: {BG}; border-bottom: 1px solid {BORDER}; }} +#Wordmark {{ font-size: 12pt; font-weight: 600; letter-spacing: 0.3px; }} +#WordmarkSub {{ color: {TEXT_FAINT}; font-size: 9pt; }} +#Chip {{ background: {SURFACE}; border: 1px solid {BORDER}; border-radius: 15px; + padding: 5px 12px 5px 30px; color: {TEXT_DIM}; text-align: left; }} +#Chip:hover {{ border-color: {BORDER_HI}; color: {TEXT}; background: {SURFACE_HI}; }} +#Chip:disabled {{ color: {TEXT_FAINT}; }} +#Segment {{ background: {SURFACE}; border: 1px solid {BORDER}; border-radius: 15px; }} +#SegmentButton {{ background: transparent; border: none; border-radius: 12px; + padding: 4px 14px; color: {TEXT_DIM}; font-weight: 500; }} +#SegmentButton:hover {{ color: {TEXT}; }} +#SegmentButton:checked {{ background: {SURFACE_HI}; color: {TEXT}; }} +#SegmentButton[danger="true"]:checked {{ background: #3a2c12; color: {ACCENT_HI}; }} + +/* transcript */ +QScrollArea {{ background: transparent; border: none; }} +#Transcript {{ background: {BG}; }} +QScrollBar:vertical {{ background: transparent; width: 10px; margin: 4px 2px; }} +QScrollBar::handle:vertical {{ background: {BORDER}; border-radius: 3px; min-height: 40px; }} +QScrollBar::handle:vertical:hover {{ background: {BORDER_HI}; }} +QScrollBar::add-line, QScrollBar::sub-line, QScrollBar::add-page, QScrollBar::sub-page {{ + background: none; height: 0; }} +QScrollBar:horizontal {{ height: 0; }} + +#UserBubble {{ background: {SURFACE_HI}; border: 1px solid {BORDER}; border-radius: 16px; }} +#AssistantText {{ color: {TEXT}; }} +#Holding {{ color: {TEXT_FAINT}; font-style: italic; }} +#Notice {{ color: {TEXT_DIM}; font-size: 9.5pt; }} +#NoticeError {{ color: {RED}; }} + +#ToolCard {{ background: {SURFACE}; border: 1px solid {BORDER}; border-radius: {RADIUS}px; }} +#ToolCard[state="waiting"] {{ border-color: #5a4418; }} +#ToolHeader {{ background: transparent; border: none; border-radius: {RADIUS}px; }} +#ToolHeader:hover {{ background: {SURFACE_HI}; }} +#ToolName {{ font-weight: 600; }} +#ToolArg {{ color: {TEXT_DIM}; font-family: "{mono}"; font-size: 9.5pt; }} +#ToolOutput {{ background: transparent; color: {TEXT_DIM}; border: none; border-top: 1px solid {BORDER}; + font-family: "{mono}"; font-size: 9.2pt; padding: 10px 14px; }} +#Pill {{ border-radius: 9px; padding: 1px 9px; font-size: 8.5pt; font-weight: 600; }} +#Pill[kind="running"] {{ background: #1c2433; color: {BLUE}; }} +#Pill[kind="ok"] {{ background: #13271d; color: {GREEN}; }} +#Pill[kind="error"] {{ background: #2e1618; color: {RED}; }} +#Pill[kind="waiting"] {{ background: #33280f; color: {ACCENT}; }} +#Pill[kind="declined"] {{ background: #22252c; color: {TEXT_DIM}; }} + +#ApprovalBar {{ border-top: 1px solid {BORDER}; background: transparent; }} +#ApprovalQuestion {{ color: {TEXT}; }} +#Primary {{ background: {ACCENT}; color: {ACCENT_INK}; border: none; border-radius: 8px; + padding: 6px 16px; font-weight: 600; }} +#Primary:hover {{ background: {ACCENT_HI}; }} +#Secondary {{ background: {SURFACE_HI}; color: {TEXT}; border: 1px solid {BORDER_HI}; + border-radius: 8px; padding: 6px 14px; }} +#Secondary:hover {{ border-color: {TEXT_FAINT}; }} +#Ghost {{ background: transparent; color: {TEXT_DIM}; border: none; padding: 6px 10px; }} +#Ghost:hover {{ color: {TEXT}; }} + +/* empty state */ +#Hero {{ font-size: 19pt; font-weight: 600; }} +#HeroSub {{ color: {TEXT_DIM}; }} +#Suggestion {{ background: {SURFACE}; border: 1px solid {BORDER}; border-radius: 12px; + padding: 12px 14px; color: {TEXT_DIM}; text-align: left; }} +#Suggestion:hover {{ border-color: {BORDER_HI}; color: {TEXT}; background: {SURFACE_HI}; }} + +/* composer */ +#Composer {{ background: {SURFACE}; border: 1px solid {BORDER}; border-radius: 18px; }} +#Composer[focused="true"] {{ border-color: {BORDER_HI}; }} +#ComposerEdit {{ background: transparent; border: none; padding: 0; selection-background-color: #3a3222; }} +#Hint {{ color: {TEXT_FAINT}; font-size: 8.8pt; }} + +/* chats dialog */ +QDialog#ChatDialog {{ background: {BG}; }} +#ChatList {{ background: {SURFACE}; border: 1px solid {BORDER}; border-radius: {RADIUS}px; + padding: 4px; outline: none; }} +#ChatList::item {{ border-radius: 9px; margin: 1px 0; }} +#ChatList::item:hover {{ background: {SURFACE_HI}; }} +#ChatList::item:selected {{ background: #262017; border: 1px solid #4a3a18; }} +#ChatTitle {{ font-weight: 600; }} +#ChatTime {{ color: {TEXT_FAINT}; font-size: 9pt; }} +#ChatPreview {{ color: {TEXT_DIM}; font-size: 9.5pt; }} +""" + + +def dark_title_bar(window) -> None: + """Windows 11: dark caption, painted the same colour as the window so the + title bar and the app read as one surface. Silently does nothing on other + platforms or older Windows.""" + if sys.platform != "win32": + return + try: + import ctypes + from ctypes import wintypes + + hwnd = wintypes.HWND(int(window.winId())) + dwm = ctypes.windll.dwmapi + + def attr(key: int, value: int) -> None: + data = ctypes.c_int(value) + dwm.DwmSetWindowAttribute(hwnd, key, ctypes.byref(data), ctypes.sizeof(data)) + + attr(20, 1) # DWMWA_USE_IMMERSIVE_DARK_MODE + colour = BG.lstrip("#") + r, g, b = int(colour[0:2], 16), int(colour[2:4], 16), int(colour[4:6], 16) + attr(35, (b << 16) | (g << 8) | r) # DWMWA_CAPTION_COLOR (COLORREF is 0x00BBGGRR) + border = BORDER.lstrip("#") + r, g, b = int(border[0:2], 16), int(border[2:4], 16), int(border[4:6], 16) + attr(34, (b << 16) | (g << 8) | r) # DWMWA_BORDER_COLOR + except Exception: + pass diff --git a/bolt_console/gui/widgets.py b/bolt_console/gui/widgets.py new file mode 100644 index 0000000..3d898c1 --- /dev/null +++ b/bolt_console/gui/widgets.py @@ -0,0 +1,699 @@ +"""The pieces the window is built from: messages, tool cards, the composer, +the header controls and the empty state. Styling lives in theme.stylesheet(); +these set object names and properties for it to hook onto.""" + +from __future__ import annotations + +import html +import math +import re + +from PySide6.QtCore import QEvent, QRectF, QSize, Qt, QTimer, Signal +from PySide6.QtGui import QColor, QFontDatabase, QPainter, QTextCursor, QTextDocument, QTextOption +from PySide6.QtWidgets import ( + QButtonGroup, QFrame, QHBoxLayout, QLabel, QPlainTextEdit, QPushButton, + QSizePolicy, QVBoxLayout, QWidget, +) + +from . import icons, theme + +# Tool name (as tools.py reports it) -> icon. +TOOL_ICONS = { + "Shell": "shell", "Read": "read", "List": "list", "Write": "write", + "Edit": "edit", "Pet": "blocked", "File": "file", +} +COLLAPSED_LINES = 6 + + +def _label(text: str = "", name: str = "", wrap: bool = True, selectable: bool = False) -> QLabel: + label = QLabel(text) + if name: + label.setObjectName(name) + label.setWordWrap(wrap) + if selectable: + label.setTextInteractionFlags(Qt.TextSelectableByMouse | Qt.LinksAccessibleByMouse) + label.setCursor(Qt.IBeamCursor) + return label + + +def repolish(widget: QWidget) -> None: + """Re-apply the stylesheet after a dynamic property changed.""" + widget.style().unpolish(widget) + widget.style().polish(widget) + widget.update() + + +def markdown_html(text: str, font) -> str: + """Markdown -> the HTML QLabel renders, with code in the app's mono font + and a warm tint instead of Qt's generic 'monospace' (usually Courier).""" + doc = QTextDocument() + doc.setDefaultFont(font) + doc.setMarkdown(text) + out = doc.toHtml() + code_style = f"font-family:'{theme.mono_font_family()}'; color:{theme.CODE};" + # Qt writes the platform's fixed font for code: 'Courier New' on Windows, + # 'monospace' elsewhere. + for fixed in {QFontDatabase.systemFont(QFontDatabase.FixedFont).family(), "monospace", "Courier New"}: + out = out.replace(f"font-family:'{fixed}';", code_style) + return out.replace("font-family:'Sans Serif';", "") + + +# ── transcript entries ────────────────────────────────────────────────────── + +class UserMessage(QWidget): + def __init__(self, text: str, parent=None): + super().__init__(parent) + row = QHBoxLayout(self) + row.setContentsMargins(80, 6, 0, 6) + row.addStretch(1) + bubble = QFrame() + bubble.setObjectName("UserBubble") + inner = QVBoxLayout(bubble) + inner.setContentsMargins(16, 10, 16, 10) + label = _label(text, selectable=True) + label.setTextFormat(Qt.PlainText) + # A word-wrapped QLabel's size hint is a guess that wraps short text + # early; give it the width its longest line actually needs. + widest = max(label.fontMetrics().horizontalAdvance(line) for line in text.splitlines() or [""]) + label.setMinimumWidth(min(widest + 4, 588)) + inner.addWidget(label) + bubble.setMaximumWidth(620) + bubble.setSizePolicy(QSizePolicy.Maximum, QSizePolicy.Preferred) + row.addWidget(bubble) + + +class AssistantMessage(QWidget): + """Bolt's reply. Streamed sentences are appended; a sentence that starts a + list item or heading goes on its own line so the markdown still renders.""" + + _BLOCK_START = re.compile(r"^(?:[-*+] |\d+[.)] |#{1,6} |```|>)") + + def __init__(self, text: str = "", parent=None): + super().__init__(parent) + self._text = "" + row = QHBoxLayout(self) + row.setContentsMargins(0, 8, 0, 8) + row.setSpacing(14) + avatar = icons.IconLabel("bolt", theme.ACCENT_INK, 26, tile=theme.ACCENT) + row.addWidget(avatar, 0, Qt.AlignTop) + self.label = _label(name="AssistantText", selectable=True) + self.label.setTextFormat(Qt.RichText) + self.label.setOpenExternalLinks(True) + self.label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred) + row.addWidget(self.label, 1) + if text: + self.set_text(text) + + def set_text(self, text: str) -> None: + self._text = text + self.label.setText(markdown_html(text, self.label.font())) + + def append(self, sentence: str) -> None: + if not self._text: + joiner = "" + elif self._BLOCK_START.match(sentence) or self._text.endswith("\n"): + joiner = "\n\n" if not self._text.endswith("\n") else "" + else: + joiner = " " + self.set_text(self._text + joiner + sentence) + + def text(self) -> str: + return self._text + + +class HoldingLine(QWidget): + def __init__(self, text: str, parent=None): + super().__init__(parent) + row = QHBoxLayout(self) + row.setContentsMargins(40, 0, 0, 2) + row.addWidget(_label(text, "Holding")) + + +class Notice(QWidget): + def __init__(self, level: str, text: str, parent=None): + super().__init__(parent) + row = QHBoxLayout(self) + row.setContentsMargins(40, 4, 0, 4) + label = _label(text, "NoticeError" if level == "error" else "Notice", selectable=True) + label.setTextFormat(Qt.PlainText) + if level == "warn": + label.setStyleSheet(f"color: {theme.ACCENT};") + row.addWidget(label, 1) + + +class Pill(QLabel): + TEXT = {"running": "Running", "ok": "Done", "error": "Failed", + "waiting": "Needs approval", "declined": "Declined"} + + def __init__(self, kind: str = "running", parent=None): + super().__init__(parent) + self.setObjectName("Pill") + self.set_kind(kind) + + def set_kind(self, kind: str) -> None: + self.setProperty("kind", kind) + self.setText(self.TEXT.get(kind, kind)) + repolish(self) + + +class _ClickableFrame(QFrame): + clicked = Signal() + + def mouseReleaseEvent(self, event) -> None: + if event.button() == Qt.LeftButton and self.rect().contains(event.position().toPoint()): + self.clicked.emit() + super().mouseReleaseEvent(event) + + +class ToolCard(QFrame): + """One relayed command: header (icon, name, argument, status), optional + diff, output that expands on click, and an inline approval bar.""" + + answered = Signal(str) # yes / no / always + + def __init__(self, name: str, argument: str, parent=None): + super().__init__(parent) + self.setObjectName("ToolCard") + self._output = "" + self._expanded = False + self._error = False + + outer = QVBoxLayout(self) + outer.setContentsMargins(0, 0, 0, 0) + outer.setSpacing(0) + + self.header = _ClickableFrame() + self.header.setObjectName("ToolHeader") + self.header.setCursor(Qt.PointingHandCursor) + head = QHBoxLayout(self.header) + head.setContentsMargins(12, 9, 12, 9) + head.setSpacing(10) + self.icon = icons.IconLabel(TOOL_ICONS.get(name, "shell"), theme.TEXT_DIM, 16) + head.addWidget(self.icon) + title = _label(name, "ToolName", wrap=False) + head.addWidget(title) + shown = argument if len(argument) <= 110 else argument[:107] + "..." + arg = _label(shown, "ToolArg", wrap=False) + arg.setTextFormat(Qt.PlainText) + arg.setToolTip(argument) + arg.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Preferred) + head.addWidget(arg, 1) + self.pill = Pill("running") + head.addWidget(self.pill) + self.chevron = icons.IconLabel("chevron", theme.TEXT_FAINT, 14) + self.chevron.hide() + head.addWidget(self.chevron) + self.header.clicked.connect(self.toggle) + outer.addWidget(self.header) + + self.diff = _label(name="ToolOutput", wrap=True, selectable=True) + self.diff.setTextFormat(Qt.RichText) + self.diff.hide() + outer.addWidget(self.diff) + + self.output = _label(name="ToolOutput", wrap=True, selectable=True) + self.output.setTextFormat(Qt.PlainText) + self.output.hide() + outer.addWidget(self.output) + + self.approval = QFrame() + self.approval.setObjectName("ApprovalBar") + bar = QHBoxLayout(self.approval) + bar.setContentsMargins(14, 10, 10, 10) + bar.setSpacing(8) + self.question = _label("", "ApprovalQuestion", wrap=False) + bar.addWidget(self.question, 1) + for text, value, style in (("Deny", "no", "Ghost"), ("Always allow", "always", "Secondary"), + ("Allow", "yes", "Primary")): + button = QPushButton(text) + button.setObjectName(style) + button.setCursor(Qt.PointingHandCursor) + button.clicked.connect(lambda _=False, v=value: self._answer(v)) + bar.addWidget(button) + if value == "yes": + self.allow_button = button + self.approval.hide() + outer.addWidget(self.approval) + + # ── state ──────────────────────────────────────────────────────────── + + def set_result(self, text: str, error: bool) -> None: + if self.pill.property("kind") == "declined": + return + self._output, self._error = text.rstrip(), error + self.pill.set_kind("error" if error else "ok") + self.icon.set_icon(self.icon._name, theme.RED if error else theme.TEXT_DIM) + self._render_output() + + def set_diff(self, old: str, new: str, limit: int = 40) -> None: + rows = [] + for sign, text, fg, bg in (("-", old, theme.RED, theme.DIFF_DEL_BG), + ("+", new, theme.GREEN, theme.DIFF_ADD_BG)): + lines = text.splitlines() or [""] + for line in lines[:limit]: + rows.append( + f'{sign} {html.escape(line) or " "}') + if len(lines) > limit: + rows.append(f'' + f' … {len(lines) - limit} more lines') + self.diff.setText('' + "".join(rows) + "
") + self.diff.show() + + def ask(self, question: str) -> None: + self.question.setText(question) + self.pill.set_kind("waiting") + self.setProperty("state", "waiting") + repolish(self) + self.approval.show() + self.allow_button.setFocus() + + def _answer(self, value: str) -> None: + self.approval.hide() + self.setProperty("state", "") + repolish(self) + if value == "no": + self.pill.set_kind("declined") + else: + self.pill.set_kind("running") + self.answered.emit(value) + + def toggle(self) -> None: + if self._output: + self._expanded = not self._expanded + self._render_output() + + def _render_output(self) -> None: + lines = align_columns(self._output.splitlines()) + if not lines: + self.output.hide() + self.chevron.hide() + return + long = len(lines) > COLLAPSED_LINES + shown = lines if (self._expanded or not long) else lines[:COLLAPSED_LINES] + text = "\n".join(shown) + if long and not self._expanded: + text += f"\n… {len(lines) - COLLAPSED_LINES} more lines — click to expand" + self.output.setText(text) + self.output.setStyleSheet(f"color: {theme.RED};" if self._error else "") + self.output.show() + self.chevron.setVisible(long) + + +def align_columns(lines): + """Tab-separated output (filectl list's "namesize") into padded + columns — a QLabel's tab stops don't line up with anything.""" + rows = [line.split(" ") for line in lines] + if not any(len(r) > 1 for r in rows): + return lines + width = max(len(r[0]) for r in rows if len(r) > 1) + return [r[0].ljust(width + 2) + " ".join(r[1:]) if len(r) > 1 else r[0] for r in rows] + + +class ThinkingIndicator(QWidget): + """Three dots breathing in sequence, next to a quiet 'Thinking'.""" + + def __init__(self, parent=None): + super().__init__(parent) + self.setFixedHeight(34) + self._phase = 0.0 + self._timer = QTimer(self) + self._timer.setInterval(40) + self._timer.timeout.connect(self._tick) + self.label = "Thinking" + + def start(self) -> None: + self._phase = 0.0 + self.show() + self._timer.start() + + def stop(self) -> None: + self._timer.stop() + self.hide() + + def _tick(self) -> None: + self._phase += 0.12 + self.update() + + def paintEvent(self, _event) -> None: + p = QPainter(self) + p.setRenderHint(QPainter.Antialiasing) + p.setPen(Qt.NoPen) + x, y = 50.0, self.height() / 2 + for i in range(3): + level = (math.sin(self._phase - i * 0.9) + 1) / 2 + colour = QColor(theme.ACCENT) + colour.setAlphaF(0.25 + 0.75 * level) + p.setBrush(colour) + r = 2.6 + 1.0 * level + p.drawEllipse(QRectF(x + i * 12 - r, y - r, 2 * r, 2 * r)) + p.setPen(QColor(theme.TEXT_FAINT)) + p.drawText(QRectF(x + 34, 0, 300, self.height()), Qt.AlignVCenter | Qt.AlignLeft, self.label) + + +# ── composer ──────────────────────────────────────────────────────────────── + +class _SendButton(QPushButton): + def __init__(self, parent=None): + super().__init__(parent) + self.setFixedSize(34, 34) + self.setCursor(Qt.PointingHandCursor) + self.setToolTip("Send (Enter)") + self.setFlat(True) + + def paintEvent(self, _event) -> None: + p = QPainter(self) + p.setRenderHint(QPainter.Antialiasing) + p.setPen(Qt.NoPen) + enabled = self.isEnabled() + fill = QColor(theme.ACCENT_HI if self.underMouse() and enabled else theme.ACCENT) + if not enabled: + fill = QColor(theme.BORDER_HI) + p.setBrush(fill) + p.drawEllipse(QRectF(self.rect()).adjusted(1, 1, -1, -1)) + icons.paint(p, "send", QRectF(self.rect()).adjusted(8, 8, -8, -8), + theme.ACCENT_INK if enabled else theme.TEXT_FAINT) + + +class MicButton(QPushButton): + """Dictation toggle. idle: a quiet mic outline. recording: red, with a + ring that swells with your voice, and a stop square. finishing: an amber + arc while the last words come back. unavailable: faint, and the tooltip + says what's missing.""" + + TIPS = { + "idle": "Dictate (Ctrl+M)\nClick, talk, click again (or pause) to stop", + "recording": "Listening. Click or pause to stop, Esc to cancel", + "finishing": "Transcribing...", + } + + def __init__(self, parent=None): + super().__init__(parent) + self.setFixedSize(34, 34) + self.setCursor(Qt.PointingHandCursor) + self.setFlat(True) + self.state = "idle" + self._level = 0.0 + self._phase = 0.0 + self._timer = QTimer(self) + self._timer.setInterval(33) + self._timer.timeout.connect(self._tick) + self.set_state("idle") + + def set_state(self, state: str, reason: str = "") -> None: + self.state = state + self.setToolTip(reason if state == "unavailable" else self.TIPS.get(state, "")) + if state in ("recording", "finishing"): + self._timer.start() + else: + self._timer.stop() + self._level = 0.0 + self.update() + + def set_level(self, level: float) -> None: + # Fast attack, slow release, so the ring breathes instead of flickering. + self._level = level if level > self._level else self._level * 0.85 + level * 0.15 + + def _tick(self) -> None: + self._phase += 0.2 + self._level *= 0.97 + self.update() + + def paintEvent(self, _event) -> None: + p = QPainter(self) + p.setRenderHint(QPainter.Antialiasing) + rect = QRectF(self.rect()).adjusted(1, 1, -1, -1) + centre = rect.center() + if self.state == "recording": + ring = QColor(theme.RED) + ring.setAlphaF(0.22 + 0.3 * self._level) + p.setPen(Qt.NoPen) + p.setBrush(ring) + p.drawEllipse(centre, 16.0, 16.0) + p.setBrush(QColor(theme.RED)) + radius = 10.5 + 3.5 * self._level + p.drawEllipse(centre, radius, radius) + icons.paint(p, "stop", QRectF(centre.x() - 8, centre.y() - 8, 16, 16), "#ffffff") + return + if self.state == "finishing": + pen = QColor(theme.ACCENT) + p.setPen(icons._pen(pen, 2.2)) + p.drawArc(rect.adjusted(4, 4, -4, -4), int(-self._phase * 16 * 57), 100 * 16) + icons.paint(p, "mic", rect.adjusted(9, 9, -9, -9), theme.TEXT_DIM) + return + if self.state == "idle" and self.underMouse(): + p.setPen(Qt.NoPen) + p.setBrush(QColor(theme.SURFACE_HI)) + p.drawEllipse(rect) + colour = theme.TEXT_FAINT if self.state == "unavailable" else ( + theme.TEXT if self.underMouse() else theme.TEXT_DIM) + icons.paint(p, "mic", rect.adjusted(8, 8, -8, -8), colour) + + +class _ComposerEdit(QPlainTextEdit): + submitted = Signal() + + def keyPressEvent(self, event) -> None: + if event.key() in (Qt.Key_Return, Qt.Key_Enter) and not (event.modifiers() & Qt.ShiftModifier): + self.submitted.emit() + return + super().keyPressEvent(event) + + +class Composer(QFrame): + """Rounded input that grows with its text. Enter sends, Shift+Enter + starts a new line. While a turn runs you can keep typing; sending waits.""" + + submitted = Signal(str) + MAX_LINES = 9 + + def __init__(self, parent=None): + super().__init__(parent) + self.setObjectName("Composer") + row = QHBoxLayout(self) + row.setContentsMargins(18, 10, 10, 10) + row.setSpacing(10) + self.edit = _ComposerEdit() + self.edit.setObjectName("ComposerEdit") + self.edit.setPlaceholderText("Ask Bolt to read, change or run something…") + self.edit.setWordWrapMode(QTextOption.WrapAtWordBoundaryOrAnywhere) + self.edit.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded) + self.edit.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + self.edit.document().setDocumentMargin(2) + self.edit.textChanged.connect(self._resize) + self.edit.submitted.connect(self._submit) + self.edit.installEventFilter(self) + row.addWidget(self.edit, 1, Qt.AlignVCenter) + self.mic = MicButton() + row.addWidget(self.mic, 0, Qt.AlignBottom) + self._dictation_prefix = None # composer text from before a recording + self.send = _SendButton() + self.send.clicked.connect(self._submit) + row.addWidget(self.send, 0, Qt.AlignBottom) + self._busy = False + self._resize() + self._sync_button() + self.edit.textChanged.connect(self._sync_button) + + def eventFilter(self, obj, event) -> bool: + if obj is self.edit and event.type() in (QEvent.FocusIn, QEvent.FocusOut): + self.setProperty("focused", event.type() == QEvent.FocusIn) + repolish(self) + return False + + def set_busy(self, busy: bool) -> None: + self._busy = busy + self._sync_button() + + def set_text(self, text: str) -> None: + self.edit.setPlainText(text) + cursor = self.edit.textCursor() + cursor.movePosition(QTextCursor.MoveOperation.End) + self.edit.setTextCursor(cursor) + self.edit.setFocus() + + # ── dictation: the words appear in the box as you speak ────────────── + + def begin_dictation(self) -> None: + self._dictation_prefix = self.edit.toPlainText().rstrip() + self.edit.setReadOnly(True) # typing into text that's being rewritten would be lost + self._sync_button() + + def show_dictation(self, final: str, interim: str = "") -> None: + if self._dictation_prefix is None: + return + spoken = " ".join(part for part in (final, interim) if part) + self._show(" ".join(part for part in (self._dictation_prefix, spoken) if part)) + + def end_dictation(self, text: str) -> None: + if self._dictation_prefix is None: + return + self._show(" ".join(part for part in (self._dictation_prefix, text.strip()) if part)) + self._dictation_prefix = None + self.edit.setReadOnly(False) + self.edit.setFocus() + self._sync_button() + + @property + def dictating(self) -> bool: + return self._dictation_prefix is not None + + def _show(self, text: str) -> None: + self.edit.setPlainText(text) + cursor = self.edit.textCursor() + cursor.movePosition(QTextCursor.MoveOperation.End) + self.edit.setTextCursor(cursor) + + def _sync_button(self) -> None: + ready = not self._busy and not self.dictating and bool(self.edit.toPlainText().strip()) + self.send.setEnabled(ready) + self.send.update() + + def _resize(self) -> None: + line = self.edit.fontMetrics().lineSpacing() + lines = max(1, min(self.MAX_LINES, int(self.edit.document().size().height()))) + self.edit.setFixedHeight(int(lines * line + 8)) + + def _submit(self) -> None: + text = self.edit.toPlainText().strip() + if not text or self._busy or self.dictating: + return + self.edit.clear() + self.submitted.emit(text) + + +# ── header ────────────────────────────────────────────────────────────────── + +class StatusDot(QWidget): + COLOURS = {"unknown": theme.TEXT_FAINT, "ok": theme.GREEN, "error": theme.RED} + + def __init__(self, parent=None): + super().__init__(parent) + self.setFixedSize(QSize(10, 10)) + self._state = "unknown" + + def set_state(self, state: str, tooltip: str) -> None: + self._state = state + self.setToolTip(tooltip) + self.update() + + def paintEvent(self, _event) -> None: + p = QPainter(self) + p.setRenderHint(QPainter.Antialiasing) + p.setPen(Qt.NoPen) + p.setBrush(QColor(self.COLOURS.get(self._state, theme.TEXT_FAINT))) + p.drawEllipse(QRectF(1, 1, 8, 8)) + + +class FolderChip(QPushButton): + """A pill button with a painted icon on the left (the folder by default).""" + + def __init__(self, parent=None, icon_name: str = "folder", text: str = ""): + super().__init__(text, parent) + self._icon_name = icon_name + self.setObjectName("Chip") + self.setCursor(Qt.PointingHandCursor) + self.setFixedHeight(30) + + def set_path(self, path: str) -> None: + name = path.rstrip("\\/").replace("\\", "/").split("/")[-1] or path + self.setText(name) + self.setToolTip(f"Working directory: {path}\nClick to change") + + def paintEvent(self, event) -> None: + super().paintEvent(event) + p = QPainter(self) + paint_colour = theme.TEXT if self.underMouse() else theme.TEXT_DIM + if not self.isEnabled(): + paint_colour = theme.TEXT_FAINT + icons.paint(p, self._icon_name, QRectF(10, (self.height() - 15) / 2, 15, 15), paint_colour) + + +class ModeSwitch(QFrame): + """Segmented control: ask before changes, or run everything.""" + + changed = Signal(str) + + def __init__(self, mode: str, parent=None): + super().__init__(parent) + self.setObjectName("Segment") + self.setFixedHeight(30) + row = QHBoxLayout(self) + row.setContentsMargins(2, 2, 2, 2) + row.setSpacing(2) + self._group = QButtonGroup(self) + self.buttons = {} + for value, text, tip in ( + ("ask", "Ask first", "Confirm shell commands, writes and edits"), + ("auto", "Auto", "Run everything Bolt asks for without confirming"), + ): + button = QPushButton(text) + button.setObjectName("SegmentButton") + button.setCheckable(True) + button.setCursor(Qt.PointingHandCursor) + button.setToolTip(tip) + if value == "auto": + button.setProperty("danger", True) + self._group.addButton(button) + row.addWidget(button) + self.buttons[value] = button + button.clicked.connect(lambda _=False, v=value: self.changed.emit(v)) + self.set_mode(mode) + + def set_mode(self, mode: str) -> None: + self.buttons.get(mode, self.buttons["ask"]).setChecked(True) + + +# ── empty state ───────────────────────────────────────────────────────────── + +SUGGESTIONS = ( + "What's in this folder?", + "Summarise the README", + "Git status and recent commits", + "Find TODO comments in this project", +) + + +class EmptyState(QWidget): + picked = Signal(str) + + def __init__(self, parent=None): + super().__init__(parent) + col = QVBoxLayout(self) + col.setContentsMargins(0, 0, 0, 0) + col.addStretch(3) + col.addWidget(icons.IconLabel("bolt", theme.ACCENT_INK, 52, tile=theme.ACCENT), 0, Qt.AlignHCenter) + col.addSpacing(18) + hero = _label("What are we working on?", "Hero") + hero.setAlignment(Qt.AlignHCenter) + col.addWidget(hero) + self.sub = _label("", "HeroSub") + self.sub.setAlignment(Qt.AlignHCenter) + col.addWidget(self.sub) + col.addSpacing(26) + grid = QVBoxLayout() + grid.setSpacing(10) + for i in range(0, len(SUGGESTIONS), 2): + pair = QHBoxLayout() + pair.setSpacing(10) + for text in SUGGESTIONS[i:i + 2]: + button = QPushButton(text) + button.setObjectName("Suggestion") + button.setCursor(Qt.PointingHandCursor) + button.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + button.clicked.connect(lambda _=False, t=text: self.picked.emit(t)) + pair.addWidget(button) + grid.addLayout(pair) + wrap = QHBoxLayout() + wrap.addStretch(1) + holder = QWidget() + holder.setLayout(grid) + holder.setMaximumWidth(560) + holder.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + wrap.addWidget(holder, 4) + wrap.addStretch(1) + col.addLayout(wrap) + col.addStretch(4) + + def set_folder(self, path: str) -> None: + self.sub.setText(f"Bolt can read, edit and run things in {path}") diff --git a/bolt_console/gui/window.py b/bolt_console/gui/window.py new file mode 100644 index 0000000..73bb6d2 --- /dev/null +++ b/bolt_console/gui/window.py @@ -0,0 +1,528 @@ +"""The main window and the worker thread a turn runs on. + +Turn logic is `console.Session` — the same object the terminal uses — driven +on a QThread with a `Bridge` standing in for the terminal. The window only +ever reacts to Bridge signals, so nothing here knows about HTTP or tools. +""" + +from __future__ import annotations + +import os +import sys +import threading +from typing import Optional + +from PySide6.QtCore import QObject, Qt, QThread, QTimer, Signal, Slot +from PySide6.QtGui import QFont, QKeySequence, QShortcut +from PySide6.QtWidgets import ( + QApplication, QFileDialog, QFrame, QHBoxLayout, QLabel, QMainWindow, + QScrollArea, QSizePolicy, QVBoxLayout, QWidget, +) + +from .. import __version__, config, dictation, server_client +from ..console import Session +from ..tools import Tools +from . import icons, theme +from .bridge import Bridge +from .chat_list_window import ChatListDialog, ChatSignals, run_in_pool +from .widgets import ( + AssistantMessage, Composer, EmptyState, FolderChip, HoldingLine, ModeSwitch, + Notice, StatusDot, ThinkingIndicator, ToolCard, UserMessage, +) + + +class TurnRunner(QObject): + """Lives on the worker thread; runs one Session.send per request.""" + + finished = Signal() + + def __init__(self, session: Session): + super().__init__() + self.session = session + + @Slot(str) + def run(self, text: str) -> None: + try: + self.session.send(text) + except Exception as exc: # never let a bug kill the worker thread + self.session.ui.error(f"Unexpected error: {exc}") + self.session.ui.stop_spinner() + finally: + self.finished.emit() + + +class HealthProbe(QObject): + done = Signal(bool, str) + + def start(self) -> None: + threading.Thread(target=self._probe, daemon=True).start() + + def _probe(self) -> None: + try: + server_client.check_health(timeout=8) + self.done.emit(True, f"Connected to {config.SERVER_URL}") + except Exception as exc: + self.done.emit(False, f"Can't reach {config.SERVER_URL}: {exc}") + + +class DictationSignals(QObject): + """Dictation calls back on background threads; these queue it onto the + GUI thread.""" + + update = Signal(str, str) # final text so far, current interim guess + level = Signal(float) # 0..1 mic loudness + done = Signal(str, str) # text, error ("" when none) + stopping = Signal() # recording over, transcript on its way + + +class Column(QWidget): + """Centres its content in a reading column of at most COLUMN_WIDTH.""" + + def __init__(self, parent=None): + super().__init__(parent) + row = QHBoxLayout(self) + row.setContentsMargins(24, 0, 24, 0) + row.addStretch(1) + self.inner = QWidget() + self.inner.setMaximumWidth(theme.COLUMN_WIDTH) + self.inner.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred) + row.addWidget(self.inner, 100) + row.addStretch(1) + self.layout_ = QVBoxLayout(self.inner) + self.layout_.setContentsMargins(0, 0, 0, 0) + + +class MainWindow(QMainWindow): + submit_turn = Signal(str) + HINT = "Enter to send · Shift+Enter for a new line · Ctrl+M dictate · Ctrl+N new chat" + + def __init__(self, mode: Optional[str] = None, session: Optional[Session] = None, + dictation_factory=None, list_chats=None, create_chat=None): + super().__init__() + # Injectable so tests never hit the network; the real ones run on the pool. + self._list_chats = list_chats or server_client.list_chats + self._create_chat = create_chat or server_client.create_chat + self._dictation_factory = dictation_factory or dictation.Dictation + self._dictation = None + self.setWindowTitle("Bolt") + self.setWindowIcon(icons.app_icon()) + self.resize(1040, 800) + self.setMinimumSize(620, 480) + + self.bridge = Bridge() + if session is None: + session = Session(self.bridge, tools=Tools(self.bridge, mode=mode or config.APPROVAL_MODE)) + self.session = session + self._cards = {} + self._assistant: Optional[AssistantMessage] = None + self._busy = False + self._stick_to_bottom = True + + root = QWidget() + root.setObjectName("Root") + self.setCentralWidget(root) + page = QVBoxLayout(root) + page.setContentsMargins(0, 0, 0, 0) + page.setSpacing(0) + page.addWidget(self._build_header()) + + # transcript + self.scroll = QScrollArea() + self.scroll.setWidgetResizable(True) + self.scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + transcript = QWidget() + transcript.setObjectName("Transcript") + body = QVBoxLayout(transcript) + body.setContentsMargins(0, 18, 0, 18) + self.column = Column() + body.addWidget(self.column) + body.addStretch(1) + self.entries = self.column.layout_ + self.entries.setSpacing(6) + self.empty = EmptyState() + self.empty.picked.connect(self._send) + self.entries.addWidget(self.empty) + self.thinking = ThinkingIndicator() + self.thinking.hide() + self.entries.addWidget(self.thinking) + self.scroll.setWidget(transcript) + self.scroll.verticalScrollBar().rangeChanged.connect(self._follow) + self.scroll.verticalScrollBar().valueChanged.connect(self._track_scroll) + page.addWidget(self.scroll, 1) + + # composer + dock = Column() + dock.layout_.setContentsMargins(0, 6, 0, 14) + dock.layout_.setSpacing(6) + self.composer = Composer() + self.composer.submitted.connect(self._send) + dock.layout_.addWidget(self.composer) + self.hint = QLabel(self.HINT) + self.hint.setObjectName("Hint") + self.hint.setAlignment(Qt.AlignHCenter) + dock.layout_.addWidget(self.hint) + self._hint_timer = QTimer(self) + self._hint_timer.setSingleShot(True) + self._hint_timer.timeout.connect(lambda: self.hint.setText(self.HINT)) + page.addWidget(dock) + + QShortcut(QKeySequence("Ctrl+L"), self, activated=self.clear_transcript) + QShortcut(QKeySequence("Ctrl+O"), self, activated=self.choose_folder) + QShortcut(QKeySequence("Ctrl+N"), self, activated=self.start_new_chat) + QShortcut(QKeySequence("Ctrl+K"), self, activated=self.open_chats) + self.chat_signals = ChatSignals() + self.chat_signals.created.connect(lambda session_id: self.switch_chat(session_id, "New chat")) + self.chat_signals.failed.connect(self._new_chat_failed) + self._creating_chat = False + QShortcut(QKeySequence("Ctrl+M"), self, activated=self.toggle_dictation) + QShortcut(QKeySequence("Escape"), self, activated=self.cancel_dictation) + + self.dictation_signals = DictationSignals() + self.dictation_signals.update.connect(self.composer.show_dictation) + self.dictation_signals.level.connect(self.composer.mic.set_level) + self.dictation_signals.done.connect(self._dictation_done) + self.dictation_signals.stopping.connect( + lambda: self._dictation is not None and self.composer.mic.set_state("finishing")) + self.composer.mic.clicked.connect(self.toggle_dictation) + self._mic_unavailable = dictation.unavailable_reason() + if self._mic_unavailable: + self.composer.mic.set_state("unavailable", self._mic_unavailable) + + self._wire_bridge() + self._start_worker() + self._refresh_folder() + self.health = HealthProbe() + self.health.done.connect(self._health_done) + QTimer.singleShot(0, self.health.start) + self.composer.edit.setFocus() + + # ── construction ───────────────────────────────────────────────────── + + def _build_header(self) -> QWidget: + header = QFrame() + header.setObjectName("Header") + header.setFixedHeight(56) + row = QHBoxLayout(header) + row.setContentsMargins(18, 0, 18, 0) + row.setSpacing(10) + row.addWidget(icons.IconLabel("bolt", theme.ACCENT_INK, 26, tile=theme.ACCENT)) + word = QLabel("Bolt") + word.setObjectName("Wordmark") + row.addWidget(word) + sub = QLabel(f"Console {__version__}") + sub.setObjectName("WordmarkSub") + row.addWidget(sub) + row.addSpacing(6) + self.status = StatusDot() + self.status.set_state("unknown", "Checking the server…") + row.addWidget(self.status) + row.addStretch(1) + self.chats = FolderChip(icon_name="chats", text="Chats") + self.chats.setToolTip("All your chats with Bolt, from every device (Ctrl+K)") + self.chats.clicked.connect(self.open_chats) + row.addWidget(self.chats) + self.new_chat = FolderChip(icon_name="plus", text="New chat") + self.new_chat.setToolTip("Start a fresh conversation (Ctrl+N)\n" + "Fresh history; Bolt still remembers you") + self.new_chat.clicked.connect(self.start_new_chat) + row.addWidget(self.new_chat) + self.folder = FolderChip() + self.folder.clicked.connect(self.choose_folder) + row.addWidget(self.folder) + self.mode = ModeSwitch(self.session.tools.mode) + self.mode.changed.connect(self._set_mode) + row.addWidget(self.mode) + return header + + def _wire_bridge(self) -> None: + b = self.bridge + b.busy.connect(self._set_busy) + b.chunk.connect(self._on_chunk) + b.reply_ended.connect(self._end_assistant) + b.reply.connect(self._on_reply) + b.holding_line.connect(lambda text: self._add(HoldingLine(text))) + b.tool_started.connect(self._on_tool_started) + b.tool_output.connect(self._on_tool_output) + b.tool_diff.connect(self._on_tool_diff) + b.approval_needed.connect(self._on_approval) + b.notice.connect(lambda level, text: self._add(Notice(level, text))) + + def _start_worker(self) -> None: + self.thread_ = QThread(self) + self.runner = TurnRunner(self.session) + self.runner.moveToThread(self.thread_) + self.submit_turn.connect(self.runner.run) + self.runner.finished.connect(self._turn_finished) + self.thread_.start() + + # ── transcript helpers ─────────────────────────────────────────────── + + def _add(self, widget: QWidget) -> None: + if self.empty.isVisible(): + self.empty.hide() + # Always insert above the thinking indicator, which stays last. + self.entries.insertWidget(self.entries.count() - 1, widget) + + def _track_scroll(self, value: int) -> None: + bar = self.scroll.verticalScrollBar() + self._stick_to_bottom = value >= bar.maximum() - 40 + + def _follow(self, _min: int, maximum: int) -> None: + if self._stick_to_bottom: + self.scroll.verticalScrollBar().setValue(maximum) + + def clear_transcript(self) -> None: + if self._busy: + return + keep = (self.empty, self.thinking) + for i in reversed(range(self.entries.count())): + widget = self.entries.itemAt(i).widget() + if widget is not None and widget not in keep: + self.entries.takeAt(i) + widget.deleteLater() + self._cards.clear() + self._assistant = None + self.empty.show() + + # ── user actions ───────────────────────────────────────────────────── + + # ── chats ──────────────────────────────────────────────────────────── + + def start_new_chat(self) -> None: + """Ask the server for a fresh chat id, off the UI thread; the switch + happens when it arrives (switch_chat).""" + if self._busy or self._creating_chat: + return + self._creating_chat = True + self.new_chat.setEnabled(False) + self._flash_hint("Starting a new chat...") + run_in_pool("create", self._create_chat, self.chat_signals) + + def _new_chat_failed(self, _operation: str, message: str) -> None: + self._creating_chat = False + self.new_chat.setEnabled(not self._busy) + self._flash_hint(f"Couldn't start a new chat: {message}") + + def open_chats(self) -> None: + if self._busy: + self._flash_hint("Wait for Bolt to finish before switching chats.") + return + dialog = ChatListDialog(self, list_chats=self._list_chats, create_chat=self._create_chat) + dialog.chosen.connect(self.switch_chat) + self.chat_dialog = dialog # kept for tests; the dialog is modal + dialog.open() + + def switch_chat(self, session_id: str, label: str = "") -> None: + """Point every server call at *session_id* and start the local + transcript over: there's no endpoint for a past chat's messages, so + the window can't show them (Bolt still has them server-side).""" + self._creating_chat = False + self.new_chat.setEnabled(not self._busy) + if self._busy: + return + note = self.session.switch_to(session_id) + self.clear_transcript() + self.chats.setToolTip(f"All your chats with Bolt, from every device (Ctrl+K)\n{note}") + self._flash_hint(f"Now in: {label or session_id}") + + def _send(self, text: str) -> None: + text = text.strip() + if not text or self._busy: + return + self._stick_to_bottom = True + self._add(UserMessage(text)) + self._assistant = None + self._set_busy(True) + self.submit_turn.emit(text) + + # ── dictation ──────────────────────────────────────────────────────── + + def toggle_dictation(self) -> None: + if self._mic_unavailable: + self._flash_hint(self._mic_unavailable) + return + if self._dictation is not None: + if self.composer.mic.state == "recording": + self.composer.mic.set_state("finishing") + self._dictation.stop() + return + sig = self.dictation_signals + recording = self._dictation_factory( + sig.update.emit, sig.level.emit, lambda text, error: sig.done.emit(text, error or ""), + silence_seconds=config.DICTATION_SILENCE_SECONDS, on_stopping=sig.stopping.emit, + ) + self.composer.begin_dictation() + try: + recording.start() + except Exception as exc: + self.composer.end_dictation("") + self._flash_hint(f"Couldn't open the microphone: {exc}") + return + self._dictation = recording + self.composer.mic.set_state("recording") + if not recording.live: + self._flash_hint("Live transcription is unavailable; the words will appear when you stop.") + + def cancel_dictation(self) -> None: + if self._dictation is not None: + self._dictation.cancel() + + def _dictation_done(self, text: str, error: str) -> None: + self._dictation = None + self.composer.mic.set_state("unavailable" if self._mic_unavailable else "idle", + self._mic_unavailable or "") + self.composer.end_dictation(text) + if error: + self._flash_hint(error) + + def _flash_hint(self, text: str, seconds: float = 6.0) -> None: + self.hint.setText(text) + self._hint_timer.start(int(seconds * 1000)) + + def choose_folder(self) -> None: + if self._busy: + return + path = QFileDialog.getExistingDirectory(self, "Working directory for Bolt", os.getcwd()) + if path: + try: + os.chdir(path) + except OSError as exc: + self._add(Notice("error", f"Couldn't switch to {path}: {exc}")) + return + self._refresh_folder() + if not self.empty.isVisible(): + self._add(Notice("info", f"Working directory is now {os.getcwd()}")) + + def _refresh_folder(self) -> None: + cwd = os.getcwd() + self.folder.set_path(cwd) + self.empty.set_folder(cwd) + + def _set_mode(self, mode: str) -> None: + self.session.tools.mode = mode + + # ── bridge slots ───────────────────────────────────────────────────── + + def _set_busy(self, busy: bool) -> None: + self._busy = busy + self.composer.set_busy(busy) + self.folder.setEnabled(not busy) + self.new_chat.setEnabled(not busy and not self._creating_chat) + self.chats.setEnabled(not busy) + if busy: + self.thinking.start() + else: + self.thinking.stop() + + def _turn_finished(self) -> None: + self._set_busy(False) + self._assistant = None + # Pick up a mode switched by "Always allow" on the worker side. + self.mode.set_mode(self.session.tools.mode) + self.composer.edit.setFocus() + + def _on_chunk(self, sentence: str) -> None: + if self._assistant is None: + self._assistant = AssistantMessage() + self._add(self._assistant) + self._assistant.append(sentence) + + def _end_assistant(self) -> None: + self._assistant = None + + def _on_reply(self, text: str) -> None: + self._add(AssistantMessage(text)) + self._assistant = None + + def _on_tool_started(self, tool_id: int, name: str, argument: str) -> None: + self._assistant = None # text after a tool call starts a new block + card = ToolCard(name, argument) + card.answered.connect(self._on_answer) + self._cards[tool_id] = card + self._add(card) + + def _on_tool_output(self, tool_id: int, text: str, error: bool) -> None: + card = self._cards.get(tool_id) + if card is not None: + card.set_result(text, error) + + def _on_tool_diff(self, tool_id: int, old: str, new: str) -> None: + card = self._cards.get(tool_id) + if card is not None: + card.set_diff(old, new) + + def _on_approval(self, tool_id: int, question: str) -> None: + card = self._cards.get(tool_id) + if card is None: + self.bridge.answer("no") + return + self.thinking.stop() + card.ask(question) + self._stick_to_bottom = True + QTimer.singleShot(0, lambda: self.scroll.ensureWidgetVisible(card, 0, 40)) + QApplication.alert(self) # flash the taskbar if the window is behind others + + def _on_answer(self, value: str) -> None: + if value == "always": + self.mode.set_mode("auto") + self.thinking.start() + self.bridge.answer(value) + + def _health_done(self, ok: bool, detail: str) -> None: + self.status.set_state("ok" if ok else "error", detail) + + # ── window chrome ──────────────────────────────────────────────────── + + def resizeEvent(self, event) -> None: + super().resizeEvent(event) + # The transcript grows from the top; give the empty state the whole + # viewport so its stretches can centre it. + self.empty.setMinimumHeight(max(0, self.scroll.viewport().height() - 40)) + + def showEvent(self, event) -> None: + super().showEvent(event) + theme.dark_title_bar(self) + + def closeEvent(self, event) -> None: + self.cancel_dictation() + self.bridge.close() + self.thread_.quit() + self.thread_.wait(1500) + super().closeEvent(event) + + +def make_app(argv=None) -> QApplication: + app = QApplication.instance() or QApplication(argv or sys.argv) + app.setApplicationName("Bolt Console") + app.setWindowIcon(icons.app_icon()) + app.setStyle("Fusion") # a neutral base, so the stylesheet decides the look + app.setPalette(theme.palette()) + font = QFont(theme.ui_font_family()) + font.setPointSizeF(10.5) + font.setHintingPreference(QFont.PreferNoHinting) + app.setFont(font) + app.setStyleSheet(theme.stylesheet()) + return app + + +def run(argv=None) -> int: + if sys.platform == "win32": + try: # own taskbar group + icon instead of python.exe's + import ctypes + + ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID("TheMajesticNetwork.BoltConsole") + except Exception: + pass + argv = list(sys.argv[1:] if argv is None else argv) + app = make_app() + if not config.is_configured(): + from PySide6.QtWidgets import QMessageBox + + QMessageBox.critical( + None, "Bolt Console", + "Missing " + ", ".join(config.missing_config()) + + ".\n\nCopy .env.example to .env next to the app and fill them in.") + return 2 + window = MainWindow(mode="auto" if "--auto" in argv else None) + window.show() + return app.exec() diff --git a/bolt_pet/relay_json.py b/bolt_console/relay_json.py similarity index 93% rename from bolt_pet/relay_json.py rename to bolt_console/relay_json.py index 71f4bfb..31c6ce4 100644 --- a/bolt_pet/relay_json.py +++ b/bolt_console/relay_json.py @@ -56,6 +56,15 @@ _REPAIRS: tuple[tuple[str, Callable[[str], str]], ...] = ( lambda text: text.translate(str.maketrans({"“": '"', "”": '"', "‘": "'", "’": "'"})), ), + ( + "doubled backslashes that weren't valid JSON escapes", + # {"path": "C:\Users\x"} -> {"path": "C:\\Users\\x"} — a Windows path + # typed raw. Only *invalid* escapes are touched, and `\\` pairs are + # consumed whole, so nothing that already parsed can change meaning. + lambda text: re.sub( + r'\\([\\/"bfnrtu])|\\', + lambda m: m.group(0) if m.group(1) else "\\\\", text), + ), ( "removed a stray quote after a bare value", # {"recursive":false"} -> {"recursive":false} diff --git a/bolt_console/server_client.py b/bolt_console/server_client.py new file mode 100644 index 0000000..92c0204 --- /dev/null +++ b/bolt_console/server_client.py @@ -0,0 +1,270 @@ +"""HTTP client for Bolt's desk API (ai/desk_api.py on the server). + +Protocol is identical to desk_client/bolt_desk.py in the main tmn-api repo — +the console is just another desk client, so it gets the exact same brain, +memory, tools, and persona as Discord chat and the other desk clients: + + text -> POST /desk/converse (or /desk/converse_stream) + [server may relay a command back to run on THIS machine] + ... -> POST /desk/tool_result (repeat until the server sends a reply) + reply <- returned to caller + +Kept dependency-free beyond `requests` so it's easy to unit test with mocks. +""" + +from __future__ import annotations + +import json +import os +import subprocess +from typing import Callable, NamedTuple, Optional + +import requests + +from . import config + +_MAX_RELAY_HOPS = 16 +_MAX_OUTPUT_CHARS = 6000 + + +class ServerError(Exception): + """Raised when the server responds with an error payload or unreachable.""" + + +class Reply(NamedTuple): + """One final reply from the desk API.""" + + text: str + # True when the text already reached the screen sentence by sentence while + # it streamed in — the caller must not print it a second time. + streamed: bool = False + + +def _headers() -> dict: + return {"X-Desk-Api-Key": config.API_KEY} + + +def check_health(timeout: float = 10.0) -> dict: + response = requests.get(f"{config.SERVER_URL}/desk/health", headers=_headers(), timeout=timeout) + response.raise_for_status() + return response.json() + + +def run_local_command(command: str, timeout: Optional[int] = None) -> str: + """Execute a relayed shell command as the current user, in the console's + working directory (see /cd). Output is captured for the server, so stdin + is closed rather than inherited — a command waiting on input would + otherwise hang on a prompt nobody can see. `sudo` still works: it asks on + the terminal itself, not on stdin.""" + timeout = timeout or config.COMMAND_TIMEOUT_SECONDS + try: + completed = subprocess.run( + command, shell=True, capture_output=True, text=True, errors="replace", + stdin=subprocess.DEVNULL, timeout=timeout, cwd=os.getcwd(), + # From the window (pythonw, no console) every cmd.exe would + # otherwise flash a console window. Output is captured either way. + creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), + ) + output = (completed.stdout or "") + (completed.stderr or "") + return f"[exit {completed.returncode}]\n{output}"[:_MAX_OUTPUT_CHARS] + except subprocess.TimeoutExpired: + return f"[command timed out after {timeout}s]" + except Exception as exc: + return f"[command failed: {exc}]" + + +def _reply_from(payload: dict, streamed: bool = False) -> Reply: + if payload.get("type") == "reply": + return Reply(text=str(payload.get("text") or ""), streamed=streamed) + raise ServerError(str(payload.get("error") or "unknown server response")) + + +def _relay(payload: dict, on_command, on_say) -> dict: + """Run every command the server hands back, posting each output to + /desk/tool_result, until the payload is no longer a command.""" + for _ in range(_MAX_RELAY_HOPS): + if payload.get("type") != "command": + break + holding = str(payload.get("say") or "").strip() + if holding and on_say is not None: + # Shown *before* the command runs — that's the point of it. + try: + on_say(holding) + except Exception: + pass # a failed acknowledgement must not cost the tool call + output = on_command(str(payload.get("command") or "")) + try: + response = requests.post( + f"{config.SERVER_URL}/desk/tool_result", + json={"session_id": config.SESSION_ID, + "token": payload.get("token"), "output": output}, + headers=_headers(), timeout=180, + ) + payload = response.json() + except Exception as exc: + raise ServerError(f"couldn't reach the server during tool relay: {exc}") from exc + return payload + + +def converse( + text: str, + on_command: Callable[[str], str] = run_local_command, + timeout: float = 120.0, + on_say: Optional[Callable[[str], None]] = None, +) -> Reply: + """Send one turn of conversation, relaying any commands the server sends + back until it produces a final reply. + + *on_say* receives the short holding line ("give me a sec") the server can + send alongside a command.""" + try: + response = requests.post( + f"{config.SERVER_URL}/desk/converse", + json={"session_id": config.SESSION_ID, "text": text}, + headers=_headers(), timeout=timeout, + ) + payload = response.json() + except Exception as exc: + raise ServerError(f"couldn't reach the server: {exc}") from exc + return _reply_from(_relay(payload, on_command, on_say)) + + +def converse_stream( + text: str, + on_say: Callable[[str], None], + on_command: Callable[[str], str] = run_local_command, + timeout: float = 180.0, +) -> Reply: + """Same turn as converse(), but handing each sentence to *on_say* as it + arrives, so the wait is time-to-first-sentence. + + Raises ServerError if the stream fails before anything was shown — the + caller then retries with converse(). Once a sentence *has* been shown, + a late failure ends the turn with what was said rather than repeating it. + """ + shown_anything = False + said = [] + try: + response = requests.post( + f"{config.SERVER_URL}/desk/converse_stream", + json={"session_id": config.SESSION_ID, "text": text}, + headers=_headers(), timeout=timeout, stream=True, + ) + response.raise_for_status() + for raw in response.iter_lines(decode_unicode=True): + if not raw: + continue + try: + event = json.loads(raw) + except (TypeError, ValueError): + continue + kind = str(event.get("type") or "") + + if kind == "say": + line = str(event.get("text") or "").strip() + if line: + shown_anything = True + said.append(line) + on_say(line) + elif kind == "command": + # The tool loop is request/response, so the rest of the turn + # finishes through the ordinary relay rather than the stream. + response.close() + return _reply_from(_relay(event, on_command, on_say)) + elif kind == "reply": + if event.get("already_spoken"): + return _reply_from(event, streamed=True) + return _unsaid(_reply_from(event), said) + elif kind == "error": + raise ServerError(str(event.get("error") or "stream failed")) + except ServerError: + raise + except Exception as exc: + if shown_anything: + return Reply(text="", streamed=True) + raise ServerError(f"streaming failed: {exc}") from exc + raise ServerError("stream ended without a reply") + + +def _unsaid(reply: Reply, said: list) -> Reply: + """The part of *reply* not already shown as streamed sentences. + + Seen live: the server streams the sentences, then sends the whole text + again in the final reply *without* `already_spoken`, so everything showed + twice. Compared ignoring whitespace, since the stream splits on sentences + and drops the line breaks the full text keeps.""" + if not said: + return reply + target = len("".join("".join(said).split())) # non-whitespace characters shown + seen, cut = 0, None + for index, char in enumerate(reply.text): + if not char.isspace(): + seen += 1 + if seen == target: + cut = index + 1 + break + if cut is None or "".join(reply.text[:cut].split()) != "".join("".join(said).split()): + return reply # a different text, not a repeat: show all of it + rest = reply.text[cut:].strip() + return Reply(text=rest, streamed=not rest) + + +def list_chats(timeout: float = 15.0) -> list: + """This user's conversations across every client (console, pet, phone), + newest first — each {session_id, label, last_message, last_active}. A + chat only appears once it's been used, so a just-minted one isn't here.""" + try: + response = requests.get( + f"{config.SERVER_URL}/desk/chats", headers=_headers(), timeout=timeout, + ) + response.raise_for_status() + return list(response.json().get("chats") or []) + except Exception as exc: + raise ServerError(f"couldn't list chats: {exc}") from exc + + +def create_chat(timeout: float = 15.0) -> str: + """Mint a brand-new, never-used session id on the server. Bolt's memory is + scoped to the user, not the session, so a new chat is fresh history, not + a memory reset.""" + try: + response = requests.post( + f"{config.SERVER_URL}/desk/chats", headers=_headers(), timeout=timeout, + ) + response.raise_for_status() + session_id = str(response.json().get("session_id") or "") + except Exception as exc: + raise ServerError(f"couldn't create a chat: {exc}") from exc + if not session_id: + raise ServerError("the server didn't return a session_id for the new chat") + return session_id + + +def list_outbox_files(timeout: float = 15.0) -> list: + """Files the server has queued for this session via its deliver_files + tool — each entry has id/name/size. Downloading one dequeues it + server-side, so a file is only ever handed out once.""" + try: + response = requests.get( + f"{config.SERVER_URL}/desk/files", + params={"session_id": config.SESSION_ID}, + headers=_headers(), timeout=timeout, + ) + response.raise_for_status() + return list(response.json().get("files") or []) + except Exception as exc: + raise ServerError(f"couldn't list delivered files: {exc}") from exc + + +def download_outbox_file(file_id: str, timeout: float = 60.0) -> bytes: + """Fetches and dequeues one file listed by list_outbox_files().""" + try: + response = requests.get( + f"{config.SERVER_URL}/desk/files/{file_id}", + params={"session_id": config.SESSION_ID}, + headers=_headers(), timeout=timeout, + ) + response.raise_for_status() + return response.content + except Exception as exc: + raise ServerError(f"couldn't download delivered file {file_id!r}: {exc}") from exc diff --git a/bolt_console/tools.py b/bolt_console/tools.py new file mode 100644 index 0000000..fb0efb1 --- /dev/null +++ b/bolt_console/tools.py @@ -0,0 +1,201 @@ +"""What happens to a command the server relays back to this machine. + +The desk API has one tool channel — a `command` string — and this module is +where the console decides what that string means: + + filectl file list/read/write/edit (file_ops.py), no shell + petctl / dialoguectl the pet's body and voice — answered with an + explanation instead of being run, since there is + no pet here and `petctl` isn't a real program + anything else a real shell command, in the console's cwd + +Reading never asks. Anything that changes the machine (a shell command, a +write, an edit) asks first unless the session is in auto mode — the pet ran +everything unasked because nobody was sitting at a prompt to answer; a +console has someone right there, so the default is to use them. +""" + +from __future__ import annotations + +import re +from typing import Callable + +from . import file_ops, server_client + +# Pseudo-commands from the pet's vocabulary. The server may still emit them — +# the session is a pet session as far as it knows — so they get an answer the +# model can act on rather than "'petctl' is not recognized as a command". +_PET_PREFIXES = ("petctl", "bolt-pet", "dialoguectl") + +PET_UNAVAILABLE = ( + "[console] {name} isn't available: this is Bolt Console, a text-only terminal " + "client with no pet body, voice, or screen reading. Use filectl for files and " + "ordinary shell commands for everything else." +) + +DECLINED = ( + "[console] the user declined this {what}, so it did not run. Don't retry it " + "as-is; ask what they'd like instead." +) + + +def is_pet_command(command: str) -> bool: + parts = (command or "").split(None, 1) + return bool(parts) and parts[0].lower() in _PET_PREFIXES + + +# The model sometimes writes the tool marker's own label into its argument +# ("command: powershell -Command ...") or wraps the whole thing in backticks. +# Neither is ever part of a real command — cmd.exe answers "'command:' is not +# recognized" — so they're stripped, and the model is told, like relay_json's +# repairs: a silent fix works today and guarantees the same mistake tomorrow. +_LABEL = re.compile(r"^(?:command|cmd)\s*:\s*", re.IGNORECASE) + + +def clean_command(command: str): + """Returns (command, repairs) with a stray marker label and a wrapping + pair of backticks removed.""" + command = (command or "").strip() + repairs = [] + stripped = _LABEL.sub("", command, count=1) + if stripped != command: + repairs.append('removed a leading "command:" label') + command = stripped.strip() + if len(command) > 2 and command[0] == command[-1] == "`" and "`" not in command[1:-1]: + repairs.append("removed wrapping backticks") + command = command[1:-1].strip() + return command, repairs + + +def repair_note(repairs) -> str: + if not repairs: + return "" + return ( + "\n(note: your command was malformed — I " + "; ".join(repairs) + + " and ran it anyway. Send only the command itself next time.)" + ) + + +class Tools: + """The `on_command` callable handed to server_client.converse*. + + *ui* is a ui.Terminal (or anything with tool_call/tool_result/diff/ + confirm); *run_shell* is injectable so tests never spawn a process.""" + + def __init__( + self, + ui, + mode: str = "ask", + run_shell: Callable[[str], str] = server_client.run_local_command, + ): + self.ui = ui + self.mode = "auto" if mode == "auto" else "ask" + self.run_shell = run_shell + # Commands that actually reached this machine — the console compares + # it with what a reply *claims* happened (see console.fabricated). + self.calls = 0 + + def __call__(self, command: str) -> str: + return self.handle(command) + + def handle(self, command: str) -> str: + self.calls += 1 + command, repairs = clean_command(command) + if not command: + return "[console] empty command" + return self._dispatch(command) + repair_note(repairs) + + def _dispatch(self, command: str) -> str: + + if is_pet_command(command): + name = command.split(None, 1)[0] + self.ui.tool_call("Pet", command) + output = PET_UNAVAILABLE.format(name=name) + self.ui.tool_result("not available in the console", error=True) + return output + + try: + action = file_ops.parse(command) + except file_ops.FileOpError as exc: + self.ui.tool_call("File", command) + self.ui.tool_result(str(exc), error=True) + return f"[filectl] {exc}" + if action is not None: + return self._file(action) + + return self._shell(command) + + # ── approval ───────────────────────────────────────────────────────── + + def _approved(self, question: str) -> bool: + if self.mode == "auto": + return True + answer = self.ui.confirm(question) + if answer == "always": + self.mode = "auto" + return True + return answer == "yes" + + # ── shell ──────────────────────────────────────────────────────────── + + def _shell(self, command: str) -> str: + self.ui.tool_call("Shell", command) + if not self._approved("Run this command?"): + self.ui.tool_result("declined", error=True) + return DECLINED.format(what="command") + output = self.run_shell(command) + status, _, body = output.partition("\n") + failed = status != "[exit 0]" + # A clean exit shows just the output; a failure keeps its status line. + shown = body.strip() or status + if failed and body.strip(): + shown = f"{status}\n{body.strip()}" + self.ui.tool_result(shown, error=failed) + return output + + # ── filectl ────────────────────────────────────────────────────────── + + def _file(self, action: dict) -> str: + kind = action["action"] + path = action.get("path", "") + if kind == "help": + return file_ops.execute(action) + + if kind == "list": + label = path if action["pattern"] == "*" else f"{path}, {action['pattern']}" + self.ui.tool_call("List", label + (", recursive" if action["recursive"] else "")) + elif kind == "read": + span = "" + if action.get("start") or action.get("end"): + span = f", lines {action.get('start') or 1}-{action.get('end') or 'end'}" + self.ui.tool_call("Read", path + span) + elif kind == "write": + lines = action["content"].count("\n") + 1 + self.ui.tool_call("Write", path) + self.ui.tool_result(f"{lines} lines, {len(action['content'])} chars") + if not self._approved("Write this file?"): + return self._declined("write") + elif kind == "edit": + self.ui.tool_call("Edit", path) + self.ui.diff(action["old"], action["new"]) + if not self._approved("Make this edit?"): + return self._declined("edit") + + try: + output = file_ops.execute(action) + except file_ops.FileOpError as exc: + self.ui.tool_result(str(exc), error=True) + return f"[filectl] {exc}" + + if kind == "read": + count = output.count("\n") + 1 if output != "[empty file]" else 0 + self.ui.tool_result(f"Read {count} lines") + elif kind == "list": + self.ui.tool_result(output) + else: + self.ui.tool_result(output.replace("[filectl] ", "")) + return output + + def _declined(self, what: str) -> str: + self.ui.tool_result("declined", error=True) + return DECLINED.format(what=what) diff --git a/bolt_console/ui.py b/bolt_console/ui.py new file mode 100644 index 0000000..9e7ed0c --- /dev/null +++ b/bolt_console/ui.py @@ -0,0 +1,257 @@ +"""Terminal rendering: colour, the thinking spinner, tool-call lines, prompts. + +Plain ANSI and the standard library only, so the console has no dependency +beyond `requests`. Everything writes through `Terminal._emit`, which holds the +same lock the spinner thread draws under — that is what keeps a spinner frame +from landing in the middle of a line of output. +""" + +from __future__ import annotations + +import itertools +import os +import sys +import threading +import time +from typing import Callable, List, Optional, TextIO + +_RESET = "\033[0m" +_STYLES = { + "bold": "\033[1m", + "dim": "\033[2m", + "red": "\033[31m", + "green": "\033[32m", + "yellow": "\033[33m", + "blue": "\033[34m", + "magenta": "\033[35m", + "cyan": "\033[36m", +} + +# Output lines shown under a tool call before collapsing to "… +N lines". +TOOL_PREVIEW_LINES = 4 + + +def enable_windows_ansi() -> None: + """Turn on VT escape processing for the Windows console (off by default + for older hosts). Harmless everywhere else.""" + if os.name != "nt": + return + try: + import ctypes + + kernel32 = ctypes.windll.kernel32 + handle = kernel32.GetStdHandle(-11) # STD_OUTPUT_HANDLE + mode = ctypes.c_uint32() + if kernel32.GetConsoleMode(handle, ctypes.byref(mode)): + kernel32.SetConsoleMode(handle, mode.value | 0x0004) # ENABLE_VIRTUAL_TERMINAL_PROCESSING + except Exception: + pass + + +def _supports_unicode(stream: TextIO) -> bool: + encoding = (getattr(stream, "encoding", "") or "").lower().replace("-", "") + return encoding.startswith("utf") + + +class Glyphs: + def __init__(self, unicode: bool): + self.bullet = "●" if unicode else "*" + self.elbow = "⎿" if unicode else "L" + self.ellipsis = "…" if unicode else "..." + self.prompt = "›" if unicode else ">" + self.spinner = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏" if unicode else "|/-\\" + + +def preview(text: str, limit: int = TOOL_PREVIEW_LINES) -> List[str]: + """The first *limit* lines of *text*, plus a count of what was cut.""" + lines = text.rstrip("\n").splitlines() or [""] + if len(lines) <= limit: + return lines + return lines[:limit] + [f"... +{len(lines) - limit} lines"] + + +class Terminal: + def __init__( + self, + out: Optional[TextIO] = None, + color: bool = True, + input_fn: Callable[[str], str] = input, + animate: Optional[bool] = None, + ): + self.out = out or sys.stdout + tty = bool(getattr(self.out, "isatty", lambda: False)()) + self.color = color and tty + self.animate = tty if animate is None else animate + self.glyphs = Glyphs(_supports_unicode(self.out)) + self._input = input_fn + self._lock = threading.RLock() + self._spinner_label = "" + self._spinner_started = 0.0 + self._spinner_line = False # a spinner frame is currently on screen + self._spinner_thread: Optional[threading.Thread] = None + self._spinner_stop = threading.Event() + self._spinner_paused = False + self._in_reply = False # streamed sentences continue one bulleted block + + # ── primitives ─────────────────────────────────────────────────────── + + def style(self, text: str, *names: str) -> str: + if not self.color or not names: + return text + return "".join(_STYLES[n] for n in names) + text + _RESET + + def _clear_spinner_line(self) -> None: + if self._spinner_line: + self.out.write("\r\033[2K" if self.color else "\r" + " " * 60 + "\r") + self._spinner_line = False + + def _emit(self, text: str) -> None: + with self._lock: + self._clear_spinner_line() + self.out.write(text) + self.out.flush() + + def line(self, text: str = "") -> None: + self._emit(text + "\n") + + # ── spinner ────────────────────────────────────────────────────────── + + def start_spinner(self, label: str = "Thinking") -> None: + self._spinner_label = label + self._spinner_started = time.monotonic() + self._spinner_paused = False + if not self.animate or self._spinner_thread is not None: + return + self._spinner_stop.clear() + self._spinner_thread = threading.Thread(target=self._spin, daemon=True) + self._spinner_thread.start() + + def stop_spinner(self) -> None: + thread = self._spinner_thread + if thread is not None: + self._spinner_stop.set() + thread.join(timeout=1) + self._spinner_thread = None + with self._lock: + self._clear_spinner_line() + self.out.flush() + + def _spin(self) -> None: + for frame in itertools.cycle(self.glyphs.spinner): + if self._spinner_stop.wait(0.1): + return + with self._lock: + if self._spinner_paused: + continue + elapsed = int(time.monotonic() - self._spinner_started) + text = f"{frame} {self._spinner_label}{self.glyphs.ellipsis} ({elapsed}s, ctrl+c to interrupt)" + self.out.write("\r" + self.style(text, "magenta")) + self.out.flush() + self._spinner_line = True + + # ── conversation ───────────────────────────────────────────────────── + + def _bulleted(self, text: str, *bullet_style: str, body_style=()) -> None: + self._in_reply = False + lines = text.splitlines() or [""] + first, rest = lines[0], lines[1:] + out = [self.style(self.glyphs.bullet, *bullet_style) + " " + self.style(first, *body_style)] + out += [" " + self.style(line, *body_style) for line in rest] + self.line("\n".join(out)) + + def assistant(self, text: str) -> None: + if text.strip(): + self._bulleted(text.strip()) + self.line() + + def assistant_chunk(self, sentence: str) -> None: + """One streamed sentence. The first opens a bulleted block and the + rest continue it on their own lines — never mid-line, because the + spinner redraws by erasing the current line.""" + sentence = sentence.strip() + if not sentence: + return + if self._in_reply: + self.line("\n".join(" " + part for part in sentence.splitlines())) + else: + self._bulleted(sentence) + self._in_reply = True + + def end_reply(self) -> None: + self._in_reply = False + self.line() + + def holding(self, text: str) -> None: + self._bulleted(text.strip(), "dim", body_style=("dim",)) + + def tool_call(self, name: str, argument: str) -> None: + argument = argument if len(argument) <= 160 else argument[:157] + "..." + self._bulleted(self.style(name, "bold") + self.style(f"({argument})", "dim"), "green") + + def tool_result(self, text: str, error: bool = False) -> None: + lines = preview(text) + elbow = self.glyphs.elbow + rendered = [f" {elbow} " + self.style(lines[0], "red" if error else "dim")] + rendered += [" " + self.style(line, "red" if error else "dim") for line in lines[1:]] + self.line("\n".join(rendered)) + + def diff(self, old: str, new: str, limit: int = 12) -> None: + rows = [] + for sign, text, colour in (("-", old, "red"), ("+", new, "green")): + lines = text.splitlines() or [""] + for line in lines[:limit]: + rows.append(" " + self.style(f"{sign} {line}", colour)) + if len(lines) > limit: + rows.append(" " + self.style(f" ... +{len(lines) - limit} lines", "dim")) + self.line("\n".join(rows)) + + def info(self, text: str) -> None: + self.line(self.style(text, "dim")) + + def warn(self, text: str) -> None: + self.line(self.style(text, "yellow")) + + def error(self, text: str) -> None: + self.line(self.style(text, "red")) + + # ── input ──────────────────────────────────────────────────────────── + + def ask(self, prompt: str) -> str: + """Read one line with the spinner held off the screen. EOF and + Ctrl+C both come back as an empty answer — the caller decides what + an empty answer means.""" + with self._lock: + self._spinner_paused = True + self._clear_spinner_line() + self.out.flush() + try: + return self._input(prompt) + except (EOFError, KeyboardInterrupt): + self.line() + return "" + finally: + with self._lock: + self._spinner_paused = False + self._spinner_started = time.monotonic() + + def confirm(self, question: str) -> str: + """y / n / a(lways). Anything unrecognised — including Ctrl+C — is no.""" + answer = self.ask( + " " + self.style(question, "yellow") + " " + + self.style("[y]es / [n]o / [a]lways this session", "dim") + " " + ).strip().lower() + if answer in ("y", "yes"): + return "yes" + if answer in ("a", "always"): + return "always" + return "no" + + def read_message(self) -> str: + """The main prompt. A trailing backslash continues onto the next line. + Raises EOFError/KeyboardInterrupt for the REPL to handle.""" + prompt = self.style(self.glyphs.prompt + " ", "bold", "cyan") + lines = [self._input(prompt)] + while lines[-1].endswith("\\"): + lines[-1] = lines[-1][:-1] + lines.append(self._input(self.style(self.glyphs.ellipsis + " ", "dim"))) + return "\n".join(lines) diff --git a/bolt_pet/__init__.py b/bolt_pet/__init__.py deleted file mode 100644 index 1814c29..0000000 --- a/bolt_pet/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -"""Bolt desktop pet. - -__version__ is what the auto-updater compares against the newest tag on the -Gitea releases page (see updater.py), so bump it in the same commit you tag. -""" - -__version__ = "0.2.3" diff --git a/bolt_pet/__main__.py b/bolt_pet/__main__.py deleted file mode 100644 index 868f596..0000000 --- a/bolt_pet/__main__.py +++ /dev/null @@ -1,15 +0,0 @@ -"""Entry point: python -m bolt_pet [--doctor [--deep]]""" - -import sys - -if __name__ == "__main__": - if "--doctor" in sys.argv: - # Imported inside the branch, not at module scope: the doctor exists to - # diagnose installs where importing the UI would itself blow up. - from .doctor import main as doctor - - sys.exit(doctor(sys.argv[1:])) - - from .ui.app import run - - sys.exit(run()) diff --git a/bolt_pet/assets/sprites/README.md b/bolt_pet/assets/sprites/README.md deleted file mode 100644 index 3bea938..0000000 --- a/bolt_pet/assets/sprites/README.md +++ /dev/null @@ -1,92 +0,0 @@ -# Sprite assets - -Art: Bolt himself — a cream shepherd pup with a slate cap, a lightning blaze -on his forehead and a bolt tag on his collar. The frames are **generated, not -hand-drawn**: `scripts/generate_bolt_sprites.py` draws every one of them with -Pillow and writes this folder. - -```bash -python scripts/generate_bolt_sprites.py # rewrite this folder -python scripts/generate_bolt_sprites.py --out /tmp/prev # preview elsewhere first -python scripts/generate_bolt_sprites.py --states idle # just one state -``` - -That means tweaking the art is editing code, not 24 PNGs: the palette is a -block of constants at the top of the script, the body/head/ear/tail shapes are -one function each in normalised 0..1 coordinates, and each state's animation is -a list of pose dicts in `frames_for()`. Everything is super-sampled 4x and -downscaled on save, because PIL's draw primitives have no antialiasing. - -**Regenerate after editing** — the PNGs here are committed, so a change to the -script alone doesn't move the pet. - -Convention the loader (`bolt_pet/ui/sprite.py`) expects: - -``` -assets/sprites/ - idle/ frame_00..07.png breathing, tail wag, blink on frame 06 - listening/ frame_00..03.png ears perked, head tilted in, collar tag lit, sound arcs - thinking/ frame_00..05.png eyes up, head cocked, cycling dots - talking/ frame_00..03.png mouth open/close with tongue, ears bouncing - error/ frame_00..01.png X eyes, ears drooped, red spark - walk/ frame_00..07.png side-view walk cycle (see below) -``` - -- One subfolder per pet state (matches `bolt_pet.state.PetState`), **plus - `walk/`**, which is not a state — see below. -- Any `*.png` filenames work — they're played back in alphabetical-sort - order, looping, at `IDLE_ANIMATION_FPS` (see `.env`). At the default 6fps - the 8-frame idle loop runs about 1.3s. -- Frames are square (320px, 2x the default `PET_SIZE` of 160) so they - downscale cleanly; the loader scales to fit `PET_SIZE` keeping aspect ratio - and centres them in the square pet window. -- A state directory with no frames in it falls back to a small - procedurally-drawn placeholder blob (see `_placeholder_frames` in - `sprite.py`). - -## The walk cycle - -`walk/` is the one animation that isn't a `PetState`. Walking is a property of -*movement* — orthogonal to whether he's idle, listening or talking — so it -stays out of the state machine and is keyed by name instead -(`sprite.EXTRA_ANIMATIONS`). `PetWindow` uses it whenever the pet is actually -travelling and falls back to the state animation the moment it stops. - -Three things about it are load-bearing if you redraw it: - -- **It's a side view, drawn facing right.** The other poses are a - front-facing sit, which is fine standing still but slides like a chess - piece when moving. `PetWindow._oriented()` mirrors the frames (cached) when - he walks left, so only the right-facing version exists on disk. -- **The cycle is advanced by distance travelled, not by the animation - timer** (`_WALK_PIXELS_PER_FRAME`, one frame per ~13px). That's what keeps - a planted paw tracking backwards at exactly the speed the window moves - forwards. Drive it off the clock and the feet skate whenever - `PET_WANDER_SPEED` doesn't happen to match `IDLE_ANIMATION_FPS`. If you - change the number of frames or the stride length in - `paw_position()`, retune that constant to match or he'll moonwalk. -- **The frames carry their own vertical bob**, so the window's own bob is - switched off while they're in use. Only the no-walk-art fallback still - bobs in code. - -Delete `walk/` and everything still runs — he reverts to sliding with a small -coded bob, which is what the pet did before the cycle existed. - -## Swapping in different art - -Replace any state's PNGs (same alphabetical-order-loops convention) to -change its look — no code changes needed, and nothing forces you to keep -using the generator. If your source is a single grid spritesheet (rows/cols -of frames in one PNG) rather than one-file-per-frame, use -`scripts/slice_spritesheet.py` to cut it into this folder-of-frames -convention: - -```bash -python scripts/slice_spritesheet.py path/to/idle_sheet.png assets/sprites/idle \ - --cols 6 --rows 1 -``` - -If your format is something else entirely (a single animated GIF/APNG, a -Spine/DragonBones skeletal export, an Aseprite `.json` atlas, etc.) — tell me -the format and I'll adapt `sprite.py`'s loader rather than making you convert -by hand. diff --git a/bolt_pet/assets/sprites/error/frame_00.png b/bolt_pet/assets/sprites/error/frame_00.png deleted file mode 100644 index 6cd4216..0000000 Binary files a/bolt_pet/assets/sprites/error/frame_00.png and /dev/null differ diff --git a/bolt_pet/assets/sprites/error/frame_01.png b/bolt_pet/assets/sprites/error/frame_01.png deleted file mode 100644 index 75096f7..0000000 Binary files a/bolt_pet/assets/sprites/error/frame_01.png and /dev/null differ diff --git a/bolt_pet/assets/sprites/idle/frame_00.png b/bolt_pet/assets/sprites/idle/frame_00.png deleted file mode 100644 index fecd36b..0000000 Binary files a/bolt_pet/assets/sprites/idle/frame_00.png and /dev/null differ diff --git a/bolt_pet/assets/sprites/idle/frame_01.png b/bolt_pet/assets/sprites/idle/frame_01.png deleted file mode 100644 index 69fe990..0000000 Binary files a/bolt_pet/assets/sprites/idle/frame_01.png and /dev/null differ diff --git a/bolt_pet/assets/sprites/idle/frame_02.png b/bolt_pet/assets/sprites/idle/frame_02.png deleted file mode 100644 index 8abfcf9..0000000 Binary files a/bolt_pet/assets/sprites/idle/frame_02.png and /dev/null differ diff --git a/bolt_pet/assets/sprites/idle/frame_03.png b/bolt_pet/assets/sprites/idle/frame_03.png deleted file mode 100644 index 6d4fd50..0000000 Binary files a/bolt_pet/assets/sprites/idle/frame_03.png and /dev/null differ diff --git a/bolt_pet/assets/sprites/idle/frame_04.png b/bolt_pet/assets/sprites/idle/frame_04.png deleted file mode 100644 index 92f4ff5..0000000 Binary files a/bolt_pet/assets/sprites/idle/frame_04.png and /dev/null differ diff --git a/bolt_pet/assets/sprites/idle/frame_05.png b/bolt_pet/assets/sprites/idle/frame_05.png deleted file mode 100644 index 700d555..0000000 Binary files a/bolt_pet/assets/sprites/idle/frame_05.png and /dev/null differ diff --git a/bolt_pet/assets/sprites/idle/frame_06.png b/bolt_pet/assets/sprites/idle/frame_06.png deleted file mode 100644 index 66b151c..0000000 Binary files a/bolt_pet/assets/sprites/idle/frame_06.png and /dev/null differ diff --git a/bolt_pet/assets/sprites/idle/frame_07.png b/bolt_pet/assets/sprites/idle/frame_07.png deleted file mode 100644 index ad5cd07..0000000 Binary files a/bolt_pet/assets/sprites/idle/frame_07.png and /dev/null differ diff --git a/bolt_pet/assets/sprites/listening/frame_00.png b/bolt_pet/assets/sprites/listening/frame_00.png deleted file mode 100644 index 3f10756..0000000 Binary files a/bolt_pet/assets/sprites/listening/frame_00.png and /dev/null differ diff --git a/bolt_pet/assets/sprites/listening/frame_01.png b/bolt_pet/assets/sprites/listening/frame_01.png deleted file mode 100644 index 88a1ab2..0000000 Binary files a/bolt_pet/assets/sprites/listening/frame_01.png and /dev/null differ diff --git a/bolt_pet/assets/sprites/listening/frame_02.png b/bolt_pet/assets/sprites/listening/frame_02.png deleted file mode 100644 index 39b59fd..0000000 Binary files a/bolt_pet/assets/sprites/listening/frame_02.png and /dev/null differ diff --git a/bolt_pet/assets/sprites/listening/frame_03.png b/bolt_pet/assets/sprites/listening/frame_03.png deleted file mode 100644 index 5f07e44..0000000 Binary files a/bolt_pet/assets/sprites/listening/frame_03.png and /dev/null differ diff --git a/bolt_pet/assets/sprites/listening/frame_04.png b/bolt_pet/assets/sprites/listening/frame_04.png deleted file mode 100644 index 729e550..0000000 Binary files a/bolt_pet/assets/sprites/listening/frame_04.png and /dev/null differ diff --git a/bolt_pet/assets/sprites/listening/frame_05.png b/bolt_pet/assets/sprites/listening/frame_05.png deleted file mode 100644 index b5ed0f0..0000000 Binary files a/bolt_pet/assets/sprites/listening/frame_05.png and /dev/null differ diff --git a/bolt_pet/assets/sprites/talking/frame_00.png b/bolt_pet/assets/sprites/talking/frame_00.png deleted file mode 100644 index 0de4378..0000000 Binary files a/bolt_pet/assets/sprites/talking/frame_00.png and /dev/null differ diff --git a/bolt_pet/assets/sprites/talking/frame_01.png b/bolt_pet/assets/sprites/talking/frame_01.png deleted file mode 100644 index 2953b89..0000000 Binary files a/bolt_pet/assets/sprites/talking/frame_01.png and /dev/null differ diff --git a/bolt_pet/assets/sprites/talking/frame_02.png b/bolt_pet/assets/sprites/talking/frame_02.png deleted file mode 100644 index 47f2497..0000000 Binary files a/bolt_pet/assets/sprites/talking/frame_02.png and /dev/null differ diff --git a/bolt_pet/assets/sprites/talking/frame_03.png b/bolt_pet/assets/sprites/talking/frame_03.png deleted file mode 100644 index 6e1930d..0000000 Binary files a/bolt_pet/assets/sprites/talking/frame_03.png and /dev/null differ diff --git a/bolt_pet/assets/sprites/talking/frame_04.png b/bolt_pet/assets/sprites/talking/frame_04.png deleted file mode 100644 index 357e401..0000000 Binary files a/bolt_pet/assets/sprites/talking/frame_04.png and /dev/null differ diff --git a/bolt_pet/assets/sprites/talking/frame_05.png b/bolt_pet/assets/sprites/talking/frame_05.png deleted file mode 100644 index 6c4eae5..0000000 Binary files a/bolt_pet/assets/sprites/talking/frame_05.png and /dev/null differ diff --git a/bolt_pet/assets/sprites/thinking/frame_00.png b/bolt_pet/assets/sprites/thinking/frame_00.png deleted file mode 100644 index 3499dad..0000000 Binary files a/bolt_pet/assets/sprites/thinking/frame_00.png and /dev/null differ diff --git a/bolt_pet/assets/sprites/thinking/frame_01.png b/bolt_pet/assets/sprites/thinking/frame_01.png deleted file mode 100644 index e59ffef..0000000 Binary files a/bolt_pet/assets/sprites/thinking/frame_01.png and /dev/null differ diff --git a/bolt_pet/assets/sprites/thinking/frame_02.png b/bolt_pet/assets/sprites/thinking/frame_02.png deleted file mode 100644 index c0a8f81..0000000 Binary files a/bolt_pet/assets/sprites/thinking/frame_02.png and /dev/null differ diff --git a/bolt_pet/assets/sprites/thinking/frame_03.png b/bolt_pet/assets/sprites/thinking/frame_03.png deleted file mode 100644 index 74a8967..0000000 Binary files a/bolt_pet/assets/sprites/thinking/frame_03.png and /dev/null differ diff --git a/bolt_pet/assets/sprites/thinking/frame_04.png b/bolt_pet/assets/sprites/thinking/frame_04.png deleted file mode 100644 index c694357..0000000 Binary files a/bolt_pet/assets/sprites/thinking/frame_04.png and /dev/null differ diff --git a/bolt_pet/assets/sprites/thinking/frame_05.png b/bolt_pet/assets/sprites/thinking/frame_05.png deleted file mode 100644 index f74e595..0000000 Binary files a/bolt_pet/assets/sprites/thinking/frame_05.png and /dev/null differ diff --git a/bolt_pet/assets/sprites/walk/frame_00.png b/bolt_pet/assets/sprites/walk/frame_00.png deleted file mode 100644 index 253de5d..0000000 Binary files a/bolt_pet/assets/sprites/walk/frame_00.png and /dev/null differ diff --git a/bolt_pet/assets/sprites/walk/frame_01.png b/bolt_pet/assets/sprites/walk/frame_01.png deleted file mode 100644 index dc391d8..0000000 Binary files a/bolt_pet/assets/sprites/walk/frame_01.png and /dev/null differ diff --git a/bolt_pet/assets/sprites/walk/frame_02.png b/bolt_pet/assets/sprites/walk/frame_02.png deleted file mode 100644 index 4b98eae..0000000 Binary files a/bolt_pet/assets/sprites/walk/frame_02.png and /dev/null differ diff --git a/bolt_pet/assets/sprites/walk/frame_03.png b/bolt_pet/assets/sprites/walk/frame_03.png deleted file mode 100644 index d7ebe50..0000000 Binary files a/bolt_pet/assets/sprites/walk/frame_03.png and /dev/null differ diff --git a/bolt_pet/assets/sprites/walk/frame_04.png b/bolt_pet/assets/sprites/walk/frame_04.png deleted file mode 100644 index e5704fc..0000000 Binary files a/bolt_pet/assets/sprites/walk/frame_04.png and /dev/null differ diff --git a/bolt_pet/assets/sprites/walk/frame_05.png b/bolt_pet/assets/sprites/walk/frame_05.png deleted file mode 100644 index bbbc2aa..0000000 Binary files a/bolt_pet/assets/sprites/walk/frame_05.png and /dev/null differ diff --git a/bolt_pet/assets/sprites/walk/frame_06.png b/bolt_pet/assets/sprites/walk/frame_06.png deleted file mode 100644 index b5d4495..0000000 Binary files a/bolt_pet/assets/sprites/walk/frame_06.png and /dev/null differ diff --git a/bolt_pet/assets/sprites/walk/frame_07.png b/bolt_pet/assets/sprites/walk/frame_07.png deleted file mode 100644 index 737ef92..0000000 Binary files a/bolt_pet/assets/sprites/walk/frame_07.png and /dev/null differ diff --git a/bolt_pet/audio/__init__.py b/bolt_pet/audio/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/bolt_pet/audio/barge_in.py b/bolt_pet/audio/barge_in.py deleted file mode 100644 index e487dca..0000000 --- a/bolt_pet/audio/barge_in.py +++ /dev/null @@ -1,190 +0,0 @@ -"""Barge-in: notice that the user wants to interrupt *while the pet is -talking* so playback can be cut short mid-sentence. - -Two detectors, picked by BARGE_IN_MODE: - -- **wake** (default) — the interruption has to be the wake word. Every mic - frame goes through the same openWakeWord model the idle listener uses, so - a sneeze, a door, or the TV can't cut Bolt off mid-sentence; only saying - "thunderbolt" does. -- **energy** — the original behaviour: N consecutive frames above - BARGE_IN_RMS_THRESHOLD. Faster to trigger and needs no model inference, - but it fires on any sustained noise. Deliberately dumber than the - utterance VAD in mic.py, since the mic hears the pet's own voice coming - back out of the speakers, so the threshold defaults to 4x the VAD one. - -Both take the same injectable stream shape as mic.record_utterance and expose -the same reset()/check() pair, so tests feed them fake frames instead of real -audio hardware and controller.py doesn't care which one it holds. -""" - -from __future__ import annotations - -from typing import Callable, Optional, Union - -import numpy as np - -from .. import config -from .mic import AudioStream, rms -from .wake_word import WakeModel, _default_model, hard_reset - - -def _read_frame(stream: AudioStream, frame_len: int) -> Optional[np.ndarray]: - """One mono frame, or None if the mic hiccuped or gave us nothing. Never - raises: a bad frame mid-playback should mean "no barge-in this frame", - not a dead reply.""" - try: - chunk, _ = stream.read(frame_len) - except Exception: - return None - frame = np.asarray(chunk) - if frame.ndim > 1: - frame = frame[:, 0] - return frame if frame.size else None - - -class BargeInDetector: - """Energy mode. Poll-driven: call check() repeatedly while audio plays. - Each call consumes exactly one mic frame (80ms at the default frame - length), which is also what paces the playback loop's polling.""" - - def __init__( - self, - stream: AudioStream, - threshold: int = None, - required_frames: int = None, - frame_len: int = config.FRAME_LEN, - ): - self._stream = stream - self._threshold = config.BARGE_IN_RMS_THRESHOLD if threshold is None else threshold - self._required = max(1, config.BARGE_IN_FRAMES if required_frames is None else required_frames) - self._frame_len = frame_len - self._loud_frames = 0 - - @property - def loud_frames(self) -> int: - return self._loud_frames - - def reset(self) -> None: - self._loud_frames = 0 - - def check(self) -> bool: - """True once the user has been loud for long enough to count as an - interruption.""" - frame = _read_frame(self._stream, self._frame_len) - if frame is None: - return False - if rms(frame) >= self._threshold: - self._loud_frames += 1 - else: - self._loud_frames = 0 # a single thump/cough shouldn't count - return self._loud_frames >= self._required - - -class WakeWordBargeIn: - """Wake-word mode: only "thunderbolt" interrupts. - - Same per-frame predict() loop as listen_for_wake_word, just driven by the - playback poll instead of its own read loop. The model instance is shared - with the idle listener by default — the two never run at the same time - (the pipeline is either speaking or listening), and reusing it avoids - loading a second copy of the ONNX graph. - - Two wrinkles the energy detector doesn't have: - - - The mic hears the pet's own voice, so the model is scoring Bolt's - speech too. That's harmless unless Bolt says its own wake word, which - is why the threshold can be raised independently - (BARGE_IN_WAKE_THRESHOLD) without desensitizing the idle listener. - - reset() has to be a *hard* reset. openwakeword keeps ~10s of audio - history in its preprocessor, so the "thunderbolt" that started this - turn is still in the model's window when playback begins — feed it one - new frame and it fires on the old phrase, cutting the reply off a word - in. Clearing that window is what makes wake-mode barge-in work at all. - """ - - def __init__( - self, - stream: AudioStream, - model: Optional[WakeModel] = None, - threshold: Union[float, Callable[[], float], None] = None, - frame_len: int = config.FRAME_LEN, - on_score: Optional[Callable[[float, float], None]] = None, - ): - self._stream = stream - self._model = model if model is not None else _default_model - if threshold is None: - threshold = config.BARGE_IN_WAKE_THRESHOLD or config.WAKE_WORD_THRESHOLD - self._resolve_threshold = threshold if callable(threshold) else (lambda: threshold) - self._frame_len = frame_len - self._on_score = on_score - self._frames = 0 - self._peak = 0.0 - self._last = 0.0 - self._last_threshold = 0.0 - - # Scoring history for the current reply. Without this an interruption is - # indistinguishable from a crash in the logs — you can't tell a genuine - # "thunderbolt" from the model firing on Bolt's own voice, or on the first - # frame (a stale window) versus halfway through (something it heard). - @property - def frames_checked(self) -> int: - return self._frames - - @property - def seconds_checked(self) -> float: - return self._frames * self._frame_len / config.SAMPLE_RATE - - @property - def peak_score(self) -> float: - return self._peak - - @property - def last_score(self) -> float: - return self._last - - @property - def last_threshold(self) -> float: - return self._last_threshold - - def reset(self) -> None: - hard_reset(self._model) # never raises - self._frames = 0 - self._peak = 0.0 - self._last = 0.0 - - def check(self) -> bool: - frame = _read_frame(self._stream, self._frame_len) - if frame is None: - return False - try: - scores = self._model.predict(frame) - except Exception: - return False # same contract as a mic hiccup: no barge-in, no crash - threshold = self._resolve_threshold() - best = max(scores.values()) if scores else 0.0 - self._frames += 1 - self._last = best - self._peak = max(self._peak, best) - self._last_threshold = threshold - if self._on_score is not None: - self._on_score(best, threshold) - if scores and best >= threshold: - self.reset() - return True - return False - - -def make_detector( - stream: AudioStream, - mode: str = None, - wake_threshold: Union[float, Callable[[], float], None] = None, - on_score: Optional[Callable[[float, float], None]] = None, -): - """Build whichever detector BARGE_IN_MODE asks for. An unrecognized mode - falls back to energy rather than raising — a typo in .env shouldn't stop - the pet from starting.""" - mode = (config.BARGE_IN_MODE if mode is None else mode).strip().lower() - if mode in ("wake", "wakeword", "wake_word"): - return WakeWordBargeIn(stream, threshold=wake_threshold, on_score=on_score) - return BargeInDetector(stream) diff --git a/bolt_pet/audio/mic.py b/bolt_pet/audio/mic.py deleted file mode 100644 index c8e2502..0000000 --- a/bolt_pet/audio/mic.py +++ /dev/null @@ -1,161 +0,0 @@ -"""Mic capture + simple energy-based VAD utterance recording. - -Ported from desk_client/bolt_desk.py's record_utterance() — same tuning -knobs, same behavior. Kept independent of any UI/threading model so it can -be unit tested by feeding it a fake "stream" object. -""" - -from __future__ import annotations - -import io -import wave -from typing import Optional, Protocol - -import numpy as np - -from .. import config - - -class AudioStream(Protocol): - """Minimal shape of the object record_utterance() needs — matches - sounddevice.InputStream's .read(frames) -> (data, overflowed).""" - - def read(self, frames: int): ... - - -def pcm_to_wav_bytes(pcm: np.ndarray, sample_rate: int = config.SAMPLE_RATE) -> bytes: - buf = io.BytesIO() - with wave.open(buf, "wb") as wf: - wf.setnchannels(1) - wf.setsampwidth(2) - wf.setframerate(sample_rate) - wf.writeframes(pcm.tobytes()) - return buf.getvalue() - - -def rms(frame: np.ndarray) -> float: - return float(np.sqrt(np.mean(frame.astype(np.float64) ** 2))) - - -def _emit(on_frame, frame) -> None: - """Hand a frame to a listener without letting it break the capture. - - Streaming STT is an optimisation riding along with recording; if its - socket dies mid-utterance the recording must carry on untouched, because - the one-shot fallback is about to need the full buffer.""" - if on_frame is None: - return - try: - on_frame(frame) - except Exception: - pass - - -def record_utterance( - stream: AudioStream, - should_continue=lambda: True, - rms_threshold: int = None, - silence_end_sec: float = None, - max_utterance_s: float = None, - min_utterance_s: float = None, - grace_s: float = None, - frame_len: int = config.FRAME_LEN, - sample_rate: int = config.SAMPLE_RATE, - on_frame=None, -) -> Optional[np.ndarray]: - """Capture one utterance from *stream*: wait for speech to start, stop - after trailing silence. Returns None if nothing usable was heard. - - *should_continue* is polled each frame so a caller can cancel recording - (e.g. the pet window was closed) without needing threading primitives - baked into this function. - - *on_frame*, if given, is called with each captured frame while speech is - in progress — that is how streaming STT transcribes as you talk rather - than after (see audio/stt_stream.py). It is fire-and-forget: this function - still returns the full buffer, so a failed stream costs nothing. - - *grace_s* is how long to wait for speech to *begin* before giving up. - The controller stretches it for follow-up questions, where you're being - asked something and need a moment to think rather than having just said - the wake word on purpose. - """ - rms_threshold = config.RMS_THRESHOLD if rms_threshold is None else rms_threshold - silence_end_sec = config.SILENCE_END_SEC if silence_end_sec is None else silence_end_sec - max_utterance_s = config.MAX_UTTERANCE_S if max_utterance_s is None else max_utterance_s - min_utterance_s = config.MIN_UTTERANCE_S if min_utterance_s is None else min_utterance_s - grace_s = config.GRACE_SECONDS if grace_s is None else grace_s - - frames: list[np.ndarray] = [] - started = False - silence_frames = 0 - silence_limit = int(silence_end_sec * sample_rate / frame_len) - max_frames = int(max_utterance_s * sample_rate / frame_len) - grace_frames = int(grace_s * sample_rate / frame_len) # how long to wait for speech to begin - waited = 0 - - while should_continue(): - chunk, _ = stream.read(frame_len) - frame = np.asarray(chunk)[:, 0].copy() - frame_rms = rms(frame) - if not started: - waited += 1 - if frame_rms >= rms_threshold: - started = True - frames.append(frame) - _emit(on_frame, frame) - elif waited > grace_frames: - return None # woke it up but said nothing - continue - frames.append(frame) - _emit(on_frame, frame) - if frame_rms < rms_threshold: - silence_frames += 1 - if silence_frames >= silence_limit: - break - else: - silence_frames = 0 - if len(frames) >= max_frames: - break - - if not frames: - return None - pcm = np.concatenate(frames) - if len(pcm) < min_utterance_s * sample_rate: - return None - return pcm - - -def list_input_devices() -> list[dict]: - """Every input-capable device sounddevice can see, as - {"index", "name", "hostapi"}. - - On Windows in particular, the *same* physical (or virtual) device is - normally listed once per host API — MME, DirectSound, WASAPI, WDM-KS — - all sharing (or near enough sharing) one name. That makes the name alone - an unreliable, often ambiguous way to pick a device (sounddevice matches - a string device as a substring and refuses to guess between ties), so the - tray's mic picker saves the numeric index instead, which this list exists - to let it show alongside a human-readable label.""" - import sounddevice as sd - - devices = [] - for index, info in enumerate(sd.query_devices()): - if info.get("max_input_channels", 0) > 0: - hostapi = sd.query_hostapis(info["hostapi"])["name"] - devices.append({"index": index, "name": info["name"], "hostapi": hostapi}) - return devices - - -def open_input_stream(): - """Real sounddevice input stream, imported lazily so pure-logic tests - (record_utterance with a fake stream) don't need PortAudio installed.""" - import sounddevice as sd - - return sd.InputStream( - samplerate=config.SAMPLE_RATE, - channels=1, - dtype="int16", - blocksize=config.FRAME_LEN, - device=config.MIC_DEVICE, - ) diff --git a/bolt_pet/audio/stt.py b/bolt_pet/audio/stt.py deleted file mode 100644 index 903baec..0000000 --- a/bolt_pet/audio/stt.py +++ /dev/null @@ -1,33 +0,0 @@ -"""Speech-to-text for the actual query, after the wake word fires. - -The "guaranteed" fallback for when stt_stream's opportunistic live-feed -session didn't produce a transcript (streaming disabled, or the socket -never came up). There is no separate one-shot REST endpoint server-side any -more — /desk/stt is a websocket relay only — so this connects the exact -same way stt_stream.py does and just feeds the whole buffered utterance in -one go instead of frame-by-frame as it's captured. That connection is -unconditional (not gated by STT_STREAMING, which only controls the -opportunistic optimisation), since there is nothing left to fall back to -if it fails. -""" - -from __future__ import annotations - -from .. import config -from . import stt_stream - - -class SttError(Exception): - pass - - -def transcribe(pcm) -> str: - if not config.is_configured(): - raise SttError("BOLT_SERVER_URL / DESK_API_KEY not set") - try: - socket = stt_stream.connect() - except Exception as exc: - raise SttError(f"couldn't reach the transcription server: {exc}") from exc - session = stt_stream.StreamingTranscriber(socket) - session.feed(pcm) - return session.finish() diff --git a/bolt_pet/audio/stt_stream.py b/bolt_pet/audio/stt_stream.py deleted file mode 100644 index 1c9b58e..0000000 --- a/bolt_pet/audio/stt_stream.py +++ /dev/null @@ -1,202 +0,0 @@ -"""Streaming speech-to-text — transcribing *while* you talk, not after. - -The one-shot path (`stt.transcribe`) waits for the utterance to finish, then -sends the whole clip and waits again. That second wait is dead time between -you stopping and the pet reacting, and it grows with the length of what you -said — a thirty-second question costs noticeably more than a five-second one. - -This connects to the Bolt server's own `/desk/stt` — the same websocket relay -the Android app uses — which forwards audio to Deepgram and Deepgram's JSON -messages back untouched. Frames go up as they are captured, so by the time the -VAD decides you have stopped, the transcript is essentially already there. -There is no local Deepgram account or API key any more; auth is this pet's own -`DESK_API_KEY`, same as every other call to the server. - -Design constraints that shaped this: - -- **Failure must be invisible.** No websocket, no network, a mid-utterance - disconnect — all of it falls back to the one-shot path, which still has the - full audio buffered. Streaming is an optimisation here, never a dependency — - `available()`/`open()` returning None/False is an ordinary outcome, not an - error. `stt.transcribe()` (the *guaranteed* fallback) talks to the exact same - server relay via `connect()` directly, bypassing that opportunistic gate, - since there is no second, different backend left to fall back to. -- **The VAD still decides when you stopped.** Deepgram has its own endpointing - and using it would save more, but it would also move a decision the rest of - the pipeline is built around (barge-in, follow-up listening, the grace - period) into a remote service. Not worth coupling those to the network on - the first pass. -- **The socket is per-utterance.** Holding one open across an idle pet would - bill for silence and drop on the first network blip; opening one takes - ~100ms, which is already inside the time it takes a person to start talking. - -The websocket client is injectable, so the whole protocol — send frames, read -`is_final` transcripts, close, take the result — is tested without a network. -""" - -from __future__ import annotations - -import json -import logging -import threading -from typing import Callable, Optional - -import numpy as np - -from .. import config - -logger = logging.getLogger("bolt_pet.stt_stream") - - -def available() -> bool: - """Whether the streaming (transcribe-while-talking) optimization should - be attempted opportunistically. Not a gate on transcription itself — the - server relay is the only way to transcribe at all now, so - stt.transcribe() connects via connect() directly rather than through - this, and isn't affected by STT_STREAMING being off.""" - if not config.STT_STREAMING or not config.is_configured(): - return False - try: - import websocket # noqa: F401 (websocket-client) - return True - except Exception: - return False - - -def _connect(sample_rate: int = None): - """Open a websocket to the server's `/desk/stt` relay. Raises on any - failure — this is the "no fallback left" connector `stt.transcribe()` - uses directly, as well as the default for `StreamingTranscriber.open()`. - - The server chooses the STT model and the endpointing behaviour; this - only states the audio format about to be sent, which is fixed by the - wake model upstream of it.""" - import websocket - - rate = sample_rate or config.SAMPLE_RATE - url = ( - config.SERVER_URL.replace("http", "ws", 1) - + "/desk/stt" - + f"?session_id={config.SESSION_ID}&encoding=linear16&sample_rate={rate}" - ) - return websocket.create_connection( - url, header=[f"X-Desk-Api-Key: {config.API_KEY}"], timeout=10, - ) - - -# Public name for external callers (stt.py, tests) — named separately from -# the module-private def so StreamingTranscriber.open()'s `connect` parameter -# can shadow the bare name locally without losing access to this. -connect = _connect - - -class StreamingTranscriber: - """One utterance's worth of live transcription. - - Usage mirrors how the capture loop already works — feed frames as they - arrive, then ask what was said: - - session = StreamingTranscriber.open() - ... - session.feed(frame) # per mic frame, non-blocking - text = session.finish() # after the VAD says you stopped - """ - - def __init__(self, socket, *, sample_rate: int = None): - self._socket = socket - self._sample_rate = sample_rate or config.SAMPLE_RATE - self._transcript: list[str] = [] - self._lock = threading.Lock() - self._closed = False - self._reader = threading.Thread( - target=self._read_loop, name="stt-stream-reader", daemon=True) - self._reader.start() - - # -- lifecycle ---------------------------------------------------------- - - @classmethod - def open(cls, *, connect: Optional[Callable] = None, - sample_rate: int = None) -> Optional["StreamingTranscriber"]: - """Connect, or return None if streaming isn't possible right now. - - None is a normal outcome, not a failure: the caller keeps the audio and - falls back to the one-shot upload.""" - if connect is None and not available(): - return None - rate = sample_rate or config.SAMPLE_RATE - try: - socket = connect() if connect is not None else _connect(rate) - return cls(socket, sample_rate=rate) - except Exception as exc: - logger.info("Streaming STT unavailable (%s) — using the one-shot path.", exc) - return None - - def feed(self, frame: np.ndarray) -> None: - """Send one captured frame. Never raises — a dead socket just means the - fallback will do the work.""" - if self._closed: - return - try: - self._socket.send_binary(np.asarray(frame, dtype=np.int16).tobytes()) - except Exception: - logger.debug("Streaming STT send failed; abandoning the stream", exc_info=True) - self._closed = True - - def finish(self, timeout: float = 3.0) -> str: - """Close the stream and return whatever was transcribed. - - Deepgram flushes its final results after the close frame, so this waits - briefly for the reader — bounded, because a hung socket must not hold - up the reply.""" - if not self._closed: - try: - self._socket.send(json.dumps({"type": "CloseStream"})) - except Exception: - pass - self._closed = True - self._reader.join(timeout=timeout) - try: - self._socket.close() - except Exception: - pass - with self._lock: - return " ".join(part for part in self._transcript if part).strip() - - # -- the reader --------------------------------------------------------- - - def _read_loop(self) -> None: - while not self._closed: - try: - message = self._socket.recv() - except Exception: - break - if not message: - break - text, is_final = self._parse(message) - if text and is_final: - with self._lock: - self._transcript.append(text) - - @staticmethod - def _parse(message) -> tuple[str, bool]: - """Pull (text, is_final) out of a Deepgram results frame. - - Tolerant on purpose: anything unrecognised is ignored rather than - raising on the reader thread, where an exception would silently kill - transcription for the rest of the utterance.""" - try: - if isinstance(message, bytes): - message = message.decode("utf-8", "ignore") - data = json.loads(message) - except (TypeError, ValueError): - return "", False - if not isinstance(data, dict): - return "", False - alternatives = ( - ((data.get("channel") or {}).get("alternatives") or []) - if data.get("type") in (None, "Results") else [] - ) - if not alternatives: - return "", False - text = str((alternatives[0] or {}).get("transcript") or "").strip() - return text, bool(data.get("is_final") or data.get("speech_final")) diff --git a/bolt_pet/audio/tts.py b/bolt_pet/audio/tts.py deleted file mode 100644 index ebcd1eb..0000000 --- a/bolt_pet/audio/tts.py +++ /dev/null @@ -1,300 +0,0 @@ -"""Text-to-speech: the Bolt server's own `/desk/tts` — the same endpoint the -Android app streams from — requested as raw PCM so playback is just -sounddevice — no external player binary (mpv/ffplay), unlike -desk_client/bolt_desk.py which shells out because it only targets Linux. - -No local ElevenLabs account needed for this: the server picks the voice -(ELEVENLABS_VOICE_ID, or a `speak_as` override) and the synthesis model -itself, authenticated with this pet's own DESK_API_KEY. Falls back to -pyttsx3 (offline, cross-platform: SAPI5 on Windows, NSSpeech on macOS, -espeak on Linux) if the server call fails, so the pet can still talk even -with the server unreachable. - -Every entry point takes an optional *voice_id* that overrides -`ELEVENLABS_VOICE_ID` for that call — that's how the server's `speak_as` -reply marker reaches the speakers (see controller._apply_voice). The offline -fallback has no such concept and always sounds like itself. - -`synthesize_dialogue()` below is the one exception: multi-voice -`dialoguectl` scenes have no server endpoint, so that one call still goes -to ElevenLabs' Text to Dialogue API directly and still needs -ELEVENLABS_API_KEY — see dialogue.py. -""" - -from __future__ import annotations - -import time -from typing import Iterable, Iterator, Optional - -import numpy as np -import requests - -from .. import config, speech_text - - -class TtsError(Exception): - pass - - -def voice_for(voice_id: Optional[str] = None) -> str: - """The voice this call should use: an override (server `speak_as`) if - given, else the configured default.""" - return (voice_id or "").strip() or config.ELEVENLABS_VOICE_ID - - -def _headers() -> dict: - return {"X-Desk-Api-Key": config.API_KEY} - - -def synthesize_pcm(text: str, voice_id: Optional[str] = None) -> tuple[np.ndarray, int]: - """Returns (pcm_int16_mono, sample_rate). Raises TtsError on failure — - callers should fall back to speak_offline() rather than treating this - as fatal.""" - voice = voice_for(voice_id) - if not (config.is_configured() and voice): - raise TtsError("BOLT_SERVER_URL / DESK_API_KEY / ELEVENLABS_VOICE_ID not set") - try: - response = requests.post( - f"{config.SERVER_URL}/desk/tts", - headers=_headers(), - json={"session_id": config.SESSION_ID, "text": text, "voice_id": voice}, - timeout=60, - ) - response.raise_for_status() - except Exception as exc: - raise TtsError(f"server tts request failed: {exc}") from exc - pcm = np.frombuffer(response.content, dtype=np.int16) - if pcm.size == 0: - raise TtsError("server returned no audio") - return pcm, config.TTS_SAMPLE_RATE - - -def stream_pcm( - text: str, chunk_bytes: int = 4096, voice_id: Optional[str] = None -) -> Iterator[np.ndarray]: - """Same audio as synthesize_pcm(), but yielded as it arrives from the - server so playback can start on the first chunk instead of after the - whole clip is synthesized. Raises TtsError before yielding anything if - the request itself fails, so callers can fall back cleanly; a mid-stream - failure just ends the generator.""" - voice = voice_for(voice_id) - if not (config.is_configured() and voice): - raise TtsError("BOLT_SERVER_URL / DESK_API_KEY / ELEVENLABS_VOICE_ID not set") - try: - response = requests.post( - f"{config.SERVER_URL}/desk/tts", - headers=_headers(), - json={"session_id": config.SESSION_ID, "text": text, "voice_id": voice}, - timeout=60, - stream=True, - ) - response.raise_for_status() - except Exception as exc: - raise TtsError(f"server tts stream request failed: {exc}") from exc - return chunks_to_int16(response.iter_content(chunk_size=chunk_bytes)) - - -def chunks_to_int16(byte_chunks: Iterable[bytes]) -> Iterator[np.ndarray]: - """Reassemble a byte stream into int16 frames. HTTP chunk boundaries fall - wherever they like, including *inside* a 16-bit sample, so a trailing odd - byte has to be carried into the next chunk — otherwise every chunk after - the first is shifted by one byte and plays as static.""" - carry = b"" - for chunk in byte_chunks: - if not chunk: - continue - data = carry + chunk - usable = len(data) - (len(data) % 2) - carry = data[usable:] - if usable: - yield np.frombuffer(data[:usable], dtype=np.int16) - - -def synthesize_dialogue( - inputs: list, model_id: Optional[str] = None, stability: Optional[float] = None -) -> tuple[np.ndarray, int]: - """Multi-voice scene via ElevenLabs Text to Dialogue. - - One request, one take: the whole exchange is synthesized together, which - is the point — the model hears the previous line, so reactions and timing - land instead of sounding like separately-rendered clips. - - Same PCM-over-`requests` posture as the rest of this module (no SDK, no - `play()` shelling out to ffplay), so playback is the same sounddevice path - everything else uses and barge-in works on it unchanged. There is no - documented streaming variant, and a scene is a short set piece anyway, so - this is whole-clip only. - """ - if not (config.ELEVENLABS_API_KEY and inputs): - raise TtsError("ELEVENLABS_API_KEY not set (or no dialogue lines)") - body: dict = { - "inputs": [ - {"text": str(entry.get("text") or ""), "voice_id": str(entry.get("voice_id") or "")} - for entry in inputs - ], - "model_id": model_id or config.DIALOGUE_MODEL_ID, - } - if stability is not None: - body["settings"] = {"stability": float(stability)} - try: - response = requests.post( - "https://api.elevenlabs.io/v1/text-to-dialogue", - headers={"xi-api-key": config.ELEVENLABS_API_KEY}, - params={"output_format": f"pcm_{config.TTS_SAMPLE_RATE}"}, - json=body, - timeout=120, # a multi-voice take is slower to render than one line - ) - response.raise_for_status() - except Exception as exc: - detail = "" - # The API explains refusals (character limit, unknown voice) in the - # body; surfacing it is what lets Bolt fix the call and retry. - body_text = getattr(getattr(exc, "response", None), "text", "") - if body_text: - detail = f" — {body_text[:300]}" - raise TtsError(f"ElevenLabs dialogue request failed: {exc}{detail}") from exc - pcm = np.frombuffer(response.content, dtype=np.int16) - if pcm.size == 0: - raise TtsError("ElevenLabs returned no dialogue audio") - return pcm, config.TTS_SAMPLE_RATE - - -# ── how loud is it right now ──────────────────────────────────────────────── -# The PCM is already decoded here on its way to the speakers, so the amplitude -# envelope is free — and it is exactly what a mouth needs to move in time with -# speech. Throwing it away and animating the mouth on a timer instead is why -# most talking sprites look dubbed. - -# int16 RMS that counts as "mouth fully open". Speech peaks around 8-12k; -# 6000 keeps normal talking in the upper half of the range without clipping -# every syllable to wide-open. -_LOUD_RMS = 6000.0 - - -def level_of(frame: np.ndarray) -> float: - """0..1 loudness for one chunk of PCM. - - Square-rooted because perceived loudness is not linear in amplitude — a - linear mapping leaves the mouth barely moving through ordinary speech.""" - if frame is None or len(frame) == 0: - return 0.0 - rms = float(np.sqrt(np.mean(np.square(frame.astype(np.float32))))) - return float(min(1.0, (rms / _LOUD_RMS) ** 0.5)) - - -def envelope(pcm: np.ndarray, sample_rate: int, fps: int = 30) -> list: - """Per-frame loudness for a whole clip, for playback that isn't streamed.""" - if pcm is None or len(pcm) == 0: - return [] - window = max(1, int(sample_rate / max(1, fps))) - return [level_of(pcm[start:start + window]) for start in range(0, len(pcm), window)] - - -def play_pcm(pcm: np.ndarray, sample_rate: int, blocking: bool = True, should_stop=None, - on_level=None) -> bool: - """Play a whole clip. Returns True if it finished, False if *should_stop* - (barge-in) cut it short. *should_stop* is polled while audio plays — each - poll consumes one mic frame, which is what paces this loop.""" - import sounddevice as sd - - levels = envelope(pcm, sample_rate) if on_level is not None else [] - started = time.monotonic() - sd.play(pcm, samplerate=sample_rate, device=config.SPEAKER_DEVICE) - if not blocking: - return True - if should_stop is None and on_level is None: - sd.wait() - return True - while True: - if levels: - # Indexed by elapsed time rather than by chunk, because this path - # hands the whole clip to the device at once and never sees it - # again — wall clock is the only position we have. - index = int((time.monotonic() - started) * 30) - if index < len(levels): - try: - on_level(levels[index]) - except Exception: - levels = [] - try: - if not sd.get_stream().active: - break - except Exception: - break # stream already torn down — playback is over - if should_stop is not None and should_stop(): - sd.stop() - return False - return True - - -def play_stream(chunks: Iterable[np.ndarray], sample_rate: int, should_stop=None, - on_level=None) -> bool: - """Play int16 chunks as they arrive. Returns False if interrupted. - - *on_level* receives each chunk's loudness (0..1) just before it is written, - which is what drives the mouth: the sprite is animated by the same audio - the speakers are getting, not by a guess about how long a word takes.""" - import sounddevice as sd - - with sd.OutputStream( - samplerate=sample_rate, channels=1, dtype="int16", device=config.SPEAKER_DEVICE - ) as out: - for chunk in chunks: - if should_stop is not None and should_stop(): - # abort() rather than draining: barge-in should stop the voice - # now, not at the end of the buffered chunk. - out.abort() - return False - if on_level is not None: - try: - on_level(level_of(chunk)) - except Exception: - on_level = None # a broken listener must not stop playback - out.write(chunk) - return True - - -def speak_offline(text: str) -> None: - try: - import pyttsx3 - except ImportError: - return # no TTS available at all — caller already logs the text - engine = pyttsx3.init() - engine.say(text) - engine.runAndWait() - - -def speak(text: str, on_error=None, should_stop=None, voice_id: Optional[str] = None, - on_level=None) -> bool: - """Speak *text*, preferring streaming ElevenLabs, then whole-clip - ElevenLabs, then offline TTS. *on_error*, if given, is called with the - exception when ElevenLabs fails (useful for logging) — a fallback still - runs either way. Returns False if barge-in interrupted playback. - *voice_id* overrides the configured voice for this line only. - - The text is sanitized first (speech_text.for_speech): server replies are - written for a chat window, and a voice reads markdown/emoji literally - ("asterisk asterisk"). Sanitizing here rather than at the call sites means - every path to the speakers — reply, heartbeat announcement — is covered.""" - text = speech_text.for_speech(text) - if not text: - return True - if config.TTS_STREAMING: - try: - return play_stream( - stream_pcm(text, voice_id=voice_id), - config.TTS_SAMPLE_RATE, - should_stop=should_stop, - on_level=on_level, - ) - except TtsError as exc: - if on_error is not None: - on_error(exc) - try: - pcm, sample_rate = synthesize_pcm(text, voice_id=voice_id) - return play_pcm(pcm, sample_rate, should_stop=should_stop, on_level=on_level) - except TtsError as exc: - if on_error is not None: - on_error(exc) - speak_offline(text) - return True diff --git a/bolt_pet/audio/wake_word.py b/bolt_pet/audio/wake_word.py deleted file mode 100644 index 6e333a0..0000000 --- a/bolt_pet/audio/wake_word.py +++ /dev/null @@ -1,259 +0,0 @@ -"""Wake-word detection via a custom-trained openWakeWord model. - -Uses `thunderbolt.onnx` — trained specifically for "thunderbolt", the same -way the main repo's `desk_client/bolt_desk.py` uses `bolt.onnx` for "hey -bolt". Same runtime (openWakeWord, ONNX inference), same per-frame -predict()/reset() pattern; the only difference is the model file -(WAKE_MODEL_FILE) and threshold (WAKE_WORD_THRESHOLD), both configurable via -.env if a differently-trained model is swapped in later. -""" - -from __future__ import annotations - -from collections import deque -from typing import Callable, Optional, Protocol, Union - -import numpy as np - -from .. import config - - -class WakeModel(Protocol): - def predict(self, frame: np.ndarray) -> dict: ... - def reset(self) -> None: ... - - -class NearMissLog: - """Rolling record of frames that *almost* fired the wake word. - - WAKE_WORD_THRESHOLD is otherwise tuned by guessing at a number in .env - and seeing whether the pet ignores you. Keeping the near misses (scores - within WAKE_NEAR_MISS_MARGIN below the threshold) turns that into - evidence: the tray's wake-word tuner shows what your actual "thunderbolt" - scores, so you can set the threshold just under it. - - Pure bookkeeping — the caller supplies timestamps, so it's testable. - """ - - def __init__(self, limit: int = None, margin: float = None): - self._entries: deque[tuple[float, float, float]] = deque( # (timestamp, score, threshold) - maxlen=max(1, config.WAKE_NEAR_MISS_LIMIT if limit is None else limit) - ) - self._margin = config.WAKE_NEAR_MISS_MARGIN if margin is None else margin - self._peak = 0.0 - - @property - def peak(self) -> float: - """Highest score seen since the last reset — the "how close did I - get?" readout while you test the wake phrase.""" - return self._peak - - def observe(self, score: float, threshold: float, timestamp: float) -> bool: - """Record *score*; returns True if it counted as a near miss.""" - self._peak = max(self._peak, score) - if score >= threshold or score < threshold - self._margin: - return False - self._entries.append((timestamp, score, threshold)) - return True - - def entries(self) -> list[tuple[float, float, float]]: - return list(self._entries) - - def clear(self) -> None: - self._entries.clear() - self._peak = 0.0 - - -# Where the cached blank state is stashed — on the preprocessor itself -# rather than in a dict keyed by id(), which CPython reuses after garbage -# collection and would hand one model another's buffers. -_BLANK_STATE_ATTR = "_bolt_blank_state" - - -def _blank_state(preprocessor) -> Optional[tuple]: - """(feature_buffer, melspectrogram_buffer) as they are on a freshly - constructed model — i.e. "having heard nothing". Computing it costs an - ONNX pass over 10s of silence, so it's cached on the preprocessor and - copied from thereafter. Returns None if openwakeword's internals don't - look the way we expect, in which case callers leave the state alone - rather than corrupting it.""" - cached = getattr(preprocessor, _BLANK_STATE_ATTR, None) - if cached is not None: - return cached - try: - # Same call the AudioFeatures constructor uses to prime the buffer. - state = (preprocessor._get_embeddings(np.zeros(160000).astype(np.int16)), np.ones((76, 32))) - setattr(preprocessor, _BLANK_STATE_ATTR, state) - except Exception: - return None - return state - - -def hard_reset(model) -> None: - """Make the model forget the audio it has already heard — not just its - predictions. - - openwakeword's ``Model.reset()`` clears the *prediction* buffer only. - The rolling audio window the classifier actually scores lives in - ``model.preprocessor`` (raw_data_buffer / melspectrogram_buffer / - feature_buffer, ~10s of history) and has no reset method of its own. So - after a detection the wake word is still sitting in that window, and the - next frame fed to the model re-fires on it — which is exactly what made - the pet interrupt itself a word into every reply: the "thunderbolt" that - started the turn was still in the buffer when barge-in resumed feeding it. - - Never raises. A model whose internals don't match (a fake in tests, a - future openwakeword release) just gets the plain reset().""" - try: - model.reset() - except Exception: - pass - preprocessor = getattr(model, "preprocessor", None) - if preprocessor is None: - return - blank = _blank_state(preprocessor) - try: - if blank is not None: - features, melspectrogram = blank - preprocessor.feature_buffer = features.copy() - preprocessor.melspectrogram_buffer = melspectrogram.copy() - preprocessor.raw_data_buffer.clear() - preprocessor.accumulated_samples = 0 - except Exception: - pass - - -def _construct_model(model_cls, model_path: str): - """openwakeword's Model() constructor keyword has drifted across - releases (wakeword_models -> wakeword_model_paths) and some builds - reject inference_framework entirely — the main repo's ai/wake_word.py - hit the same drift and works around it the same way: try each known - calling convention in turn.""" - attempts = [ - lambda: model_cls(wakeword_model_paths=[model_path], inference_framework="onnx"), - lambda: model_cls(wakeword_model_paths=[model_path]), - lambda: model_cls(wakeword_models=[model_path], inference_framework="onnx"), - lambda: model_cls(wakeword_models=[model_path]), - lambda: model_cls([model_path]), - ] - last_exc: Optional[TypeError] = None - for attempt in attempts: - try: - return attempt() - except TypeError as exc: - last_exc = exc - raise RuntimeError( - f"Could not construct openwakeword.Model with any known calling convention " - f"(last error: {last_exc})" - ) - - -class _OpenWakeWordModel: - """Lazily loads the ONNX model on first use so importing this module - (and unit-testing listen_for_wake_word with a fake model) never requires - onnxruntime/openwakeword or the model file to be present.""" - - def __init__(self): - self._model = None - - def _ensure_model(self): - if self._model is None: - from openwakeword.model import Model - # from openwakeword.utils import download_models - - # # The pip package doesn't bundle its melspectrogram/embedding - # # feature-extraction sub-models — fetch them once on first use - # # (no-op if already cached in openwakeword's own resources dir). - # # A non-empty, non-matching model_names list keeps this from - # # also pulling every official pretrained wakeword model. - # download_models(model_names=["thunderbolt"]) - - self._model = _construct_model(Model, config.WAKE_MODEL_PATH) - return self._model - - @property - def preprocessor(self): - """Proxy the wrapped model's audio-feature buffers. - - Without this, hard_reset() sees a wrapper with no `preprocessor` and - silently degrades to openwakeword's shallow reset() — which leaves the - previous detection sitting in the audio window, i.e. exactly the bug - hard_reset exists to fix. Returns None before the model is loaded, so - a reset that happens first is a no-op rather than a load.""" - return getattr(self._model, "preprocessor", None) - - def predict(self, frame: np.ndarray) -> dict: - return self._ensure_model().predict(frame) - - def reset(self) -> None: - if self._model is not None: - self._model.reset() - - -_default_model = _OpenWakeWordModel() - - -def listen_for_wake_word( - stream, - should_continue=lambda: True, - model: Optional[WakeModel] = None, - threshold: Union[float, Callable[[], float], None] = None, - on_tick=None, - on_score: Optional[Callable[[float, float], None]] = None, -) -> bool: - """Block until the wake word fires (returns True) or *should_continue* - goes false (returns False). - - Feeds every frame to *model* (the thunderbolt openWakeWord model by - default) and treats any class score >= *threshold* as a detection, - clearing the model's internal state (audio window included, see - hard_reset) afterward so the next call starts clean — same pattern as - desk_client/bolt_desk.py's main loop. - - *threshold* may be a number or a zero-argument callable. The callable - form exists because this function blocks for minutes at a time: the - tray's wake-word tuner slider has to be able to change sensitivity - *during* a listen, not only at the start of the next one. - - *on_tick*, if given, is called once per ``WAKE_CHECK_INTERVAL_SECONDS`` - (not every frame — prediction is cheap enough to run on every frame, but - this is the only point control returns to the caller while otherwise - blocked here for a possibly long time, so it's how a caller drives - periodic work, e.g. the heartbeat/announcement poll in controller.py, - during quiet stretches with no wake word). - - *on_score*, if given, gets ``(best_score, threshold)`` every frame — used - to log near misses for threshold tuning. - """ - model = model or _default_model - if threshold is None: - threshold = config.WAKE_WORD_THRESHOLD - resolve_threshold = threshold if callable(threshold) else (lambda: threshold) - - frame_len = config.FRAME_LEN - check_every_frames = max(1, int(config.WAKE_CHECK_INTERVAL_SECONDS * config.SAMPLE_RATE / frame_len)) - frames_since_tick = 0 - - while should_continue(): - chunk, _ = stream.read(frame_len) - frame = np.asarray(chunk)[:, 0] - scores = model.predict(frame) - current_threshold = resolve_threshold() - best = max(scores.values()) if scores else 0.0 - - frames_since_tick += 1 - if frames_since_tick >= check_every_frames: - frames_since_tick = 0 - if on_tick is not None: - on_tick() - - if on_score is not None: - on_score(best, current_threshold) - - if scores and best >= current_threshold: - # hard_reset, not reset: the phrase has to leave the model's audio - # window too, or the very next frame we feed it re-fires on the - # same "thunderbolt" (see hard_reset's docstring). - hard_reset(model) - return True - return False diff --git a/bolt_pet/config.py b/bolt_pet/config.py deleted file mode 100644 index 57aaabc..0000000 --- a/bolt_pet/config.py +++ /dev/null @@ -1,386 +0,0 @@ -"""Environment configuration for the Bolt desktop pet. - -Same lightweight ".env next to the script" pattern as desk_client/bolt_desk.py -in the main tmn-api repo, so this project can be copied anywhere (it does not -import anything from that repo) and configured the same way. -""" - -from __future__ import annotations - -import os -import platform -import re -from pathlib import Path - -HERE = Path(__file__).resolve().parent.parent # project root (one above bolt_pet/) - - -def _load_env() -> None: - env_path = HERE / ".env" - if not env_path.exists(): - return - for line in env_path.read_text(encoding="utf-8").splitlines(): - line = line.strip() - if not line or line.startswith("#") or "=" not in line: - continue - key, _, value = line.partition("=") - os.environ.setdefault(key.strip(), value.strip()) - - -_load_env() - - -def set_env_value(key: str, value: str) -> None: - """Write *key=value* into .env, in place — replacing (or uncommenting) - an existing line for it, appending a new one otherwise. Used by the tray's - microphone picker so a device pick survives a restart without hand-editing - the file. Settings read once at import time (like MIC_DEVICE) still need - that restart to take effect; this only updates the file and os.environ.""" - env_path = HERE / ".env" - lines = env_path.read_text(encoding="utf-8").splitlines() if env_path.exists() else [] - pattern = re.compile(rf"^\s*#?\s*{re.escape(key)}\s*=") - new_line = f"{key}={value}" - for i, line in enumerate(lines): - if pattern.match(line): - lines[i] = new_line - break - else: - lines.append(new_line) - env_path.write_text("\n".join(lines) + "\n", encoding="utf-8") - os.environ[key] = value - - -def _node_name() -> str: - try: - return platform.node() or "desktop" - except Exception: - return "desktop" - - -# ── server / identity ─────────────────────────────────────────────────────── - -SERVER_URL = os.environ.get("BOLT_SERVER_URL", "").rstrip("/") -API_KEY = os.environ.get("DESK_API_KEY", "") -SESSION_ID = os.environ.get("DESK_SESSION_ID", "pet-" + _node_name()) - -# ── wake word ──────────────────────────────────────────────────────────────── -# openWakeWord model trained specifically for "thunderbolt" — same pattern as -# desk_client/bolt_desk.py's WAKE_MODEL_FILE (bolt.onnx / "hey bolt") in the -# main repo, resolved relative to the project root so it sits next to .env. - -WAKE_MODEL_PATH = str(HERE / os.environ.get("WAKE_MODEL_FILE", "thunderbolt.onnx")) -WAKE_WORD_THRESHOLD = float(os.environ.get("WAKE_WORD_THRESHOLD", "0.5")) -# How often (independent of prediction, which runs every frame) the wake -# listener yields control back to its caller via on_tick — e.g. the -# heartbeat poll in controller.py during quiet stretches with no wake word. -WAKE_CHECK_INTERVAL_SECONDS = float(os.environ.get("WAKE_CHECK_INTERVAL_SECONDS", "1.2")) - -# ── STT (server-hosted, same /desk/stt relay the Android app uses) ───────── -# No local Deepgram account needed any more: audio/stt_stream.py opens a -# websocket to this pet's own BOLT_SERVER_URL/DESK_API_KEY, which the server -# relays to Deepgram and meters it against the same credit ledger as a chat turn. -# There is no separate one-shot REST path server-side, so audio/stt.py's -# "guaranteed" fallback uses this exact same connection too — just fed the -# whole utterance at once instead of frame-by-frame. - -# Transcribe *while* you talk instead of waiting for the utterance to end: -# frames go up as they are captured, so the transcript is ready the moment the -# VAD says you stopped. Needs `websocket-client` (in requirements.txt); when -# it can't connect the one-shot path (audio/stt.py) still tries the same -# server relay itself, so turning this off only costs latency, not the -# ability to transcribe at all. -STT_STREAMING = os.environ.get("STT_STREAMING", "true").lower() in ("1", "true", "yes", "on") - -# ── TTS (server-hosted, same /desk/tts endpoint the Android app uses) ────── -# No local ElevenLabs account needed for the normal reply voice any more — -# audio/tts.py posts to this pet's own BOLT_SERVER_URL/DESK_API_KEY and gets -# back raw 16 kHz mono PCM16, same as the phone. Falls back to offline -# pyttsx3 if the server call fails. -# -# ELEVENLABS_API_KEY is still read directly by this client for exactly one -# feature the server has no endpoint for: multi-voice `dialoguectl` scenes -# (audio/tts.synthesize_dialogue, see dialogue.py) — leave it blank and -# everything except that one feature works with zero local API keys. - -ELEVENLABS_API_KEY = os.environ.get("ELEVENLABS_API_KEY", "") -# Which voice to ask the server for — an ElevenLabs voice id (or a "vb:"- -# prefixed cloned voice, if this desk key owns one). The server picks the -# synthesis model itself now; there is nothing left for this client to choose. -ELEVENLABS_VOICE_ID = os.environ.get("ELEVENLABS_VOICE_ID", "") -# Fixed by the server (ai/desk_media.py's PCM_SAMPLE_RATE) — not a free -# tunable any more, but still an env override in case that ever changes. -TTS_SAMPLE_RATE = int(os.environ.get("TTS_SAMPLE_RATE", "16000")) - -# Does a voice the server picks (its speak_as marker — "talk like a pirate", -# "say that in Japanese") stay on for later replies, or last one reply only? -# Sticky by default: the server tags a single reply and does *not* keep the -# voice id in its history, so a one-reply-only voice can't be re-used when -# you say "keep talking like that" — it would have to search for a voice -# again. Reset it from the tray ("Use default voice") or by restarting. -VOICE_STICKY = os.environ.get("VOICE_STICKY", "true").lower() in ("1", "true", "yes", "on") - -# ── multi-voice dialogue (ElevenLabs Text to Dialogue) ────────────────────── -# Lets Bolt play a short scene in several voices with delivery tags the v3 -# model acts on ("[cheerfully] Hello"), instead of one voice reading a line. -# Driven by the server through the `dialoguectl` relayed command — see -# dialogue.py. Costs a separate (slower, whole-clip) request per scene, so -# it's a set piece, not the normal reply path. -# -# DIALOGUE_VOICES names the cast: "narrator:9BWtsMINqrJLrRacOk9x,villain:IKne3meq5aSn9XLyUdCD". -# The name "self" always resolves to the voice the pet is currently using, -# including one the server picked with speak_as. - -DIALOGUE = os.environ.get("DIALOGUE", "true").lower() in ("1", "true", "yes", "on") -DIALOGUE_MODEL_ID = os.environ.get("DIALOGUE_MODEL_ID", "eleven_v3") -DIALOGUE_VOICES = os.environ.get("DIALOGUE_VOICES", "") - -# ── mic / VAD (same tuning knobs as bolt_desk.py) ─────────────────────────── - -def _mic_device(raw: str): - """sounddevice treats an int index and a string name very differently: a - string is matched as a (fragile, often ambiguous) substring against every - device name, while an int is used as-is. .env only ever gives us a - string, so a numeric one — what the tray's mic picker saves, since the - same physical device usually shows up once per host API (MME, - DirectSound, WASAPI, ...) under the same name — has to be cast back to - int here, or it goes through the substring path and matches nothing (or - everything) instead.""" - raw = raw.strip() - if not raw: - return None - return int(raw) if raw.lstrip("-").isdigit() else raw - - -MIC_DEVICE = _mic_device(os.environ.get("MIC_DEVICE", "")) -SPEAKER_DEVICE = os.environ.get("SPEAKER_DEVICE", "") or None - -RMS_THRESHOLD = int(os.environ.get("VAD_RMS_THRESHOLD", "300")) -SILENCE_END_SEC = float(os.environ.get("VAD_SILENCE_END_SEC", "1.2")) -MAX_UTTERANCE_S = float(os.environ.get("VAD_MAX_UTTERANCE_SECONDS", "15")) -MIN_UTTERANCE_S = float(os.environ.get("VAD_MIN_UTTERANCE_SECONDS", "0.4")) -# How long to wait for you to *start* talking before giving up on a turn. -GRACE_SECONDS = float(os.environ.get("VAD_GRACE_SECONDS", "4")) - -# ── follow-up listening ───────────────────────────────────────────────────── -# When a reply ends on a question, the pet keeps listening for the answer -# instead of dropping back to idle and making you say the wake word again. -# The grace period is longer than a normal turn's because you were asked -# something and may need a beat to think. FOLLOW_UP_MAX_TURNS caps how many -# question-and-answer rounds can chain without you re-triggering it — a stop -# on runaway loops if the server ends every reply with a question and the mic -# keeps feeding it noise. 0 means no cap. - -FOLLOW_UP_LISTEN = os.environ.get("FOLLOW_UP_LISTEN", "true").lower() in ("1", "true", "yes", "on") -FOLLOW_UP_MAX_TURNS = int(os.environ.get("FOLLOW_UP_MAX_TURNS", "10")) -FOLLOW_UP_GRACE_SECONDS = float(os.environ.get("FOLLOW_UP_GRACE_SECONDS", "7")) - -COMMAND_TIMEOUT_SECONDS = int(os.environ.get("COMMAND_TIMEOUT_SECONDS", "30")) - -# ── sudo password prompts ─────────────────────────────────────────────────── -# The pet has no terminal, so a relayed `sudo` would block on a tty nobody is -# watching. With this on, bare `sudo` is rewritten to `sudo -A` and the -# password is collected in a desktop dialog (a real askpass binary if one is -# installed, otherwise a generated zenity/kdialog wrapper). Turn it off and -# sudo commands simply fail, which is the safer default if you'd rather Bolt -# never be able to ask for root at all. -SUDO_ASKPASS_PROMPT = os.environ.get("SUDO_ASKPASS_PROMPT", "true").lower() in ("1", "true", "yes", "on") -SUDO_ASKPASS_HELPER = os.environ.get("SUDO_ASKPASS_HELPER", "") # blank = auto-detect -# Longer than COMMAND_TIMEOUT_SECONDS because a person has to notice the -# dialog, read it, and type — 30s is nowhere near enough for that. -SUDO_COMMAND_TIMEOUT_SECONDS = int(os.environ.get("SUDO_COMMAND_TIMEOUT_SECONDS", "180")) -HEARTBEAT_INTERVAL_SECONDS = float(os.environ.get("HEARTBEAT_INTERVAL_SECONDS", "60")) - -# ── self-restart ──────────────────────────────────────────────────────────── -# `petctl self_restart` lets Bolt restart the pet after editing its code, so -# he can see his own change running instead of waiting for someone to restart -# it by hand. The code is import-checked in a subprocess first, and the reason -# is carried across the restart so the new process can report back — see -# self_restart.py. SELF_RESTART_MAX/_WINDOW_SECONDS bound the crash-loop case. -SELF_RESTART = os.environ.get("SELF_RESTART", "true").lower() in ("1", "true", "yes", "on") - -# ── barge-in (interrupt playback while the pet is talking) ────────────────── -# The mic stays live while the pet talks. BARGE_IN_MODE decides what counts -# as an interruption: -# wake — only the wake word cuts playback (default). Immune to coughs, -# doors, and the TV, at the cost of ~a word of extra latency. -# energy — any sustained noise above BARGE_IN_RMS_THRESHOLD does. Faster, -# but interrupts on background noise. That threshold is well above -# VAD_RMS_THRESHOLD because the mic also hears the pet's own voice -# through the speakers — raise it further if playback keeps -# interrupting itself. -# Set BARGE_IN=false to make playback uninterruptible either way. - -BARGE_IN = os.environ.get("BARGE_IN", "true").lower() in ("1", "true", "yes", "on") -BARGE_IN_MODE = os.environ.get("BARGE_IN_MODE", "wake") -BARGE_IN_RMS_THRESHOLD = int(os.environ.get("BARGE_IN_RMS_THRESHOLD", str(RMS_THRESHOLD * 4))) -BARGE_IN_FRAMES = int(os.environ.get("BARGE_IN_FRAMES", "4")) # consecutive loud frames (80ms each) -# Wake-mode sensitivity. Blank means "track the live WAKE_WORD_THRESHOLD from -# the tray tuner"; set a number to make interrupting deliberately harder than -# waking the pet from idle (useful if Bolt's own voice trips the model). -BARGE_IN_WAKE_THRESHOLD = float(os.environ.get("BARGE_IN_WAKE_THRESHOLD") or 0) or None - -# ── streaming the reply ───────────────────────────────────────────────────── -# Speak each sentence as the server produces it, instead of waiting out the -# whole model call before the first word. Falls back to the ordinary -# request/response path automatically if the server has no streaming endpoint -# or the stream fails before anything has been spoken. -STREAMING_REPLIES = os.environ.get("STREAMING_REPLIES", "true").lower() in ("1", "true", "yes", "on") - -# ── streaming TTS ─────────────────────────────────────────────────────────── -# ElevenLabs' /stream endpoint + chunked playback: the pet starts talking -# after the first PCM chunk instead of after the whole clip is synthesized. - -TTS_STREAMING = os.environ.get("TTS_STREAMING", "true").lower() in ("1", "true", "yes", "on") - -# ── screen context ────────────────────────────────────────────────────────── -# Appends the active window's title to what you say, so "what's this error?" -# has a referent. The desk API takes text only, so this is a text annotation -# (no screenshot upload). - -SCREEN_CONTEXT = os.environ.get("SCREEN_CONTEXT", "true").lower() in ("1", "true", "yes", "on") - -# ── monitors ──────────────────────────────────────────────────────────────── -# A one-line note about the screen layout (how many, their sizes, which one -# the pet is standing on) rides along with each utterance, so Bolt can decide -# to `petctl jump` somewhere without asking you what you've got plugged in. -# Cheap — the list comes from the UI, nothing is probed per turn. - -MONITOR_CONTEXT = os.environ.get("MONITOR_CONTEXT", "true").lower() in ( - "1", "true", "yes", "on" -) - -# ── screen text (OCR) ─────────────────────────────────────────────────────── -# Lets Bolt actually read a monitor, via `petctl read`. Pull-only: nothing is -# captured unless the server asks for it, and every read is logged. Needs the -# optional capture/OCR extras — see the comments in requirements.txt. -# -# This widens what can leave the machine more than any other switch here: the -# recognised text of a whole screen goes to the server. It is *not* a new -# capability (the shell relay could already run a screenshot tool and OCR it), -# but it is a much easier one to use by accident. Set SCREEN_TEXT=false to -# take it away entirely. - -SCREEN_TEXT = os.environ.get("SCREEN_TEXT", "true").lower() in ("1", "true", "yes", "on") -SCREEN_TEXT_MAX_CHARS = int(os.environ.get("SCREEN_TEXT_MAX_CHARS", "4000")) - -# ── quiet hours / do-not-disturb ──────────────────────────────────────────── -# Comma-separated HH:MM-HH:MM ranges (wrapping midnight is fine). While -# napping the pet dims, stops wandering, and makes no proactive noise — -# wake word and click-to-talk still work. - -QUIET_HOURS = os.environ.get("QUIET_HOURS", "") -DND_ON_FULLSCREEN = os.environ.get("DND_ON_FULLSCREEN", "true").lower() in ("1", "true", "yes", "on") - -# ── desktop notification bridge ───────────────────────────────────────────── -# Mirrors desktop notifications to the server so Bolt can react to them. -# Off by default: every forwarded notification costs a converse() round trip. - -NOTIFICATION_BRIDGE = os.environ.get("NOTIFICATION_BRIDGE", "false").lower() in ("1", "true", "yes", "on") -# Regex matched against ": "; empty means "everything". -NOTIFICATION_FILTER = os.environ.get("NOTIFICATION_FILTER", "") -NOTIFICATION_MIN_INTERVAL_SECONDS = float(os.environ.get("NOTIFICATION_MIN_INTERVAL_SECONDS", "60")) - -# ── file delivery ──────────────────────────────────────────────────────── -# The server's deliver_files tool (ai/desk_api.py in the main tmn-api repo) -# queues workspace files on this session — e.g. "send me that report" — for -# the client to fetch via GET /desk/files. Downloading a file dequeues it -# server-side, so each one lands here exactly once. - -RECEIVE_FILES = os.environ.get("RECEIVE_FILES", "true").lower() in ("1", "true", "yes", "on") -DELIVERED_FILES_DIR = Path( - os.environ.get("DELIVERED_FILES_DIR") or str(Path.home() / "Downloads" / "Bolt") -).expanduser() - -# ── conversation history ──────────────────────────────────────────────────── - -HISTORY_LIMIT = int(os.environ.get("HISTORY_LIMIT", "100")) - -# ── push-to-talk ──────────────────────────────────────────────────────────── -# Global hotkey (needs `pynput`; unavailable on most Wayland sessions, in -# which case it logs once and the wake word / tray still work). Empty to -# disable. - -PUSH_TO_TALK_HOTKEY = os.environ.get("PUSH_TO_TALK_HOTKEY", "ctrl+alt+space") - -# ── wake-word tuning ──────────────────────────────────────────────────────── -# Scores this far below the threshold are recorded as "near misses" and shown -# in the tray's wake-word tuner, so the threshold can be set from evidence. - -WAKE_NEAR_MISS_MARGIN = float(os.environ.get("WAKE_NEAR_MISS_MARGIN", "0.2")) -WAKE_NEAR_MISS_LIMIT = int(os.environ.get("WAKE_NEAR_MISS_LIMIT", "40")) - -# ── auto-update ───────────────────────────────────────────────────────────── -# Watches the Gitea releases API for a tag newer than bolt_pet.__version__, -# then `git checkout`s it in place and restarts (see updater.py). The install -# has to be a git clone with a clean working tree — a dirty tree is skipped -# rather than stashed, so local edits are never thrown away. Any failure -# after checkout rolls back to the ref that was checked out before. - -AUTO_UPDATE = os.environ.get("AUTO_UPDATE", "true").lower() in ("1", "true", "yes", "on") -UPDATE_REPO_API = os.environ.get( - "UPDATE_REPO_API", - "https://git.themajesticnetwork.com/api/v1/repos/TheMajesticNetwork/Bolt-Pet", -).rstrip("/") -UPDATE_CHECK_INTERVAL_SECONDS = float(os.environ.get("UPDATE_CHECK_INTERVAL_SECONDS", "3600")) -UPDATE_GIT_REMOTE = os.environ.get("UPDATE_GIT_REMOTE", "origin") -# Only needed if the repo is private — releases on a public repo read fine -# anonymously. A Gitea access token with read:repository. -UPDATE_TOKEN = os.environ.get("UPDATE_TOKEN", "") -# Reinstall requirements.txt when an update changes it. Off means a release -# that adds a dependency will roll straight back on the import smoke test. -UPDATE_INSTALL_DEPS = os.environ.get("UPDATE_INSTALL_DEPS", "true").lower() in ("1", "true", "yes", "on") - -SAMPLE_RATE = 16000 # mic capture / STT rate -FRAME_LEN = 1280 # 80ms @ 16kHz — matches bolt_desk.py's chunking - -# ── pet window ─────────────────────────────────────────────────────────────── - -PET_SIZE = int(os.environ.get("PET_SIZE", "160")) # on-screen pixel size (square) -PET_START_X = os.environ.get("PET_START_X", "") # blank = bottom-right of primary screen -PET_START_Y = os.environ.get("PET_START_Y", "") -PET_ALWAYS_ON_TOP = os.environ.get("PET_ALWAYS_ON_TOP", "true").lower() in ("1", "true", "yes", "on") -IDLE_ANIMATION_FPS = float(os.environ.get("IDLE_ANIMATION_FPS", "6")) - -# ── wandering ──────────────────────────────────────────────────────────────── -# The pet strolls to a random spot on its own while idle. Only ever moves when -# it's IDLE and not speaking/being dragged, so it never walks out from under a -# speech bubble mid-sentence. - -PET_WANDER = os.environ.get("PET_WANDER", "true").lower() in ("1", "true", "yes", "on") -# Average seconds of standing still between strolls (each wait is randomized -# to 0.5x–1.5x this, so the pet doesn't move on an obvious metronome). -PET_WANDER_INTERVAL_SECONDS = float(os.environ.get("PET_WANDER_INTERVAL_SECONDS", "45")) -PET_WANDER_SPEED = float(os.environ.get("PET_WANDER_SPEED", "90")) # pixels/second -# Cap on how far one stroll can be, so it doesn't teleport across a 4K screen. -PET_WANDER_MAX_DISTANCE = float(os.environ.get("PET_WANDER_MAX_DISTANCE", "600")) -PET_WANDER_MARGIN = int(os.environ.get("PET_WANDER_MARGIN", "20")) # keep off screen edges - -# ── mouse behaviour ───────────────────────────────────────────────────────── -# The sprite is drawn in a square translucent window, so its transparent -# corners would otherwise swallow clicks meant for whatever is underneath. -# PET_SHAPED_INPUT masks the window's input region to the sprite's own opaque -# pixels; PET_CLICK_THROUGH goes further and makes the whole pet ignore the -# mouse (tray-only control until you turn it back off). - -PET_SHAPED_INPUT = os.environ.get("PET_SHAPED_INPUT", "true").lower() in ("1", "true", "yes", "on") -PET_CLICK_THROUGH = os.environ.get("PET_CLICK_THROUGH", "false").lower() in ("1", "true", "yes", "on") -# Snap flush to a screen edge when dropped/parked within this many pixels of it. -# Start where it was left rather than in the bottom-right corner. Ignored when -# PET_START_X/Y pin it explicitly, and a saved position on a monitor that is no -# longer plugged in is discarded rather than hiding the pet offscreen. -PET_REMEMBER_POSITION = os.environ.get("PET_REMEMBER_POSITION", "true").lower() in ("1", "true", "yes", "on") -PET_EDGE_SNAP = os.environ.get("PET_EDGE_SNAP", "true").lower() in ("1", "true", "yes", "on") -PET_SNAP_MARGIN = int(os.environ.get("PET_SNAP_MARGIN", "48")) - - -def is_configured() -> bool: - return bool(SERVER_URL and API_KEY) - - -def missing_config() -> list[str]: - missing = [] - if not SERVER_URL: - missing.append("BOLT_SERVER_URL") - if not API_KEY: - missing.append("DESK_API_KEY") - return missing diff --git a/bolt_pet/controller.py b/bolt_pet/controller.py deleted file mode 100644 index 4938e0b..0000000 --- a/bolt_pet/controller.py +++ /dev/null @@ -1,901 +0,0 @@ -"""Orchestrates the pet's mic -> wake word -> STT -> server -> TTS pipeline. - -Runs on a background QThread (see ui/app.py) so the Qt event loop / window -painting is never blocked by audio I/O or network calls. Talks to the UI -only through Qt signals (state_changed / said / log / action / napping), -which Qt marshals safely across threads — this class never touches a QWidget -directly. - -Beyond the core loop it owns the side channels that let the pet act on its -own: the heartbeat (proactive announcements), the desktop notification -bridge, quiet hours, barge-in, and the live wake-word threshold. -""" - -from __future__ import annotations - -import threading -import time -from typing import Optional - -from PySide6.QtCore import QObject, Signal - -from . import ( - config, dialogue as dialogue_mod, speech, file_delivery, file_ops, - history as history_mod, monitors as monitors_mod, notifications, - pet_actions, quiet, screen_context, screen_text, self_restart, - server_client, speech_text, updater, -) -from . import __version__ -from .audio import barge_in, mic, stt, stt_stream, tts, wake_word -from .state import PetState, PetStateMachine - -# A burst while the pet is busy is batched into one turn, but the queue still -# needs a ceiling — a notification storm must not become an unbounded backlog -# that gets read out minutes later. -_MAX_PENDING_NOTIFICATIONS = 12 - -# How often to re-check whether the pet should be napping. The fullscreen -# probe shells out to xprop, so this deliberately isn't every heartbeat tick. -_NAP_CHECK_INTERVAL_SECONDS = 10.0 - - -class PetController(QObject): - state_changed = Signal(str) # PetState.value - said = Signal(str) # text now showing in the speech bubble - log = Signal(str) - action = Signal(dict) # parsed petctl action for the UI to perform - napping = Signal(bool) # quiet hours / fullscreen do-not-disturb - voice_changed = Signal(str) # name of the server-picked voice ("" = default) - mouth = Signal(float) # 0..1 speech loudness, for lip-sync while talking - restart_requested = Signal(str) # version we just updated to - finished = Signal() - - def __init__(self): - super().__init__() - self._running = True - self._muted = False - self._talk_now = threading.Event() - self._stream = None - self._last_heartbeat = 0.0 - self._state = PetStateMachine(on_change=self._handle_state_change) - - # Conversation scrollback, shared read-only with the UI's History - # window. Append-only from this thread; the UI only ever snapshots it. - self.history = history_mod.ConversationHistory(limit=config.HISTORY_LIMIT) - - # The screen layout, as published by the UI (see set_monitors). Held - # here rather than probed, so "monitor 2" means the same thing to the - # controller and to the window that has to jump there — see - # monitors.py for why that matters. - self._monitors: list[monitors_mod.Monitor] = [] - self._pet_monitor: Optional[int] = None - - # Wake-word sensitivity is live-tunable (tray tuner), so it's read - # through a callable on every frame rather than captured per listen. - self._wake_threshold = config.WAKE_WORD_THRESHOLD - self._near_misses = wake_word.NearMissLog() - - # The voice the server last picked for us with `speak_as` ("" = the - # configured default). Held here rather than passed straight through - # to one tts.speak() call because it's sticky by default — see - # _apply_voice for why. - self._voice_id = "" - self._voice_name = "" - - self._barge_in: Optional[barge_in.BargeInDetector] = None - # Everything the pet says goes through here; see speech.py for why the - # four hand-rolled copies of this became one. - self._speaker = speech.Speaker( - state=self._state, tts=tts, - history=lambda text: self.history.add(history_mod.PET, text, time.time()), - on_said=self.said.emit, on_log=self.log.emit, - barge_in=lambda: self._barge_in, - detail_of=self._barge_in_detail, - voice_id=lambda: self._voice_id, - on_level=self.mouth.emit, - ) - self._napping = False - self._nap_forced: Optional[bool] = None # petctl nap on/off overrides the schedule - self._last_nap_check = 0.0 - - # When a reply ends on a question the pet keeps listening for the - # answer. _follow_ups counts how many have chained without you - # re-triggering, so a server that ends every reply with "?" can't - # loop forever off mic noise. - self._pending_follow_up = False - self._follow_ups = 0 - - self._last_update_check = 0.0 - self._update_pending = False # applied on disk, waiting for the restart - # Armed by `petctl self_restart`, fired after the turn it was asked in - # (see _arm_self_restart for why it can't happen inline). - self._restart_context = None - - self._notification_watcher: Optional[notifications.NotificationWatcher] = None - self._notification_gate = notifications.NotificationGate( - config.NOTIFICATION_FILTER, config.NOTIFICATION_MIN_INTERVAL_SECONDS - ) - self._pending_notifications: list[notifications.Notification] = [] - self._notification_lock = threading.Lock() - - # ── external controls (safe to call from the Qt/UI thread) ───────── - - def request_talk_now(self) -> None: - self._talk_now.set() - - def toggle_mute(self) -> bool: - self._muted = not self._muted - self.log.emit("Muted." if self._muted else "Unmuted.") - return self._muted - - def set_napping(self, napping: Optional[bool]) -> None: - """Force the nap state on/off, or pass None to hand control back to - the quiet-hours schedule.""" - self._nap_forced = napping - if napping is not None: - self._apply_nap_state(napping) - - def wake_threshold(self) -> float: - return self._wake_threshold - - def set_wake_threshold(self, value: float) -> None: - self._wake_threshold = min(max(float(value), 0.01), 0.99) - - def wake_stats(self) -> dict: - return {"peak": self._near_misses.peak, "near_misses": self._near_misses.entries()} - - def reset_wake_stats(self) -> None: - self._near_misses.clear() - - def current_voice(self) -> str: - """Name (or id) of the server-picked voice in use, "" for the default.""" - return self._voice_name or self._voice_id - - def reset_voice(self) -> None: - """Drop a server-picked voice and go back to Bolt's own. The tray's - way out of a voice you didn't want to keep — the server has no way to - ask for the default back, since it never learns what it is.""" - if not self._voice_id: - return - self._voice_id = self._voice_name = "" - self.log.emit("Voice: back to the default.") - self.voice_changed.emit("") - - def stop(self) -> None: - self._running = False - self._talk_now.set() # wake up anything blocked waiting on it - if self._notification_watcher is not None: - self._notification_watcher.stop() - - # ── internal ───────────────────────────────────────────────────────── - - def _handle_state_change(self, _old: PetState, new: PetState) -> None: - self.state_changed.emit(new.value) - - def _should_continue(self) -> bool: - return self._running - - def run(self) -> None: - """Thread entry point (connected to QThread.started).""" - missing = config.missing_config() - if missing: - self.log.emit(f"Missing config: {', '.join(missing)} — set them in .env and restart.") - self.finished.emit() - return - try: - self._stream = mic.open_input_stream() - except Exception as exc: - self.log.emit(f"Could not open microphone: {exc}") - self.finished.emit() - return - - if config.BARGE_IN: - # In wake mode the detector shares the idle listener's model and - # its live threshold, so the tray tuner's slider applies to - # interrupting as well as waking (unless BARGE_IN_WAKE_THRESHOLD - # pins it to a fixed, stricter number). - self._barge_in = barge_in.make_detector( - self._stream, - wake_threshold=( - config.BARGE_IN_WAKE_THRESHOLD - if config.BARGE_IN_WAKE_THRESHOLD is not None - else self.wake_threshold - ), - ) - self.log.emit(f"Barge-in: {config.BARGE_IN_MODE} mode.") - - with self._stream: - try: - health = server_client.check_health() - self.log.emit(f"Connected to server: {health}") - except Exception as exc: - self.log.emit(f"Server not reachable yet ({exc}) — will keep trying per-request.") - self._start_notification_bridge() - self._report_self_restart() - self._loop() - if self._notification_watcher is not None: - self._notification_watcher.stop() - self.finished.emit() - - def _loop(self) -> None: - while self._running: - if self._muted: - triggered = self._talk_now.wait(timeout=0.5) - if not self._running: - return - if not triggered: - continue - self._talk_now.clear() - else: - if not self._wait_for_wake_or_click(): - if not self._running: - return - continue - self._handle_conversation_turn() - - def _wait_for_wake_or_click(self) -> bool: - """True once either the wake phrase was heard or a click-to-talk - request came in; False on a spurious wakeup (loop again).""" - def should_continue() -> bool: - return self._running and not self._talk_now.is_set() - - detected = wake_word.listen_for_wake_word( - self._stream, - should_continue=should_continue, - threshold=self.wake_threshold, # callable: the tuner slider is live - on_tick=self._maybe_heartbeat, - on_score=self._observe_wake_score, - ) - if not self._running: - return False - if detected: - self._talk_now.clear() # in case both fired around the same time - return True - if self._talk_now.is_set(): - self._talk_now.clear() - return True - return False - - def _observe_wake_score(self, score: float, threshold: float) -> None: - self._near_misses.observe(score, threshold, time.time()) - - def _handle_conversation_turn(self) -> None: - # A turn you started yourself ends any follow-up chain in progress. - following_up, self._pending_follow_up = self._pending_follow_up, False - if not following_up: - self._follow_ups = 0 - - self._state.transition(PetState.LISTENING) - # Transcribe while they talk rather than after: frames go to Deepgram - # as they are captured, so the text is ready the moment the VAD says - # they stopped. None here just means the one-shot path will do it. - streamed = stt_stream.StreamingTranscriber.open() - pcm = mic.record_utterance( - self._stream, - should_continue=self._should_continue, - # Answering a question deserves longer than saying the wake word - # on purpose does — you were just asked something. - grace_s=config.FOLLOW_UP_GRACE_SECONDS if following_up else None, - on_frame=streamed.feed if streamed is not None else None, - ) - if pcm is None: - if streamed is not None: - streamed.finish() - self._follow_ups = 0 # silence ends the chain - self._state.transition(PetState.IDLE) - return - - self._state.transition(PetState.THINKING) - try: - text = self._transcribe(pcm, streamed) - except stt.SttError as exc: - self.log.emit(f"STT failed: {exc}") - self._state.transition(PetState.ERROR) - self._state.transition(PetState.IDLE) - return - if not text: - self._state.transition(PetState.IDLE) - return - self.log.emit(f"You: {text}") - self.history.add(history_mod.USER, text, time.time()) - - try: - # What's focused right now rides along, so "what's this error?" - # has a referent without you having to describe the window. - reply = self._ask_server(self._with_context(text)) - except server_client.ServerError as exc: - self.log.emit(f"Server error: {exc}") - self._state.transition(PetState.ERROR) - self._state.transition(PetState.IDLE) - return - - self._check_deliveries() - self._apply_voice(reply) - if reply.spoken: - # Streamed: every sentence was spoken and logged as it arrived. - # The text still matters — a reply ending on a question should keep - # the mic open — but saying it again would repeat the whole answer. - self._after_speaking(reply.text, completed=True) - else: - self._speak(reply.text) - self._state.transition(PetState.IDLE) - self._maybe_self_restart() - - def _transcribe(self, pcm, streamed) -> str: - """The transcript, from the live stream if it produced one. - - The fallback is not a rare path to be tolerated — it is the safety net - that lets streaming be switched on at all. Whatever happened to the - socket, the full audio is still buffered here, so a failed stream costs - one ordinary upload and nothing else.""" - if streamed is not None: - try: - text = streamed.finish() - except Exception: - self.log.emit("Streaming STT failed — falling back.") - text = "" - if text: - self.log.emit("(transcribed while you spoke)") - return text - return stt.transcribe(pcm) - - def _with_context(self, text: str) -> str: - """Everything the server gets alongside what you actually said: the - focused window title, and a one-line note about the screen layout so - Bolt knows how many monitors there are and where he's standing - without having to ask. Only the *layout* rides along for free — the - text on those screens costs an OCR pass, so it stays behind - `petctl read`.""" - text = screen_context.context_for(text) - if config.MONITOR_CONTEXT: - text = monitors_mod.annotate(text, self._monitors, self._pet_monitor) - return text - - # ── screen layout, published by the UI ─────────────────────────────── - - def set_monitors(self, monitors: list) -> None: - """Slot: the window telling us what screens exist (queued signal).""" - self._monitors = list(monitors) - self.log.emit( - "Screens: " + (monitors_mod.summary(self._monitors) or "none reported") - ) - - def set_pet_monitor(self, index: int) -> None: - """Slot: the window telling us which screen the pet is standing on.""" - self._pet_monitor = int(index) - - def _handle_command(self, command: str) -> str: - """Server-relayed command. `petctl ...` drives the pet's body and - `filectl ...` does local file read/write/edit — neither ever reaches - a shell; everything else is a real command, exactly as before (see - the security notes in the README).""" - try: - action = pet_actions.parse(command) - except pet_actions.ActionError as exc: - self.log.emit(f"petctl: {exc}") - return f"[pet] {exc}" - if action is not None: - self.log.emit(f"Pet action: {action}") - - # Queries answer from here rather than from pet_actions.describe(): - # their output *is* the useful part, and it's what the server reads - # back off the tool-result relay. - kind = action["action"] - if kind == "monitors": - return monitors_mod.describe(self._monitors, self._pet_monitor) - if kind == "self_restart": - return self._arm_self_restart(action.get("reason") or "") - if kind == "voice": - # Answered here, not by describe(): the UI has no part in it, - # and the server needs to hear whether there was anything to - # drop — it can't see which voice we're using. - previous = self.current_voice() - self.reset_voice() - return ( - f"[pet] back to your own voice (was {previous})" if previous - else "[pet] already using your own voice" - ) - if kind == "read": - return self._read_screen(action["target"]) - if kind == "jump": - try: - target = monitors_mod.resolve( - self._monitors, action["target"], self._pet_monitor - ) - except ValueError as exc: - self.log.emit(f"petctl jump: {exc}") - return f"[pet] {exc}" - # Hand the window a resolved index, so it can't re-resolve the - # spec against a different screen ordering. - self.action.emit({"action": "jump", "monitor": target.index}) - return f"[pet] jumped to monitor {target.label}" - - if kind == "nap": - self.set_napping(bool(action["enabled"])) - self.action.emit(action) - return pet_actions.describe(action) - - try: - scene = dialogue_mod.parse(command) - except dialogue_mod.DialogueError as exc: - self.log.emit(f"dialoguectl: {exc}") - return f"[dialogue] {exc}" - if scene is not None: - return self._play_dialogue(scene) - - try: - file_action = file_ops.parse(command) - except file_ops.FileOpError as exc: - self.log.emit(f"filectl: {exc}") - return f"[filectl] {exc}" - if file_action is not None: - self.log.emit(file_ops.describe(file_action)) - try: - return file_ops.execute(file_action) - except file_ops.FileOpError as exc: - self.log.emit(f"filectl: {exc}") - return f"[filectl] {exc}" - - return server_client.run_local_command(command) - - def _read_screen(self, target: str) -> str: - """`petctl read` — OCR a screen and hand the text back to the server.""" - if not config.SCREEN_TEXT: - return "[pet] screen reading is disabled (set SCREEN_TEXT=true in .env)" - if not self._monitors: - return "[pet] no monitor information available" - limit = config.SCREEN_TEXT_MAX_CHARS - if target in ("all", "everything", "*"): - self.log.emit(f"Reading all {len(self._monitors)} screens…") - return screen_text.read_monitors(self._monitors, limit) - if target in ("here", "", "this", "current"): - index = self._pet_monitor if self._pet_monitor is not None else 0 - monitor = self._monitors[min(index, len(self._monitors) - 1)] - else: - try: - monitor = monitors_mod.resolve( - self._monitors, target, self._pet_monitor - ) - except ValueError as exc: - return f"[pet] {exc}" - self.log.emit(f"Reading monitor {monitor.number} ({monitor.name})…") - return screen_text.read_monitor(monitor, limit) - - def _arm_self_restart(self, reason: str) -> str: - """`petctl self_restart` — check the code, then arm a restart. - - Nothing restarts here. The tool result has to get back up the relay - before this process can die (otherwise the server waits out its - timeout on a turn that will never finish), so the restart is armed and - `_maybe_self_restart` fires it once the turn has been spoken. The - preflight import runs *now*, in this turn, so a syntax error Bolt just - introduced comes back as something he can read and fix rather than as - a pet that never comes back.""" - if not config.SELF_RESTART: - return "[pet] self-restart is disabled on this device (SELF_RESTART=false)" - if self._restart_context is not None: - return "[pet] a restart is already armed for the end of this turn" - try: - self_restart.check_loop_guard(self_restart.load()) - self.log.emit("Self-restart requested — checking the code imports first…") - self_restart.preflight() - except self_restart.RestartError as exc: - self.log.emit(f"Self-restart refused: {exc}") - return f"[pet] restart refused — {exc}" - - recent = [entry.text[:120] for entry in self.history.entries()[-4:]] - self._restart_context = self_restart.arm( - reason or "no reason given", - verify=reason, - version=__version__, - session=config.SESSION_ID, - recent=recent, - ) - self.log.emit("Self-restart armed; it happens after this turn.") - return ( - "[pet] code imports cleanly; restarting as soon as this turn finishes. " - "I'll come back and tell you what version I'm on and what I found — " - "wrap up your reply now, the next thing you hear from me is the report." - ) - - def _maybe_self_restart(self) -> bool: - """Fire an armed restart, once the turn is over and the reply spoken. - - Returns True if a restart was requested, so the caller can stop - driving the pipeline — the process is on its way out.""" - if self._restart_context is None: - return False - self._update_pending = True # same latch the updater uses: no double restart - self.log.emit("Restarting now.") - self._state.force(PetState.IDLE) - self.restart_requested.emit(f"self-restart: {self._restart_context.reason[:60]}") - return True - - def _report_self_restart(self) -> None: - """On the way up: tell the server we're back, and why we left. - - Runs once, before the listen loop starts, and only when a context file - was left behind. The report goes through the ordinary conversation - path, so Bolt's answer is spoken out loud like any other turn — which - is what makes "restart and check the sprites load" finish as a - sentence instead of a silence.""" - context = self_restart.load() - if context is None: - return - self_restart.clear() - message = self_restart.report(context, version=__version__) - self.log.emit(f"Back from a self-restart ({context.reason[:80]}).") - self.history.add(history_mod.SYSTEM, message, time.time()) - try: - reply = server_client.converse(message, on_command=self._handle_command) - except server_client.ServerError as exc: - # The restart still worked; only the report failed. Say so locally - # rather than pretending nothing happened. - self.log.emit(f"Couldn't report the restart to the server: {exc}") - return - self._apply_voice(reply) - if reply.text.strip() and not self._napping: - self._speak(reply.text) - self._state.force(PetState.IDLE) - - def _ask_server(self, text: str): - """One turn with the server, streamed when possible. - - Streaming speaks each sentence as it is generated, so the wait is - time-to-first-sentence rather than the whole model call. It falls back - to the ordinary request/response path when the server has no streaming - endpoint, or when a stream dies *before* anything was spoken — after - that, retrying would say the first half twice.""" - if config.STREAMING_REPLIES: - try: - return server_client.converse_stream( - text, on_say=self._speak_stream_chunk, - on_command=self._handle_command, - ) - except server_client.ServerError as exc: - self.log.emit(f"Streaming unavailable ({exc}) — using the plain path.") - return server_client.converse( - text, on_command=self._handle_command, on_say=self._speak_holding, - ) - - def _play_dialogue(self, scene: dict) -> str: - """Play a `dialoguectl` scene and report back up the relay. - - This runs *mid-turn* (the server is still waiting on the tool result), - so the pet has to look like it's talking and then go back to waiting — - hence the TALKING → THINKING leg rather than the usual return to IDLE. - Everything a normal reply gets, a scene gets too: the bubble, the - transcript, and barge-in, so a long scene can be talked over exactly - like a long answer.""" - if not config.DIALOGUE: - return "[dialogue] disabled on this device (DIALOGUE=false)" - try: - inputs = dialogue_mod.resolve( - scene, - voices=dialogue_mod.parse_voice_map(config.DIALOGUE_VOICES), - self_voice=self._voice_id or config.ELEVENLABS_VOICE_ID, - ) - except dialogue_mod.DialogueError as exc: - self.log.emit(f"dialoguectl: {exc}") - return f"[dialogue] {exc}" - - text = dialogue_mod.spoken_text(scene) - self.log.emit(f"Dialogue ({len(inputs)} lines): {text[:120]}") - try: - pcm, sample_rate = tts.synthesize_dialogue( - inputs, model_id=scene.get("model"), stability=scene.get("stability") - ) - except tts.TtsError as exc: - self.log.emit(f"Dialogue failed: {exc}") - # Reported, not raised: the server can read this, shorten the - # scene or fix the voice, and try again inside the same turn. - return f"[dialogue] couldn't synthesize it: {exc}" - - completed = self._speaker.say_pcm( - speech.Utterance.scene(text), pcm=pcm, sample_rate=sample_rate) - - if not completed: - return dialogue_mod.describe(scene) + " (interrupted — they talked over it)" - return dialogue_mod.describe(scene) - - def _apply_voice(self, reply) -> None: - """Adopt (or drop) the voice the server tagged this reply with. - - The server's `speak_as` marker names an ElevenLabs voice it just - picked — and it adds a Voice Library pick to the account first, so by - the time the id gets here it's usable for TTS. It tags *one* reply, - but the voice sticks by default: the server strips the marker before - storing the turn, so it can't recall the id later, and "keep talking - like that" would otherwise send it searching for a voice all over - again. `VOICE_STICKY=false` makes each pick last exactly one reply. - - Untagged replies never *change* the voice — with stickiness on they - just keep whatever's in use, which is what makes the rest of the - conversation stay in the requested voice.""" - voice_id = getattr(reply, "voice_id", "") - if voice_id: - if voice_id != self._voice_id: - self._voice_id = voice_id - self._voice_name = getattr(reply, "voice_name", "") or "" - self.log.emit(f"Voice: {self.current_voice()}") - self.voice_changed.emit(self.current_voice()) - elif not config.VOICE_STICKY: - self.reset_voice() - - def _speak(self, text: str) -> None: - """The answer: transcript, bubble, and the follow-up rule.""" - completed = self._speaker.say(speech.Utterance.reply(text)) - self._after_speaking(text, completed=completed, - detail=self._speaker.last_detail) - - def _speak_holding(self, text: str) -> None: - """"Give me a sec" while a tool runs — filler, so no transcript, and it - resumes the state it interrupted because the turn isn't over.""" - self._speaker.say(speech.Utterance.holding(text)) - - def _speak_stream_chunk(self, text: str) -> None: - """One sentence of a streamed answer, spoken the moment it arrives.""" - completed = self._speaker.say(speech.Utterance.stream_chunk(text)) - if not completed: - self.log.emit("Interrupted — listening.") - self._follow_ups = 0 - self._talk_now.set() - - def _after_speaking(self, text: str, *, completed: bool, detail: str = "") -> None: - """What happens once an answer has been said, however it was said. - - Shared by the plain and streamed paths: a streamed reply is spoken - sentence by sentence, but it still has to obey the same rules about - keeping the mic open when it ended on a question.""" - if not completed: - # You talked over it — take that as the start of the next turn - # rather than making you say the wake word again. - self.log.emit(f"Interrupted — listening. {detail}") - self._follow_ups = 0 # you're clearly engaged; start the count over - self._talk_now.set() - elif self._should_follow_up(text): - self._follow_ups += 1 - cap = config.FOLLOW_UP_MAX_TURNS - self.log.emit( - f"Asked a question — listening for your answer " - f"({self._follow_ups}{'/' + str(cap) if cap > 0 else ''})." - ) - self._pending_follow_up = True - self._talk_now.set() - - def _should_follow_up(self, text: str) -> bool: - """Whether *text* leaves the pet waiting on an answer. - - The rule itself — question, cap, off switch — is - `speech.follow_up_decision`, because it is a rule with an off-by-one in - it and deserves a test that needs no audio. What stays here is the part - that is genuinely the controller's: mute, and the log line. Muted is - excluded because mute means "don't listen to me" and an automatic turn - would walk straight past it. Napping isn't: quiet hours suppress the - pet *starting* something, and a question is only ever asked in reply to - you.""" - if self._muted: - return False - keep, why = speech.follow_up_decision(text, completed=True, follow_ups=self._follow_ups) - if not keep and "cap" in why: - # Only worth mentioning the cap on a reply that would otherwise have - # kept listening, or it fires on every statement the pet makes. - self.log.emit("Follow-up limit reached — say the wake word to keep going.") - return keep - - def _barge_in_detail(self) -> str: - """Why the interruption fired, for the log. How far into playback it - happened is the tell: frame 1 means the detector was still holding - audio from before this reply started, whereas a hit several seconds - in is something the mic actually heard.""" - detector = self._barge_in - if isinstance(detector, barge_in.WakeWordBargeIn): - return ( - f"(wake score {detector.last_score:.3f} >= {detector.last_threshold:.2f}, " - f"peak {detector.peak_score:.3f}, at frame {detector.frames_checked} / " - f"{detector.seconds_checked:.1f}s into playback)" - ) - if isinstance(detector, barge_in.BargeInDetector): - return f"(loud frames {detector.loud_frames}, threshold {config.BARGE_IN_RMS_THRESHOLD})" - return "" - - # ── quiet hours / do-not-disturb ───────────────────────────────────── - - def _apply_nap_state(self, napping: bool) -> None: - if napping == self._napping: - return - self._napping = napping - self.log.emit("Napping — no proactive noise." if napping else "Awake.") - self.napping.emit(napping) - - def _refresh_nap_state(self) -> None: - if self._nap_forced is not None: - self._apply_nap_state(self._nap_forced) - return - now = time.monotonic() - if now - self._last_nap_check < _NAP_CHECK_INTERVAL_SECONDS: - return - self._last_nap_check = now - napping = quiet.is_quiet( - config.QUIET_HOURS, - on_error=lambda exc: self.log.emit(f"QUIET_HOURS is malformed ({exc}) — ignoring it."), - ) - if not napping and config.DND_ON_FULLSCREEN: - napping = screen_context.is_fullscreen_active() - self._apply_nap_state(napping) - - # ── desktop notification bridge ────────────────────────────────────── - - def _start_notification_bridge(self) -> None: - if not config.NOTIFICATION_BRIDGE: - return - watcher = notifications.NotificationWatcher(self._queue_notification) - problem = watcher.start() - if problem: - self.log.emit(problem) - return - self._notification_watcher = watcher - self.log.emit("Notification bridge on.") - - def _queue_notification(self, notification: notifications.Notification) -> None: - """Called on the watcher thread — just queue it; forwarding happens on - the pipeline thread where it can't collide with a live conversation. - - Only the *filter* applies here. The rate limit used to as well, which - meant a second message arriving inside the window was silently thrown - away — with NOTIFICATION_MIN_INTERVAL_SECONDS=60, two texts a minute - apart and you only ever heard about one of them. Losing a message from - a person to save a round trip is the wrong trade; they are batched at - the far end instead, which costs the same one round trip and keeps - them all.""" - if not self._notification_gate.matches(notification): - return - with self._notification_lock: - if len(self._pending_notifications) >= _MAX_PENDING_NOTIFICATIONS: - self._pending_notifications.pop(0) # bound it; oldest goes first - self._pending_notifications.append(notification) - - def _drain_notifications(self) -> None: - """Forward everything waiting as ONE turn. - - Batching is what makes it safe to keep every notification: five that - arrived while the pet was mid-conversation become one message and one - round trip, instead of five separate interruptions queued up to fire - back to back.""" - with self._notification_lock: - pending, self._pending_notifications = self._pending_notifications, [] - if not pending or not self._running or self._napping: - return - for notification in pending: - self.log.emit(f"Notification: {notification.as_text()}") - self.history.add(history_mod.SYSTEM, notification.as_text(), time.time()) - - if len(pending) == 1: - message = f"[desktop notification] {pending[0].as_text()}" - else: - lines = "\n".join(f"- {n.as_text()}" for n in pending) - message = f"[{len(pending)} desktop notifications]\n{lines}" - try: - reply = server_client.converse( - message, on_command=self._handle_command, on_say=self._speak_holding, - ) - except server_client.ServerError as exc: - self.log.emit(f"Couldn't forward notification: {exc}") - return - self._check_deliveries() - self._apply_voice(reply) - if reply.text.strip() and not reply.spoken: - self._speak(reply.text) - self._state.transition(PetState.IDLE) - - # ── file delivery ──────────────────────────────────────────────────── - - def _check_deliveries(self) -> None: - """Download anything the server has queued via deliver_files — - called right after a conversation/notification turn (the common - case: "send me that file") and once per heartbeat for anything - queued out-of-band. Best-effort: a failure here is logged, not - raised, so it can't sour a turn that already got its spoken reply.""" - if not config.RECEIVE_FILES: - return - try: - queued = server_client.list_outbox_files() - except server_client.ServerError as exc: - self.log.emit(f"Couldn't check for delivered files: {exc}") - return - for entry in queued: - file_id = entry.get("id") - name = entry.get("name") or file_id - if not file_id: - continue - try: - data = server_client.download_outbox_file(file_id) - except server_client.ServerError as exc: - self.log.emit(f"Couldn't download {name}: {exc}") - continue - path = file_delivery.save(config.DELIVERED_FILES_DIR, name, data) - self.log.emit(f"Received file: {path}") - - # ── auto-update ────────────────────────────────────────────────────── - - def _maybe_update(self) -> None: - """Poll the Gitea releases page and, if there's a newer tag, apply it - and ask the UI to restart. - - Only ever runs from the wake-listener's tick, so the pet is IDLE and - between turns by construction — an update can't land mid-sentence. - Failures are logged and the interval resets, so a server that's down - (or a release that rolls back) costs one log line an hour, not a - retry storm.""" - if not config.AUTO_UPDATE or self._update_pending: - return - now = time.monotonic() - if now - self._last_update_check < config.UPDATE_CHECK_INTERVAL_SECONDS: - return - self._last_update_check = now - try: - release = updater.check_for_update() - except updater.UpdateError as exc: - self.log.emit(f"Update check failed: {exc}") - return - if release is None: - return - - self.log.emit(f"Update available: {release.tag} — applying.") - try: - previous = updater.apply_update(release.tag, on_log=self.log.emit) - except updater.UpdateError as exc: - self.log.emit(f"Update to {release.tag} failed: {exc}") - return - - self._update_pending = True - self.log.emit(f"Updated {previous} -> {release.tag}; restarting.") - if not self._napping: - # Napping means no proactive noise, so a silent restart it is. - self._speak(f"Updating to {release.tag}. Back in a second.") - self._state.transition(PetState.IDLE) - self.restart_requested.emit(release.tag) - - # ── heartbeat ──────────────────────────────────────────────────────── - - def _maybe_heartbeat(self) -> None: - """Called from the wake listener's tick (~every WAKE_CHECK_INTERVAL_SECONDS). - - Two different cadences live here, and conflating them was costing - minutes. A *notification* is an event that already happened — it should - go out as soon as the pet is free, which is the next tick. The - *heartbeat* is a poll, and polling the server every 1.2s would be - absurd, so it stays on its own interval.""" - self._refresh_nap_state() - self._maybe_update() - if self._update_pending: - return # on the way out — don't start a conversation now - - # Notifications: every tick, not every heartbeat. - if self._state.state == PetState.IDLE and not self._napping: - self._drain_notifications() - - now = time.monotonic() - if now - self._last_heartbeat < config.HEARTBEAT_INTERVAL_SECONDS: - return - if self._state.state != PetState.IDLE: - # Mid-conversation. Do NOT stamp the clock — an earlier version - # did, so a heartbeat that landed while the pet was talking burned - # its slot and waited another full interval. With follow-up - # listening that could repeat for several cycles, which is why a - # notification could sit unspoken for five or ten minutes. - return - if self._napping: - return # quiet hours: still answers when spoken to, just doesn't start - self._last_heartbeat = now - self._check_deliveries() - if self._state.state != PetState.IDLE: - return - try: - announcement = server_client.report_status() - except server_client.ServerError as exc: - self.log.emit(f"Heartbeat failed: {exc}") - return - if announcement: - self._speak(announcement) - self._state.transition(PetState.IDLE) diff --git a/bolt_pet/dialogue.py b/bolt_pet/dialogue.py deleted file mode 100644 index b95028f..0000000 --- a/bolt_pet/dialogue.py +++ /dev/null @@ -1,235 +0,0 @@ -"""`dialoguectl` — multi-voice dialogue playback (ElevenLabs Text to Dialogue). - -Normal replies are one voice saying one thing (audio/tts.py). This is the -other mode: a short *scene* — two or more voices, with delivery tags the v3 -model acts on (`[cheerfully]`, `[stuttering]`, `[whispering]`) — synthesized -as a single take so the timing and reactions between lines actually sound -like a conversation rather than clips glued together. - -Wire format, the same discipline as file_ops.py and for the same reason: it -rides the server's ordinary `command` tool marker, whose extractor only -captures up to the next newline, so the payload is a **single-line compact -JSON object**. - - dialoguectl {"lines": [{"voice": "self", "text": "[cheerfully] Morning!"}, - {"voice": "narrator", "text": "[whispering] He lies."}]} - -The ElevenLabs field names are accepted too (`inputs` / `voice_id`), because -the model has read that API and copying its shape is the obvious thing to -try: - - dialoguectl {"inputs": [{"voice_id": "9BWtsMINqrJLrRacOk9x", "text": "hi"}]} - -Voices are *named*, not pasted as ids. `DIALOGUE_VOICES` in .env maps names -to ids (`narrator:9BWts…,villain:IKne3…`), and `self` always means the voice -the pet is speaking with right now — including a voice the server picked -mid-conversation with `speak_as`, so a scene featuring Bolt sounds like -whoever Bolt currently is. - -Pure parsing and validation here; the HTTP call is -`audio/tts.synthesize_dialogue` and the playback/state handling is -`controller._play_dialogue`, matching the parse/execute split used by -pet_actions.py and file_ops.py. - -The API's own limits are enforced *here*, before the request goes out, so a -mistake comes back through the tool-result relay as a sentence Bolt can act -on ("too many characters, split it") rather than as an HTTP 422 he can't see. -""" - -from __future__ import annotations - -import json -import re -from typing import Iterable, Optional - -from . import relay_json - -_PREFIXES = ("dialoguectl", "dialogue", "scene") - -# ElevenLabs Text to Dialogue limits (docs, 2026-07): at most 10 distinct -# voice ids per request and ~2000 characters across all inputs. -MAX_VOICES = 10 -MAX_CHARS = 2000 - -# Names that always mean "the voice the pet is using right now". -SELF_NAMES = ("self", "bolt", "me", "pet") - -# A raw ElevenLabs voice id: 20 URL-safe characters, no separators. Used to -# tell "the model pasted an id" from "the model used a name". -_VOICE_ID_RE = re.compile(r"^[A-Za-z0-9]{20}$") - - -class DialogueError(Exception): - """Bad dialoguectl syntax or an unusable request — reported back to the - server as this command's output.""" - - -def is_dialogue_command(command: str) -> bool: - parts = (command or "").strip().split(None, 1) - return bool(parts) and parts[0].lower() in _PREFIXES - - -def parse(command: str) -> Optional[dict]: - """Parse `dialoguectl ` into {"lines": [{"voice", "text"}], ...}. - - Returns None if this isn't a dialogue command at all (the caller then - tries filectl, then a real shell command). Raises DialogueError on a - dialogue command that doesn't make sense.""" - if not is_dialogue_command(command): - return None - _, _, payload = (command or "").strip().partition(" ") - payload = payload.strip() - if not payload: - raise DialogueError( - 'dialoguectl needs a JSON argument, e.g. dialoguectl {"lines": ' - '[{"voice": "self", "text": "[cheerfully] hello"}]}' - ) - # Same lenient parse as filectl (see relay_json): machine-written JSON - # fails in a handful of repeatable ways, and a stray quote shouldn't cost - # a turn — but the repair is reported back rather than hidden. - try: - data, repairs = relay_json.loads(payload) - except relay_json.RelayJsonError as exc: - raise DialogueError( - f"couldn't parse the JSON — {exc}\nIt must be one line of compact " - "JSON — put line breaks inside text as \\n, never as real newlines." - ) from exc - if not isinstance(data, dict): - raise DialogueError("the argument must be a JSON object, not a list or a bare value") - - raw_lines = data.get("lines") - if raw_lines is None: - raw_lines = data.get("inputs") # the ElevenLabs field name - if not isinstance(raw_lines, list) or not raw_lines: - raise DialogueError('needs a non-empty "lines" array of {"voice", "text"} objects') - - lines: list[dict] = [] - for index, entry in enumerate(raw_lines, start=1): - if not isinstance(entry, dict): - raise DialogueError(f"line {index} must be an object with 'voice' and 'text'") - text = str(entry.get("text") or "").strip() - if not text: - raise DialogueError(f"line {index} has no text") - voice = str(entry.get("voice") or entry.get("voice_id") or "self").strip() - lines.append({"voice": voice, "text": text}) - - action = {"action": "dialogue", "lines": lines} - if repairs: - action["_repairs"] = repairs - model = str(data.get("model") or data.get("model_id") or "").strip() - if model: - action["model"] = model - stability = data.get("stability") - if stability is not None: - try: - action["stability"] = min(1.0, max(0.0, float(stability))) - except (TypeError, ValueError): - raise DialogueError("stability must be a number between 0 and 1") from None - return action - - -def parse_voice_map(spec: str) -> dict[str, str]: - """Parse DIALOGUE_VOICES ("narrator:9BWts…, villain:IKne3…") into a map. - - Malformed entries are skipped rather than raising: a typo in .env should - cost that one voice, not the whole feature.""" - voices: dict[str, str] = {} - for chunk in str(spec or "").split(","): - name, separator, voice_id = chunk.partition(":") - name, voice_id = name.strip().lower(), voice_id.strip() - if separator and name and voice_id: - voices[name] = voice_id - return voices - - -def resolve( - action: dict, - *, - voices: Optional[dict] = None, - self_voice: str = "", -) -> list[dict]: - """Turn parsed lines into the API's `inputs`, resolving names to ids. - - *self_voice* is the pet's current voice (which may be a `speak_as` pick, - not the configured default), so "self" tracks whoever Bolt sounds like - right now.""" - known = dict(voices or {}) - resolved: list[dict] = [] - for index, line in enumerate(action.get("lines") or [], start=1): - name = str(line.get("voice") or "self") - key = name.lower() - if key in SELF_NAMES: - voice_id = self_voice - if not voice_id: - raise DialogueError( - "no voice is configured for the pet itself — set " - "ELEVENLABS_VOICE_ID, or name a voice from DIALOGUE_VOICES" - ) - elif key in known: - voice_id = known[key] - elif _VOICE_ID_RE.match(name): - voice_id = name # a raw id pasted straight from the voice library - else: - available = ", ".join(sorted(known) + list(SELF_NAMES[:1])) or "self" - raise DialogueError( - f"line {index}: unknown voice {name!r}. Known names: {available}. " - "Use one of those, 'self' for your own voice, or a raw voice id." - ) - resolved.append({"text": str(line.get("text") or ""), "voice_id": voice_id}) - - check_limits(resolved) - return resolved - - -def check_limits(inputs: Iterable[dict], *, max_voices: int = MAX_VOICES, - max_chars: int = MAX_CHARS) -> None: - """Enforce the API's own limits before spending a request on a 422.""" - entries = list(inputs) - if not entries: - raise DialogueError("no lines to speak") - distinct = {entry["voice_id"] for entry in entries} - if len(distinct) > max_voices: - raise DialogueError( - f"{len(distinct)} different voices — the limit is {max_voices} per scene" - ) - total = sum(len(entry["text"]) for entry in entries) - if total > max_chars: - raise DialogueError( - f"{total} characters — the limit is {max_chars} per scene. " - "Split it into two dialoguectl calls." - ) - - -def spoken_text(action: dict) -> str: - """The scene as readable text, for the speech bubble and the transcript. - - Delivery tags are stripped: `[cheerfully]` is a stage direction for the - model, not something to show (or, via tts.speak's sanitizer, to read out).""" - parts = [] - for line in action.get("lines") or []: - text = re.sub(r"\[[^\]]{1,40}\]", " ", str(line.get("text") or "")) - text = " ".join(text.split()) - if text: - parts.append(text) - return " ".join(parts) - - -def describe(action: dict, *, played: bool = True) -> str: - """The tool-result string handed back to the server.""" - lines = action.get("lines") or [] - voices = sorted({str(line.get("voice") or "self") for line in lines}) - note = relay_json.repair_note(action.get("_repairs") or []) - if not played: - return f"[dialogue] not played ({len(lines)} lines){note}" - return ( - f"[dialogue] played {len(lines)} line{'s' if len(lines) != 1 else ''} " - f"in {len(voices)} voice{'s' if len(voices) != 1 else ''}: {', '.join(voices)}{note}" - # The scene was spoken out loud before this result got back to the - # server, and the model has no other way to know that. Without saying - # so it writes a final reply summarising what the user just heard, and - # the pet says the same thing twice in a row (observed 2026-07-31). - # An instruction delivered here, at the moment it applies, lands far - # better than a rule buried in a long system prompt. - "\nThe user HEARD this already. Do not repeat, summarise or narrate it " - "in your reply — answer with at most one short line, or nothing new." - ) diff --git a/bolt_pet/doctor.py b/bolt_pet/doctor.py deleted file mode 100644 index 3c3a36e..0000000 --- a/bolt_pet/doctor.py +++ /dev/null @@ -1,323 +0,0 @@ -"""`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: - """STT is a websocket relay to the server (/desk/stt) — no local Deepgram - account, but websocket-client is now load-bearing for transcription to - work at all, not just the streaming optimisation (there's no separate - REST fallback any more).""" - if not config.is_configured(): - return Check("speech-to-text", FAIL, "no BOLT_SERVER_URL/DESK_API_KEY", - "set them in .env — nothing you say can be transcribed without the server") - if not _module("websocket"): - return Check("speech-to-text", FAIL, "websocket-client is missing", - "pip install -r requirements.txt — /desk/stt is a websocket relay " - "with no REST fallback") - mode = "streaming" if config.STT_STREAMING else "one-shot (still via the server relay)" - return Check("speech-to-text", OK, f"server relay, {mode}") - - -def check_tts(deep: bool = False) -> Check: - """TTS is the server's /desk/tts — no local ElevenLabs account needed for - the normal reply voice, just a voice id for it to request. - - Shallow only checks config presence, same as the other shallow checks — - it can't see a live failure (bad voice id, an expired key, a Voicebox - clone owned by someone else). --deep actually calls /desk/tts with a - couple of words and reports the real HTTP status/error, which is the - only way to tell "misconfigured" apart from "configured but broken" - (e.g. speak() silently falling back to the offline voice).""" - if not (config.is_configured() and config.ELEVENLABS_VOICE_ID): - if _module("pyttsx3"): - return Check("text-to-speech", WARN, "server/voice not configured — offline voice only", - "set BOLT_SERVER_URL/DESK_API_KEY and ELEVENLABS_VOICE_ID for the real voice") - return Check("text-to-speech", FAIL, "no server/voice config and no pyttsx3 fallback", - "set BOLT_SERVER_URL/DESK_API_KEY/ELEVENLABS_VOICE_ID, " - "or pip install pyttsx3 for an offline voice") - if not deep: - return Check("text-to-speech", OK, - f"server relay, voice …{config.ELEVENLABS_VOICE_ID[-6:]} " - "(not probed; --deep to actually synthesize)") - try: - import requests - - response = requests.post( - f"{config.SERVER_URL}/desk/tts", - headers={"X-Desk-Api-Key": config.API_KEY}, - json={"session_id": config.SESSION_ID, "text": "testing", - "voice_id": config.ELEVENLABS_VOICE_ID}, - timeout=20, - ) - except Exception as exc: - return Check("text-to-speech", FAIL, f"probe failed: {exc}", - "check BOLT_SERVER_URL is reachable from this machine") - if response.status_code == 200 and response.content: - return Check("text-to-speech", OK, - f"server synthesized {len(response.content)} bytes " - f"for voice …{config.ELEVENLABS_VOICE_ID[-6:]}") - detail = "" - try: - detail = str(response.json().get("error") or "") - except Exception: - pass - return Check( - "text-to-speech", FAIL, - f"server returned {response.status_code}{': ' + detail if detail else ''} " - f"for voice …{config.ELEVENLABS_VOICE_ID[-6:]}", - "a 404 means this voice id isn't yours (or doesn't exist); 402 means " - "the account is out of credits; 401 means DESK_API_KEY is wrong", - ) - - -def check_dialogue() -> Check: - if not config.DIALOGUE: - return Check("multi-voice scenes", WARN, "disabled (DIALOGUE=false)") - if not config.ELEVENLABS_API_KEY: - return Check("multi-voice scenes", WARN, "no ELEVENLABS_API_KEY", - "set it in .env — dialogue scenes are the one feature still calling " - "ElevenLabs directly, since the server has no equivalent endpoint") - 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 diff --git a/bolt_pet/history.py b/bolt_pet/history.py deleted file mode 100644 index 30177b1..0000000 --- a/bolt_pet/history.py +++ /dev/null @@ -1,62 +0,0 @@ -"""Rolling transcript of the conversation. - -The speech bubble hides itself after a few seconds, which is fine for chat -but bad for anything you actually needed to read (a command's output, a -number, a URL). This keeps the last HISTORY_LIMIT turns so the tray's -History window — and click-to-copy on the bubble — have something to show. - -Pure logic, no Qt: the UI half is ui/history_window.py. -""" - -from __future__ import annotations - -from collections import deque -from dataclasses import dataclass -from typing import Iterable, Optional - -USER = "you" -PET = "bolt" -SYSTEM = "system" - - -@dataclass(frozen=True) -class Entry: - role: str - text: str - timestamp: Optional[float] = None # time.time(); None when not recorded - - def formatted(self, clock=None) -> str: - label = {USER: "You", PET: "Bolt", SYSTEM: "—"}.get(self.role, self.role) - stamp = clock(self.timestamp) if (clock and self.timestamp) else None - return f"[{stamp}] {label}: {self.text}" if stamp else f"{label}: {self.text}" - - -class ConversationHistory: - def __init__(self, limit: int = 100): - self._entries: deque[Entry] = deque(maxlen=max(1, limit)) - - def add(self, role: str, text: str, timestamp: Optional[float] = None) -> Optional[Entry]: - text = (text or "").strip() - if not text: - return None - entry = Entry(role=role, text=text, timestamp=timestamp) - self._entries.append(entry) - return entry - - def entries(self) -> list[Entry]: - return list(self._entries) - - def last(self, role: Optional[str] = None) -> Optional[Entry]: - for entry in reversed(self._entries): - if role is None or entry.role == role: - return entry - return None - - def clear(self) -> None: - self._entries.clear() - - def as_text(self, clock=None, entries: Optional[Iterable[Entry]] = None) -> str: - return "\n".join(e.formatted(clock) for e in (entries if entries is not None else self._entries)) - - def __len__(self) -> int: - return len(self._entries) diff --git a/bolt_pet/hotkey.py b/bolt_pet/hotkey.py deleted file mode 100644 index 9c45f36..0000000 --- a/bolt_pet/hotkey.py +++ /dev/null @@ -1,105 +0,0 @@ -"""Global push-to-talk hotkey. - -The wake word is the primary trigger, but it misfires in a noisy room and -won't fire at all if you're on a call — so there's a keyboard fallback that -works even when the pet has no focus (it's a frameless Qt.Tool window with no -taskbar entry, so an ordinary QShortcut would never see the key). - -Needs `pynput`, which needs an X11/Win32/macOS input hook: on most Wayland -sessions it can't grab global keys, and on macOS it needs Accessibility -permission. All of that is a soft failure — start() reports the reason and -the wake word / tray keep working. -""" - -from __future__ import annotations - -from typing import Callable, Optional - -# Aliases for the names people actually type in a .env file. -_ALIASES = { - "control": "ctrl", - "ctl": "ctrl", - "option": "alt", - "opt": "alt", - "win": "cmd", - "windows": "cmd", - "super": "cmd", - "meta": "cmd", - "command": "cmd", - "return": "enter", - "escape": "esc", - "del": "delete", - "ins": "insert", - "pgup": "page_up", - "pgdn": "page_down", -} - - -class HotkeyError(Exception): - pass - - -def to_pynput_spec(spec: str) -> str: - """"ctrl+alt+space" -> "++" (pynput's GlobalHotKeys - syntax: named keys in angle brackets, literal characters bare).""" - tokens = [t.strip().lower() for t in (spec or "").split("+")] - tokens = [t for t in tokens if t] - if not tokens: - raise HotkeyError("empty hotkey") - parts = [] - for token in tokens: - token = _ALIASES.get(token, token) - parts.append(token if len(token) == 1 else f"<{token}>") - return "+".join(parts) - - -class GlobalHotkey: - """Fires *callback* whenever the hotkey is pressed, anywhere. Safe to - construct unconditionally — nothing happens until start(), and start() - reports failure instead of raising into the UI thread.""" - - def __init__(self, spec: str, callback: Callable[[], None]): - self.spec = spec - self._callback = callback - self._listener = None - - @property - def running(self) -> bool: - return self._listener is not None - - def start(self) -> Optional[str]: - """None on success, otherwise a human-readable reason it's off.""" - if not (self.spec or "").strip(): - return None # explicitly disabled — not an error worth reporting - try: - pynput_spec = to_pynput_spec(self.spec) - except HotkeyError as exc: - return f"push-to-talk hotkey {self.spec!r} is invalid: {exc}" - try: - from pynput import keyboard - except Exception as exc: # ImportError, or a backend that won't load - return f"push-to-talk needs pynput ({exc}) — wake word still works" - try: - listener = keyboard.GlobalHotKeys({pynput_spec: self._safe_callback}) - listener.daemon = True - listener.start() - except Exception as exc: - return f"push-to-talk unavailable on this session ({exc}) — wake word still works" - self._listener = listener - return None - - def _safe_callback(self) -> None: - # This runs on pynput's listener thread; an exception there would - # silently kill the listener for the rest of the session. - try: - self._callback() - except Exception: - pass - - def stop(self) -> None: - if self._listener is not None: - try: - self._listener.stop() - except Exception: - pass - self._listener = None diff --git a/bolt_pet/monitors.py b/bolt_pet/monitors.py deleted file mode 100644 index 9f3990c..0000000 --- a/bolt_pet/monitors.py +++ /dev/null @@ -1,213 +0,0 @@ -"""Which screens exist, and which one the pet is standing on. - -Deliberately free of Qt *and* of any subprocess probing: the monitor list is -published by the UI (`ui/pet_window.py` builds it from -`QGuiApplication.screens()`) and handed to the controller over a queued -signal, the same way every other UI↔controller message travels. - -That indirection is the whole point. The pet has to agree with itself about -what "monitor 2" means — if the controller enumerated screens with `xrandr` -while the window jumped using Qt's screen list, the two orderings could -disagree and Bolt would announce one screen and land on another. Making Qt the -single source of truth removes that class of bug, and leaves everything here -pure enough to unit test without a display. - -Indices are **1-based in every string a human or the model ever sees**, and -0-based in the list itself. `Monitor.index` is the 0-based one; `.number` is -what gets printed. -""" - -from __future__ import annotations - -from dataclasses import dataclass -from typing import Iterable, Optional - -# Directional specs understood by resolve(), mapped to a (dx, dy) heading. -_DIRECTIONS = { - "left": (-1, 0), - "right": (1, 0), - "up": (0, -1), - "above": (0, -1), - "down": (0, 1), - "below": (0, 1), -} - - -@dataclass(frozen=True) -class Monitor: - """One screen, in the global desktop coordinate space.""" - - index: int # 0-based position in the published list - name: str - x: int - y: int - width: int - height: int - primary: bool = False - - @property - def number(self) -> int: - """1-based, for anything a person or the model reads.""" - return self.index + 1 - - @property - def right(self) -> int: - return self.x + self.width - - @property - def bottom(self) -> int: - return self.y + self.height - - @property - def center(self) -> tuple[int, int]: - return self.x + self.width // 2, self.y + self.height // 2 - - def contains(self, x: int, y: int) -> bool: - return self.x <= x < self.right and self.y <= y < self.bottom - - @property - def label(self) -> str: - bits = f"{self.number}: {self.name} {self.width}x{self.height}" - return bits + " (primary)" if self.primary else bits - - -def monitor_containing( - monitors: Iterable[Monitor], x: int, y: int -) -> Optional[Monitor]: - """The screen holding point (x, y), or None if it's off every screen.""" - for monitor in monitors: - if monitor.contains(x, y): - return monitor - return None - - -def nearest_monitor(monitors: Iterable[Monitor], x: int, y: int) -> Optional[Monitor]: - """Screen whose centre is closest to (x, y) — the fallback when a point - lands in the dead space between mismatched screens.""" - monitors = list(monitors) - if not monitors: - return None - return min( - monitors, - key=lambda m: (m.center[0] - x) ** 2 + (m.center[1] - y) ** 2, - ) - - -def resolve( - monitors: list[Monitor], spec: str, current: Optional[int] = None -) -> Monitor: - """Turn a `petctl jump` target into a screen. - - Accepts a 1-based number, a name (case-insensitive substring, so "hdmi" - finds "HDMI-0"), `next`/`prev`, `primary`, `other`, or a direction - (`left`/`right`/`up`/`down`) relative to *current*. Raises ValueError with - a message meant to be read by the model, since it goes back as tool - output. - """ - if not monitors: - raise ValueError("no monitors have been reported yet") - spec = (spec or "").strip().lower() - if not spec: - raise ValueError("jump needs a target monitor") - count = len(monitors) - if current is None or not (0 <= current < count): - current = next((m.index for m in monitors if m.primary), 0) - - if spec.isdigit(): - number = int(spec) - if not (1 <= number <= count): - raise ValueError( - f"there is no monitor {number}; you have {count} " - f"(1-{count})" - ) - return monitors[number - 1] - - if spec in ("next", "forward"): - return monitors[(current + 1) % count] - if spec in ("prev", "previous", "back"): - return monitors[(current - 1) % count] - if spec == "primary": - return next((m for m in monitors if m.primary), monitors[0]) - if spec == "other": - # With two screens "the other one" is unambiguous; with more it's just - # the next one round, which is at least always a *different* screen. - return monitors[(current + 1) % count] - if spec == "random": - # Deterministic-free choice is the caller's business; pick the screen - # furthest from the current one so "random" always visibly moves. - here = monitors[current].center - return max( - monitors, - key=lambda m: (m.center[0] - here[0]) ** 2 + (m.center[1] - here[1]) ** 2, - ) - - if spec in _DIRECTIONS: - dx, dy = _DIRECTIONS[spec] - here = monitors[current].center - candidates = [] - for monitor in monitors: - if monitor.index == current: - continue - ox, oy = monitor.center - along = (ox - here[0]) * dx + (oy - here[1]) * dy - if along <= 0: - continue # not in that direction at all - drift = abs((ox - here[0]) * dy + (oy - here[1]) * dx) - candidates.append((drift, along, monitor)) - if not candidates: - raise ValueError( - f"there's no monitor to the {spec} of monitor " - f"{monitors[current].number}" - ) - # Prefer the best-aligned screen, then the closest of those. - candidates.sort(key=lambda item: (item[0], item[1])) - return candidates[0][2] - - matches = [m for m in monitors if spec in m.name.lower()] - if len(matches) == 1: - return matches[0] - if len(matches) > 1: - raise ValueError( - f"{spec!r} matches several monitors: " - + ", ".join(m.label for m in matches) - ) - raise ValueError( - f"unknown monitor {spec!r}; you have: " - + "; ".join(m.label for m in monitors) - + " — or use next/prev/primary/left/right/up/down" - ) - - -def describe(monitors: list[Monitor], current: Optional[int] = None) -> str: - """Full listing, used as `petctl monitors` output.""" - if not monitors: - return "[pet] no monitor information available" - lines = [f"[pet] {len(monitors)} monitor(s):"] - for monitor in monitors: - here = " <- Bolt is here" if monitor.index == current else "" - lines.append( - f" {monitor.label} at +{monitor.x}+{monitor.y}{here}" - ) - return "\n".join(lines) - - -def summary(monitors: list[Monitor], current: Optional[int] = None) -> Optional[str]: - """One-line version tacked onto each utterance — short on purpose, since - it rides along with every single thing you say.""" - if not monitors: - return None - parts = ", ".join(f"{m.number}) {m.name} {m.width}x{m.height}" for m in monitors) - line = f"{len(monitors)} monitors: {parts}" - if current is not None and 0 <= current < len(monitors): - line += f"; Bolt is on {monitors[current].number}" - return line - - -def annotate(text: str, monitors: list[Monitor], current: Optional[int] = None) -> str: - """Attach the screen summary as a separate aside, matching the style of - screen_context.annotate() so the model can ignore it when irrelevant.""" - text = (text or "").strip() - line = summary(monitors, current) - if not text or not line: - return text - return f"{text}\n\n[{line}]" diff --git a/bolt_pet/notifications.py b/bolt_pet/notifications.py deleted file mode 100644 index 6ad4a6d..0000000 --- a/bolt_pet/notifications.py +++ /dev/null @@ -1,166 +0,0 @@ -"""Desktop notification bridge (Linux/D-Bus). - -Lets Bolt react to things that happen without you: a build finishing, a -calendar alert, a message arriving. Notifications are tailed from -`dbus-monitor`, filtered, rate-limited, and handed to the controller, which -forwards them through the normal converse() path — so the pet can say -"your deploy just went green" instead of only ever answering questions. - -Off by default (NOTIFICATION_BRIDGE): every forwarded notification is a -round trip to the server, and an unfiltered desktop can be very chatty. -NOTIFICATION_FILTER (a regex) is the main knob for keeping it useful. - -The dbus-monitor *parsing* is pure and unit tested; only the subprocess -plumbing needs a real session bus. -""" - -from __future__ import annotations - -import platform -import re -import shutil -import subprocess -import threading -from dataclasses import dataclass -from typing import Callable, Iterable, Iterator, Optional - -_STRING_LINE = re.compile(r'^\s*string\s+"(.*)"\s*$') -_BLOCK_START = re.compile(r"^(method call|signal|method return|error)\b") - - -@dataclass(frozen=True) -class Notification: - app: str - summary: str - body: str - - def as_text(self) -> str: - parts = [p for p in (self.summary, self.body) if p] - joined = " — ".join(parts) - return f"{self.app}: {joined}" if self.app else joined - - -def iter_notifications(lines: Iterable[str]) -> Iterator[Notification]: - """Pull Notification records out of a `dbus-monitor` line stream. - - A Notify call prints its arguments one per line after the header; the - string arguments arrive in the order app_name, app_icon, summary, body - (replaces_id is a uint32, so it isn't in the string list). Anything - that doesn't look like that is skipped rather than guessed at. - """ - collecting = False - strings: list[str] = [] - - def _emit() -> Optional[Notification]: - if len(strings) < 3: - return None - return Notification(app=strings[0].strip(), summary=strings[2].strip(), - body=(strings[3].strip() if len(strings) > 3 else "")) - - for line in lines: - if _BLOCK_START.match(line): - if collecting: - notification = _emit() - if notification is not None: - yield notification - collecting = "member=Notify" in line - strings = [] - continue - if not collecting: - continue - match = _STRING_LINE.match(line) - if match: - strings.append(match.group(1)) - if collecting: - notification = _emit() - if notification is not None: - yield notification - - -class NotificationGate: - """Filter + rate limit. Clock is passed in (monotonic seconds) so the - rate limiting is testable without sleeping.""" - - def __init__(self, pattern: str = "", min_interval: float = 60.0): - self._min_interval = max(0.0, min_interval) - self._last_forwarded = None - self._pattern = None - if (pattern or "").strip(): - try: - self._pattern = re.compile(pattern, re.IGNORECASE) - except re.error: - self._pattern = None # a broken regex shouldn't mute everything - - def matches(self, notification: Notification) -> bool: - if self._pattern is None: - return True - return bool(self._pattern.search(notification.as_text())) - - def should_forward(self, notification: Notification, now: float) -> bool: - if not notification.as_text().strip(): - return False - if not self.matches(notification): - return False - if self._last_forwarded is not None and now - self._last_forwarded < self._min_interval: - return False - self._last_forwarded = now - return True - - -def available() -> bool: - return platform.system() == "Linux" and shutil.which("dbus-monitor") is not None - - -class NotificationWatcher: - """Tails dbus-monitor on a daemon thread, calling *callback* per - notification. Best-effort: if the session bus isn't reachable it reports - why via start() and stays off.""" - - _ARGS = [ - "dbus-monitor", "--session", - "interface='org.freedesktop.Notifications',member='Notify'", - ] - - def __init__(self, callback: Callable[[Notification], None]): - self._callback = callback - self._process: Optional[subprocess.Popen] = None - self._thread: Optional[threading.Thread] = None - self._running = False - - def start(self) -> Optional[str]: - """None on success, otherwise the reason the bridge is off.""" - if not available(): - return "notification bridge needs Linux + dbus-monitor — skipping" - try: - self._process = subprocess.Popen( - self._ARGS, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, - text=True, bufsize=1, - ) - except Exception as exc: - return f"couldn't start dbus-monitor ({exc}) — notification bridge off" - self._running = True - self._thread = threading.Thread(target=self._pump, name="notification-bridge", daemon=True) - self._thread.start() - return None - - def _pump(self) -> None: - assert self._process is not None and self._process.stdout is not None - try: - for notification in iter_notifications(self._process.stdout): - if not self._running: - return - try: - self._callback(notification) - except Exception: - pass # one bad notification shouldn't end the bridge - except Exception: - pass - - def stop(self) -> None: - self._running = False - if self._process is not None: - try: - self._process.terminate() - except Exception: - pass - self._process = None diff --git a/bolt_pet/pet_actions.py b/bolt_pet/pet_actions.py deleted file mode 100644 index dba41e2..0000000 --- a/bolt_pet/pet_actions.py +++ /dev/null @@ -1,191 +0,0 @@ -"""Commands that drive the pet's *body* instead of the shell. - -The server relays shell commands to this machine (see server_client. -run_local_command). Rather than inventing a new payload type the desk API -doesn't speak — this client can't change the server — a small `petctl` -pseudo-command is intercepted before it ever reaches `subprocess`: if Bolt -emits `petctl move top-left` or `petctl emote wave`, the pet does it and -returns a normal-looking command output string, so from the server's side -it's just another tool call that worked. - -Pure parsing logic — no Qt, no subprocess — so it's cheap to unit test. The -UI half lives in ui/pet_window.py (apply_action). -""" - -from __future__ import annotations - -import shlex -from typing import Optional - -# What Bolt is allowed to type. Anything else falls through to a real shell. -_PREFIXES = ("petctl", "bolt-pet", "pet") - -ANCHORS = ( - "top-left", "top", "top-right", - "left", "center", "right", - "bottom-left", "bottom", "bottom-right", - "cursor", "random", -) - -EMOTES = ("wave", "hop", "spin", "nod", "shake", "bounce", "wiggle") - -# Where `petctl jump` can be aimed. A bare number (1-based) works too, as does -# any unique part of a monitor's name — resolution lives in monitors.resolve(). -MONITOR_SPECS = ( - "next", "prev", "primary", "other", "random", - "left", "right", "up", "down", -) - -HELP = ( - "petctl move | <" + "|".join(ANCHORS) + ">\n" - "petctl jump \n" - "petctl monitors\n" - "petctl read [monitor number|here|all]\n" - "petctl emote <" + "|".join(EMOTES) + ">\n" - "petctl say \n" - "petctl wander on|off\n" - "petctl nap on|off\n" - "petctl voice reset\n" - "petctl self_restart [why]" -) - -# `petctl voice` only ever goes one way: back to the configured voice. Picking -# a *different* one is the server's job (its speak_as reply marker), and it -# already knows how — what it has no way to say is "never mind, be yourself -# again", because it was never told which voice that is. -VOICE_RESETS = ("reset", "default", "normal", "own", "back", "mine", "yours") - - -class ActionError(Exception): - """Bad petctl syntax — reported back to the server as command output.""" - - -def is_pet_command(command: str) -> bool: - parts = (command or "").strip().split() - return bool(parts) and parts[0].lower() in _PREFIXES - - -def _bool_arg(value: str) -> bool: - value = value.lower() - if value in ("on", "true", "yes", "1", "start", "enable"): - return True - if value in ("off", "false", "no", "0", "stop", "disable"): - return False - raise ActionError(f"expected on/off, got {value!r}") - - -def parse(command: str) -> Optional[dict]: - """Parse a `petctl ...` string into an action dict, or None if this isn't - a pet command at all (caller should run it as a real shell command). - Raises ActionError on a pet command that doesn't make sense.""" - if not is_pet_command(command): - return None - try: - parts = shlex.split(command.strip()) - except ValueError as exc: # unbalanced quotes - raise ActionError(f"couldn't parse arguments: {exc}") from exc - verb = (parts[1].lower() if len(parts) > 1 else "help") - args = parts[2:] - - if verb in ("help", "-h", "--help"): - return {"action": "help"} - - if verb in ("move", "goto", "walk"): - if not args: - raise ActionError("move needs a target: " + ", ".join(ANCHORS) + ", or x y") - if len(args) >= 2 and _looks_numeric(args[0]) and _looks_numeric(args[1]): - return {"action": "move", "x": int(float(args[0])), "y": int(float(args[1]))} - anchor = args[0].lower().replace("_", "-") - if anchor not in ANCHORS: - raise ActionError(f"unknown position {args[0]!r}; try one of: " + ", ".join(ANCHORS)) - return {"action": "move", "anchor": anchor} - - if verb in ("jump", "monitor", "screen"): - if not args: - raise ActionError( - "jump needs a monitor: a number, a name, or one of " - + ", ".join(MONITOR_SPECS) - ) - # The spec isn't validated here on purpose: which monitors exist is a - # runtime fact this pure module doesn't have. monitors.resolve() does - # it once the published screen list is in hand. - return {"action": "jump", "target": " ".join(args).strip()} - - if verb in ("monitors", "screens", "displays"): - return {"action": "monitors"} - - if verb in ("read", "look", "ocr", "see"): - target = (" ".join(args).strip() or "here").lower() - return {"action": "read", "target": target} - - if verb in ("emote", "do"): - if not args: - raise ActionError("emote needs a name: " + ", ".join(EMOTES)) - emote = args[0].lower() - if emote not in EMOTES: - raise ActionError(f"unknown emote {args[0]!r}; try one of: " + ", ".join(EMOTES)) - return {"action": "emote", "emote": emote} - - if verb == "say": - text = " ".join(args).strip() - if not text: - raise ActionError("say needs something to say") - return {"action": "say", "text": text} - - if verb == "wander": - if not args: - raise ActionError("wander needs on or off") - return {"action": "wander", "enabled": _bool_arg(args[0])} - - if verb == "voice": - target = (args[0].lower() if args else "reset").lstrip("-") - if target not in VOICE_RESETS: - raise ActionError( - f"can't set a voice from petctl (got {args[0]!r}); " - "use the speak_as reply marker to pick one. " - "petctl voice reset goes back to the default voice." - ) - return {"action": "voice", "voice": "default"} - - if verb in ("self_restart", "restart", "reboot"): - # Free text, not a fixed grammar: the argument is a note to the pet's - # *next* process about why it died and what to look at when it comes - # back, so anything the model wants to tell future-itself is valid. - return {"action": "self_restart", "reason": " ".join(args).strip()} - - if verb in ("nap", "sleep", "dnd"): - if not args: - raise ActionError("nap needs on or off") - return {"action": "nap", "enabled": _bool_arg(args[0])} - - raise ActionError(f"unknown petctl verb {verb!r}\n{HELP}") - - -def _looks_numeric(value: str) -> bool: - try: - float(value) - return True - except ValueError: - return False - - -def describe(action: dict) -> str: - """The text handed back to the server as this "command"'s output. Phrased - as a completed fact so the model doesn't narrate the mechanics of it.""" - kind = action.get("action") - if kind == "move": - where = action.get("anchor") or f"({action.get('x')}, {action.get('y')})" - return f"[pet] walking to {where}" - if kind == "jump": - return f"[pet] jumping to monitor {action['target']}" - if kind == "emote": - return f"[pet] {action['emote']}" - if kind == "say": - return "[pet] showing that in the speech bubble" - if kind == "wander": - return "[pet] wandering " + ("enabled" if action["enabled"] else "disabled") - if kind == "nap": - return "[pet] " + ("napping" if action["enabled"] else "awake") - if kind == "help": - return HELP - return "[pet] ok" diff --git a/bolt_pet/quiet.py b/bolt_pet/quiet.py deleted file mode 100644 index 2e54f21..0000000 --- a/bolt_pet/quiet.py +++ /dev/null @@ -1,78 +0,0 @@ -"""Quiet hours — when the pet is allowed to make noise on its own. - -Napping only ever suppresses *proactive* noise (heartbeat announcements, -forwarded notifications) and wandering. The wake word, click-to-talk and -push-to-talk still work: telling it to be quiet shouldn't mean it stops -answering when spoken to. - -Pure logic (parsing + a time comparison) so it's testable without waiting -for 11pm. -""" - -from __future__ import annotations - -from datetime import time as dtime -from typing import Iterable, Optional - - -class QuietHoursError(ValueError): - pass - - -def _parse_clock(value: str) -> dtime: - parts = value.strip().split(":") - if len(parts) != 2: - raise QuietHoursError(f"expected HH:MM, got {value!r}") - try: - hour, minute = int(parts[0]), int(parts[1]) - except ValueError as exc: - raise QuietHoursError(f"expected HH:MM, got {value!r}") from exc - if not (0 <= hour <= 23 and 0 <= minute <= 59): - raise QuietHoursError(f"{value!r} is not a real time of day") - return dtime(hour, minute) - - -def parse_ranges(spec: str) -> list[tuple[dtime, dtime]]: - """"23:00-08:00, 13:00-14:00" -> [(23:00, 08:00), (13:00, 14:00)]. - Empty/blank spec means "no quiet hours".""" - ranges: list[tuple[dtime, dtime]] = [] - for chunk in (spec or "").split(","): - chunk = chunk.strip() - if not chunk: - continue - start, sep, end = chunk.partition("-") - if not sep: - raise QuietHoursError(f"expected HH:MM-HH:MM, got {chunk!r}") - ranges.append((_parse_clock(start), _parse_clock(end))) - return ranges - - -def in_ranges(now: dtime, ranges: Iterable[tuple[dtime, dtime]]) -> bool: - for start, end in ranges: - if start == end: - continue # a zero-length range is a typo, not "all day" - if start < end: - if start <= now < end: - return True - elif now >= start or now < end: # wraps past midnight - return True - return False - - -def is_quiet(spec: str, now: Optional[dtime] = None, on_error=None) -> bool: - """True if *now* (defaults to the local wall clock) falls inside *spec*. - A malformed spec is reported via *on_error* and treated as "not quiet" — - a config typo shouldn't silently mute the pet forever.""" - if not (spec or "").strip(): - return False - try: - ranges = parse_ranges(spec) - except QuietHoursError as exc: - if on_error is not None: - on_error(exc) - return False - if now is None: - from datetime import datetime - - now = datetime.now().time() - return in_ranges(now, ranges) diff --git a/bolt_pet/screen_context.py b/bolt_pet/screen_context.py deleted file mode 100644 index 7946912..0000000 --- a/bolt_pet/screen_context.py +++ /dev/null @@ -1,184 +0,0 @@ -"""What's on screen right now — the active window's title, and whether -something is running fullscreen. - -Two consumers: - * annotate() tacks the focused window's title onto what you said, so - "what's this error?" has a referent without you describing it (the desk - API takes text only, so this is a text annotation — no screenshot upload). - * is_fullscreen_active() feeds do-not-disturb: the pet shouldn't announce - anything over a call or a fullscreen game. - -Everything here is best-effort and must never raise: on a locked-down Wayland -session none of it is available, and the correct behaviour is simply "no -context", not a crashed pipeline. The subprocess *parsing* is split into pure -functions so it can be tested without a display server. -""" - -from __future__ import annotations - -import platform -import re -import shutil -import subprocess -from typing import Optional - -_TIMEOUT = 2.0 -_MAX_TITLE_CHARS = 160 - -# Titles that are just the desktop itself — annotating with these is noise. -_BORING_TITLES = {"", "desktop", "@!0,0;bdib", "plasmashell", "gnome-shell", "xfdesktop"} - - -def _run(args: list[str]) -> Optional[str]: - try: - completed = subprocess.run(args, capture_output=True, text=True, timeout=_TIMEOUT) - except Exception: - return None - if completed.returncode != 0: - return None - return completed.stdout - - -# ── pure parsing helpers (unit tested; no display server needed) ───────────── - -def parse_xprop_window_id(output: str) -> Optional[str]: - """`xprop -root _NET_ACTIVE_WINDOW` -> '_NET_ACTIVE_WINDOW(WINDOW): window id # 0x3c00007'""" - match = re.search(r"(0x[0-9a-fA-F]+)", output or "") - if not match or int(match.group(1), 16) == 0: - return None - return match.group(1) - - -def parse_xprop_window_name(output: str) -> Optional[str]: - """`xprop -id _NET_WM_NAME` -> '_NET_WM_NAME(UTF8_STRING) = "Firefox"'""" - match = re.search(r'=\s*"(.*)"\s*$', (output or "").strip(), re.DOTALL) - if not match: - return None - return match.group(1).strip() - - -def parse_xprop_fullscreen(output: str) -> bool: - return "_NET_WM_STATE_FULLSCREEN" in (output or "") - - -def clean_title(title: Optional[str]) -> Optional[str]: - title = (title or "").strip().replace("\n", " ") - if title.lower() in _BORING_TITLES: - return None - if len(title) > _MAX_TITLE_CHARS: - title = title[: _MAX_TITLE_CHARS - 1].rstrip() + "…" - return title or None - - -def annotate(text: str, title: Optional[str]) -> str: - """Attach the window title as an explicit aside rather than splicing it - into the sentence, so the model can ignore it when it's irrelevant.""" - text = (text or "").strip() - title = clean_title(title) - if not text or not title: - return text - return f"{text}\n\n[on screen right now: {title}]" - - -# ── platform probes ────────────────────────────────────────────────────────── - -def _linux_active_window_id() -> Optional[str]: - if not shutil.which("xprop"): - return None - output = _run(["xprop", "-root", "_NET_ACTIVE_WINDOW"]) - return parse_xprop_window_id(output or "") - - -def _linux_title() -> Optional[str]: - if shutil.which("xdotool"): - output = _run(["xdotool", "getactivewindow", "getwindowname"]) - if output and output.strip(): - return output.strip() - window_id = _linux_active_window_id() - if window_id is None: - return None - for prop in ("_NET_WM_NAME", "WM_NAME"): - output = _run(["xprop", "-id", window_id, prop]) - title = parse_xprop_window_name(output or "") - if title: - return title - return None - - -def _windows_title() -> Optional[str]: - try: - import ctypes - - user32 = ctypes.windll.user32 - handle = user32.GetForegroundWindow() - if not handle: - return None - length = user32.GetWindowTextLengthW(handle) - buffer = ctypes.create_unicode_buffer(length + 1) - user32.GetWindowTextW(handle, buffer, length + 1) - return buffer.value or None - except Exception: - return None - - -def _macos_title() -> Optional[str]: - script = ( - 'tell application "System Events" to get name of first application process ' - "whose frontmost is true" - ) - output = _run(["osascript", "-e", script]) - return (output or "").strip() or None - - -def active_window_title() -> Optional[str]: - """Focused window's title, or None if the platform won't tell us.""" - try: - system = platform.system() - if system == "Linux": - return clean_title(_linux_title()) - if system == "Windows": - return clean_title(_windows_title()) - if system == "Darwin": - return clean_title(_macos_title()) - except Exception: - pass - return None - - -def is_fullscreen_active() -> bool: - """True when the focused window is fullscreen (call, game, presentation). - False whenever we can't tell — do-not-disturb should be something you opt - into, not something a failed probe turns on.""" - try: - system = platform.system() - if system == "Linux": - window_id = _linux_active_window_id() - if window_id is None: - return False - return parse_xprop_fullscreen(_run(["xprop", "-id", window_id, "_NET_WM_STATE"]) or "") - if system == "Windows": - import ctypes - from ctypes import wintypes - - user32 = ctypes.windll.user32 - handle = user32.GetForegroundWindow() - if not handle: - return False - rect = wintypes.RECT() - user32.GetWindowRect(handle, ctypes.byref(rect)) - screen_w = user32.GetSystemMetrics(0) - screen_h = user32.GetSystemMetrics(1) - return (rect.right - rect.left) >= screen_w and (rect.bottom - rect.top) >= screen_h - except Exception: - pass - return False - - -def context_for(text: str) -> str: - """What the controller sends: *text* plus the active window title, when - SCREEN_CONTEXT is on and there's a title worth mentioning.""" - from . import config - - if not config.SCREEN_CONTEXT: - return text - return annotate(text, active_window_title()) diff --git a/bolt_pet/screen_text.py b/bolt_pet/screen_text.py deleted file mode 100644 index a462e5d..0000000 --- a/bolt_pet/screen_text.py +++ /dev/null @@ -1,187 +0,0 @@ -"""Reading the text that's actually on a monitor, via screenshot + OCR. - -This is the "Bolt can see what's on screen" half of the screen features. It is -**pull, not push**: nothing here runs on its own. The server has to ask, by -relaying `petctl read`, and the recognised text goes back as that command's -output through the existing tool-result relay (see server_client.converse). -That's deliberate on two counts — OCR of a 4K screen costs a second or two, -which would be tacked onto every single utterance if it ran automatically, and -"screen contents leave this machine" should be a thing Bolt decides to do and -you can see in the log, not a silent constant. - -Both halves are optional and soft-fail with a reason, the way hotkey.py does: -capture needs `mss`, recognition needs a Tesseract or RapidOCR install. With -neither, `petctl read` reports what's missing instead of raising, and the rest -of the pet carries on. - -The pure parts (cleaning OCR output, formatting the reply, deciding which -engine to use given what's installed) are split out and unit tested; only -capture and the OCR call itself need a real screen. -""" - -from __future__ import annotations - -import re -import shutil -from typing import Callable, Optional - -from .monitors import Monitor - -DEFAULT_MAX_CHARS = 4000 - -INSTALL_HINT = ( - "install one of: `pip install mss pytesseract` + `sudo apt install " - "tesseract-ocr` (fastest), or `pip install mss rapidocr-onnxruntime` " - "(no system package needed)" -) - -# Lines that are almost certainly OCR noise rather than text: window chrome -# fragments, isolated punctuation, single stray characters. -_MIN_MEANINGFUL = 2 - - -def _module_available(name: str) -> bool: - import importlib.util - - try: - return importlib.util.find_spec(name) is not None - except (ImportError, ValueError): - return False - - -# ── pure helpers (unit tested; no screen, no OCR engine needed) ────────────── - -def resolve_engine( - has_module: Callable[[str], bool] = _module_available, - which: Callable[[str], Optional[str]] = shutil.which, -) -> tuple[Optional[str], str]: - """Pick an OCR engine from what's installed. - - Returns `(engine, reason)`. *engine* is None when nothing usable is - present, and *reason* then explains what to install. Probes are injected - so this is testable on a machine with a different set of things installed. - """ - if has_module("pytesseract") and which("tesseract"): - return "pytesseract", "" - if has_module("rapidocr_onnxruntime"): - return "rapidocr", "" - if has_module("pytesseract") and not which("tesseract"): - return None, ( - "pytesseract is installed but the tesseract binary isn't on PATH " - "(try: sudo apt install tesseract-ocr)" - ) - return None, f"no OCR engine available — {INSTALL_HINT}" - - -def capture_available(has_module: Callable[[str], bool] = _module_available) -> bool: - return has_module("mss") - - -def clean_ocr_text(raw: str, max_chars: int = DEFAULT_MAX_CHARS) -> str: - """Squeeze raw OCR output into something worth sending. - - Screen OCR produces a lot of junk — single stray glyphs off window - borders, runs of blank lines, the same toolbar label recognised twice. All - of that costs tokens and tells the model nothing, so it goes. - """ - if not raw: - return "" - lines: list[str] = [] - for line in raw.splitlines(): - line = re.sub(r"[^\S\n]+", " ", line).strip() - if not line: - continue - if len(re.sub(r"[^0-9A-Za-z]", "", line)) < _MIN_MEANINGFUL: - continue - if lines and line == lines[-1]: - continue # consecutive duplicate - lines.append(line) - text = "\n".join(lines) - if max_chars and len(text) > max_chars: - text = text[: max_chars - 1].rstrip() + "…" - text += "\n[truncated]" - return text - - -def format_reading(monitor: Optional[Monitor], text: str) -> str: - """The tool output handed back for `petctl read`.""" - where = f"monitor {monitor.number} ({monitor.name})" if monitor else "screen" - if not text.strip(): - return f"[pet] read {where}: no text recognised" - return f"[pet] text on {where}:\n{text}" - - -# ── capture + recognition (needs a real screen) ────────────────────────────── - -def capture(monitor: Monitor): - """Grab *monitor* as a PIL image, or None if capture isn't available.""" - try: - import mss - from PIL import Image - except ImportError: - return None - try: - box = { - "left": monitor.x, - "top": monitor.y, - "width": monitor.width, - "height": monitor.height, - } - with mss.mss() as sct: - shot = sct.grab(box) - return Image.frombytes("RGB", shot.size, shot.bgra, "raw", "BGRX") - except Exception: - return None - - -def _ocr(image, engine: str) -> str: - if engine == "pytesseract": - import pytesseract - - # Grayscale first: tesseract is measurably better on it than on the - # colour desktop, and it's a cheap conversion. - return pytesseract.image_to_string(image.convert("L")) - if engine == "rapidocr": - import numpy as np - from rapidocr_onnxruntime import RapidOCR - - result, _ = RapidOCR()(np.array(image)) - if not result: - return "" - return "\n".join(line[1] for line in result) - return "" - - -def read_monitor(monitor: Monitor, max_chars: int = DEFAULT_MAX_CHARS) -> str: - """OCR one screen and return the formatted tool output. - - Never raises: every failure path returns a sentence explaining itself, - because the return value goes straight back to the server as the result of - a command Bolt chose to run. - """ - if not capture_available(): - return f"[pet] can't capture the screen — {INSTALL_HINT}" - engine, reason = resolve_engine() - if engine is None: - return f"[pet] can't read the screen — {reason}" - image = capture(monitor) - if image is None: - return ( - f"[pet] couldn't capture monitor {monitor.number} " - "(is this a Wayland session? mss needs X11)" - ) - try: - raw = _ocr(image, engine) - except Exception as exc: - return f"[pet] OCR failed on monitor {monitor.number}: {exc}" - return format_reading(monitor, clean_ocr_text(raw, max_chars)) - - -def read_monitors(monitors: list[Monitor], max_chars: int = DEFAULT_MAX_CHARS) -> str: - """OCR several screens, splitting the character budget between them.""" - if not monitors: - return "[pet] no monitor information available" - if len(monitors) == 1: - return read_monitor(monitors[0], max_chars) - share = max(400, max_chars // len(monitors)) - return "\n\n".join(read_monitor(m, share) for m in monitors) diff --git a/bolt_pet/self_restart.py b/bolt_pet/self_restart.py deleted file mode 100644 index 506e017..0000000 --- a/bolt_pet/self_restart.py +++ /dev/null @@ -1,235 +0,0 @@ -"""`petctl self_restart` — the pet restarting itself, and remembering why. - -Bolt can already edit this repo through `filectl` and run commands through the -shell relay, which means he can change the pet's own code. What he could not -do is *see the result*: the running process keeps the old modules in memory, -so an edit is invisible until somebody restarts the pet by hand, and by then -the conversation that motivated it is over. That makes the edit-test-review -loop a human errand. - -This closes the loop. The tricky part is that the thing being asked to report -back is the thing that dies, so the mechanism is built around three problems: - -1. **The turn must survive.** A restart mid-turn would kill the HTTP tool - relay before the result was posted, and the server would sit waiting until - it timed out — the conversation lost, with no explanation. So the command - only *arms* the restart: it returns immediately, the turn finishes and Bolt - speaks his reply, and the restart happens after (see - `controller._maybe_self_restart`), exactly like the updater's "only between - turns" rule. -2. **A broken edit must not be fatal.** Before anything is armed, the new code - is imported in a *subprocess* (`preflight`) — this process still holds the - old modules, so importing here would prove nothing. A syntax error comes - back as the command's output, in the same turn, and nothing restarts. That - is the difference between "Bolt broke the pet and lost his own way to fix - it" and "Bolt got a traceback and tried again". -3. **The reason must outlive the process.** The context (why, what to check, - which version, when) is written to disk before exec and read on the way - back up, so the new process can open with "I'm back — you asked me to check - X" instead of amnesia. That report goes to the server as a normal turn, so - Bolt sees the result of his own change and can carry on. - -A loop guard bounds the worst case: `MAX_RESTARTS` inside `WINDOW_SECONDS` -and further self-restarts are refused with a reason, so an edit-restart-crash -cycle stops on its own rather than spinning the process forever. - -Pure-ish and injectable throughout (paths, clock, subprocess runner) so the -whole thing is testable without ever restarting anything. -""" - -from __future__ import annotations - -import json -import os -import subprocess -import sys -import tempfile -import time -from dataclasses import asdict, dataclass, field -from pathlib import Path -from typing import Any, Callable, Optional - -from . import config - -# Lives in the cache dir, not the repo: it is transient state about *this* -# machine's process, and it must never end up in a git diff of the checkout -# Bolt is editing. -DEFAULT_STATE_PATH = Path.home() / ".cache" / "bolt-pet" / "restart_context.json" - -# Loop guard. Deliberately small: a healthy edit-check cycle is one restart -# per change, and anything hammering past this is a crash loop, not work. -MAX_RESTARTS = int(os.environ.get("SELF_RESTART_MAX", "5")) -WINDOW_SECONDS = float(os.environ.get("SELF_RESTART_WINDOW_SECONDS", "900")) - -# What the preflight subprocess imports. `ui.app` pulls in the widest slice of -# the package (Qt, controller, audio, every helper), so if this imports, a -# restart will at least reach the event loop. -_PREFLIGHT_IMPORT = "import bolt_pet, bolt_pet.controller, bolt_pet.ui.app" - - -class RestartError(Exception): - """A refused restart — reported back to the server as command output.""" - - -@dataclass -class RestartContext: - """What the dying process wants the next one to know.""" - - reason: str = "" - verify: str = "" - armed_at: float = 0.0 - version: str = "" - session: str = "" - recent: list = field(default_factory=list) - restarts: list = field(default_factory=list) # timestamps, for the loop guard - - def as_dict(self) -> dict[str, Any]: - return asdict(self) - - -def _now() -> float: - return time.time() - - -def load(path: Optional[Path] = None) -> Optional[RestartContext]: - """Read the context left by a previous process, or None.""" - target = Path(path or DEFAULT_STATE_PATH) - try: - data = json.loads(target.read_text(encoding="utf-8")) - except (FileNotFoundError, json.JSONDecodeError, OSError): - return None - if not isinstance(data, dict): - return None - known = {field_name for field_name in RestartContext().as_dict()} - return RestartContext(**{k: v for k, v in data.items() if k in known}) - - -def save(context: RestartContext, path: Optional[Path] = None) -> None: - """Persist the context atomically — a half-written file on the way out - would make the next process start confused instead of oriented.""" - target = Path(path or DEFAULT_STATE_PATH) - target.parent.mkdir(parents=True, exist_ok=True) - descriptor, temp_path = tempfile.mkstemp(dir=target.parent, prefix=".restart_", suffix=".tmp") - try: - with os.fdopen(descriptor, "w", encoding="utf-8") as handle: - json.dump(context.as_dict(), handle, ensure_ascii=False, indent=1) - os.replace(temp_path, target) - except BaseException: - try: - os.unlink(temp_path) - except OSError: - pass - raise - - -def clear(path: Optional[Path] = None) -> None: - """Consume the context. Called once it has been reported, so the pet - doesn't announce the same restart every time it starts.""" - try: - Path(path or DEFAULT_STATE_PATH).unlink() - except (FileNotFoundError, OSError): - pass - - -def recent_restarts(context: Optional[RestartContext], *, now: Optional[float] = None) -> list: - current = now if now is not None else _now() - stamps = list((context.restarts if context else []) or []) - return [stamp for stamp in stamps if current - float(stamp) <= WINDOW_SECONDS] - - -def check_loop_guard(context: Optional[RestartContext], *, now: Optional[float] = None) -> None: - """Refuse to restart if we've already done it too many times recently.""" - stamps = recent_restarts(context, now=now) - if len(stamps) >= MAX_RESTARTS: - raise RestartError( - f"refusing: {len(stamps)} self-restarts in the last " - f"{int(WINDOW_SECONDS / 60)} minutes. Something is looping — fix the " - "cause, or wait for the window to clear before trying again." - ) - - -def preflight( - repo: Optional[Path] = None, - run: Optional[Callable[..., Any]] = None, - timeout: float = 120.0, -) -> None: - """Import the current source in a subprocess; raise if it's broken. - - This process has the *old* modules loaded, so importing in-process would - happily succeed on a file that no longer parses. Mirrors - `updater._smoke_test`, and exists for the same reason: never hand the - session to code that can't start.""" - runner = run or subprocess.run - root = Path(repo or config.HERE) - try: - completed = runner( - [sys.executable, "-c", _PREFLIGHT_IMPORT], - cwd=str(root), capture_output=True, text=True, timeout=timeout, - env={**os.environ, "QT_QPA_PLATFORM": "offscreen"}, # no display needed to import - ) - except Exception as exc: # subprocess itself failed to run - raise RestartError(f"couldn't run the preflight import check: {exc}") from exc - if completed.returncode != 0: - detail = (completed.stderr or completed.stdout or "").strip() - raise RestartError( - "the current code does not import, so restarting would leave you with " - f"nothing running. Fix this first:\n{detail[-800:]}" - ) - - -def arm( - reason: str, - *, - verify: str = "", - version: str = "", - session: str = "", - recent: Optional[list] = None, - path: Optional[Path] = None, - now: Optional[float] = None, -) -> RestartContext: - """Record why we're about to die, carrying the restart history forward.""" - current = now if now is not None else _now() - previous = load(path) - context = RestartContext( - reason=" ".join(str(reason or "").split())[:400], - verify=" ".join(str(verify or "").split())[:400], - armed_at=current, - version=str(version or ""), - session=str(session or ""), - recent=list(recent or [])[-6:], - restarts=recent_restarts(previous, now=current) + [current], - ) - save(context, path) - return context - - -def report( - context: RestartContext, - *, - version: str = "", - now: Optional[float] = None, -) -> str: - """The message the new process sends the server on the way up. - - Phrased as Bolt reporting to himself, because that is what it is: the - server sees it as an ordinary turn, and the reply comes back through the - normal pipeline — which is what lets "restart and check X" finish as a - sentence spoken out loud.""" - current = now if now is not None else _now() - took = max(0.0, current - float(context.armed_at or current)) - lines = [ - "[pet self-restart] I restarted myself and I'm back up.", - f"- reason: {context.reason or 'not recorded'}", - f"- took: {took:.1f}s", - f"- version now running: {version or 'unknown'}" - + (f" (was {context.version})" if context.version and context.version != version else ""), - ] - if context.verify: - lines.append(f"- you wanted to check: {context.verify}") - if context.recent: - lines.append("- what we were doing before: " + " | ".join(str(x)[:120] for x in context.recent)) - lines.append( - "The new code is loaded and running. If you wanted to verify something, " - "check it now (filectl to read, command to test) and tell the user what you found." - ) - return "\n".join(lines) diff --git a/bolt_pet/server_client.py b/bolt_pet/server_client.py deleted file mode 100644 index 5908f2b..0000000 --- a/bolt_pet/server_client.py +++ /dev/null @@ -1,307 +0,0 @@ -"""HTTP client for Bolt's desk API (ai/desk_api.py on the server). - -Protocol is identical to desk_client/bolt_desk.py in the main tmn-api repo — -this pet is just another desk client, so it gets the exact same brain, -memory, tools, and persona as Discord chat and the Linux voice client: - - text -> POST /desk/converse - [server may relay a shell command back to run on THIS machine] - ... -> POST /desk/tool_result (repeat until the server sends a reply) - reply <- returned to caller - -A reply can also carry a voice (`voice_id`/`voice_name`), which is how the -server's `speak_as` marker reaches us: Bolt searched the ElevenLabs voice -library, picked one, and tagged the reply with it — the client is what -actually speaks in it. See `Reply` and controller._apply_voice. - -Kept dependency-free beyond `requests` so it's easy to unit test with mocks. -""" - -from __future__ import annotations - -import json -import subprocess -from pathlib import Path -from typing import Callable, NamedTuple, Optional - -import requests - -from . import config, sudo_askpass - -_MAX_RELAY_HOPS = 16 - - -class ServerError(Exception): - """Raised when the server responds with an error payload or unreachable.""" - - -class Reply(NamedTuple): - """One final reply from the desk API. *voice_id* is set only when the - server tagged this reply with a `speak_as` voice; *voice_name* is the - human-readable name that came with it (may be empty even when the id - isn't). Both empty means "say it in the usual voice".""" - - text: str - voice_id: str = "" - voice_name: str = "" - # True when the sentences were already spoken as they streamed in. The - # text is still carried — the follow-up rule needs to see whether the - # answer ended on a question — it just must not be read out again. - spoken: bool = False - - -def _headers() -> dict: - return {"X-Desk-Api-Key": config.API_KEY} - - -def check_health(timeout: float = 10.0) -> dict: - response = requests.get(f"{config.SERVER_URL}/desk/health", headers=_headers(), timeout=timeout) - response.raise_for_status() - return response.json() - - -def run_local_command(command: str, timeout: int = None) -> str: - """Execute a command relayed by the server, exactly as bolt_desk.py does — - "full desktop control" for things like "open firefox" or "how full is my - disk". Runs as the current desktop user. See README security notes. - - `sudo` gets special handling: the pet has no terminal, so sudo would sit - waiting on a tty that nobody is looking at. With SUDO_ASKPASS_PROMPT on, - bare `sudo` becomes `sudo -A` and the password is collected in a desktop - dialog you have to answer — which also gives those commands a longer - timeout, since a human has to notice the window and type.""" - env = None - if config.SUDO_ASKPASS_PROMPT and sudo_askpass.needs_password_prompt(command): - helper = sudo_askpass.find_helper() - if helper: - env = sudo_askpass.environment(helper) - command = sudo_askpass.add_askpass_flag(command) - timeout = timeout or config.SUDO_COMMAND_TIMEOUT_SECONDS - timeout = timeout or config.COMMAND_TIMEOUT_SECONDS - try: - completed = subprocess.run( - command, shell=True, capture_output=True, text=True, - timeout=timeout, cwd=str(Path.home()), env=env, - ) - output = (completed.stdout or "") + (completed.stderr or "") - return f"[exit {completed.returncode}]\n{output}"[:6000] - except subprocess.TimeoutExpired: - return f"[command timed out after {timeout}s]" - except Exception as exc: - return f"[command failed: {exc}]" - - -def converse( - text: str, - on_command: Callable[[str], str] = run_local_command, - timeout: float = 120.0, - on_say: Optional[Callable[[str], None]] = None, -) -> Reply: - """Send one turn of conversation to the desk API, relaying any commands - the server sends back until it produces a final reply. - - *on_command* is injectable for tests; defaults to actually running the - command locally (matching bolt_desk.py's behavior). - - *on_say* is called with a short holding line ("give me a sec") when the - server sends one alongside a command. It is the difference between silence - and an answer while a tool runs: the model's acknowledgement used to be - discarded server-side, so the whole round trip was dead air and the model - then repeated itself in the final reply. Optional, so an older pet against - a newer server simply stays quiet as before. - """ - headers = _headers() - try: - response = requests.post( - f"{config.SERVER_URL}/desk/converse", - json={"session_id": config.SESSION_ID, "text": text}, - headers=headers, timeout=timeout, - ) - payload = response.json() - except Exception as exc: - raise ServerError(f"couldn't reach the server: {exc}") from exc - - for _ in range(_MAX_RELAY_HOPS): - if payload.get("type") != "command": - break - holding = str(payload.get("say") or "").strip() - if holding and on_say is not None: - # Spoken *before* the command runs — that is the whole point. - try: - on_say(holding) - except Exception: - pass # a failed acknowledgement must not cost the tool call - output = on_command(str(payload.get("command") or "")) - try: - response = requests.post( - f"{config.SERVER_URL}/desk/tool_result", - json={ - "session_id": config.SESSION_ID, - "token": payload.get("token"), - "output": output, - }, - headers=headers, timeout=180, - ) - payload = response.json() - except Exception as exc: - raise ServerError(f"couldn't reach the server during tool relay: {exc}") from exc - - if payload.get("type") == "reply": - return Reply( - text=str(payload.get("text") or ""), - voice_id=str(payload.get("voice_id") or ""), - voice_name=str(payload.get("voice_name") or ""), - ) - raise ServerError(str(payload.get("error") or "unknown server response")) - - -def converse_stream( - text: str, - on_say: Callable[[str], None], - on_command: Callable[[str], str] = run_local_command, - timeout: float = 180.0, -) -> Reply: - """Same turn as converse(), but speaking each sentence as it arrives. - - Without this the pet waits out the *entire* model call before a single - word is heard; with it the wait is time-to-first-sentence, which on a - multi-sentence answer is most of the difference. - - Falls back by raising ServerError before anything has been spoken — the - caller then retries the ordinary path and the user never finds out. Once a - sentence *has* been spoken there is no going back, so late failures end the - turn with whatever was said rather than repeating it. - """ - spoke_anything = False - try: - response = requests.post( - f"{config.SERVER_URL}/desk/converse_stream", - json={"session_id": config.SESSION_ID, "text": text}, - headers=_headers(), timeout=timeout, stream=True, - ) - response.raise_for_status() - for raw in response.iter_lines(decode_unicode=True): - if not raw: - continue - try: - event = json.loads(raw) - except (TypeError, ValueError): - continue - kind = str(event.get("type") or "") - - if kind == "say": - line = str(event.get("text") or "").strip() - if line: - spoke_anything = True - on_say(line) - elif kind == "command": - # The tool loop is request/response, so the rest of the turn - # finishes through the ordinary relay rather than inside the - # stream — one protocol for tools, not two. - response.close() - return _finish_relay(event, on_command, on_say) - elif kind == "reply": - return Reply( - text=str(event.get("text") or ""), - voice_id=str(event.get("voice_id") or ""), - voice_name=str(event.get("voice_name") or ""), - spoken=bool(event.get("already_spoken")), - ) - elif kind == "error": - raise ServerError(str(event.get("error") or "stream failed")) - except ServerError: - raise - except Exception as exc: - if spoke_anything: - # Half a reply is out loud already; ending quietly beats saying it - # all again through the fallback path. - return Reply(text="", spoken=True) - raise ServerError(f"streaming failed: {exc}") from exc - raise ServerError("stream ended without a reply") - - -def _finish_relay(event: dict, on_command, on_say) -> Reply: - """Run the tool the stream handed over, then continue the classic relay.""" - payload = dict(event) - headers = _headers() - for _ in range(_MAX_RELAY_HOPS): - if payload.get("type") != "command": - break - holding = str(payload.get("say") or "").strip() - if holding and on_say is not None: - try: - on_say(holding) - except Exception: - pass - output = on_command(str(payload.get("command") or "")) - try: - response = requests.post( - f"{config.SERVER_URL}/desk/tool_result", - json={"session_id": config.SESSION_ID, - "token": payload.get("token"), "output": output}, - headers=headers, timeout=180, - ) - payload = response.json() - except Exception as exc: - raise ServerError(f"couldn't reach the server during tool relay: {exc}") from exc - if payload.get("type") == "reply": - return Reply( - text=str(payload.get("text") or ""), - voice_id=str(payload.get("voice_id") or ""), - voice_name=str(payload.get("voice_name") or ""), - ) - raise ServerError(str(payload.get("error") or "unknown server response")) - - -def list_outbox_files(timeout: float = 15.0) -> list: - """Files the server has queued for this session via its deliver_files - tool (e.g. "send me that report" during a conversation) — each entry has - id/name/size. Downloading one (download_outbox_file) dequeues it - server-side, so a file is only ever handed out once.""" - try: - response = requests.get( - f"{config.SERVER_URL}/desk/files", - params={"session_id": config.SESSION_ID}, - headers=_headers(), timeout=timeout, - ) - response.raise_for_status() - return list(response.json().get("files") or []) - except Exception as exc: - raise ServerError(f"couldn't list delivered files: {exc}") from exc - - -def download_outbox_file(file_id: str, timeout: float = 60.0) -> bytes: - """Fetches and dequeues one file listed by list_outbox_files().""" - try: - response = requests.get( - f"{config.SERVER_URL}/desk/files/{file_id}", - params={"session_id": config.SESSION_ID}, - headers=_headers(), timeout=timeout, - ) - response.raise_for_status() - return response.content - except Exception as exc: - raise ServerError(f"couldn't download delivered file {file_id!r}: {exc}") from exc - - -def report_status(timeout: float = 15.0) -> Optional[str]: - """Heartbeat — lets the desk API attach a pending spoken announcement - (proactive nudges, reminders fired since the last heartbeat) that the pet - can speak unprompted, exactly like the phone/desk clients. A pet has no - battery/GPS to report, so the device-status fields - (battery/is_charging/latitude/longitude/address, all optional server-side) - are simply omitted. - - Returns the announcement text to speak, or None if there's nothing pending. - """ - try: - response = requests.post( - f"{config.SERVER_URL}/desk/report_status", - json={"session_id": config.SESSION_ID}, headers=_headers(), timeout=timeout, - ) - response.raise_for_status() - data = response.json() - except Exception as exc: - raise ServerError(f"heartbeat failed: {exc}") from exc - reply = data.get("reply") - return str(reply) if reply else None diff --git a/bolt_pet/speech.py b/bolt_pet/speech.py deleted file mode 100644 index 83f229a..0000000 --- a/bolt_pet/speech.py +++ /dev/null @@ -1,194 +0,0 @@ -"""Everything the pet says, and the policy differences between kinds of saying. - -There used to be four of these in controller.py — a reply, a holding line, a -streamed sentence, a dialogue scene — each written when its feature was built, -each repeating the same dance: transition state, show the bubble, maybe record -history, reset barge-in, call TTS, reset barge-in again, resume state, decide -whether to keep the mic open. Only the *policy* differed, and the copies had -already started to drift: one forgot to arm barge-in, another logged a -different prefix, a third skipped the follow-up rule. - -So the dance lives here once, and the differences are data: - - reply the answer. Transcript, bubble, follow-up rule, ends IDLE. - holding "give me a sec" while a tool runs. No transcript — it is - filler, and the transcript should keep the answer. Resumes - whatever state it interrupted, because the turn isn't over. - stream one sentence of a streamed answer. Transcript and bubble like - a reply, but stays TALKING so the sprite doesn't flicker - between sentences, and the follow-up rule waits for the last. - scene a dialoguectl take. Transcript (the user heard it), resumes - mid-turn like a holding line. - -The controller keeps the pipeline; this keeps the rules about talking. -""" - -from __future__ import annotations - -from dataclasses import dataclass -from typing import Callable, Optional - -from . import config, speech_text -from .state import PetState - - -@dataclass(frozen=True) -class Utterance: - """One thing to say, and how saying it should behave.""" - - text: str - record: bool = True # goes in the transcript, or is it filler? - resume: bool = False # return to the state it interrupted (mid-turn) - hold_talking: bool = False # stay TALKING afterwards (more is coming) - follow_up: bool = True # may leave the mic open if it ends on a question - interruptible: bool = True # arm barge-in for this one - log_prefix: str = "Bolt" - - @classmethod - def reply(cls, text: str) -> "Utterance": - return cls(text) - - @classmethod - def holding(cls, text: str) -> "Utterance": - # Filler: no transcript, no follow-up, and not interruptible — cutting - # off "give me a sec" would strand the tool that is already running. - return cls(text, record=False, resume=True, follow_up=False, - interruptible=False, log_prefix="Bolt (holding)") - - @classmethod - def stream_chunk(cls, text: str) -> "Utterance": - return cls(text, hold_talking=True, follow_up=False) - - @classmethod - def scene(cls, text: str) -> "Utterance": - return cls(text, resume=True, follow_up=False) - - -class Speaker: - """Says things on behalf of the controller. - - Collaborators are passed in rather than reached for, so the whole of - speaking is testable without Qt, audio hardware or a state machine: hand it - fakes and assert on what came out.""" - - def __init__(self, *, state, tts, history=None, on_said=None, on_log=None, - barge_in: Callable[[], object] = lambda: None, - voice_id: Callable[[], str] = lambda: "", - detail_of: Callable[[], str] = lambda: "", - on_level: Optional[Callable[[float], None]] = None): - self._state = state - self._tts = tts - self._history = history - self._on_said = on_said or (lambda _text: None) - self._on_log = on_log or (lambda _msg: None) - # Read through a callable rather than held: the detector is built after - # the speaker (it needs the mic stream), replaced when barge-in mode - # changes, and swapped by tests. Two copies of it drifting apart is a - # bug nobody notices until the wake model starts hearing the pet. - self._barge_in_of = barge_in - self._voice_id = voice_id - # What actually fired, captured BEFORE the post-playback reset. Read it - # afterwards and every interruption reports 0.000 at frame 0 — which - # looks like hard evidence and is nothing of the sort. - self._detail_of = detail_of - self.last_detail = "" - self._on_level = on_level - - @property - def _barge_in(self): - try: - return self._barge_in_of() - except Exception: - return None - - def say(self, utterance: Utterance) -> bool: - """Speak it. Returns False if it was interrupted. - - The one place that knows the order these steps go in — which is the - point, because getting that order wrong is invisible until the wake - model starts hearing the pet's own voice.""" - line = speech_text.for_display(utterance.text) - if not line: - return True - - resume_state = self._state.state - if self._state.state != PetState.TALKING: - self._state.transition(PetState.TALKING) - self._on_said(line) - self._on_log(f"{utterance.log_prefix}: {line}") - if utterance.record and self._history is not None: - self._history(utterance.text) - - should_stop = None - if self._barge_in is not None and utterance.interruptible: - self._barge_in.reset() - should_stop = self._barge_in.check - - completed = self._tts.speak( - utterance.text, - on_error=lambda exc: self._on_log(f"TTS failed: {exc}"), - should_stop=should_stop, - voice_id=self._voice_id() or None, - on_level=self._on_level, - ) - self.last_detail = self._detail_of() - if self._barge_in is not None: - # Playback fed the pet's own voice into the wake model's rolling - # window. Clear it before the idle listener scores again, or the - # last sentence is still in there being re-heard. - self._barge_in.reset() - if self._on_level is not None: - self._on_level(0.0) # mouth closed; nothing is playing now - - if utterance.resume and resume_state in (PetState.THINKING, PetState.IDLE): - self._state.transition(resume_state) - return bool(completed) - - - def say_pcm(self, utterance: Utterance, *, pcm, sample_rate: int) -> bool: - """Speak audio that is already synthesized — a dialoguectl scene. - - Same policy, same barge-in handling, same mouth; only the source of - the samples differs. Sharing this is why a scene can be talked over - exactly like an ordinary reply.""" - line = speech_text.for_display(utterance.text) - resume_state = self._state.state - if self._state.state != PetState.TALKING: - self._state.transition(PetState.TALKING) - if line: - self._on_said(line) - self._on_log(f"{utterance.log_prefix}: {line}") - if utterance.record and self._history is not None: - self._history(utterance.text) - - should_stop = None - if self._barge_in is not None and utterance.interruptible: - self._barge_in.reset() - should_stop = self._barge_in.check - completed = self._tts.play_pcm( - pcm, sample_rate, should_stop=should_stop, on_level=self._on_level) - self.last_detail = self._detail_of() - if self._barge_in is not None: - self._barge_in.reset() - if self._on_level is not None: - self._on_level(0.0) - if utterance.resume and resume_state in (PetState.THINKING, PetState.IDLE): - self._state.transition(resume_state) - return bool(completed) - - -def follow_up_decision(text: str, *, completed: bool, follow_ups: int) -> tuple[bool, str]: - """Whether to keep listening after speaking, and why. - - Split out as a pure function because it is a *rule* with an off-by-one cap - in it, and rules with counters deserve a test that doesn't need audio.""" - if not completed: - return True, "interrupted" - if not speech_text.is_question(text): - return False, "" - cap = config.FOLLOW_UP_MAX_TURNS - if not config.FOLLOW_UP_LISTEN: - return False, "" - if cap > 0 and follow_ups >= cap: - return False, f"follow-up cap ({cap}) reached" - return True, "question" diff --git a/bolt_pet/speech_text.py b/bolt_pet/speech_text.py deleted file mode 100644 index fd82242..0000000 --- a/bolt_pet/speech_text.py +++ /dev/null @@ -1,173 +0,0 @@ -"""Turn a server reply into something worth *hearing*. - -The server's persona writes for a chat window: markdown emphasis, bullet -lists, emoji, bare URLs. A TTS voice reads those literally ("asterisk -asterisk OS colon", "https colon slash slash..."), so everything spoken goes -through for_speech() first. Pure string logic, no Qt/audio imports — cheap to -unit test (see tests/test_speech_text.py). - -for_display() is the lighter sibling used for the speech bubble: it drops the -markdown *syntax* but keeps emoji and punctuation, since those render fine. -""" - -from __future__ import annotations - -import re - -# Pictographs, symbols, flags, dingbats, arrows, box drawing, variation -# selectors, ZWJ — anything a voice would either skip or read as a name -# ("black right-pointing triangle"). -_EMOJI = re.compile( - "[" - "\U0001F000-\U0001FAFF" # emoji / pictographs / symbols blocks - "\U00002190-\U000021FF" # arrows - "\U00002300-\U000023FF" # misc technical (⌘ ⏱ …) - "\U000025A0-\U000027BF" # geometric shapes, misc symbols, dingbats - "\U00002B00-\U00002BFF" # extra arrows / shapes - "\U0000FE00-\U0000FE0F" # variation selectors - "\U0001F1E6-\U0001F1FF" # regional indicators (flags) - "\U0000200D" # zero-width joiner - "]+", - flags=re.UNICODE, -) - -# Characters that are markup or decoration rather than speech. Kept out of -# the spoken text entirely; ordinary punctuation (. , ! ? ; : ' " ( ) -) is -# preserved because it shapes prosody. -_UNSPEAKABLE = re.compile(r"[*_#`~^|<>\\{}\[\]/=+@©®™•·–—]+") - -_FENCED_CODE = re.compile(r"```.*?```", re.DOTALL) -_INLINE_CODE = re.compile(r"`([^`]*)`") -_MD_IMAGE = re.compile(r"!\[([^\]]*)\]\([^)]*\)") -_MD_LINK = re.compile(r"\[([^\]]+)\]\([^)]*\)") -_URL = re.compile(r"\b(?:https?://|www\.)\S+") -_HEADING = re.compile(r"^\s{0,3}#{1,6}\s*", re.MULTILINE) -_BLOCKQUOTE = re.compile(r"^\s{0,3}>\s?", re.MULTILINE) -_RULE = re.compile(r"^\s*([-*_=])(?:\s*\1){2,}\s*$", re.MULTILINE) -_BULLET = re.compile(r"^\s*(?:[-*+•·]|\d+[.)])\s+", re.MULTILINE) -_EMPHASIS = re.compile(r"(\*{1,3}|_{1,3})(\S(?:.*?\S)?)\1", re.DOTALL) -_TABLE_PIPE = re.compile(r"[ \t]*\|[ \t]*") - -# Symbols worth saying out loud rather than dropping — a bare "&" read as -# nothing turns "R&D" into "RD". Split in two because the non-ASCII ones sit -# inside the arrow/symbol blocks _EMOJI strips, so they have to be worded -# before that pass; the ASCII ones must wait until *after* markdown parsing -# (an "=" turned into " equals " would stop a "====" rule matching _RULE). -_PRE_SPOKEN_SYMBOLS = { - "→": " to ", - "×": " by ", - "°": " degrees ", - "≈": " about ", -} -_SPOKEN_SYMBOLS = { - "&": " and ", - "%": " percent ", - "@": " at ", - "+": " plus ", - "=": " equals ", -} - -_MULTI_SPACE = re.compile(r"[ \t]+") -_MULTI_PUNCT = re.compile(r"(?:\s*\.){2,}") - - -def _strip_markdown(text: str, *, keep_emoji: bool) -> str: - text = _FENCED_CODE.sub(" (code) ", text) - text = _INLINE_CODE.sub(r"\1", text) - text = _MD_IMAGE.sub(r"\1", text) - text = _MD_LINK.sub(r"\1", text) - text = _RULE.sub("", text) - text = _HEADING.sub("", text) - text = _BLOCKQUOTE.sub("", text) - text = _EMPHASIS.sub(r"\2", text) - if not keep_emoji: - text = _EMOJI.sub(" ", text) - return text - - -def _bullets_to_sentences(text: str) -> str: - """A read-aloud list needs pauses where the eye would see line breaks, - otherwise "OS Linux Uptime 1 day CPU load moderate" runs together.""" - lines = [_BULLET.sub("", line).strip() for line in text.splitlines()] - lines = [line for line in lines if line] - if len(lines) < 2: - return lines[0] if lines else "" - # A bullet like "OS: Linux" ends mid-thought — give the voice a full stop - # so consecutive items don't slur into one run-on sentence. - return " ".join(line if line[-1] in ".!?:,;" else line + "." for line in lines) - - -# ElevenLabs v3 delivery tags — "[laughing]", "[whispering]", "[sighs]". They -# are instructions to the *dialogue* model (see dialogue.py), and only inside a -# dialoguectl scene. Left in an ordinary reply they reach eleven_flash_v2, -# which has no idea what they mean and simply reads the word: observed live -# 2026-07-31, the pet announcing "laughing That one came through clean". -# -# Matched narrowly — a bracketed adverb/gerund, or one of the noise words that -# aren't either — so real bracketed text ("[1]", "[see the docs]") survives. -_AUDIO_TAG_RE = re.compile( - r"\[\s*(?:" - r"[a-z]+(?:ly|ing)" - r"|laughs?|chuckles?|sighs?|exhales?|inhales?|gulps?|pause|beat" - r"|clears throat|shouts?|whispers?|cries|sings?|gasps?|snorts?" - r"|sarcastic|excited|curious|nervous|angry|sad|happy|deadpan|flat" - r")\s*\]", - re.IGNORECASE, -) - - -def strip_audio_tags(text: str) -> str: - """Remove v3 delivery tags from text destined for the ordinary voice.""" - return _AUDIO_TAG_RE.sub(" ", str(text or "")) - - -def for_speech(text: str) -> str: - """Plain prose for the TTS engine: no markdown, no emoji, no bare URLs, - no stray symbols that would be read out character by character.""" - text = strip_audio_tags((text or "").strip()) - if not text: - return "" - for symbol, spoken in _PRE_SPOKEN_SYMBOLS.items(): - text = text.replace(symbol, spoken) - text = _strip_markdown(text, keep_emoji=False) - text = _URL.sub(" link ", text) - text = _TABLE_PIPE.sub(", ", text) - text = _bullets_to_sentences(text) - for symbol, spoken in _SPOKEN_SYMBOLS.items(): - text = text.replace(symbol, spoken) - text = _UNSPEAKABLE.sub(" ", text) - text = _MULTI_PUNCT.sub(".", text) - text = _MULTI_SPACE.sub(" ", text) - text = re.sub(r"\s+([.,!?;:])", r"\1", text) - return text.strip() - - -def is_question(text: str) -> bool: - """True if the reply *ends* by asking the user something — the cue for - the pet to keep listening instead of making you say the wake word again. - - Deliberately only looks at the end. A reply that asks something in - passing ("What time is it? It's 7:15.") isn't waiting on an answer, - whereas one that finishes on a question mark is. The test runs on the - spoken form, so a '?' that only exists inside a stripped code block or a - URL doesn't count, and trailing decoration (emoji, quotes, brackets) is - peeled off first so "Ready to go? 🚀" still reads as a question.""" - spoken = for_speech(text) - while spoken and not (spoken[-1].isalnum() or spoken[-1] == "?"): - spoken = spoken[:-1] - return spoken.endswith("?") - - -def for_display(text: str) -> str: - """What the speech bubble shows: markdown syntax removed (the bubble - can't render it) but emoji and layout-ish punctuation left alone. - - Delivery tags go too — the voice no longer says them, so showing them - would caption a laugh nobody heard.""" - text = strip_audio_tags((text or "").strip()) - if not text: - return "" - text = _strip_markdown(text, keep_emoji=True) - lines = [_BULLET.sub("• ", line).strip() for line in text.splitlines()] - text = " ".join(line for line in lines if line) - return _MULTI_SPACE.sub(" ", text).strip() diff --git a/bolt_pet/state.py b/bolt_pet/state.py deleted file mode 100644 index 7747baf..0000000 --- a/bolt_pet/state.py +++ /dev/null @@ -1,76 +0,0 @@ -"""Pet state machine — pure logic, no Qt/audio dependencies, so it's cheap -to unit test. The UI layer (ui/pet_window.py) reacts to state changes by -swapping the active sprite animation; the worker thread (ui/app.py) drives -transitions as the mic/wake/converse/tts pipeline progresses. -""" - -from __future__ import annotations - -from enum import Enum -from typing import Callable, Optional - - -class PetState(str, Enum): - IDLE = "idle" # waiting for the wake phrase (or a click) - LISTENING = "listening" # actively recording an utterance - THINKING = "thinking" # waiting on the server (STT done, converse in flight) - TALKING = "talking" # playing back the TTS reply - ERROR = "error" # brief flash state on failure, then back to idle - - -# States it's valid to move to from each state. Keeps ad-hoc bugs (e.g. -# firing TALKING before a reply exists) from silently passing through. -# -# IDLE -> TALKING is legal (not just IDLE -> LISTENING) because of proactive -# announcements: the heartbeat poll (controller.py's _maybe_heartbeat) can -# make the pet speak unprompted — a reminder firing, a nudge from the server -# — without the user having said anything first, so there's no preceding -# LISTENING/THINKING leg for that turn. -# -# TALKING -> THINKING is the mirror case: a `dialoguectl` scene is played -# *mid-turn*, while the server is still waiting on the tool result, so the pet -# talks and then goes back to waiting rather than falling to IDLE (which would -# make it look like the turn had ended). -_TRANSITIONS: dict[PetState, set[PetState]] = { - PetState.IDLE: {PetState.LISTENING, PetState.TALKING, PetState.ERROR}, - PetState.LISTENING: {PetState.THINKING, PetState.IDLE, PetState.ERROR}, - PetState.THINKING: {PetState.TALKING, PetState.IDLE, PetState.ERROR}, - PetState.TALKING: {PetState.IDLE, PetState.THINKING, PetState.ERROR}, - PetState.ERROR: {PetState.IDLE}, -} - - -class InvalidTransition(Exception): - pass - - -class PetStateMachine: - def __init__(self, on_change: Optional[Callable[[PetState, PetState], None]] = None): - self._state = PetState.IDLE - self._on_change = on_change - - @property - def state(self) -> PetState: - return self._state - - def transition(self, new_state: PetState) -> None: - if new_state == self._state: - return - allowed = _TRANSITIONS.get(self._state, set()) - if new_state not in allowed: - raise InvalidTransition(f"{self._state} -> {new_state} is not allowed") - old_state = self._state - self._state = new_state - if self._on_change is not None: - self._on_change(old_state, new_state) - - def force(self, new_state: PetState) -> None: - """Bypass the transition table — used only for recovering to IDLE - from an unexpected/edge-case state (e.g. after an exception mid - pipeline). Prefer transition() everywhere else.""" - old_state = self._state - if new_state == old_state: - return - self._state = new_state - if self._on_change is not None: - self._on_change(old_state, new_state) diff --git a/bolt_pet/sudo_askpass.py b/bolt_pet/sudo_askpass.py deleted file mode 100644 index 791e30a..0000000 --- a/bolt_pet/sudo_askpass.py +++ /dev/null @@ -1,145 +0,0 @@ -"""Graphical password prompts for server-relayed `sudo` commands. - -The pet has no terminal. When the server relays something like -`sudo apt update`, sudo tries to read a password from a tty, finds none (or -finds the terminal the pet was launched from, which you're not looking at), -and the command fails with no way to answer it. - -sudo's own answer to this is SUDO_ASKPASS: with `-A`, it runs a helper -program and reads the password from the helper's stdout instead of a tty. -Any GUI prompt that prints what was typed works, so this module finds a real -askpass binary if one is installed and otherwise generates a one-line wrapper -around zenity/kdialog, which every desktop has one of. - -Worth being clear about what this changes: the prompt is a *feature*, not -just plumbing. Server-relayed commands already run as your desktop user (see -server_client.run_local_command); this lets them ask to run as root, and the -dialog is the only thing standing between "Bolt decided to run sudo" and it -happening. Leave SUDO_ASKPASS_PROMPT on, and read the dialogs. - -The parts that decide *what* to run are pure functions so they're tested -without a display, a password, or a working sudo. -""" - -from __future__ import annotations - -import os -import re -import shutil -import stat -from pathlib import Path -from typing import Callable, Optional - -from . import config - -# Real askpass binaries, in preference order. These are purpose-built for -# this (they grab the keyboard, hide the input, and don't leave the password -# in a process argument), so they win over a generated wrapper. -_KNOWN_HELPERS = ( - "/usr/bin/ssh-askpass", - "/usr/lib/ssh/ssh-askpass", - "/usr/lib/openssh/gnome-ssh-askpass3", - "/usr/lib/openssh/gnome-ssh-askpass", - "/usr/libexec/openssh/ssh-askpass", - "/usr/bin/ksshaskpass", - "/usr/bin/lxqt-openssh-askpass", -) - -# Dialog tools we can wrap when no askpass binary exists. Each must print the -# typed password to stdout and nothing else. -_WRAPPABLE = { - "zenity": '{tool} --password --title="Bolt" --text="$1" 2>/dev/null', - "kdialog": '{tool} --password "$1" --title "Bolt" 2>/dev/null', -} - -# `sudo` at the start of the command or right after a shell separator, not -# already carrying a flag. Deliberately conservative: a `sudo` inside a -# quoted string or a heredoc is left alone, because rewriting it could change -# what the command means. -_SUDO = re.compile(r"(^|[;&|]\s*|\n\s*)(sudo)(\s+)(?!-)") - - -def add_askpass_flag(command: str) -> str: - """Insert `-A` after each bare `sudo`, so it prompts through the helper - instead of a tty. Commands that already pass a flag (`sudo -n`, `sudo -A`, - `sudo -u bob`) are left exactly as they are — the caller was explicit.""" - return _SUDO.sub(r"\1\2 -A\3", command or "") - - -def needs_password_prompt(command: str) -> bool: - """True if *command* has a `sudo` that might sit waiting on a dialog. - Used to give those commands a longer timeout — 30 seconds is fine for a - shell command and nowhere near enough for a human to notice a window, - read it, and type a password.""" - return bool(_SUDO.search(command or "")) - - -def helper_script(tool_path: str) -> str: - """The wrapper script for a dialog *tool_path*. sudo passes its prompt - ("[sudo] password for maji:") as $1, which is worth showing — it names - the user the password is for.""" - name = Path(tool_path).name - body = _WRAPPABLE[name].format(tool=tool_path) - return f"#!/bin/sh\n# Generated by bolt-pet. Prints the typed password on stdout for sudo -A.\n{body}\n" - - -def _default_cache_dir() -> Path: - base = os.environ.get("XDG_CACHE_HOME") or (Path.home() / ".cache") - return Path(base) / "bolt-pet" - - -def find_helper( - configured: str = None, - is_executable: Callable[[str], bool] = None, - which: Callable[[str], Optional[str]] = None, - cache_dir: Path = None, - write: bool = True, -) -> Optional[str]: - """Path to an askpass helper, or None if the desktop has nothing we can - use. Order: whatever SUDO_ASKPASS_HELPER names, then a real askpass - binary, then a generated wrapper around zenity/kdialog. - - The lookups are injectable so the resolution order is testable on a box - with a different set of these installed than yours.""" - configured = config.SUDO_ASKPASS_HELPER if configured is None else configured - is_executable = is_executable or (lambda path: os.path.isfile(path) and os.access(path, os.X_OK)) - which = which or shutil.which - - if configured: - return configured if is_executable(configured) else None - - for candidate in _KNOWN_HELPERS: - if is_executable(candidate): - return candidate - - for tool in _WRAPPABLE: - tool_path = which(tool) - if not tool_path: - continue - if not write: - return tool_path - return _write_wrapper(tool_path, cache_dir or _default_cache_dir()) - return None - - -def _write_wrapper(tool_path: str, cache_dir: Path) -> Optional[str]: - """Drop the wrapper script somewhere sudo can execute it. Mode 0700: it - isn't secret, but it's a thing that pops up a password box, so nobody - else on the machine gets to edit it.""" - try: - cache_dir.mkdir(parents=True, exist_ok=True) - script = cache_dir / "askpass.sh" - source = helper_script(tool_path) - if not script.exists() or script.read_text(encoding="utf-8") != source: - script.write_text(source, encoding="utf-8") - script.chmod(stat.S_IRWXU) - return str(script) - except Exception: - return None # no prompt is better than a crashed command relay - - -def environment(helper: str, base: dict = None) -> dict: - """The subprocess environment with SUDO_ASKPASS pointed at *helper*.""" - env = dict(os.environ if base is None else base) - env["SUDO_ASKPASS"] = helper - return env diff --git a/bolt_pet/ui/__init__.py b/bolt_pet/ui/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/bolt_pet/ui/app.py b/bolt_pet/ui/app.py deleted file mode 100644 index 954393f..0000000 --- a/bolt_pet/ui/app.py +++ /dev/null @@ -1,158 +0,0 @@ -"""Wires everything together: QApplication, the pet window, the tray icon, -the history / wake-tuner windows, the global push-to-talk hotkey, and the -background PetController thread that owns the mic/wake/server/TTS pipeline. - -Everything the controller wants the UI to do arrives as a Qt signal, so the -worker thread never touches a widget. -""" - -from __future__ import annotations - -import sys - -from PySide6.QtCore import QThread -from PySide6.QtWidgets import QApplication, QMessageBox - -from .. import config, updater -from ..controller import PetController -from ..hotkey import GlobalHotkey -from ..state import PetState -from .history_window import HistoryWindow -from .mic_picker import MicPickerDialog -from .pet_window import PetWindow -from .tray import PetTray -from .wake_tuner import WakeTunerWindow - - -def _log(message: str) -> None: - print(message, flush=True) - - -def run() -> int: - app = QApplication(sys.argv) - app.setQuitOnLastWindowClosed(False) # tray-driven app; closing the pet isn't "quit" - - window = PetWindow() - window.show() - - controller = PetController() - thread = QThread() - controller.moveToThread(thread) - - thread.started.connect(controller.run) - controller.state_changed.connect(lambda value: window.set_state(PetState(value))) - controller.said.connect(window.say) - # Lip-sync: the loudness of the audio actually going to the speakers. - controller.mouth.connect(window.set_mouth) - controller.log.connect(_log) - controller.action.connect(window.apply_action) # petctl move/emote/say/... - controller.finished.connect(thread.quit) - - window.talk_requested.connect(controller.request_talk_now) - # The window owns the screen list and tells the controller about it, so - # both ends agree on what "monitor 2" means (see monitors.py). - window.monitors_changed.connect(controller.set_monitors) - window.pet_monitor_changed.connect(controller.set_pet_monitor) - # PetWindow publishes once in its constructor, which ran before those - # connections existed — so say it again now that anyone is listening. - window.publish_monitors() - window.copied.connect(lambda text: _log(f"Copied to clipboard: {text[:60]}")) - - # The updater has already moved the checkout by the time this fires; all - # that's left is to let Qt tear down cleanly (so the mic and the tray - # icon are released) and then exec the new code. Doing the exec after - # app.exec() returns, rather than from the controller thread, is what - # guarantees the audio device is free before the new process opens it. - # Defined early so the mic picker (below) can trigger the same restart - # a self-update or self-restart does. - pending_restart = {"tag": None} - - def _handle_restart(tag: str) -> None: - pending_restart["tag"] = tag - app.quit() - - history_window = HistoryWindow(controller.history) - tuner_window = WakeTunerWindow( - get_threshold=controller.wake_threshold, - set_threshold=controller.set_wake_threshold, - get_stats=controller.wake_stats, - on_reset=controller.reset_wake_stats, - ) - - def _save_mic_device(value) -> None: - config.set_env_value("MIC_DEVICE", str(value) if value is not None else "") - _log(f"Microphone set to device #{value}." if value is not None else - "Microphone set to system default.") - answer = QMessageBox.question( - None, "Restart Bolt?", - "Restart now so Bolt starts listening on the new microphone?", - QMessageBox.Yes | QMessageBox.No, - ) - if answer == QMessageBox.Yes: - _handle_restart("microphone change") - - mic_picker = MicPickerDialog( - get_current=lambda: config.MIC_DEVICE, - on_save=_save_mic_device, - ) - - def _set_nap(napping: bool) -> None: - # Clicking the tray item pins the state; the schedule takes over again - # only after a restart or a `petctl nap off`. - controller.set_napping(napping) - window.set_napping(napping) - - tray = PetTray( - on_talk_now=controller.request_talk_now, - on_toggle_mute=controller.toggle_mute, - on_quit=app.quit, - on_set_wander=window.set_wander_enabled, - wander_enabled=config.PET_WANDER, - on_set_click_through=window.set_click_through, - click_through_enabled=config.PET_CLICK_THROUGH, - on_set_nap=_set_nap, - on_show_history=history_window.show_refreshed, - on_show_wake_tuner=tuner_window.show_refreshed, - on_show_mic_picker=mic_picker.show_refreshed, - on_reset_voice=controller.reset_voice, - ) - - def _handle_napping(napping: bool) -> None: - window.set_napping(napping) - tray.set_napping(napping) - - controller.napping.connect(_handle_napping) - # The server can hand Bolt a different voice mid-conversation (speak_as); - # the tray is where you get his own back. - controller.voice_changed.connect(tray.set_voice) - - # Push-to-talk: a global hook, because the pet window never has focus. - # request_talk_now() only sets a threading.Event, so it's safe to call - # from pynput's listener thread. - hotkey = GlobalHotkey(config.PUSH_TO_TALK_HOTKEY, controller.request_talk_now) - problem = hotkey.start() - if problem: - _log(problem) - elif hotkey.running: - _log(f"Push-to-talk: {config.PUSH_TO_TALK_HOTKEY}") - - controller.restart_requested.connect(_handle_restart) - - def _shutdown() -> None: - hotkey.stop() - controller.stop() - thread.quit() - thread.wait(5000) - - app.aboutToQuit.connect(_shutdown) - - thread.start() - status = app.exec() - - if pending_restart["tag"]: - _log(f"Restarting into {pending_restart['tag']}…") - try: - updater.restart() # never returns - except Exception as exc: - _log(f"Couldn't restart automatically ({exc}) — start the pet again by hand.") - return status diff --git a/bolt_pet/ui/history_window.py b/bolt_pet/ui/history_window.py deleted file mode 100644 index 00795cc..0000000 --- a/bolt_pet/ui/history_window.py +++ /dev/null @@ -1,83 +0,0 @@ -"""Scrollback for the speech bubble. - -The bubble is transient by design, so anything Bolt said more than a few -seconds ago is gone. This is the "wait, what was that path again?" window: -the last HISTORY_LIMIT turns, copyable. Opened from the tray. - -Reads a bolt_pet.history.ConversationHistory (pure logic, tested separately); -this file is only presentation. -""" - -from __future__ import annotations - -import time -from typing import Callable, Optional - -from PySide6.QtCore import Qt -from PySide6.QtWidgets import ( - QApplication, QDialog, QHBoxLayout, QPlainTextEdit, QPushButton, QVBoxLayout, -) - -from ..history import ConversationHistory - - -def _clock(timestamp: float) -> str: - return time.strftime("%H:%M:%S", time.localtime(timestamp)) - - -class HistoryWindow(QDialog): - def __init__(self, history: ConversationHistory, on_clear: Optional[Callable[[], None]] = None): - super().__init__() - self._history = history - self._on_clear = on_clear - self.setWindowTitle("Bolt — conversation history") - self.resize(620, 420) - - self._view = QPlainTextEdit() - self._view.setReadOnly(True) - self._view.setLineWrapMode(QPlainTextEdit.WidgetWidth) - - copy_button = QPushButton("Copy all") - copy_button.clicked.connect(self._copy_all) - clear_button = QPushButton("Clear") - clear_button.clicked.connect(self._clear) - close_button = QPushButton("Close") - close_button.clicked.connect(self.close) - close_button.setDefault(True) - - buttons = QHBoxLayout() - buttons.addWidget(copy_button) - buttons.addWidget(clear_button) - buttons.addStretch(1) - buttons.addWidget(close_button) - - layout = QVBoxLayout(self) - layout.addWidget(self._view) - layout.addLayout(buttons) - - def refresh(self) -> None: - self._view.setPlainText(self._history.as_text(clock=_clock)) - # Jump to the newest line — that's what you opened this for. - scrollbar = self._view.verticalScrollBar() - scrollbar.setValue(scrollbar.maximum()) - - def show_refreshed(self) -> None: - self.refresh() - self.show() - self.raise_() - self.activateWindow() - - def _copy_all(self) -> None: - QApplication.clipboard().setText(self._history.as_text(clock=_clock)) - - def _clear(self) -> None: - self._history.clear() - if self._on_clear is not None: - self._on_clear() - self.refresh() - - def keyPressEvent(self, event) -> None: - if event.key() == Qt.Key_Escape: - self.close() - return - super().keyPressEvent(event) diff --git a/bolt_pet/ui/mic_picker.py b/bolt_pet/ui/mic_picker.py deleted file mode 100644 index a1547fd..0000000 --- a/bolt_pet/ui/mic_picker.py +++ /dev/null @@ -1,98 +0,0 @@ -"""Microphone picker — MIC_DEVICE in .env is a device index most people have -no way to look up short of running a script. This dialog lists whatever -sounddevice can actually see, lets you click one, and writes its index back -to .env so it survives a restart. - -The index, not the name, is what gets saved: the same device is commonly -listed once per host API (MME, DirectSound, WASAPI, WDM-KS, ...) under the -same or a near-identical name, which makes a bare name an ambiguous pick that -sounddevice will flatly refuse to resolve (see audio/mic.list_input_devices). -The index is exact. - -The mic is only ever opened once, at startup (see controller.run()), so a -pick made here still needs a restart before Bolt actually uses it — this -dialog just makes finding and saving the right device painless, and offers -to do the restart for you. -""" - -from __future__ import annotations - -from typing import Callable, Optional - -from PySide6.QtCore import Qt -from PySide6.QtWidgets import ( - QDialog, QDialogButtonBox, QHBoxLayout, QLabel, QListWidget, - QListWidgetItem, QPushButton, QVBoxLayout, -) - -from ..audio.mic import list_input_devices - -_DEFAULT_LABEL = "System default" - - -class MicPickerDialog(QDialog): - def __init__( - self, - get_current: Callable[[], Optional[int]], - on_save: Callable[[Optional[int]], None], - ): - super().__init__() - self._get_current = get_current - self._on_save = on_save - - self.setWindowTitle("Bolt — select microphone") - self.resize(440, 320) - - self._list = QListWidget() - refresh_button = QPushButton("Refresh") - refresh_button.clicked.connect(self.refresh) - - top = QHBoxLayout() - top.addWidget(QLabel("Pick the microphone Bolt should listen on:")) - top.addStretch(1) - top.addWidget(refresh_button) - - buttons = QDialogButtonBox(QDialogButtonBox.Save | QDialogButtonBox.Cancel) - buttons.accepted.connect(self._save) - buttons.rejected.connect(self.reject) - - layout = QVBoxLayout(self) - layout.addLayout(top) - layout.addWidget(self._list) - layout.addWidget(buttons) - - def refresh(self) -> None: - self._list.clear() - default_item = QListWidgetItem(_DEFAULT_LABEL) - default_item.setData(Qt.UserRole, None) - self._list.addItem(default_item) - - current = self._get_current() - selected = default_item - try: - devices = list_input_devices() - except Exception as exc: - self._list.addItem(f"Could not list devices: {exc}") - devices = [] - - for device in devices: - item = QListWidgetItem( - f"{device['name']} [{device['hostapi']}] (#{device['index']})" - ) - item.setData(Qt.UserRole, device["index"]) - self._list.addItem(item) - if current == device["index"]: - selected = item - self._list.setCurrentItem(selected) - - def _save(self) -> None: - item = self._list.currentItem() - value = item.data(Qt.UserRole) if item is not None else None - self._on_save(value) - self.accept() - - def show_refreshed(self) -> None: - self.refresh() - self.show() - self.raise_() - self.activateWindow() diff --git a/bolt_pet/ui/pet_window.py b/bolt_pet/ui/pet_window.py deleted file mode 100644 index 8bce0bf..0000000 --- a/bolt_pet/ui/pet_window.py +++ /dev/null @@ -1,822 +0,0 @@ -"""The pet itself: a frameless, translucent, always-on-top window that -renders the current sprite animation, wanders the desktop on its own while -idle, walks/emotes on command from the server (see pet_actions.py), can be -dragged around, dims when napping, and turns a plain (non-drag) click into a -"talk now" request. -""" - -from __future__ import annotations - -import math -import random -import time -from pathlib import Path -from typing import Optional - -from PySide6.QtCore import QPoint, Qt, QTimer, Signal -from PySide6.QtGui import ( - QColor, QCursor, QFont, QFontMetrics, QPainter, QPainterPath, QPixmap, QRegion, QTransform, -) -from PySide6.QtWidgets import QApplication, QWidget - -from .. import config, window_state -from ..monitors import Monitor -from ..state import PetState -from .sprite import WALK, SpriteSet - -_DRAG_THRESHOLD_PX = 4 -# Movement runs on its own ~30fps timer, independent of the (slower) sprite -# animation timer, so a stroll looks smooth even at IDLE_ANIMATION_FPS=6. -_WANDER_TICK_MS = 33 -_EMOTE_TICKS = 36 # ~1.2s per emote at the tick rate above -_NAP_OPACITY = 0.35 -# How far the pet travels per walk-cycle frame. The cycle is advanced by -# distance rather than by the animation clock so a planted paw tracks backwards -# at exactly the speed the window moves forwards — drive it off a timer instead -# and the feet skate whenever PET_WANDER_SPEED doesn't happen to match the fps. -# Eight frames at 13px is a ~104px stride cycle, a bit under the pet's width. -_WALK_PIXELS_PER_FRAME = 13.0 -# How long a loudness level stays believable. The audio thread sends one per -# ~30ms while a clip plays; if they stop arriving (offline TTS has no envelope, -# or playback died) the mouth must not stay frozen mid-syllable, so after this -# long the ordinary looping animation takes back over. -_MOUTH_STALE_SECONDS = 0.35 - - -def emote_transform(emote: str, progress: float) -> tuple[float, float, float, float]: - """(dx, dy, rotation_degrees, scale) for an emote at *progress* 0..1. - - Pure maths, deliberately separate from paintEvent so the motion curves can - be unit tested (and so adding an emote doesn't mean touching painting - code). Every emote must return to (0, 0, 0, 1) at progress 1.0, otherwise - the pet ends up permanently askew. - """ - progress = min(max(progress, 0.0), 1.0) - fade = math.sin(math.pi * progress) # 0 -> 1 -> 0, so it always lands home - tau = 2 * math.pi - if emote == "wave": - return 0.0, 0.0, 14.0 * fade * math.sin(tau * 2 * progress), 1.0 - if emote in ("hop", "bounce"): - hops = 2 if emote == "hop" else 3 - return 0.0, -22.0 * fade * abs(math.sin(math.pi * hops * progress)), 0.0, 1.0 - if emote == "spin": - return 0.0, 0.0, 360.0 * progress % 360.0, 1.0 - if emote == "nod": - return 0.0, 10.0 * fade * math.sin(tau * 2 * progress), 0.0, 1.0 - 0.05 * fade - if emote in ("shake", "wiggle"): - return 14.0 * fade * math.sin(tau * 3 * progress), 0.0, 0.0, 1.0 - return 0.0, 0.0, 0.0, 1.0 - - -class SpeechBubble(QWidget): - """Small translucent word-bubble shown above the pet while it talks.""" - - _MAX_WIDTH = 260 - _PADDING = 10 - - copied = Signal(str) - - def __init__(self, parent: Optional[QWidget] = None): - super().__init__(parent, Qt.FramelessWindowHint | Qt.Tool) - self.setAttribute(Qt.WA_TranslucentBackground) - self.setAttribute(Qt.WA_ShowWithoutActivating) - self._text = "" - self._font = QFont() - self._font.setPointSize(10) - self._flash = "" # transient overlay ("Copied") drawn over the text - self._hide_timer = QTimer(self) - self._hide_timer.setSingleShot(True) - self._hide_timer.timeout.connect(self.hide) - self._flash_timer = QTimer(self) - self._flash_timer.setSingleShot(True) - self._flash_timer.timeout.connect(self._clear_flash) - self.setToolTip("Click to copy") - self.setCursor(Qt.PointingHandCursor) - self.hide() - - @property - def text(self) -> str: - return self._text - - def show_text(self, text: str, duration_ms: int = 6000) -> None: - text = (text or "").strip() - if not text: - self.hide() - return - self._text = text - self._relayout() - self.show() - self.raise_() - self._hide_timer.start(duration_ms) - - # ── click to copy ──────────────────────────────────────────────────── - # The bubble hides itself after a few seconds, which is fine for chat and - # awful for anything you needed to keep (a path, a number, a command's - # output). One click puts it on the clipboard; the tray's History window - # has the rest. - - def mousePressEvent(self, event) -> None: - if event.button() != Qt.LeftButton or not self._text: - return - QApplication.clipboard().setText(self._text) - self.copied.emit(self._text) - self._flash = "Copied to clipboard" - self.update() - self._flash_timer.start(900) - self._hide_timer.start(2500) # linger a moment so the flash is visible - - def _clear_flash(self) -> None: - self._flash = "" - self.update() - - def _wrapped_lines(self) -> list[str]: - metrics = QFontMetrics(self._font) - words = self._text.split() - lines: list[str] = [] - current = "" - max_text_width = self._MAX_WIDTH - 2 * self._PADDING - for word in words: - candidate = f"{current} {word}".strip() - if metrics.horizontalAdvance(candidate) <= max_text_width or not current: - current = candidate - else: - lines.append(current) - current = word - if current: - lines.append(current) - return lines[:6] # don't let a huge reply turn into a wall of bubble - - def _relayout(self) -> None: - metrics = QFontMetrics(self._font) - lines = self._wrapped_lines() - text_width = max((metrics.horizontalAdvance(line) for line in lines), default=0) - width = min(self._MAX_WIDTH, text_width + 2 * self._PADDING) - height = metrics.height() * len(lines) + 2 * self._PADDING - self.resize(width, height) - - def paintEvent(self, _event) -> None: - painter = QPainter(self) - painter.setRenderHint(QPainter.Antialiasing) - path = QPainterPath() - path.addRoundedRect(0, 0, self.width(), self.height(), 10, 10) - painter.fillPath(path, QColor(30, 30, 35, 230)) - painter.setFont(self._font) - metrics = QFontMetrics(self._font) - if self._flash: - painter.setPen(QColor(150, 230, 170)) - painter.drawText(self.rect(), Qt.AlignCenter, self._flash) - return - painter.setPen(QColor(240, 240, 245)) - y = self._PADDING + metrics.ascent() - for line in self._wrapped_lines(): - painter.drawText(self._PADDING, y, line) - y += metrics.height() - - -class PetWindow(QWidget): - talk_requested = Signal() - copied = Signal(str) # bubble text the user just put on the clipboard - # The screen layout, published *to* the controller (queued, cross-thread). - # The window is the only thing allowed to ask Qt about screens, so the - # controller and the window can never disagree about what "monitor 2" - # means — see monitors.py. - monitors_changed = Signal(list) # list[monitors.Monitor] - pet_monitor_changed = Signal(int) # 0-based index the pet is standing on - - def __init__(self, sprite_dir: Optional[Path] = None, size: Optional[int] = None): - super().__init__() - flags = Qt.FramelessWindowHint | Qt.Tool - if config.PET_ALWAYS_ON_TOP: - flags |= Qt.WindowStaysOnTopHint - self.setWindowFlags(flags) - self.setAttribute(Qt.WA_TranslucentBackground) - - self.sprites = SpriteSet(sprite_dir or (Path(__file__).resolve().parent.parent / "assets" / "sprites"), - size or config.PET_SIZE) - self.resize(self.sprites.size, self.sprites.size) - self._current_state = PetState.IDLE - - self._drag_offset: Optional[QPoint] = None - self._press_pos: Optional[QPoint] = None - self._dragged = False - - self._bubble = SpeechBubble() - self._bubble.copied.connect(self.copied) - - self._napping = False - self._emote: Optional[str] = None - self._emote_tick = 0 - self._mask_key = None - - self._anim_timer = QTimer(self) - self._anim_timer.timeout.connect(self._advance_frame) - fps = max(1.0, config.IDLE_ANIMATION_FPS) - self._anim_timer.start(int(1000 / fps)) - - self._wander_enabled = config.PET_WANDER - self._wander_target: Optional[QPoint] = None - self._commanded_move = False # a petctl move — happens even mid-conversation - self._next_wander_at = 0.0 - self._bob_offset = 0 - # Lip-sync: loudness of what is playing right now, and when it arrived. - self._mouth_level: Optional[float] = None - self._mouth_at = 0.0 - self._bob_phase = 0.0 - self._walking = False - self._facing = 1 # +1 right, -1 left; the walk art is drawn facing right - self._walk_distance = 0.0 - self._mirror_cache: dict[int, QPixmap] = {} - self._schedule_next_wander() - self._wander_timer = QTimer(self) - self._wander_timer.timeout.connect(self._movement_tick) - self._wander_timer.start(_WANDER_TICK_MS) - - self._click_through = False - self.set_click_through(config.PET_CLICK_THROUGH) - self._place_start_position() - - self._monitors: list[Monitor] = [] - self._pet_monitor: Optional[int] = None - self._last_published_pos: Optional[QPoint] = None - app = QApplication.instance() - if app is not None: - # Screens come and go — a laptop docking, a TV waking up. Republish - # rather than letting Bolt jump to a monitor that's been unplugged. - app.screenAdded.connect(lambda _s: self.publish_monitors()) - app.screenRemoved.connect(lambda _s: self.publish_monitors()) - app.primaryScreenChanged.connect(lambda _s: self.publish_monitors()) - self.publish_monitors() - - # ── placement ──────────────────────────────────────────────────────── - - def _place_start_position(self) -> None: - screen = QApplication.primaryScreen() - geo = screen.availableGeometry() if screen else None - try: - x = int(config.PET_START_X) if config.PET_START_X else None - y = int(config.PET_START_Y) if config.PET_START_Y else None - except ValueError: - x = y = None - if x is None and y is None and config.PET_REMEMBER_POSITION: - remembered = window_state.load() - # Only if it still lands on a screen that exists — the usual reason - # a saved position is stale is that the monitor it was on has been - # unplugged, and restoring it faithfully would hide the pet. - if remembered is not None: - rectangles = [ - (s.availableGeometry().left(), s.availableGeometry().top(), - s.availableGeometry().right(), s.availableGeometry().bottom()) - for s in QApplication.screens() - ] - if window_state.is_visible_on(*remembered, self.width(), rectangles): - x, y = remembered - if geo is not None: - x = geo.right() - self.width() - 40 if x is None else x - y = geo.bottom() - self.height() - 60 if y is None else y - self.move(x or 0, y or 0) - self._reposition_bubble() - - def _reposition_bubble(self) -> None: - top_left = self.geometry().topLeft() - self._bubble.move( - top_left.x() + self.width() // 2 - self._bubble.width() // 2, - top_left.y() - self._bubble.height() - 8, - ) - - # ── server-driven actions (petctl) ─────────────────────────────────── - - def apply_action(self, action: dict) -> None: - """Perform one parsed petctl action (see pet_actions.py). Called on - the UI thread via a queued signal from the controller.""" - kind = action.get("action") - if kind == "move": - target = self._resolve_move_target(action) - if target is not None: - self._wander_target = target - self._commanded_move = True # overrides the idle-only rule - elif kind == "jump": - self.jump_to_monitor(int(action["monitor"])) - elif kind == "emote": - self.start_emote(action["emote"]) - elif kind == "say": - self.say(action["text"]) - elif kind == "wander": - self.set_wander_enabled(bool(action["enabled"])) - elif kind == "nap": - self.set_napping(bool(action["enabled"])) - - def _resolve_move_target(self, action: dict) -> Optional[QPoint]: - geo = self._screen_geometry() - if "x" in action and "y" in action: - point = QPoint(int(action["x"]), int(action["y"])) - return self._clamp_to_screen(point, geo) - anchor = action.get("anchor") - if anchor == "cursor": - cursor = QCursor.pos() - return self._clamp_to_screen( - QPoint(cursor.x() - self.width() // 2, cursor.y() - self.height() // 2), geo - ) - if geo is None: - return None - if anchor == "random": - return self._pick_wander_target() - margin = config.PET_WANDER_MARGIN - left, right = geo.left() + margin, geo.right() - self.width() - margin - top, bottom = geo.top() + margin, geo.bottom() - self.height() - margin - middle_x = geo.left() + (geo.width() - self.width()) // 2 - middle_y = geo.top() + (geo.height() - self.height()) // 2 - positions = { - "top-left": (left, top), "top": (middle_x, top), "top-right": (right, top), - "left": (left, middle_y), "center": (middle_x, middle_y), "right": (right, middle_y), - "bottom-left": (left, bottom), "bottom": (middle_x, bottom), "bottom-right": (right, bottom), - } - if anchor not in positions: - return None - return QPoint(*positions[anchor]) - - def _clamp_to_screen(self, point: QPoint, geo) -> QPoint: - if geo is None: - return point - x = min(max(point.x(), geo.left()), max(geo.left(), geo.right() - self.width())) - y = min(max(point.y(), geo.top()), max(geo.top(), geo.bottom() - self.height())) - return QPoint(x, y) - - # ── emotes ─────────────────────────────────────────────────────────── - - def start_emote(self, emote: str) -> None: - self._emote = emote - self._emote_tick = 0 - self.update() - - def _advance_emote(self) -> None: - if self._emote is None: - return - self._emote_tick += 1 - if self._emote_tick > _EMOTE_TICKS: - self._emote = None - self._emote_tick = 0 - self.update() - - def _emote_transform(self) -> tuple[float, float, float, float]: - if self._emote is None: - return 0.0, 0.0, 0.0, 1.0 - return emote_transform(self._emote, self._emote_tick / _EMOTE_TICKS) - - # ── napping (quiet hours / do-not-disturb) ─────────────────────────── - - def set_napping(self, napping: bool) -> None: - """Dim and stand still. Purely cosmetic here — the controller is what - actually suppresses proactive speech.""" - if napping == self._napping: - return - self._napping = napping - self.setWindowOpacity(_NAP_OPACITY if napping else 1.0) - if napping: - self._stop_walking() - self.update() - - @property - def napping(self) -> bool: - return self._napping - - # ── mouse transparency ─────────────────────────────────────────────── - - def set_click_through(self, enabled: bool) -> None: - """When on, the pet ignores the mouse entirely (tray-only control) — - for when it's parked over something you need to click a lot.""" - self._click_through = enabled - self.setAttribute(Qt.WA_TransparentForMouseEvents, enabled) - if enabled: - self.clearMask() - self._mask_key = None - else: - self._mask_key = None # force the shaped mask to be rebuilt - - @property - def click_through(self) -> bool: - return self._click_through - - def _apply_input_mask(self, pixmap: Optional[QPixmap], x: int, y: int) -> None: - """Restrict the window to the sprite's opaque pixels, so the square - window's transparent corners stop swallowing clicks meant for what's - underneath. Rebuilt only when the frame actually changes — the mask - is derived from the pixmap's alpha, which isn't free.""" - if self._click_through or not config.PET_SHAPED_INPUT: - return - if pixmap is None: - if self._mask_key is not None: - self.clearMask() - self._mask_key = None - return - key = (pixmap.cacheKey(), x, y) - if key == self._mask_key: - return - self._mask_key = key - try: - region = QRegion(pixmap.mask()) - region.translate(x, y) - self.setMask(region) - except Exception: - self.clearMask() # a sprite without an alpha channel — never mind - - # ── wandering ──────────────────────────────────────────────────────── - - def set_wander_enabled(self, enabled: bool) -> None: - self._wander_enabled = enabled - if not enabled: - self._stop_walking() - - def wander_now(self) -> None: - """Stroll immediately (tray menu / anything that wants a nudge).""" - self._next_wander_at = 0.0 - - # ── monitors ───────────────────────────────────────────────────────── - - def _build_monitors(self) -> list[Monitor]: - """Snapshot Qt's screen list as plain dataclasses. - - Full `geometry()`, not `availableGeometry()`: these coordinates are - what a screen grab gets cropped to, and a grab doesn't stop at the - taskbar. Placement uses availableGeometry separately. - """ - primary = QApplication.primaryScreen() - out = [] - for index, screen in enumerate(QApplication.screens()): - geo = screen.geometry() - out.append( - Monitor( - index=index, - name=screen.name() or f"screen-{index + 1}", - x=geo.x(), - y=geo.y(), - width=geo.width(), - height=geo.height(), - primary=screen is primary, - ) - ) - return out - - def publish_monitors(self, force: bool = True) -> None: - """Push the current layout to whoever's listening (the controller). - - *force* re-emits even when nothing changed, which is what the initial - wiring in ui/app.py needs: this window is built before the controller - exists, so the constructor's first publish goes to nobody. - """ - monitors = self._build_monitors() - changed = monitors != self._monitors - self._monitors = monitors - if changed or force: - self.monitors_changed.emit(monitors) - self._publish_pet_monitor(force=True) - - def monitors(self) -> list[Monitor]: - return list(self._monitors) - - def current_monitor_index(self) -> Optional[int]: - center = self.frameGeometry().center() - screens = QApplication.screens() - if not screens: - return None - screen = QApplication.screenAt(center) - if screen is not None: - try: - return screens.index(screen) - except ValueError: - pass - # Straddling a gap or dragged off the desktop entirely — fall back to - # whichever screen centre is nearest rather than reporting nothing. - best = min( - range(len(screens)), - key=lambda i: (screens[i].geometry().center() - center).manhattanLength(), - ) - return best - - def _publish_pet_monitor(self, force: bool = False) -> None: - index = self.current_monitor_index() - if index is None: - return - if force or index != self._pet_monitor: - self._pet_monitor = index - self.pet_monitor_changed.emit(index) - - def jump_to_monitor(self, index: int) -> None: - """Teleport to *index* (0-based, resolved by the controller) and land - with a hop. Instant rather than a stroll — Bolt asked to *jump*, and - walking between screens would take the long way across the desktop.""" - screens = QApplication.screens() - if not (0 <= index < len(screens)): - return - geo = screens[index].availableGeometry() - point = self._clamp_to_screen( - QPoint( - geo.left() + (geo.width() - self.width()) // 2, - geo.top() + (geo.height() - self.height()) // 2, - ), - geo, - ) - self._stop_walking() # drop any stroll in flight, or it walks straight back - self.move(point) - self._schedule_next_wander() - self._publish_pet_monitor(force=True) - self.start_emote("hop") - self.update() - - def _screen_geometry(self): - # screenAt() so a multi-monitor setup keeps the pet on the screen - # it's currently standing on rather than yanking it to the primary. - screen = QApplication.screenAt(self.frameGeometry().center()) or QApplication.primaryScreen() - return screen.availableGeometry() if screen else None - - def _schedule_next_wander(self) -> None: - base = max(1.0, config.PET_WANDER_INTERVAL_SECONDS) - self._next_wander_at = time.monotonic() + random.uniform(0.5 * base, 1.5 * base) - - def _stop_walking(self) -> None: - if self._wander_target is None and not self._bob_offset and not self._walking: - return - self._wander_target = None - self._commanded_move = False - self._bob_phase = 0.0 - self._bob_offset = 0 - self._walking = False - self._walk_distance = 0.0 - # Back to a standing frame, so the next stroll starts from a contact - # pose instead of mid-stride. - self.sprites.get(WALK).reset() - self.update() - - def snap_to_edge(self) -> bool: - """If the pet has come to rest near a screen edge, tuck it flush - against it — a desktop pet parked 11px off the taskbar looks like a - bug. Returns True if it moved.""" - geo = self._screen_geometry() - if geo is None or not config.PET_EDGE_SNAP: - return False - margin = config.PET_SNAP_MARGIN - here = self.pos() - x, y = here.x(), here.y() - if abs(x - geo.left()) <= margin: - x = geo.left() - elif abs(geo.right() - (x + self.width())) <= margin: - x = geo.right() - self.width() + 1 - if abs(y - geo.top()) <= margin: - y = geo.top() - elif abs(geo.bottom() - (y + self.height())) <= margin: - y = geo.bottom() - self.height() + 1 - if (x, y) == (here.x(), here.y()): - return False - self.move(x, y) - self._reposition_bubble() - return True - - def _pick_wander_target(self) -> Optional[QPoint]: - geo = self._screen_geometry() - if geo is None: - return None - margin = config.PET_WANDER_MARGIN - min_x, max_x = geo.left() + margin, geo.right() - self.width() - margin - min_y, max_y = geo.top() + margin, geo.bottom() - self.height() - margin - if max_x <= min_x or max_y <= min_y: # pet bigger than the screen - return None - here = self.pos() - target = QPoint(random.randint(min_x, max_x), random.randint(min_y, max_y)) - dx, dy = target.x() - here.x(), target.y() - here.y() - distance = math.hypot(dx, dy) - limit = max(1.0, config.PET_WANDER_MAX_DISTANCE) - if distance > limit: # shorten the trip rather than sprinting the diagonal - scale = limit / distance - target = QPoint(round(here.x() + dx * scale), round(here.y() + dy * scale)) - elif distance < 8: # already there — not worth a stroll - return None - return target - - def _movement_tick(self) -> None: - """One timer, two jobs — emotes play whatever the pet is doing, while - wandering only happens when it's otherwise unoccupied.""" - self._advance_emote() - self._wander_tick() - # Report crossing a screen boundary — by strolling, by being dragged, - # by anything. Guarded on the position actually changing so the common - # case (a stationary pet, 30x a second) costs one comparison. - position = self.pos() - if position != self._last_published_pos: - self._last_published_pos = position - self._publish_pet_monitor() - - def _wander_tick(self) -> None: - # Only stroll while genuinely idle: not mid-drag, not napping, not - # talking/listening, and not while a speech bubble is up (it would walk - # out from under it). A commanded `petctl move` ignores all of that - # except the drag — if Bolt says go, it goes. - busy = ( - not self._wander_enabled - or self._napping - or self._current_state != PetState.IDLE - or self._bubble.isVisible() - ) - if self._drag_offset is not None or (busy and not self._commanded_move): - self._stop_walking() - self._schedule_next_wander() # settle first, then wander - return - - if self._wander_target is None: - if time.monotonic() < self._next_wander_at: - return - self._wander_target = self._pick_wander_target() - if self._wander_target is None: - self._schedule_next_wander() - return - - here = self.pos() - dx = self._wander_target.x() - here.x() - dy = self._wander_target.y() - here.y() - distance = math.hypot(dx, dy) - step = max(1.0, config.PET_WANDER_SPEED * _WANDER_TICK_MS / 1000.0) - if distance <= step: - self.move(self._wander_target) - self._stop_walking() - self._schedule_next_wander() - self.snap_to_edge() - else: - self.move(round(here.x() + dx / distance * step), round(here.y() + dy / distance * step)) - self._advance_walk(dx, dy, step) - self.update() - self._reposition_bubble() - - def _advance_walk(self, dx: float, dy: float, step: float) -> None: - """Drive the walk cycle from distance travelled (see the constant). - - Falls back to the old bob-in-code if there's no walk art, so a sprite - folder without a walk/ directory still looks like it's moving rather - than sliding perfectly flat. - """ - self._walking = True - # Only turn on meaningful horizontal travel: a near-vertical stroll - # would otherwise flip him back and forth on rounding noise. - if abs(dx) > 1.0: - self._facing = 1 if dx > 0 else -1 - if not self.sprites.has(WALK): - self._bob_phase += 0.45 - self._bob_offset = int(round(-2.5 * abs(math.sin(self._bob_phase)))) - return - self._bob_offset = 0 # the walk frames carry their own weight shift - self._walk_distance += step - while self._walk_distance >= _WALK_PIXELS_PER_FRAME: - self._walk_distance -= _WALK_PIXELS_PER_FRAME - self.sprites.get(WALK).advance() - - def _animation_key(self): - """Walking overrides the state animation — but only while genuinely - idle-and-moving, so he doesn't trot on the spot mid-sentence.""" - if self._walking and self.sprites.has(WALK): - return WALK - return self._current_state - - def _oriented(self, pixmap: Optional[QPixmap]) -> Optional[QPixmap]: - """Mirror the (right-facing) walk art when he's heading left. Cached - per source frame — flipping on every paint would be wasteful at 30fps.""" - if pixmap is None or self._facing >= 0: - return pixmap - key = pixmap.cacheKey() - mirrored = self._mirror_cache.get(key) - if mirrored is None: - mirrored = pixmap.transformed(QTransform().scale(-1, 1), Qt.SmoothTransformation) - self._mirror_cache[key] = mirrored - return mirrored - - # ── state / speech ────────────────────────────────────────────────── - - def set_state(self, state: PetState) -> None: - if state == self._current_state: - return - self._current_state = state - self.sprites.get(state).reset() - if state != PetState.IDLE and not self._commanded_move: - # Stand still while listening/thinking/talking — but not if Bolt - # just told it to walk somewhere: that command arrives mid-turn, - # and the reply (-> TALKING) lands a moment later. - self._stop_walking() - self.update() - - def say(self, text: str, duration_ms: int = 6000) -> None: - self._reposition_bubble() - self._bubble.show_text(text, duration_ms) - - def closeEvent(self, event) -> None: - self._bubble.close() - super().closeEvent(event) - - # ── animation ──────────────────────────────────────────────────────── - - def set_mouth(self, level: float) -> None: - """How loud the pet is *right now* (0..1), straight off the PCM going - to the speakers (audio/tts.level_of). - - The talking frames are ordered by mouth openness, so this indexes them - directly: the mouth moves with the actual waveform instead of flapping - on a timer, which is the difference between a talking sprite and a - dubbed one.""" - self._mouth_level = max(0.0, min(1.0, float(level))) - self._mouth_at = time.monotonic() - if self._current_state == PetState.TALKING: - self.update() - - def _mouth_frame(self, animation) -> Optional[QPixmap]: - """The frame matching the current loudness, or None to use the timer. - - Falls back the moment the levels go stale — offline TTS has no - envelope, and a mouth frozen mid-syllable is worse than a timed loop.""" - if self._mouth_level is None or animation is None: - return None - if time.monotonic() - self._mouth_at > _MOUTH_STALE_SECONDS: - return None - frames = animation.frames - if len(frames) < 2: - return None - index = int(round(self._mouth_level * (len(frames) - 1))) - return frames[max(0, min(len(frames) - 1, index))] - - def _advance_frame(self) -> None: - # While walking the cycle is stepped by _advance_walk from distance - # travelled; letting this timer also advance it would double-step it - # and put the feet out of sync with the movement. - if self._walking and self.sprites.has(WALK): - return - self.sprites.get(self._current_state).advance() - self.update() - - def paintEvent(self, _event) -> None: - painter = QPainter(self) - painter.setRenderHint(QPainter.Antialiasing) - painter.setRenderHint(QPainter.SmoothPixmapTransform) - key = self._animation_key() - animation = self.sprites.get(key) - pixmap: Optional[QPixmap] = None - if key == PetState.TALKING: - pixmap = self._mouth_frame(animation) - if pixmap is None: - pixmap = animation.current() - if key == WALK: - pixmap = self._oriented(pixmap) - if pixmap is None: - self._apply_input_mask(None, 0, 0) - return - # Non-square source art (e.g. the Kenney robot sprites) keeps its - # aspect ratio when scaled in SpriteSet, so it may be narrower or - # shorter than the (square) window — center it either way. - x = (self.width() - pixmap.width()) // 2 - y = (self.height() - pixmap.height()) // 2 + self._bob_offset - # The input mask tracks the resting position, not the emote/bob - # offset: rebuilding it every frame of a spin would be both expensive - # and visibly janky, and the offsets are only a few pixels. - self._apply_input_mask(pixmap, x, (self.height() - pixmap.height()) // 2) - - dx, dy, angle, scale = self._emote_transform() - if (dx, dy, angle, scale) == (0.0, 0.0, 0.0, 1.0): - painter.drawPixmap(x, y, pixmap) - return - # Rotate/scale about the sprite's own center so a spin doesn't orbit - # the window's corner. - center_x = x + pixmap.width() / 2 - center_y = y + pixmap.height() / 2 - transform = QTransform() - transform.translate(center_x + dx, center_y + dy) - transform.rotate(angle) - transform.scale(scale, scale) - transform.translate(-pixmap.width() / 2, -pixmap.height() / 2) - painter.setTransform(transform) - painter.drawPixmap(0, 0, pixmap) - - # ── drag / click-to-talk ───────────────────────────────────────────── - - def mousePressEvent(self, event) -> None: - if event.button() == Qt.LeftButton: - global_pos = event.globalPosition().toPoint() - self._drag_offset = global_pos - self.frameGeometry().topLeft() - self._press_pos = global_pos - self._dragged = False - self._stop_walking() # grabbing it interrupts a stroll at once - - def mouseMoveEvent(self, event) -> None: - if self._drag_offset is None: - return - global_pos = event.globalPosition().toPoint() - self.move(global_pos - self._drag_offset) - self._reposition_bubble() - if (global_pos - self._press_pos).manhattanLength() > _DRAG_THRESHOLD_PX: - self._dragged = True - - def mouseReleaseEvent(self, event) -> None: - if event.button() != Qt.LeftButton: - return - was_click = not self._dragged - self._drag_offset = None - self._press_pos = None - if not was_click and config.PET_REMEMBER_POSITION: - position = self.geometry().topLeft() - window_state.save(position.x(), position.y()) - if was_click: - self.talk_requested.emit() - else: - self.snap_to_edge() # dropped near an edge -> tuck it flush diff --git a/bolt_pet/ui/sprite.py b/bolt_pet/ui/sprite.py deleted file mode 100644 index 33060b6..0000000 --- a/bolt_pet/ui/sprite.py +++ /dev/null @@ -1,140 +0,0 @@ -"""Sprite loading + frame animation. - -Convention: assets/sprites//*.png, frames played in filename-sorted -order (e.g. frame_00.png, frame_01.png, ...), looping. matches -bolt_pet.state.PetState values: idle, listening, thinking, talking. - -If a state's directory has no frames (real art not dropped in yet), falls -back to a small procedurally-drawn placeholder blob so the app still runs -end-to-end. Swap in real sprite sheets by pointing SPRITE_DIR at your own -folder (see assets/sprites/README.md) — no code changes needed as long as -the same per-state-subfolder-of-PNGs convention is followed. If your sheets -use a different layout (single grid image, etc.), tell me the format and -this loader can be adapted. -""" - -from __future__ import annotations - -from pathlib import Path -from typing import Optional - -from PySide6.QtCore import QSize, Qt -from PySide6.QtGui import QColor, QPainter, QPixmap - -from ..state import PetState - -DEFAULT_SPRITE_DIR = Path(__file__).resolve().parent.parent / "assets" / "sprites" - -# Placeholder palette per state, used only when no frames are found. -_PLACEHOLDER_COLORS = { - PetState.IDLE: QColor(120, 170, 240), - PetState.LISTENING: QColor(120, 220, 160), - PetState.THINKING: QColor(230, 190, 90), - PetState.TALKING: QColor(240, 130, 150), - PetState.ERROR: QColor(220, 90, 90), -} - - -def _placeholder_frames(state: PetState, size: int) -> list[QPixmap]: - """A tiny 2-frame "breathing" blob so idle/listening/etc. are visually - distinguishable even before real art exists.""" - color = _PLACEHOLDER_COLORS.get(state, QColor(150, 150, 150)) - frames = [] - for scale in (1.0, 0.92): - pixmap = QPixmap(size, size) - pixmap.fill(Qt.transparent) - painter = QPainter(pixmap) - painter.setRenderHint(QPainter.Antialiasing) - painter.setBrush(color) - painter.setPen(Qt.NoPen) - margin = size * (1 - scale) / 2 - painter.drawEllipse(int(margin), int(margin), int(size * scale), int(size * scale)) - # simple eyes so it reads as a face, not just a circle - eye_r = max(2, size // 16) - eye_y = int(size * 0.42) - painter.setBrush(QColor(30, 30, 40)) - painter.drawEllipse(int(size * 0.36) - eye_r, eye_y - eye_r, eye_r * 2, eye_r * 2) - painter.drawEllipse(int(size * 0.64) - eye_r, eye_y - eye_r, eye_r * 2, eye_r * 2) - painter.end() - frames.append(pixmap) - return frames - - -class SpriteAnimation: - """One state's frame sequence + current playback position.""" - - def __init__(self, frames: list[QPixmap]): - self.frames = frames or [] - self._index = 0 - - def advance(self) -> None: - if self.frames: - self._index = (self._index + 1) % len(self.frames) - - def current(self) -> Optional[QPixmap]: - if not self.frames: - return None - return self.frames[self._index] - - def reset(self) -> None: - self._index = 0 - - -def _load_frames_from_dir(directory: Path, size: int) -> list[QPixmap]: - if not directory.is_dir(): - return [] - paths = sorted(directory.glob("*.png")) + sorted(directory.glob("*.PNG")) - frames = [] - for path in paths: - pixmap = QPixmap(str(path)) - if pixmap.isNull(): - continue - if pixmap.size() != QSize(size, size): - pixmap = pixmap.scaled(size, size, Qt.KeepAspectRatio, Qt.SmoothTransformation) - frames.append(pixmap) - return frames - - -WALK = "walk" - -# Animations that aren't pipeline states. Walking is a property of *movement*, -# orthogonal to whether the pet is idle/listening/talking, so it deliberately -# isn't a PetState — state.py stays a description of the conversation, not of -# the body. Loaded the same way, keyed by name. -EXTRA_ANIMATIONS = (WALK,) - - -class SpriteSet: - """All animations for every PetState, plus the extras, from *sprite_dir*.""" - - def __init__(self, sprite_dir: Path = DEFAULT_SPRITE_DIR, size: int = 160): - self.size = size - self._animations: dict[str, SpriteAnimation] = {} - self._loaded: set[str] = set() # keys backed by real art, not placeholders - for state in PetState: - frames = _load_frames_from_dir(sprite_dir / state.value, size) - if frames: - self._loaded.add(state.value) - else: - frames = _placeholder_frames(state, size) - self._animations[state.value] = SpriteAnimation(frames) - for name in EXTRA_ANIMATIONS: - frames = _load_frames_from_dir(sprite_dir / name, size) - if frames: - self._loaded.add(name) - self._animations[name] = SpriteAnimation(frames) - - @staticmethod - def _key(key) -> str: - return key.value if isinstance(key, PetState) else str(key) - - def get(self, key) -> SpriteAnimation: - """Animation for a PetState or an extra name. Unknown/absent extras - fall back to idle, so a sprite folder with no walk/ still runs.""" - return self._animations.get(self._key(key)) or self._animations[PetState.IDLE.value] - - def has(self, key) -> bool: - """True only when real frames were found — the caller uses this to - decide whether to use an extra animation at all, rather than being - handed a placeholder blob that looks nothing like walking.""" - return self._key(key) in self._loaded diff --git a/bolt_pet/ui/tray.py b/bolt_pet/ui/tray.py deleted file mode 100644 index 113686c..0000000 --- a/bolt_pet/ui/tray.py +++ /dev/null @@ -1,161 +0,0 @@ -"""System tray icon — the pet window is frameless with no taskbar entry, so -this menu is the only always-available way to control or exit it: talk now, -mute, wander, click-through, nap, history, wake-word tuning, voice reset, -quit. - -Every entry is a plain callback passed in by ui/app.py; this file knows -nothing about the controller or the pet window. -""" - -from __future__ import annotations - -from typing import Callable, Optional - -from PySide6.QtCore import Qt -from PySide6.QtGui import QAction, QColor, QIcon, QPainter, QPixmap -from PySide6.QtWidgets import QMenu, QSystemTrayIcon - - -def _make_icon(muted: bool, napping: bool = False) -> QIcon: - pixmap = QPixmap(32, 32) - pixmap.fill(Qt.transparent) - painter = QPainter(pixmap) - painter.setRenderHint(QPainter.Antialiasing) - if muted: - color = QColor(200, 60, 60) - elif napping: - color = QColor(120, 120, 140) - else: - color = QColor(120, 170, 240) - painter.setBrush(color) - painter.setPen(Qt.NoPen) - painter.drawEllipse(2, 2, 28, 28) - painter.end() - return QIcon(pixmap) - - -class PetTray(QSystemTrayIcon): - def __init__( - self, - on_talk_now: Callable[[], None], - on_toggle_mute: Callable[[], bool], - on_quit: Callable[[], None], - on_set_wander: Optional[Callable[[bool], None]] = None, - wander_enabled: bool = True, - on_set_click_through: Optional[Callable[[bool], None]] = None, - click_through_enabled: bool = False, - on_set_nap: Optional[Callable[[bool], None]] = None, - on_show_history: Optional[Callable[[], None]] = None, - on_show_wake_tuner: Optional[Callable[[], None]] = None, - on_show_mic_picker: Optional[Callable[[], None]] = None, - on_reset_voice: Optional[Callable[[], None]] = None, - parent=None, - ): - super().__init__(_make_icon(muted=False), parent) - self._on_toggle_mute = on_toggle_mute - self._muted = False - self._napping = False - self.setToolTip("Bolt") - - menu = QMenu() - self._talk_action = QAction("Talk now", menu) - self._talk_action.triggered.connect(on_talk_now) - menu.addAction(self._talk_action) - - self._mute_action = QAction("Mute mic", menu) - self._mute_action.setCheckable(True) - self._mute_action.triggered.connect(self._handle_toggle_mute) - menu.addAction(self._mute_action) - - self._nap_action = None - if on_set_nap is not None: - self._nap_action = QAction("Nap (no proactive noise)", menu) - self._nap_action.setCheckable(True) - self._nap_action.triggered.connect(lambda checked: on_set_nap(checked)) - menu.addAction(self._nap_action) - - menu.addSeparator() - - if on_set_wander is not None: - self._wander_action = QAction("Wander around", menu) - self._wander_action.setCheckable(True) - self._wander_action.setChecked(wander_enabled) - self._wander_action.triggered.connect(lambda checked: on_set_wander(checked)) - menu.addAction(self._wander_action) - - if on_set_click_through is not None: - self._click_through_action = QAction("Click through the pet", menu) - self._click_through_action.setCheckable(True) - self._click_through_action.setChecked(click_through_enabled) - self._click_through_action.setToolTip( - "Ignore the mouse entirely — control it from this menu instead." - ) - self._click_through_action.triggered.connect(lambda checked: on_set_click_through(checked)) - menu.addAction(self._click_through_action) - - menu.addSeparator() - - if on_show_history is not None: - history_action = QAction("History…", menu) - history_action.triggered.connect(on_show_history) - menu.addAction(history_action) - - if on_show_wake_tuner is not None: - tuner_action = QAction("Wake word tuning…", menu) - tuner_action.triggered.connect(on_show_wake_tuner) - menu.addAction(tuner_action) - - if on_show_mic_picker is not None: - mic_action = QAction("Select microphone…", menu) - mic_action.triggered.connect(on_show_mic_picker) - menu.addAction(mic_action) - - # Only ever enabled while a server-picked voice (speak_as) is in use — - # it's the way back from "talk like a pirate", which nothing else - # undoes short of a restart. - self._voice_action = None - if on_reset_voice is not None: - self._voice_action = QAction("Use default voice", menu) - self._voice_action.setEnabled(False) - self._voice_action.triggered.connect(on_reset_voice) - menu.addAction(self._voice_action) - - menu.addSeparator() - quit_action = QAction("Quit", menu) - quit_action.triggered.connect(on_quit) - menu.addAction(quit_action) - - self.setContextMenu(menu) - self.show() - - def _handle_toggle_mute(self) -> None: - self._muted = self._on_toggle_mute() - self._mute_action.setChecked(self._muted) - self._refresh_icon() - - def set_voice(self, voice: str) -> None: - """Reflect the voice the controller is speaking in — a name (or id) - when the server picked one, "" for Bolt's own.""" - if self._voice_action is None: - return - self._voice_action.setEnabled(bool(voice)) - self._voice_action.setText( - f"Use default voice (now: {voice})" if voice else "Use default voice" - ) - - def set_napping(self, napping: bool) -> None: - """Reflect a nap the *controller* decided on (quiet hours, fullscreen, - or a petctl command) — not just ones clicked here.""" - self._napping = napping - if self._nap_action is not None: - self._nap_action.setChecked(napping) - self._refresh_icon() - - def _refresh_icon(self) -> None: - self.setIcon(_make_icon(self._muted, self._napping)) - if self._muted: - self.setToolTip("Bolt (muted)") - elif self._napping: - self.setToolTip("Bolt (napping)") - else: - self.setToolTip("Bolt") diff --git a/bolt_pet/ui/wake_tuner.py b/bolt_pet/ui/wake_tuner.py deleted file mode 100644 index a463b3b..0000000 --- a/bolt_pet/ui/wake_tuner.py +++ /dev/null @@ -1,118 +0,0 @@ -"""Wake-word sensitivity tuner. - -WAKE_WORD_THRESHOLD is otherwise a number you guess at in .env, restart, and -then test by saying "thunderbolt" at your computer repeatedly. This window -makes it evidence-based: a live peak-score readout while you talk, a rolling -list of near misses (frames that scored just under the threshold — i.e. the -times it *nearly* heard you), and a slider that takes effect immediately, -mid-listen, without a restart. - -The controller owns the threshold; this window is a view over it. -""" - -from __future__ import annotations - -import time -from typing import Callable - -from PySide6.QtCore import Qt, QTimer -from PySide6.QtWidgets import ( - QDialog, QHBoxLayout, QLabel, QListWidget, QPushButton, QSlider, QVBoxLayout, -) - -_SLIDER_SCALE = 100 # QSlider is integer-only; threshold is 0.00-1.00 - - -class WakeTunerWindow(QDialog): - def __init__( - self, - get_threshold: Callable[[], float], - set_threshold: Callable[[float], None], - get_stats: Callable[[], dict], - on_reset: Callable[[], None], - ): - super().__init__() - self._get_threshold = get_threshold - self._set_threshold = set_threshold - self._get_stats = get_stats - self._on_reset = on_reset - - self.setWindowTitle("Bolt — wake word tuning") - self.resize(460, 380) - - self._threshold_label = QLabel() - self._slider = QSlider(Qt.Horizontal) - self._slider.setRange(5, 99) - self._slider.setValue(int(round(get_threshold() * _SLIDER_SCALE))) - self._slider.valueChanged.connect(self._threshold_changed) - - self._peak_label = QLabel("Peak score since reset: —") - self._peak_label.setToolTip( - 'Say "thunderbolt" a few times and watch this. Set the threshold ' - "just below the peak you can hit reliably." - ) - - self._misses = QListWidget() - - reset_button = QPushButton("Reset stats") - reset_button.clicked.connect(self._reset) - close_button = QPushButton("Close") - close_button.clicked.connect(self.close) - close_button.setDefault(True) - - buttons = QHBoxLayout() - buttons.addWidget(reset_button) - buttons.addStretch(1) - buttons.addWidget(close_button) - - layout = QVBoxLayout(self) - layout.addWidget(self._threshold_label) - layout.addWidget(self._slider) - layout.addWidget(self._peak_label) - layout.addWidget(QLabel("Near misses (heard something, didn't quite fire):")) - layout.addWidget(self._misses) - layout.addLayout(buttons) - - # Polled rather than signal-driven: scores arrive ~12x/second on the - # audio thread, and a queued signal per frame to repaint a label is - # more traffic than this is worth. - self._timer = QTimer(self) - self._timer.timeout.connect(self.refresh) - self._update_threshold_label() - - def _threshold_changed(self, value: int) -> None: - self._set_threshold(value / _SLIDER_SCALE) - self._update_threshold_label() - - def _update_threshold_label(self) -> None: - threshold = self._slider.value() / _SLIDER_SCALE - self._threshold_label.setText( - f"Threshold: {threshold:.2f} (lower = more sensitive, more false triggers)" - ) - - def _reset(self) -> None: - self._on_reset() - self.refresh() - - def refresh(self) -> None: - stats = self._get_stats() or {} - peak = stats.get("peak", 0.0) - self._peak_label.setText(f"Peak score since reset: {peak:.3f}") - self._misses.clear() - for timestamp, score, threshold in reversed(stats.get("near_misses", [])): - when = time.strftime("%H:%M:%S", time.localtime(timestamp)) - self._misses.addItem(f"{when} scored {score:.3f} (threshold {threshold:.2f})") - if self._misses.count() == 0: - self._misses.addItem("Nothing yet — say the wake phrase a few times.") - - def show_refreshed(self) -> None: - self._slider.setValue(int(round(self._get_threshold() * _SLIDER_SCALE))) - self.refresh() - self.show() - self.raise_() - self.activateWindow() - self._timer.start(500) - - def closeEvent(self, event) -> None: - self._timer.stop() - super().closeEvent(event) diff --git a/bolt_pet/updater.py b/bolt_pet/updater.py deleted file mode 100644 index b41fec7..0000000 --- a/bolt_pet/updater.py +++ /dev/null @@ -1,323 +0,0 @@ -"""Self-update from the Gitea releases page. - -Polls `/releases/latest` for a tag newer than -``bolt_pet.__version__`` and, if there is one, moves the checkout to that tag -and restarts the pet. The install is expected to be a git clone (which is how -it's deployed), so "download the update" is just `git fetch` + `git checkout` -— atomic, and the previous ref is one command away if anything goes wrong. - -Safety rules, in the order they're enforced: - -1. **A dirty working tree is never touched.** Local edits are skipped over, - not stashed — the pet silently discarding your work-in-progress would be - far worse than running an old version. -2. **Everything after checkout is guarded.** Dependency install and an import - smoke test both run before the restart; if either fails, the checkout is - rolled back to the exact ref that was live before (branch name if we were - on one, otherwise the commit) and the deps reinstalled from it. -3. **The restart only happens once the new code imports.** So a broken - release costs you a rollback and a log line, not a pet that won't start. - -The git side goes through an injectable *run* callable — ``(args) -> -(returncode, output)`` — so the whole apply/rollback dance is unit-tested -against a fake git rather than a real repo. Version comparison and release -parsing are pure functions for the same reason. -""" - -from __future__ import annotations - -import os -import subprocess -import sys -from dataclasses import dataclass -from pathlib import Path -from typing import Callable, Optional, Tuple - -import requests - -from . import config - -# (returncode, combined stdout+stderr) -GitResult = Tuple[int, str] -GitRunner = Callable[[list], GitResult] - -_GIT_TIMEOUT_SECONDS = 300 - - -class UpdateError(Exception): - """Raised when an update can't be applied. If it's raised *after* the - checkout moved, the rollback has already run.""" - - -@dataclass(frozen=True) -class Release: - tag: str - name: str - body: str - prerelease: bool - - -# ── pure logic ─────────────────────────────────────────────────────────────── - - -def parse_version(tag: str) -> tuple: - """``"v1.2.3"`` -> ``(1, 2, 3)``. Leading "v" optional; a trailing - suffix ends the parse (``"1.2.3-beta1"`` -> ``(1, 2, 3)``), so a - prerelease of a version compares equal to it rather than sorting - randomly. Junk parses to ``()``, which is never newer than anything.""" - parts: list[int] = [] - for chunk in (tag or "").strip().lstrip("vV").split("."): - digits = "" - for char in chunk: - if not char.isdigit(): - break - digits += char - if not digits: - break - parts.append(int(digits)) - return tuple(parts) - - -def is_newer(candidate: str, current: str) -> bool: - """True if *candidate* is a strictly newer version than *current*. - Compares zero-padded, so 1.2 == 1.2.0 and 1.2.1 > 1.2.""" - new, old = parse_version(candidate), parse_version(current) - if not new: - return False - width = max(len(new), len(old)) - return new + (0,) * (width - len(new)) > old + (0,) * (width - len(old)) - - -def release_from_payload(payload: dict) -> Optional[Release]: - """Gitea's release JSON -> Release, or None if it's a draft or has no - tag. /releases/latest already excludes drafts and prereleases, but the - same parser is used for the full list.""" - if not isinstance(payload, dict) or payload.get("draft"): - return None - tag = str(payload.get("tag_name") or "").strip() - if not tag: - return None - return Release( - tag=tag, - name=str(payload.get("name") or tag), - body=str(payload.get("body") or ""), - prerelease=bool(payload.get("prerelease")), - ) - - -# ── talking to Gitea ───────────────────────────────────────────────────────── - - -def fetch_latest_release(api_url: str = None, token: str = None, timeout: float = 15.0) -> Optional[Release]: - """Newest published release, or None if the repo has no releases yet - (a fresh repo 404s here, which is not an error worth logging every hour). - Raises UpdateError if the server is unreachable or answers with junk.""" - api_url = (api_url if api_url is not None else config.UPDATE_REPO_API).rstrip("/") - if not api_url: - raise UpdateError("UPDATE_REPO_API is not set") - token = config.UPDATE_TOKEN if token is None else token - headers = {"Authorization": f"token {token}"} if token else {} - try: - response = requests.get(f"{api_url}/releases/latest", headers=headers, timeout=timeout) - except Exception as exc: - raise UpdateError(f"couldn't reach the releases API: {exc}") from exc - if response.status_code == 404: - return None - try: - response.raise_for_status() - payload = response.json() - except Exception as exc: - raise UpdateError(f"bad response from the releases API: {exc}") from exc - return release_from_payload(payload) - - -def check_for_update(current_version: str = None, **kwargs) -> Optional[Release]: - """The whole "is there anything new?" question in one call. Returns the - Release to move to, or None if we're already current.""" - from . import __version__ - - current = __version__ if current_version is None else current_version - release = fetch_latest_release(**kwargs) - if release is None or not is_newer(release.tag, current): - return None - return release - - -# ── git ────────────────────────────────────────────────────────────────────── - - -def git_runner(repo: Path = None) -> GitRunner: - repo = Path(repo or config.HERE) - - def run(args: list) -> GitResult: - try: - completed = subprocess.run( - ["git", *args], cwd=str(repo), capture_output=True, - text=True, timeout=_GIT_TIMEOUT_SECONDS, - ) - except Exception as exc: - return 1, f"git {' '.join(args)} failed to start: {exc}" - return completed.returncode, ((completed.stdout or "") + (completed.stderr or "")).strip() - - return run - - -def is_git_clone(run: GitRunner) -> bool: - return run(["rev-parse", "--git-dir"])[0] == 0 - - -def working_tree_dirty(run: GitRunner) -> bool: - code, output = run(["status", "--porcelain"]) - return code != 0 or bool(output.strip()) - - -def current_ref(run: GitRunner) -> str: - """The branch name if we're on one, else the commit SHA — i.e. whatever - `git checkout` needs to put things back exactly as they were.""" - code, output = run(["symbolic-ref", "--quiet", "--short", "HEAD"]) - if code == 0 and output.strip(): - return output.strip() - code, output = run(["rev-parse", "HEAD"]) - if code != 0 or not output.strip(): - raise UpdateError("couldn't work out the current git ref") - return output.strip() - - -def already_at_tag(run: GitRunner, tag: str) -> bool: - """True if HEAD is already the commit *tag* points at. - - Guards the loop you get when a release is cut without bumping - __version__ in the tagged commit: the checkout succeeds (it's a no-op), - the pet restarts, reads the same old __version__, sees the same "newer" - tag, and does it again — a restart every check, forever.""" - code, head = run(["rev-parse", "HEAD"]) - if code != 0 or not head.strip(): - return False - code, target = run(["rev-parse", f"tags/{tag}^{{commit}}"]) - if code != 0 or not target.strip(): - return False # tag isn't known locally yet, so we're certainly not on it - return head.strip() == target.strip() - - -def _requirements_changed(run: GitRunner, before: str, after: str) -> bool: - code, output = run(["diff", "--name-only", before, after, "--", "requirements.txt"]) - return code == 0 and bool(output.strip()) - - -def _install_deps(repo: Path) -> None: - completed = subprocess.run( - [sys.executable, "-m", "pip", "install", "-r", "requirements.txt"], - cwd=str(repo), capture_output=True, text=True, timeout=_GIT_TIMEOUT_SECONDS, - ) - if completed.returncode != 0: - raise UpdateError(f"pip install failed: {(completed.stderr or '')[-500:]}") - - -def _smoke_test(repo: Path) -> None: - """Import the freshly checked-out package in a *subprocess* — this one - still has the old modules loaded, so importing here would prove nothing. - Catches the common broken release (syntax error, missing dependency) - before we hand the session over to it.""" - completed = subprocess.run( - [sys.executable, "-c", "import bolt_pet; import bolt_pet.controller"], - cwd=str(repo), capture_output=True, text=True, timeout=120, - ) - if completed.returncode != 0: - raise UpdateError(f"the new version failed to import: {(completed.stderr or '')[-500:]}") - - -def apply_update( - tag: str, - run: GitRunner = None, - repo: Path = None, - on_log: Callable[[str], None] = lambda _msg: None, - install_deps: bool = None, - verify: Callable[[Path], None] = None, -) -> str: - """Move the checkout to *tag*, rolling back to where it was if anything - downstream of the checkout fails. Returns the ref we came from (handy for - logging / a manual `git checkout` back). Raises UpdateError otherwise.""" - repo = Path(repo or config.HERE) - run = run or git_runner(repo) - install_deps = config.UPDATE_INSTALL_DEPS if install_deps is None else install_deps - verify = _smoke_test if verify is None else verify - - if not is_git_clone(run): - raise UpdateError("not a git clone — auto-update only works on a git checkout") - if working_tree_dirty(run): - raise UpdateError("working tree has local changes — skipping (nothing was touched)") - - previous = current_ref(run) - code, output = run(["fetch", "--tags", "--prune", config.UPDATE_GIT_REMOTE]) - if code != 0: - raise UpdateError(f"git fetch failed: {output}") - - if already_at_tag(run, tag): - from . import __version__ - - raise UpdateError( - f"already checked out {tag}, but __version__ still reads {__version__} — " - f"bump it in the tagged commit, or every check re-applies the same release" - ) - - code, output = run(["checkout", "--force", f"tags/{tag}"]) - if code != 0: - raise UpdateError(f"git checkout {tag} failed: {output}") - on_log(f"Checked out {tag} (was {previous}).") - - # Past this point every failure has to put the checkout back. - try: - if install_deps and _requirements_changed(run, previous, f"tags/{tag}"): - on_log("requirements.txt changed — installing.") - _install_deps(repo) - verify(repo) - except UpdateError as exc: - _rollback(run, previous, repo, on_log, install_deps) - raise UpdateError(f"{exc} — rolled back to {previous}") from exc - except Exception as exc: # a verify() that blows up is still a failed update - _rollback(run, previous, repo, on_log, install_deps) - raise UpdateError(f"update failed ({exc}) — rolled back to {previous}") from exc - - return previous - - -def _rollback( - run: GitRunner, - previous: str, - repo: Path, - on_log: Callable[[str], None], - install_deps: bool, -) -> None: - """Best-effort return to *previous*. Never raises — it's already running - inside a failure path, and the caller's UpdateError is the thing worth - surfacing. A rollback that itself fails gets its own loud log line, - because that's the one case needing a human.""" - code, output = run(["checkout", "--force", previous]) - if code != 0: - on_log(f"ROLLBACK FAILED — the checkout is stranded. Run: git checkout {previous} ({output})") - return - on_log(f"Rolled back to {previous}.") - if install_deps: - try: - _install_deps(repo) - except Exception as exc: - on_log(f"Rolled back, but reinstalling the old requirements failed: {exc}") - - -# ── restart ────────────────────────────────────────────────────────────────── - - -def restart() -> None: - """Replace this process with a fresh `python -m bolt_pet`. - - execv rather than spawn-and-exit so there's no window with two pets - holding the same mic, and no orphan if the parent dies first. Never - returns when it works; callers should have shut the Qt app and released - the audio device before calling it. - - chdir first because `-m bolt_pet` resolves the package from the working - directory: the pet may well have been launched from somewhere else - (autostart entry, run.sh invoked by path), and the new process has to - land on the checkout the update was just applied to.""" - os.chdir(str(config.HERE)) - os.execv(sys.executable, [sys.executable, "-m", "bolt_pet"]) diff --git a/bolt_pet/window_state.py b/bolt_pet/window_state.py deleted file mode 100644 index ca45fc4..0000000 --- a/bolt_pet/window_state.py +++ /dev/null @@ -1,75 +0,0 @@ -"""Where the pet was left, so it starts there next time. - -Listed in the README as a known limitation: drag it somewhere deliberate, and -the next launch puts it back in the bottom-right corner. For something that -lives on your desktop all day that is a small daily annoyance, and it is a -config write on drag-end. - -Lives in the cache dir rather than the repo, next to the restart context: it -is per-machine state about this install, not something that belongs in a git -diff. Every function swallows its own errors — a corrupt or unwritable state -file must never stop the pet from starting, it just means the default corner. - -Positions are validated against the *current* screen layout on load, because -the common case for a stale position is exactly the case where it is -dangerous: the pet was last on a monitor that is now unplugged, and restoring -it faithfully would put it somewhere you cannot see or reach. -""" - -from __future__ import annotations - -import json -import logging -import os -import tempfile -from pathlib import Path -from typing import Optional - -logger = logging.getLogger("bolt_pet.window_state") - -DEFAULT_PATH = Path.home() / ".cache" / "bolt-pet" / "window.json" - - -def load(path: Optional[Path] = None) -> Optional[tuple[int, int]]: - """The saved position, or None if there isn't a usable one.""" - try: - data = json.loads(Path(path or DEFAULT_PATH).read_text(encoding="utf-8")) - return int(data["x"]), int(data["y"]) - except (FileNotFoundError, json.JSONDecodeError, KeyError, TypeError, ValueError, OSError): - return None - - -def save(x: int, y: int, path: Optional[Path] = None) -> None: - """Remember where it is now. Atomic, so a crash mid-write can't leave a - half-file that makes the next start fall back to the corner.""" - target = Path(path or DEFAULT_PATH) - try: - target.parent.mkdir(parents=True, exist_ok=True) - descriptor, temp_path = tempfile.mkstemp(dir=target.parent, prefix=".window_", suffix=".tmp") - try: - with os.fdopen(descriptor, "w", encoding="utf-8") as handle: - json.dump({"x": int(x), "y": int(y)}, handle) - os.replace(temp_path, target) - except BaseException: - try: - os.unlink(temp_path) - except OSError: - pass - raise - except Exception: - logger.debug("Could not save the window position", exc_info=True) - - -def is_visible_on(x: int, y: int, size: int, rectangles) -> bool: - """Whether that position still lands on a screen that exists. - - *rectangles* are (left, top, right, bottom) tuples — the caller's job, - because this module has no business importing Qt. Requires a real overlap - rather than a touching edge, so a pet saved flush against the boundary of a - monitor that has since been unplugged is not counted as reachable.""" - for left, top, right, bottom in rectangles: - overlap_x = min(x + size, right) - max(x, left) - overlap_y = min(y + size, bottom) - max(y, top) - if overlap_x > size * 0.25 and overlap_y > size * 0.25: - return True - return False diff --git a/launch.py b/launch.py new file mode 100644 index 0000000..3286462 --- /dev/null +++ b/launch.py @@ -0,0 +1,12 @@ +"""Frozen-app launcher. + +PyInstaller compiles this as a top-level script, so it must NOT use relative +imports. It imports the package by name and hands off to bolt_console.__main__. +""" + +import sys + +import bolt_console.__main__ as entry + +if __name__ == "__main__": + sys.exit(entry.main()) diff --git a/requirements.txt b/requirements.txt index 55c1ccd..bb79dfd 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,54 +1,13 @@ -# Desktop UI +# HTTP client to the Bolt desk API. +requests>=2.31 + +# The window (python -m bolt_console). Not needed for --cli. PySide6>=6.6 -# Audio I/O (mic capture + speaker playback) — cross-platform via PortAudio. -# On Linux you may also need the system package: sudo apt install libportaudio2 +# Dictation (the mic button): mic capture + the server's /desk/stt websocket. +# Optional: without them the mic button explains what's missing. sounddevice>=0.4.6 -numpy>=1.24 - -# HTTP client to the Bolt desk API -requests>=2.31 -# Speech-to-text, via the server's /desk/stt websocket relay (audio/stt.py, -# audio/stt_stream.py) — there is no separate REST fallback, so this is now -# load-bearing for transcription to work at all, not just for the -# transcribe-while-talking optimisation STT_STREAMING controls. websocket-client>=1.7 -# Wake-word detection (local, offline after first run) — runs the -# custom-trained thunderbolt.onnx model shipped in this repo, same runtime -# as the main repo's desk_client/bolt_desk.py (bolt.onnx). First use -# downloads openwakeword's feature-extraction sub-models (~few MB, cached -# under the package's own resources/ dir afterward) — needs internet once. -openwakeword - -# Offline TTS fallback if the server call isn't configured or fails. -# Uses SAPI5 on Windows, NSSpeechSynthesizer on macOS, espeak on Linux -# (Linux also needs: sudo apt install espeak-ng). -pyttsx3>=2.90 - -# Global push-to-talk hotkey (PUSH_TO_TALK_HOTKEY). Optional: the pet -# degrades to wake-word + tray + click if it's missing or if the session -# won't allow a global key hook (most Wayland setups; macOS needs -# Accessibility permission). -pynput>=1.7 - -# Optional — only needed for scripts/slice_spritesheet.py (converting a -# grid sprite sheet into the per-frame-PNG convention sprite.py expects). -# Not imported by the app itself. -Pillow>=10.0 - -# Screen reading (`petctl read`) — Bolt OCRs a monitor and uses the text in -# his reply. Both optional: without them `petctl read` reports what's missing -# and the rest of the pet is unaffected. -# mss screen capture. X11/Win32/macOS — NOT Wayland. -# pytesseract a thin wrapper; the actual engine is a system package: -# sudo apt install tesseract-ocr -# No-sudo alternative to those two lines: pip install rapidocr-onnxruntime -# (pure pip, reuses the onnxruntime openwakeword already pulls in, slower to -# start). screen_text.resolve_engine() picks whichever is present. -mss>=9.0 -pytesseract>=0.3.10 - -# Test runner (tests/ — pure logic, no audio hardware or display needed; -# run with QT_QPA_PLATFORM=offscreen). +# Test runner (tests/ — no real server or terminal needed). pytest>=8.0 diff --git a/run-cli.bat b/run-cli.bat new file mode 100644 index 0000000..2f913d4 --- /dev/null +++ b/run-cli.bat @@ -0,0 +1,14 @@ +@echo off +REM The terminal version of Bolt Console. Starts in the directory you ran it +REM from, so that is the working directory Bolt's commands and paths use. +setlocal +set "ROOT=%~dp0" + +if not exist "%ROOT%.venv" ( + python -m venv "%ROOT%.venv" + "%ROOT%.venv\Scripts\python" -m pip install --upgrade pip + "%ROOT%.venv\Scripts\python" -m pip install -r "%ROOT%requirements.txt" +) + +set "PYTHONPATH=%ROOT%;%PYTHONPATH%" +"%ROOT%.venv\Scripts\python" -m bolt_console --cli %* diff --git a/run.bat b/run.bat index 599df02..4748fcb 100644 --- a/run.bat +++ b/run.bat @@ -1,11 +1,16 @@ @echo off -REM Convenience launcher for Windows. -cd /d "%~dp0" +REM Opens the Bolt window (no console left behind). For the terminal version +REM use run-cli.bat. Bolt starts in the directory you ran this from; change it +REM from the folder button in the window. +setlocal +set "ROOT=%~dp0" -if not exist .venv ( - python -m venv .venv - .venv\Scripts\pip install --upgrade pip - .venv\Scripts\pip install -r requirements.txt +if not exist "%ROOT%.venv" ( + echo First run: creating .venv and installing requirements... + python -m venv "%ROOT%.venv" + "%ROOT%.venv\Scripts\python" -m pip install --upgrade pip + "%ROOT%.venv\Scripts\python" -m pip install -r "%ROOT%requirements.txt" ) -.venv\Scripts\python -m bolt_pet +set "PYTHONPATH=%ROOT%;%PYTHONPATH%" +start "" "%ROOT%.venv\Scripts\pythonw.exe" -m bolt_console %* diff --git a/run.sh b/run.sh index 3fe538e..389edc2 100755 --- a/run.sh +++ b/run.sh @@ -1,12 +1,15 @@ #!/usr/bin/env bash -# Convenience launcher for macOS/Linux. +# Launcher for macOS/Linux: the window by default, ./run.sh --cli for the +# terminal version. Starts in the directory you ran it from, so that is +# the working directory Bolt's commands and relative paths use. set -euo pipefail -cd "$(dirname "$0")" +ROOT="$(cd "$(dirname "$0")" && pwd)" -if [ ! -d .venv ]; then - python3 -m venv .venv - ./.venv/bin/pip install --upgrade pip - ./.venv/bin/pip install -r requirements.txt +if [ ! -d "$ROOT/.venv" ]; then + python3 -m venv "$ROOT/.venv" + "$ROOT/.venv/bin/pip" install --upgrade pip + "$ROOT/.venv/bin/pip" install -r "$ROOT/requirements.txt" fi -exec ./.venv/bin/python -m bolt_pet +export PYTHONPATH="$ROOT${PYTHONPATH:+:$PYTHONPATH}" +exec "$ROOT/.venv/bin/python" -m bolt_console "$@" diff --git a/scripts/generate_bolt_sprites.py b/scripts/generate_bolt_sprites.py deleted file mode 100644 index 53b8ea5..0000000 --- a/scripts/generate_bolt_sprites.py +++ /dev/null @@ -1,807 +0,0 @@ -"""Draw Bolt — the pet — as per-state PNG frame sequences. - -Produces the `assets/sprites//frame_NN.png` convention that -`bolt_pet/ui/sprite.py` loads (see `assets/sprites/README.md`). The art is -generated rather than sourced so it stays editable: tweak a colour or a pose -parameter here and re-run, instead of hand-editing 24 PNGs. - - python scripts/generate_bolt_sprites.py # write into the real asset dir - python scripts/generate_bolt_sprites.py --out /tmp/prev # preview somewhere else - -Everything is drawn in normalised 0..1 coordinates on a square canvas and -super-sampled `SS`x before being downscaled, because PIL's draw primitives -have no antialiasing of their own. -""" - -from __future__ import annotations - -import argparse -import math -from pathlib import Path - -from PIL import Image, ImageDraw - -SS = 4 # supersampling factor -OUT = 320 # final frame size (2x the default PET_SIZE of 160) -S = OUT * SS - -# --- palette --------------------------------------------------------------- -# A cream shepherd-ish pup with a slate cap, amber eyes and a lightning blaze. -C_OUTLINE = (34, 42, 58, 255) -C_FUR = (246, 244, 238, 255) -C_FUR_SHADE = (214, 210, 200, 255) -C_DARK = (78, 92, 122, 255) -C_DARK2 = (58, 70, 96, 255) -C_INNER_EAR = (226, 154, 158, 255) -C_BROW = (206, 166, 118, 255) -# The far side of the walking pose. Distinctly darker than C_FUR_SHADE, which -# is too close to the cream to read as "behind the dog" at 160px. -C_FUR_FAR = (168, 176, 192, 255) -C_NOSE = (40, 48, 66, 255) -C_IRIS = (196, 128, 50, 255) -C_PUPIL = (30, 36, 50, 255) -C_WHITE = (255, 255, 255, 255) -C_BOLT = (255, 206, 61, 255) -C_COLLAR = (222, 84, 46, 255) -C_TAG = (255, 198, 68, 255) -C_TONGUE = (230, 116, 128, 255) -C_GLOW = (92, 214, 244, 255) - -# --- layout constants (normalised) ----------------------------------------- -HEAD_CX, HEAD_CY = 0.50, 0.375 -HEAD_W, HEAD_H = 0.50, 0.44 -NECK_Y = 0.565 # head layer rotates about here so tilts pivot at the neck -EAR_PIVOT = 0.335, 0.275 - -OW = 0.0105 # outline width, normalised - - -def px(v: float) -> float: - return v * S - - -def _w(width: float) -> int: - return max(1, int(round(px(width)))) - - -def ell(d, cx, cy, w, h, fill, outline=C_OUTLINE, ow=OW): - d.ellipse( - [px(cx - w / 2), px(cy - h / 2), px(cx + w / 2), px(cy + h / 2)], - fill=fill, - outline=outline, - width=_w(ow) if outline else 0, - ) - - -def rrect(d, cx, cy, w, h, r, fill, outline=C_OUTLINE, ow=OW): - d.rounded_rectangle( - [px(cx - w / 2), px(cy - h / 2), px(cx + w / 2), px(cy + h / 2)], - radius=px(r), - fill=fill, - outline=outline, - width=_w(ow) if outline else 0, - ) - - -def poly(d, pts, fill, outline=C_OUTLINE, ow=OW): - d.polygon( - [(px(x), px(y)) for x, y in pts], - fill=fill, - outline=outline, - width=_w(ow) if outline else 0, - ) - - -def rotate_pts(pts, pivot, deg): - a = math.radians(deg) - ca, sa = math.cos(a), math.sin(a) - ox, oy = pivot - out = [] - for x, y in pts: - dx, dy = x - ox, y - oy - out.append((ox + dx * ca - dy * sa, oy + dx * sa + dy * ca)) - return out - - -def lerp(a, b, t): - return a + (b - a) * t - - -def bolt_shape(cx, cy, w, h): - """A lightning bolt polygon in a (w x h) box centred on (cx, cy).""" - unit = [ - (0.62, 0.00), - (0.10, 0.56), - (0.44, 0.56), - (0.28, 1.00), - (0.90, 0.40), - (0.55, 0.40), - (0.80, 0.00), - ] - return [(cx + (u - 0.5) * w, cy + (v - 0.5) * h) for u, v in unit] - - -# --- body ------------------------------------------------------------------ -def _tail_points(p, steps=26): - """Quadratic-bezier spine of the tail as (x, y, radius) samples. - - Shared by the fill and outline passes so a wag can't move one and not the - other. The base sits deep inside the haunch, which is drawn over it, so - the tail reads as growing out of the body rather than floating beside it. - """ - wag = p["tail"] - base = (0.620, 0.845) - ctrl = (0.955, 0.870 - 0.025 * wag) - end = (0.905, 0.605 - 0.065 * wag) - pts = [] - for i in range(steps + 1): - t = i / steps - x = (1 - t) ** 2 * base[0] + 2 * (1 - t) * t * ctrl[0] + t**2 * end[0] - y = (1 - t) ** 2 * base[1] + 2 * (1 - t) * t * ctrl[1] + t**2 * end[1] - pts.append((x, y, lerp(0.080, 0.042, t))) - return pts - - -def draw_tapered(d, pts, color_at): - """Draw a tapered limb from (x, y, radius) samples. - - Two passes: circles along the spine for the fill, then the two silhouette - edges, so it reads as one solid shape instead of a string of beads. - *color_at* takes 0..1 along the length, which is how the tail gets its - cream tip. - """ - last = len(pts) - 1 - for i, (x, y, r) in enumerate(pts): - ell(d, x, y, r * 2, r * 2, color_at(i / last), outline=None) - for side in (1, -1): - edge = [] - for i, (x, y, r) in enumerate(pts): - j = min(i + 1, last) - k = max(i - 1, 0) - tx, ty = pts[j][0] - pts[k][0], pts[j][1] - pts[k][1] - n = math.hypot(tx, ty) or 1e-6 - nx, ny = -ty / n, tx / n - edge.append((x + nx * r * side, y + ny * r * side)) - d.line([(px(x), px(y)) for x, y in edge], fill=C_OUTLINE, width=_w(OW), joint="curve") - x, y, r = pts[last] - ell(d, x, y, r * 2, r * 2, color_at(1.0)) - - -def draw_tail(d, p): - # Only the last stretch is the cream tip. The haunch hides the first ~half - # of the tail, so a generous tip leaves the visible part looking like a - # pale blob floating next to the dog rather than its tail. - draw_tapered(d, _tail_points(p), lambda t: C_DARK if t < 0.84 else C_FUR) - - -def draw_body(d, p): - br = p["breathe"] - # haunches (sitting) - ell(d, 0.285, 0.795, 0.235, 0.275, C_DARK) - ell(d, 0.715, 0.795, 0.235, 0.275, C_DARK) - # torso - ell(d, 0.50, 0.745 - 0.004 * br, 0.455 + 0.012 * br, 0.395 + 0.014 * br, C_DARK) - # front legs - for cx in (0.415, 0.585): - rrect(d, cx, 0.845, 0.125, 0.215, 0.062, C_FUR) - ell(d, cx, 0.925, 0.155, 0.095, C_FUR) - # chest / belly blaze - ell(d, 0.50, 0.735 - 0.004 * br, 0.275 + 0.008 * br, 0.315 + 0.012 * br, C_FUR) - # toes - for cx in (0.415, 0.585): - for off in (-0.035, 0.0, 0.035): - d.arc( - [px(cx + off - 0.017), px(0.902), px(cx + off + 0.017), px(0.945)], - start=250, - end=290, - fill=C_FUR_SHADE, - width=_w(0.007), - ) - - -def draw_collar(d, p): - rrect(d, 0.50, 0.585, 0.315, 0.062, 0.031, C_COLLAR) - tag = C_GLOW if p.get("tag_glow") else C_TAG - ell(d, 0.50, 0.638, 0.082, 0.082, tag) - poly(d, bolt_shape(0.50, 0.638, 0.030, 0.052), C_OUTLINE, outline=None) - - -# --- head ------------------------------------------------------------------ -# Ear outline in a *local* frame: origin at the base on the skull, +x points -# outward (away from the muzzle), +y points up. Keeping it side-agnostic here -# and mirroring at draw time avoids sign confusion — an earlier version mixed -# the conventions and the ears flattened into a brim whenever they rotated. -_EAR_LOCAL = [ - (-0.058, -0.038), - (0.078, -0.038), - (0.092, 0.140), - (0.030, 0.248), - (-0.038, 0.122), -] - - -def _ear_polygon(side, lean_deg): - """Mirror + lean the local ear, returning canvas-space points. - - *lean_deg* tips the ear away from vertical: 0 is fully perked, larger - values relax and eventually droop it out sideways. - """ - a = math.radians(lean_deg) - ca, sa = math.cos(a), math.sin(a) - pivot_x = 0.5 + side * (0.5 - EAR_PIVOT[0]) - pts = [] - for x, y in _EAR_LOCAL: - rx = x * ca + y * sa - ry = -x * sa + y * ca - pts.append((pivot_x + side * rx, EAR_PIVOT[1] - ry)) - return pts - - -def draw_ears(d, p): - perk = p["ear"] - twitch = p.get("ear_twitch", 0.0) - for side in (-1, 1): - lean = 18.0 * (1.0 - perk) + 44.0 * max(0.0, -perk) - if side == 1: - lean -= twitch * 12.0 - pts = _ear_polygon(side, lean) - poly(d, pts, C_DARK) - base_mid = ( - (pts[0][0] + pts[1][0]) / 2, - (pts[0][1] + pts[1][1]) / 2, - ) - inner = [(lerp(base_mid[0], x, 0.60), lerp(base_mid[1], y, 0.64)) for x, y in pts] - poly(d, inner, C_INNER_EAR, outline=None) - - -def draw_cap(layer, p): - """Slate cap over the top of the head, clipped to the head silhouette.""" - mask = Image.new("L", (S, S), 0) - ImageDraw.Draw(mask).ellipse( - [ - px(HEAD_CX - HEAD_W / 2), - px(HEAD_CY - HEAD_H / 2), - px(HEAD_CX + HEAD_W / 2), - px(HEAD_CY + HEAD_H / 2), - ], - fill=255, - ) - cap = Image.new("RGBA", (S, S), (0, 0, 0, 0)) - dc = ImageDraw.Draw(cap) - ell(dc, HEAD_CX, 0.245, 0.54, 0.30, C_DARK, outline=None) - # brow dip between the eyes, so the cap reads as a marking not a helmet - ell(dc, HEAD_CX, 0.352, 0.155, 0.115, C_FUR, outline=None) - cap.putalpha(Image.composite(cap.getchannel("A"), Image.new("L", (S, S), 0), mask)) - layer.alpha_composite(cap) - - -def draw_eyes(d, p): - blink = p["blink"] - lx, ly = 0.383, 0.372 - rx, ry = 0.617, 0.372 - dx, dy = p.get("look", (0.0, 0.0)) - for cx, cy in ((lx, ly), (rx, ry)): - if p.get("cross"): - for ang in (45, -45): - a = math.radians(ang) - hx, hy = 0.042 * math.cos(a), 0.042 * math.sin(a) - d.line( - [px(cx - hx), px(cy - hy), px(cx + hx), px(cy + hy)], - fill=C_OUTLINE, - width=_w(0.014), - ) - continue - if blink > 0.55: - d.arc( - [px(cx - 0.052), px(cy - 0.030), px(cx + 0.052), px(cy + 0.040)], - start=200, - end=340, - fill=C_OUTLINE, - width=_w(0.013), - ) - continue - h = lerp(0.118, 0.030, blink) - ell(d, cx, cy, 0.106, h, C_WHITE) - if h > 0.06: - ell(d, cx + dx, cy + dy * 0.6, 0.082, min(h - 0.022, 0.092), C_IRIS, outline=None) - ell(d, cx + dx, cy + dy * 0.6, 0.046, min(h - 0.045, 0.056), C_PUPIL, outline=None) - ell(d, cx + dx - 0.020, cy + dy * 0.6 - 0.024, 0.030, 0.026, C_WHITE, outline=None) - # Tan brow dots on the slate cap (the shepherd/doberman marking) rather - # than dashes — as lines above the eyes they read as heavy eyelids and - # make an idle pet look permanently fed up. - raise_ = p.get("brow", 0.0) - angry = p.get("brow_angle", 0.0) - for side, cx in ((-1, lx), (1, rx)): - by = 0.291 - 0.020 * raise_ - ell( - d, - cx + side * 0.006, - by + side * angry * 0.020, - 0.062, - 0.040, - C_BROW, - outline=None, - ) - - -def draw_muzzle(d, p): - mouth = p["mouth"] - ell(d, 0.50, 0.487, 0.285, 0.195, C_FUR) - # nose - ell(d, 0.50, 0.440, 0.105, 0.078, C_NOSE, outline=None) - ell(d, 0.478, 0.428, 0.030, 0.020, (92, 102, 124, 255), outline=None) - if mouth > 0.02: - h = 0.030 + 0.085 * mouth - w = 0.105 + 0.055 * mouth - ell(d, 0.50, 0.500 + h / 2 - 0.008, w, h, C_NOSE) - ell(d, 0.50, 0.500 + h * 0.72, w * 0.60, h * 0.52, C_TONGUE, outline=None) - else: - # closed muzzle: a short philtrum down from the nose into two - # downward-bulging curves (PIL arcs run clockwise from 3 o'clock with - # y down, so 0->180 is the lower half — the smiling side). - d.line([px(0.50), px(0.470), px(0.50), px(0.508)], fill=C_OUTLINE, width=_w(0.011)) - for side in (-1, 1): - cx = 0.50 + side * 0.032 - d.arc( - [px(cx - 0.032), px(0.492), px(cx + 0.032), px(0.536)], - start=0, - end=180, - fill=C_OUTLINE, - width=_w(0.011), - ) - - -def draw_head(layer, p): - d = ImageDraw.Draw(layer) - draw_ears(d, p) - ell(d, HEAD_CX, HEAD_CY, HEAD_W, HEAD_H, C_FUR) - draw_cap(layer, p) - # blaze - poly(d, bolt_shape(0.50, 0.243, 0.088, 0.150), C_BOLT, outline=None) - draw_muzzle(d, p) - draw_eyes(d, p) - - -# --- extras ---------------------------------------------------------------- -def draw_extras(layer, p): - d = ImageDraw.Draw(layer) - kind = p.get("extras") - if kind == "listen": - for i in range(3): - r = 0.045 + i * 0.036 - alpha = int(210 - i * 55) - phase = p.get("phase", 0) - if (phase + i) % 3 == 0: - alpha = min(255, alpha + 45) - d.arc( - [px(0.845 - r), px(0.235 - r), px(0.845 + r), px(0.235 + r)], - start=200, - end=340, - fill=C_GLOW[:3] + (alpha,), - width=_w(0.014), - ) - elif kind == "think": - phase = p.get("phase", 0) - for i in range(3): - grow = 1.0 if i == phase % 3 else 0.62 - ell( - layer_d := d, - 0.735 + i * 0.072, - 0.145 - i * 0.030, - 0.040 * grow, - 0.040 * grow, - C_GLOW, - outline=C_OUTLINE, - ow=0.008, - ) - elif kind == "error": - poly(d, bolt_shape(0.815, 0.185, 0.070, 0.120), (235, 92, 74, 255)) - - -# --- side view: the walk cycle --------------------------------------------- -# The pose above is a front-facing sit, which is right for standing around but -# slides like a chess piece the moment the pet actually moves. Walking gets its -# own construction: a profile torso, four legs following a paw path, and a head -# side-on. Drawn facing RIGHT — ui/pet_window.py mirrors it when he walks left. - -_GROUND = 0.930 # paw centre while a foot is planted -_STRIDE = 0.088 # how far ahead of / behind the pivot a paw reaches -_LIFT = 0.080 # peak height of a paw mid-swing -_STANCE = 0.62 # fraction of the cycle a foot spends on the ground - -FRONT_PIVOT = (0.650, 0.620) -HIND_PIVOT = (0.315, 0.640) -WALK_HEAD = (0.780, 0.370, 0.260, 0.250) # cx, cy, w, h - - -def paw_position(pivot, phase): - """Where one paw is at *phase* (0..1) of the cycle. - - Stance is the half that matters: the foot is planted and travels backwards - under the dog at a constant rate. The window advances this cycle by - distance travelled rather than by clock, so that backwards travel cancels - the forward motion and the feet don't skate. - """ - phase %= 1.0 - if phase < _STANCE: - t = phase / _STANCE - return pivot[0] + _STRIDE - 2 * _STRIDE * t, _GROUND - t = (phase - _STANCE) / (1.0 - _STANCE) - return ( - pivot[0] - _STRIDE + 2 * _STRIDE * t, - _GROUND - _LIFT * math.sin(math.pi * t), - ) - - -def draw_leg(d, pivot, paw, fill, bend=0.032, top=0.052, toe=0.030): - """A limb from pivot to paw: a bezier through a displaced knee, tapered - from thigh to ankle. - - Tapering matters more than it sounds — a constant-width limb reads as a - length of white pipe, and four of them make the dog look like furniture. - """ - vx, vy = paw[0] - pivot[0], paw[1] - pivot[1] - length = math.hypot(vx, vy) or 1e-6 - nx, ny = -vy / length, vx / length # perpendicular; points backwards - knee = ( - (pivot[0] + paw[0]) / 2 + nx * bend, - (pivot[1] + paw[1]) / 2 + ny * bend, - ) - pts = [] - for i in range(13): - t = i / 12 - x = (1 - t) ** 2 * pivot[0] + 2 * (1 - t) * t * knee[0] + t**2 * paw[0] - y = (1 - t) ** 2 * pivot[1] + 2 * (1 - t) * t * knee[1] + t**2 * paw[1] - pts.append((x, y, lerp(top, toe, t))) - draw_tapered(d, pts, lambda _t: fill) - ell(d, paw[0], paw[1] + 0.008, 0.098, 0.056, fill) - - -def draw_walk_tail(d, p, dy): - """A curled plume over the back. - - Cubic rather than quadratic: a single control point can only bend one way, - which gives a straight tapered tube — a club with a white ball on the end, - not a tail. The curl back over the spine is what makes it read. - """ - wag = p["tail"] - base = (0.250, 0.575 + dy) - c1 = (0.075, 0.545 + dy - 0.030 * wag) - c2 = (0.070, 0.300 + dy - 0.040 * wag) - end = (0.215, 0.290 + dy - 0.020 * wag) - pts = [] - for i in range(29): - t = i / 28 - u = 1 - t - x = u**3 * base[0] + 3 * u**2 * t * c1[0] + 3 * u * t**2 * c2[0] + t**3 * end[0] - y = u**3 * base[1] + 3 * u**2 * t * c1[1] + 3 * u * t**2 * c2[1] + t**3 * end[1] - pts.append((x, y, lerp(0.076, 0.028, t))) - draw_tapered(d, pts, lambda t: C_DARK if t < 0.90 else C_FUR) - - -def draw_torso(d, dy): - """Rump + barrel + chest as one silhouette. - - Drawn in two passes — every shape swollen by the stroke width in the - outline colour, then every shape again at true size in the fill. Outlining - each piece individually instead leaves the construction arcs showing - across the body, which looks like the dog has panel lines. - """ - shapes = [ - ("ell", 0.300, 0.600 + dy, 0.290, 0.300, 0.0), - ("rrect", 0.480, 0.585 + dy, 0.520, 0.265, 0.130), - ("ell", 0.650, 0.590 + dy, 0.250, 0.280, 0.0), - ] - grow = 2 * OW - for colour, pad in ((C_OUTLINE, grow), (C_DARK, 0.0)): - for shape in shapes: - kind, cx, cy, w, h, extra = shape - if kind == "ell": - ell(d, cx, cy, w + pad, h + pad, colour, outline=None) - else: - rrect(d, cx, cy, w + pad, h + pad, extra + pad / 2, colour, outline=None) - # Belly kept small and low: any bigger and it merges with the cream legs - # into one white mass with a slate lid. - ell(d, 0.490, 0.672 + dy, 0.350, 0.098, C_FUR, outline=None) - - -def draw_walk_head(layer, p, dy): - d = ImageDraw.Draw(layer) - cx, cy, w, h = WALK_HEAD[0], WALK_HEAD[1] + dy, WALK_HEAD[2], WALK_HEAD[3] - - # ear first, so the head covers its base - bounce = p.get("ear_bounce", 0.0) - ear = [ - (0.690, cy - 0.030), - (0.700, cy - 0.150 - 0.012 * bounce), - (0.752, cy - 0.205 - 0.018 * bounce), - (0.788, cy - 0.090), - ] - poly(d, ear, C_DARK) - inner = [(lerp(0.735, x, 0.58), lerp(cy - 0.040, y, 0.62)) for x, y in ear] - poly(d, inner, C_INNER_EAR, outline=None) - - # neck into the chest - d.line( - [px(0.660), px(cy + 0.190), px(0.735), px(cy + 0.080)], - fill=C_OUTLINE, - width=_w(0.215), - joint="curve", - ) - d.line( - [px(0.660), px(cy + 0.190), px(0.735), px(cy + 0.080)], - fill=C_DARK, - width=_w(0.190), - joint="curve", - ) - - ell(d, cx, cy, w, h, C_FUR) - - # slate cap, clipped to the skull - mask = Image.new("L", (S, S), 0) - ImageDraw.Draw(mask).ellipse( - [px(cx - w / 2), px(cy - h / 2), px(cx + w / 2), px(cy + h / 2)], fill=255 - ) - cap = Image.new("RGBA", (S, S), (0, 0, 0, 0)) - dc = ImageDraw.Draw(cap) - ell(dc, cx - 0.010, cy - 0.070, w * 1.02, h * 0.72, C_DARK, outline=None) - cap.putalpha(Image.composite(cap.getchannel("A"), Image.new("L", (S, S), 0), mask)) - layer.alpha_composite(cap) - - poly(d, bolt_shape(0.762, cy - 0.088, 0.062, 0.108), C_BOLT, outline=None) - - # muzzle, nose, mouth - ell(d, 0.880, cy + 0.048, 0.145, 0.108, C_FUR) - ell(d, 0.950, cy + 0.018, 0.058, 0.048, C_NOSE, outline=None) - d.arc( - [px(0.885), px(cy + 0.058), px(0.945), px(cy + 0.100)], - start=0, - end=150, - fill=C_OUTLINE, - width=_w(0.010), - ) - - # one eye in profile, plus the brow marking - ell(d, 0.812, cy - 0.020, 0.092, 0.100, C_WHITE) - ell(d, 0.820, cy - 0.020, 0.062, 0.070, C_IRIS, outline=None) - ell(d, 0.824, cy - 0.020, 0.036, 0.042, C_PUPIL, outline=None) - ell(d, 0.812, cy - 0.040, 0.026, 0.022, C_WHITE, outline=None) - ell(d, 0.795, cy - 0.088, 0.055, 0.034, C_BROW, outline=None) - - # Collar: a band *across* the neck, so it has to run perpendicular to it. - # Along the neck it just reads as an orange brick stuck to his chest. - collar = [ - (px(0.648), px(cy + 0.098)), - (px(0.762), px(cy + 0.196)), - ] - d.line(collar, fill=C_OUTLINE, width=_w(0.070), joint="curve") - d.line(collar, fill=C_COLLAR, width=_w(0.050), joint="curve") - ell(d, 0.712, cy + 0.196, 0.070, 0.070, C_TAG) - poly(d, bolt_shape(0.712, cy + 0.196, 0.025, 0.044), C_OUTLINE, outline=None) - - -def render_walk_frame(p) -> Image.Image: - base = Image.new("RGBA", (S, S), (0, 0, 0, 0)) - d = ImageDraw.Draw(base) - phase = p["phase"] - # Two contacts per cycle, so the body dips twice — the give-away that a - # walk cycle is weight-bearing rather than a slide. - dy = -0.011 * abs(math.sin(2 * math.pi * phase)) - head_dy = dy * 0.6 - 0.006 * math.sin(2 * math.pi * phase + 0.7) - - # Diagonal pairs (a trot): each front leg moves with the opposite hind. - far_front = paw_position(FRONT_PIVOT, phase + 0.5) - far_hind = paw_position(HIND_PIVOT, phase) - near_front = paw_position(FRONT_PIVOT, phase) - near_hind = paw_position(HIND_PIVOT, phase + 0.5) - - draw_walk_tail(d, p, dy) - # far side first, in the shade colour, so the near legs read as in front - draw_leg(d, (HIND_PIVOT[0], HIND_PIVOT[1] + dy), far_hind, C_FUR_FAR, bend=0.046) - draw_leg(d, (FRONT_PIVOT[0], FRONT_PIVOT[1] + dy), far_front, C_FUR_FAR) - - draw_torso(d, dy) - - draw_leg(d, (HIND_PIVOT[0], HIND_PIVOT[1] + dy), near_hind, C_FUR, bend=0.046) - draw_leg(d, (FRONT_PIVOT[0], FRONT_PIVOT[1] + dy), near_front, C_FUR) - - draw_walk_head(base, p, head_dy) - return base.resize((OUT, OUT), Image.LANCZOS) - - -# --- frame assembly -------------------------------------------------------- -def default_pose(**over): - p = dict( - breathe=0.0, - tail=0.0, - ear=0.0, - ear_twitch=0.0, - blink=0.0, - mouth=0.0, - tilt=0.0, - head_dy=0.0, - look=(0.0, 0.0), - brow=0.0, - brow_angle=0.0, - cross=False, - tag_glow=False, - extras=None, - phase=0, - ) - p.update(over) - return p - - -def render_frame(p) -> Image.Image: - if p.get("pose") == "walk": - return render_walk_frame(p) - base = Image.new("RGBA", (S, S), (0, 0, 0, 0)) - - body = Image.new("RGBA", (S, S), (0, 0, 0, 0)) - db = ImageDraw.Draw(body) - draw_tail(db, p) - draw_body(db, p) - draw_collar(db, p) - base.alpha_composite(body) - - head = Image.new("RGBA", (S, S), (0, 0, 0, 0)) - draw_head(head, p) - if p["tilt"]: - head = head.rotate( - p["tilt"], resample=Image.BICUBIC, center=(px(HEAD_CX), px(NECK_Y)) - ) - dy = int(px(p["head_dy"])) - if dy: - shifted = Image.new("RGBA", (S, S), (0, 0, 0, 0)) - shifted.alpha_composite(head, (0, dy)) - head = shifted - base.alpha_composite(head) - - draw_extras(base, p) - return base.resize((OUT, OUT), Image.LANCZOS) - - -def frames_for(state: str) -> list[dict]: - if state == "idle": - # Eight frames, all distinct. The old version drove breathing on - # sin(2*pi*t) and the tail on sin(4*pi*t), which both cross zero at - # i=0 and i=4 — so frame 4 was byte-identical to frame 0 and the loop - # was really four frames stored twice. - out = [] - for i in range(8): - t = i / 8 - br = math.sin(t * 2 * math.pi + math.pi / 7) - out.append( - default_pose( - breathe=br, - head_dy=-0.006 * br, - # Three-halves harmonic: never in phase with the breath, so - # no two frames of the cycle can coincide. - tail=math.sin(t * 3 * math.pi + 0.6), - ear_twitch=0.30 if i == 3 else 0.0, # a flick, once a loop - blink=1.0 if i == 6 else 0.0, - ) - ) - return out - if state == "listening": - # Six frames of *orienting*, not idling: ears up, head turning toward - # whoever is talking, then settling. The old four had frames 0 and 2 - # differing by 0.09 mean pixels — a two-pose animation wearing four. - out = [] - for i in range(6): - t = i / 6 - lean = math.sin(t * 2 * math.pi + math.pi / 5) - out.append( - default_pose( - ear=1.0, - ear_twitch=0.45 * math.sin(t * 4 * math.pi), - tilt=-9 + 4.0 * lean, - look=(0.010 * lean, -0.004), - brow=1.0, - tail=0.7 * math.sin(t * 2 * math.pi + 1.1), - head_dy=-0.010 - 0.004 * lean, - tag_glow=True, - extras="listen", - phase=i, - ) - ) - return out - if state == "thinking": - # The old six moved by a mean of ~1.3 pixels — effectively a still - # image. Thinking should *look* like thinking: the head tilts, the eyes - # travel as if following a thought, and one ear rotates independently. - out = [] - for i in range(6): - t = i / 6 - sway = math.sin(t * 2 * math.pi) - out.append( - default_pose( - ear=0.25 + 0.35 * abs(sway), - ear_twitch=0.5 * math.cos(t * 2 * math.pi), - tilt=4.0 + 7.0 * sway, - # Eyes wander a small circle: the cheapest possible read of - # "working something out" and the thing most obviously - # missing before. - look=(0.026 * math.cos(t * 2 * math.pi), - -0.020 + 0.014 * math.sin(t * 2 * math.pi)), - brow=0.5 + 0.4 * abs(sway), - breathe=0.5 * math.sin(t * 2 * math.pi + 0.9), - head_dy=-0.008 * sway, - tail=0.35 * math.sin(t * 3 * math.pi), - blink=1.0 if i == 4 else 0.0, - extras="think", - phase=i // 2, - ) - ) - return out - if state == "talking": - # Ordered by mouth openness — closed at frame 0, widest at the last — - # because the window indexes these by the loudness of the audio that is - # actually playing (see audio/tts.level_of and PetWindow.set_mouth). - # A time-ordered loop cannot be indexed that way, and a mouth that - # flaps on a timer is what makes a talking sprite look dubbed. - out = [] - count = 6 - for i in range(count): - open_ = i / (count - 1) - out.append( - default_pose( - mouth=0.06 + 0.94 * open_, - ear=0.6, - head_dy=-0.010 * open_, - breathe=open_, - tail=0.45 * math.sin(i * 0.9), - brow=0.35 * open_, - ) - ) - return out - if state == "walk": - # 8 frames: two full strides, so the loop lands back on the pose it - # started from and the cycle is seamless however it's entered. - out = [] - for i in range(8): - phase = i / 8 - out.append( - default_pose( - pose="walk", - phase=phase, - tail=math.sin(2 * math.pi * phase), - ear_bounce=math.sin(2 * math.pi * phase + 0.9), - ) - ) - return out - if state == "error": - return [ - default_pose(ear=-1.0, cross=True, brow_angle=1.0, mouth=0.35, tail=-0.6, - extras="error"), - default_pose(ear=-0.85, cross=True, brow_angle=1.0, mouth=0.15, tail=-0.4, - head_dy=0.008), - ] - raise ValueError(state) - - -STATES = ["idle", "listening", "thinking", "talking", "error", "walk"] - - -def main(): - ap = argparse.ArgumentParser(description=__doc__) - ap.add_argument( - "--out", - type=Path, - default=Path(__file__).resolve().parent.parent / "bolt_pet" / "assets" / "sprites", - ) - ap.add_argument("--states", nargs="*", default=STATES) - args = ap.parse_args() - - for state in args.states: - d = args.out / state - d.mkdir(parents=True, exist_ok=True) - for old in d.glob("*.png"): - old.unlink() - for i, pose in enumerate(frames_for(state)): - render_frame(pose).save(d / f"frame_{i:02d}.png") - print(f"{state}: {len(frames_for(state))} frames -> {d}") - - -if __name__ == "__main__": - main() diff --git a/scripts/slice_spritesheet.py b/scripts/slice_spritesheet.py deleted file mode 100755 index ff67744..0000000 --- a/scripts/slice_spritesheet.py +++ /dev/null @@ -1,50 +0,0 @@ -#!/usr/bin/env python3 -"""Cut a grid-based sprite sheet (rows x cols of equal-size frames in one -PNG) into the assets/sprites//frame_NN.png convention this project's -sprite loader expects. - -Usage: - python scripts/slice_spritesheet.py idle_sheet.png assets/sprites/idle --cols 6 --rows 1 -""" - -from __future__ import annotations - -import argparse -from pathlib import Path - -from PIL import Image - - -def slice_sheet(sheet_path: Path, out_dir: Path, cols: int, rows: int) -> int: - sheet = Image.open(sheet_path).convert("RGBA") - frame_w = sheet.width // cols - frame_h = sheet.height // rows - if frame_w == 0 or frame_h == 0: - raise ValueError(f"sheet is {sheet.width}x{sheet.height}, too small for {cols}x{rows} frames") - - out_dir.mkdir(parents=True, exist_ok=True) - count = 0 - for row in range(rows): - for col in range(cols): - box = (col * frame_w, row * frame_h, (col + 1) * frame_w, (row + 1) * frame_h) - frame = sheet.crop(box) - frame.save(out_dir / f"frame_{count:02d}.png") - count += 1 - return count - - -def main() -> int: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("sheet", type=Path, help="path to the grid sprite sheet PNG") - parser.add_argument("out_dir", type=Path, help="e.g. assets/sprites/idle") - parser.add_argument("--cols", type=int, required=True) - parser.add_argument("--rows", type=int, default=1) - args = parser.parse_args() - - count = slice_sheet(args.sheet, args.out_dir, args.cols, args.rows) - print(f"Wrote {count} frames to {args.out_dir}") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/tests/test_barge_in.py b/tests/test_barge_in.py deleted file mode 100644 index 9ad2ffe..0000000 --- a/tests/test_barge_in.py +++ /dev/null @@ -1,218 +0,0 @@ -"""Barge-in detection, driven by a fake mic stream and a fake wake model -(no audio hardware, no ONNX runtime).""" - -import sys -from pathlib import Path - -import numpy as np - -sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) - -from bolt_pet.audio.barge_in import BargeInDetector, WakeWordBargeIn, make_detector - - -class FakeStream: - """Yields frames of a given amplitude, mimicking sounddevice's - (data, overflowed) 2-D int16 return shape.""" - - def __init__(self, amplitudes): - self._amplitudes = list(amplitudes) - - def read(self, frames): - amplitude = self._amplitudes.pop(0) if self._amplitudes else 0 - data = np.full((frames, 1), amplitude, dtype=np.int16) - return data, False - - -def test_silence_never_interrupts(): - detector = BargeInDetector(FakeStream([0] * 20), threshold=1000, required_frames=3) - assert not any(detector.check() for _ in range(20)) - - -def test_sustained_speech_interrupts_after_the_required_frames(): - detector = BargeInDetector(FakeStream([2000] * 5), threshold=1000, required_frames=3) - assert detector.check() is False - assert detector.check() is False - assert detector.check() is True - - -def test_a_single_thump_does_not_interrupt(): - # loud, quiet, loud, quiet ... never three in a row - detector = BargeInDetector(FakeStream([2000, 0, 2000, 0, 2000, 0]), threshold=1000, required_frames=3) - assert not any(detector.check() for _ in range(6)) - - -def test_counter_resets_after_a_quiet_frame(): - detector = BargeInDetector(FakeStream([2000, 2000, 0, 2000, 2000, 2000]), threshold=1000, required_frames=3) - results = [detector.check() for _ in range(6)] - assert results == [False, False, False, False, False, True] - - -def test_reset_clears_progress(): - detector = BargeInDetector(FakeStream([2000] * 6), threshold=1000, required_frames=3) - detector.check() - detector.check() - detector.reset() - assert detector.check() is False - assert detector.loud_frames == 1 - - -def test_a_mic_error_mid_playback_is_not_fatal(): - class BrokenStream: - def read(self, frames): - raise OSError("device disappeared") - - detector = BargeInDetector(BrokenStream(), threshold=1000, required_frames=1) - assert detector.check() is False - - -# ── wake-word mode ─────────────────────────────────────────────────────────── - - -class FakeModel: - """Scores frames from a canned list, mimicking openWakeWord's - {class_name: score} return. Records reset() calls.""" - - def __init__(self, scores): - self._scores = list(scores) - self.resets = 0 - - def predict(self, frame): - score = self._scores.pop(0) if self._scores else 0.0 - return {"thunderbolt": score} - - def reset(self): - self.resets += 1 - - -def _wake_detector(scores, threshold=0.5, amplitudes=None): - stream = FakeStream(amplitudes if amplitudes is not None else [500] * len(scores)) - return WakeWordBargeIn(stream, model=FakeModel(scores), threshold=threshold), stream - - -def test_loud_noise_alone_does_not_interrupt_in_wake_mode(): - """The whole point of wake mode: a slammed door is deafening and scores - nothing, so the pet keeps talking.""" - detector, _ = _wake_detector([0.01] * 6, amplitudes=[30000] * 6) - assert not any(detector.check() for _ in range(6)) - - -def test_the_wake_word_interrupts(): - detector, _ = _wake_detector([0.1, 0.2, 0.9]) - assert [detector.check() for _ in range(3)] == [False, False, True] - - -def test_a_single_frame_is_enough_when_it_clears_the_threshold(): - detector, _ = _wake_detector([0.55]) - assert detector.check() is True - - -def test_scores_just_under_the_threshold_do_not_fire(): - detector, _ = _wake_detector([0.49, 0.499], threshold=0.5) - assert not any(detector.check() for _ in range(2)) - - -def test_detecting_resets_the_model_so_the_tail_is_not_reused(): - model = FakeModel([0.9]) - detector = WakeWordBargeIn(FakeStream([500]), model=model, threshold=0.5) - assert detector.check() is True - assert model.resets == 1 - - -def test_reset_clears_the_models_audio_window_not_just_predictions(): - """The regression that made the pet interrupt itself a word into every - reply: openwakeword's reset() clears only the prediction buffer, so the - "thunderbolt" that started the turn was still in the preprocessor's - rolling window when playback began, and the first frame fed to the model - re-fired on it.""" - - class FakePreprocessor: - def __init__(self): - self.raw_data_buffer = [1, 2, 3] - self.feature_buffer = np.ones((120, 96)) - self.melspectrogram_buffer = np.zeros((76, 32)) - self.accumulated_samples = 4096 - - def _get_embeddings(self, audio): - return np.zeros((120, 96)) - - model = FakeModel([0.9]) - model.preprocessor = FakePreprocessor() - - WakeWordBargeIn(FakeStream([500]), model=model, threshold=0.5).reset() - - assert model.preprocessor.raw_data_buffer == [] - assert model.preprocessor.accumulated_samples == 0 - assert not model.preprocessor.feature_buffer.any() # blank, not the old audio - assert model.preprocessor.melspectrogram_buffer.all() # restored to ones - - -def test_the_lazy_wrapper_exposes_its_preprocessor(): - """The pet holds _default_model — a lazy *wrapper* around openwakeword's - Model. If the wrapper stops proxying .preprocessor, hard_reset() finds - nothing to clear and silently degrades to the shallow reset that leaves - the last detection in the audio window. That failure is invisible: no - exception, no log, the pet just interrupts itself again.""" - from bolt_pet.audio.wake_word import _OpenWakeWordModel - - wrapper = _OpenWakeWordModel() - assert hasattr(wrapper, "preprocessor") - assert wrapper.preprocessor is None # not loaded yet: a no-op, not a load - - class FakeInner: - preprocessor = object() - - def reset(self): - pass - - wrapper._model = FakeInner() - assert wrapper.preprocessor is FakeInner.preprocessor - - -def test_a_model_without_a_preprocessor_still_resets(): - """Fakes in tests, and any future openwakeword whose internals moved.""" - model = FakeModel([0.0]) - WakeWordBargeIn(FakeStream([500]), model=model, threshold=0.5).reset() - assert model.resets == 1 - - -def test_a_callable_threshold_is_read_every_frame(): - """The tray tuner's slider has to apply mid-playback, not just mid-idle.""" - threshold = {"value": 0.9} - detector = WakeWordBargeIn( - FakeStream([500] * 2), model=FakeModel([0.6, 0.6]), - threshold=lambda: threshold["value"], - ) - assert detector.check() is False - threshold["value"] = 0.5 - assert detector.check() is True - - -def test_a_model_that_blows_up_mid_playback_is_not_fatal(): - class BrokenModel: - def predict(self, frame): - raise RuntimeError("onnx session died") - - def reset(self): - raise RuntimeError("still dead") - - detector = WakeWordBargeIn(FakeStream([500]), model=BrokenModel(), threshold=0.5) - assert detector.check() is False - detector.reset() # must not raise either - - -def test_a_mic_error_is_not_fatal_in_wake_mode(): - class BrokenStream: - def read(self, frames): - raise OSError("device disappeared") - - detector = WakeWordBargeIn(BrokenStream(), model=FakeModel([0.9]), threshold=0.5) - assert detector.check() is False - - -def test_make_detector_picks_the_mode(): - stream = FakeStream([0]) - assert isinstance(make_detector(stream, mode="wake"), WakeWordBargeIn) - assert isinstance(make_detector(stream, mode="energy"), BargeInDetector) - # A typo in .env shouldn't stop the pet from starting. - assert isinstance(make_detector(stream, mode="waek"), BargeInDetector) diff --git a/tests/test_chats.py b/tests/test_chats.py new file mode 100644 index 0000000..b5b9e5e --- /dev/null +++ b/tests/test_chats.py @@ -0,0 +1,209 @@ +"""The Chats dialog, the header New chat, /chats in the terminal, and the +row helpers. No network: list/create are injected fakes, and they still run +on the real QThreadPool, so the off-UI-thread hand-off is exercised.""" + +import io +import os +import sys +import threading +import time +from datetime import datetime, timedelta, timezone +from pathlib import Path + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +import pytest +from PySide6.QtCore import Qt + +from bolt_console import chats, config, console, server_client +from bolt_console.tools import Tools +from bolt_console.ui import Terminal + +CHATS = [ + {"session_id": "chat-new1", "label": "Fix the build", + "last_message": "try again [Bolt Console: a text terminal on Windows 10; working directory C:/x. To act", + "last_active": "2026-09-25T23:46:31+00:00"}, + {"session_id": "pet-console-test", "label": "console DESKTOP", + "last_message": "what is in this folder", "last_active": "2026-09-25T21:26:20+00:00"}, + {"session_id": "android-SM-S928U", "label": "android SM S928U", + "last_message": "wifi networks", "last_active": "2026-09-20T22:33:34+00:00"}, +] + + +@pytest.fixture(autouse=True) +def _session(monkeypatch, tmp_path): + monkeypatch.setattr(config, "SESSION_ID", "pet-console-test") + monkeypatch.setattr(config, "STATE_FILE", tmp_path / "session.json") + monkeypatch.setattr(config, "SERVER_URL", "http://127.0.0.1:9") + monkeypatch.setattr(config, "API_KEY", "k") + monkeypatch.delenv("DESK_SESSION_ID", raising=False) + + +class Calls: + """Fake list/create that record which thread they ran on.""" + + def __init__(self, chat_list=CHATS, new_id="chat-fresh", fail=None): + self.chat_list, self.new_id, self.fail, self.threads = chat_list, new_id, fail, [] + + def list(self): + self.threads.append(threading.current_thread()) + if self.fail: + raise server_client.ServerError(self.fail) + return self.chat_list + + def create(self): + self.threads.append(threading.current_thread()) + if self.fail: + raise server_client.ServerError(self.fail) + return self.new_id + + +# ── helpers ───────────────────────────────────────────────────────────────── + +def test_preview_drops_client_context_notes_even_when_cut_off(): + assert chats.clean_preview(CHATS[0]["last_message"]) == "try again" + assert chats.clean_preview("hi [on screen right now: VS Code] there") == "hi there" + assert chats.clean_preview("x" * 200, 20).endswith("…") + + +def test_relative_time(): + now = datetime(2026, 9, 26, 0, 0, tzinfo=timezone.utc) + + def ago(**delta): + return (now - timedelta(**delta)).isoformat() + + assert chats.relative_time(ago(seconds=10), now) == "just now" + assert chats.relative_time(ago(minutes=12), now) == "12 min ago" + assert chats.relative_time(ago(hours=3), now) == "3 h ago" + assert chats.relative_time(ago(days=1, hours=2), now) == "yesterday" + assert chats.relative_time(ago(days=4), now) == "4 days ago" + assert chats.relative_time("garbage", now) == "garbage" + + +# ── terminal ──────────────────────────────────────────────────────────────── + +def terminal_session(calls): + out = io.StringIO() + ui = Terminal(out=out, color=False, input_fn=lambda _p: "", animate=False) + session = console.Session(ui, tools=Tools(ui), list_chats=calls.list, + create_chat=calls.create, list_files=lambda: []) + return session, out + + +def test_chats_command_lists_and_marks_the_current_one(): + session, out = terminal_session(Calls()) + session.handle("/chats") + text = out.getvalue() + assert "Fix the build" in text and "try again" in text and "Bolt Console:" not in text + assert "* 2 console DESKTOP" in text # the current chat is starred + + +def test_chat_command_switches_by_number_and_remembers_it(): + session, _ = terminal_session(Calls()) + session.handle("/chats") + session.handle("/chat 3") + assert config.SESSION_ID == "android-SM-S928U" + assert config._remembered_session() == "android-SM-S928U" + + +def test_new_uses_the_server_minted_id_and_falls_back_locally(): + session, _ = terminal_session(Calls(new_id="chat-abc123")) + session.handle("/new") + assert config.SESSION_ID == "chat-abc123" + session, out = terminal_session(Calls(fail="HTTP 401")) + session.handle("/new") + assert config.SESSION_ID.startswith("pet-console-") and "made locally" in out.getvalue() + + +# ── window ────────────────────────────────────────────────────────────────── + +def pump(app, predicate, timeout=5.0): + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + app.processEvents() + if predicate(): + return True + time.sleep(0.01) + return False + + +@pytest.fixture +def window(): + from bolt_console.gui.window import MainWindow, make_app + + app = make_app(["test"]) + calls = Calls() + win = MainWindow(list_chats=calls.list, create_chat=calls.create) + win.calls, win.app = calls, app + yield win + win.close() + + +def test_dialog_lists_chats_off_the_ui_thread(window): + window.open_chats() + dialog = window.chat_dialog + assert pump(window.app, lambda: dialog.list.count() == 3) + assert window.calls.threads and all(t is not threading.main_thread() for t in window.calls.threads) + assert dialog.list.currentItem().data(Qt.UserRole) == "pet-console-test" # current one selected + dialog.close() + + +def test_picking_a_chat_switches_session_and_clears_the_transcript(window): + from bolt_console.gui.widgets import UserMessage + + window._add(UserMessage("old conversation")) + window.open_chats() + dialog = window.chat_dialog + assert pump(window.app, lambda: dialog.list.count() == 3) + dialog.list.setCurrentRow(0) + dialog.open_button.click() + window.app.processEvents() + assert config.SESSION_ID == "chat-new1" + assert not window.empty.isHidden() + assert "Fix the build" in window.hint.text() + + +def test_new_chat_in_the_dialog_switches_to_the_minted_id(window): + window.open_chats() + dialog = window.chat_dialog + assert pump(window.app, lambda: dialog.list.count() == 3) + dialog.new_button.click() + assert pump(window.app, lambda: config.SESSION_ID == "chat-fresh") + assert not dialog.isVisible() + + +def test_header_new_chat_mints_off_thread_and_clears(window): + from bolt_console.gui.widgets import UserMessage + + window._add(UserMessage("old")) + window.start_new_chat() + assert pump(window.app, lambda: config.SESSION_ID == "chat-fresh") + assert window.calls.threads[0] is not threading.main_thread() + assert not window.empty.isHidden() and window.new_chat.isEnabled() + + +def test_header_new_chat_failure_keeps_the_current_chat(window): + window.calls.fail = "HTTP 401 unauthorized" + window.start_new_chat() + assert pump(window.app, lambda: "Couldn't start a new chat" in window.hint.text()) + assert config.SESSION_ID == "pet-console-test" + assert window.new_chat.isEnabled() + + +def test_dialog_shows_a_load_failure(window): + window.calls.fail = "HTTP 401" + window.open_chats() + dialog = window.chat_dialog + assert pump(window.app, lambda: "Couldn't load chats" in dialog.status.text()) + assert dialog.refresh_button.isEnabled() + dialog.close() + + +def test_switching_is_refused_while_bolt_is_working(window): + window._set_busy(True) + window.open_chats() + assert "Wait for Bolt" in window.hint.text() + window.switch_chat("chat-other", "Other") + assert config.SESSION_ID == "pet-console-test" + window._set_busy(False) diff --git a/tests/test_config.py b/tests/test_config.py deleted file mode 100644 index 5dd3106..0000000 --- a/tests/test_config.py +++ /dev/null @@ -1,62 +0,0 @@ -import sys -from pathlib import Path - -sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) - -from bolt_pet import config - - -def test_set_env_value_uncomments_existing_line(tmp_path, monkeypatch): - (tmp_path / ".env").write_text("#MIC_DEVICE=\nOTHER=1\n", encoding="utf-8") - monkeypatch.setattr(config, "HERE", tmp_path) - - config.set_env_value("MIC_DEVICE", "USB Mic") - - lines = (tmp_path / ".env").read_text(encoding="utf-8").splitlines() - assert lines == ["MIC_DEVICE=USB Mic", "OTHER=1"] - assert config.os.environ["MIC_DEVICE"] == "USB Mic" - - -def test_set_env_value_overwrites_existing_uncommented_line(tmp_path, monkeypatch): - (tmp_path / ".env").write_text("MIC_DEVICE=Old Mic\n", encoding="utf-8") - monkeypatch.setattr(config, "HERE", tmp_path) - - config.set_env_value("MIC_DEVICE", "New Mic") - - lines = (tmp_path / ".env").read_text(encoding="utf-8").splitlines() - assert lines == ["MIC_DEVICE=New Mic"] - - -def test_set_env_value_appends_when_key_is_missing(tmp_path, monkeypatch): - (tmp_path / ".env").write_text("OTHER=1\n", encoding="utf-8") - monkeypatch.setattr(config, "HERE", tmp_path) - - config.set_env_value("MIC_DEVICE", "New Mic") - - lines = (tmp_path / ".env").read_text(encoding="utf-8").splitlines() - assert lines == ["OTHER=1", "MIC_DEVICE=New Mic"] - - -def test_set_env_value_creates_file_if_missing(tmp_path, monkeypatch): - monkeypatch.setattr(config, "HERE", tmp_path) - - config.set_env_value("MIC_DEVICE", "New Mic") - - assert (tmp_path / ".env").read_text(encoding="utf-8") == "MIC_DEVICE=New Mic\n" - - -def test_mic_device_parses_a_numeric_index_as_int(): - # sounddevice only takes an int index as-is; a string (even "1") goes - # through substring matching against every device name instead, which is - # not what a saved index means — see the tray's mic picker. - assert config._mic_device("1") == 1 - assert isinstance(config._mic_device("1"), int) - - -def test_mic_device_keeps_a_name_as_a_string(): - assert config._mic_device("USB Mic") == "USB Mic" - - -def test_mic_device_blank_is_none(): - assert config._mic_device("") is None - assert config._mic_device(" ") is None diff --git a/tests/test_console.py b/tests/test_console.py new file mode 100644 index 0000000..b3bcb5c --- /dev/null +++ b/tests/test_console.py @@ -0,0 +1,293 @@ +"""Whole turns over real HTTP: the real server_client, the real Tools and the +real Terminal (writing to a buffer), against a threaded server on a loopback +port that speaks the desk protocol. Only the keyboard is faked.""" + +import io +import json +import sys +import threading +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from bolt_console import config, console +from bolt_console.console import Session +from bolt_console.tools import Tools +from bolt_console.ui import Terminal + + +class FakeDesk: + """Scripted responses, real HTTP. Set `.script` per test.""" + + def __init__(self): + self.script = {} + self.requests = [] + self._server = ThreadingHTTPServer(("127.0.0.1", 0), self._handler()) + threading.Thread(target=self._server.serve_forever, daemon=True).start() + + @property + def url(self) -> str: + host, port = self._server.server_address[:2] + return f"http://{host}:{port}" + + def stop(self) -> None: + self._server.shutdown() + self._server.server_close() + + def _handler(self): + desk = self + + class Handler(BaseHTTPRequestHandler): + def log_message(self, *_args): + pass + + def do_GET(self): + path = self.path.split("?")[0] + desk.requests.append(("GET", path)) + response = desk.script.get(path, {"files": []}) + if isinstance(response, bytes): + self.send_response(200) + self.send_header("Content-Length", str(len(response))) + self.end_headers() + self.wfile.write(response) + else: + self._json(response) + + def do_POST(self): + length = int(self.headers.get("Content-Length") or 0) + body = json.loads(self.rfile.read(length) or b"{}") + path = self.path.split("?")[0] + desk.requests.append(("POST", path, body)) + response = desk.script.get(path) + if callable(response): + response = response(body) + if path.endswith("converse_stream"): + if response is None: + self.send_response(404) + self.end_headers() + return + self.send_response(200) + self.send_header("Content-Type", "application/x-ndjson") + self.end_headers() + for event in response: + self.wfile.write((json.dumps(event) + "\n").encode()) + self.wfile.flush() + else: + self._json(response if response is not None else {"type": "reply", "text": "ok"}) + + def _json(self, payload): + data = json.dumps(payload).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(data))) + self.end_headers() + self.wfile.write(data) + + return Handler + + +@pytest.fixture +def desk(monkeypatch, tmp_path): + server = FakeDesk() + monkeypatch.setattr(config, "SERVER_URL", server.url) + monkeypatch.setattr(config, "API_KEY", "k") + monkeypatch.setattr(config, "SESSION_ID", "pet-console-test") + monkeypatch.setattr(config, "DELIVERED_FILES_DIR", tmp_path / "delivered") + monkeypatch.chdir(tmp_path) + yield server + server.stop() + + +def make_session(answers=(), mode="ask"): + answers = list(answers) + out = io.StringIO() + ui = Terminal(out=out, color=False, input_fn=lambda _p: answers.pop(0), animate=False) + return Session(ui, tools=Tools(ui, mode=mode)), out + + +def converse_bodies(desk): + return [r[2] for r in desk.requests if r[0] == "POST" and "converse" in r[1]] + + +def test_a_plain_reply_is_printed_once(desk): + desk.script["/desk/converse"] = {"type": "reply", "text": "Hi there."} + session, out = make_session() + session.handle("hello") + assert out.getvalue().count("Hi there.") == 1 + + +def test_streamed_sentences_print_as_one_block_and_are_not_repeated(desk): + desk.script["/desk/converse_stream"] = [ + {"type": "say", "text": "First."}, + {"type": "say", "text": "Second."}, + {"type": "reply", "text": "First. Second.", "already_spoken": True}, + ] + session, out = make_session() + session.handle("hello") + text = out.getvalue() + assert text.count("First.") == 1 and text.count("Second.") == 1 + assert "* First.\n Second." in text + + +def test_the_message_carries_the_working_directory(desk, tmp_path): + session, _ = make_session() + session.handle("what's here?") + body = converse_bodies(desk)[-1] + assert body["session_id"] == "pet-console-test" + assert body["text"].startswith("what's here?") + assert str(tmp_path) in body["text"] + + +def test_an_approved_edit_lands_on_disk_and_the_result_goes_back_up(desk, tmp_path): + target = tmp_path / "app.py" + target.write_text("DEBUG = True\n", encoding="utf-8") + edit = "filectl " + json.dumps( + {"op": "edit", "path": str(target), "old": "DEBUG = True", "new": "DEBUG = False"}) + desk.script["/desk/converse"] = {"type": "command", "command": edit, "token": "t1"} + desk.script["/desk/tool_result"] = {"type": "reply", "text": "Turned debug off."} + + session, out = make_session(answers=["y"]) + session.handle("turn debug off in app.py") + + assert target.read_text(encoding="utf-8") == "DEBUG = False\n" + posted = [r[2] for r in desk.requests if r[1] == "/desk/tool_result"] + assert posted[0]["token"] == "t1" and "edited" in posted[0]["output"] + text = out.getvalue() + assert "Edit(" in text and "- DEBUG = True" in text and "+ DEBUG = False" in text + assert "Turned debug off." in text + + +def test_a_declined_shell_command_reports_back_and_the_turn_still_ends(desk, tmp_path): + desk.script["/desk/converse"] = {"type": "command", "command": "echo hi > made.txt", "token": "t"} + desk.script["/desk/tool_result"] = {"type": "reply", "text": "Okay, I won't."} + session, out = make_session(answers=["n"]) + session.handle("make a file") + assert not (tmp_path / "made.txt").exists() + posted = [r[2] for r in desk.requests if r[1] == "/desk/tool_result"] + assert "declined" in posted[0]["output"] + assert "Okay, I won't." in out.getvalue() + + +def test_delivered_files_are_saved_after_the_turn(desk, tmp_path): + desk.script["/desk/files"] = {"files": [{"id": "f1", "name": "report.txt", "size": 5}]} + desk.script["/desk/files/f1"] = b"hello" + session, out = make_session() + session.handle("send me the report") + assert (tmp_path / "delivered" / "report.txt").read_bytes() == b"hello" + assert "Received report.txt" in out.getvalue() + + +def test_server_errors_are_shown_not_raised(desk): + desk.script["/desk/converse"] = {"type": "error", "error": "unauthorized"} + session, out = make_session() + session.handle("hi") + assert "Error: unauthorized" in out.getvalue() + + +def test_slash_commands(desk, tmp_path): + (tmp_path / "sub").mkdir() + session, out = make_session() + assert session.handle("/cd sub") is True + assert Path.cwd() == tmp_path / "sub" + session.handle("/auto") + assert session.tools.mode == "auto" + session.handle("/ask") + assert session.tools.mode == "ask" + assert session.handle("/exit") is False + assert converse_bodies(desk) == [] # none of that went to Bolt + + +def test_loop_exits_cleanly_on_eof(desk): + out = io.StringIO() + + def no_more_input(_prompt): + raise EOFError + + ui = Terminal(out=out, color=False, input_fn=no_more_input, animate=False) + assert Session(ui).loop() == 0 + assert "Bolt Console" in out.getvalue() + + +def test_backslash_continues_a_message_onto_the_next_line(desk): + lines = ["first line \\", "second line"] + ui = Terminal(out=io.StringIO(), color=False, input_fn=lambda _p: lines.pop(0), animate=False) + assert ui.read_message() == "first line \nsecond line" + + +def test_annotate_names_the_directory_and_says_there_is_no_pet(): + note = console.annotate("hi", "/work", "Linux 6.1") + assert note.startswith("hi\n\n") + assert "/work" in note and "Linux 6.1" in note and "no pet body" in note + + +def test_a_reply_that_invents_command_results_is_flagged_and_corrected(desk): + """Seen live: 'wrote 1200 chars to …' three turns running, no command sent, + no file on disk.""" + desk.script["/desk/converse_stream"] = [ + {"type": "say", "text": "Let me check that."}, + {"type": "say", "text": "→ Command result: [filectl] wrote 1200 chars to C:/x/main.py"}, + {"type": "reply", "text": "", "already_spoken": True}, + ] + session, out = make_session() + session.handle("write it") + assert console.FABRICATION_WARNING in out.getvalue() + + desk.script["/desk/converse_stream"] = [{"type": "reply", "text": "Sorry.", "already_spoken": False}] + session.handle("did you write it?") + corrected = converse_bodies(desk)[-1]["text"] + assert corrected.startswith(console.CORRECTION) + assert "did you write it?" in corrected + + session.handle("thanks") # the correction goes out once, not forever + assert not converse_bodies(desk)[-1]["text"].startswith(console.CORRECTION) + + +def test_real_command_output_quoted_in_a_reply_is_not_flagged(desk, tmp_path): + desk.script["/desk/converse"] = {"type": "command", "command": "echo hi", "token": "t"} + desk.script["/desk/tool_result"] = {"type": "reply", "text": "Got [exit 0] and hi."} + session, out = make_session(answers=["y"]) + session.handle("say hi") + assert console.FABRICATION_WARNING not in out.getvalue() + + +def test_fabricated_matches_the_shapes_seen_live(): + assert console.fabricated("→ Command result: [filectl] {...}", 0) + assert console.fabricated("-> Deliver files result: Files delivered to C:/x", 0) + assert console.fabricated("[filectl] wrote 1200 chars to C:/x", 0) + assert not console.fabricated("I wrote the plan below.", 0) + assert not console.fabricated("→ Command result: [filectl] wrote 3 chars", 1) + + +def test_the_context_note_spells_out_the_command_marker(): + """'use filectl and shell commands' made the live model write filectl as + prose; the explicit `command:` form got real relayed commands.""" + note = console.annotate("hi", "/work", "Windows 10") + assert "`command: filectl {" in note + assert "`command: `" in note + assert "Never write a command's output yourself" in note + + +def test_new_conversation_switches_and_remembers_the_session(desk, tmp_path, monkeypatch): + monkeypatch.setattr(config, "STATE_FILE", tmp_path / "state" / "session.json") + monkeypatch.delenv("DESK_SESSION_ID", raising=False) + session, out = make_session() + session._correct_next = True + session.handle("/new") + new_id = config.SESSION_ID + assert new_id.startswith("pet-console-") and new_id != "pet-console-test" + assert new_id in out.getvalue() + assert not session._correct_next # nothing to correct in an empty history + assert config._remembered_session() == new_id # survives a restart + + session.handle("hello") + assert converse_bodies(desk)[-1]["session_id"] == new_id + + +def test_a_corrupt_state_file_means_the_default_session(tmp_path, monkeypatch): + state = tmp_path / "session.json" + state.write_text("{not json", encoding="utf-8") + monkeypatch.setattr(config, "STATE_FILE", state) + assert config._remembered_session() == "" diff --git a/tests/test_controller.py b/tests/test_controller.py deleted file mode 100644 index b4a0322..0000000 --- a/tests/test_controller.py +++ /dev/null @@ -1,203 +0,0 @@ -import sys -from pathlib import Path - -sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) - -import numpy as np -import pytest -from PySide6.QtWidgets import QApplication - -from bolt_pet import controller as controller_mod -from bolt_pet.state import PetState - -# A QApplication is required before any QObject with signals can be built. -_app = QApplication.instance() or QApplication(["test"]) - - -@pytest.fixture(autouse=True) -def no_screen_probes(monkeypatch): - # The real ones shell out to xprop/osascript — irrelevant here, and slow - # (or hung) on a headless box. - monkeypatch.setattr(controller_mod.screen_context, "context_for", lambda text: text) - monkeypatch.setattr(controller_mod.screen_context, "is_fullscreen_active", lambda: False) - - -@pytest.fixture(autouse=True) -def _no_streaming(monkeypatch): - """Most tests drive the plain request/response path; leaving streaming on - would have them attempt a real HTTP call, fail, and fall back — passing, - slowly, for the wrong reason. The streaming path has its own tests.""" - monkeypatch.setattr(controller_mod.config, "STREAMING_REPLIES", False) - - -@pytest.fixture -def ctrl(): - return controller_mod.PetController() - - -def _capture(signal): - events = [] - signal.connect(lambda *a: events.append(a[0] if len(a) == 1 else a)) - return events - - -# ── conversation turn ──────────────────────────────────────────────────────── - -def test_full_turn_happy_path(monkeypatch, ctrl): - states = _capture(ctrl.state_changed) - said = _capture(ctrl.said) - - monkeypatch.setattr(controller_mod.mic, "record_utterance", lambda *a, **k: np.zeros(10, dtype=np.int16)) - monkeypatch.setattr(controller_mod.stt, "transcribe", lambda pcm: "what's the weather") - monkeypatch.setattr(controller_mod.server_client, "converse", lambda text, on_command=None, on_say=None: controller_mod.server_client.Reply("sunny and 72")) - spoken = [] - monkeypatch.setattr(controller_mod.tts, "speak", - lambda text, on_error=None, should_stop=None, voice_id=None, on_level=None: (spoken.append(text), True)[1]) - - ctrl._handle_conversation_turn() - - assert states == ["listening", "thinking", "talking", "idle"] - assert said == ["sunny and 72"] - assert spoken == ["sunny and 72"] - assert ctrl._state.state == PetState.IDLE - - -def test_turn_with_nothing_heard_returns_to_idle_without_calling_server(monkeypatch, ctrl): - states = _capture(ctrl.state_changed) - monkeypatch.setattr(controller_mod.mic, "record_utterance", lambda *a, **k: None) - called = {"n": 0} - monkeypatch.setattr(controller_mod.server_client, "converse", - lambda text, on_command=None, on_say=None: called.__setitem__("n", called["n"] + 1)) - - ctrl._handle_conversation_turn() - - assert states == ["listening", "idle"] - assert called["n"] == 0 - - -def test_turn_with_empty_transcript_returns_to_idle(monkeypatch, ctrl): - states = _capture(ctrl.state_changed) - monkeypatch.setattr(controller_mod.mic, "record_utterance", lambda *a, **k: np.zeros(10, dtype=np.int16)) - monkeypatch.setattr(controller_mod.stt, "transcribe", lambda pcm: "") - - ctrl._handle_conversation_turn() - - assert states == ["listening", "thinking", "idle"] - - -def test_turn_with_stt_error_flashes_error_then_idle(monkeypatch, ctrl): - states = _capture(ctrl.state_changed) - logs = _capture(ctrl.log) - monkeypatch.setattr(controller_mod.mic, "record_utterance", lambda *a, **k: np.zeros(10, dtype=np.int16)) - - def boom(pcm): - raise controller_mod.stt.SttError("deepgram is down") - monkeypatch.setattr(controller_mod.stt, "transcribe", boom) - - ctrl._handle_conversation_turn() - - assert states == ["listening", "thinking", "error", "idle"] - assert any("deepgram is down" in msg for msg in logs) - - -def test_turn_with_server_error_flashes_error_then_idle(monkeypatch, ctrl): - states = _capture(ctrl.state_changed) - monkeypatch.setattr(controller_mod.mic, "record_utterance", lambda *a, **k: np.zeros(10, dtype=np.int16)) - monkeypatch.setattr(controller_mod.stt, "transcribe", lambda pcm: "hello") - - def boom(text, on_command=None, on_say=None): - raise controller_mod.server_client.ServerError("server is down") - monkeypatch.setattr(controller_mod.server_client, "converse", boom) - - ctrl._handle_conversation_turn() - - assert states == ["listening", "thinking", "error", "idle"] - - -# ── mute / talk-now / wake-or-click disambiguation ────────────────────────── - -def test_toggle_mute_flips_and_returns_new_state(ctrl): - assert ctrl.toggle_mute() is True - assert ctrl.toggle_mute() is False - - -def test_wait_for_wake_or_click_true_on_phrase_detection(monkeypatch, ctrl): - monkeypatch.setattr(controller_mod.wake_word, "listen_for_wake_word", lambda *a, **k: True) - ctrl._stream = object() - assert ctrl._wait_for_wake_or_click() is True - - -def test_wait_for_wake_or_click_true_on_manual_trigger(monkeypatch, ctrl): - # listen_for_wake_word returns False because should_continue() went - # false (the talk_now flag got set) — controller must still recognize - # this as "proceed", not "spurious wakeup". - def fake_listen(stream, should_continue, on_tick=None, **kwargs): - ctrl.request_talk_now() - should_continue() # simulate the loop noticing the flag - return False - - monkeypatch.setattr(controller_mod.wake_word, "listen_for_wake_word", fake_listen) - ctrl._stream = object() - assert ctrl._wait_for_wake_or_click() is True - assert not ctrl._talk_now.is_set() # cleared after being consumed - - -def test_wait_for_wake_or_click_false_on_shutdown(monkeypatch, ctrl): - def fake_listen(stream, should_continue, on_tick=None, **kwargs): - ctrl.stop() - return False - monkeypatch.setattr(controller_mod.wake_word, "listen_for_wake_word", fake_listen) - ctrl._stream = object() - assert ctrl._wait_for_wake_or_click() is False - - -# ── heartbeat / proactive announcements ───────────────────────────────────── - -def test_heartbeat_speaks_a_pending_announcement_when_idle(monkeypatch, ctrl): - monkeypatch.setattr(controller_mod.config, "HEARTBEAT_INTERVAL_SECONDS", 0) - monkeypatch.setattr(controller_mod.server_client, "report_status", lambda: "don't forget your 3pm") - spoken = [] - monkeypatch.setattr(controller_mod.tts, "speak", - lambda text, on_error=None, should_stop=None, voice_id=None, on_level=None: (spoken.append(text), True)[1]) - said = _capture(ctrl.said) - - ctrl._maybe_heartbeat() - - assert spoken == ["don't forget your 3pm"] - assert said == ["don't forget your 3pm"] - assert ctrl._state.state == PetState.IDLE - - -def test_heartbeat_does_nothing_when_not_idle(monkeypatch, ctrl): - monkeypatch.setattr(controller_mod.config, "HEARTBEAT_INTERVAL_SECONDS", 0) - ctrl._state.transition(PetState.LISTENING) - called = {"n": 0} - monkeypatch.setattr(controller_mod.server_client, "report_status", - lambda: called.__setitem__("n", called["n"] + 1)) - - ctrl._maybe_heartbeat() - - assert called["n"] == 0 - - -def test_heartbeat_respects_the_interval(monkeypatch, ctrl): - # Deterministic fake clock — time.monotonic()'s absolute value is - # arbitrary (often system uptime), so asserting behavior relative to it - # without controlling it would be flaky. - fake_now = {"t": 1000.0} - monkeypatch.setattr(controller_mod.time, "monotonic", lambda: fake_now["t"]) - monkeypatch.setattr(controller_mod.config, "HEARTBEAT_INTERVAL_SECONDS", 60) - called = {"n": 0} - monkeypatch.setattr(controller_mod.server_client, "report_status", - lambda: called.__setitem__("n", called["n"] + 1)) - - ctrl._maybe_heartbeat() # last_heartbeat starts at 0.0 -> elapsed is huge -> runs - assert called["n"] == 1 - - fake_now["t"] += 10 # only 10s later — inside the 60s interval - ctrl._maybe_heartbeat() - assert called["n"] == 1 # skipped - - fake_now["t"] += 60 # now well past the interval - ctrl._maybe_heartbeat() - assert called["n"] == 2 diff --git a/tests/test_controller_features.py b/tests/test_controller_features.py deleted file mode 100644 index fb4c4df..0000000 --- a/tests/test_controller_features.py +++ /dev/null @@ -1,1042 +0,0 @@ -"""Controller-level wiring for the newer behaviours: petctl routing, -barge-in follow-up, quiet hours, screen context, notification forwarding and -the live wake threshold. - -Needs a QApplication (signals), so run with QT_QPA_PLATFORM=offscreen. -""" - -import json -import sys -from pathlib import Path - -import numpy as np -import pytest -from PySide6.QtWidgets import QApplication - -from bolt_pet import controller as controller_mod -from bolt_pet.notifications import Notification -from bolt_pet.server_client import Reply -from bolt_pet.state import PetState - -sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) - -_app = QApplication.instance() or QApplication(["test"]) - - -@pytest.fixture(autouse=True) -def no_screen_probes(monkeypatch): - monkeypatch.setattr(controller_mod.screen_context, "is_fullscreen_active", lambda: False) - monkeypatch.setattr(controller_mod.screen_context, "context_for", lambda text: text) - - -@pytest.fixture(autouse=True) -def _no_streaming(monkeypatch): - """Most tests drive the plain request/response path; leaving streaming on - would have them attempt a real HTTP call, fail, and fall back — passing, - slowly, for the wrong reason. The streaming path has its own tests.""" - monkeypatch.setattr(controller_mod.config, "STREAMING_REPLIES", False) - - -@pytest.fixture -def ctrl(): - return controller_mod.PetController() - - -def _capture(signal): - events = [] - signal.connect(lambda *a: events.append(a[0] if len(a) == 1 else a)) - return events - - -# ── petctl routing ────────────────────────────────────────────────────────── - -def test_petctl_commands_never_reach_the_shell(monkeypatch, ctrl): - ran = [] - monkeypatch.setattr(controller_mod.server_client, "run_local_command", lambda cmd: ran.append(cmd)) - actions = _capture(ctrl.action) - - output = ctrl._handle_command("petctl move top-left") - - assert ran == [] - assert actions == [{"action": "move", "anchor": "top-left"}] - assert "top-left" in output - - -def test_ordinary_commands_still_run_locally(monkeypatch, ctrl): - ran = [] - monkeypatch.setattr(controller_mod.server_client, "run_local_command", - lambda cmd: ran.append(cmd) or "[exit 0]\n") - actions = _capture(ctrl.action) - - ctrl._handle_command("df -h /") - - assert ran == ["df -h /"] - assert actions == [] - - -def test_bad_petctl_syntax_is_reported_back_not_executed(monkeypatch, ctrl): - ran = [] - monkeypatch.setattr(controller_mod.server_client, "run_local_command", lambda cmd: ran.append(cmd)) - output = ctrl._handle_command("petctl move sideways") - assert ran == [] - assert "[pet]" in output - - -def test_petctl_nap_also_flips_the_controller_state(ctrl): - napping = _capture(ctrl.napping) - ctrl._handle_command("petctl nap on") - assert napping == [True] - assert ctrl._napping is True - - -# ── filectl routing ────────────────────────────────────────────────────────── -# filectl's wire format is a single-line JSON envelope (see file_ops.py's -# module docstring for why) — build commands with json.dumps so the tests -# don't hardcode escaping by hand. - -def _filectl(payload: dict) -> str: - return "filectl " + json.dumps(payload) - - -def test_filectl_commands_never_reach_the_shell(monkeypatch, ctrl, tmp_path): - ran = [] - monkeypatch.setattr(controller_mod.server_client, "run_local_command", lambda cmd: ran.append(cmd)) - target = tmp_path / "a.txt" - - output = ctrl._handle_command(_filectl({"op": "write", "path": str(target), "content": "hello"})) - - assert ran == [] - assert target.read_text() == "hello" - assert str(target) in output - - -def test_filectl_edit_round_trips_through_the_relay(monkeypatch, ctrl, tmp_path): - monkeypatch.setattr(controller_mod.server_client, "run_local_command", - lambda cmd: (_ for _ in ()).throw(AssertionError("should not shell out"))) - target = tmp_path / "a.py" - target.write_text("x = 1\n") - - output = ctrl._handle_command( - _filectl({"op": "edit", "path": str(target), "old": "x = 1", "new": "x = 2"}) - ) - - assert target.read_text() == "x = 2\n" - assert str(target) in output - - -def test_bad_filectl_syntax_is_reported_back_not_executed(monkeypatch, ctrl): - ran = [] - monkeypatch.setattr(controller_mod.server_client, "run_local_command", lambda cmd: ran.append(cmd)) - output = ctrl._handle_command("filectl {not valid json") - assert ran == [] - assert "[filectl]" in output - - -def test_filectl_execution_failure_is_reported_back_not_raised(monkeypatch, ctrl): - output = ctrl._handle_command(_filectl({"op": "read", "path": "/no/such/file.txt"})) - assert "[filectl]" in output - - -def test_ordinary_commands_still_run_locally_alongside_filectl(monkeypatch, ctrl): - ran = [] - monkeypatch.setattr(controller_mod.server_client, "run_local_command", - lambda cmd: ran.append(cmd) or "[exit 0]\n") - ctrl._handle_command("df -h /") - assert ran == ["df -h /"] - - -# ── barge-in ──────────────────────────────────────────────────────────────── - -def test_the_interrupt_log_reports_what_fired_not_the_reset_counters(monkeypatch, ctrl): - """_speak resets the detector after playback so the pet's own voice - doesn't linger in the wake model's window. Reading the stats after that - reset reports 0.000 at frame 0 for every interruption, which is worse - than no instrumentation — it looks like hard evidence and isn't.""" - from bolt_pet.audio import barge_in as barge_in_mod - - class Detector(barge_in_mod.WakeWordBargeIn): - def __init__(self): - self._frames, self._peak, self._last, self._last_threshold = 0, 0.0, 0.0, 0.5 - self._frame_len = 1280 - self.reset_calls = 0 - - def reset(self): - self.reset_calls += 1 - self._frames, self._peak, self._last = 0, 0.0, 0.0 - - detector = Detector() - ctrl._barge_in = detector - logs = _capture(ctrl.log) - - def interrupted_playback(text, on_error=None, should_stop=None, voice_id=None, on_level=None): - # What really happens: frames get scored during playback, then one - # clears the threshold and playback aborts. - detector._frames, detector._peak, detector._last = 7, 0.81, 0.81 - return False - - monkeypatch.setattr(controller_mod.tts, "speak", interrupted_playback) - - ctrl._speak("a very long explanation") - - interrupted = next(m for m in logs if "Interrupted" in m) - assert "0.810" in interrupted and "frame 7" in interrupted - # Once before playback (clear the window) and once after (drop the pet's - # own voice) — the point is that the *read* happens between them. - assert detector.reset_calls == 2 - - -def test_interrupted_playback_queues_an_immediate_next_turn(monkeypatch, ctrl): - logs = _capture(ctrl.log) - monkeypatch.setattr(controller_mod.tts, "speak", - lambda text, on_error=None, should_stop=None, voice_id=None, on_level=None: False) # interrupted - - ctrl._speak("a very long explanation") - - assert ctrl._talk_now.is_set() # loop picks the next turn up without a wake word - assert any("Interrupted" in message for message in logs) - - -def test_uninterrupted_playback_does_not_queue_a_turn(monkeypatch, ctrl): - monkeypatch.setattr(controller_mod.tts, "speak", - lambda text, on_error=None, should_stop=None, voice_id=None, on_level=None: True) - ctrl._speak("short answer") - assert not ctrl._talk_now.is_set() - - -# ── follow-up listening ───────────────────────────────────────────────────── - -@pytest.fixture -def spoke(monkeypatch): - """Playback that always completes, so only the follow-up rule decides - whether another turn is queued.""" - monkeypatch.setattr(controller_mod.tts, "speak", - lambda text, on_error=None, should_stop=None, voice_id=None, on_level=None: True) - - -def test_a_reply_ending_in_a_question_keeps_listening(spoke, ctrl): - logs = _capture(ctrl.log) - - ctrl._speak("You're still in ~/Documents/bolt-pet. Ready to run a command?") - - assert ctrl._talk_now.is_set() # no wake word needed for the answer - assert ctrl._pending_follow_up # and the next turn knows it's an answer - assert any("listening for your answer" in message for message in logs) - - -def test_a_statement_does_not_keep_listening(spoke, ctrl): - ctrl._speak("It's 7:15 AM on July 23, 2026.") - assert not ctrl._talk_now.is_set() - assert not ctrl._pending_follow_up - - -def test_a_question_in_passing_does_not_count(spoke, ctrl): - """Only a reply that *ends* on a question is waiting for an answer.""" - ctrl._speak("What time is it? It's 7:15 AM.") - assert not ctrl._talk_now.is_set() - - -def test_follow_ups_stop_at_the_cap(spoke, monkeypatch, ctrl): - monkeypatch.setattr(controller_mod.config, "FOLLOW_UP_MAX_TURNS", 2) - logs = _capture(ctrl.log) - - for _ in range(2): - ctrl._speak("Want me to keep going?") - ctrl._talk_now.clear() - assert ctrl._follow_ups == 2 - - ctrl._speak("Want me to keep going?") - - assert not ctrl._talk_now.is_set() # chain broken until you re-trigger it - assert any("Follow-up limit reached" in message for message in logs) - - -def test_the_cap_is_not_announced_on_ordinary_replies(spoke, monkeypatch, ctrl): - monkeypatch.setattr(controller_mod.config, "FOLLOW_UP_MAX_TURNS", 1) - ctrl._follow_ups = 1 - logs = _capture(ctrl.log) - - ctrl._speak("Done — the file is saved.") - - assert not any("Follow-up limit" in message for message in logs) - - -def test_starting_a_turn_yourself_resets_the_chain(spoke, monkeypatch, ctrl): - monkeypatch.setattr(controller_mod.mic, "record_utterance", - lambda *a, **kw: None) # you said nothing - ctrl._follow_ups = 3 - - ctrl._handle_conversation_turn() - - assert ctrl._follow_ups == 0 - - -def test_an_answered_question_gets_a_longer_grace_period(spoke, monkeypatch, ctrl): - """You were just asked something — you get longer to think than when you - deliberately said the wake word.""" - grace = [] - monkeypatch.setattr(controller_mod.mic, "record_utterance", - lambda *a, **kw: grace.append(kw.get("grace_s")) or None) - - ctrl._handle_conversation_turn() # you started this one - ctrl._pending_follow_up = True - ctrl._handle_conversation_turn() # this one answers a question - - assert grace == [None, controller_mod.config.FOLLOW_UP_GRACE_SECONDS] - - -def test_muting_stops_follow_ups(spoke, ctrl): - ctrl._muted = True - ctrl._speak("Shall I continue?") - assert not ctrl._talk_now.is_set() # mute means don't listen, question or not - - -def test_follow_up_can_be_turned_off(spoke, monkeypatch, ctrl): - monkeypatch.setattr(controller_mod.config, "FOLLOW_UP_LISTEN", False) - ctrl._speak("Shall I continue?") - assert not ctrl._talk_now.is_set() - - -def test_being_interrupted_restarts_the_chain(monkeypatch, ctrl): - monkeypatch.setattr(controller_mod.tts, "speak", - lambda text, on_error=None, should_stop=None, voice_id=None, on_level=None: False) - ctrl._follow_ups = 3 - - ctrl._speak("a very long explanation") - - assert ctrl._follow_ups == 0 # you're clearly engaged - assert ctrl._talk_now.is_set() - - -def test_speech_is_recorded_in_the_history(monkeypatch, ctrl): - monkeypatch.setattr(controller_mod.tts, "speak", - lambda text, on_error=None, should_stop=None, voice_id=None, on_level=None: True) - ctrl._speak("**bold** reply") - assert ctrl.history.last().text == "**bold** reply" # raw, for copy/paste - - -# ── screen context ────────────────────────────────────────────────────────── - -def test_the_active_window_rides_along_with_the_utterance(monkeypatch, ctrl): - monkeypatch.setattr(controller_mod.mic, "record_utterance", - lambda *a, **k: np.zeros(10, dtype=np.int16)) - monkeypatch.setattr(controller_mod.stt, "transcribe", lambda pcm: "what's this error?") - monkeypatch.setattr(controller_mod.screen_context, "context_for", - lambda text: f"{text}\n\n[on screen right now: app.py]") - monkeypatch.setattr(controller_mod.tts, "speak", - lambda text, on_error=None, should_stop=None, voice_id=None, on_level=None: True) - sent = [] - monkeypatch.setattr(controller_mod.server_client, "converse", - lambda text, on_command=None, on_say=None: sent.append(text) or Reply("that's a KeyError")) - - ctrl._handle_conversation_turn() - - assert "[on screen right now: app.py]" in sent[0] - # ...but the *history* keeps what you actually said, not the annotation. - assert ctrl.history.entries()[0].text == "what's this error?" - - -# ── quiet hours / do-not-disturb ──────────────────────────────────────────── - -def test_quiet_hours_suppress_the_heartbeat(monkeypatch, ctrl): - monkeypatch.setattr(controller_mod.config, "HEARTBEAT_INTERVAL_SECONDS", 0) - monkeypatch.setattr(controller_mod.config, "QUIET_HOURS", "00:00-23:59") - called = {"n": 0} - monkeypatch.setattr(controller_mod.server_client, "report_status", - lambda: called.__setitem__("n", called["n"] + 1)) - - ctrl._maybe_heartbeat() - - assert called["n"] == 0 - assert ctrl._napping is True - - -def test_fullscreen_triggers_do_not_disturb(monkeypatch, ctrl): - monkeypatch.setattr(controller_mod.config, "QUIET_HOURS", "") - monkeypatch.setattr(controller_mod.config, "DND_ON_FULLSCREEN", True) - monkeypatch.setattr(controller_mod.screen_context, "is_fullscreen_active", lambda: True) - napping = _capture(ctrl.napping) - - ctrl._refresh_nap_state() - - assert napping == [True] - - -def test_a_manual_nap_overrides_the_schedule(monkeypatch, ctrl): - monkeypatch.setattr(controller_mod.config, "QUIET_HOURS", "") - ctrl.set_napping(True) - ctrl._last_nap_check = 0.0 - ctrl._refresh_nap_state() - assert ctrl._napping is True # the schedule doesn't wake it back up - - ctrl.set_napping(None) # back on schedule - ctrl._last_nap_check = 0.0 - ctrl._refresh_nap_state() - assert ctrl._napping is False - - -def test_napping_still_answers_when_spoken_to(monkeypatch, ctrl): - monkeypatch.setattr(controller_mod.mic, "record_utterance", - lambda *a, **k: np.zeros(10, dtype=np.int16)) - monkeypatch.setattr(controller_mod.stt, "transcribe", lambda pcm: "you awake?") - monkeypatch.setattr(controller_mod.server_client, "converse", - lambda text, on_command=None, on_say=None: Reply("always")) - spoken = [] - monkeypatch.setattr(controller_mod.tts, "speak", - lambda text, on_error=None, should_stop=None, voice_id=None, on_level=None: spoken.append(text) or True) - ctrl.set_napping(True) - - ctrl._handle_conversation_turn() - - assert spoken == ["always"] - - -# ── notification bridge ───────────────────────────────────────────────────── - -def test_notifications_are_forwarded_and_spoken(monkeypatch, ctrl): - monkeypatch.setattr(controller_mod.config, "NOTIFICATION_MIN_INTERVAL_SECONDS", 0) - ctrl._notification_gate = controller_mod.notifications.NotificationGate("", 0) - sent, spoken = [], [] - monkeypatch.setattr(controller_mod.server_client, "converse", - lambda text, on_command=None, on_say=None: sent.append(text) or Reply("your build is green")) - monkeypatch.setattr(controller_mod.tts, "speak", - lambda text, on_error=None, should_stop=None, voice_id=None, on_level=None: spoken.append(text) or True) - - ctrl._queue_notification(Notification(app="CI", summary="Build finished", body="")) - ctrl._drain_notifications() - - assert "CI: Build finished" in sent[0] - assert spoken == ["your build is green"] - assert ctrl._state.state == PetState.IDLE - - -def test_filtered_out_notifications_are_never_queued(ctrl): - ctrl._notification_gate = controller_mod.notifications.NotificationGate("deploy", 0) - ctrl._queue_notification(Notification(app="Chat", summary="lunch?", body="")) - assert ctrl._pending_notifications == [] - - -def test_notifications_are_not_forwarded_while_napping(monkeypatch, ctrl): - ctrl._notification_gate = controller_mod.notifications.NotificationGate("", 0) - called = {"n": 0} - monkeypatch.setattr(controller_mod.server_client, "converse", - lambda text, on_command=None, on_say=None: called.__setitem__("n", called["n"] + 1)) - ctrl.set_napping(True) - - ctrl._queue_notification(Notification(app="CI", summary="Build finished", body="")) - ctrl._drain_notifications() - - assert called["n"] == 0 - - -# ── wake threshold ────────────────────────────────────────────────────────── - -def test_threshold_is_live_and_clamped(ctrl): - ctrl.set_wake_threshold(0.72) - assert ctrl.wake_threshold() == pytest.approx(0.72) - ctrl.set_wake_threshold(5) - assert ctrl.wake_threshold() == pytest.approx(0.99) - ctrl.set_wake_threshold(-1) - assert ctrl.wake_threshold() == pytest.approx(0.01) - - -def test_near_misses_are_recorded_for_the_tuner(ctrl): - ctrl.set_wake_threshold(0.5) - ctrl._observe_wake_score(0.42, 0.5) # near miss - ctrl._observe_wake_score(0.01, 0.5) # background noise, not interesting - stats = ctrl.wake_stats() - assert len(stats["near_misses"]) == 1 - assert stats["peak"] == pytest.approx(0.42) - - ctrl.reset_wake_stats() - assert ctrl.wake_stats()["near_misses"] == [] - - -# ── file delivery ──────────────────────────────────────────────────────────── - -def test_check_deliveries_downloads_and_saves_queued_files(monkeypatch, ctrl, tmp_path): - monkeypatch.setattr(controller_mod.config, "DELIVERED_FILES_DIR", tmp_path) - monkeypatch.setattr(controller_mod.server_client, "list_outbox_files", - lambda: [{"id": "abc", "name": "report.pdf", "size": 5}]) - monkeypatch.setattr(controller_mod.server_client, "download_outbox_file", - lambda file_id: b"hello" if file_id == "abc" else b"") - - ctrl._check_deliveries() - - assert (tmp_path / "report.pdf").read_bytes() == b"hello" - - -def test_check_deliveries_is_a_noop_when_nothing_queued(monkeypatch, ctrl, tmp_path): - monkeypatch.setattr(controller_mod.config, "DELIVERED_FILES_DIR", tmp_path) - monkeypatch.setattr(controller_mod.server_client, "list_outbox_files", lambda: []) - downloaded = [] - monkeypatch.setattr(controller_mod.server_client, "download_outbox_file", - lambda file_id: downloaded.append(file_id)) - - ctrl._check_deliveries() - - assert downloaded == [] - - -def test_check_deliveries_can_be_disabled(monkeypatch, ctrl): - monkeypatch.setattr(controller_mod.config, "RECEIVE_FILES", False) - called = {"n": 0} - monkeypatch.setattr(controller_mod.server_client, "list_outbox_files", - lambda: called.__setitem__("n", called["n"] + 1)) - - ctrl._check_deliveries() - - assert called["n"] == 0 - - -def test_check_deliveries_logs_and_continues_on_download_failure(monkeypatch, ctrl, tmp_path): - monkeypatch.setattr(controller_mod.config, "DELIVERED_FILES_DIR", tmp_path) - monkeypatch.setattr(controller_mod.server_client, "list_outbox_files", lambda: [ - {"id": "bad", "name": "a.txt", "size": 1}, - {"id": "good", "name": "b.txt", "size": 1}, - ]) - - def fake_download(file_id): - if file_id == "bad": - raise controller_mod.server_client.ServerError("gone") - return b"ok" - - monkeypatch.setattr(controller_mod.server_client, "download_outbox_file", fake_download) - logs = _capture(ctrl.log) - - ctrl._check_deliveries() - - assert (tmp_path / "b.txt").read_bytes() == b"ok" - assert not (tmp_path / "a.txt").exists() - assert any("bad" in msg or "a.txt" in msg for msg in logs) - - -def test_check_deliveries_runs_after_a_conversation_turn(monkeypatch, ctrl, tmp_path): - monkeypatch.setattr(controller_mod.mic, "record_utterance", - lambda *a, **k: np.zeros(10, dtype=np.int16)) - monkeypatch.setattr(controller_mod.stt, "transcribe", lambda pcm: "send me that file") - monkeypatch.setattr(controller_mod.server_client, "converse", - lambda text, on_command=None, on_say=None: Reply("it's on the way")) - monkeypatch.setattr(controller_mod.tts, "speak", - lambda text, on_error=None, should_stop=None, voice_id=None, on_level=None: True) - monkeypatch.setattr(controller_mod.config, "DELIVERED_FILES_DIR", tmp_path) - monkeypatch.setattr(controller_mod.server_client, "list_outbox_files", - lambda: [{"id": "abc", "name": "notes.txt", "size": 2}]) - monkeypatch.setattr(controller_mod.server_client, "download_outbox_file", - lambda file_id: b"hi") - - ctrl._handle_conversation_turn() - - assert (tmp_path / "notes.txt").read_bytes() == b"hi" - - -# ── server-picked voice (the desk API's speak_as marker) ──────────────────── - -def _voice_turn(monkeypatch, ctrl, reply, said="talk like a pirate"): - """Run one full conversation turn whose reply is *reply*, returning the - voice_id each tts.speak() call was given.""" - voices = [] - monkeypatch.setattr(controller_mod.mic, "record_utterance", - lambda *a, **k: np.zeros(10, dtype=np.int16)) - monkeypatch.setattr(controller_mod.stt, "transcribe", lambda pcm: said) - monkeypatch.setattr(controller_mod.server_client, "converse", - lambda text, on_command=None, on_say=None: reply) - monkeypatch.setattr( - controller_mod.tts, "speak", - lambda text, on_error=None, should_stop=None, voice_id=None, on_level=None: - voices.append(voice_id) or True, - ) - ctrl._handle_conversation_turn() - return voices - - -def test_a_speak_as_reply_is_spoken_in_that_voice(monkeypatch, ctrl): - voices = _voice_turn(monkeypatch, ctrl, Reply("Ahoy.", "VOICE1", "Terence")) - assert voices == ["VOICE1"] - assert ctrl.current_voice() == "Terence" - - -def test_the_picked_voice_sticks_for_later_replies(monkeypatch, ctrl): - """The server tags one reply and doesn't keep the id in its history, so - it can't re-request the voice when you say "keep talking like that".""" - monkeypatch.setattr(controller_mod.config, "VOICE_STICKY", True) - _voice_turn(monkeypatch, ctrl, Reply("Ahoy.", "VOICE1", "Terence")) - voices = _voice_turn(monkeypatch, ctrl, Reply("Still me."), said="and now?") - assert voices == ["VOICE1"] - - -def test_voice_stickiness_can_be_turned_off(monkeypatch, ctrl): - monkeypatch.setattr(controller_mod.config, "VOICE_STICKY", False) - _voice_turn(monkeypatch, ctrl, Reply("Ahoy.", "VOICE1", "Terence")) - voices = _voice_turn(monkeypatch, ctrl, Reply("Back to normal."), said="and now?") - assert voices == [None] - assert ctrl.current_voice() == "" - - -def test_a_new_pick_replaces_the_old_one(monkeypatch, ctrl): - changes = _capture(ctrl.voice_changed) - _voice_turn(monkeypatch, ctrl, Reply("Ahoy.", "VOICE1", "Terence")) - voices = _voice_turn(monkeypatch, ctrl, Reply("こんにちは。", "VOICE2", "Asahi"), - said="say that in Japanese") - assert voices == ["VOICE2"] - assert changes == ["Terence", "Asahi"] - - -def test_resetting_the_voice_goes_back_to_the_default(monkeypatch, ctrl): - _voice_turn(monkeypatch, ctrl, Reply("Ahoy.", "VOICE1", "Terence")) - changes = _capture(ctrl.voice_changed) - - ctrl.reset_voice() - - assert ctrl.current_voice() == "" - assert changes == [""] # the tray's menu entry follows this signal - voices = _voice_turn(monkeypatch, ctrl, Reply("Normal again."), said="hi") - assert voices == [None] - - -def test_an_unnamed_voice_still_reports_something_resettable(monkeypatch, ctrl): - """voice_name is optional server-side — falling back to the id keeps the - tray entry from reading "now: " with nothing after it.""" - _voice_turn(monkeypatch, ctrl, Reply("Ahoy.", "VOICE1")) - assert ctrl.current_voice() == "VOICE1" - - -def test_petctl_voice_reset_returns_bolt_to_his_own_voice(monkeypatch, ctrl): - """The server can pick a voice but can't ask for the default back — it - was never told what Bolt's own voice id is. This is how it asks.""" - ran = [] - monkeypatch.setattr(controller_mod.server_client, "run_local_command", lambda cmd: ran.append(cmd)) - _voice_turn(monkeypatch, ctrl, Reply("Ahoy.", "VOICE1", "Terence")) - - output = ctrl._handle_command("petctl voice reset") - - assert ran == [] # never reaches a shell, like every other petctl verb - assert "Terence" in output # the server can't see the voice; tell it what changed - assert ctrl.current_voice() == "" - assert _voice_turn(monkeypatch, ctrl, Reply("Normal again."), said="hi") == [None] - - -def test_petctl_voice_reset_says_so_when_there_was_nothing_to_reset(ctrl): - assert "already" in ctrl._handle_command("petctl voice reset") - - -# ── dialoguectl (multi-voice scenes) ──────────────────────────────────────── - -def _dialogue_command(*lines): - import json - return "dialoguectl " + json.dumps({"lines": list(lines)}) - - -def test_dialoguectl_never_reaches_the_shell(monkeypatch, ctrl): - ran = [] - monkeypatch.setattr(controller_mod.server_client, "run_local_command", lambda cmd: ran.append(cmd)) - monkeypatch.setattr(controller_mod.config, "ELEVENLABS_VOICE_ID", "aaorr6ZHIL88gEexu7dC") - monkeypatch.setattr(controller_mod.tts, "synthesize_dialogue", - lambda inputs, model_id=None, stability=None: (np.zeros(4, dtype=np.int16), 24000)) - monkeypatch.setattr(controller_mod.tts, "play_pcm", - lambda pcm, rate, should_stop=None, on_level=None: True) - - output = ctrl._handle_command(_dialogue_command({"voice": "self", "text": "[cheerfully] hi"})) - - assert ran == [] - assert "[dialogue] played 1 line" in output - - -def test_a_scene_shows_in_the_bubble_with_the_delivery_tags_stripped(monkeypatch, ctrl): - monkeypatch.setattr(controller_mod.config, "ELEVENLABS_VOICE_ID", "aaorr6ZHIL88gEexu7dC") - monkeypatch.setattr(controller_mod.config, "DIALOGUE_VOICES", "narrator:9BWtsMINqrJLrRacOk9x") - monkeypatch.setattr(controller_mod.tts, "synthesize_dialogue", - lambda inputs, model_id=None, stability=None: (np.zeros(4, dtype=np.int16), 24000)) - monkeypatch.setattr(controller_mod.tts, "play_pcm", lambda pcm, rate, should_stop=None, on_level=None: True) - said = _capture(ctrl.said) - - ctrl._handle_command(_dialogue_command( - {"voice": "self", "text": "[cheerfully] Hello there!"}, - {"voice": "narrator", "text": "[whispering] He is lying."}, - )) - - assert said == ["Hello there! He is lying."] - assert ctrl.history.last().text == "Hello there! He is lying." - - -def test_a_mid_turn_scene_returns_to_thinking_not_idle(monkeypatch, ctrl): - """The server is still waiting on the tool result, so the pet talks and - goes back to waiting — dropping to IDLE would look like the turn ended.""" - monkeypatch.setattr(controller_mod.config, "ELEVENLABS_VOICE_ID", "aaorr6ZHIL88gEexu7dC") - monkeypatch.setattr(controller_mod.tts, "synthesize_dialogue", - lambda inputs, model_id=None, stability=None: (np.zeros(4, dtype=np.int16), 24000)) - monkeypatch.setattr(controller_mod.tts, "play_pcm", lambda pcm, rate, should_stop=None, on_level=None: True) - ctrl._state.transition(PetState.LISTENING) - ctrl._state.transition(PetState.THINKING) - states = _capture(ctrl.state_changed) - - ctrl._handle_command(_dialogue_command({"voice": "self", "text": "hi"})) - - assert states == ["talking", "thinking"] - assert ctrl._state.state == PetState.THINKING - - -def test_the_scene_uses_a_voice_the_server_picked_with_speak_as(monkeypatch, ctrl): - monkeypatch.setattr(controller_mod.config, "ELEVENLABS_VOICE_ID", "aaorr6ZHIL88gEexu7dC") - seen = {} - - def capture(inputs, model_id=None, stability=None): - seen["inputs"] = inputs - return np.zeros(4, dtype=np.int16), 24000 - - monkeypatch.setattr(controller_mod.tts, "synthesize_dialogue", capture) - monkeypatch.setattr(controller_mod.tts, "play_pcm", lambda pcm, rate, should_stop=None, on_level=None: True) - ctrl._apply_voice(controller_mod.server_client.Reply("ok", "PICKEDvoice123456789", "Terence")) - - ctrl._handle_command(_dialogue_command({"voice": "self", "text": "hi"})) - - assert seen["inputs"][0]["voice_id"] == "PICKEDvoice123456789" - - -def test_a_synthesis_failure_is_reported_back_for_bolt_to_retry(monkeypatch, ctrl): - monkeypatch.setattr(controller_mod.config, "ELEVENLABS_VOICE_ID", "aaorr6ZHIL88gEexu7dC") - - def boom(inputs, model_id=None, stability=None): - raise controller_mod.tts.TtsError("voice_id not found") - - monkeypatch.setattr(controller_mod.tts, "synthesize_dialogue", boom) - - output = ctrl._handle_command(_dialogue_command({"voice": "self", "text": "hi"})) - - assert "couldn't synthesize" in output and "voice_id not found" in output - assert ctrl._state.state == PetState.IDLE # nothing left half-transitioned - - -def test_a_bad_voice_name_comes_back_as_advice_not_an_exception(monkeypatch, ctrl): - monkeypatch.setattr(controller_mod.config, "DIALOGUE_VOICES", "narrator:9BWtsMINqrJLrRacOk9x") - output = ctrl._handle_command(_dialogue_command({"voice": "wizard", "text": "hi"})) - assert "unknown voice" in output and "narrator" in output - - -def test_dialogue_can_be_switched_off_on_this_device(monkeypatch, ctrl): - monkeypatch.setattr(controller_mod.config, "DIALOGUE", False) - called = [] - monkeypatch.setattr(controller_mod.tts, "synthesize_dialogue", - lambda *a, **k: called.append(1)) - output = ctrl._handle_command(_dialogue_command({"voice": "self", "text": "hi"})) - assert "disabled" in output and called == [] - - -def test_talking_over_a_scene_is_reported_up_the_relay(monkeypatch, ctrl): - monkeypatch.setattr(controller_mod.config, "ELEVENLABS_VOICE_ID", "aaorr6ZHIL88gEexu7dC") - monkeypatch.setattr(controller_mod.tts, "synthesize_dialogue", - lambda inputs, model_id=None, stability=None: (np.zeros(4, dtype=np.int16), 24000)) - monkeypatch.setattr(controller_mod.tts, "play_pcm", - lambda pcm, rate, should_stop=None, on_level=None: False) # barge-in - - output = ctrl._handle_command(_dialogue_command({"voice": "self", "text": "hi"})) - - assert "interrupted" in output - - -# ── petctl self_restart ───────────────────────────────────────────────────── - -def test_self_restart_arms_after_the_turn_rather_than_dying_mid_relay(monkeypatch, ctrl, tmp_path): - """Restarting inline would kill the HTTP tool relay before the result was - posted, and the server would wait out its timeout on a turn that can never - finish. So the command returns, the turn completes, *then* the pet dies.""" - monkeypatch.setattr(controller_mod.self_restart, "DEFAULT_STATE_PATH", tmp_path / "ctx.json") - monkeypatch.setattr(controller_mod.self_restart, "preflight", lambda *a, **k: None) - restarts = _capture(ctrl.restart_requested) - - output = ctrl._handle_command("petctl self_restart check the new dialogue code") - - assert "restarting as soon as this turn finishes" in output - assert restarts == [] # nothing has happened yet - - assert ctrl._maybe_self_restart() is True - assert restarts and "check the new dialogue code" in restarts[0] - - -def test_a_broken_edit_is_reported_instead_of_leaving_nothing_running(monkeypatch, ctrl, tmp_path): - monkeypatch.setattr(controller_mod.self_restart, "DEFAULT_STATE_PATH", tmp_path / "ctx.json") - - def boom(*args, **kwargs): - raise controller_mod.self_restart.RestartError( - "the current code does not import, so restarting would leave you with " - "nothing running. Fix this first:\nSyntaxError: invalid syntax" - ) - - monkeypatch.setattr(controller_mod.self_restart, "preflight", boom) - restarts = _capture(ctrl.restart_requested) - - output = ctrl._handle_command("petctl self_restart try the new code") - - assert "SyntaxError" in output and "refused" in output - assert ctrl._maybe_self_restart() is False - assert restarts == [] - - -def test_a_second_restart_request_in_one_turn_is_a_no_op(monkeypatch, ctrl, tmp_path): - monkeypatch.setattr(controller_mod.self_restart, "DEFAULT_STATE_PATH", tmp_path / "ctx.json") - monkeypatch.setattr(controller_mod.self_restart, "preflight", lambda *a, **k: None) - ctrl._handle_command("petctl self_restart first") - assert "already armed" in ctrl._handle_command("petctl self_restart second") - - -def test_self_restart_can_be_switched_off_on_this_device(monkeypatch, ctrl): - monkeypatch.setattr(controller_mod.config, "SELF_RESTART", False) - checked = [] - monkeypatch.setattr(controller_mod.self_restart, "preflight", - lambda *a, **k: checked.append(1)) - assert "disabled" in ctrl._handle_command("petctl self_restart go") - assert checked == [] - - -def test_coming_back_up_reports_to_the_server_and_speaks_the_reply(monkeypatch, ctrl, tmp_path): - """The half that makes it a loop: the new process tells Bolt it's back and - why, and his answer is spoken like any other turn.""" - state = tmp_path / "ctx.json" - monkeypatch.setattr(controller_mod.self_restart, "DEFAULT_STATE_PATH", state) - controller_mod.self_restart.arm("check the walk cycle", version="0.2.3", - path=state, now=1000.0) - sent, spoken = [], [] - monkeypatch.setattr(controller_mod.server_client, "converse", - lambda text, on_command=None, on_say=None: sent.append(text) or Reply("Good, it's up.")) - monkeypatch.setattr(controller_mod.tts, "speak", - lambda text, on_error=None, should_stop=None, voice_id=None, on_level=None: - spoken.append(text) or True) - - ctrl._report_self_restart() - - assert "[pet self-restart]" in sent[0] and "check the walk cycle" in sent[0] - assert spoken == ["Good, it's up."] - # Consumed, so the next start doesn't announce the same restart again. - assert controller_mod.self_restart.load(state) is None - - -def test_an_ordinary_start_reports_nothing(monkeypatch, ctrl, tmp_path): - monkeypatch.setattr(controller_mod.self_restart, "DEFAULT_STATE_PATH", tmp_path / "none.json") - called = [] - monkeypatch.setattr(controller_mod.server_client, "converse", - lambda text, on_command=None, on_say=None: called.append(text)) - - ctrl._report_self_restart() - - assert called == [] - - -# ── holding line: "give me a sec" while a tool runs ──────────────────────── -# The server used to discard whatever the model wrote alongside a tool call, -# so the whole round trip was silence — and the model, with no evidence its -# sentence landed, said it again in the final reply. - -def test_a_holding_line_is_spoken_before_the_tool_runs(monkeypatch, ctrl): - order = [] - monkeypatch.setattr(controller_mod.tts, "speak", - lambda text, on_error=None, should_stop=None, voice_id=None, on_level=None: - order.append(("spoke", text)) or True) - monkeypatch.setattr(controller_mod.server_client, "run_local_command", - lambda cmd: order.append(("ran", cmd)) or "[exit 0]") - - ctrl._speak_holding("Give me a sec.") - ctrl._handle_command("df -h /") - - assert order == [("spoke", "Give me a sec."), ("ran", "df -h /")] - - -def test_the_holding_line_shows_in_the_bubble_but_not_the_transcript(monkeypatch, ctrl): - """It's filler. The transcript should keep the actual answer.""" - monkeypatch.setattr(controller_mod.tts, "speak", - lambda text, on_error=None, should_stop=None, voice_id=None, on_level=None: True) - said = _capture(ctrl.said) - - ctrl._speak_holding("I'll check on that — one moment.") - - assert said == ["I'll check on that — one moment."] - assert ctrl.history.entries() == [] - - -def test_a_holding_line_returns_to_waiting_not_idle(monkeypatch, ctrl): - monkeypatch.setattr(controller_mod.tts, "speak", - lambda text, on_error=None, should_stop=None, voice_id=None, on_level=None: True) - ctrl._state.transition(PetState.LISTENING) - ctrl._state.transition(PetState.THINKING) - states = _capture(ctrl.state_changed) - - ctrl._speak_holding("one sec") - - assert states == ["talking", "thinking"] - - -def test_the_relay_speaks_whatever_the_server_attaches(monkeypatch, ctrl): - spoken = [] - monkeypatch.setattr(controller_mod.tts, "speak", - lambda text, on_error=None, should_stop=None, voice_id=None, on_level=None: - spoken.append(text) or True) - monkeypatch.setattr(controller_mod.mic, "record_utterance", - lambda *a, **k: np.zeros(10, dtype=np.int16)) - monkeypatch.setattr(controller_mod.stt, "transcribe", lambda pcm: "what's in that folder?") - - def fake_converse(text, on_command=None, on_say=None): - on_say("Let me check that for you.") # what the server now sends - on_command("filectl {\"op\": \"list\", \"path\": \"/tmp\"}") - return Reply("It's got three files in it.") - - monkeypatch.setattr(controller_mod.server_client, "converse", fake_converse) - - ctrl._handle_conversation_turn() - - assert spoken == ["Let me check that for you.", "It's got three files in it."] - - -# ── streamed replies ─────────────────────────────────────────────────────── -# Without streaming the pet waits out the entire model call before saying a -# word. With it, the wait is time-to-first-sentence. - -def test_each_sentence_is_spoken_as_it_arrives(monkeypatch, ctrl): - monkeypatch.setattr(controller_mod.config, "STREAMING_REPLIES", True) - spoken = [] - monkeypatch.setattr(controller_mod.tts, "speak", - lambda text, on_error=None, should_stop=None, voice_id=None, on_level=None: - spoken.append(text) or True) - - def fake_stream(text, on_say, on_command=None, timeout=180.0): - on_say("The disk is fine.") - on_say("About sixty percent used.") - return controller_mod.server_client.Reply( - "The disk is fine. About sixty percent used.", spoken=True) - - monkeypatch.setattr(controller_mod.server_client, "converse_stream", fake_stream) - monkeypatch.setattr(controller_mod.mic, "record_utterance", - lambda *a, **k: np.zeros(10, dtype=np.int16)) - monkeypatch.setattr(controller_mod.stt, "transcribe", lambda pcm: "how's the disk?") - - ctrl._handle_conversation_turn() - - assert spoken == ["The disk is fine.", "About sixty percent used."] - # ...and the empty final reply is not spoken as an extra blank utterance. - assert ctrl.history.entries()[-1].text == "About sixty percent used." - - -def test_a_stream_that_fails_before_speaking_falls_back_silently(monkeypatch, ctrl): - monkeypatch.setattr(controller_mod.config, "STREAMING_REPLIES", True) - spoken = [] - monkeypatch.setattr(controller_mod.tts, "speak", - lambda text, on_error=None, should_stop=None, voice_id=None, on_level=None: - spoken.append(text) or True) - - def broken_stream(text, on_say, on_command=None, timeout=180.0): - raise controller_mod.server_client.ServerError("no streaming endpoint") - - monkeypatch.setattr(controller_mod.server_client, "converse_stream", broken_stream) - monkeypatch.setattr(controller_mod.server_client, "converse", - lambda text, on_command=None, on_say=None: Reply("Fell back fine.")) - - assert ctrl._ask_server("hello").text == "Fell back fine." - assert spoken == [] # nothing was said twice - - -def test_streaming_can_be_switched_off(monkeypatch, ctrl): - monkeypatch.setattr(controller_mod.config, "STREAMING_REPLIES", False) - called = [] - monkeypatch.setattr(controller_mod.server_client, "converse_stream", - lambda *a, **k: called.append(1)) - monkeypatch.setattr(controller_mod.server_client, "converse", - lambda text, on_command=None, on_say=None: Reply("plain path")) - - assert ctrl._ask_server("hi").text == "plain path" - assert called == [] - - -def test_talking_over_a_streamed_reply_still_interrupts(monkeypatch, ctrl): - monkeypatch.setattr(controller_mod.config, "STREAMING_REPLIES", True) - monkeypatch.setattr(controller_mod.tts, "speak", - lambda text, on_error=None, should_stop=None, voice_id=None, on_level=None: False) - - ctrl._speak_stream_chunk("A long explanation you cut short.") - - assert ctrl._talk_now.is_set() - - -# ── notification latency (reported 2026-08-02: 5-10 minutes) ─────────────── -# Draining was wired to the heartbeat's 60s interval, and a heartbeat that -# landed mid-conversation stamped its clock before noticing — so it burned the -# slot and waited another full interval. Several of those in a row is minutes. - -def test_a_notification_goes_out_on_the_next_tick_not_the_next_heartbeat(monkeypatch, ctrl): - monkeypatch.setattr(controller_mod.config, "NOTIFICATION_MIN_INTERVAL_SECONDS", 0) - ctrl._notification_gate = controller_mod.notifications.NotificationGate("", 0) - sent = [] - monkeypatch.setattr(controller_mod.server_client, "converse", - lambda text, on_command=None, on_say=None: - sent.append(text) or Reply("noted")) - monkeypatch.setattr(controller_mod.tts, "speak", - lambda text, on_error=None, should_stop=None, voice_id=None, on_level=None: True) - monkeypatch.setattr(controller_mod.server_client, "report_status", lambda: None) - # A heartbeat has just run, so the next one is a full interval away. - ctrl._last_heartbeat = controller_mod.time.monotonic() - - ctrl._queue_notification(Notification(app="Signal", summary="Harry", body="you there?")) - ctrl._maybe_heartbeat() - - assert sent and "Harry" in sent[0] - - -def test_a_heartbeat_skipped_mid_conversation_does_not_burn_its_slot(monkeypatch, ctrl): - monkeypatch.setattr(controller_mod.server_client, "report_status", lambda: None) - ctrl._last_heartbeat = 0.0 - ctrl._state.transition(PetState.LISTENING) - ctrl._state.transition(PetState.THINKING) - - ctrl._maybe_heartbeat() # due, but the pet is busy - assert ctrl._last_heartbeat == 0.0, "the clock must not advance on a skipped tick" - - ctrl._state.transition(PetState.IDLE) - ctrl._maybe_heartbeat() # free now — runs immediately - assert ctrl._last_heartbeat > 0.0 - - -def test_notifications_wait_while_the_pet_is_mid_turn(monkeypatch, ctrl): - """Speaking over the answer they're already getting would be worse than - waiting a couple of seconds.""" - ctrl._notification_gate = controller_mod.notifications.NotificationGate("", 0) - sent = [] - monkeypatch.setattr(controller_mod.server_client, "converse", - lambda text, on_command=None, on_say=None: sent.append(text)) - ctrl._state.transition(PetState.LISTENING) - - ctrl._queue_notification(Notification(app="CI", summary="Build finished", body="")) - ctrl._maybe_heartbeat() - - assert sent == [] - assert len(ctrl._pending_notifications) == 1 # kept, not dropped - - -def test_a_second_message_inside_the_rate_limit_is_no_longer_lost(monkeypatch, ctrl): - """The gate used to drop it. With NOTIFICATION_MIN_INTERVAL_SECONDS=60, - two texts a minute apart meant you heard about one of them.""" - ctrl._notification_gate = controller_mod.notifications.NotificationGate("", 60) - sent = [] - monkeypatch.setattr(controller_mod.server_client, "converse", - lambda text, on_command=None, on_say=None: - sent.append(text) or Reply("ok")) - monkeypatch.setattr(controller_mod.tts, "speak", - lambda text, on_error=None, should_stop=None, voice_id=None, on_level=None: True) - - ctrl._queue_notification(Notification(app="Signal", summary="Harry", body="you there?")) - ctrl._queue_notification(Notification(app="Signal", summary="Harry", body="it's urgent")) - ctrl._drain_notifications() - - assert len(sent) == 1 # one round trip... - assert "you there?" in sent[0] and "it's urgent" in sent[0] # ...both messages - assert "2 desktop notifications" in sent[0] - - -def test_the_filter_still_applies(ctrl): - ctrl._notification_gate = controller_mod.notifications.NotificationGate("deploy", 0) - ctrl._queue_notification(Notification(app="Chat", summary="lunch?", body="")) - assert ctrl._pending_notifications == [] - - -def test_a_notification_storm_cannot_become_an_unbounded_backlog(ctrl): - ctrl._notification_gate = controller_mod.notifications.NotificationGate("", 0) - for index in range(40): - ctrl._queue_notification(Notification(app="Spam", summary=f"#{index}", body="")) - - assert len(ctrl._pending_notifications) == controller_mod._MAX_PENDING_NOTIFICATIONS - assert "#39" in ctrl._pending_notifications[-1].as_text() # newest kept diff --git a/tests/test_dialogue.py b/tests/test_dialogue.py deleted file mode 100644 index 4745c5f..0000000 --- a/tests/test_dialogue.py +++ /dev/null @@ -1,236 +0,0 @@ -"""`dialoguectl` — multi-voice scene parsing, voice resolution, API limits, -and the request the ElevenLabs Text to Dialogue endpoint actually gets. - -Pure logic plus one mocked HTTP call: no audio device, no network, no display. -""" - -import sys -from pathlib import Path -from unittest.mock import MagicMock, patch - -import numpy as np -import pytest - -sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) - -from bolt_pet import dialogue -from bolt_pet.audio import tts - -SELF_ID = "aaorr6ZHIL88gEexu7dC" -NARRATOR_ID = "9BWtsMINqrJLrRacOk9x" -VILLAIN_ID = "IKne3meq5aSn9XLyUdCD" -VOICES = {"narrator": NARRATOR_ID, "villain": VILLAIN_ID} - - -def _scene(*lines): - return '{"lines": [' + ", ".join(lines) + "]}" - - -# ── parsing ───────────────────────────────────────────────────────────────── - -def test_non_dialogue_commands_are_left_alone(): - assert dialogue.parse("ls -la") is None - assert dialogue.parse('filectl {"op": "list"}') is None - assert dialogue.parse("") is None - # "dialogues" must not be mistaken for the "dialogue" prefix - assert dialogue.parse("dialogues --list") is None - - -def test_a_scene_parses_into_lines(): - action = dialogue.parse( - 'dialoguectl ' + _scene( - '{"voice": "self", "text": "[cheerfully] Hello, how are you?"}', - '{"voice": "villain", "text": "[stuttering] I am... fine."}', - ) - ) - assert action["action"] == "dialogue" - assert [line["voice"] for line in action["lines"]] == ["self", "villain"] - assert action["lines"][0]["text"].startswith("[cheerfully]") - - -def test_the_elevenlabs_field_names_are_accepted_too(): - """The model has read that API; copying its shape is the obvious thing to - try, so 'inputs'/'voice_id' work as well as 'lines'/'voice'.""" - action = dialogue.parse( - 'dialoguectl {"inputs": [{"voice_id": "%s", "text": "hi"}]}' % NARRATOR_ID - ) - assert action["lines"] == [{"voice": NARRATOR_ID, "text": "hi"}] - - -def test_a_line_with_no_voice_defaults_to_the_pet_itself(): - action = dialogue.parse('dialoguectl {"lines": [{"text": "just me talking"}]}') - assert action["lines"][0]["voice"] == "self" - - -def test_truncated_json_explains_the_one_line_rule(): - """The real failure mode: the server's command extractor stops at the - first newline, so a multi-line payload arrives cut in half. The error has - to name the cause, since Bolt is the one who has to fix it.""" - with pytest.raises(dialogue.DialogueError, match="one line"): - dialogue.parse('dialoguectl {"lines": [{"voice": "self", "text": "hi"') - - -def test_an_empty_or_shapeless_payload_is_rejected(): - with pytest.raises(dialogue.DialogueError, match="needs a JSON argument"): - dialogue.parse("dialoguectl") - with pytest.raises(dialogue.DialogueError, match="non-empty"): - dialogue.parse('dialoguectl {"lines": []}') - with pytest.raises(dialogue.DialogueError, match="no text"): - dialogue.parse('dialoguectl {"lines": [{"voice": "self", "text": " "}]}') - - -def test_optional_model_and_stability_ride_along(): - action = dialogue.parse( - 'dialoguectl {"model_id": "eleven_v3", "stability": 0.8, ' - '"lines": [{"text": "hi"}]}' - ) - assert action["model"] == "eleven_v3" - assert action["stability"] == 0.8 - - -# ── voice resolution ──────────────────────────────────────────────────────── - -def test_named_voices_resolve_from_the_configured_cast(): - action = dialogue.parse('dialoguectl ' + _scene( - '{"voice": "narrator", "text": "Once upon a time."}', - '{"voice": "villain", "text": "Not this again."}', - )) - inputs = dialogue.resolve(action, voices=VOICES, self_voice=SELF_ID) - assert [entry["voice_id"] for entry in inputs] == [NARRATOR_ID, VILLAIN_ID] - - -def test_self_tracks_the_voice_the_pet_is_currently_using(): - """A scene featuring Bolt should sound like whoever Bolt currently is — - including a voice the server picked mid-conversation with speak_as.""" - action = dialogue.parse('dialoguectl {"lines": [{"voice": "self", "text": "hi"}]}') - picked = "VOICEfromSPEAKas1234" - assert dialogue.resolve(action, self_voice=picked)[0]["voice_id"] == picked - - -def test_a_raw_voice_id_passes_straight_through(): - action = dialogue.parse('dialoguectl {"lines": [{"voice": "%s", "text": "hi"}]}' % NARRATOR_ID) - assert dialogue.resolve(action, self_voice=SELF_ID)[0]["voice_id"] == NARRATOR_ID - - -def test_an_unknown_name_lists_what_is_available(): - action = dialogue.parse('dialoguectl {"lines": [{"voice": "wizard", "text": "hi"}]}') - with pytest.raises(dialogue.DialogueError) as excinfo: - dialogue.resolve(action, voices=VOICES, self_voice=SELF_ID) - message = str(excinfo.value) - assert "wizard" in message and "narrator" in message and "villain" in message - - -def test_self_without_a_configured_voice_says_so(): - action = dialogue.parse('dialoguectl {"lines": [{"voice": "self", "text": "hi"}]}') - with pytest.raises(dialogue.DialogueError, match="ELEVENLABS_VOICE_ID"): - dialogue.resolve(action, self_voice="") - - -def test_the_voice_map_parser_skips_typos_instead_of_dying(): - voices = dialogue.parse_voice_map(f"narrator:{NARRATOR_ID}, broken-entry, villain:{VILLAIN_ID}") - assert voices == {"narrator": NARRATOR_ID, "villain": VILLAIN_ID} - assert dialogue.parse_voice_map("") == {} - - -# ── API limits, enforced before the request goes out ──────────────────────── - -def test_too_many_distinct_voices_is_refused_locally(): - inputs = [{"text": "hi", "voice_id": f"voice{index:015d}"} for index in range(11)] - with pytest.raises(dialogue.DialogueError, match="limit is 10"): - dialogue.check_limits(inputs) - - -def test_an_over_long_scene_is_refused_with_advice(): - inputs = [{"text": "x" * 1100, "voice_id": SELF_ID} for _ in range(2)] - with pytest.raises(dialogue.DialogueError) as excinfo: - dialogue.check_limits(inputs) - assert "Split it" in str(excinfo.value) # actionable, since Bolt reads this - - -# ── display / reporting ───────────────────────────────────────────────────── - -def test_delivery_tags_are_stripped_from_what_the_bubble_shows(): - action = dialogue.parse('dialoguectl ' + _scene( - '{"voice": "self", "text": "[cheerfully] Hello there!"}', - '{"voice": "narrator", "text": "[whispering] He is lying."}', - )) - assert dialogue.spoken_text(action) == "Hello there! He is lying." - - -def test_the_relay_report_names_the_cast(): - action = dialogue.parse('dialoguectl ' + _scene( - '{"voice": "self", "text": "one"}', '{"voice": "narrator", "text": "two"}', - )) - assert dialogue.describe(action).startswith( - "[dialogue] played 2 lines in 2 voices: narrator, self") - - -def test_the_report_says_the_scene_was_already_heard(): - """Observed 2026-07-31: the scene played, then the final reply summarised - it, so the pet said the same thing twice with nothing in between. The - model cannot know the audio already happened unless it is told.""" - action = dialogue.parse('dialoguectl {"lines": [{"text": "hello"}]}') - report = dialogue.describe(action) - assert "HEARD this already" in report - assert "Do not repeat" in report - - -# ── the HTTP request ──────────────────────────────────────────────────────── - -def _pcm_response(samples=(1, 2, 3, 4)): - response = MagicMock() - response.content = np.array(samples, dtype=np.int16).tobytes() - response.raise_for_status = MagicMock() - return response - - -def test_the_request_matches_the_text_to_dialogue_api(monkeypatch): - monkeypatch.setattr(tts.config, "ELEVENLABS_API_KEY", "test-key") - monkeypatch.setattr(tts.config, "TTS_SAMPLE_RATE", 24000) - monkeypatch.setattr(tts.config, "DIALOGUE_MODEL_ID", "eleven_v3") - inputs = [ - {"text": "[cheerfully] Hello", "voice_id": NARRATOR_ID}, - {"text": "[stuttering] H-hi", "voice_id": VILLAIN_ID}, - ] - with patch.object(tts.requests, "post", return_value=_pcm_response()) as post: - pcm, rate = tts.synthesize_dialogue(inputs) - - assert rate == 24000 and pcm.tolist() == [1, 2, 3, 4] - args, kwargs = post.call_args - assert args[0] == "https://api.elevenlabs.io/v1/text-to-dialogue" - assert kwargs["params"] == {"output_format": "pcm_24000"} - assert kwargs["headers"] == {"xi-api-key": "test-key"} - assert kwargs["json"]["inputs"] == inputs - assert kwargs["json"]["model_id"] == "eleven_v3" - assert "settings" not in kwargs["json"] # omitted unless asked for - - -def test_stability_is_only_sent_when_given(monkeypatch): - monkeypatch.setattr(tts.config, "ELEVENLABS_API_KEY", "test-key") - with patch.object(tts.requests, "post", return_value=_pcm_response()) as post: - tts.synthesize_dialogue([{"text": "hi", "voice_id": SELF_ID}], stability=0.3) - assert post.call_args.kwargs["json"]["settings"] == {"stability": 0.3} - - -def test_a_rejected_request_surfaces_what_the_api_said(monkeypatch): - """The API explains refusals in the body; Bolt reads this through the tool - relay, so it has to reach him rather than being flattened to '422'.""" - monkeypatch.setattr(tts.config, "ELEVENLABS_API_KEY", "test-key") - failure = MagicMock() - failure.text = '{"detail": "voice_id not found"}' - error = Exception("422 Client Error") - error.response = failure - response = MagicMock() - response.raise_for_status = MagicMock(side_effect=error) - - with patch.object(tts.requests, "post", return_value=response): - with pytest.raises(tts.TtsError, match="voice_id not found"): - tts.synthesize_dialogue([{"text": "hi", "voice_id": "nope"}]) - - -def test_no_api_key_fails_before_the_request(monkeypatch): - monkeypatch.setattr(tts.config, "ELEVENLABS_API_KEY", "") - with patch.object(tts.requests, "post") as post: - with pytest.raises(tts.TtsError): - tts.synthesize_dialogue([{"text": "hi", "voice_id": SELF_ID}]) - post.assert_not_called() diff --git a/tests/test_dictation.py b/tests/test_dictation.py new file mode 100644 index 0000000..10bac77 --- /dev/null +++ b/tests/test_dictation.py @@ -0,0 +1,295 @@ +"""Dictation without a mic or a server: a fake PortAudio stream we push +frames into, and a fake /desk/stt socket that answers like Deepgram.""" + +import array +import json +import os +import queue +import sys +import threading +import time +from pathlib import Path + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +import pytest + +from bolt_console import dictation + + +def frame(level: int, samples: int = 1280) -> bytes: + return array.array("h", [level, -level] * (samples // 2)).tobytes() + + +def result(text, final): + return json.dumps({"type": "Results", "is_final": final, + "channel": {"alternatives": [{"transcript": text}]}}) + + +class FakeSocket: + """Answers each audio chunk with the next scripted message; on CloseStream + sends whatever is left, then closes.""" + + def __init__(self, replies=()): + self.replies = list(replies) + self.audio = [] + self.closed_stream = False + self._inbox = queue.Queue() + + def send_binary(self, data): + self.audio.append(data) + if self.replies and len(self.audio) % 2 == 0: + self._inbox.put(self.replies.pop(0)) + + def send(self, text): + if json.loads(text).get("type") == "CloseStream": + self.closed_stream = True + for reply in self.replies: + self._inbox.put(reply) + self._inbox.put("") + + def recv(self): + return self._inbox.get(timeout=5) + + def close(self): + self._inbox.put("") + + +class FakeStream: + def __init__(self): + self.callback = None + self.stopped = False + + def open(self, callback, _rate): + self.callback = callback + return self + + def push(self, *chunks): + for chunk in chunks: + self.callback(chunk) + + def stop(self): + self.stopped = True + + def close(self): + pass + + +class Recorder: + def __init__(self): + self.updates, self.levels, self.done = [], [], threading.Event() + self.result = None + + def kwargs(self): + return dict(on_update=lambda f, i: self.updates.append((f, i)), + on_level=self.levels.append, + on_done=lambda text, err: (setattr(self, "result", (text, err)), self.done.set())) + + +def make(sockets, stream, rec, **extra): + sockets = list(sockets) + + def connect(_rate): + item = sockets.pop(0) + if isinstance(item, Exception): + raise item + return item + + return dictation.Dictation(**rec.kwargs(), connect_fn=connect, open_stream_fn=stream.open, **extra) + + +def test_live_words_arrive_while_talking_and_stop_returns_the_text(): + sock = FakeSocket([result("open the", False), result("open the readme", True), + result("please", True)]) + stream, rec = FakeStream(), Recorder() + d = make([sock], stream, rec) + d.start() + assert d.live + stream.push(*[frame(3000)] * 4) + deadline = time.monotonic() + 3 + while len(rec.updates) < 2 and time.monotonic() < deadline: + time.sleep(0.01) + assert rec.updates[0] == ("", "open the") + assert rec.updates[1] == ("open the readme", "") + d.stop() + assert rec.done.wait(5) + assert rec.result == ("open the readme please", None) + assert sock.closed_stream and stream.stopped + assert max(rec.levels) > 0 and rec.levels[-1] == 0.0 + + +def test_no_live_socket_means_the_whole_recording_is_sent_on_stop(): + late = FakeSocket([result("hello bolt", True)]) + stream, rec = FakeStream(), Recorder() + d = make([ConnectionError("down"), late], stream, rec) + d.start() + assert not d.live + stream.push(frame(3000), frame(3000), frame(0)) + time.sleep(0.3) + d.stop() + assert rec.done.wait(5) + assert rec.result == ("hello bolt", None) + assert b"".join(late.audio) == frame(3000) * 2 + frame(0) + + +def test_a_socket_dying_mid_recording_does_not_duplicate_words(): + class Dying(FakeSocket): + def send_binary(self, data): + if len(self.audio) >= 1: + raise OSError("connection reset") + super().send_binary(data) + self._inbox.put(result("half", True)) + + retry = FakeSocket([result("the whole thing", True)]) + stream, rec = FakeStream(), Recorder() + d = make([Dying(), retry], stream, rec) + d.start() + stream.push(frame(3000), frame(3000), frame(3000)) + time.sleep(0.3) + d.stop() + assert rec.done.wait(5) + assert rec.result == ("the whole thing", None) # not "half the whole thing" + + +def test_pausing_after_speech_stops_on_its_own(): + sock = FakeSocket([result("done talking", True)]) + stream, rec = FakeStream(), Recorder() + stopping = threading.Event() + d = make([sock], stream, rec, silence_seconds=0.3, on_stopping=stopping.set) + d.start() + stream.push(frame(3000), frame(3000)) + for _ in range(8): + stream.push(frame(10)) + time.sleep(0.05) + assert rec.done.wait(5) # nobody called stop() + assert stopping.is_set() + assert rec.result == ("done talking", None) + + +def test_silence_alone_never_auto_stops(): + stream, rec = FakeStream(), Recorder() + d = make([FakeSocket()], stream, rec, silence_seconds=0.2) + d.start() + for _ in range(8): + stream.push(frame(10)) + time.sleep(0.05) + assert not rec.done.is_set() # still waiting for you to start talking + d.stop() + assert rec.done.wait(5) + assert rec.result[0] == "" and "Didn't catch anything" in rec.result[1] + + +def test_cancel_throws_the_result_away_without_a_fallback_upload(): + stream, rec = FakeStream(), Recorder() + d = make([ConnectionError("down")], stream, rec) # a fallback would need a 2nd socket + d.start() + stream.push(frame(3000)) + d.cancel() + assert rec.done.wait(5) + assert rec.result == ("", None) + + +def test_mic_failure_raises_from_start_and_closes_the_socket(): + sock = FakeSocket() + rec = Recorder() + + def broken(_cb, _rate): + raise OSError("no input device") + + d = dictation.Dictation(**rec.kwargs(), connect_fn=lambda _r: sock, open_stream_fn=broken) + with pytest.raises(OSError): + d.start() + assert sock._inbox.get(timeout=1) == "" # closed + + +def test_parse_result_is_tolerant(): + assert dictation.parse_result(result("hi", True)) == ("hi", True) + assert dictation.parse_result(json.dumps({"type": "Metadata"})) == ("", False) + assert dictation.parse_result(b"not json") == ("", False) + assert dictation.parse_result(json.dumps({"type": "Results", "speech_final": True, + "channel": {"alternatives": [{"transcript": "x"}]}})) == ("x", True) + + +def test_connect_url_carries_the_session_and_audio_format(monkeypatch): + import types + + seen = {} + fake_ws = types.SimpleNamespace(create_connection=lambda url, header, timeout: seen.update(url=url, header=header)) + monkeypatch.setitem(sys.modules, "websocket", fake_ws) + monkeypatch.setattr(dictation.config, "SERVER_URL", "https://bolt.example") + monkeypatch.setattr(dictation.config, "API_KEY", "k") + monkeypatch.setattr(dictation.config, "SESSION_ID", "pet-console-x y") + dictation.connect() + assert seen["url"].startswith("wss://bolt.example/desk/stt?") + assert "session_id=pet-console-x+y" in seen["url"] and "sample_rate=16000" in seen["url"] + assert seen["header"] == ["X-Desk-Api-Key: k"] + + +# ── the window ────────────────────────────────────────────────────────────── + +class ScriptedDictation: + """Stands in for Dictation in the window: the test drives its callbacks.""" + + last = None + + def __init__(self, on_update, on_level, on_done, silence_seconds=3.0, on_stopping=None): + self.on_update, self.on_level, self.on_done = on_update, on_level, on_done + self.stopped = self.cancelled = False + self.live = True + ScriptedDictation.last = self + + def start(self): + pass + + def stop(self): + self.stopped = True + + def cancel(self): + self.cancelled = True + + +def test_window_mic_fills_the_composer_and_blocks_send_until_done(monkeypatch): + from bolt_console import config + from bolt_console.gui.window import MainWindow, make_app + + monkeypatch.setattr(config, "SERVER_URL", "http://127.0.0.1:9") + monkeypatch.setattr(config, "API_KEY", "k") + monkeypatch.setattr(dictation, "unavailable_reason", lambda: None) + app = make_app(["test"]) + window = MainWindow(dictation_factory=ScriptedDictation) + try: + window.composer.edit.setPlainText("Please") + window.toggle_dictation() + rec = ScriptedDictation.last + assert window.composer.mic.state == "recording" + rec.on_update("open the readme", "and") + app.processEvents() + assert window.composer.edit.toPlainText() == "Please open the readme and" + assert window.composer.edit.isReadOnly() and not window.composer.send.isEnabled() + + window.toggle_dictation() # click again: stop + assert rec.stopped and window.composer.mic.state == "finishing" + rec.on_done("open the readme and summarise it", None) + app.processEvents() + assert window.composer.edit.toPlainText() == "Please open the readme and summarise it" + assert not window.composer.edit.isReadOnly() and window.composer.send.isEnabled() + assert window.composer.mic.state == "idle" + finally: + window.close() + + +def test_window_shows_why_the_mic_is_unavailable(monkeypatch): + from bolt_console.gui.window import MainWindow, make_app + + monkeypatch.setattr(dictation, "unavailable_reason", lambda: "Dictation needs sounddevice") + make_app(["test"]) + window = MainWindow(dictation_factory=ScriptedDictation) + try: + ScriptedDictation.last = None + window.toggle_dictation() + assert ScriptedDictation.last is None + assert window.composer.mic.state == "unavailable" + assert "sounddevice" in window.hint.text() + finally: + window.close() diff --git a/tests/test_doctor.py b/tests/test_doctor.py deleted file mode 100644 index b4f38bc..0000000 --- a/tests/test_doctor.py +++ /dev/null @@ -1,187 +0,0 @@ -"""The preflight. Its one job is to never be the thing that's broken. - -A doctor that raises on a broken install diagnoses the wrong patient, so the -tests that matter here are the ugly-input ones: no config at all, a check that -throws, a dependency missing. The individual diagnoses are simple enough to -read; that they *run* on a machine missing everything is the property worth -pinning down. -""" - -import sys -from pathlib import Path -from unittest.mock import MagicMock, patch - -import pytest - -sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) - -from bolt_pet import config, doctor -from bolt_pet.doctor import FAIL, OK, WARN - - -def test_every_check_returns_a_verdict_on_a_bare_machine(monkeypatch): - """Nothing configured, nothing installed — still a full report.""" - for name in ("SERVER_URL", "API_KEY", "ELEVENLABS_API_KEY", "ELEVENLABS_VOICE_ID"): - monkeypatch.setattr(config, name, "") - monkeypatch.setattr(doctor, "_module", lambda _n: False) - - results = doctor.run() - - assert len(results) == len(doctor.CHECKS) - assert all(c.status in (OK, WARN, FAIL) for c in results) - assert all(c.name and c.detail for c in results) - - -def test_a_check_that_raises_does_not_hide_the_others(): - """One broken probe must not cost you the other eleven diagnoses.""" - def explode(*_args): - raise RuntimeError("boom") - - original = doctor.CHECKS - doctor.CHECKS = (("mic", explode),) + original[:2] - try: - results = doctor.run() - finally: - doctor.CHECKS = original - - assert len(results) == 3 - assert results[0].status == FAIL - assert "boom" in results[0].detail - - -def test_missing_server_config_is_a_failure_not_a_warning(monkeypatch): - """Without it the controller exits its thread at startup — the pet looks - alive and simply never answers. That is the worst failure mode there is.""" - monkeypatch.setattr(config, "missing_config", lambda: ["BOLT_SERVER_URL", "DESK_API_KEY"]) - check = doctor.check_config() - assert check.status == FAIL - assert "BOLT_SERVER_URL" in check.detail - assert check.fix - - -def test_a_configured_server_passes_without_being_contacted(monkeypatch): - """The shallow run must not need the network — it's the first thing you - reach for when the network is what's wrong.""" - monkeypatch.setattr(config, "missing_config", lambda: []) - monkeypatch.setattr(config, "SERVER_URL", "http://bolt.local:8000") - assert doctor.check_config().status == OK - assert doctor.check_server(deep=False).status == OK - - -def test_every_problem_comes_with_something_to_do_about_it(monkeypatch): - """"screen reading: warn" is useless on its own; "apt install tesseract-ocr" - is the entire point of the tool.""" - for name in ("SERVER_URL", "API_KEY"): - monkeypatch.setattr(config, name, "") - monkeypatch.setattr(doctor, "_module", lambda _n: False) - - for check in doctor.run(): - if check.status == FAIL: - assert check.fix, f"{check.name} says what's wrong but not what to do" - - -def test_the_exit_code_is_nonzero_only_for_real_failures(monkeypatch, capsys): - monkeypatch.setattr(doctor, "run", lambda deep=False: [ - doctor.Check("a", OK, "fine"), doctor.Check("b", WARN, "degraded")]) - assert doctor.main([]) == 0 - - monkeypatch.setattr(doctor, "run", lambda deep=False: [doctor.Check("a", FAIL, "broken")]) - assert doctor.main([]) == 1 - assert "Fix those first" in capsys.readouterr().out - - -def test_deep_is_off_unless_asked(monkeypatch): - seen = [] - monkeypatch.setattr(doctor, "run", lambda deep=False: seen.append(deep) or []) - doctor.main([]) - doctor.main(["--deep"]) - assert seen == [False, True] - - -def test_a_slow_silence_timeout_is_flagged(monkeypatch): - """The setting most likely to make it feel sluggish, and the least obvious - — it is pure dead air before anything at all starts happening.""" - monkeypatch.setattr(config, "SILENCE_END_SEC", 2.0) - check = doctor.check_latency() - assert check.status == WARN - assert "2s" in check.detail or "2 " in check.detail - - monkeypatch.setattr(config, "SILENCE_END_SEC", 0.9) - assert doctor.check_latency().status == OK - - -def test_a_check_line_renders_the_fix_only_when_there_is_a_problem(): - assert "→" not in doctor.Check("x", OK, "all good", fix="unused").line() - assert "→ do the thing" in doctor.Check("x", WARN, "hmm", fix="do the thing").line() - - -# ── check_tts's --deep probe ──────────────────────────────────────────────── -# check_streaming_endpoint already established the pattern: shallow only -# checks config (no network), --deep actually calls the server. TTS needs -# its own deep probe specifically because "configured but broken" (a -# rejected voice id, a dead credit balance) is otherwise indistinguishable -# from "working" until speak() silently falls back to the offline voice — -# which is exactly the bug report this was added to diagnose. - -@pytest.fixture(autouse=True) -def _tts_configured(monkeypatch): - monkeypatch.setattr(config, "SERVER_URL", "http://test-server:5002") - monkeypatch.setattr(config, "API_KEY", "test-key") - monkeypatch.setattr(config, "SESSION_ID", "pet-test") - monkeypatch.setattr(config, "ELEVENLABS_VOICE_ID", "abc123voiceid") - - -def test_tts_shallow_does_not_touch_the_network(): - with patch("requests.post") as post: - check = doctor.check_tts(deep=False) - post.assert_not_called() - assert check.status == OK - assert "not probed" in check.detail - - -def test_tts_deep_reports_success(): - response = MagicMock(status_code=200, content=b"\x00\x01" * 100) - with patch("requests.post", return_value=response) as post: - check = doctor.check_tts(deep=True) - post.assert_called_once() - assert check.status == OK - assert "200 bytes" in check.detail - - args, kwargs = post.call_args - assert args[0] == "http://test-server:5002/desk/tts" - assert kwargs["headers"] == {"X-Desk-Api-Key": "test-key"} - assert kwargs["json"]["voice_id"] == "abc123voiceid" - - -def test_tts_deep_surfaces_the_servers_error_message(): - """A 404 with 'voice not found' is exactly the shape of the reported bug: - a vb: clone owned by someone else, or a stale/mistyped id.""" - response = MagicMock(status_code=404) - response.json.return_value = {"error": "voice not found"} - with patch("requests.post", return_value=response): - check = doctor.check_tts(deep=True) - assert check.status == FAIL - assert "404" in check.detail - assert "voice not found" in check.detail - assert check.fix - - -def test_tts_deep_handles_a_network_failure_without_raising(): - with patch("requests.post", side_effect=OSError("no route to host")): - check = doctor.check_tts(deep=True) - assert check.status == FAIL - assert "no route to host" in check.detail - - -def test_tts_run_wires_deep_through_automatically(monkeypatch): - """run()'s TypeError-fallback dance (doctor.py's CHECKS loop) must - actually reach check_tts's deep parameter, not silently call it shallow.""" - seen = [] - - def fake_check_tts(deep=False): - seen.append(deep) - return doctor.Check("text-to-speech", OK, "stub") - - monkeypatch.setattr(doctor, "CHECKS", (("tts", fake_check_tts),)) - doctor.run(deep=True) - assert seen == [True] diff --git a/tests/test_file_delivery.py b/tests/test_file_delivery.py index 5835afa..ff541fb 100644 --- a/tests/test_file_delivery.py +++ b/tests/test_file_delivery.py @@ -3,7 +3,7 @@ from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) -from bolt_pet import file_delivery +from bolt_console import file_delivery def test_sanitize_filename_strips_directory_components(): diff --git a/tests/test_file_ops.py b/tests/test_file_ops.py index ea6a5c8..12b59b4 100644 --- a/tests/test_file_ops.py +++ b/tests/test_file_ops.py @@ -4,7 +4,7 @@ read/write/edit local files instead of a raw shell heredoc. filectl {"op": ...} is a single-line JSON envelope (not a multi-line marker block) because it rides the "command" tool marker, which the main repo's tool-call extractor only captures up to the next newline — see the -module docstring in bolt_pet/file_ops.py.""" +module docstring in bolt_console/file_ops.py.""" import json import sys @@ -14,7 +14,7 @@ import pytest sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) -from bolt_pet import file_ops +from bolt_console import file_ops def _cmd(payload: dict) -> str: @@ -305,7 +305,7 @@ def test_a_repair_is_reported_in_the_output_never_hidden(tmp_path): tomorrow — the model has to be told while it still has the turn.""" (tmp_path / "a.txt").write_text("hello", encoding="utf-8") action = file_ops.parse( - 'filectl {"op":"list","path":"%s","recursive":false"}' % tmp_path + 'filectl {"op":"list","path":%s,"recursive":false"}' % json.dumps(str(tmp_path)) ) output = file_ops.execute(action) assert "a.txt" in output @@ -315,7 +315,7 @@ def test_a_repair_is_reported_in_the_output_never_hidden(tmp_path): def test_valid_json_gets_no_repair_note(tmp_path): (tmp_path / "a.txt").write_text("hello", encoding="utf-8") - action = file_ops.parse('filectl {"op": "list", "path": "%s"}' % tmp_path) + action = file_ops.parse('filectl {"op": "list", "path": %s}' % json.dumps(str(tmp_path))) assert "_repairs" not in action assert "malformed" not in file_ops.execute(action) @@ -327,3 +327,17 @@ def test_genuinely_unparseable_json_points_at_the_character(): message = str(excinfo.value) assert "^" in message # caret under the offending character assert '"extra"' in message # ...and the fragment around it + + +def test_a_raw_windows_path_is_repaired_and_reported(): + r"""Models type Windows paths with single backslashes. `\U` isn't a JSON + escape, so strict parsing would reject the whole call.""" + action = file_ops.parse(r'filectl {"op": "read", "path": "C:\Users\sam\data.txt"}') + assert action["path"] == r"C:\Users\sam\data.txt" + assert any("backslash" in repair for repair in action["_repairs"]) + + +def test_the_backslash_repair_leaves_valid_escapes_alone(): + # The escaped pair and the valid \n survive; only the lone \U is doubled. + action = file_ops.parse(r'filectl {"op": "read", "path": "C:\\ok\Users\n.txt"}') + assert action["path"] == "C:\\ok\\Users\n.txt" diff --git a/tests/test_gui.py b/tests/test_gui.py new file mode 100644 index 0000000..9aa1776 --- /dev/null +++ b/tests/test_gui.py @@ -0,0 +1,189 @@ +"""The window: a whole turn through the real MainWindow, worker thread and +Bridge against the loopback desk server from test_console — only the mouse is +faked (by clicking the approval button programmatically).""" + +import json +import os +import sys +import threading +import time +from pathlib import Path + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +import pytest +from PySide6.QtWidgets import QApplication, QLabel + +from bolt_console.gui import widgets +from bolt_console.gui.bridge import Bridge +from bolt_console.gui.window import MainWindow, make_app + +from tests.test_console import desk # noqa: F401 (fixture) + +app = make_app(["test"]) + + +def pump_until(predicate, timeout=10.0): + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + app.processEvents() + if predicate(): + return True + time.sleep(0.01) + return False + + +def entries(window, kind): + layout = window.entries + return [layout.itemAt(i).widget() for i in range(layout.count()) + if isinstance(layout.itemAt(i).widget(), kind)] + + +@pytest.fixture +def window(desk, monkeypatch): # noqa: F811 + from bolt_console import config + + monkeypatch.setattr(config, "STREAMING_REPLIES", True) + win = MainWindow(mode="ask") + yield win + win.close() + + +def test_confirm_blocks_the_worker_until_the_window_answers(): + bridge = Bridge() + asked = [] + bridge.approval_needed.connect(lambda tool_id, q: asked.append(q)) + result = {} + worker = threading.Thread(target=lambda: result.setdefault("a", bridge.confirm("Run it?"))) + worker.start() + assert pump_until(lambda: asked == ["Run it?"]) + assert worker.is_alive() # still waiting on the user + bridge.answer("always") + worker.join(2) + assert result["a"] == "always" + + +def test_close_releases_a_pending_approval_as_no(): + bridge = Bridge() + result = {} + worker = threading.Thread(target=lambda: result.setdefault("a", bridge.confirm("Run it?"))) + worker.start() + time.sleep(0.05) + bridge.close() + worker.join(2) + assert result["a"] == "no" + assert bridge.confirm("again?") == "no" # and never blocks after close + + +def test_a_turn_with_an_approved_edit(window, desk, tmp_path): # noqa: F811 + target = tmp_path / "app.py" + target.write_text("DEBUG = True\n", encoding="utf-8") + edit = "filectl " + json.dumps( + {"op": "edit", "path": str(target), "old": "DEBUG = True", "new": "DEBUG = False"}) + desk.script["/desk/converse_stream"] = [ + {"type": "say", "text": "On it."}, + {"type": "command", "command": edit, "token": "t1"}, + ] + desk.script["/desk/tool_result"] = {"type": "reply", "text": "Debug is **off** now."} + + window._send("turn debug off") + assert pump_until(lambda: entries(window, widgets.ToolCard) + and entries(window, widgets.ToolCard)[0].approval.isVisibleTo(window)) + card = entries(window, widgets.ToolCard)[0] + assert card.pill.property("kind") == "waiting" + assert "DEBUG = True" in card.diff.text() + + card.allow_button.click() + assert pump_until(lambda: not window._busy) + + assert target.read_text(encoding="utf-8") == "DEBUG = False\n" + assert card.pill.property("kind") == "ok" + texts = [m.text() for m in entries(window, widgets.AssistantMessage)] + assert texts == ["On it.", "Debug is **off** now."] + assert len(entries(window, widgets.UserMessage)) == 1 + + +def test_deny_reports_back_and_the_turn_finishes(window, desk, tmp_path): # noqa: F811 + desk.script["/desk/converse"] = {"type": "command", "command": "echo hi > made.txt", "token": "t"} + desk.script["/desk/tool_result"] = {"type": "reply", "text": "Okay, I won't."} + window._send("make a file") + assert pump_until(lambda: entries(window, widgets.ToolCard) + and entries(window, widgets.ToolCard)[0].approval.isVisibleTo(window)) + card = entries(window, widgets.ToolCard)[0] + card._answer("no") + assert pump_until(lambda: not window._busy) + assert not (tmp_path / "made.txt").exists() + assert card.pill.property("kind") == "declined" + posted = [r[2] for r in desk.requests if r[1] == "/desk/tool_result"] + assert "declined" in posted[0]["output"] + + +def test_always_allow_flips_the_header_switch_to_auto(window, desk, tmp_path): # noqa: F811 + desk.script["/desk/converse"] = {"type": "command", "command": "echo hi", "token": "t"} + window._send("say hi") + assert pump_until(lambda: entries(window, widgets.ToolCard) + and entries(window, widgets.ToolCard)[0].approval.isVisibleTo(window)) + entries(window, widgets.ToolCard)[0]._answer("always") + assert pump_until(lambda: not window._busy) + assert window.session.tools.mode == "auto" + assert window.mode.buttons["auto"].isChecked() + + +def test_clear_empties_the_transcript_but_keeps_the_scaffolding(window, desk): # noqa: F811 + window._send("hello") + assert pump_until(lambda: not window._busy) + window.clear_transcript() + app.processEvents() + assert entries(window, widgets.UserMessage) == [] + assert window.entries.indexOf(window.thinking) != -1 + assert not window.empty.isHidden() + + +def test_markdown_code_uses_the_app_mono_font(): + html = widgets.markdown_html("run `ls` now", QLabel().font()) + assert "Courier New" not in html + assert widgets.theme.CODE in html + + +def test_align_columns_pads_tab_separated_rows(): + rows = widgets.align_columns(["dir/", "a.txt\t5B", "longer.txt\t12B"]) + assert rows[0] == "dir/" + assert rows[1].index("5B") == rows[2].index("12B") + + +def test_new_chat_clears_the_window_and_starts_a_fresh_session(window, desk, tmp_path, monkeypatch): # noqa: F811 + from bolt_console import config + + monkeypatch.setattr(config, "STATE_FILE", tmp_path / "session.json") + desk.script["/desk/chats"] = {"session_id": "chat-3f9a2b1c9e04"} # POST mints the id + window._send("hello") + assert pump_until(lambda: not window._busy) + window.start_new_chat() + assert pump_until(lambda: config.SESSION_ID == "chat-3f9a2b1c9e04") + assert ("POST", "/desk/chats", {}) in desk.requests # over real HTTP, off the UI thread + assert entries(window, widgets.UserMessage) == [] + assert not window.empty.isHidden() + + window._send("next") + assert pump_until(lambda: not window._busy) + converse = [r[2] for r in desk.requests if r[0] == "POST" and "converse" in r[1]] + assert converse[-1]["session_id"] == "chat-3f9a2b1c9e04" # the next turn uses the new chat + + +def test_write_ico_holds_every_size_the_exe_needs(tmp_path): + from PySide6.QtGui import QImageReader + from bolt_console.gui import icons + + path = tmp_path / "bolt.ico" + icons.write_ico(str(path)) + reader = QImageReader(str(path)) + sizes = [] + while True: + image = reader.read() + if image.isNull(): + break + sizes.append(image.width()) + if not reader.jumpToNextImage(): + break + assert sizes == list(icons.APP_ICON_SIZES) diff --git a/tests/test_history_and_hotkey.py b/tests/test_history_and_hotkey.py deleted file mode 100644 index 895acaa..0000000 --- a/tests/test_history_and_hotkey.py +++ /dev/null @@ -1,92 +0,0 @@ -"""Conversation scrollback + push-to-talk hotkey parsing.""" - -import sys -from pathlib import Path - -import pytest - -sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) - -from bolt_pet import history as history_mod -from bolt_pet.hotkey import GlobalHotkey, HotkeyError, to_pynput_spec - - -# ── history ───────────────────────────────────────────────────────────────── - -def test_keeps_entries_in_order(): - log = history_mod.ConversationHistory(limit=10) - log.add(history_mod.USER, "what's the weather") - log.add(history_mod.PET, "sunny and 72") - assert [e.text for e in log.entries()] == ["what's the weather", "sunny and 72"] - - -def test_drops_the_oldest_past_the_limit(): - log = history_mod.ConversationHistory(limit=2) - for i in range(5): - log.add(history_mod.PET, f"line {i}") - assert [e.text for e in log.entries()] == ["line 3", "line 4"] - - -def test_blank_entries_are_ignored(): - log = history_mod.ConversationHistory() - assert log.add(history_mod.PET, " ") is None - assert len(log) == 0 - - -def test_last_can_filter_by_role(): - log = history_mod.ConversationHistory() - log.add(history_mod.USER, "hello") - log.add(history_mod.PET, "hi there") - log.add(history_mod.USER, "still there?") - assert log.last().text == "still there?" - assert log.last(history_mod.PET).text == "hi there" - - -def test_as_text_is_copyable_transcript(): - log = history_mod.ConversationHistory() - log.add(history_mod.USER, "ping") - log.add(history_mod.PET, "pong") - assert log.as_text() == "You: ping\nBolt: pong" - - -def test_timestamps_are_rendered_when_present(): - log = history_mod.ConversationHistory() - log.add(history_mod.PET, "pong", timestamp=1710000000.0) - assert log.as_text(clock=lambda t: "12:00:00") == "[12:00:00] Bolt: pong" - - -def test_clear_empties_the_log(): - log = history_mod.ConversationHistory() - log.add(history_mod.PET, "pong") - log.clear() - assert len(log) == 0 - - -# ── hotkey ────────────────────────────────────────────────────────────────── - -def test_translates_a_readable_spec_to_pynput_syntax(): - assert to_pynput_spec("ctrl+alt+space") == "++" - assert to_pynput_spec("ctrl+shift+b") == "++b" - - -def test_accepts_the_names_people_actually_type(): - assert to_pynput_spec("Control+Option+Space") == "++" - assert to_pynput_spec("super+k") == "+k" - - -def test_empty_spec_is_an_error_at_parse_time(): - with pytest.raises(HotkeyError): - to_pynput_spec("") - - -def test_disabled_hotkey_starts_cleanly_and_reports_nothing(): - hotkey = GlobalHotkey("", lambda: None) - assert hotkey.start() is None - assert hotkey.running is False - - -def test_invalid_hotkey_reports_instead_of_raising(): - hotkey = GlobalHotkey("+++", lambda: None) - problem = hotkey.start() - assert problem and "invalid" in problem - assert hotkey.running is False diff --git a/tests/test_lipsync.py b/tests/test_lipsync.py deleted file mode 100644 index 7451fb8..0000000 --- a/tests/test_lipsync.py +++ /dev/null @@ -1,135 +0,0 @@ -"""Amplitude → mouth openness, from PCM samples to the drawn frame. - -Two halves, tested separately because only one of them needs a display: -`tts.level_of`/`envelope` turn samples into a 0..1 loudness, and -`PetWindow._mouth_frame` turns that loudness into a frame index. They meet at -the `mouth` signal, which the pipeline smoke test covers end to end. -""" - -import sys -import time -from pathlib import Path - -import numpy as np -import pytest - -sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) - -from bolt_pet.audio import tts - - -def frame(amplitude: int, samples: int = 480) -> np.ndarray: - return np.full(samples, amplitude, dtype=np.int16) - - -# ── loudness ──────────────────────────────────────────────────────────────── - -def test_silence_closes_the_mouth(): - assert tts.level_of(np.zeros(480, dtype=np.int16)) == 0.0 - - -def test_a_loud_frame_opens_it_fully(): - assert tts.level_of(frame(30000)) == 1.0 - - -def test_the_level_never_leaves_zero_to_one(): - """It indexes a frame list; out of range is an IndexError on the UI thread.""" - for amplitude in (0, 1, 500, 6000, 20000, 32767): - assert 0.0 <= tts.level_of(frame(amplitude)) <= 1.0 - - -def test_it_rises_with_amplitude(): - quiet, middling, loud = (tts.level_of(frame(a)) for a in (800, 4000, 12000)) - assert quiet < middling < loud - - -def test_quiet_speech_still_moves_the_mouth_visibly(): - """The sqrt curve is the whole point. Speech spends most of its time well - below peak, so a linear map leaves the mouth barely open for normal talking - and the pet looks like it's mumbling.""" - assert tts.level_of(frame(1500)) > 0.15 - - -def test_an_empty_frame_is_silence_not_a_crash(): - assert tts.level_of(np.zeros(0, dtype=np.int16)) == 0.0 - - -# ── the envelope of a whole clip ──────────────────────────────────────────── - -def test_an_envelope_has_one_value_per_frame_at_the_requested_rate(): - one_second = np.zeros(16000, dtype=np.int16) - assert len(tts.envelope(one_second, 16000, fps=30)) == pytest.approx(30, abs=1) - - -def test_an_envelope_tracks_loud_and_quiet_stretches(): - pcm = np.concatenate([np.zeros(8000, dtype=np.int16), frame(20000, 8000)]) - levels = tts.envelope(pcm, 16000, fps=10) - assert max(levels[:4]) == 0.0 # the silent half - assert min(levels[-4:]) > 0.5 # the loud half - - -def test_an_empty_clip_has_an_empty_envelope(): - assert tts.envelope(np.zeros(0, dtype=np.int16), 16000) == [] - - -# ── the drawn frame ───────────────────────────────────────────────────────── - -@pytest.fixture -def window(): - """Needs a QApplication; run with QT_QPA_PLATFORM=offscreen.""" - from PySide6.QtWidgets import QApplication - - from bolt_pet.ui.pet_window import PetWindow - - _app = QApplication.instance() or QApplication(["test"]) - win = PetWindow() - yield win - win.close() - - -class FakeAnimation: - """Stands in for sprite.Animation — _mouth_frame only wants `.frames`.""" - - def __init__(self, frames): - self.frames = list(frames) - - -def test_the_talking_frame_follows_the_level(window): - """The talking frames are an openness ramp — closed first, widest last — - specifically so loudness can index them directly.""" - animation = FakeAnimation(["closed", "a", "b", "c", "d", "open"]) - - assert window._mouth_frame(animation) is None # no level set yet - - window.set_mouth(0.0) - assert window._mouth_frame(animation) == "closed" - - window.set_mouth(1.0) - assert window._mouth_frame(animation) == "open" - - window.set_mouth(0.5) - assert window._mouth_frame(animation) not in ("closed", "open") - - -def test_a_stale_level_gives_the_animation_back(window): - """If the audio thread stops sending levels — TTS died, the clip ended - without a final zero — the mouth must not freeze half-open forever. After a - moment it falls back to the ordinary looping animation.""" - animation = FakeAnimation(["a", "b", "c"]) - window.set_mouth(0.9) - assert window._mouth_frame(animation) is not None - - window._mouth_at = time.monotonic() - 5.0 - assert window._mouth_frame(animation) is None - - -def test_a_single_frame_animation_falls_back_instead_of_indexing(window): - """Art with one talking frame can't lip-sync; it must not try.""" - window.set_mouth(1.0) - assert window._mouth_frame(FakeAnimation(["only"])) is None - assert window._mouth_frame(FakeAnimation([])) is None - - -def test_no_animation_at_all_is_handled(window): - window.set_mouth(0.5) - assert window._mouth_frame(None) is None diff --git a/tests/test_mic.py b/tests/test_mic.py deleted file mode 100644 index 6f67a5a..0000000 --- a/tests/test_mic.py +++ /dev/null @@ -1,140 +0,0 @@ -import sys -from pathlib import Path - -sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) - -import numpy as np -import pytest - -from bolt_pet.audio import mic - -FRAME_LEN = 320 # small for fast tests -SAMPLE_RATE = 8000 - - -class _ScriptedStream: - """Replays a fixed list of frames, then quiet forever.""" - - def __init__(self, frames): - self._frames = list(frames) - - def read(self, frames): - if self._frames: - frame = self._frames.pop(0) - else: - frame = np.zeros(FRAME_LEN, dtype=np.int16) - return frame.reshape(-1, 1), False - - -def _loud(n=1): - return [np.full(FRAME_LEN, 5000, dtype=np.int16) for _ in range(n)] - - -def _quiet(n=1): - return [np.zeros(FRAME_LEN, dtype=np.int16) for _ in range(n)] - - -def test_returns_none_when_nothing_ever_gets_loud(): - stream = _ScriptedStream(_quiet(50)) - calls = {"i": 0} - - def should_continue(): - calls["i"] += 1 - return calls["i"] <= 50 - - result = mic.record_utterance( - stream, should_continue=should_continue, - rms_threshold=300, silence_end_sec=0.5, max_utterance_s=5, min_utterance_s=0.1, - frame_len=FRAME_LEN, sample_rate=SAMPLE_RATE, - ) - assert result is None - - -def test_captures_speech_and_stops_after_trailing_silence(): - # speech, then enough silence to cross the silence_end_sec threshold - silence_end_sec = 0.5 - silence_limit_frames = int(silence_end_sec * SAMPLE_RATE / FRAME_LEN) - frames = _loud(5) + _quiet(silence_limit_frames + 2) - stream = _ScriptedStream(frames) - - result = mic.record_utterance( - stream, rms_threshold=300, silence_end_sec=silence_end_sec, - max_utterance_s=5, min_utterance_s=0.05, - frame_len=FRAME_LEN, sample_rate=SAMPLE_RATE, - ) - assert result is not None - # captured the loud frames plus the silence up to (and including) the - # frame that crossed the silence-end threshold, but not endless silence - assert len(result) < len(frames) * FRAME_LEN - - -def test_returns_none_if_utterance_shorter_than_minimum(): - frames = _loud(1) + _quiet(2) # crosses silence limit almost immediately - stream = _ScriptedStream(frames) - result = mic.record_utterance( - stream, rms_threshold=300, silence_end_sec=0.05, - max_utterance_s=5, min_utterance_s=5.0, # impossible to satisfy - frame_len=FRAME_LEN, sample_rate=SAMPLE_RATE, - ) - assert result is None - - -def test_stops_at_max_utterance_even_without_silence(): - max_utterance_s = 0.5 - max_frames = int(max_utterance_s * SAMPLE_RATE / FRAME_LEN) - stream = _ScriptedStream(_loud(max_frames + 20)) # never goes quiet - - result = mic.record_utterance( - stream, rms_threshold=300, silence_end_sec=10.0, # would never trigger - max_utterance_s=max_utterance_s, min_utterance_s=0.01, - frame_len=FRAME_LEN, sample_rate=SAMPLE_RATE, - ) - assert result is not None - assert len(result) == max_frames * FRAME_LEN - - -def test_returns_none_when_should_continue_stops_before_speech(): - stream = _ScriptedStream(_quiet(100)) - result = mic.record_utterance(stream, should_continue=lambda: False, - frame_len=FRAME_LEN, sample_rate=SAMPLE_RATE) - assert result is None - - -def test_list_input_devices_filters_to_input_capable(monkeypatch): - fake_devices = [ - {"name": "Speakers", "max_input_channels": 0, "hostapi": 0}, - {"name": "USB Mic", "max_input_channels": 2, "hostapi": 0}, - {"name": "Line In", "max_input_channels": 1, "hostapi": 1}, - ] - fake_hostapis = [{"name": "MME"}, {"name": "WASAPI"}] - - class _FakeSoundDevice: - @staticmethod - def query_devices(): - return fake_devices - - @staticmethod - def query_hostapis(index): - return fake_hostapis[index] - - monkeypatch.setitem(sys.modules, "sounddevice", _FakeSoundDevice()) - - devices = mic.list_input_devices() - assert devices == [ - {"index": 1, "name": "USB Mic", "hostapi": "MME"}, - {"index": 2, "name": "Line In", "hostapi": "WASAPI"}, - ] - - -def test_pcm_to_wav_bytes_round_trips_via_wave_module(): - import wave - import io - - pcm = np.array([0, 100, -100, 32767, -32768], dtype=np.int16) - wav_bytes = mic.pcm_to_wav_bytes(pcm, sample_rate=16000) - with wave.open(io.BytesIO(wav_bytes), "rb") as wf: - assert wf.getnchannels() == 1 - assert wf.getsampwidth() == 2 - assert wf.getframerate() == 16000 - frames = wf.readframes(wf.getnframes()) - assert np.frombuffer(frames, dtype=np.int16).tolist() == pcm.tolist() diff --git a/tests/test_monitors.py b/tests/test_monitors.py deleted file mode 100644 index b9efcf4..0000000 --- a/tests/test_monitors.py +++ /dev/null @@ -1,169 +0,0 @@ -"""Screen layout logic — resolving `petctl jump` targets and describing the -setup. Pure: the monitor list is normally published by the UI, so none of this -needs a display.""" - -import sys -from pathlib import Path - -import pytest - -sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) - -from bolt_pet import monitors as m - - -def grid(): - """The 2x2 setup this was built against: four 1080p screens. - - [1 HDMI-0] [2 HDMI-1] - [3 DP-0 ] [4 DP-2 ] - """ - return [ - m.Monitor(0, "HDMI-0", 0, 0, 1920, 1080, primary=False), - m.Monitor(1, "HDMI-1", 1920, 0, 1920, 1080, primary=False), - m.Monitor(2, "DP-0", 0, 1080, 1920, 1080, primary=True), - m.Monitor(3, "DP-2", 1920, 1080, 1920, 1080, primary=False), - ] - - -def two(): - return [ - m.Monitor(0, "eDP-1", 0, 0, 1920, 1080, primary=True), - m.Monitor(1, "HDMI-1", 1920, 0, 2560, 1440), - ] - - -def test_numbers_shown_to_humans_are_one_based(): - left, right = two() - assert left.index == 0 and left.number == 1 - assert right.index == 1 and right.number == 2 - assert "1: eDP-1 1920x1080 (primary)" == left.label - - -def test_geometry_helpers(): - screen = m.Monitor(1, "HDMI-1", 1920, 0, 1920, 1080) - assert screen.right == 3840 and screen.bottom == 1080 - assert screen.center == (2880, 540) - assert screen.contains(1920, 0) - assert screen.contains(3839, 1079) - assert not screen.contains(3840, 0) # right edge is exclusive - assert not screen.contains(1919, 0) - - -def test_monitor_containing_and_nearest(): - screens = grid() - assert m.monitor_containing(screens, 100, 100).name == "HDMI-0" - assert m.monitor_containing(screens, 2000, 1500).name == "DP-2" - assert m.monitor_containing(screens, -50, -50) is None - # off the desktop entirely still resolves to something - assert m.nearest_monitor(screens, -500, -500).name == "HDMI-0" - - -def test_resolve_by_number(): - screens = grid() - assert m.resolve(screens, "3").name == "DP-0" - with pytest.raises(ValueError, match="no monitor 9"): - m.resolve(screens, "9") - with pytest.raises(ValueError): - m.resolve(screens, "0") - - -def test_resolve_next_and_prev_wrap(): - screens = grid() - assert m.resolve(screens, "next", current=3).number == 1 - assert m.resolve(screens, "prev", current=0).number == 4 - assert m.resolve(screens, "next", current=0).number == 2 - - -def test_resolve_primary_and_other(): - screens = grid() - assert m.resolve(screens, "primary", current=0).name == "DP-0" - # "other" on a two-screen setup is genuinely the other one - pair = two() - assert m.resolve(pair, "other", current=0).number == 2 - assert m.resolve(pair, "other", current=1).number == 1 - - -def test_resolve_directions_on_a_grid(): - screens = grid() - # from top-left (HDMI-0) - assert m.resolve(screens, "right", current=0).name == "HDMI-1" - assert m.resolve(screens, "down", current=0).name == "DP-0" - # from bottom-right (DP-2) - assert m.resolve(screens, "left", current=3).name == "DP-0" - assert m.resolve(screens, "up", current=3).name == "HDMI-1" - - -def test_direction_prefers_the_best_aligned_screen(): - screens = grid() - # "right" from DP-0 (bottom-left) must pick DP-2 (same row), not HDMI-1, - # even though both are to the right. - assert m.resolve(screens, "right", current=2).name == "DP-2" - - -def test_resolve_direction_with_nothing_there(): - screens = grid() - with pytest.raises(ValueError, match="no monitor to the left"): - m.resolve(screens, "left", current=0) - - -def test_resolve_by_name_is_fuzzy_but_refuses_ambiguity(): - screens = grid() - assert m.resolve(screens, "dp-2").name == "DP-2" - assert m.resolve(screens, "HDMI-0").name == "HDMI-0" - with pytest.raises(ValueError, match="matches several"): - m.resolve(screens, "hdmi") - - -def test_resolve_unknown_spec_lists_the_options(): - screens = two() - with pytest.raises(ValueError) as excinfo: - m.resolve(screens, "the big one") - assert "eDP-1" in str(excinfo.value) and "HDMI-1" in str(excinfo.value) - - -def test_resolve_without_a_current_screen_falls_back_to_primary(): - screens = grid() - # primary is index 2, so "next" from nowhere is index 3 - assert m.resolve(screens, "next", current=None).number == 4 - # an out-of-range current is treated the same way rather than exploding - assert m.resolve(screens, "next", current=99).number == 4 - - -def test_resolve_needs_monitors(): - with pytest.raises(ValueError, match="no monitors"): - m.resolve([], "next") - with pytest.raises(ValueError, match="needs a target"): - m.resolve(grid(), "") - - -def test_random_always_moves_somewhere_else(): - screens = grid() - for current in range(4): - assert m.resolve(screens, "random", current=current).index != current - - -def test_summary_is_one_line_and_marks_where_the_pet_is(): - line = m.summary(grid(), current=1) - assert "\n" not in line - assert line.startswith("4 monitors:") - assert "Bolt is on 2" in line - assert m.summary([]) is None - assert "Bolt is on" not in m.summary(grid(), current=None) - - -def test_annotate_matches_the_screen_context_style(): - out = m.annotate("what's on the other screen?", grid(), current=0) - assert out.startswith("what's on the other screen?") - assert "[4 monitors:" in out - # nothing to say, nothing added - assert m.annotate("hello", [], None) == "hello" - assert m.annotate("", grid(), 0) == "" - - -def test_describe_lists_every_screen_and_flags_the_pet(): - text = m.describe(grid(), current=2) - assert text.count("\n") == 4 # header + 4 screens - assert "DP-0" in text and "+0+1080" in text - assert text.count("Bolt is here") == 1 - assert m.describe([]) == "[pet] no monitor information available" diff --git a/tests/test_notifications.py b/tests/test_notifications.py deleted file mode 100644 index 569e55d..0000000 --- a/tests/test_notifications.py +++ /dev/null @@ -1,90 +0,0 @@ -"""dbus-monitor parsing + the forward/rate-limit gate. No session bus -needed — the parser is fed canned dbus-monitor output.""" - -import sys -from pathlib import Path - -sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) - -from bolt_pet.notifications import Notification, NotificationGate, iter_notifications - -SAMPLE = '''signal time=1710000000.1 sender=org.freedesktop.DBus -> destination=:1.7 serial=2 path=/org/freedesktop/DBus; interface=org.freedesktop.DBus; member=NameAcquired - string ":1.7" -method call time=1710000001.2 sender=:1.72 -> destination=org.freedesktop.Notifications serial=88 path=/org/freedesktop/Notifications; interface=org.freedesktop.Notifications; member=Notify - string "Firefox" - uint32 0 - string "" - string "Build finished" - string "All 42 tests passed" - array [ - ] - int32 -1 -method call time=1710000002.3 sender=:1.80 -> destination=org.freedesktop.Notifications serial=91 path=/org/freedesktop/Notifications; interface=org.freedesktop.Notifications; member=Notify - string "Calendar" - uint32 0 - string "calendar-icon" - string "Standup in 5 minutes" - string "" - array [ - ] -''' - - -def _parse(text=SAMPLE): - return list(iter_notifications(text.splitlines())) - - -def test_parses_each_notify_call(): - parsed = _parse() - assert parsed == [ - Notification(app="Firefox", summary="Build finished", body="All 42 tests passed"), - Notification(app="Calendar", summary="Standup in 5 minutes", body=""), - ] - - -def test_unrelated_dbus_traffic_is_ignored(): - noise = SAMPLE.split("method call")[0] - assert _parse(noise) == [] - - -def test_trailing_notification_without_a_following_block_is_still_emitted(): - assert _parse()[-1].summary == "Standup in 5 minutes" - - -def test_as_text_is_what_gets_sent_to_the_server(): - assert _parse()[0].as_text() == "Firefox: Build finished — All 42 tests passed" - assert _parse()[1].as_text() == "Calendar: Standup in 5 minutes" - - -# ── gate ──────────────────────────────────────────────────────────────────── - -def _notification(summary="Build finished", app="Firefox"): - return Notification(app=app, summary=summary, body="") - - -def test_empty_filter_forwards_everything(): - gate = NotificationGate("", min_interval=0) - assert gate.should_forward(_notification(), now=0) is True - - -def test_filter_regex_selects_what_is_worth_a_round_trip(): - gate = NotificationGate(r"build|deploy", min_interval=0) - assert gate.should_forward(_notification("Build finished"), now=0) is True - assert gate.should_forward(_notification("New message from Dave"), now=1) is False - - -def test_rate_limit_drops_a_burst(): - gate = NotificationGate("", min_interval=60) - assert gate.should_forward(_notification(), now=100) is True - assert gate.should_forward(_notification(), now=120) is False - assert gate.should_forward(_notification(), now=161) is True - - -def test_a_broken_regex_does_not_silence_the_bridge(): - gate = NotificationGate("(unclosed", min_interval=0) - assert gate.should_forward(_notification(), now=0) is True - - -def test_empty_notifications_are_dropped(): - gate = NotificationGate("", min_interval=0) - assert gate.should_forward(Notification(app="", summary="", body=""), now=0) is False diff --git a/tests/test_pet_actions.py b/tests/test_pet_actions.py deleted file mode 100644 index c321e9c..0000000 --- a/tests/test_pet_actions.py +++ /dev/null @@ -1,85 +0,0 @@ -"""petctl parsing — the pseudo-commands the server can relay to drive the -pet's body instead of a shell.""" - -import sys -from pathlib import Path - -import pytest - -sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) - -from bolt_pet import pet_actions - - -def test_non_pet_commands_are_left_alone(): - assert pet_actions.parse("ls -la") is None - assert pet_actions.parse("systemctl restart nginx") is None - assert pet_actions.parse("") is None - # "petstore" must not be mistaken for the "pet" prefix - assert pet_actions.parse("petstore --list") is None - - -def test_move_to_an_anchor(): - assert pet_actions.parse("petctl move top-left") == {"action": "move", "anchor": "top-left"} - assert pet_actions.parse("petctl move bottom_right") == {"action": "move", "anchor": "bottom-right"} - - -def test_move_to_coordinates(): - assert pet_actions.parse("petctl move 300 120") == {"action": "move", "x": 300, "y": 120} - - -def test_move_rejects_nonsense_targets(): - with pytest.raises(pet_actions.ActionError): - pet_actions.parse("petctl move sideways") - with pytest.raises(pet_actions.ActionError): - pet_actions.parse("petctl move") - - -def test_emotes(): - assert pet_actions.parse("petctl emote wave") == {"action": "emote", "emote": "wave"} - with pytest.raises(pet_actions.ActionError): - pet_actions.parse("petctl emote moonwalk") - - -def test_say_keeps_the_whole_sentence(): - assert pet_actions.parse('petctl say "build is green"') == { - "action": "say", "text": "build is green" - } - assert pet_actions.parse("petctl say build is green")["text"] == "build is green" - - -def test_wander_and_nap_toggles(): - assert pet_actions.parse("petctl wander off") == {"action": "wander", "enabled": False} - assert pet_actions.parse("petctl nap on") == {"action": "nap", "enabled": True} - with pytest.raises(pet_actions.ActionError): - pet_actions.parse("petctl wander maybe") - - -def test_alternate_prefixes_and_verbs(): - assert pet_actions.parse("bolt-pet goto center")["anchor"] == "center" - assert pet_actions.parse("pet do hop")["emote"] == "hop" - - -def test_unknown_verb_is_an_error_not_a_shell_command(): - with pytest.raises(pet_actions.ActionError): - pet_actions.parse("petctl explode") - - -def test_describe_is_reported_back_to_the_server(): - assert "top-left" in pet_actions.describe({"action": "move", "anchor": "top-left"}) - assert "wave" in pet_actions.describe({"action": "emote", "emote": "wave"}) - assert pet_actions.describe({"action": "help"}) == pet_actions.HELP - - -def test_voice_reset_parses_with_or_without_the_word_reset(): - assert pet_actions.parse("petctl voice reset") == {"action": "voice", "voice": "default"} - assert pet_actions.parse("petctl voice default") == {"action": "voice", "voice": "default"} - assert pet_actions.parse("petctl voice") == {"action": "voice", "voice": "default"} - - -def test_petctl_cannot_be_used_to_pick_a_voice(): - """Choosing a voice is the server's job (speak_as) — it has the voice - library. petctl only ever undoes one, so an attempt to set a voice here - is pointed back at the marker that works.""" - with pytest.raises(pet_actions.ActionError, match="speak_as"): - pet_actions.parse("petctl voice Terence") diff --git a/tests/test_pet_window_features.py b/tests/test_pet_window_features.py deleted file mode 100644 index 76b440e..0000000 --- a/tests/test_pet_window_features.py +++ /dev/null @@ -1,334 +0,0 @@ -"""Window-side behaviour for the newer features: emote curves, petctl -actions, edge snapping, napping, click-through. - -Needs a QApplication — run with QT_QPA_PLATFORM=offscreen. -""" - -import sys -from pathlib import Path - -import pytest -from PySide6.QtCore import QPoint -from PySide6.QtWidgets import QApplication - -sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) - -from bolt_pet import config -from bolt_pet.state import PetState -from bolt_pet.ui.pet_window import ( - _EMOTE_TICKS, _WALK_PIXELS_PER_FRAME, PetWindow, emote_transform, -) -from bolt_pet.ui.sprite import WALK - - -@pytest.fixture(scope="module") -def qt_app(): - yield QApplication.instance() or QApplication([]) - - -@pytest.fixture -def pet(qt_app): - window = PetWindow() - yield window - window.close() - - -# ── emote curves (pure maths) ─────────────────────────────────────────────── - -@pytest.mark.parametrize("emote", ["wave", "hop", "bounce", "spin", "nod", "shake", "wiggle"]) -def test_every_emote_returns_the_sprite_to_rest(emote): - # Anything that doesn't land back at the identity transform leaves the pet - # permanently askew. (approx: the sine curves land on ~1e-16, not 0.0.) - rest = pytest.approx((0.0, 0.0, 0.0, 1.0), abs=1e-9) - assert emote_transform(emote, 1.0) == rest - assert emote_transform(emote, 0.0) == rest - - -def test_emotes_actually_move_the_sprite_mid_animation(): - for emote in ("wave", "hop", "spin", "nod", "shake"): - samples = [emote_transform(emote, i / 20) for i in range(1, 20)] - assert any(sample != (0.0, 0.0, 0.0, 1.0) for sample in samples), emote - - -def test_an_unknown_emote_is_a_no_op_not_a_crash(): - assert emote_transform("moonwalk", 0.5) == (0.0, 0.0, 0.0, 1.0) - - -def test_progress_is_clamped(): - assert emote_transform("hop", 5.0) == emote_transform("hop", 1.0) - assert emote_transform("hop", -3.0) == emote_transform("hop", 0.0) - - -def test_an_emote_finishes_and_clears_itself(pet): - pet.start_emote("spin") - assert pet._emote == "spin" - for _ in range(_EMOTE_TICKS + 2): - pet._advance_emote() - assert pet._emote is None - - -# ── petctl actions ────────────────────────────────────────────────────────── - -def test_move_action_sets_a_walk_target(pet): - pet.apply_action({"action": "move", "anchor": "top-left"}) - assert pet._wander_target is not None - assert pet._commanded_move is True - - -def test_commanded_moves_happen_even_while_talking(pet, monkeypatch): - monkeypatch.setattr(config, "PET_EDGE_SNAP", False) # snapping would move it again on arrival - pet.set_state(PetState.TALKING) - pet.apply_action({"action": "move", "anchor": "top-left"}) - target = pet._wander_target - if target is None: - pytest.skip("no usable screen geometry on this host") - for _ in range(2000): - pet._wander_tick() - if pet._wander_target is None: - break - assert pet.pos() == target - - -def test_a_commanded_move_survives_the_reply_arriving(pet): - # Real ordering: `petctl move` comes back as a tool call mid-turn, then - # the reply flips the pet to TALKING a moment later. That must not cancel - # the walk it was just told to make. - pet.apply_action({"action": "move", "anchor": "center"}) - pet.set_state(PetState.TALKING) - assert pet._wander_target is not None - - -def test_move_to_explicit_coordinates_is_clamped_on_screen(pet): - pet.apply_action({"action": "move", "x": -5000, "y": -5000}) - geo = pet._screen_geometry() - if geo is None: - pytest.skip("no usable screen geometry on this host") - assert pet._wander_target.x() >= geo.left() - assert pet._wander_target.y() >= geo.top() - - -def test_say_action_shows_the_bubble(pet): - pet.apply_action({"action": "say", "text": "build is green"}) - assert pet._bubble.text == "build is green" - - -def test_wander_and_nap_actions(pet): - pet.apply_action({"action": "wander", "enabled": False}) - assert pet._wander_enabled is False - pet.apply_action({"action": "nap", "enabled": True}) - assert pet.napping is True - - -def test_emote_action_starts_the_emote(pet): - pet.apply_action({"action": "emote", "emote": "wave"}) - assert pet._emote == "wave" - - -# ── napping ───────────────────────────────────────────────────────────────── - -def test_napping_dims_the_pet_and_stops_it_wandering(pet): - pet.set_napping(True) - assert pet.windowOpacity() < 1.0 - start = pet.pos() - pet.wander_now() - for _ in range(60): - pet._wander_tick() - assert pet.pos() == start - - pet.set_napping(False) - assert pet.windowOpacity() == 1.0 - - -# ── edge snapping ─────────────────────────────────────────────────────────── - -def test_snaps_flush_when_parked_near_an_edge(pet, monkeypatch): - geo = pet._screen_geometry() - if geo is None: - pytest.skip("no usable screen geometry on this host") - monkeypatch.setattr(config, "PET_EDGE_SNAP", True) - pet.move(geo.left() + 10, geo.top() + 10) - assert pet.snap_to_edge() is True - assert pet.pos() == QPoint(geo.left(), geo.top()) - - -def test_does_not_snap_from_the_middle_of_the_screen(pet, monkeypatch): - geo = pet._screen_geometry() - if geo is None: - pytest.skip("no usable screen geometry on this host") - monkeypatch.setattr(config, "PET_EDGE_SNAP", True) - middle = QPoint(geo.left() + geo.width() // 2, geo.top() + geo.height() // 2) - pet.move(middle) - assert pet.snap_to_edge() is False - assert pet.pos() == middle - - -def test_snapping_can_be_turned_off(pet, monkeypatch): - geo = pet._screen_geometry() - if geo is None: - pytest.skip("no usable screen geometry on this host") - monkeypatch.setattr(config, "PET_EDGE_SNAP", False) - pet.move(geo.left() + 10, geo.top() + 10) - assert pet.snap_to_edge() is False - - -# ── click-through ─────────────────────────────────────────────────────────── - -def test_click_through_toggles_mouse_transparency(pet): - from PySide6.QtCore import Qt - - pet.set_click_through(True) - assert pet.click_through is True - assert pet.testAttribute(Qt.WA_TransparentForMouseEvents) is True - - pet.set_click_through(False) - assert pet.testAttribute(Qt.WA_TransparentForMouseEvents) is False - - -# ── monitors ──────────────────────────────────────────────────────────────── - -def test_window_publishes_a_monitor_list(pet): - """Whatever the test host's screen setup is, the window must describe it - in the shape the controller expects.""" - monitors = pet.monitors() - assert monitors, "offscreen Qt still reports at least one screen" - assert [m.index for m in monitors] == list(range(len(monitors))) - assert all(m.width > 0 and m.height > 0 for m in monitors) - assert all(m.name for m in monitors) - assert sum(1 for m in monitors if m.primary) <= 1 - - -def test_publish_monitors_re_emits_when_forced(pet): - """ui/app.py relies on this: the window is built before the controller - exists, so its constructor's publish reaches nobody and has to be redone.""" - seen = [] - pet.monitors_changed.connect(seen.append) - pet.publish_monitors() # force defaults to True - assert len(seen) == 1 - pet.publish_monitors(force=False) # nothing changed -> stays quiet - assert len(seen) == 1 - - -def test_pet_reports_which_monitor_it_is_on(pet): - seen = [] - pet.pet_monitor_changed.connect(seen.append) - pet.publish_monitors() - assert seen and seen[-1] == pet.current_monitor_index() - assert 0 <= seen[-1] < len(pet.monitors()) - - -def test_jump_moves_the_window_onto_the_target_screen(pet): - monitors = pet.monitors() - target = len(monitors) - 1 - pet.apply_action({"action": "jump", "monitor": target}) - assert pet.current_monitor_index() == target - # a jump lands with a hop rather than sliding there - assert pet._emote == "hop" - - -def test_jump_cancels_a_stroll_so_it_does_not_walk_back(pet): - pet.apply_action({"action": "move", "anchor": "top-left"}) - assert pet._wander_target is not None - pet.apply_action({"action": "jump", "monitor": 0}) - assert pet._wander_target is None - assert pet._commanded_move is False - - -def test_jump_to_a_bogus_index_is_a_no_op(pet): - before = pet.pos() - pet.apply_action({"action": "jump", "monitor": 99}) - pet.apply_action({"action": "jump", "monitor": -1}) - assert pet.pos() == before - - -# ── walk cycle ────────────────────────────────────────────────────────────── - -def test_walk_art_loads_as_a_non_state_animation(pet): - """Walking is a property of movement, not a PetState, so it lives outside - the state machine but still loads like any other animation.""" - assert pet.sprites.has(WALK) - assert len(pet.sprites.get(WALK).frames) == 8 - assert pet.sprites.get("nonsense") is pet.sprites.get(PetState.IDLE) - assert not pet.sprites.has("nonsense") - - -def test_walking_overrides_the_state_animation(pet): - assert pet._animation_key() == pet._current_state - pet._advance_walk(50, 0, 1.0) - assert pet._animation_key() == WALK - - -def test_walk_cycle_advances_by_distance_not_by_the_clock(pet): - """The planted paw tracks backwards at the speed the window moves - forwards; drive it off the animation timer instead and the feet skate.""" - anim = pet.sprites.get(WALK) - anim.reset() - pet._advance_walk(100, 0, _WALK_PIXELS_PER_FRAME * 3) - assert anim._index == 3 - # a step too small to cross the threshold banks the distance instead - pet._advance_walk(100, 0, _WALK_PIXELS_PER_FRAME * 0.5) - assert anim._index == 3 - pet._advance_walk(100, 0, _WALK_PIXELS_PER_FRAME * 0.5) - assert anim._index == 4 - - -def test_the_animation_timer_does_not_double_step_the_walk(pet): - anim = pet.sprites.get(WALK) - pet._advance_walk(50, 0, 1.0) - anim.reset() - pet._advance_frame() - assert anim._index == 0 - - -def test_facing_follows_horizontal_travel(pet): - pet._advance_walk(50, 0, 1.0) - assert pet._facing == 1 - pet._advance_walk(-50, 0, 1.0) - assert pet._facing == -1 - - -def test_a_near_vertical_stroll_does_not_flip_him(pet): - """Rounding noise on dx would otherwise flip him back and forth every - tick on a straight-up walk.""" - pet._facing = 1 - pet._advance_walk(0.4, 60, 1.0) - assert pet._facing == 1 - - -def test_walking_left_paints_a_mirrored_frame(pet): - frame = pet.sprites.get(WALK).current() - pet._facing = 1 - assert pet._oriented(frame) is frame # art is drawn facing right - pet._facing = -1 - flipped = pet._oriented(frame) - assert flipped is not frame - assert flipped.size() == frame.size() - assert pet._oriented(frame) is flipped # cached, not re-flipped per paint - - -def test_stopping_resets_the_cycle_to_a_standing_frame(pet): - pet._advance_walk(50, 0, _WALK_PIXELS_PER_FRAME * 2) - assert pet._walking - pet._stop_walking() - assert not pet._walking - assert pet._walk_distance == 0.0 - assert pet.sprites.get(WALK)._index == 0 - assert pet._animation_key() == pet._current_state - - -def test_walk_art_suppresses_the_hard_coded_bob(pet): - """The frames carry their own weight shift — bobbing the window as well - would double it up.""" - pet._advance_walk(50, 0, 5.0) - assert pet._bob_offset == 0 - - -def test_without_walk_art_it_falls_back_to_the_old_bob(qt_app, tmp_path): - window = PetWindow(sprite_dir=tmp_path) - try: - assert not window.sprites.has(WALK) - window._advance_walk(50, 0, 5.0) - assert window._walking - assert window._animation_key() == window._current_state - assert window._bob_offset < 0 # still visibly moving - finally: - window.close() diff --git a/tests/test_pipeline_smoke.py b/tests/test_pipeline_smoke.py deleted file mode 100644 index 2c7bd6f..0000000 --- a/tests/test_pipeline_smoke.py +++ /dev/null @@ -1,289 +0,0 @@ -"""End-to-end: microphone in, speech out, over real HTTP. - -Every other test in this suite injects a fake at the seam it cares about, and -every one of them passed all week while these got through to production: - - - notifications sitting unspoken for minutes (a clock stamped in the wrong - order, two correct units) - - the pet saying the same thing twice (server discarded prose, model repeated - it — both sides behaving as written) - - `[laughing]` read out loud (a tag that means something to one model and - nothing to the next one down the pipe) - - a device command written as prose (extractor fine, prompt fine, no marker) - -They were all *interaction* bugs. So this one runs the actual pipeline against -a real socket: a threaded HTTP server that speaks the desk protocol, the real -`server_client` doing real requests (including NDJSON streaming), the real -controller loop and state machine. The only fakes are where the hardware is — -the mic stream and the speakers — because those are the two things a test -genuinely cannot have. -""" - -import json -import sys -import threading -from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer -from pathlib import Path - -import numpy as np -import pytest -from PySide6.QtWidgets import QApplication - -sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) - -from bolt_pet import controller as controller_mod -from bolt_pet.notifications import Notification -from bolt_pet.state import PetState - -_app = QApplication.instance() or QApplication(["test"]) - - -# ── a desk server that actually listens on a port ─────────────────────────── - -class FakeDesk: - """Scripted responses, real HTTP. Set `.script` per test.""" - - def __init__(self): - self.script = {} - self.requests = [] - self._server = ThreadingHTTPServer(("127.0.0.1", 0), self._handler()) - self._thread = threading.Thread(target=self._server.serve_forever, daemon=True) - self._thread.start() - - @property - def url(self) -> str: - host, port = self._server.server_address[:2] - return f"http://{host}:{port}" - - def stop(self) -> None: - self._server.shutdown() - self._server.server_close() - - def _handler(self): - desk = self - - class Handler(BaseHTTPRequestHandler): - def log_message(self, *_args): - pass # the test output is not an access log - - def do_GET(self): - path = self.path.split("?")[0] - desk.requests.append(("GET", path)) - self._json(desk.script.get(path, {"files": []})) - - def do_POST(self): - length = int(self.headers.get("Content-Length") or 0) - body = json.loads(self.rfile.read(length) or b"{}") - path = self.path.split("?")[0] - desk.requests.append(("POST", path, body)) - response = desk.script.get(path) - if callable(response): - response = response(body) - if path.endswith("converse_stream"): - self._ndjson(response or []) - else: - self._json(response if response is not None else {"type": "reply", "text": "ok"}) - - def _json(self, payload): - data = json.dumps(payload).encode() - self.send_response(200) - self.send_header("Content-Type", "application/json") - self.send_header("Content-Length", str(len(data))) - self.end_headers() - self.wfile.write(data) - - def _ndjson(self, events): - self.send_response(200) - self.send_header("Content-Type", "application/x-ndjson") - self.end_headers() - for event in events: - self.wfile.write((json.dumps(event) + "\n").encode()) - self.wfile.flush() - - return Handler - - -class FakeMic: - """Loud frames then quiet ones, so the VAD ends the utterance on its own.""" - - def __init__(self, loud=8, quiet=60): - self.frames = ([np.full((320, 1), 4000, dtype=np.int16)] * loud - + [np.zeros((320, 1), dtype=np.int16)] * quiet) - - def read(self, _n): - return (self.frames.pop(0) if self.frames - else np.zeros((320, 1), dtype=np.int16)), None - - def __enter__(self): - return self - - def __exit__(self, *_a): - return False - - -@pytest.fixture -def pipeline(monkeypatch): - """A controller wired to a real local server, with fake ears and mouth.""" - desk = FakeDesk() - spoken: list[str] = [] - levels: list[float] = [] - - monkeypatch.setattr(controller_mod.config, "SERVER_URL", desk.url) - monkeypatch.setattr(controller_mod.config, "API_KEY", "test-key") - monkeypatch.setattr(controller_mod.config, "SESSION_ID", "pet-smoke") - monkeypatch.setattr(controller_mod.config, "SILENCE_END_SEC", 0.2) - monkeypatch.setattr(controller_mod.config, "MIN_UTTERANCE_S", 0.0) - monkeypatch.setattr(controller_mod.config, "RECEIVE_FILES", False) - # Off by default so each test picks its own path; the streaming tests opt in. - monkeypatch.setattr(controller_mod.config, "STREAMING_REPLIES", False) - monkeypatch.setattr(controller_mod.screen_context, "context_for", lambda text: text) - monkeypatch.setattr(controller_mod.screen_context, "is_fullscreen_active", lambda: False) - # Deepgram and the speakers are the two things a test can't have. - monkeypatch.setattr(controller_mod.stt, "transcribe", lambda pcm: "what's on my disk?") - monkeypatch.setattr(controller_mod.stt_stream.StreamingTranscriber, "open", - classmethod(lambda cls, **kw: None)) - - def fake_speak(text, on_error=None, should_stop=None, voice_id=None, on_level=None): - spoken.append(text) - if on_level is not None: - on_level(0.8) # the mouth opens while a word plays... - levels.append(0.8) - return True - - monkeypatch.setattr(controller_mod.tts, "speak", fake_speak) - - ctrl = controller_mod.PetController() - ctrl._stream = FakeMic() - try: - yield ctrl, desk, spoken, levels - finally: - desk.stop() - - -# ── the whole path ────────────────────────────────────────────────────────── - -def test_a_plain_turn_goes_mic_to_speaker(pipeline): - ctrl, desk, spoken, _levels = pipeline - desk.script["/desk/converse"] = {"type": "reply", "text": "About sixty percent full."} - - ctrl._handle_conversation_turn() - - assert spoken == ["About sixty percent full."] - assert ctrl._state.state == PetState.IDLE - posted = [r for r in desk.requests if r[0] == "POST"] - assert posted[0][1] == "/desk/converse" - assert "what's on my disk?" in posted[0][2]["text"] - assert ctrl.history.entries()[-1].text == "About sixty percent full." - - -def test_a_streamed_turn_speaks_each_sentence_as_it_lands(pipeline, monkeypatch): - """The NDJSON is parsed by the real client over a real socket — the layer - that a mocked `converse` can never exercise.""" - ctrl, desk, spoken, _levels = pipeline - monkeypatch.setattr(controller_mod.config, "STREAMING_REPLIES", True) - desk.script["/desk/converse_stream"] = [ - {"type": "say", "text": "The disk is fine."}, - {"type": "say", "text": "About sixty percent used."}, - {"type": "reply", "text": "The disk is fine. About sixty percent used.", - "already_spoken": True}, - ] - - ctrl._handle_conversation_turn() - - assert spoken == ["The disk is fine.", "About sixty percent used."] - # The final reply must not be spoken a third time. - assert len(spoken) == 2 - - -def test_a_tool_turn_says_give_me_a_sec_then_the_answer(pipeline, monkeypatch): - """Holding line, relayed command, and the real answer — in that order.""" - ctrl, desk, spoken, _levels = pipeline - ran = [] - monkeypatch.setattr(controller_mod.server_client, "run_local_command", - lambda cmd: ran.append(cmd) or "[exit 0]\n60% used") - desk.script["/desk/converse"] = { - "type": "command", "command": "df -h /", "token": "tok", - "say": "Let me check that for you.", - } - desk.script["/desk/tool_result"] = {"type": "reply", "text": "Sixty percent used."} - - ctrl._handle_conversation_turn() - - assert spoken == ["Let me check that for you.", "Sixty percent used."] - assert ran == ["df -h /"] - relayed = next(r for r in desk.requests if r[0] == "POST" and r[1] == "/desk/tool_result") - assert relayed[2]["output"].endswith("60% used") - - -def test_a_device_command_never_reaches_the_shell(pipeline, monkeypatch): - ctrl, desk, spoken, _levels = pipeline - ran = [] - monkeypatch.setattr(controller_mod.server_client, "run_local_command", lambda cmd: ran.append(cmd)) - actions = [] - ctrl.action.connect(actions.append) - desk.script["/desk/converse"] = { - "type": "command", "command": "petctl emote wave", "token": "tok"} - desk.script["/desk/tool_result"] = {"type": "reply", "text": "There you go."} - - ctrl._handle_conversation_turn() - - assert ran == [] - assert actions == [{"action": "emote", "emote": "wave"}] - assert spoken == ["There you go."] - - -def test_the_mouth_moves_with_the_audio(pipeline): - """Lip-sync is only real if the level actually reaches the window.""" - ctrl, desk, _spoken, levels = pipeline - mouth = [] - ctrl.mouth.connect(mouth.append) - desk.script["/desk/converse"] = {"type": "reply", "text": "Talking now."} - - ctrl._handle_conversation_turn() - - assert levels, "TTS was never given a level callback" - assert 0.8 in mouth # opened while speaking... - assert mouth[-1] == 0.0 # ...and closed at the end - - -def test_a_notification_is_forwarded_and_spoken(pipeline): - """The path that was silently sitting for five to ten minutes.""" - ctrl, desk, spoken, _levels = pipeline - ctrl._notification_gate = controller_mod.notifications.NotificationGate("", 0) - desk.script["/desk/converse"] = {"type": "reply", "text": "Harry says he's around."} - - ctrl._queue_notification(Notification(app="Signal", summary="Harry", body="you there?")) - ctrl._maybe_heartbeat() - - assert spoken == ["Harry says he's around."] - forwarded = next(r for r in desk.requests if r[0] == "POST" and r[1] == "/desk/converse") - assert "Harry" in forwarded[2]["text"] - - -def test_streaming_falls_back_when_the_server_is_older(pipeline, monkeypatch): - """A server without /desk/converse_stream (or one that answers with nothing) - must not cost a turn — the client drops to the plain endpoint. This is how - the pet keeps working against a container that hasn't been updated yet.""" - ctrl, desk, spoken, _levels = pipeline - monkeypatch.setattr(controller_mod.config, "STREAMING_REPLIES", True) - desk.script["/desk/converse_stream"] = [] # nothing streamed back - desk.script["/desk/converse"] = {"type": "reply", "text": "Still here."} - - ctrl._handle_conversation_turn() - - assert spoken == ["Still here."] - paths = [r[1] for r in desk.requests if r[0] == "POST"] - assert paths == ["/desk/converse_stream", "/desk/converse"] - - -def test_a_dead_server_leaves_the_pet_usable(pipeline): - """It should flash an error and go back to listening, not wedge.""" - ctrl, desk, spoken, _levels = pipeline - desk.stop() # the server disappears mid-session - states = [] - ctrl.state_changed.connect(states.append) - - ctrl._handle_conversation_turn() - - assert states[-2:] == ["error", "idle"] - assert spoken == [] diff --git a/tests/test_quiet.py b/tests/test_quiet.py deleted file mode 100644 index df1a7c3..0000000 --- a/tests/test_quiet.py +++ /dev/null @@ -1,54 +0,0 @@ -"""Quiet-hours parsing and matching, without waiting for 11pm.""" - -import sys -from datetime import time as dtime -from pathlib import Path - -import pytest - -sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) - -from bolt_pet import quiet - - -def test_no_spec_means_never_quiet(): - assert quiet.is_quiet("") is False - assert quiet.is_quiet(" ") is False - - -def test_simple_daytime_range(): - ranges = quiet.parse_ranges("13:00-14:00") - assert quiet.in_ranges(dtime(13, 30), ranges) is True - assert quiet.in_ranges(dtime(12, 59), ranges) is False - assert quiet.in_ranges(dtime(14, 0), ranges) is False # end is exclusive - - -def test_range_wrapping_past_midnight(): - ranges = quiet.parse_ranges("23:00-08:00") - for moment in (dtime(23, 0), dtime(23, 59), dtime(0, 0), dtime(7, 59)): - assert quiet.in_ranges(moment, ranges) is True, moment - for moment in (dtime(8, 0), dtime(12, 0), dtime(22, 59)): - assert quiet.in_ranges(moment, ranges) is False, moment - - -def test_multiple_ranges(): - ranges = quiet.parse_ranges("23:00-08:00, 13:00-14:00") - assert quiet.in_ranges(dtime(13, 15), ranges) is True - assert quiet.in_ranges(dtime(2, 0), ranges) is True - assert quiet.in_ranges(dtime(16, 0), ranges) is False - - -def test_zero_length_range_is_not_all_day(): - assert quiet.in_ranges(dtime(12, 0), quiet.parse_ranges("09:00-09:00")) is False - - -def test_malformed_specs_raise_when_parsed_directly(): - for spec in ("nonsense", "25:00-26:00", "13:00", "13:60-14:00"): - with pytest.raises(quiet.QuietHoursError): - quiet.parse_ranges(spec) - - -def test_malformed_spec_is_reported_but_never_mutes_the_pet(): - seen = [] - assert quiet.is_quiet("nonsense", now=dtime(3, 0), on_error=seen.append) is False - assert len(seen) == 1 diff --git a/tests/test_screen_context.py b/tests/test_screen_context.py deleted file mode 100644 index bfc63aa..0000000 --- a/tests/test_screen_context.py +++ /dev/null @@ -1,56 +0,0 @@ -"""Screen-context parsing/annotation. The subprocess probes are platform -specific; the parsing they feed is not, so that's what's tested here.""" - -import sys -from pathlib import Path - -sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) - -from bolt_pet import screen_context - - -def test_parses_the_active_window_id(): - output = "_NET_ACTIVE_WINDOW(WINDOW): window id # 0x3c00007\n" - assert screen_context.parse_xprop_window_id(output) == "0x3c00007" - - -def test_no_active_window_id_when_nothing_is_focused(): - assert screen_context.parse_xprop_window_id("_NET_ACTIVE_WINDOW(WINDOW): window id # 0x0") is None - assert screen_context.parse_xprop_window_id("") is None - - -def test_parses_the_window_title(): - output = '_NET_WM_NAME(UTF8_STRING) = "bolt_pet/controller.py - Cursor"\n' - assert screen_context.parse_xprop_window_name(output) == "bolt_pet/controller.py - Cursor" - - -def test_unset_title_property(): - assert screen_context.parse_xprop_window_name("_NET_WM_NAME: not found") is None - - -def test_fullscreen_state_detection(): - assert screen_context.parse_xprop_fullscreen( - "_NET_WM_STATE(ATOM) = _NET_WM_STATE_FULLSCREEN, _NET_WM_STATE_FOCUSED") is True - assert screen_context.parse_xprop_fullscreen("_NET_WM_STATE(ATOM) = _NET_WM_STATE_FOCUSED") is False - - -def test_desktop_titles_are_treated_as_no_context(): - assert screen_context.clean_title("Desktop") is None - assert screen_context.clean_title(" ") is None - - -def test_long_titles_are_truncated(): - cleaned = screen_context.clean_title("x" * 500) - assert len(cleaned) <= 160 and cleaned.endswith("…") - - -def test_annotate_appends_context_as_an_aside(): - annotated = screen_context.annotate("what's this error?", "app.py — Traceback") - assert annotated.startswith("what's this error?") - assert "[on screen right now: app.py — Traceback]" in annotated - - -def test_annotate_is_a_no_op_without_a_title_or_text(): - assert screen_context.annotate("hello", None) == "hello" - assert screen_context.annotate("hello", "Desktop") == "hello" - assert screen_context.annotate("", "Firefox") == "" diff --git a/tests/test_screen_text.py b/tests/test_screen_text.py deleted file mode 100644 index 88a26e1..0000000 --- a/tests/test_screen_text.py +++ /dev/null @@ -1,168 +0,0 @@ -"""OCR plumbing for `petctl read` — engine selection and output cleanup. - -Only the pure half is covered, per the testing conventions: capture and the -OCR call itself need a real screen and a real engine. Engine probes are -injected so these pass on a machine with a different set installed (or none). -""" - -import sys -from pathlib import Path - -sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) - -from bolt_pet import screen_text -from bolt_pet.monitors import Monitor - - -def probes(modules=(), binaries=()): - return (lambda name: name in modules), ( - lambda name: f"/usr/bin/{name}" if name in binaries else None - ) - - -def test_prefers_tesseract_when_fully_installed(): - has_module, which = probes({"pytesseract"}, {"tesseract"}) - assert screen_text.resolve_engine(has_module, which) == ("pytesseract", "") - - -def test_falls_back_to_rapidocr_when_tesseract_binary_is_absent(): - has_module, which = probes({"pytesseract", "rapidocr_onnxruntime"}, set()) - engine, reason = screen_text.resolve_engine(has_module, which) - assert engine == "rapidocr" - assert reason == "" - - -def test_pytesseract_without_the_binary_says_which_half_is_missing(): - """The commonest broken setup: `pip install pytesseract` and stop, not - realising the actual engine is a system package.""" - has_module, which = probes({"pytesseract"}, set()) - engine, reason = screen_text.resolve_engine(has_module, which) - assert engine is None - assert "tesseract binary" in reason - assert "apt install tesseract-ocr" in reason - - -def test_nothing_installed_explains_how_to_fix_it(): - has_module, which = probes(set(), set()) - engine, reason = screen_text.resolve_engine(has_module, which) - assert engine is None - assert "pip install" in reason - - -def test_capture_availability_follows_mss(): - assert screen_text.capture_available(lambda name: name == "mss") - assert not screen_text.capture_available(lambda name: False) - - -def test_clean_drops_ocr_noise_and_blank_runs(): - raw = "Firefox\n\n\n |\n .\nBuild failed\n~\n" - assert screen_text.clean_ocr_text(raw) == "Firefox\nBuild failed" - - -def test_clean_collapses_whitespace_but_keeps_line_structure(): - raw = " File edit view \nline\ttwo " - assert screen_text.clean_ocr_text(raw) == "File edit view\nline two" - - -def test_clean_drops_consecutive_duplicates_only(): - raw = "Terminal\nTerminal\nEditor\nTerminal" - assert screen_text.clean_ocr_text(raw) == "Terminal\nEditor\nTerminal" - - -def test_clean_keeps_short_but_real_tokens(): - # two alphanumerics is the bar — "ok" and "42" survive, "-" doesn't - assert screen_text.clean_ocr_text("ok\n-\n42") == "ok\n42" - - -def test_clean_truncates_and_says_so(): - out = screen_text.clean_ocr_text("word " * 500, max_chars=100) - assert out.endswith("[truncated]") - # the cap applies to the text, before the marker is appended - assert len(out.split("\n[truncated]")[0]) <= 100 - - -def test_clean_handles_empty_input(): - assert screen_text.clean_ocr_text("") == "" - assert screen_text.clean_ocr_text(None) == "" - - -def test_format_reading_names_the_monitor(): - monitor = Monitor(1, "HDMI-1", 1920, 0, 1920, 1080) - out = screen_text.format_reading(monitor, "Build failed") - assert "monitor 2 (HDMI-1)" in out - assert out.endswith("Build failed") - - -def test_format_reading_when_nothing_was_recognised(): - monitor = Monitor(0, "DP-0", 0, 0, 1920, 1080) - assert "no text recognised" in screen_text.format_reading(monitor, " ") - - -def test_read_monitor_never_raises_without_an_engine(monkeypatch): - """Its return value goes back to the server as command output, so every - failure has to come back as a sentence rather than an exception.""" - monkeypatch.setattr(screen_text, "capture_available", lambda *a, **k: True) - monkeypatch.setattr( - screen_text, "resolve_engine", lambda *a, **k: (None, "no engine here") - ) - out = screen_text.read_monitor(Monitor(0, "DP-0", 0, 0, 1920, 1080)) - assert out.startswith("[pet]") - assert "no engine here" in out - - -def test_read_monitor_reports_a_failed_capture(monkeypatch): - monkeypatch.setattr(screen_text, "capture_available", lambda *a, **k: True) - monkeypatch.setattr(screen_text, "resolve_engine", lambda *a, **k: ("pytesseract", "")) - monkeypatch.setattr(screen_text, "capture", lambda monitor: None) - out = screen_text.read_monitor(Monitor(0, "DP-0", 0, 0, 1920, 1080)) - assert "couldn't capture" in out and "Wayland" in out - - -def test_read_monitor_survives_an_exploding_engine(monkeypatch): - monkeypatch.setattr(screen_text, "capture_available", lambda *a, **k: True) - monkeypatch.setattr(screen_text, "resolve_engine", lambda *a, **k: ("pytesseract", "")) - monkeypatch.setattr(screen_text, "capture", lambda monitor: object()) - monkeypatch.setattr( - screen_text, "_ocr", lambda image, engine: (_ for _ in ()).throw(RuntimeError("boom")) - ) - out = screen_text.read_monitor(Monitor(0, "DP-0", 0, 0, 1920, 1080)) - assert "OCR failed" in out and "boom" in out - - -def test_read_monitors_splits_the_budget(monkeypatch): - seen = [] - - def fake_read(monitor, max_chars): - seen.append((monitor.number, max_chars)) - return f"screen {monitor.number}" - - monkeypatch.setattr(screen_text, "read_monitor", fake_read) - screens = [ - Monitor(0, "A", 0, 0, 100, 100), - Monitor(1, "B", 100, 0, 100, 100), - Monitor(2, "C", 200, 0, 100, 100), - ] - out = screen_text.read_monitors(screens, 3000) - assert [n for n, _ in seen] == [1, 2, 3] - assert all(limit == 1000 for _, limit in seen) - assert out.count("screen ") == 3 - - -def test_read_monitors_keeps_a_floor_on_the_budget(monkeypatch): - monkeypatch.setattr( - screen_text, "read_monitor", lambda monitor, max_chars: str(max_chars) - ) - screens = [Monitor(i, str(i), 0, 0, 10, 10) for i in range(20)] - # 100/20 would be 5 characters per screen, which is useless — floor wins - assert "400" in screen_text.read_monitors(screens, 100) - - -def test_read_monitors_with_one_screen_uses_the_whole_budget(monkeypatch): - monkeypatch.setattr( - screen_text, "read_monitor", lambda monitor, max_chars: str(max_chars) - ) - assert screen_text.read_monitors([Monitor(0, "A", 0, 0, 10, 10)], 4000) == "4000" - - -def test_read_monitors_with_no_screens(): - assert "no monitor information" in screen_text.read_monitors([], 4000) diff --git a/tests/test_screens_feature.py b/tests/test_screens_feature.py deleted file mode 100644 index 15f272a..0000000 --- a/tests/test_screens_feature.py +++ /dev/null @@ -1,233 +0,0 @@ -"""End-to-end wiring for the multi-monitor features: `petctl jump`, `petctl -monitors`, `petctl read`, and the screen-layout note that rides along with -each utterance. - -Needs a QApplication (signals), so run with QT_QPA_PLATFORM=offscreen. -""" - -import sys -from pathlib import Path - -import pytest -from PySide6.QtWidgets import QApplication - -from bolt_pet import controller as controller_mod -from bolt_pet import pet_actions -from bolt_pet.monitors import Monitor - -sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) - -_app = QApplication.instance() or QApplication(["test"]) - - -@pytest.fixture(autouse=True) -def no_screen_probes(monkeypatch): - monkeypatch.setattr(controller_mod.screen_context, "is_fullscreen_active", lambda: False) - monkeypatch.setattr(controller_mod.screen_context, "context_for", lambda text: text) - - -@pytest.fixture -def ctrl(): - controller = controller_mod.PetController() - controller.set_monitors( - [ - Monitor(0, "HDMI-0", 0, 0, 1920, 1080), - Monitor(1, "HDMI-1", 1920, 0, 1920, 1080), - Monitor(2, "DP-0", 0, 1080, 1920, 1080, primary=True), - ] - ) - controller.set_pet_monitor(0) - return controller - - -def _capture(signal): - events = [] - signal.connect(lambda *a: events.append(a[0] if len(a) == 1 else a)) - return events - - -# ── parsing ───────────────────────────────────────────────────────────────── - -def test_jump_parses_without_validating_the_target(): - """Which monitors exist is a runtime fact, so the pure parser passes the - spec through and monitors.resolve() judges it later.""" - assert pet_actions.parse("petctl jump 2") == {"action": "jump", "target": "2"} - assert pet_actions.parse("petctl jump next") == {"action": "jump", "target": "next"} - assert pet_actions.parse("petctl monitor left") == {"action": "jump", "target": "left"} - assert pet_actions.parse("petctl screen HDMI-1") == {"action": "jump", "target": "HDMI-1"} - # a nonsense target is still parsed — it fails at resolve time, with a - # message listing the real monitors - assert pet_actions.parse("petctl jump sideways") == { - "action": "jump", "target": "sideways", - } - - -def test_jump_needs_a_target(): - with pytest.raises(pet_actions.ActionError, match="needs a monitor"): - pet_actions.parse("petctl jump") - - -def test_read_defaults_to_the_current_screen(): - assert pet_actions.parse("petctl read") == {"action": "read", "target": "here"} - assert pet_actions.parse("petctl read all") == {"action": "read", "target": "all"} - assert pet_actions.parse("petctl look 2") == {"action": "read", "target": "2"} - assert pet_actions.parse("petctl see here") == {"action": "read", "target": "here"} - - -def test_monitors_verb(): - for spelling in ("monitors", "screens", "displays"): - assert pet_actions.parse(f"petctl {spelling}") == {"action": "monitors"} - - -def test_help_mentions_the_new_verbs(): - assert "petctl jump" in pet_actions.HELP - assert "petctl read" in pet_actions.HELP - assert "petctl monitors" in pet_actions.HELP - - -# ── jump ──────────────────────────────────────────────────────────────────── - -def test_jump_resolves_to_an_index_before_reaching_the_window(ctrl): - """The controller resolves and emits a concrete index, so the window can't - re-resolve the spec against a different screen ordering.""" - actions = _capture(ctrl.action) - out = ctrl._handle_command("petctl jump next") - assert actions == [{"action": "jump", "monitor": 1}] - assert "monitor 2: HDMI-1" in out - - -def test_jump_by_direction_uses_the_published_layout(ctrl): - actions = _capture(ctrl.action) - ctrl._handle_command("petctl jump down") - assert actions == [{"action": "jump", "monitor": 2}] - - -def test_jump_tracks_where_the_pet_actually_is(ctrl): - ctrl.set_pet_monitor(1) - actions = _capture(ctrl.action) - ctrl._handle_command("petctl jump left") - assert actions == [{"action": "jump", "monitor": 0}] - - -def test_jump_to_a_nonexistent_monitor_reports_back_and_moves_nothing(ctrl): - actions = _capture(ctrl.action) - out = ctrl._handle_command("petctl jump 7") - assert actions == [] - assert "no monitor 7" in out - assert "you have 3" in out - - -def test_jump_never_reaches_the_shell(monkeypatch, ctrl): - ran = [] - monkeypatch.setattr(controller_mod.server_client, "run_local_command", ran.append) - ctrl._handle_command("petctl jump 2") - ctrl._handle_command("petctl monitors") - ctrl._handle_command("petctl read") - assert ran == [] - - -# ── monitors ──────────────────────────────────────────────────────────────── - -def test_monitors_lists_the_layout_and_where_the_pet_is(ctrl): - ctrl.set_pet_monitor(2) - out = ctrl._handle_command("petctl monitors") - assert "3 monitor(s)" in out - assert "HDMI-0" in out and "DP-0" in out - assert out.count("Bolt is here") == 1 - - -def test_monitors_before_the_ui_has_published_anything(): - fresh = controller_mod.PetController() - assert "no monitor information" in fresh._handle_command("petctl monitors") - - -# ── read ──────────────────────────────────────────────────────────────────── - -def test_read_here_uses_the_pets_own_screen(monkeypatch, ctrl): - seen = [] - monkeypatch.setattr( - controller_mod.screen_text, "read_monitor", - lambda monitor, limit: seen.append(monitor.number) or "text", - ) - ctrl.set_pet_monitor(1) - assert ctrl._handle_command("petctl read") == "text" - assert seen == [2] - - -def test_read_a_named_screen(monkeypatch, ctrl): - seen = [] - monkeypatch.setattr( - controller_mod.screen_text, "read_monitor", - lambda monitor, limit: seen.append(monitor.name) or "text", - ) - ctrl._handle_command("petctl read DP-0") - assert seen == ["DP-0"] - - -def test_read_all_goes_through_the_multi_screen_path(monkeypatch, ctrl): - seen = [] - monkeypatch.setattr( - controller_mod.screen_text, "read_monitors", - lambda monitors, limit: seen.append(len(monitors)) or "everything", - ) - assert ctrl._handle_command("petctl read all") == "everything" - assert seen == [3] - - -def test_read_an_unknown_screen_explains_rather_than_raising(ctrl): - out = ctrl._handle_command("petctl read 9") - assert out.startswith("[pet]") - assert "no monitor 9" in out - - -def test_read_respects_the_kill_switch(monkeypatch, ctrl): - monkeypatch.setattr(controller_mod.config, "SCREEN_TEXT", False) - called = [] - monkeypatch.setattr( - controller_mod.screen_text, "read_monitor", - lambda *a, **k: called.append(1) or "text", - ) - out = ctrl._handle_command("petctl read") - assert called == [] - assert "disabled" in out and "SCREEN_TEXT" in out - - -def test_read_passes_the_character_cap_through(monkeypatch, ctrl): - monkeypatch.setattr(controller_mod.config, "SCREEN_TEXT_MAX_CHARS", 123) - seen = [] - monkeypatch.setattr( - controller_mod.screen_text, "read_monitor", - lambda monitor, limit: seen.append(limit) or "text", - ) - ctrl._handle_command("petctl read") - assert seen == [123] - - -# ── per-turn context ──────────────────────────────────────────────────────── - -def test_layout_rides_along_with_each_utterance(monkeypatch, ctrl): - monkeypatch.setattr(controller_mod.config, "MONITOR_CONTEXT", True) - out = ctrl._with_context("what's on the other screen?") - assert out.startswith("what's on the other screen?") - assert "3 monitors:" in out - assert "Bolt is on 1" in out - - -def test_layout_context_can_be_switched_off(monkeypatch, ctrl): - monkeypatch.setattr(controller_mod.config, "MONITOR_CONTEXT", False) - assert ctrl._with_context("hello") == "hello" - - -def test_screen_text_never_rides_along_automatically(monkeypatch, ctrl): - """The layout is free; the *contents* cost an OCR pass and a lot of - privacy, so they only ever move on an explicit petctl read.""" - monkeypatch.setattr(controller_mod.config, "MONITOR_CONTEXT", True) - called = [] - monkeypatch.setattr( - controller_mod.screen_text, "read_monitor", lambda *a, **k: called.append(1) - ) - monkeypatch.setattr( - controller_mod.screen_text, "read_monitors", lambda *a, **k: called.append(1) - ) - ctrl._with_context("hello") - assert called == [] diff --git a/tests/test_self_restart.py b/tests/test_self_restart.py deleted file mode 100644 index 1f9de64..0000000 --- a/tests/test_self_restart.py +++ /dev/null @@ -1,158 +0,0 @@ -"""`petctl self_restart` — the pet restarting itself and remembering why. - -Everything here runs against a temp context file and a fake subprocess runner, -so the tests exercise the arming/preflight/report logic without any process -actually dying. -""" - -import subprocess -import sys -from pathlib import Path -from types import SimpleNamespace - -import pytest - -sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) - -from bolt_pet import pet_actions, self_restart - - -@pytest.fixture -def state(tmp_path): - return tmp_path / "restart_context.json" - - -def _ok_run(*args, **kwargs): - return SimpleNamespace(returncode=0, stdout="", stderr="") - - -def _broken_run(*args, **kwargs): - return SimpleNamespace( - returncode=1, stdout="", - stderr=' File "bolt_pet/controller.py", line 42\n def _speak(\nSyntaxError: invalid syntax', - ) - - -# ── parsing ───────────────────────────────────────────────────────────────── - -def test_self_restart_parses_with_a_free_text_reason(): - action = pet_actions.parse("petctl self_restart check the new walk cycle loads") - assert action == { - "action": "self_restart", "reason": "check the new walk cycle loads", - } - - -def test_self_restart_needs_no_reason_and_accepts_aliases(): - assert pet_actions.parse("petctl self_restart")["reason"] == "" - assert pet_actions.parse("petctl restart")["action"] == "self_restart" - assert pet_actions.parse("petctl reboot")["action"] == "self_restart" - - -def test_self_restart_is_listed_in_the_help(): - assert "self_restart" in pet_actions.HELP - - -# ── preflight ─────────────────────────────────────────────────────────────── - -def test_preflight_passes_when_the_code_imports(): - self_restart.preflight(run=_ok_run) # no exception - - -def test_preflight_hands_back_the_traceback_instead_of_dying(state): - """The whole point: a syntax error Bolt just introduced comes back as - something he can read and fix, in the same turn, with the pet still up.""" - with pytest.raises(self_restart.RestartError) as excinfo: - self_restart.preflight(run=_broken_run) - message = str(excinfo.value) - assert "does not import" in message - assert "SyntaxError" in message and "controller.py" in message - - -def test_preflight_runs_the_import_in_a_subprocess_not_here(): - """This process holds the *old* modules, so an in-process import would - pass on a file that no longer parses.""" - seen = {} - - def capture(cmd, **kwargs): - seen["cmd"], seen["kwargs"] = cmd, kwargs - return SimpleNamespace(returncode=0, stdout="", stderr="") - - self_restart.preflight(run=capture) - assert seen["cmd"][0] == sys.executable - assert "import bolt_pet" in seen["cmd"][2] - assert seen["kwargs"]["env"]["QT_QPA_PLATFORM"] == "offscreen" # imports need no display - - -def test_a_subprocess_that_cannot_even_run_is_reported(monkeypatch): - def explode(*args, **kwargs): - raise OSError("no python here") - - with pytest.raises(self_restart.RestartError, match="couldn't run the preflight"): - self_restart.preflight(run=explode) - - -# ── context across the restart ────────────────────────────────────────────── - -def test_arming_persists_the_reason_for_the_next_process(state): - self_restart.arm("check the sprite frames load", version="0.2.3", - session="pet-desktop", recent=["you: reload the sprites"], - path=state, now=1000.0) - revived = self_restart.load(state) - assert revived.reason == "check the sprite frames load" - assert revived.version == "0.2.3" - assert revived.recent == ["you: reload the sprites"] - assert revived.restarts == [1000.0] - - -def test_no_context_means_a_normal_start(state): - assert self_restart.load(state) is None - - -def test_a_corrupt_context_file_is_ignored_not_fatal(state): - state.write_text("{not json at all", encoding="utf-8") - assert self_restart.load(state) is None - - -def test_clearing_the_context_stops_it_being_re_announced(state): - self_restart.arm("once", path=state, now=1000.0) - self_restart.clear(state) - assert self_restart.load(state) is None - self_restart.clear(state) # clearing twice is not an error - - -def test_the_report_says_what_happened_and_what_to_check(state): - context = self_restart.arm( - "verify the dialogue command works", verify="verify the dialogue command works", - version="0.2.3", recent=["you: try a scene"], path=state, now=1000.0, - ) - text = self_restart.report(context, version="0.2.4", now=1004.5) - assert "I restarted myself" in text - assert "verify the dialogue command works" in text - assert "4.5s" in text - assert "0.2.4" in text and "was 0.2.3" in text - assert "you: try a scene" in text - - -# ── loop guard ────────────────────────────────────────────────────────────── - -def test_restart_history_accumulates_across_restarts(state): - self_restart.arm("one", path=state, now=1000.0) - self_restart.arm("two", path=state, now=1100.0) - assert self_restart.load(state).restarts == [1000.0, 1100.0] - - -def test_too_many_restarts_in_the_window_is_refused(state): - now = 1000.0 - for index in range(self_restart.MAX_RESTARTS): - self_restart.arm(f"attempt {index}", path=state, now=now + index) - with pytest.raises(self_restart.RestartError, match="looping"): - self_restart.check_loop_guard(self_restart.load(state), now=now + 10) - - -def test_old_restarts_fall_out_of_the_window(state): - now = 1000.0 - for index in range(self_restart.MAX_RESTARTS): - self_restart.arm(f"attempt {index}", path=state, now=now + index) - later = now + self_restart.WINDOW_SECONDS + 60 - self_restart.check_loop_guard(self_restart.load(state), now=later) # no exception - assert self_restart.recent_restarts(self_restart.load(state), now=later) == [] diff --git a/tests/test_server_client.py b/tests/test_server_client.py index 7c3b8a7..11f565e 100644 --- a/tests/test_server_client.py +++ b/tests/test_server_client.py @@ -6,14 +6,14 @@ sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) import pytest -from bolt_pet import server_client +from bolt_console import server_client @pytest.fixture(autouse=True) def _configure(monkeypatch): monkeypatch.setattr(server_client.config, "SERVER_URL", "http://test-server:5002") monkeypatch.setattr(server_client.config, "API_KEY", "test-key") - monkeypatch.setattr(server_client.config, "SESSION_ID", "pet-test") + monkeypatch.setattr(server_client.config, "SESSION_ID", "pet-console-test") def _mock_response(json_data, ok=True): @@ -28,11 +28,10 @@ def test_converse_returns_reply_directly(): post.return_value = _mock_response({"type": "reply", "text": "hello there"}) result = server_client.converse("hi") assert result.text == "hello there" - assert result.voice_id == "" # no speak_as on this reply post.assert_called_once() args, kwargs = post.call_args assert args[0] == "http://test-server:5002/desk/converse" - assert kwargs["json"] == {"session_id": "pet-test", "text": "hi"} + assert kwargs["json"] == {"session_id": "pet-console-test", "text": "hi"} assert kwargs["headers"] == {"X-Desk-Api-Key": "test-key"} @@ -50,23 +49,10 @@ def test_converse_relays_a_command_then_returns_reply(): second_call = post.call_args_list[1] assert second_call.args[0] == "http://test-server:5002/desk/tool_result" assert second_call.kwargs["json"] == { - "session_id": "pet-test", "token": "tok1", "output": "[exit 0]\nhi", + "session_id": "pet-console-test", "token": "tok1", "output": "[exit 0]\nhi", } -def test_converse_carries_a_speak_as_voice_back_with_the_reply(): - """The server tags a reply with the voice it picked (speak_as); this - client is what actually speaks in it, so the id has to survive the - return trip rather than being dropped with the rest of the payload.""" - with patch.object(server_client.requests, "post") as post: - post.return_value = _mock_response({ - "type": "reply", "text": "Ahoy there.", - "voice_id": "hnhGxwvHP8fc469w51rM", "voice_name": "Terence", - }) - result = server_client.converse("talk like a pirate") - assert result == server_client.Reply("Ahoy there.", "hnhGxwvHP8fc469w51rM", "Terence") - - def test_converse_raises_server_error_on_error_payload(): with patch.object(server_client.requests, "post") as post: post.return_value = _mock_response({"type": "error", "error": "unauthorized"}) @@ -80,19 +66,6 @@ def test_converse_raises_server_error_when_unreachable(): server_client.converse("hi") -def test_report_status_returns_reply_text_when_present(): - with patch.object(server_client.requests, "post") as post: - post.return_value = _mock_response({"reply": "don't forget your 3pm"}) - result = server_client.report_status() - assert result == "don't forget your 3pm" - - -def test_report_status_returns_none_when_nothing_pending(): - with patch.object(server_client.requests, "post") as post: - post.return_value = _mock_response({"ok": True}) - assert server_client.report_status() is None - - def test_check_health_returns_parsed_json(): with patch.object(server_client.requests, "get") as get: get.return_value = _mock_response({"ok": True, "service": "bolt-desk-api"}) @@ -109,7 +82,7 @@ def test_list_outbox_files_returns_the_queue(): assert result == [{"id": "abc", "name": "report.pdf", "size": 9}] args, kwargs = get.call_args assert args[0] == "http://test-server:5002/desk/files" - assert kwargs["params"] == {"session_id": "pet-test"} + assert kwargs["params"] == {"session_id": "pet-console-test"} assert kwargs["headers"] == {"X-Desk-Api-Key": "test-key"} @@ -134,7 +107,7 @@ def test_download_outbox_file_returns_raw_bytes(): assert result == b"%PDF fake bytes" args, kwargs = get.call_args assert args[0] == "http://test-server:5002/desk/files/abc" - assert kwargs["params"] == {"session_id": "pet-test"} + assert kwargs["params"] == {"session_id": "pet-console-test"} def test_download_outbox_file_raises_server_error_on_http_failure(): @@ -142,3 +115,133 @@ def test_download_outbox_file_raises_server_error_on_http_failure(): get.return_value = _mock_response({}, ok=False) with pytest.raises(server_client.ServerError, match="abc"): server_client.download_outbox_file("abc") + + +class _StreamResponse: + def __init__(self, events): + self._lines = [__import__("json").dumps(e) for e in events] + self.closed = False + + def raise_for_status(self): + pass + + def iter_lines(self, decode_unicode=True): + return iter(self._lines) + + def close(self): + self.closed = True + + +def test_converse_stream_hands_each_sentence_over_and_marks_the_reply_streamed(): + said = [] + events = [ + {"type": "say", "text": "First."}, + {"type": "say", "text": "Second."}, + {"type": "reply", "text": "First. Second.", "already_spoken": True}, + ] + with patch.object(server_client.requests, "post", return_value=_StreamResponse(events)): + reply = server_client.converse_stream("hi", on_say=said.append) + assert said == ["First.", "Second."] + assert reply == server_client.Reply("First. Second.", streamed=True) + + +def test_converse_stream_finishes_a_tool_call_through_the_plain_relay(): + events = [{"type": "command", "command": "filectl {}", "token": "t1"}] + posts = [_StreamResponse(events), _mock_response({"type": "reply", "text": "done"})] + on_command = MagicMock(return_value="out") + with patch.object(server_client.requests, "post", side_effect=posts) as post: + reply = server_client.converse_stream("hi", on_say=lambda _s: None, on_command=on_command) + on_command.assert_called_once_with("filectl {}") + assert post.call_args_list[1].args[0].endswith("/desk/tool_result") + assert reply == server_client.Reply("done", streamed=False) + + +def test_converse_stream_failing_before_any_text_raises_for_the_fallback(): + with patch.object(server_client.requests, "post", side_effect=ConnectionError("down")): + with pytest.raises(server_client.ServerError): + server_client.converse_stream("hi", on_say=lambda _s: None) + + +def test_run_local_command_runs_in_the_current_directory(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + (tmp_path / "marker.txt").write_text("x") + output = server_client.run_local_command("dir /b" if sys.platform == "win32" else "ls") + assert output.startswith("[exit 0]") + assert "marker.txt" in output + + +def test_a_final_reply_repeating_the_streamed_sentences_is_not_shown_twice(): + """Seen live: sentences streamed, then the same text again as the reply, + without already_spoken — with line breaks the stream had dropped.""" + events = [ + {"type": "say", "text": "Let me check that."}, + {"type": "say", "text": "The folder contains:"}, + {"type": "reply", "text": "Let me check that.\n\nThe folder contains:\n- a\n- b"}, + ] + with patch.object(server_client.requests, "post", return_value=_StreamResponse(events)): + reply = server_client.converse_stream("hi", on_say=lambda _s: None) + assert reply == server_client.Reply("- a\n- b", streamed=False) + + +def test_an_exact_repeat_counts_as_streamed(): + events = [{"type": "say", "text": "Done."}, {"type": "reply", "text": "Done."}] + with patch.object(server_client.requests, "post", return_value=_StreamResponse(events)): + reply = server_client.converse_stream("hi", on_say=lambda _s: None) + assert reply.streamed and reply.text == "" + + +def test_a_different_final_reply_is_shown_in_full(): + events = [{"type": "say", "text": "Checking."}, {"type": "reply", "text": "All good."}] + with patch.object(server_client.requests, "post", return_value=_StreamResponse(events)): + reply = server_client.converse_stream("hi", on_say=lambda _s: None) + assert reply == server_client.Reply("All good.", streamed=False) + + +def test_list_chats_returns_the_list_with_the_api_key(): + chats = [{"session_id": "chat-3f9a2b1c9e04", "label": "Fix the build", + "last_message": "try again", "last_active": "2026-09-25T23:46:31+00:00"}] + with patch.object(server_client.requests, "get") as get: + get.return_value = _mock_response({"chats": chats}) + assert server_client.list_chats() == chats + args, kwargs = get.call_args + assert args[0] == "http://test-server:5002/desk/chats" + assert kwargs["headers"] == {"X-Desk-Api-Key": "test-key"} + + +def test_list_chats_defaults_to_empty_and_wraps_failures(): + with patch.object(server_client.requests, "get") as get: + get.return_value = _mock_response({}) + assert server_client.list_chats() == [] + with patch.object(server_client.requests, "get") as get: + get.return_value = _mock_response({}, ok=False) # e.g. a 401 + with pytest.raises(server_client.ServerError, match="couldn't list chats"): + server_client.list_chats() + + +def test_create_chat_posts_and_returns_the_new_session_id(): + with patch.object(server_client.requests, "post") as post: + post.return_value = _mock_response({"session_id": "chat-3f9a2b1c9e04"}) + assert server_client.create_chat() == "chat-3f9a2b1c9e04" + args, kwargs = post.call_args + assert args[0] == "http://test-server:5002/desk/chats" + assert kwargs["headers"] == {"X-Desk-Api-Key": "test-key"} + + +def test_create_chat_without_a_session_id_is_an_error(): + with patch.object(server_client.requests, "post") as post: + post.return_value = _mock_response({}) + with pytest.raises(server_client.ServerError, match="session_id"): + server_client.create_chat() + with patch.object(server_client.requests, "post", side_effect=ConnectionError("down")): + with pytest.raises(server_client.ServerError): + server_client.create_chat() + + +def test_every_call_follows_a_reassigned_session_id(monkeypatch): + """Switching chats is just `config.SESSION_ID = new_id`; the next request + must carry it, with no call site holding on to the old one.""" + monkeypatch.setattr(server_client.config, "SESSION_ID", "chat-new") + with patch.object(server_client.requests, "post") as post: + post.return_value = _mock_response({"type": "reply", "text": "hi"}) + server_client.converse("hi") + assert post.call_args.kwargs["json"]["session_id"] == "chat-new" diff --git a/tests/test_speech.py b/tests/test_speech.py deleted file mode 100644 index 00b39db..0000000 --- a/tests/test_speech.py +++ /dev/null @@ -1,260 +0,0 @@ -"""The rules about talking — the four kinds of utterance and the mic policy. - -These were four near-copies in the controller before, and the copies had -drifted: one didn't arm barge-in, one skipped the follow-up rule. The value of -having one `Speaker` is only real if the differences between the kinds stay -*visible*, so this asserts on the differences rather than on the machinery. -""" - -import sys -from pathlib import Path - -import pytest - -sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) - -from bolt_pet import config, speech -from bolt_pet.speech import Speaker, Utterance -from bolt_pet.state import PetState, PetStateMachine - - -class FakeTts: - def __init__(self, completed=True): - self.completed = completed - self.calls = [] - - def speak(self, text, on_error=None, should_stop=None, voice_id=None, on_level=None): - self.calls.append({"text": text, "voice_id": voice_id, - "interruptible": should_stop is not None}) - if on_level is not None: - on_level(0.7) - return self.completed - - def play_pcm(self, pcm, sample_rate, should_stop=None, on_level=None): - self.calls.append({"pcm": pcm, "rate": sample_rate, - "interruptible": should_stop is not None}) - return self.completed - - -class FakeBargeIn: - def __init__(self): - self.resets = 0 - - def reset(self): - self.resets += 1 - - def check(self, _frame=None): - return False - - -def build(**kwargs): - """A speaker plus the things worth asserting on.""" - state = PetStateMachine() - tts = kwargs.pop("tts", None) or FakeTts() - said, logged, recorded, levels = [], [], [], [] - speaker = Speaker( - state=state, tts=tts, - history=recorded.append, - on_said=said.append, - on_log=logged.append, - on_level=levels.append, - **kwargs, - ) - return speaker, state, tts, said, logged, recorded, levels - - -# ── what distinguishes the four kinds ─────────────────────────────────────── - -def test_a_reply_is_recorded_but_a_holding_line_is_not(): - """Filler must not push the actual answer out of the transcript.""" - speaker, state, _tts, _said, _logged, recorded, _levels = build() - state.transition(PetState.LISTENING) - state.transition(PetState.THINKING) - - speaker.say(Utterance.holding("Give me a sec.")) - speaker.say(Utterance.reply("Sixty percent used.")) - - assert recorded == ["Sixty percent used."] - - -def test_a_holding_line_resumes_the_turn_it_interrupted(): - """The turn isn't over — a tool is still running — so it must go back to - THINKING, not drop to IDLE and end the turn.""" - speaker, state, _tts, *_ = build() - state.transition(PetState.LISTENING) - state.transition(PetState.THINKING) - - speaker.say(Utterance.holding("Let me check.")) - - assert state.state == PetState.THINKING - - -def test_a_holding_line_cannot_be_talked_over(): - """Cutting off "give me a sec" strands the tool that's already running.""" - detector = FakeBargeIn() - speaker, state, tts, *_ = build(barge_in=lambda: detector) - state.transition(PetState.LISTENING) - state.transition(PetState.THINKING) - - speaker.say(Utterance.holding("One sec.")) - assert tts.calls[-1]["interruptible"] is False - - speaker.say(Utterance.reply("Done.")) - assert tts.calls[-1]["interruptible"] is True - - -def test_a_streamed_sentence_stays_talking_between_sentences(): - """Otherwise the sprite flickers idle-talking-idle down a long answer.""" - speaker, state, *_ = build() - state.transition(PetState.LISTENING) - state.transition(PetState.THINKING) - - speaker.say(Utterance.stream_chunk("The disk is fine.")) - - assert state.state == PetState.TALKING - - -def test_a_scene_is_recorded_because_the_user_heard_it(): - speaker, state, _tts, _said, _logged, recorded, _levels = build() - state.transition(PetState.LISTENING) - state.transition(PetState.THINKING) - - speaker.say_pcm(Utterance.scene("Once upon a time."), pcm=b"\x00\x00", sample_rate=44100) - - assert recorded == ["Once upon a time."] - assert state.state == PetState.THINKING - - -# ── barge-in ordering, which is what the copies got wrong ─────────────────── - -def test_the_detector_is_reset_after_playback_not_only_before(): - """Playback fed the pet's own voice into the wake model's window. If it - isn't cleared afterwards, the idle listener re-hears the last sentence and - the pet answers itself.""" - detector = FakeBargeIn() - speaker, state, *_ = build(barge_in=lambda: detector) - state.transition(PetState.LISTENING) - state.transition(PetState.THINKING) - - speaker.say(Utterance.reply("Hello there.")) - - assert detector.resets == 2 # armed before, cleared after - - -def test_the_barge_in_detail_is_captured_before_the_reset(): - """Read it after the reset and every interruption reports zeroed counters — - which reads like hard evidence and is nothing of the sort.""" - detector = FakeBargeIn() - details = ["score 0.81 at frame 12", "score 0.000 at frame 0"] - speaker, state, *_ = build(barge_in=lambda: detector, - detail_of=lambda: details.pop(0)) - state.transition(PetState.LISTENING) - state.transition(PetState.THINKING) - - speaker.say(Utterance.reply("Hello there.")) - - assert speaker.last_detail == "score 0.81 at frame 12" - - -def test_a_detector_that_appears_late_is_still_used(): - """The detector is built after the speaker — it needs the mic stream — so - it's read through a callable. Holding a copy is how the two drift apart.""" - detector = None - speaker, state, tts, *_ = build(barge_in=lambda: detector) - state.transition(PetState.LISTENING) - state.transition(PetState.THINKING) - - speaker.say(Utterance.reply("Before.")) - assert tts.calls[-1]["interruptible"] is False - - detector = FakeBargeIn() - speaker.say(Utterance.reply("After.")) - assert tts.calls[-1]["interruptible"] is True - - -# ── the mouth ─────────────────────────────────────────────────────────────── - -def test_the_mouth_is_closed_when_the_line_ends(): - """A pet left mid-vowel after the audio stops looks broken.""" - speaker, state, _tts, _said, _logged, _recorded, levels = build() - state.transition(PetState.LISTENING) - state.transition(PetState.THINKING) - - speaker.say(Utterance.reply("Talking.")) - - assert levels[0] > 0 - assert levels[-1] == 0.0 - - -# ── text handling ─────────────────────────────────────────────────────────── - -def test_an_empty_line_is_not_spoken_at_all(): - """A reply that is nothing but an audio tag reduces to '' — and a silent - bubble with no audio is better than the pet announcing "laughing".""" - speaker, state, tts, said, *_ = build() - - assert speaker.say(Utterance.reply("[laughing]")) is True - assert tts.calls == [] - assert said == [] - assert state.state == PetState.IDLE - - -def test_the_bubble_gets_display_text_and_tts_gets_the_original(): - """The bubble keeps emoji and drops markdown; TTS does its own stripping - (inside speak(), so every path is covered) and needs the real text.""" - speaker, state, tts, said, *_ = build() - state.transition(PetState.LISTENING) - state.transition(PetState.THINKING) - - speaker.say(Utterance.reply("**Sixty** percent 🎉")) - - assert said == ["Sixty percent 🎉"] - assert tts.calls[-1]["text"] == "**Sixty** percent 🎉" - - -def test_a_voice_override_reaches_tts(): - speaker, state, tts, *_ = build(voice_id=lambda: "voice-abc") - state.transition(PetState.LISTENING) - state.transition(PetState.THINKING) - - speaker.say(Utterance.reply("In character.")) - - assert tts.calls[-1]["voice_id"] == "voice-abc" - - -# ── the follow-up rule ────────────────────────────────────────────────────── - -@pytest.fixture -def follow_ups_on(monkeypatch): - monkeypatch.setattr(config, "FOLLOW_UP_LISTEN", True) - monkeypatch.setattr(config, "FOLLOW_UP_MAX_TURNS", 3) - - -def test_an_interruption_always_reopens_the_mic(follow_ups_on): - """You talked over it — you are mid-sentence, so it has to listen.""" - keep, why = speech.follow_up_decision("Anything else?", completed=False, follow_ups=99) - assert keep is True - assert why == "interrupted" - - -def test_a_question_keeps_the_mic_open_without_the_wake_word(follow_ups_on): - keep, why = speech.follow_up_decision("Want me to check?", completed=True, follow_ups=0) - assert (keep, why) == (True, "question") - - -def test_a_statement_ends_the_turn(follow_ups_on): - keep, _why = speech.follow_up_decision("Sixty percent used.", completed=True, follow_ups=0) - assert keep is False - - -def test_the_cap_stops_a_server_that_ends_every_reply_with_a_question(follow_ups_on): - """Otherwise mic noise loops it forever.""" - assert speech.follow_up_decision("Ok?", completed=True, follow_ups=2)[0] is True - keep, why = speech.follow_up_decision("Ok?", completed=True, follow_ups=3) - assert keep is False - assert "cap" in why - - -def test_the_rule_can_be_switched_off(monkeypatch): - monkeypatch.setattr(config, "FOLLOW_UP_LISTEN", False) - assert speech.follow_up_decision("Ok?", completed=True, follow_ups=0)[0] is False diff --git a/tests/test_speech_text.py b/tests/test_speech_text.py deleted file mode 100644 index 38dc385..0000000 --- a/tests/test_speech_text.py +++ /dev/null @@ -1,111 +0,0 @@ -"""Sanitizing chat-formatted replies into speakable prose. Pure string logic -— no audio hardware, no Qt (see the testing conventions in CLAUDE.md).""" - -import sys -from pathlib import Path - -import pytest - -sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) - -from bolt_pet.speech_text import for_display, for_speech, is_question - - -def test_bold_markers_are_not_spoken(): - assert "*" not in for_speech("Here's the **maji-desktop** snapshot") - assert for_speech("Here's the **maji-desktop** snapshot") == "Here's the maji-desktop snapshot" - - -def test_italics_and_underscores_dropped(): - assert for_speech("that is _really_ odd") == "that is really odd" - assert for_speech("***everything*** is fine") == "everything is fine" - - -def test_bullet_list_becomes_sentences(): - spoken = for_speech( - "System status:\n" - "* **OS:** Linux 7.0.0\n" - "* **Uptime:** 1 day\n" - ) - assert "*" not in spoken - assert spoken == "System status: OS: Linux 7.0.0. Uptime: 1 day." - - -def test_emoji_and_symbols_removed(): - assert for_speech("✅ Done 🚀 — all good") == "Done all good" - assert for_speech("→ next step") == "to next step" - - -def test_urls_and_code_are_not_read_out_character_by_character(): - assert for_speech("see https://example.com/x?y=1 for docs") == "see link for docs" - assert for_speech("run `sudo reboot` now") == "run sudo reboot now" - assert for_speech("here:\n```\nls -la\n```\n") == "here: (code)." - - -def test_headings_quotes_and_rules_stripped(): - assert for_speech("## Summary\n---\n> quoted bit") == "Summary. quoted bit." - - -def test_ampersand_and_percent_are_spoken_as_words(): - assert for_speech("R&D at 50% capacity") == "R and D at 50 percent capacity" - - -def test_blank_and_symbol_only_input(): - assert for_speech("") == "" - assert for_speech(None) == "" - assert for_speech("***") == "" - - -def test_display_keeps_emoji_but_drops_markdown(): - assert for_display("**Done** ✅") == "Done ✅" - assert for_display("* one\n* two") == "• one • two" - - -def test_is_question_only_fires_on_a_trailing_question(): - assert is_question("Ready to run a command or start a project?") - assert is_question("It's 7:15 AM. Want me to set a timer?") - assert not is_question("It's 7:15 AM on July 23, 2026.") - assert not is_question("What time is it? It's 7:15 AM.") # asked in passing - - -def test_is_question_ignores_trailing_decoration(): - assert is_question("Ready to go? 🚀") - assert is_question('Shall I continue?"') - assert is_question("Want me to fix it? **") - - -def test_is_question_ignores_question_marks_that_are_not_spoken(): - # The '?' here is inside a URL query string, which for_speech strips. - assert not is_question("Docs are at https://example.com/x?y=1") - assert not is_question("") - assert not is_question(None) - - -# ── ElevenLabs v3 delivery tags (observed live 2026-07-31) ────────────────── -# "[laughing] That one came through clean" was spoken as "laughing That one -# came through clean": the tags mean something to the dialogue model inside a -# dialoguectl scene, and nothing at all to the ordinary reply voice. - -def test_delivery_tags_are_never_spoken_aloud(): - spoken = for_speech("[laughing] That one came through clean.") - assert "laughing" not in spoken - assert spoken.startswith("That one came through clean") - - -def test_the_bubble_does_not_caption_a_laugh_nobody_heard(): - assert "[laughing]" not in for_display("[laughing] All good.") - - -@pytest.mark.parametrize("tag", [ - "[whispering]", "[cheerfully]", "[sighs]", "[laughs]", "[clears throat]", - "[nervously]", "[excited]", "[pause]", "[shouting]", -]) -def test_the_common_tags_are_all_covered(tag): - assert "" == for_speech(tag).strip() - - -def test_ordinary_bracketed_text_survives(): - """The tags are matched narrowly on purpose — real bracketed content is - part of what the user asked to hear.""" - assert "1" in for_speech("See reference [1] for details.") - assert "docs" in for_speech("It's in [the docs] somewhere.") diff --git a/tests/test_state.py b/tests/test_state.py deleted file mode 100644 index 89be627..0000000 --- a/tests/test_state.py +++ /dev/null @@ -1,73 +0,0 @@ -import sys -from pathlib import Path - -sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) - -import pytest - -from bolt_pet.state import InvalidTransition, PetState, PetStateMachine - - -def test_starts_idle(): - sm = PetStateMachine() - assert sm.state == PetState.IDLE - - -def test_happy_path_transitions(): - sm = PetStateMachine() - sm.transition(PetState.LISTENING) - sm.transition(PetState.THINKING) - sm.transition(PetState.TALKING) - sm.transition(PetState.IDLE) - assert sm.state == PetState.IDLE - - -def test_idle_to_talking_is_allowed_for_proactive_announcements(): - # The heartbeat poll can make the pet speak unprompted (a reminder - # firing, a nudge from the server) with no preceding listen/think leg. - sm = PetStateMachine() - sm.transition(PetState.TALKING) - assert sm.state == PetState.TALKING - - -def test_invalid_transition_raises(): - sm = PetStateMachine() - with pytest.raises(InvalidTransition): - sm.transition(PetState.THINKING) # can't skip straight to thinking with no utterance - - -def test_same_state_transition_is_a_noop(): - calls = [] - sm = PetStateMachine(on_change=lambda old, new: calls.append((old, new))) - sm.transition(PetState.IDLE) # already idle - assert calls == [] - - -def test_on_change_callback_fires_with_old_and_new(): - calls = [] - sm = PetStateMachine(on_change=lambda old, new: calls.append((old, new))) - sm.transition(PetState.LISTENING) - assert calls == [(PetState.IDLE, PetState.LISTENING)] - - -def test_every_state_can_reach_error_and_recover(): - for path in ( - [PetState.LISTENING], - [PetState.LISTENING, PetState.THINKING], - [PetState.LISTENING, PetState.THINKING, PetState.TALKING], - ): - sm = PetStateMachine() - for step in path: - sm.transition(step) - sm.transition(PetState.ERROR) - sm.transition(PetState.IDLE) - assert sm.state == PetState.IDLE - - -def test_force_recovers_from_talking_directly_to_idle_without_validation(): - sm = PetStateMachine() - sm.transition(PetState.LISTENING) - sm.transition(PetState.THINKING) - sm.transition(PetState.TALKING) - sm.force(PetState.LISTENING) # not in TALKING's allowed set, but force skips the check - assert sm.state == PetState.LISTENING diff --git a/tests/test_stt.py b/tests/test_stt.py deleted file mode 100644 index 48de327..0000000 --- a/tests/test_stt.py +++ /dev/null @@ -1,103 +0,0 @@ -"""stt.transcribe() — the "guaranteed" one-shot fallback. - -There is no separate REST endpoint server-side any more: /desk/stt is a -websocket relay only, so this connects the exact same way stt_stream.py's -opportunistic streaming path does (via stt_stream.connect(), unconditionally -— not gated by STT_STREAMING, since there's nothing left to fall back to). -""" - -import json -import sys -import time -from pathlib import Path - -import numpy as np -import pytest - -sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) - -from bolt_pet.audio import stt, stt_stream - - -class FakeSocket: - def __init__(self, messages=(), fail_to_connect=False): - self.sent = [] - self.closed = False - self._fail_to_connect = fail_to_connect - self._messages = list(messages) - - def send_binary(self, data): - self.sent.append(data) - - def send(self, text): - self.sent.append(text) - - def recv(self): - if self._messages: - return self._messages.pop(0) - time.sleep(0.01) - raise ConnectionError("closed") - - def close(self): - self.closed = True - - -def _results(transcript, is_final=True): - return json.dumps({ - "type": "Results", "is_final": is_final, - "channel": {"alternatives": [{"transcript": transcript}]}, - }) - - -@pytest.fixture(autouse=True) -def _configured(monkeypatch): - monkeypatch.setattr(stt.config, "SERVER_URL", "http://test-server:5002") - monkeypatch.setattr(stt.config, "API_KEY", "test-key") - - -def test_transcribe_raises_when_not_configured(monkeypatch): - monkeypatch.setattr(stt.config, "API_KEY", "") - with pytest.raises(stt.SttError, match="BOLT_SERVER_URL"): - stt.transcribe(np.zeros(320, dtype=np.int16)) - - -def test_transcribe_connects_and_feeds_the_whole_utterance(monkeypatch): - socket = FakeSocket([_results("turn on the lights")]) - monkeypatch.setattr(stt_stream, "connect", lambda: socket) - - pcm = np.full(3200, 500, dtype=np.int16) - text = stt.transcribe(pcm) - - assert text == "turn on the lights" - # one binary frame (the whole utterance) plus the close message - assert len(socket.sent) == 2 - assert socket.closed - - -def test_transcribe_raises_when_the_server_is_unreachable(monkeypatch): - def refuse(): - raise OSError("no route to host") - - monkeypatch.setattr(stt_stream, "connect", refuse) - - with pytest.raises(stt.SttError, match="couldn't reach"): - stt.transcribe(np.zeros(320, dtype=np.int16)) - - -def test_transcribe_returns_empty_string_for_silence_not_an_error(monkeypatch): - """No speech recognized is a legitimate outcome, not a failure — callers - (controller.py) treat "" as "say nothing" rather than logging an error.""" - socket = FakeSocket([]) # never says anything back - monkeypatch.setattr(stt_stream, "connect", lambda: socket) - - assert stt.transcribe(np.zeros(320, dtype=np.int16)) == "" - - -def test_transcribe_is_unaffected_by_stt_streaming_being_off(monkeypatch): - """The opportunistic accelerator and the guaranteed fallback share a - connector, but STT_STREAMING must only gate the former.""" - monkeypatch.setattr(stt.config, "STT_STREAMING", False) - socket = FakeSocket([_results("still works")]) - monkeypatch.setattr(stt_stream, "connect", lambda: socket) - - assert stt.transcribe(np.zeros(320, dtype=np.int16)) == "still works" diff --git a/tests/test_stt_stream.py b/tests/test_stt_stream.py deleted file mode 100644 index a1fd3c8..0000000 --- a/tests/test_stt_stream.py +++ /dev/null @@ -1,224 +0,0 @@ -"""Streaming speech-to-text: the protocol, and the fallback that makes it safe -to switch on at all. - -A fake websocket throughout — no network, no Deepgram account, no audio. -""" - -import json -import sys -import time -from pathlib import Path - -import numpy as np -import pytest - -sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) - -from bolt_pet.audio import mic -from bolt_pet.audio.stt_stream import StreamingTranscriber - - -class FakeSocket: - """Records what was sent; replays scripted Deepgram frames.""" - - def __init__(self, messages=(), fail_on_send=False): - self.sent = [] - self.closed = False - self.fail_on_send = fail_on_send - self._messages = list(messages) - - def send_binary(self, data): - if self.fail_on_send: - raise ConnectionError("socket died") - self.sent.append(data) - - def send(self, text): - self.sent.append(text) - - def recv(self): - if self._messages: - return self._messages.pop(0) - time.sleep(0.01) - raise ConnectionError("closed") - - def close(self): - self.closed = True - - -def _results(transcript, is_final=True): - return json.dumps({ - "type": "Results", "is_final": is_final, - "channel": {"alternatives": [{"transcript": transcript}]}, - }) - - -def _frame(value=1000): - return np.full(320, value, dtype=np.int16) - - -# ── the protocol ──────────────────────────────────────────────────────────── - -def test_frames_go_up_as_they_are_captured(): - socket = FakeSocket([_results("what's the weather")]) - session = StreamingTranscriber(socket) - - for _ in range(3): - session.feed(_frame()) - text = session.finish() - - assert len(socket.sent) == 4 # three frames plus the close message - assert text == "what's the weather" - assert socket.closed - - -def test_only_final_results_are_kept(): - """Interim hypotheses change under you; concatenating them would produce - "what what's what's the what's the weather".""" - socket = FakeSocket([ - _results("what's", is_final=False), - _results("what's the", is_final=False), - _results("what's the weather", is_final=True), - ]) - session = StreamingTranscriber(socket) - assert session.finish() == "what's the weather" - - -def test_several_final_segments_are_joined(): - socket = FakeSocket([_results("turn the lights on"), _results("in the kitchen")]) - session = StreamingTranscriber(socket) - assert session.finish() == "turn the lights on in the kitchen" - - -def test_junk_frames_are_ignored_rather_than_killing_the_reader(): - """An exception on the reader thread would silently end transcription for - the rest of the utterance.""" - socket = FakeSocket(["not json at all", '{"type":"Metadata"}', - _results("still works")]) - session = StreamingTranscriber(socket) - assert session.finish() == "still works" - - -def test_an_empty_frame_means_the_socket_closed(): - """websocket-client returns "" from recv() on a closed connection, so it - ends the read loop rather than being treated as a blank transcript.""" - socket = FakeSocket([_results("heard this much"), "", _results("never arrives")]) - session = StreamingTranscriber(socket) - assert session.finish() == "heard this much" - - -def test_a_socket_that_dies_mid_utterance_gives_up_quietly(): - socket = FakeSocket([], fail_on_send=True) - session = StreamingTranscriber(socket) - - session.feed(_frame()) # must not raise — recording carries on - assert session.finish() == "" - - -# ── opening: failure is an ordinary outcome ──────────────────────────────── - -def test_open_returns_none_when_it_cannot_connect(): - """None means "the one-shot path will do it", not an error.""" - def refuse(): - raise OSError("no network") - - assert StreamingTranscriber.open(connect=refuse) is None - - -def test_open_returns_a_session_when_it_can(): - session = StreamingTranscriber.open(connect=lambda: FakeSocket([_results("hi")])) - assert session is not None - assert session.finish() == "hi" - - -def test_streaming_is_off_without_the_switch_or_server_config(monkeypatch): - from bolt_pet.audio import stt_stream - - monkeypatch.setattr(stt_stream.config, "STT_STREAMING", False) - monkeypatch.setattr(stt_stream.config, "SERVER_URL", "http://test-server:5002") - monkeypatch.setattr(stt_stream.config, "API_KEY", "test-key") - assert stt_stream.available() is False - monkeypatch.setattr(stt_stream.config, "STT_STREAMING", True) - assert stt_stream.available() is True - monkeypatch.setattr(stt_stream.config, "API_KEY", "") - assert stt_stream.available() is False - - -# ── connecting to the server relay ────────────────────────────────────────── - -def test_connect_builds_the_server_relay_url(monkeypatch): - """No local Deepgram account any more — the pet connects to its own - server's /desk/stt, authenticated with its own desk key.""" - from bolt_pet.audio import stt_stream - - monkeypatch.setattr(stt_stream.config, "SERVER_URL", "http://my-server:5002") - monkeypatch.setattr(stt_stream.config, "API_KEY", "my-desk-key") - monkeypatch.setattr(stt_stream.config, "SESSION_ID", "pet-test") - - captured = {} - - class _FakeWebsocketModule: - @staticmethod - def create_connection(url, header=None, timeout=None): - captured["url"] = url - captured["header"] = header - return "a-socket" - - monkeypatch.setitem(sys.modules, "websocket", _FakeWebsocketModule()) - - result = stt_stream.connect(sample_rate=16000) - - assert result == "a-socket" - assert captured["url"] == ( - "ws://my-server:5002/desk/stt?session_id=pet-test" - "&encoding=linear16&sample_rate=16000" - ) - assert captured["header"] == ["X-Desk-Api-Key: my-desk-key"] - - -def test_open_uses_connect_by_default(monkeypatch): - """StreamingTranscriber.open() with no injected connect() goes through - the real server-relay connector.""" - from bolt_pet.audio import stt_stream - - monkeypatch.setattr(stt_stream.config, "STT_STREAMING", True) - monkeypatch.setattr(stt_stream.config, "SERVER_URL", "http://my-server:5002") - monkeypatch.setattr(stt_stream.config, "API_KEY", "my-desk-key") - monkeypatch.setattr(stt_stream, "_connect", lambda rate: FakeSocket([_results("hi")])) - - session = stt_stream.StreamingTranscriber.open() - - assert session is not None - assert session.finish() == "hi" - - -# ── the capture hook ──────────────────────────────────────────────────────── - -class _Stream: - """Loud frames, then quiet ones, so the VAD ends the utterance.""" - - def __init__(self, loud=6, quiet=40): - self.frames = ([np.full((320, 1), 3000, dtype=np.int16)] * loud - + [np.zeros((320, 1), dtype=np.int16)] * quiet) - - def read(self, n): - return (self.frames.pop(0) if self.frames - else np.zeros((320, 1), dtype=np.int16)), None - - -def test_recording_hands_every_speech_frame_to_the_listener(): - seen = [] - pcm = mic.record_utterance(_Stream(), on_frame=seen.append, - silence_end_sec=0.2, min_utterance_s=0.0) - assert pcm is not None - assert len(seen) >= 6 # every frame of speech was streamed - - -def test_a_listener_that_throws_cannot_break_the_recording(): - """The fallback is about to need this audio — a dead stream must not cost - the recording too.""" - def explode(frame): - raise RuntimeError("stream died") - - pcm = mic.record_utterance(_Stream(), on_frame=explode, - silence_end_sec=0.2, min_utterance_s=0.0) - assert pcm is not None and len(pcm) > 0 diff --git a/tests/test_sudo_askpass.py b/tests/test_sudo_askpass.py deleted file mode 100644 index cc0ed0e..0000000 --- a/tests/test_sudo_askpass.py +++ /dev/null @@ -1,122 +0,0 @@ -"""Graphical sudo prompts: command rewriting and helper resolution. -Pure logic — no display, no sudo, no password.""" - -import sys -from pathlib import Path - -import pytest - -sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) - -from bolt_pet import sudo_askpass - - -# ── rewriting ──────────────────────────────────────────────────────────────── - - -@pytest.mark.parametrize( - "command,expected", - [ - ("sudo apt update", "sudo -A apt update"), - ("sudo apt update", "sudo -A apt update"), # spacing preserved - ("apt update && sudo apt upgrade", "apt update && sudo -A apt upgrade"), - ("ls; sudo reboot", "ls; sudo -A reboot"), - ("echo hi | sudo tee /etc/motd", "echo hi | sudo -A tee /etc/motd"), - ("sudo systemctl restart x\nsudo systemctl status x", - "sudo -A systemctl restart x\nsudo -A systemctl status x"), - ], -) -def test_bare_sudo_gets_the_askpass_flag(command, expected): - assert sudo_askpass.add_askpass_flag(command) == expected - - -@pytest.mark.parametrize( - "command", - [ - "sudo -n apt update", # explicitly non-interactive - "sudo -A apt update", # already asking - "sudo -u bob whoami", # the caller was explicit - "ls -la", # no sudo at all - "echo 'run sudo later'", # inside a quoted string - "pseudo --version", # not the word sudo - ], -) -def test_commands_that_must_not_be_rewritten(command): - assert sudo_askpass.add_askpass_flag(command) == command - - -def test_blank_input(): - assert sudo_askpass.add_askpass_flag("") == "" - assert sudo_askpass.add_askpass_flag(None) == "" - - -@pytest.mark.parametrize( - "command,expected", - [ - ("sudo apt update", True), - ("ls && sudo reboot", True), - ("ls -la", False), - ("echo 'sudo'", False), - ("", False), - ], -) -def test_which_commands_get_the_longer_timeout(command, expected): - assert sudo_askpass.needs_password_prompt(command) is expected - - -# ── helper resolution ──────────────────────────────────────────────────────── - - -def test_a_configured_helper_wins(): - found = sudo_askpass.find_helper( - configured="/opt/my-askpass", is_executable=lambda p: p == "/opt/my-askpass", - ) - assert found == "/opt/my-askpass" - - -def test_a_configured_helper_that_is_not_executable_is_not_silently_replaced(): - """Better to have sudo fail than to quietly prompt with something the - user didn't choose.""" - assert sudo_askpass.find_helper( - configured="/opt/typo", is_executable=lambda p: False, which=lambda t: "/usr/bin/zenity", - ) is None - - -def test_a_real_askpass_binary_beats_a_generated_wrapper(): - found = sudo_askpass.find_helper( - configured="", is_executable=lambda p: p == "/usr/bin/ksshaskpass", - which=lambda tool: "/usr/bin/zenity", - ) - assert found == "/usr/bin/ksshaskpass" - - -def test_falls_back_to_wrapping_a_dialog_tool(tmp_path): - found = sudo_askpass.find_helper( - configured="", is_executable=lambda p: False, - which=lambda tool: "/usr/bin/zenity" if tool == "zenity" else None, - cache_dir=tmp_path, - ) - script = tmp_path / "askpass.sh" - assert found == str(script) - assert "zenity --password" in script.read_text() - assert script.stat().st_mode & 0o777 == 0o700 # nobody else edits the password box - - -def test_no_helper_available_at_all(): - assert sudo_askpass.find_helper( - configured="", is_executable=lambda p: False, which=lambda tool: None, - ) is None - - -def test_the_wrapper_passes_sudos_prompt_through(): - """sudo hands the helper its prompt as $1 — it names the account the - password is for, which is worth showing in the dialog.""" - script = sudo_askpass.helper_script("/usr/bin/zenity") - assert script.startswith("#!/bin/sh") - assert '"$1"' in script - - -def test_environment_points_sudo_at_the_helper(): - env = sudo_askpass.environment("/tmp/askpass.sh", base={"PATH": "/usr/bin"}) - assert env["SUDO_ASKPASS"] == "/tmp/askpass.sh" - assert env["PATH"] == "/usr/bin" # the rest of the environment survives diff --git a/tests/test_tools.py b/tests/test_tools.py new file mode 100644 index 0000000..b47b465 --- /dev/null +++ b/tests/test_tools.py @@ -0,0 +1,164 @@ +"""The relayed-command dispatcher: what runs, what asks first, what's refused.""" + +import json +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from bolt_console import tools as tools_mod +from bolt_console.tools import Tools + + +class FakeUI: + def __init__(self, answers=()): + self.answers = list(answers) + self.calls = [] + self.results = [] + self.questions = [] + + def tool_call(self, name, argument): + self.calls.append((name, argument)) + + def tool_result(self, text, error=False): + self.results.append((text, error)) + + def diff(self, old, new): + self.calls.append(("diff", old, new)) + + def confirm(self, question): + self.questions.append(question) + return self.answers.pop(0) if self.answers else "no" + + +class FakeShell: + def __init__(self, output="[exit 0]\nhello"): + self.output = output + self.ran = [] + + def __call__(self, command): + self.ran.append(command) + return self.output + + +def filectl(**payload) -> str: + return "filectl " + json.dumps(payload) + + +def test_shell_command_asks_first_and_runs_on_yes(): + ui, shell = FakeUI(["yes"]), FakeShell() + output = Tools(ui, run_shell=shell).handle("echo hello") + assert shell.ran == ["echo hello"] + assert output == "[exit 0]\nhello" + assert ui.calls == [("Shell", "echo hello")] + assert ui.results == [("hello", False)] + + +def test_declined_command_never_runs_and_tells_the_model_why(): + ui, shell = FakeUI(["no"]), FakeShell() + output = Tools(ui, run_shell=shell).handle("rm -rf build") + assert shell.ran == [] + assert "declined" in output and "did not run" in output + + +def test_always_switches_the_session_to_auto(): + ui, shell = FakeUI(["always"]), FakeShell() + tools = Tools(ui, run_shell=shell) + tools.handle("echo one") + tools.handle("echo two") + assert tools.mode == "auto" + assert len(ui.questions) == 1 + assert shell.ran == ["echo one", "echo two"] + + +def test_auto_mode_never_asks(): + ui, shell = FakeUI(), FakeShell() + Tools(ui, mode="auto", run_shell=shell).handle("echo hi") + assert ui.questions == [] + assert shell.ran == ["echo hi"] + + +def test_failed_command_is_shown_as_an_error_with_its_status(): + ui = FakeUI(["yes"]) + Tools(ui, run_shell=FakeShell("[exit 2]\nno such file")).handle("cat nope") + assert ui.results == [("[exit 2]\nno such file", True)] + + +def test_reads_and_lists_never_ask(tmp_path): + (tmp_path / "a.txt").write_text("one\ntwo\n", encoding="utf-8") + ui = FakeUI() + tools = Tools(ui, run_shell=FakeShell()) + read = tools.handle(filectl(op="read", path=str(tmp_path / "a.txt"))) + listed = tools.handle(filectl(op="list", path=str(tmp_path))) + assert ui.questions == [] + assert "one" in read and "two" in read + assert "a.txt" in listed + assert ui.calls[0] == ("Read", str(tmp_path / "a.txt")) + assert ui.results[0] == ("Read 2 lines", False) + + +def test_write_asks_and_declining_leaves_the_disk_alone(tmp_path): + target = tmp_path / "new.txt" + ui = FakeUI(["no"]) + output = Tools(ui).handle(filectl(op="write", path=str(target), content="x")) + assert not target.exists() + assert "declined" in output + + +def test_edit_shows_the_diff_then_applies_on_yes(tmp_path): + target = tmp_path / "f.py" + target.write_text("x = 1\n", encoding="utf-8") + ui = FakeUI(["yes"]) + output = Tools(ui).handle(filectl(op="edit", path=str(target), old="x = 1", new="x = 2")) + assert target.read_text(encoding="utf-8") == "x = 2\n" + assert ("diff", "x = 1", "x = 2") in ui.calls + assert "edited" in output + + +def test_filectl_errors_go_back_to_the_model_not_up_the_stack(tmp_path): + ui = FakeUI() + output = Tools(ui).handle(filectl(op="read", path=str(tmp_path / "missing.txt"))) + assert output.startswith("[filectl] no such file") + assert ui.results[-1][1] is True + + +def test_pet_commands_are_explained_rather_than_run(): + ui, shell = FakeUI(), FakeShell() + output = Tools(ui, mode="auto", run_shell=shell).handle("petctl emote wave") + assert shell.ran == [] + assert output == tools_mod.PET_UNAVAILABLE.format(name="petctl") + + +def test_dialogue_scenes_are_explained_rather_than_run(): + shell = FakeShell() + output = Tools(FakeUI(), mode="auto", run_shell=shell).handle('dialoguectl {"lines": []}') + assert shell.ran == [] + assert "isn't available" in output + + +def test_a_stray_command_label_is_stripped_and_reported(): + """Seen live: the server relayed `command: powershell -Command "..."` and + cmd.exe answered "'command:' is not recognized".""" + ui, shell = FakeUI(["yes"]), FakeShell() + output = Tools(ui, run_shell=shell).handle('command: powershell -Command "Get-PnpDevice"') + assert shell.ran == ['powershell -Command "Get-PnpDevice"'] + assert ui.calls[0] == ("Shell", 'powershell -Command "Get-PnpDevice"') + assert 'removed a leading "command:" label' in output + + +def test_wrapping_backticks_are_stripped(): + shell = FakeShell() + Tools(FakeUI(), mode="auto", run_shell=shell).handle("`git status`") + assert shell.ran == ["git status"] + + +def test_the_label_is_stripped_before_filectl_is_recognised(tmp_path): + (tmp_path / "a.txt").write_text("hi", encoding="utf-8") + output = Tools(FakeUI()).handle( + "command: filectl " + json.dumps({"op": "read", "path": str(tmp_path / "a.txt")})) + assert "hi" in output and "command:" in output # read ran; note explains the fix + + +def test_a_clean_command_gets_no_note(): + output = Tools(FakeUI(), mode="auto", run_shell=FakeShell()).handle("echo hi") + assert "note" not in output diff --git a/tests/test_tts_stream.py b/tests/test_tts_stream.py deleted file mode 100644 index 456d056..0000000 --- a/tests/test_tts_stream.py +++ /dev/null @@ -1,175 +0,0 @@ -"""Streaming-TTS chunk reassembly and the near-miss log. Both are pure — -no network, no audio device, no ONNX model.""" - -import sys -from pathlib import Path -from unittest.mock import MagicMock, patch - -import numpy as np -import pytest - -sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) - -from bolt_pet import config as tts_config -from bolt_pet.audio import tts -from bolt_pet.audio.tts import TtsError, chunks_to_int16, voice_for -from bolt_pet.audio.wake_word import NearMissLog - - -def _pcm(*values): - return np.array(values, dtype=np.int16).tobytes() - - -def test_whole_samples_pass_straight_through(): - chunks = list(chunks_to_int16([_pcm(1, 2), _pcm(3, 4)])) - assert np.concatenate(chunks).tolist() == [1, 2, 3, 4] - - -def test_a_sample_split_across_two_http_chunks_is_rejoined(): - # The killer bug this exists to prevent: an odd byte at a chunk boundary - # shifts everything after it by one byte and plays as static. - raw = _pcm(100, -200, 300, -400) - chunks = list(chunks_to_int16([raw[:3], raw[3:]])) - assert np.concatenate(chunks).tolist() == [100, -200, 300, -400] - - -def test_many_odd_boundaries_in_a_row(): - raw = _pcm(*range(1, 21)) - pieces = [raw[i:i + 3] for i in range(0, len(raw), 3)] # every boundary odd - assert np.concatenate(list(chunks_to_int16(pieces))).tolist() == list(range(1, 21)) - - -def test_empty_chunks_are_skipped(): - assert list(chunks_to_int16([b"", b""])) == [] - - -def test_a_dangling_byte_at_the_end_is_dropped_not_played(): - raw = _pcm(7, 8) + b"\x01" - assert np.concatenate(list(chunks_to_int16([raw]))).tolist() == [7, 8] - - -# ── wake-word near misses ─────────────────────────────────────────────────── - -def test_scores_just_under_the_threshold_are_recorded(): - log = NearMissLog(limit=10, margin=0.2) - assert log.observe(0.45, threshold=0.5, timestamp=1.0) is True - assert log.entries() == [(1.0, 0.45, 0.5)] - - -def test_detections_and_background_noise_are_not_near_misses(): - log = NearMissLog(limit=10, margin=0.2) - assert log.observe(0.90, threshold=0.5, timestamp=1.0) is False # it fired - assert log.observe(0.05, threshold=0.5, timestamp=2.0) is False # just noise - assert log.entries() == [] - - -def test_peak_tracks_every_score_not_just_near_misses(): - log = NearMissLog(limit=10, margin=0.2) - log.observe(0.30, threshold=0.5, timestamp=1.0) - log.observe(0.95, threshold=0.5, timestamp=2.0) - log.observe(0.10, threshold=0.5, timestamp=3.0) - assert log.peak == 0.95 - - -def test_the_log_is_bounded(): - log = NearMissLog(limit=3, margin=0.2) - for i in range(10): - log.observe(0.45, threshold=0.5, timestamp=float(i)) - assert len(log.entries()) == 3 - assert log.entries()[-1][0] == 9.0 - - -def test_clear_resets_peak_and_entries(): - log = NearMissLog(limit=3, margin=0.2) - log.observe(0.45, threshold=0.5, timestamp=1.0) - log.clear() - assert log.entries() == [] and log.peak == 0.0 - - -# ── voice selection (server speak_as) ─────────────────────────────────────── - -def test_the_override_voice_wins_over_the_configured_one(monkeypatch): - monkeypatch.setattr(tts_config, "ELEVENLABS_VOICE_ID", "DEFAULT") - assert voice_for("VOICE1") == "VOICE1" - assert voice_for("") == "DEFAULT" - assert voice_for(None) == "DEFAULT" - - -# ── server-hosted synthesis (/desk/tts) ───────────────────────────────────── -# No local ElevenLabs account: both the whole-clip and streaming paths post -# to this pet's own server, same as server_client.py's other endpoints. - -@pytest.fixture(autouse=True) -def _configured(monkeypatch): - monkeypatch.setattr(tts_config, "SERVER_URL", "http://test-server:5002") - monkeypatch.setattr(tts_config, "API_KEY", "test-key") - monkeypatch.setattr(tts_config, "SESSION_ID", "pet-test") - monkeypatch.setattr(tts_config, "ELEVENLABS_VOICE_ID", "default-voice") - - -def _mock_response(content=b"", ok=True): - resp = MagicMock() - resp.content = content - resp.raise_for_status = MagicMock() if ok else MagicMock(side_effect=Exception("boom")) - return resp - - -def test_synthesize_pcm_posts_to_the_servers_tts_endpoint(): - pcm_bytes = np.array([1, 2, 3], dtype=np.int16).tobytes() - with patch.object(tts.requests, "post") as post: - post.return_value = _mock_response(pcm_bytes) - pcm, rate = tts.synthesize_pcm("hello there") - post.assert_called_once() - args, kwargs = post.call_args - assert args[0] == "http://test-server:5002/desk/tts" - assert kwargs["headers"] == {"X-Desk-Api-Key": "test-key"} - assert kwargs["json"] == { - "session_id": "pet-test", "text": "hello there", "voice_id": "default-voice", - } - assert pcm.tolist() == [1, 2, 3] - assert rate == tts_config.TTS_SAMPLE_RATE - - -def test_synthesize_pcm_uses_the_override_voice(): - with patch.object(tts.requests, "post") as post: - post.return_value = _mock_response(b"\x01\x00") - tts.synthesize_pcm("hi", voice_id="picked-voice") - assert post.call_args.kwargs["json"]["voice_id"] == "picked-voice" - - -def test_synthesize_pcm_raises_when_not_configured(monkeypatch): - monkeypatch.setattr(tts_config, "API_KEY", "") - with pytest.raises(TtsError, match="BOLT_SERVER_URL"): - tts.synthesize_pcm("hi") - - -def test_synthesize_pcm_raises_on_empty_audio(): - with patch.object(tts.requests, "post") as post: - post.return_value = _mock_response(b"") - with pytest.raises(TtsError, match="no audio"): - tts.synthesize_pcm("hi") - - -def test_synthesize_pcm_raises_when_the_request_fails(): - with patch.object(tts.requests, "post") as post: - post.return_value = _mock_response(b"", ok=False) - with pytest.raises(TtsError, match="server tts request failed"): - tts.synthesize_pcm("hi") - - -def test_stream_pcm_posts_to_the_same_endpoint_with_stream_true(): - with patch.object(tts.requests, "post") as post: - response = MagicMock() - response.raise_for_status = MagicMock() - response.iter_content.return_value = [np.array([4, 5], dtype=np.int16).tobytes()] - post.return_value = response - chunks = list(tts.stream_pcm("hi")) - assert post.call_args.kwargs["stream"] is True - assert post.call_args[0][0] == "http://test-server:5002/desk/tts" - assert np.concatenate(chunks).tolist() == [4, 5] - - -def test_stream_pcm_raises_when_not_configured(monkeypatch): - monkeypatch.setattr(tts_config, "SERVER_URL", "") - with pytest.raises(TtsError, match="BOLT_SERVER_URL"): - list(tts.stream_pcm("hi")) diff --git a/tests/test_updater.py b/tests/test_updater.py deleted file mode 100644 index d006622..0000000 --- a/tests/test_updater.py +++ /dev/null @@ -1,237 +0,0 @@ -"""Auto-updater: version comparison, release parsing, and the apply/rollback -dance driven by a fake git (no network, no real repo, nothing checked out).""" - -import sys -from pathlib import Path - -import pytest - -sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) - -from bolt_pet import updater - - -# ── version comparison ─────────────────────────────────────────────────────── - - -@pytest.mark.parametrize( - "tag,expected", - [ - ("v1.2.3", (1, 2, 3)), - ("1.2.3", (1, 2, 3)), - ("V0.1.0", (0, 1, 0)), - ("1.2", (1, 2)), - ("1.2.3-beta1", (1, 2, 3)), # suffix ends the parse - ("", ()), - ("nightly", ()), - ], -) -def test_parse_version(tag, expected): - assert updater.parse_version(tag) == expected - - -@pytest.mark.parametrize( - "candidate,current", - [("0.2.0", "0.1.0"), ("1.0.0", "0.9.9"), ("0.1.1", "0.1"), ("v2.0", "1.9.9")], -) -def test_is_newer_accepts_newer_versions(candidate, current): - assert updater.is_newer(candidate, current) - - -@pytest.mark.parametrize( - "candidate,current", - [ - ("0.1.0", "0.1.0"), - ("0.1.0", "0.2.0"), - ("0.1", "0.1.0"), # zero-padded: equal, not newer - ("", "0.1.0"), - ("nightly", "0.1.0"), # unparseable is never newer - ], -) -def test_is_newer_rejects_same_or_older(candidate, current): - assert not updater.is_newer(candidate, current) - - -def test_release_parsing_skips_drafts_and_untagged(): - assert updater.release_from_payload({"tag_name": "v1.0.0", "draft": True}) is None - assert updater.release_from_payload({"name": "no tag"}) is None - release = updater.release_from_payload({"tag_name": "v1.0.0", "name": "One", "prerelease": True}) - assert (release.tag, release.name, release.prerelease) == ("v1.0.0", "One", True) - - -# ── fake git ───────────────────────────────────────────────────────────────── - - -class FakeGit: - """Records every git invocation and answers from a canned script. - *failures* maps a leading-args tuple to the (code, output) it should - return, so a test can make exactly one command fail.""" - - def __init__(self, ref="main", dirty=False, failures=None, requirements_changed=False, - head="abc1234", tag_commit="def5678"): - self.calls = [] - self._ref = ref - self._dirty = dirty - self._failures = failures or {} - self._requirements_changed = requirements_changed - self._head = head - self._tag_commit = tag_commit # equal to head = "already on this tag" - - def __call__(self, args): - self.calls.append(list(args)) - for prefix, result in self._failures.items(): - if tuple(args[: len(prefix)]) == prefix: - return result - head = args[0] - if head == "rev-parse" and args[1] == "--git-dir": - return 0, ".git" - if head == "status": - return 0, " M bolt_pet/config.py" if self._dirty else "" - if head == "symbolic-ref": - return (0, self._ref) if self._ref else (1, "") - if head == "rev-parse": - return 0, self._tag_commit if args[1].startswith("tags/") else self._head - if head == "diff": - return 0, "requirements.txt" if self._requirements_changed else "" - return 0, "" - - def commands(self): - """Just the verbs, for asserting on the sequence.""" - return [call[0] for call in self.calls] - - -def test_apply_update_checks_out_the_tag(tmp_path): - git = FakeGit() - previous = updater.apply_update( - "v1.0.0", run=git, repo=tmp_path, install_deps=False, verify=lambda repo: None - ) - assert previous == "main" - assert ["fetch", "--tags", "--prune", "origin"] in git.calls - assert ["checkout", "--force", "tags/v1.0.0"] in git.calls - - -def test_a_dirty_working_tree_is_left_completely_alone(tmp_path): - git = FakeGit(dirty=True) - with pytest.raises(updater.UpdateError, match="local changes"): - updater.apply_update("v1.0.0", run=git, repo=tmp_path, install_deps=False) - assert "fetch" not in git.commands() - assert "checkout" not in git.commands() - - -def test_a_non_git_install_refuses_before_touching_anything(tmp_path): - git = FakeGit(failures={("rev-parse", "--git-dir"): (128, "not a repository")}) - with pytest.raises(updater.UpdateError, match="not a git clone"): - updater.apply_update("v1.0.0", run=git, repo=tmp_path, install_deps=False) - assert "checkout" not in git.commands() - - -def test_a_failed_smoke_test_rolls_back_to_the_previous_ref(tmp_path): - git = FakeGit(ref="main") - - def broken(repo): - raise updater.UpdateError("the new version failed to import: boom") - - with pytest.raises(updater.UpdateError, match="rolled back to main"): - updater.apply_update("v1.0.0", run=git, repo=tmp_path, install_deps=False, verify=broken) - - checkouts = [call for call in git.calls if call[0] == "checkout"] - assert checkouts == [["checkout", "--force", "tags/v1.0.0"], ["checkout", "--force", "main"]] - - -def test_rollback_targets_the_commit_when_head_is_detached(tmp_path): - # No branch to go back to (symbolic-ref fails) — the SHA is the ref. - git = FakeGit(ref="") - - with pytest.raises(updater.UpdateError): - updater.apply_update( - "v1.0.0", run=git, repo=tmp_path, install_deps=False, - verify=lambda repo: (_ for _ in ()).throw(RuntimeError("nope")), - ) - assert ["checkout", "--force", "abc1234"] in git.calls - - -def test_a_verify_that_raises_something_unexpected_still_rolls_back(tmp_path): - git = FakeGit() - - def exploding(repo): - raise ValueError("not even an UpdateError") - - with pytest.raises(updater.UpdateError, match="rolled back"): - updater.apply_update( - "v1.0.0", run=git, repo=tmp_path, install_deps=False, verify=exploding - ) - assert ["checkout", "--force", "main"] in git.calls - - -def test_a_release_tagged_without_bumping_the_version_does_not_loop(tmp_path): - """Cut a release but forget to bump __version__ in the tagged commit and - every check would see the same "newer" tag: check out (a no-op), restart, - read the old version, repeat — a restart loop every check interval.""" - git = FakeGit(head="same1234", tag_commit="same1234") - - with pytest.raises(updater.UpdateError, match="bump it in the tagged commit"): - updater.apply_update("v0.2.1", run=git, repo=tmp_path, install_deps=False, - verify=lambda repo: None) - - assert "checkout" not in git.commands() # nothing moved, so nothing to restart into - - -def test_an_unknown_tag_is_not_mistaken_for_being_already_on_it(tmp_path): - git = FakeGit(failures={("rev-parse", "tags/"): (128, "unknown revision")}) - # The failure key matches by prefix, so make it explicit that a tag we - # can't resolve means "not there yet" rather than "already applied". - assert updater.already_at_tag(git, "v9.9.9") is False - - -def test_a_failed_fetch_never_moves_the_checkout(tmp_path): - git = FakeGit(failures={("fetch",): (1, "could not resolve host")}) - with pytest.raises(updater.UpdateError, match="git fetch failed"): - updater.apply_update("v1.0.0", run=git, repo=tmp_path, install_deps=False) - assert "checkout" not in git.commands() - - -def test_a_stranded_checkout_is_logged_loudly(tmp_path): - """Rollback itself failing is the one case a human has to fix by hand.""" - git = FakeGit(failures={("checkout", "--force", "main"): (1, "index locked")}) - logs = [] - - with pytest.raises(updater.UpdateError): - updater.apply_update( - "v1.0.0", run=git, repo=tmp_path, install_deps=False, on_log=logs.append, - verify=lambda repo: (_ for _ in ()).throw(updater.UpdateError("bad build")), - ) - assert any("ROLLBACK FAILED" in line and "git checkout main" in line for line in logs) - - -def test_deps_are_only_reinstalled_when_requirements_actually_changed(tmp_path, monkeypatch): - installs = [] - monkeypatch.setattr(updater, "_install_deps", lambda repo: installs.append(repo)) - - updater.apply_update( - "v1.0.0", run=FakeGit(requirements_changed=False), repo=tmp_path, - install_deps=True, verify=lambda repo: None, - ) - assert installs == [] - - updater.apply_update( - "v1.0.0", run=FakeGit(requirements_changed=True), repo=tmp_path, - install_deps=True, verify=lambda repo: None, - ) - assert installs == [tmp_path] - - -# ── release checking ───────────────────────────────────────────────────────── - - -def test_check_for_update_returns_nothing_when_current(monkeypatch): - monkeypatch.setattr( - updater, "fetch_latest_release", - lambda **kwargs: updater.Release("v0.1.0", "0.1.0", "", False), - ) - assert updater.check_for_update(current_version="0.1.0") is None - assert updater.check_for_update(current_version="0.0.9").tag == "v0.1.0" - - -def test_no_releases_yet_is_not_an_error(monkeypatch): - monkeypatch.setattr(updater, "fetch_latest_release", lambda **kwargs: None) - assert updater.check_for_update(current_version="0.1.0") is None diff --git a/tests/test_wake_word.py b/tests/test_wake_word.py deleted file mode 100644 index b09ee2c..0000000 --- a/tests/test_wake_word.py +++ /dev/null @@ -1,148 +0,0 @@ -import sys -from pathlib import Path - -sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) - -import numpy as np -import pytest - -from bolt_pet.audio import wake_word - - -class _FakeStream: - """Yields a fixed sequence of frames, then silence forever.""" - - def __init__(self, frames, frame_len): - self._frames = list(frames) - self._frame_len = frame_len - - def read(self, frames): - if self._frames: - frame = self._frames.pop(0) - else: - frame = np.zeros(self._frame_len, dtype=np.int16) - return frame.reshape(-1, 1), False - - -def _frame(frame_len): - return np.zeros(frame_len, dtype=np.int16) - - -class _FakeModel: - """Reports the given score sequence (one dict per predict() call, then - repeats the last entry) and records reset() calls.""" - - def __init__(self, score_sequence): - self._scores = list(score_sequence) - self.reset_calls = 0 - - def predict(self, frame): - if self._scores: - return self._scores.pop(0) - return {"thunderbolt": 0.0} - - def reset(self): - self.reset_calls += 1 - - -def test_returns_true_and_resets_on_detection(monkeypatch): - frame_len = 1280 - monkeypatch.setattr(wake_word.config, "FRAME_LEN", frame_len) - monkeypatch.setattr(wake_word.config, "SAMPLE_RATE", 16000) - monkeypatch.setattr(wake_word.config, "WAKE_CHECK_INTERVAL_SECONDS", 0.08) # 1 frame - monkeypatch.setattr(wake_word.config, "WAKE_WORD_THRESHOLD", 0.5) - - stream = _FakeStream([_frame(frame_len)] * 3, frame_len) - model = _FakeModel([{"thunderbolt": 0.1}, {"thunderbolt": 0.9}]) - - detected = wake_word.listen_for_wake_word(stream, model=model) - - assert detected is True - assert model.reset_calls == 1 - - -def test_returns_false_when_should_continue_goes_false_first(): - frame_len = 1280 - stream = _FakeStream([_frame(frame_len)] * 5, frame_len) - model = _FakeModel([{"thunderbolt": 0.0}] * 5) - calls = {"n": 0} - - def should_continue(): - calls["n"] += 1 - return calls["n"] <= 3 - - detected = wake_word.listen_for_wake_word( - stream, should_continue=should_continue, model=model, - ) - assert detected is False - assert model.reset_calls == 0 - - -def test_custom_threshold_is_respected(monkeypatch): - frame_len = 1280 - monkeypatch.setattr(wake_word.config, "FRAME_LEN", frame_len) - monkeypatch.setattr(wake_word.config, "SAMPLE_RATE", 16000) - monkeypatch.setattr(wake_word.config, "WAKE_CHECK_INTERVAL_SECONDS", 0.08) - - stream = _FakeStream([_frame(frame_len)] * 3, frame_len) - model = _FakeModel([{"thunderbolt": 0.6}] * 3) - calls = {"n": 0} - - def should_continue(): - calls["n"] += 1 - return calls["n"] <= 3 - - detected = wake_word.listen_for_wake_word( - stream, should_continue=should_continue, model=model, threshold=0.7, - ) - assert detected is False - - -def test_on_tick_fires_once_per_check_interval(monkeypatch): - frame_len = 1280 - monkeypatch.setattr(wake_word.config, "FRAME_LEN", frame_len) - monkeypatch.setattr(wake_word.config, "SAMPLE_RATE", 16000) - monkeypatch.setattr(wake_word.config, "WAKE_CHECK_INTERVAL_SECONDS", 0.08) # 1 frame/check - - frames = [_frame(frame_len) for _ in range(5)] - stream = _FakeStream(frames, frame_len) - model = _FakeModel([{"thunderbolt": 0.0}] * 5) - ticks = {"n": 0} - state = {"i": 0} - - def should_continue(): - state["i"] += 1 - return state["i"] <= 5 - - wake_word.listen_for_wake_word( - stream, - should_continue=should_continue, - model=model, - on_tick=lambda: ticks.__setitem__("n", ticks["n"] + 1), - ) - assert ticks["n"] == 5 # one check-interval per frame at this config - - -def test_on_tick_interval_can_span_multiple_frames(monkeypatch): - frame_len = 1280 - monkeypatch.setattr(wake_word.config, "FRAME_LEN", frame_len) - monkeypatch.setattr(wake_word.config, "SAMPLE_RATE", 16000) - monkeypatch.setattr(wake_word.config, "WAKE_CHECK_INTERVAL_SECONDS", 0.24) # 3 frames/check - - frames = [_frame(frame_len) for _ in range(6)] - stream = _FakeStream(frames, frame_len) - model = _FakeModel([{"thunderbolt": 0.0}] * 6) - ticks = {"n": 0} - state = {"i": 0} - - def should_continue(): - state["i"] += 1 - return state["i"] <= 6 - - wake_word.listen_for_wake_word( - stream, - should_continue=should_continue, - model=model, - on_tick=lambda: ticks.__setitem__("n", ticks["n"] + 1), - ) - assert ticks["n"] == 2 # 6 frames / 3 frames-per-check diff --git a/tests/test_wander.py b/tests/test_wander.py deleted file mode 100644 index 1dc8458..0000000 --- a/tests/test_wander.py +++ /dev/null @@ -1,89 +0,0 @@ -"""Autonomous wandering. Needs a QApplication, so run with -QT_QPA_PLATFORM=offscreen (same as test_controller.py).""" - -import sys -from pathlib import Path - -import pytest - -sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) - -from PySide6.QtWidgets import QApplication - -from bolt_pet import config -from bolt_pet.state import PetState -from bolt_pet.ui.pet_window import PetWindow - - -@pytest.fixture(scope="module") -def qt_app(): - app = QApplication.instance() or QApplication([]) - yield app - - -@pytest.fixture -def pet(qt_app): - window = PetWindow() - window.set_wander_enabled(True) - yield window - window.close() - - -def _walk_until_done(pet, max_ticks=2000): - for _ in range(max_ticks): - pet._wander_tick() - if pet._wander_target is None: - return True - return False - - -def test_wanders_when_idle_and_reaches_its_target(pet, monkeypatch): - # Snapping is tested separately; here it would tug the pet off its target - # the moment it arrives near an edge. - monkeypatch.setattr(config, "PET_EDGE_SNAP", False) - pet.wander_now() - pet._wander_tick() - target = pet._wander_target - if target is None: - pytest.skip("no usable screen geometry for a stroll on this host") - assert _walk_until_done(pet), "pet never reached its wander target" - assert pet.pos() == target - - -def test_stays_put_while_talking(pet): - pet.set_state(PetState.TALKING) - start = pet.pos() - pet.wander_now() - for _ in range(50): - pet._wander_tick() - assert pet.pos() == start - assert pet._wander_target is None - - -def test_stays_put_while_bubble_is_up(pet): - pet.say("hello there", duration_ms=60000) - start = pet.pos() - pet.wander_now() - for _ in range(50): - pet._wander_tick() - assert pet.pos() == start - - -def test_disabling_wander_stops_movement(pet): - pet.set_wander_enabled(False) - start = pet.pos() - pet.wander_now() - for _ in range(50): - pet._wander_tick() - assert pet.pos() == start - - -def test_stroll_stays_on_screen(pet): - geo = pet._screen_geometry() - if geo is None: - pytest.skip("no usable screen geometry on this host") - for _ in range(10): - pet.wander_now() - pet._wander_tick() - assert _walk_until_done(pet) - assert geo.contains(pet.geometry()) diff --git a/tests/test_window_state.py b/tests/test_window_state.py deleted file mode 100644 index 03b8950..0000000 --- a/tests/test_window_state.py +++ /dev/null @@ -1,84 +0,0 @@ -"""Remembering where the pet was left — and refusing to when that's a trap.""" - -import json -import sys -from pathlib import Path - -sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) - -from bolt_pet import window_state - - -def test_a_saved_position_comes_back(tmp_path): - path = tmp_path / "window.json" - window_state.save(1200, 640, path) - assert window_state.load(path) == (1200, 640) - - -def test_no_file_yet_is_not_an_error(tmp_path): - assert window_state.load(tmp_path / "nope.json") is None - - -def test_a_corrupt_file_falls_back_to_the_default_corner(tmp_path): - """Whatever is in there, the pet still has to start.""" - path = tmp_path / "window.json" - for junk in ("", "{", "null", "[]", '{"x": "left"}', '{"y": 3}'): - path.write_text(junk) - assert window_state.load(path) is None - - -def test_saving_creates_the_cache_directory(tmp_path): - path = tmp_path / "deep" / "nested" / "window.json" - window_state.save(10, 20, path) - assert window_state.load(path) == (10, 20) - - -def test_an_unwritable_location_is_swallowed(tmp_path): - """A read-only cache dir is a reason to forget the position, not to crash - on every drag.""" - window_state.save(1, 2, tmp_path / "no" / "\0bad" / "window.json") - - -def test_the_write_is_atomic_and_leaves_no_litter(tmp_path): - path = tmp_path / "window.json" - window_state.save(5, 5, path) - window_state.save(7, 7, path) - assert json.loads(path.read_text()) == {"x": 7, "y": 7} - assert [p.name for p in tmp_path.iterdir()] == ["window.json"] - - -# ── validating against the screens that exist *now* ───────────────────────── - -LAPTOP = (0, 0, 1920, 1080) -EXTERNAL = (1920, 0, 4480, 1440) - - -def test_a_position_on_a_connected_screen_is_kept(): - assert window_state.is_visible_on(1700, 900, 128, [LAPTOP]) - - -def test_a_position_on_an_unplugged_monitor_is_refused(): - """The dangerous case: restoring it faithfully puts the pet somewhere you - can't see or reach.""" - assert not window_state.is_visible_on(3000, 700, 128, [LAPTOP]) - assert window_state.is_visible_on(3000, 700, 128, [LAPTOP, EXTERNAL]) - - -def test_mostly_off_screen_counts_as_gone(): - """A few pixels of ear poking onto the desktop is not "reachable".""" - assert not window_state.is_visible_on(1910, 500, 128, [LAPTOP]) - assert window_state.is_visible_on(1830, 500, 128, [LAPTOP]) - - -def test_touching_an_edge_is_not_overlapping(): - assert not window_state.is_visible_on(1920, 0, 128, [LAPTOP]) - - -def test_negative_coordinates_are_fine_when_a_screen_is_there(): - """Monitors left of or above the primary have negative origins.""" - left_of_primary = (-1920, 0, 0, 1080) - assert window_state.is_visible_on(-900, 400, 128, [left_of_primary, LAPTOP]) - - -def test_no_screens_at_all_is_not_visible(): - assert not window_state.is_visible_on(100, 100, 128, []) diff --git a/thunderbolt.onnx b/thunderbolt.onnx deleted file mode 100644 index 44e5868..0000000 Binary files a/thunderbolt.onnx and /dev/null differ