7 Commits

Author SHA1 Message Date
themajesticmagician 3a0959f55d Streaming replies and STT, amplitude lip-sync, one place for speaking
Latency: replies are spoken sentence-by-sentence off the desk API's NDJSON
endpoint, so the wait is time-to-first-sentence rather than the whole model
call, and Deepgram's live websocket transcribes while you're still talking
instead of uploading the WAV afterwards. Both fall back invisibly — a stream
that fails before anything was said drops to converse(), and a socket that
never opens just means the old one-shot path.

Speaking lived in four near-copies in the controller (a reply, a holding line,
a streamed sentence, a dialogue scene) that had already drifted: one didn't arm
barge-in, another skipped the follow-up rule. It's now speech.Speaker plus an
Utterance describing the policy differences, with collaborators injected so the
whole of it tests without Qt or audio.

The mouth follows the audio rather than a timer: tts.level_of reduces each PCM
frame to a 0..1 loudness on a sqrt curve (speech sits well below peak, and a
linear map leaves the mouth barely open during normal talking) and that indexes
the talking frames, which the sprite script now draws as an openness ramp.
Offline pyttsx3 has no waveform, so stale levels hand control back to the timed
loop instead of freezing the mouth mid-syllable.

Also: the pet starts where you left it (ignoring positions on monitors that are
no longer connected, since restoring those faithfully is how it ends up
somewhere unreachable), and `python -m bolt_pet --doctor` is a preflight that
says what to do about each problem rather than only what's wrong.

tests/test_pipeline_smoke.py breaks the pure-logic rule on purpose. Every unit
test passed all week while notifications sat unspoken for minutes, the pet said
things twice and [laughing] got read aloud — each an interaction between two
individually-correct units. It drives whole turns against a real HTTP server on
a loopback port, faking only the mic and the speakers. It found a NameError in
the paint path that would have fired on every repaint while talking.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 19:01:06 -06:00
themajesticmagician c4e805defd Add relay_json module, update dialogue and file_ops, update local settings 2026-07-31 01:27:20 -06:00
themajesticmagician 96afc351ac Add text-to-dialogue, self-restart capability, and misc updates 2026-07-30 20:50:41 -06:00
themajesticmagician 5b49670983 v0.2.3
Multi-monitor jumps (`petctl jump`/`monitors`), pull-only screen OCR
(`petctl read`), generated sprite art with a distance-stepped walk cycle,
plus the filectl file ops and server file delivery merged back in.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 16:20:46 -06:00
themajesticmagician 311fe8b709 Merge remote-tracking branch 'origin/main' into screens
# Conflicts:
#	.claude/settings.local.json
#	CLAUDE.md
#	bolt_pet/controller.py
2026-07-28 16:19:45 -06:00
themajesticmagician b121bbba17 Multi-monitor jumps, screen OCR, and generated sprite art
petctl gains screen verbs: `jump` (1-based number, name, next/prev/
primary/other, or a direction resolved from real geometry), `monitors`,
and `read` for OCR of a monitor's contents.

- monitors.py: pure layout model + jump-target resolution. The monitor
  list is published by PetWindow from QGuiApplication.screens() over a
  queued signal, so the controller and window agree on what "monitor 2"
  means; xrandr and Qt order screens differently on the same machine.
- screen_text.py: pull-only OCR (mss capture + Tesseract/RapidOCR).
  Nothing captures unless the server asks, and the text rides back up
  the tool-result relay so Bolt can read a screen mid-turn. Both deps
  optional, soft-failing with a reason. SCREEN_TEXT=false removes it.
- Query verbs are answered in controller._handle_command rather than
  pet_actions.describe(), because their output is the point.
- scripts/generate_bolt_sprites.py draws every frame; walk/ is a
  side-view cycle stepped by distance travelled, not by the animation
  timer, so the planted paw tracks the window exactly. sprite.py loads
  it via EXTRA_ANIMATIONS keyed by name, with has() so callers can
  decline a placeholder blob.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 16:17:40 -06:00
themajesticmagician ccb3aeb7ae v0.2.2 2026-07-26 19:10:14 -06:00
86 changed files with 7250 additions and 197 deletions
+50 -1
View File
@@ -31,6 +31,16 @@
"Bash(git remote *)", "Bash(git remote *)",
"Bash(grep -v '^$')", "Bash(grep -v '^$')",
"Bash(.venv/bin/pip install *)", "Bash(.venv/bin/pip install *)",
"Bash(QT_QPA_PLATFORM=offscreen .venv/bin/python *)",
"Bash(python3 *)",
"Bash(.venv/bin/pytest tests/test_monitors.py -q)",
"Bash(QT_QPA_PLATFORM=offscreen .venv/bin/pytest tests/test_pet_window_features.py -q)",
"Bash(git fetch *)",
"Bash(git switch *)",
"Bash(git add *)",
"Bash(git merge *)",
"Bash(echo \"=== EXIT: $? ===\")",
"Bash(git commit *)",
"Bash(QT_QPA_PLATFORM=offscreen /root/Documents/bolt-pet/.venv/bin/pytest tests/ -q)", "Bash(QT_QPA_PLATFORM=offscreen /root/Documents/bolt-pet/.venv/bin/pytest tests/ -q)",
"Bash(.venv/bin/pytest tests/test_desk_api.py tests/test_desk_files.py tests/test_desk_voice.py tests/test_desk_status.py tests/test_desk_keys.py tests/test_desk_guild_action.py tests/test_desk_admin.py tests/test_desk_billing_auth.py -q)", "Bash(.venv/bin/pytest tests/test_desk_api.py tests/test_desk_files.py tests/test_desk_voice.py tests/test_desk_status.py tests/test_desk_keys.py tests/test_desk_guild_action.py tests/test_desk_admin.py tests/test_desk_billing_auth.py -q)",
"Bash(docker inspect *)", "Bash(docker inspect *)",
@@ -40,7 +50,46 @@
"Bash(python3 -c ' *)", "Bash(python3 -c ' *)",
"Bash(QT_QPA_PLATFORM=offscreen /root/Documents/bolt-pet/.venv/bin/pytest /home/themajesticmagician/Documents/Bolt-Pet/tests/ -q)", "Bash(QT_QPA_PLATFORM=offscreen /root/Documents/bolt-pet/.venv/bin/pytest /home/themajesticmagician/Documents/Bolt-Pet/tests/ -q)",
"Bash(docker exec bolt *)", "Bash(docker exec bolt *)",
"Bash(QT_QPA_PLATFORM=offscreen /root/Documents/bolt-pet/.venv/bin/pytest /home/themajesticmagician/Documents/Bolt-Pet/tests/test_file_ops.py -q)" "Bash(QT_QPA_PLATFORM=offscreen /root/Documents/bolt-pet/.venv/bin/pytest /home/themajesticmagician/Documents/Bolt-Pet/tests/test_file_ops.py -q)",
"Bash(grep -rn *)",
"Bash(git ls-tree *)",
"Bash(QT_QPA_PLATFORM=offscreen .venv/bin/pytest tests/test_controller_features.py -q)",
"Read(//home/maji/Documents/tmn-api/**)",
"Bash(timeout 300 .venv/bin/python -m pytest tests/test_desk_voice.py -q)",
"Bash(echo \"exit=$?\")",
"Bash(timeout 300 /home/maji/Documents/tmn-api/.venv/bin/python -m pytest /home/maji/Documents/tmn-api/tests/test_desk_voice.py -q -p no:cacheprovider --rootdir=/home/maji/Documents/tmn-api)",
"Bash(echo \"EXIT=$?\")",
"Bash(/home/maji/Documents/tmn-api/.venv/bin/python -c \"import ast,pathlib; ast.parse\\(pathlib.Path\\('/home/maji/Documents/tmn-api/ai/desk_api.py'\\).read_text\\(\\)\\); print\\('desk_api.py parses OK'\\)\")",
"Bash(ps -eo pid,etime,cmd)",
"Bash(systemctl --user list-units --type=service)",
"Read(//run/user/1000/gvfs/sftp:host=192.168.2.231,user=root/Main/Docker-Compose/TMN-API/tmn-api/ai/**)",
"Bash(findmnt -T /home/maji/Documents/tmn-api -o TARGET,SOURCE,FSTYPE)",
"Bash(echo \"rc=$?\")",
"Bash(timeout 600 /home/maji/Documents/Bolt-Pet/.venv/bin/python -m pytest tests/test_temporal_core.py tests/test_temporal_episodes.py -q -p no:cacheprovider)",
"Bash(timeout 600 /home/maji/Documents/Bolt-Pet/.venv/bin/python -m pytest tests/test_temporal_core.py tests/test_temporal_episodes.py tests/test_temporal_stream.py tests/test_temporal_facade.py -q -p no:cacheprovider)",
"Bash(awk 'NR>=1150 && NR<=1310 && \\(/return / || /def /\\)' ai/agents/default.py)",
"Bash(/home/maji/Documents/Bolt-Pet/.venv/bin/python -c ' *)",
"Bash(timeout 900 /home/maji/Documents/Bolt-Pet/.venv/bin/python -m pytest tests/test_temporal_core.py tests/test_temporal_episodes.py tests/test_temporal_stream.py tests/test_temporal_facade.py tests/test_proactive.py -q -p no:cacheprovider)",
"Bash(timeout 300 /home/maji/Documents/Bolt-Pet/.venv/bin/python -m pytest tests/test_proactive.py::test_send_trims_swallowed_tool_lines -q -p no:cacheprovider)",
"Bash(/home/maji/Documents/Bolt-Pet/.venv/bin/python *)",
"Bash(./tmnvenv/bin/pip install *)",
"Bash(./tmnvenv/bin/python -c \"import pytest,dotenv,yaml; print\\('scratch venv ready'\\)\")",
"Bash(/tmp/claude-1000/-home-maji-Documents-Bolt-Pet/58f4a7c3-6a92-47ed-9e43-bb2216d8b135/scratchpad/tmnvenv/bin/pip install *)",
"Bash(timeout 1800 /tmp/claude-1000/-home-maji-Documents-Bolt-Pet/58f4a7c3-6a92-47ed-9e43-bb2216d8b135/scratchpad/tmnvenv/bin/python -m pytest tests -q -p no:cacheprovider --ignore=tests/test_tool_markers.py --ignore=tests/test_tts_sanitization.py --ignore=tests/test_speaker_matching.py --ignore=tests/test_recent_speaker_fallback.py --ignore=tests/test_assistant_cli_call_proxy.py --ignore=tests/test_assistant_cli_permissions.py)",
"Bash(timeout 1800 /tmp/claude-1000/-home-maji-Documents-Bolt-Pet/58f4a7c3-6a92-47ed-9e43-bb2216d8b135/scratchpad/tmnvenv/bin/python -m pytest tests -q -p no:cacheprovider)",
"Bash(timeout 1800 /tmp/claude-1000/-home-maji-Documents-Bolt-Pet/58f4a7c3-6a92-47ed-9e43-bb2216d8b135/scratchpad/tmnvenv/bin/python -m pytest tests -q -p no:cacheprovider --ignore=tests/test_tool_markers.py --ignore=tests/test_tts_sanitization.py --ignore=tests/test_speaker_matching.py --ignore=tests/test_recent_speaker_fallback.py --ignore=tests/test_assistant_cli_call_proxy.py --ignore=tests/test_assistant_cli_permissions.py --ignore=tests/test_memory_store.py --ignore=tests/test_default_agent.py)",
"Bash(timeout 900 /tmp/claude-1000/-home-maji-Documents-Bolt-Pet/58f4a7c3-6a92-47ed-9e43-bb2216d8b135/scratchpad/tmnvenv/bin/python -m pytest tests/test_default_agent.py -q -p no:cacheprovider)",
"Bash(timeout 900 /tmp/claude-1000/-home-maji-Documents-Bolt-Pet/58f4a7c3-6a92-47ed-9e43-bb2216d8b135/scratchpad/tmnvenv/bin/python -m pytest tests/test_emotional_memory.py tests/test_inner_monologue.py -q -p no:cacheprovider)",
"Bash(timeout 900 /tmp/claude-1000/-home-maji-Documents-Bolt-Pet/58f4a7c3-6a92-47ed-9e43-bb2216d8b135/scratchpad/tmnvenv/bin/python -m pytest tests/test_emotional_memory.py tests/test_inner_monologue.py tests/test_temporal_core.py tests/test_temporal_episodes.py tests/test_temporal_stream.py tests/test_temporal_facade.py tests/test_proactive.py tests/test_desk_api.py tests/test_main_helpers.py -q -p no:cacheprovider)",
"Bash(timeout 1800 /tmp/claude-1000/-home-maji-Documents-Bolt-Pet/58f4a7c3-6a92-47ed-9e43-bb2216d8b135/scratchpad/tmnvenv/bin/python -m pytest tests -q -p no:cacheprovider --ignore=tests/test_tool_markers.py --ignore=tests/test_tts_sanitization.py --ignore=tests/test_speaker_matching.py --ignore=tests/test_recent_speaker_fallback.py --ignore=tests/test_assistant_cli_call_proxy.py --ignore=tests/test_assistant_cli_permissions.py --ignore=tests/test_memory_store.py --ignore=tests/test_default_agent.py --ignore=tests/test_billing_web.py)",
"WebFetch(domain:elevenlabs.io)",
"Bash(QT_QPA_PLATFORM=offscreen .venv/bin/pytest tests/test_dialogue.py -q)",
"Bash(timeout 900 /tmp/claude-1000/-home-maji-Documents-Bolt-Pet/58f4a7c3-6a92-47ed-9e43-bb2216d8b135/scratchpad/tmnvenv/bin/python -m pytest tests/test_system_index.py tests/test_initiative.py tests/test_self_experiments.py -q -p no:cacheprovider)",
"Bash($V *)",
"Bash(dig +short themajesticnetwork.com)",
"Bash(dig +short api.themajesticnetwork.com)",
"Bash(timeout 900 /tmp/claude-1000/-home-maji-Documents-Bolt-Pet/58f4a7c3-6a92-47ed-9e43-bb2216d8b135/scratchpad/tmnvenv/bin/python -m pytest tests/test_site.py -q -p no:cacheprovider)",
"Bash(curl -s -o /dev/null -w 'HTTP %{http_code} bytes=%{size_download}\\\\n' -m 15 -H 'X-Forwarded-For: 1.2.3.4' -H 'X-Real-IP: 1.2.3.4' -A 'Mozilla/5.0 \\(X11; Linux x86_64\\) Firefox/152.0' https://themajesticnetwork.com/?claude-probe-__TRACKED_VAR__)"
] ]
} }
} }
+67
View File
@@ -31,6 +31,36 @@ ELEVENLABS_VOICE_ID=
#ELEVENLABS_MODEL_ID=eleven_flash_v2 #ELEVENLABS_MODEL_ID=eleven_flash_v2
#TTS_SAMPLE_RATE=24000 #TTS_SAMPLE_RATE=24000
# Ask Bolt to use a different voice (or another language) and the server
# picks one from the ElevenLabs voice library and tags the reply with it.
# It only tags one reply, and it can't remember the id afterwards — so the
# pet keeps using that voice until a new one is picked or you choose "Use
# default voice" in the tray. VOICE_STICKY=false makes each pick last for
# exactly the one reply it came with instead.
#VOICE_STICKY=true
# ── 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", "[whispering] He is lying"), driven by
# the server through a relayed `dialoguectl` command. Name the cast here —
# "self" always means whatever voice the pet is currently using.
#DIALOGUE=true
#DIALOGUE_MODEL_ID=eleven_v3
#DIALOGUE_VOICES=narrator:9BWtsMINqrJLrRacOk9x,villain:IKne3meq5aSn9XLyUdCD
# ── Self-restart (optional) ─────────────────────────────────────────────────
# `petctl self_restart <why>` lets Bolt reload the pet after editing its own
# code, so he can check the change live. The code is import-checked first, the
# restart waits for the current turn to finish, and the reason is carried
# across so the new process reports back. The guard refuses more than
# SELF_RESTART_MAX restarts within SELF_RESTART_WINDOW_SECONDS.
#SELF_RESTART=true
#SELF_RESTART_MAX=5
#SELF_RESTART_WINDOW_SECONDS=900
# Used instead of ELEVENLABS_MODEL_ID whenever the server picked the voice
# or the reply has non-ASCII in it — the flash_v2 default is English-only.
#ELEVENLABS_MULTILINGUAL_MODEL_ID=eleven_flash_v2_5
# ── Audio devices (optional — leave blank for the system default) ────────── # ── Audio devices (optional — leave blank for the system default) ──────────
#MIC_DEVICE= #MIC_DEVICE=
#SPEAKER_DEVICE= #SPEAKER_DEVICE=
@@ -73,6 +103,9 @@ ELEVENLABS_VOICE_ID=
#PET_CLICK_THROUGH=false #PET_CLICK_THROUGH=false
#PET_EDGE_SNAP=true #PET_EDGE_SNAP=true
#PET_SNAP_MARGIN=48 #PET_SNAP_MARGIN=48
# Start where you last dragged it. A position on a monitor that's no longer
# connected is ignored, so unplugging a screen can't hide the pet off-desktop.
#PET_REMEMBER_POSITION=true
# ── Barge-in (optional) — interrupt the pet mid-sentence ──────────────────── # ── Barge-in (optional) — interrupt the pet mid-sentence ────────────────────
# BARGE_IN_MODE decides what counts as an interruption: # BARGE_IN_MODE decides what counts as an interruption:
@@ -108,11 +141,45 @@ ELEVENLABS_VOICE_ID=
# ── Streaming TTS (optional) — starts talking on the first chunk ──────────── # ── Streaming TTS (optional) — starts talking on the first chunk ────────────
#TTS_STREAMING=true #TTS_STREAMING=true
# ── Latency: streaming the reply and the transcript ─────────────────────────
# STREAMING_REPLIES speaks each sentence as the server generates it, instead of
# waiting out the whole model call before the first word. STT_STREAMING sends
# mic frames to Deepgram as you talk, so the transcript is ready the moment you
# stop. Both fall back to the old path automatically if anything goes wrong.
# VAD_SILENCE_END_SEC is the other half: it is dead air on every single turn,
# so 0.8-1.0 feels markedly snappier than the 1.2 default.
#STREAMING_REPLIES=true
#STT_STREAMING=true
# ── Screen context (optional) ─────────────────────────────────────────────── # ── Screen context (optional) ───────────────────────────────────────────────
# Sends the focused window's title along with what you said, so "what's this # Sends the focused window's title along with what you said, so "what's this
# error?" has a referent. Text only — no screenshots leave the machine. # error?" has a referent. Text only — no screenshots leave the machine.
#SCREEN_CONTEXT=true #SCREEN_CONTEXT=true
# ── Monitors (optional) ─────────────────────────────────────────────────────
# Tacks a one-line summary of your screen layout onto each utterance (how
# many, their sizes, which one the pet is standing on) so Bolt can decide to
# `petctl jump 2` without asking what you've got plugged in. Costs nothing —
# the list comes from the UI, nothing is probed per turn.
#MONITOR_CONTEXT=true
# ── Screen text / OCR (optional) ────────────────────────────────────────────
# Lets Bolt read what's actually on a monitor with `petctl read [n|here|all]`
# and use it in his reply. Pull-only — nothing is captured unless he asks,
# and every read is logged.
#
# Needs the extras from requirements.txt plus an OCR engine:
# pip install mss pytesseract && sudo apt install tesseract-ocr
# or, without sudo:
# pip install mss rapidocr-onnxruntime
# mss captures on X11/Windows/macOS but NOT Wayland.
#
# This sends the text of a whole screen to the server when used. That's not a
# new capability — the shell relay could already screenshot and OCR — but it's
# a far easier one to reach for. SCREEN_TEXT=false removes it entirely.
#SCREEN_TEXT=true
#SCREEN_TEXT_MAX_CHARS=4000
# ── Quiet hours / do-not-disturb (optional) ──────────────────────────────── # ── Quiet hours / do-not-disturb (optional) ────────────────────────────────
# Comma-separated HH:MM-HH:MM ranges; wrapping past midnight is fine. While # Comma-separated HH:MM-HH:MM ranges; wrapping past midnight is fine. While
# napping the pet dims, stops wandering and makes no proactive noise — the # napping the pet dims, stops wandering and makes no proactive noise — the
+272 -22
View File
@@ -11,13 +11,19 @@ dependency** on the server repo; it's a standalone HTTP client configured via
its own `.env`. its own `.env`.
Pipeline: `mic → openWakeWord ("thunderbolt", on-device) / push-to-talk / Pipeline: `mic → openWakeWord ("thunderbolt", on-device) / push-to-talk /
click → record utterance → Deepgram STT → + active-window context → POST click → record utterance → Deepgram STT → + active-window + screen-layout
/desk/converse → [server may relay a shell command to run on this machine, or context → POST /desk/converse → [server may relay a shell command to run on
a `petctl` pseudo-command that moves/emotes the pet instead] → reply → 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) →
ElevenLabs streaming TTS (or offline pyttsx3 fallback) → speakers`, with the ElevenLabs streaming TTS (or offline pyttsx3 fallback) → speakers`, with the
pet sprite/speech bubble reflecting state throughout, and playback pet sprite/speech bubble reflecting state throughout, and playback
interruptible by talking over it (barge-in). 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 Side channels that let the pet act between turns: the heartbeat (proactive
announcements), the desktop notification bridge, and autonomous wandering — announcements), the desktop notification bridge, and autonomous wandering —
all suppressed while it's napping (quiet hours / fullscreen DND). all suppressed while it's napping (quiet hours / fullscreen DND).
@@ -29,11 +35,18 @@ all suppressed while it's napping (quiet hours / fullscreen DND).
./run.sh # macOS/Linux ./run.sh # macOS/Linux
run.bat # Windows 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). # 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 avoids a QApplication segfault on headless/no-display hosts.
QT_QPA_PLATFORM=offscreen .venv/bin/pytest tests/ QT_QPA_PLATFORM=offscreen .venv/bin/pytest tests/
.venv/bin/pytest tests/test_state.py::test_happy_path_transitions # single test .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 # 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 python scripts/slice_spritesheet.py path/to/sheet.png assets/sprites/idle --cols 6 --rows 1
``` ```
@@ -71,7 +84,10 @@ logs a missing-config message and exits its thread instead of starting.
`/desk/tool_result` until the server sends a final `reply` (capped at `/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 `_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 server repo's other desk clients — commands only ever originate from
the user's own voice/click requests in their own session. `list_outbox_files` 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/<id>` / `download_outbox_file` hit the same `/desk/files` and `/desk/files/<id>`
endpoints the server's `deliver_files` tool queues onto — see `file_delivery.py`. endpoints the server's `deliver_files` tool queues onto — see `file_delivery.py`.
- **`file_delivery.py`** — the filesystem half of receiving files the server - **`file_delivery.py`** — the filesystem half of receiving files the server
@@ -96,7 +112,11 @@ logs a missing-config message and exits its thread instead of starting.
`play_stream()` start playback on the first chunk; `chunks_to_int16()` `play_stream()` start playback on the first chunk; `chunks_to_int16()`
carries odd bytes across HTTP chunk boundaries, without which everything carries odd bytes across HTTP chunk boundaries, without which everything
after the first split sample plays as static — falling back to whole-clip after the first split sample plays as static — falling back to whole-clip
PCM then offline `pyttsx3`), `barge_in.py` (two detectors behind one PCM then offline `pyttsx3`; every entry point takes an optional `voice_id`
overriding `ELEVENLABS_VOICE_ID`, and `model_for()` picks the multilingual
model whenever there's an override or non-ASCII text, since the default
`eleven_flash_v2` is English-only and would read either as garbled
phonetic English rather than failing), `barge_in.py` (two detectors behind one
`reset()`/`check()` shape, chosen by `BARGE_IN_MODE` via `make_detector`: `reset()`/`check()` shape, chosen by `BARGE_IN_MODE` via `make_detector`:
**wake** (default) scores every frame with the same openWakeWord model the **wake** (default) scores every frame with the same openWakeWord model the
idle listener uses, so only the wake phrase cuts playback; **energy** is the idle listener uses, so only the wake phrase cuts playback; **energy** is the
@@ -107,11 +127,61 @@ logs a missing-config message and exits its thread instead of starting.
accepts an injectable stream/model/protocol so tests don't need real audio accepts an injectable stream/model/protocol so tests don't need real audio
hardware or a display. hardware or a display.
- **`pet_actions.py`** — `petctl` pseudo-commands (`petctl move top-left`, - **`pet_actions.py`** — `petctl` pseudo-commands (`petctl move top-left`,
`petctl emote wave`, `say`/`wander`/`nap`). The desk API has no "move the `petctl emote wave`, `say`/`wander`/`nap`, the screen verbs
pet" payload type and this repo can't change the server, so these ride the `jump`/`monitors`/`read`, and `voice reset`). The desk API has no "move the pet" payload type
existing shell-command relay: `controller._handle_command` parses them and and this repo can't change the server, so these ride the existing
they never reach `subprocess`; anything else is a real shell command exactly shell-command relay: `controller._handle_command` parses them and they never
as before. Pure parsing; the UI half is `PetWindow.apply_action`. 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 <name>` 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` - **`file_ops.py`** — `filectl` pseudo-commands, checked in `_handle_command`
right after petctl and before falling through to a real shell command. right after petctl and before falling through to a real shell command.
Executing arbitrary commands already worked via the shell relay Executing arbitrary commands already worked via the shell relay
@@ -147,10 +217,44 @@ logs a missing-config message and exits its thread instead of starting.
no images. Every probe is best-effort and returns None/False rather than 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 raising; the parsing is split into pure functions that are tested without a
display server. 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, - **`quiet.py`** — quiet-hours spec parsing (`23:00-08:00`, wraps midnight,
comma-separated). Napping suppresses *proactive* noise and wandering only; comma-separated). Napping suppresses *proactive* noise and wandering only;
wake word / click / push-to-talk still work. wake word / click / push-to-talk still work.
- **`notifications.py`** — Linux/D-Bus notification bridge: tails - **`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 `dbus-monitor`, parses Notify calls (pure `iter_notifications()`), filters
and rate-limits them (`NotificationGate`), and the controller forwards and rate-limits them (`NotificationGate`), and the controller forwards
survivors through `converse()`. Off by default — each one is a round trip. survivors through `converse()`. Off by default — each one is a round trip.
@@ -186,6 +290,81 @@ logs a missing-config message and exits its thread instead of starting.
- **`hotkey.py`** — global push-to-talk via `pynput`; soft-fails with a logged - **`hotkey.py`** — global push-to-talk via `pynput`; soft-fails with a logged
reason (Wayland, missing package, macOS permissions) since the wake word is reason (Wayland, missing package, macOS permissions) since the wake word is
the primary trigger. the primary trigger.
- **`audio/stt_stream.py`** — streaming speech-to-text. The one-shot path waits for
the utterance to end, uploads the whole WAV, then waits again; that second wait is
dead time that grows with how long you spoke. Deepgram's live websocket removes it:
`record_utterance(on_frame=...)` hands each captured frame to a
`StreamingTranscriber`, so by the time the VAD decides you stopped the transcript is
essentially already there. Three deliberate limits: `open()` returning **None is an
ordinary outcome** (no websocket-client, no network, no key) because the full audio is
still buffered and `controller._transcribe` just falls back; 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. - **`speech_text.py`** — sanitizes server replies before they're heard/shown.
`for_speech()` (called inside `tts.speak()`, so every path to the speakers is `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 covered) strips markdown, emoji, URLs and stray symbols the voice would read
@@ -195,7 +374,7 @@ logs a missing-config message and exits its thread instead of starting.
whether a reply leaves the pet waiting on an answer: it tests the *spoken* 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 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 trailing one counts, since a question asked in passing isn't awaiting a
reply. `controller._should_follow_up` uses it to keep listening without the 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 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 with a question can't loop forever off mic noise. Pure string logic, no
Qt/audio imports. Qt/audio imports.
@@ -208,6 +387,15 @@ logs a missing-config message and exits its thread instead of starting.
target every `PET_WANDER_INTERVAL_SECONDS` (randomized), suppressed target every `PET_WANDER_INTERVAL_SECONDS` (randomized), suppressed
whenever the pet is non-IDLE, napping, dragged, or has a bubble up. A whenever the pet is non-IDLE, napping, dragged, or has a bubble up. A
commanded `petctl move` overrides all of that except the drag. 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 - **emotes** — `emote_transform()` is pure maths (dx, dy, rotation, scale
from a 0..1 progress) kept out of `paintEvent` so the curves are unit 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 tested; every emote must return to the identity transform at progress 1.0
@@ -220,12 +408,46 @@ logs a missing-config message and exits its thread instead of starting.
- **edge snapping** (`PET_EDGE_SNAP`) after a drag or a stroll, and **nap - **edge snapping** (`PET_EDGE_SNAP`) after a drag or a stroll, and **nap
dimming** (`set_napping`). dimming** (`set_napping`).
`sprite.py` loads `assets/sprites/<state>/*.png` (filename-sorted, looping — `sprite.py` loads `assets/sprites/<state>/*.png` (filename-sorted, looping —
currently Kenney's CC0 robot pack, see `assets/sprites/README.md`) and falls 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 back to a procedurally-drawn placeholder blob per state if a folder has no
frames; `tray.py` is the system tray menu (talk now / mute / nap / wander / frames. It also loads `EXTRA_ANIMATIONS` — currently just `walk/` — keyed by
click-through / history / wake-word tuning / quit) — the pet window has no name rather than by `PetState`, with `has()` reporting whether a key is
title bar or taskbar entry; `history_window.py` and `wake_tuner.py` are the backed by real art so callers can decline a placeholder instead of trotting
two dialogs it opens. 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.
### Voices (the server's `speak_as`)
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: <voice_id>` 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=...)`.
Two things are decided *here*, though, because the server can't:
- **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.
### Wake-word detection ### Wake-word detection
@@ -282,6 +504,20 @@ HTTP chunk reassembly by `chunks_to_int16`, emote motion by
`screen_context.context_for` / `is_fullscreen_active`, otherwise they shell `screen_context.context_for` / `is_fullscreen_active`, otherwise they shell
out to xprop on a headless box. 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.
## Security notes ## Security notes
The server can relay a shell command back to this machine to execute as the The server can relay a shell command back to this machine to execute as the
@@ -315,9 +551,23 @@ 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 reject the 16 kHz capture rate — `paInvalidSampleRate`), and every relayed
command would run unconstrained. command would run unconstrained.
Two newer features widen what leaves this machine, both switchable in `.env`: Several features widen what leaves this machine, all switchable in `.env`:
`SCREEN_CONTEXT` appends the focused window's *title* to each utterance `SCREEN_CONTEXT` appends the focused window's *title* to each utterance
(titles often contain file paths, document names, or subject lines), and (titles often contain file paths, document names, or subject lines),
`NOTIFICATION_BRIDGE` (off by default) forwards matching desktop `MONITOR_CONTEXT` appends the screen layout (sizes and names only — no
notifications to the server. Neither sends screenshots or notification contents), and `NOTIFICATION_BRIDGE` (off by default) forwards matching
contents you haven't matched with `NOTIFICATION_FILTER`. 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.
+77 -9
View File
@@ -69,8 +69,8 @@ limitations).
speaks, and barging in starts your next turn immediately (`BARGE_IN`). speaks, and barging in starts your next turn immediately (`BARGE_IN`).
- Right-click the tray icon for **Talk now**, **Mute mic**, **Nap**, - Right-click the tray icon for **Talk now**, **Mute mic**, **Nap**,
**Wander around**, **Click through the pet**, **History…**, **Wake word **Wander around**, **Click through the pet**, **History…**, **Wake word
tuning…** and **Quit** — the pet window itself has no title bar or taskbar tuning…**, **Use default voice** and **Quit** — the pet window itself has
entry. no title bar or taskbar entry.
- **Click the speech bubble** to copy what it just said; the tray's - **Click the speech bubble** to copy what it just said; the tray's
**History…** window keeps the last `HISTORY_LIMIT` turns. **History…** window keeps the last `HISTORY_LIMIT` turns.
@@ -80,8 +80,8 @@ limitations).
listening/thinking/talking or while a bubble is up. listening/thinking/talking or while a bubble is up.
- **Moves and emotes on command.** Bolt can relay `petctl move top-left`, - **Moves and emotes on command.** Bolt can relay `petctl move top-left`,
`petctl emote wave|hop|spin|nod|shake`, `petctl say ...`, `petctl wander `petctl emote wave|hop|spin|nod|shake`, `petctl say ...`, `petctl wander
on|off`, `petctl nap on|off`. These are intercepted here and never reach a on|off`, `petctl nap on|off`, `petctl voice reset`, and `dialoguectl` for a
shell. 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 - **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 app is focused (`DND_ON_FULLSCREEN`) — it dims, stops wandering, and makes
no proactive noise. It still answers when you speak to it. no proactive noise. It still answers when you speak to it.
@@ -90,6 +90,41 @@ limitations).
notifications get forwarded to the server, so it can tell you the deploy notifications get forwarded to the server, so it can tell you the deploy
went green. Off by default: each one costs a round trip. 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, and non-English replies (or
any picked voice) go through `ELEVENLABS_MULTILINGUAL_MODEL_ID` rather than
the English-only `eleven_flash_v2` default.
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 ## Wake-word detection
`bolt_pet/audio/wake_word.py` feeds every mic frame into `thunderbolt.onnx` `bolt_pet/audio/wake_word.py` feeds every mic frame into `thunderbolt.onnx`
@@ -105,6 +140,33 @@ near misses — frames that scored just under the threshold — and the slider
takes effect immediately, mid-listen. Set the threshold just below the peak 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`. you can hit reliably, then write it into `.env` as `WAKE_WORD_THRESHOLD`.
## Something not working?
```bash
python -m bolt_pet --doctor
```
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.
## Little things
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.
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.
## Project layout ## Project layout
``` ```
@@ -113,6 +175,7 @@ bolt_pet/
state.py PetState enum + a small transition-checked state machine state.py PetState enum + a small transition-checked state machine
server_client.py /desk/converse, /desk/tool_result, /desk/report_status server_client.py /desk/converse, /desk/tool_result, /desk/report_status
controller.py the pipeline: wake word -> STT -> server -> TTS, on a QThread 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" speech_text.py strips markdown/emoji/URLs so the voice never says "asterisk"
pet_actions.py petctl move/emote/say/wander/nap parsing pet_actions.py petctl move/emote/say/wander/nap parsing
screen_context.py active-window title + fullscreen detection screen_context.py active-window title + fullscreen detection
@@ -120,11 +183,15 @@ bolt_pet/
notifications.py desktop notification bridge (Linux/D-Bus) notifications.py desktop notification bridge (Linux/D-Bus)
history.py rolling conversation transcript history.py rolling conversation transcript
hotkey.py global push-to-talk (pynput, optional) 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/ audio/
mic.py input stream + energy-based VAD utterance capture mic.py input stream + energy-based VAD utterance capture
wake_word.py openWakeWord thunderbolt.onnx detection (see above) wake_word.py openWakeWord thunderbolt.onnx detection (see above)
stt.py Deepgram stt.py Deepgram (one-shot)
tts.py ElevenLabs streaming PCM, offline pyttsx3 fallback stt_stream.py Deepgram live websocket — transcribes while you speak
tts.py ElevenLabs streaming PCM, offline pyttsx3 fallback,
plus the loudness envelope that drives the mouth
barge_in.py "you started talking" detector, to cut playback short barge_in.py "you started talking" detector, to cut playback short
ui/ ui/
app.py wires QApplication + window + tray + controller thread together app.py wires QApplication + window + tray + controller thread together
@@ -137,8 +204,10 @@ bolt_pet/
scripts/ scripts/
slice_spritesheet.py cuts a grid sprite sheet into the per-frame convention slice_spritesheet.py cuts a grid sprite sheet into the per-frame convention
tests/ pure-logic unit tests (state machine, wake-phrase tests/ pure-logic unit tests (state machine, wake-phrase
matching, HTTP client against mocks) — nothing here matching, HTTP client against mocks) plus one
needs real audio hardware or a display end-to-end smoke test that drives whole turns against
a real local HTTP server — nothing here needs real
audio hardware or a display
``` ```
## Security notes ## Security notes
@@ -162,7 +231,6 @@ notifications. No screenshots or images are ever sent.
## Known limitations / not-yet-done ## Known limitations / not-yet-done
- Pet screen position isn't persisted across restarts.
- Wandering is a straight walk to a random point — no Shimeji-style physics, - Wandering is a straight walk to a random point — no Shimeji-style physics,
wall-climbing or falling. wall-climbing or falling.
- Push-to-talk and the notification bridge are platform-limited: the hotkey - Push-to-talk and the notification bridge are platform-limited: the hotkey
+1 -1
View File
@@ -4,4 +4,4 @@ __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. Gitea releases page (see updater.py), so bump it in the same commit you tag.
""" """
__version__ = "0.1.0" __version__ = "0.2.3"
+9 -2
View File
@@ -1,8 +1,15 @@
"""Entry point: python -m bolt_pet""" """Entry point: python -m bolt_pet [--doctor [--deep]]"""
import sys 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 from .ui.app import run
if __name__ == "__main__":
sys.exit(run()) sys.exit(run())
+64 -17
View File
@@ -1,37 +1,84 @@
# Sprite assets # Sprite assets
Art: [Kenney's Robot Pack](https://kenney.nl/assets/robot-pack) (CC0 — no Art: Bolt himself — a cream shepherd pup with a slate cap, a lightning blaze
attribution required, credited here anyway), the green side-view robot. on his forehead and a bolt tag on his collar. The frames are **generated, not
Source pack lives at `~/Documents/kenney_robot-pack`; only the frames listed hand-drawn**: `scripts/generate_bolt_sprites.py` draws every one of them with
below were copied in. 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: Convention the loader (`bolt_pet/ui/sprite.py`) expects:
``` ```
assets/sprites/ assets/sprites/
idle/ frame_00.png robot_greenBody (standing) idle/ frame_00..07.png breathing, tail wag, blink on frame 06
listening/ frame_00.png, frame_01.png robot_greenDrive1/2 (tracks rolling — "leaning in") listening/ frame_00..03.png ears perked, head tilted in, collar tag lit, sound arcs
thinking/ frame_00.png, frame_01.png robot_greenDamage1/2 (flicker — "processing") thinking/ frame_00..05.png eyes up, head cocked, cycling dots
talking/ frame_00.png, frame_01.png robot_greenBody, robot_greenJump (bounce) talking/ frame_00..03.png mouth open/close with tongue, ears bouncing
error/ frame_00.png robot_greenHurt 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`). - 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 - Any `*.png` filenames work — they're played back in alphabetical-sort
order, looping, at `IDLE_ANIMATION_FPS` (see `.env`). order, looping, at `IDLE_ANIMATION_FPS` (see `.env`). At the default 6fps
- Frames are scaled to fit within `PET_SIZE` (default 160px), keeping aspect the 8-frame idle loop runs about 1.3s.
ratio, and centered in the (square) pet window — the source art here isn't - Frames are square (320px, 2x the default `PET_SIZE` of 160) so they
square, so don't assume it fills the frame edge-to-edge. 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 - A state directory with no frames in it falls back to a small
procedurally-drawn placeholder blob (see `_placeholder_frames` in procedurally-drawn placeholder blob (see `_placeholder_frames` in
`sprite.py`). `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 ## Swapping in different art
Replace any state's PNGs (same alphabetical-order-loops convention) to Replace any state's PNGs (same alphabetical-order-loops convention) to
change its look — no code changes needed. If your source is a single grid change its look — no code changes needed, and nothing forces you to keep
spritesheet (rows/cols of frames in one PNG) rather than one-file-per-frame, using the generator. If your source is a single grid spritesheet (rows/cols
use `scripts/slice_spritesheet.py` to cut it into this folder-of-frames 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: convention:
```bash ```bash
Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

+22
View File
@@ -37,6 +37,20 @@ def rms(frame: np.ndarray) -> float:
return float(np.sqrt(np.mean(frame.astype(np.float64) ** 2))) 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( def record_utterance(
stream: AudioStream, stream: AudioStream,
should_continue=lambda: True, should_continue=lambda: True,
@@ -47,6 +61,7 @@ def record_utterance(
grace_s: float = None, grace_s: float = None,
frame_len: int = config.FRAME_LEN, frame_len: int = config.FRAME_LEN,
sample_rate: int = config.SAMPLE_RATE, sample_rate: int = config.SAMPLE_RATE,
on_frame=None,
) -> Optional[np.ndarray]: ) -> Optional[np.ndarray]:
"""Capture one utterance from *stream*: wait for speech to start, stop """Capture one utterance from *stream*: wait for speech to start, stop
after trailing silence. Returns None if nothing usable was heard. after trailing silence. Returns None if nothing usable was heard.
@@ -55,6 +70,11 @@ def record_utterance(
(e.g. the pet window was closed) without needing threading primitives (e.g. the pet window was closed) without needing threading primitives
baked into this function. 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. *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 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 asked something and need a moment to think rather than having just said
@@ -83,10 +103,12 @@ def record_utterance(
if frame_rms >= rms_threshold: if frame_rms >= rms_threshold:
started = True started = True
frames.append(frame) frames.append(frame)
_emit(on_frame, frame)
elif waited > grace_frames: elif waited > grace_frames:
return None # woke it up but said nothing return None # woke it up but said nothing
continue continue
frames.append(frame) frames.append(frame)
_emit(on_frame, frame)
if frame_rms < rms_threshold: if frame_rms < rms_threshold:
silence_frames += 1 silence_frames += 1
if silence_frames >= silence_limit: if silence_frames >= silence_limit:
+181
View File
@@ -0,0 +1,181 @@
"""Streaming speech-to-text — transcribing *while* you talk, not after.
The one-shot path (`stt.transcribe`) waits for the utterance to finish, then
uploads the whole WAV 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.
Deepgram's live endpoint removes it: frames go up as they are captured, so by
the time the VAD decides you have stopped, the transcript is essentially
already there. Same model, same account, same accuracy — the difference is
purely when the work happens.
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, never a dependency, so
`open()` returning None is an ordinary outcome rather than an error.
- **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")
_ENDPOINT = (
"wss://api.deepgram.com/v1/listen"
"?encoding=linear16&channels=1&sample_rate={rate}&model={model}"
"&language=en&smart_format=true&interim_results=false"
)
def available() -> bool:
"""Whether streaming STT can even be attempted in this install."""
if not config.STT_STREAMING or not config.DEEPGRAM_API_KEY:
return False
try:
import websocket # noqa: F401 (websocket-client)
return True
except Exception:
return False
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:
if connect is not None:
socket = connect()
else:
import websocket
socket = websocket.create_connection(
_ENDPOINT.format(rate=rate, model=config.DEEPGRAM_MODEL),
header={"Authorization": f"Token {config.DEEPGRAM_API_KEY}"},
timeout=10,
)
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"))
+159 -18
View File
@@ -5,11 +5,17 @@ desk_client/bolt_desk.py which shells out because it only targets Linux.
Falls back to pyttsx3 (offline, cross-platform: SAPI5 on Windows, NSSpeech Falls back to pyttsx3 (offline, cross-platform: SAPI5 on Windows, NSSpeech
on macOS, espeak on Linux) if ElevenLabs isn't configured or the request on macOS, espeak on Linux) if ElevenLabs isn't configured or the request
fails, so the pet can still talk with zero cloud config. fails, so the pet can still talk with zero cloud config.
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.
""" """
from __future__ import annotations from __future__ import annotations
from typing import Iterable, Iterator import time
from typing import Iterable, Iterator, Optional
import numpy as np import numpy as np
import requests import requests
@@ -21,18 +27,40 @@ class TtsError(Exception):
pass pass
def synthesize_pcm(text: str) -> tuple[np.ndarray, int]: 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 model_for(text: str, voice_id: Optional[str] = None) -> str:
"""Which ElevenLabs model to synthesize with.
The default (`eleven_flash_v2`) is English-only, and both things that
reach this branch mean the reply probably isn't English: a voice the
server picked mid-conversation is nearly always about a language or an
accent, and non-ASCII text can't be English at all. Rendering either one
through the English model gets you a mangled phonetic reading rather
than a failure, which is worse — so those go through the multilingual
model instead."""
if (voice_id or "").strip() or not text.isascii():
return config.ELEVENLABS_MULTILINGUAL_MODEL_ID
return config.ELEVENLABS_MODEL_ID
def synthesize_pcm(text: str, voice_id: Optional[str] = None) -> tuple[np.ndarray, int]:
"""Returns (pcm_int16_mono, sample_rate). Raises TtsError on failure — """Returns (pcm_int16_mono, sample_rate). Raises TtsError on failure —
callers should fall back to speak_offline() rather than treating this callers should fall back to speak_offline() rather than treating this
as fatal.""" as fatal."""
if not (config.ELEVENLABS_API_KEY and config.ELEVENLABS_VOICE_ID): voice = voice_for(voice_id)
if not (config.ELEVENLABS_API_KEY and voice):
raise TtsError("ELEVENLABS_API_KEY / ELEVENLABS_VOICE_ID not set") raise TtsError("ELEVENLABS_API_KEY / ELEVENLABS_VOICE_ID not set")
try: try:
response = requests.post( response = requests.post(
f"https://api.elevenlabs.io/v1/text-to-speech/{config.ELEVENLABS_VOICE_ID}", f"https://api.elevenlabs.io/v1/text-to-speech/{voice}",
headers={"xi-api-key": config.ELEVENLABS_API_KEY}, headers={"xi-api-key": config.ELEVENLABS_API_KEY},
params={"output_format": f"pcm_{config.TTS_SAMPLE_RATE}"}, params={"output_format": f"pcm_{config.TTS_SAMPLE_RATE}"},
json={"text": text, "model_id": config.ELEVENLABS_MODEL_ID}, json={"text": text, "model_id": model_for(text, voice_id)},
timeout=60, timeout=60,
) )
response.raise_for_status() response.raise_for_status()
@@ -44,20 +72,23 @@ def synthesize_pcm(text: str) -> tuple[np.ndarray, int]:
return pcm, config.TTS_SAMPLE_RATE return pcm, config.TTS_SAMPLE_RATE
def stream_pcm(text: str, chunk_bytes: int = 4096) -> Iterator[np.ndarray]: 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 """Same audio as synthesize_pcm(), but yielded as it arrives from
ElevenLabs' /stream endpoint so playback can start on the first chunk ElevenLabs' /stream endpoint so playback can start on the first chunk
(~300ms) instead of after the whole clip is synthesized. Raises TtsError (~300ms) instead of after the whole clip is synthesized. Raises TtsError
before yielding anything if the request itself fails, so callers can fall before yielding anything if the request itself fails, so callers can fall
back cleanly; a mid-stream failure just ends the generator.""" back cleanly; a mid-stream failure just ends the generator."""
if not (config.ELEVENLABS_API_KEY and config.ELEVENLABS_VOICE_ID): voice = voice_for(voice_id)
if not (config.ELEVENLABS_API_KEY and voice):
raise TtsError("ELEVENLABS_API_KEY / ELEVENLABS_VOICE_ID not set") raise TtsError("ELEVENLABS_API_KEY / ELEVENLABS_VOICE_ID not set")
try: try:
response = requests.post( response = requests.post(
f"https://api.elevenlabs.io/v1/text-to-speech/{config.ELEVENLABS_VOICE_ID}/stream", f"https://api.elevenlabs.io/v1/text-to-speech/{voice}/stream",
headers={"xi-api-key": config.ELEVENLABS_API_KEY}, headers={"xi-api-key": config.ELEVENLABS_API_KEY},
params={"output_format": f"pcm_{config.TTS_SAMPLE_RATE}"}, params={"output_format": f"pcm_{config.TTS_SAMPLE_RATE}"},
json={"text": text, "model_id": config.ELEVENLABS_MODEL_ID}, json={"text": text, "model_id": model_for(text, voice_id)},
timeout=60, timeout=60,
stream=True, stream=True,
) )
@@ -83,32 +114,130 @@ def chunks_to_int16(byte_chunks: Iterable[bytes]) -> Iterator[np.ndarray]:
yield np.frombuffer(data[:usable], dtype=np.int16) yield np.frombuffer(data[:usable], dtype=np.int16)
def play_pcm(pcm: np.ndarray, sample_rate: int, blocking: bool = True, should_stop=None) -> bool: 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* """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 (barge-in) cut it short. *should_stop* is polled while audio plays — each
poll consumes one mic frame, which is what paces this loop.""" poll consumes one mic frame, which is what paces this loop."""
import sounddevice as sd 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) sd.play(pcm, samplerate=sample_rate, device=config.SPEAKER_DEVICE)
if not blocking: if not blocking:
return True return True
if should_stop is None: if should_stop is None and on_level is None:
sd.wait() sd.wait()
return True return True
while 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: try:
if not sd.get_stream().active: if not sd.get_stream().active:
break break
except Exception: except Exception:
break # stream already torn down — playback is over break # stream already torn down — playback is over
if should_stop(): if should_stop is not None and should_stop():
sd.stop() sd.stop()
return False return False
return True return True
def play_stream(chunks: Iterable[np.ndarray], sample_rate: int, should_stop=None) -> bool: def play_stream(chunks: Iterable[np.ndarray], sample_rate: int, should_stop=None,
"""Play int16 chunks as they arrive. Returns False if interrupted.""" 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 import sounddevice as sd
with sd.OutputStream( with sd.OutputStream(
@@ -120,6 +249,11 @@ def play_stream(chunks: Iterable[np.ndarray], sample_rate: int, should_stop=None
# now, not at the end of the buffered chunk. # now, not at the end of the buffered chunk.
out.abort() out.abort()
return False 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) out.write(chunk)
return True return True
@@ -134,11 +268,13 @@ def speak_offline(text: str) -> None:
engine.runAndWait() engine.runAndWait()
def speak(text: str, on_error=None, should_stop=None) -> bool: 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 """Speak *text*, preferring streaming ElevenLabs, then whole-clip
ElevenLabs, then offline TTS. *on_error*, if given, is called with the ElevenLabs, then offline TTS. *on_error*, if given, is called with the
exception when ElevenLabs fails (useful for logging) — a fallback still exception when ElevenLabs fails (useful for logging) — a fallback still
runs either way. Returns False if barge-in interrupted playback. 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 The text is sanitized first (speech_text.for_speech): server replies are
written for a chat window, and a voice reads markdown/emoji literally written for a chat window, and a voice reads markdown/emoji literally
@@ -149,13 +285,18 @@ def speak(text: str, on_error=None, should_stop=None) -> bool:
return True return True
if config.TTS_STREAMING: if config.TTS_STREAMING:
try: try:
return play_stream(stream_pcm(text), config.TTS_SAMPLE_RATE, should_stop=should_stop) 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: except TtsError as exc:
if on_error is not None: if on_error is not None:
on_error(exc) on_error(exc)
try: try:
pcm, sample_rate = synthesize_pcm(text) pcm, sample_rate = synthesize_pcm(text, voice_id=voice_id)
return play_pcm(pcm, sample_rate, should_stop=should_stop) return play_pcm(pcm, sample_rate, should_stop=should_stop, on_level=on_level)
except TtsError as exc: except TtsError as exc:
if on_error is not None: if on_error is not None:
on_error(exc) on_error(exc)
+78 -1
View File
@@ -58,6 +58,11 @@ WAKE_CHECK_INTERVAL_SECONDS = float(os.environ.get("WAKE_CHECK_INTERVAL_SECONDS"
DEEPGRAM_API_KEY = os.environ.get("DEEPGRAM_API_KEY", "") DEEPGRAM_API_KEY = os.environ.get("DEEPGRAM_API_KEY", "")
DEEPGRAM_MODEL = os.environ.get("DEEPGRAM_MODEL", "nova-3") DEEPGRAM_MODEL = os.environ.get("DEEPGRAM_MODEL", "nova-3")
# Transcribe *while* you talk instead of uploading the finished clip: frames go
# up as they are captured, so the transcript is ready the moment the VAD says
# you stopped. Needs `websocket-client`; falls back to the one-shot upload
# whenever it can't connect, so turning it on can only help.
STT_STREAMING = os.environ.get("STT_STREAMING", "true").lower() in ("1", "true", "yes", "on")
# ── TTS (ElevenLabs, requested as raw PCM so playback needs no external # ── TTS (ElevenLabs, requested as raw PCM so playback needs no external
# player binary — cross-platform via sounddevice instead of shelling out to # player binary — cross-platform via sounddevice instead of shelling out to
@@ -66,9 +71,38 @@ DEEPGRAM_MODEL = os.environ.get("DEEPGRAM_MODEL", "nova-3")
ELEVENLABS_API_KEY = os.environ.get("ELEVENLABS_API_KEY", "") ELEVENLABS_API_KEY = os.environ.get("ELEVENLABS_API_KEY", "")
ELEVENLABS_VOICE_ID = os.environ.get("ELEVENLABS_VOICE_ID", "") ELEVENLABS_VOICE_ID = os.environ.get("ELEVENLABS_VOICE_ID", "")
ELEVENLABS_MODEL_ID = os.environ.get("ELEVENLABS_MODEL_ID", "eleven_flash_v2") ELEVENLABS_MODEL_ID = os.environ.get("ELEVENLABS_MODEL_ID", "eleven_flash_v2")
# eleven_flash_v2 is English-only, and the two cases that swap the voice
# (server-picked `speak_as`, or a reply with non-ASCII in it) are usually
# exactly the cases where the reply isn't English — see tts.model_for().
ELEVENLABS_MULTILINGUAL_MODEL_ID = os.environ.get(
"ELEVENLABS_MULTILINGUAL_MODEL_ID", "eleven_flash_v2_5"
)
# ElevenLabs PCM output formats are named pcm_<sample_rate>. # ElevenLabs PCM output formats are named pcm_<sample_rate>.
TTS_SAMPLE_RATE = int(os.environ.get("TTS_SAMPLE_RATE", "24000")) TTS_SAMPLE_RATE = int(os.environ.get("TTS_SAMPLE_RATE", "24000"))
# 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) ─────────────────────────── # ── mic / VAD (same tuning knobs as bolt_desk.py) ───────────────────────────
MIC_DEVICE = os.environ.get("MIC_DEVICE", "") or None # sounddevice name/index MIC_DEVICE = os.environ.get("MIC_DEVICE", "") or None # sounddevice name/index
@@ -91,7 +125,7 @@ GRACE_SECONDS = float(os.environ.get("VAD_GRACE_SECONDS", "4"))
# keeps feeding it noise. 0 means no cap. # 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_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", "3")) 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")) FOLLOW_UP_GRACE_SECONDS = float(os.environ.get("FOLLOW_UP_GRACE_SECONDS", "7"))
COMMAND_TIMEOUT_SECONDS = int(os.environ.get("COMMAND_TIMEOUT_SECONDS", "30")) COMMAND_TIMEOUT_SECONDS = int(os.environ.get("COMMAND_TIMEOUT_SECONDS", "30"))
@@ -110,6 +144,14 @@ SUDO_ASKPASS_HELPER = os.environ.get("SUDO_ASKPASS_HELPER", "") # blank = auto-
SUDO_COMMAND_TIMEOUT_SECONDS = int(os.environ.get("SUDO_COMMAND_TIMEOUT_SECONDS", "180")) SUDO_COMMAND_TIMEOUT_SECONDS = int(os.environ.get("SUDO_COMMAND_TIMEOUT_SECONDS", "180"))
HEARTBEAT_INTERVAL_SECONDS = float(os.environ.get("HEARTBEAT_INTERVAL_SECONDS", "60")) 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) ────────────────── # ── barge-in (interrupt playback while the pet is talking) ──────────────────
# The mic stays live while the pet talks. BARGE_IN_MODE decides what counts # The mic stays live while the pet talks. BARGE_IN_MODE decides what counts
# as an interruption: # as an interruption:
@@ -131,6 +173,13 @@ BARGE_IN_FRAMES = int(os.environ.get("BARGE_IN_FRAMES", "4")) # consecutive lou
# waking the pet from idle (useful if Bolt's own voice trips the model). # 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 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 ─────────────────────────────────────────────────────────── # ── streaming TTS ───────────────────────────────────────────────────────────
# ElevenLabs' /stream endpoint + chunked playback: the pet starts talking # ElevenLabs' /stream endpoint + chunked playback: the pet starts talking
# after the first PCM chunk instead of after the whole clip is synthesized. # after the first PCM chunk instead of after the whole clip is synthesized.
@@ -144,6 +193,30 @@ TTS_STREAMING = os.environ.get("TTS_STREAMING", "true").lower() in ("1", "true",
SCREEN_CONTEXT = os.environ.get("SCREEN_CONTEXT", "true").lower() in ("1", "true", "yes", "on") 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 ──────────────────────────────────────────── # ── quiet hours / do-not-disturb ────────────────────────────────────────────
# Comma-separated HH:MM-HH:MM ranges (wrapping midnight is fine). While # Comma-separated HH:MM-HH:MM ranges (wrapping midnight is fine). While
# napping the pet dims, stops wandering, and makes no proactive noise — # napping the pet dims, stops wandering, and makes no proactive noise —
@@ -246,6 +319,10 @@ PET_WANDER_MARGIN = int(os.environ.get("PET_WANDER_MARGIN", "20")) # keep off s
PET_SHAPED_INPUT = os.environ.get("PET_SHAPED_INPUT", "true").lower() in ("1", "true", "yes", "on") 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") 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. # 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_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")) PET_SNAP_MARGIN = int(os.environ.get("PET_SNAP_MARGIN", "48"))
+428 -54
View File
@@ -20,12 +20,20 @@ from typing import Optional
from PySide6.QtCore import QObject, Signal from PySide6.QtCore import QObject, Signal
from . import ( from . import (
config, file_delivery, file_ops, history as history_mod, notifications, config, dialogue as dialogue_mod, speech, file_delivery, file_ops,
pet_actions, quiet, screen_context, server_client, speech_text, updater, history as history_mod, monitors as monitors_mod, notifications,
pet_actions, quiet, screen_context, screen_text, self_restart,
server_client, speech_text, updater,
) )
from .audio import barge_in, mic, stt, tts, wake_word from . import __version__
from .audio import barge_in, mic, stt, stt_stream, tts, wake_word
from .state import PetState, PetStateMachine 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 # 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. # probe shells out to xprop, so this deliberately isn't every heartbeat tick.
_NAP_CHECK_INTERVAL_SECONDS = 10.0 _NAP_CHECK_INTERVAL_SECONDS = 10.0
@@ -37,6 +45,8 @@ class PetController(QObject):
log = Signal(str) log = Signal(str)
action = Signal(dict) # parsed petctl action for the UI to perform action = Signal(dict) # parsed petctl action for the UI to perform
napping = Signal(bool) # quiet hours / fullscreen do-not-disturb 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 restart_requested = Signal(str) # version we just updated to
finished = Signal() finished = Signal()
@@ -53,12 +63,37 @@ class PetController(QObject):
# window. Append-only from this thread; the UI only ever snapshots it. # window. Append-only from this thread; the UI only ever snapshots it.
self.history = history_mod.ConversationHistory(limit=config.HISTORY_LIMIT) 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 # Wake-word sensitivity is live-tunable (tray tuner), so it's read
# through a callable on every frame rather than captured per listen. # through a callable on every frame rather than captured per listen.
self._wake_threshold = config.WAKE_WORD_THRESHOLD self._wake_threshold = config.WAKE_WORD_THRESHOLD
self._near_misses = wake_word.NearMissLog() 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 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._napping = False
self._nap_forced: Optional[bool] = None # petctl nap on/off overrides the schedule self._nap_forced: Optional[bool] = None # petctl nap on/off overrides the schedule
self._last_nap_check = 0.0 self._last_nap_check = 0.0
@@ -72,6 +107,9 @@ class PetController(QObject):
self._last_update_check = 0.0 self._last_update_check = 0.0
self._update_pending = False # applied on disk, waiting for the restart 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_watcher: Optional[notifications.NotificationWatcher] = None
self._notification_gate = notifications.NotificationGate( self._notification_gate = notifications.NotificationGate(
@@ -109,6 +147,20 @@ class PetController(QObject):
def reset_wake_stats(self) -> None: def reset_wake_stats(self) -> None:
self._near_misses.clear() 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: def stop(self) -> None:
self._running = False self._running = False
self._talk_now.set() # wake up anything blocked waiting on it self._talk_now.set() # wake up anything blocked waiting on it
@@ -159,6 +211,7 @@ class PetController(QObject):
except Exception as exc: except Exception as exc:
self.log.emit(f"Server not reachable yet ({exc}) — will keep trying per-request.") self.log.emit(f"Server not reachable yet ({exc}) — will keep trying per-request.")
self._start_notification_bridge() self._start_notification_bridge()
self._report_self_restart()
self._loop() self._loop()
if self._notification_watcher is not None: if self._notification_watcher is not None:
self._notification_watcher.stop() self._notification_watcher.stop()
@@ -213,21 +266,28 @@ class PetController(QObject):
self._follow_ups = 0 self._follow_ups = 0
self._state.transition(PetState.LISTENING) 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( pcm = mic.record_utterance(
self._stream, self._stream,
should_continue=self._should_continue, should_continue=self._should_continue,
# Answering a question deserves longer than saying the wake word # Answering a question deserves longer than saying the wake word
# on purpose does — you were just asked something. # on purpose does — you were just asked something.
grace_s=config.FOLLOW_UP_GRACE_SECONDS if following_up else None, 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 pcm is None:
if streamed is not None:
streamed.finish()
self._follow_ups = 0 # silence ends the chain self._follow_ups = 0 # silence ends the chain
self._state.transition(PetState.IDLE) self._state.transition(PetState.IDLE)
return return
self._state.transition(PetState.THINKING) self._state.transition(PetState.THINKING)
try: try:
text = stt.transcribe(pcm) text = self._transcribe(pcm, streamed)
except stt.SttError as exc: except stt.SttError as exc:
self.log.emit(f"STT failed: {exc}") self.log.emit(f"STT failed: {exc}")
self._state.transition(PetState.ERROR) self._state.transition(PetState.ERROR)
@@ -242,9 +302,7 @@ class PetController(QObject):
try: try:
# What's focused right now rides along, so "what's this error?" # What's focused right now rides along, so "what's this error?"
# has a referent without you having to describe the window. # has a referent without you having to describe the window.
reply = server_client.converse( reply = self._ask_server(self._with_context(text))
screen_context.context_for(text), on_command=self._handle_command
)
except server_client.ServerError as exc: except server_client.ServerError as exc:
self.log.emit(f"Server error: {exc}") self.log.emit(f"Server error: {exc}")
self._state.transition(PetState.ERROR) self._state.transition(PetState.ERROR)
@@ -252,8 +310,59 @@ class PetController(QObject):
return return
self._check_deliveries() self._check_deliveries()
self._speak(reply) 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._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: def _handle_command(self, command: str) -> str:
"""Server-relayed command. `petctl ...` drives the pet's body and """Server-relayed command. `petctl ...` drives the pet's body and
@@ -267,11 +376,53 @@ class PetController(QObject):
return f"[pet] {exc}" return f"[pet] {exc}"
if action is not None: if action is not None:
self.log.emit(f"Pet action: {action}") self.log.emit(f"Pet action: {action}")
if action["action"] == "nap":
# 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.set_napping(bool(action["enabled"]))
self.action.emit(action) self.action.emit(action)
return pet_actions.describe(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: try:
file_action = file_ops.parse(command) file_action = file_ops.parse(command)
except file_ops.FileOpError as exc: except file_ops.FileOpError as exc:
@@ -287,32 +438,215 @@ class PetController(QObject):
return server_client.run_local_command(command) return server_client.run_local_command(command)
def _speak(self, text: str) -> None: def _read_screen(self, target: str) -> str:
self._state.transition(PetState.TALKING) """`petctl read` — OCR a screen and hand the text back to the server."""
# Bubble gets the markdown stripped but emoji kept (it can't render if not config.SCREEN_TEXT:
# **bold** but draws emoji fine); tts.speak() does its own, stricter return "[pet] screen reading is disabled (set SCREEN_TEXT=true in .env)"
# sanitizing for the voice. if not self._monitors:
self.said.emit(speech_text.for_display(text)) return "[pet] no monitor information available"
self.log.emit(f"Bolt: {text}") limit = config.SCREEN_TEXT_MAX_CHARS
self.history.add(history_mod.PET, text, time.time()) if target in ("all", "everything", "*"):
self.log.emit(f"Reading all {len(self._monitors)} screens…")
should_stop = None return screen_text.read_monitors(self._monitors, limit)
if self._barge_in is not None: if target in ("here", "", "this", "current"):
self._barge_in.reset() index = self._pet_monitor if self._pet_monitor is not None else 0
should_stop = self._barge_in.check monitor = self._monitors[min(index, len(self._monitors) - 1)]
completed = tts.speak( else:
text, try:
on_error=lambda exc: self.log.emit(f"TTS failed: {exc}"), monitor = monitors_mod.resolve(
should_stop=should_stop, self._monitors, target, self._pet_monitor
) )
# Read the scoring history *before* resetting, or the log reports the except ValueError as exc:
# blank counters instead of what actually fired. return f"[pet] {exc}"
detail = self._barge_in_detail() self.log.emit(f"Reading monitor {monitor.number} ({monitor.name})…")
if self._barge_in is not None: return screen_text.read_monitor(monitor, limit)
# Playback fed the pet's own voice into the wake model's rolling
# window. Clear it before the idle listener starts scoring again, def _arm_self_restart(self, reason: str) -> str:
# or Bolt's last sentence is still in there being re-scored. """`petctl self_restart` — check the code, then arm a restart.
self._barge_in.reset()
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: if not completed:
# You talked over it — take that as the start of the next turn # You talked over it — take that as the start of the next turn
# rather than making you say the wake word again. # rather than making you say the wake word again.
@@ -332,20 +666,22 @@ class PetController(QObject):
def _should_follow_up(self, text: str) -> bool: def _should_follow_up(self, text: str) -> bool:
"""Whether *text* leaves the pet waiting on an answer. """Whether *text* leaves the pet waiting on an answer.
Muted is excluded because mute means "don't listen to me"an The rule itself — question, cap, off switchis
automatic turn would walk straight past it. Napping isn't: quiet `speech.follow_up_decision`, because it is a rule with an off-by-one in
hours suppress the pet *starting* something, and a question is only it and deserves a test that needs no audio. What stays here is the part
ever asked in reply to you.""" that is genuinely the controller's: mute, and the log line. Muted is
if not config.FOLLOW_UP_LISTEN or self._muted: excluded because mute means "don't listen to me" and an automatic turn
return False would walk straight past it. Napping isn't: quiet hours suppress the
if not speech_text.is_question(text): pet *starting* something, and a question is only ever asked in reply to
you."""
if self._muted:
return False 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 # Only worth mentioning the cap on a reply that would otherwise have
# kept listening, or it fires on every statement the pet makes. # kept listening, or it fires on every statement the pet makes.
if config.FOLLOW_UP_MAX_TURNS > 0 and self._follow_ups >= config.FOLLOW_UP_MAX_TURNS:
self.log.emit("Follow-up limit reached — say the wake word to keep going.") self.log.emit("Follow-up limit reached — say the wake word to keep going.")
return False return keep
return True
def _barge_in_detail(self) -> str: def _barge_in_detail(self) -> str:
"""Why the interruption fired, for the log. How far into playback it """Why the interruption fired, for the log. How far into playback it
@@ -403,31 +739,53 @@ class PetController(QObject):
def _queue_notification(self, notification: notifications.Notification) -> None: def _queue_notification(self, notification: notifications.Notification) -> None:
"""Called on the watcher thread — just queue it; forwarding happens on """Called on the watcher thread — just queue it; forwarding happens on
the pipeline thread where it can't collide with a live conversation.""" the pipeline thread where it can't collide with a live conversation.
if not self._notification_gate.should_forward(notification, time.monotonic()):
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 return
with self._notification_lock: 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) self._pending_notifications.append(notification)
def _drain_notifications(self) -> None: 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: with self._notification_lock:
pending, self._pending_notifications = self._pending_notifications, [] pending, self._pending_notifications = self._pending_notifications, []
for notification in pending: if not pending or not self._running or self._napping:
if not self._running or self._napping:
return return
for notification in pending:
self.log.emit(f"Notification: {notification.as_text()}") self.log.emit(f"Notification: {notification.as_text()}")
self.history.add(history_mod.SYSTEM, notification.as_text(), time.time()) 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: try:
reply = server_client.converse( reply = server_client.converse(
f"[desktop notification] {notification.as_text()}", message, on_command=self._handle_command, on_say=self._speak_holding,
on_command=self._handle_command,
) )
except server_client.ServerError as exc: except server_client.ServerError as exc:
self.log.emit(f"Couldn't forward notification: {exc}") self.log.emit(f"Couldn't forward notification: {exc}")
return return
self._check_deliveries() self._check_deliveries()
if reply.strip(): self._apply_voice(reply)
self._speak(reply) if reply.text.strip() and not reply.spoken:
self._speak(reply.text)
self._state.transition(PetState.IDLE) self._state.transition(PetState.IDLE)
# ── file delivery ──────────────────────────────────────────────────── # ── file delivery ────────────────────────────────────────────────────
@@ -501,20 +859,36 @@ class PetController(QObject):
# ── heartbeat ──────────────────────────────────────────────────────── # ── heartbeat ────────────────────────────────────────────────────────
def _maybe_heartbeat(self) -> None: 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._refresh_nap_state()
self._maybe_update() self._maybe_update()
if self._update_pending: if self._update_pending:
return # on the way out — don't start a conversation now 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() now = time.monotonic()
if now - self._last_heartbeat < config.HEARTBEAT_INTERVAL_SECONDS: if now - self._last_heartbeat < config.HEARTBEAT_INTERVAL_SECONDS:
return return
self._last_heartbeat = now
if self._state.state != PetState.IDLE: 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 return
if self._napping: if self._napping:
return # quiet hours: still answers when spoken to, just doesn't start return # quiet hours: still answers when spoken to, just doesn't start
self._last_heartbeat = now
self._check_deliveries() self._check_deliveries()
self._drain_notifications()
if self._state.state != PetState.IDLE: if self._state.state != PetState.IDLE:
return return
try: try:
+235
View File
@@ -0,0 +1,235 @@
"""`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 <json>` 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."
)
+272
View File
@@ -0,0 +1,272 @@
"""`python -m bolt_pet --doctor` — is this install actually going to work?
Written after a week of debugging things a preflight would have shown in one
command: a missing port publish, a wrong reverse-proxy header, a venv whose
python was a zero-byte file, an OCR engine that was never installed. Every one
of those presented as "the pet is being weird" and took a conversation to find.
Each check is independent and reports one of three things — ok, a warning
(works, but degraded), or a failure (this will not do what you expect) — and
says *what to do about it* rather than just what is wrong. Nothing here raises:
a doctor that crashes on a broken install is diagnosing the wrong patient.
Deliberately does not open the mic or call a paid API by default. It checks
that the door is unlocked, not that the room is furnished; `--deep` is there
when you want it to actually knock.
"""
from __future__ import annotations
import importlib
import shutil
import sys
from dataclasses import dataclass
from typing import Callable, Optional
from . import config
OK, WARN, FAIL = "ok", "warn", "fail"
_MARKS = {OK: " ok ", WARN: " warn ", FAIL: " FAIL "}
@dataclass
class Check:
name: str
status: str
detail: str = ""
fix: str = ""
def line(self) -> str:
text = f"[{_MARKS[self.status]}] {self.name:22} {self.detail}"
if self.fix and self.status != OK:
text += f"\n{'':32}{self.fix}"
return text
def _module(name: str) -> bool:
try:
importlib.import_module(name)
return True
except Exception:
return False
# ── the checks ──────────────────────────────────────────────────────────────
def check_config() -> Check:
missing = config.missing_config()
if missing:
return Check("server config", FAIL, f"missing {', '.join(missing)}",
"set them in .env — without these the controller exits at startup")
return Check("server config", OK, f"{config.SERVER_URL} as {config.SESSION_ID}")
def check_server(deep: bool = False) -> Check:
if not config.SERVER_URL:
return Check("server", FAIL, "no BOLT_SERVER_URL",
"cp .env.example .env and set BOLT_SERVER_URL to your Bolt server")
if not deep:
return Check("server", OK, f"{config.SERVER_URL} (not contacted; --deep to try)")
try:
from . import server_client
health = server_client.check_health(timeout=8)
return Check("server", OK, f"reachable — {health}")
except Exception as exc:
return Check("server", FAIL, f"unreachable: {exc}",
"check the URL, the key, and that the container is up")
def check_streaming_endpoint(deep: bool = False) -> Check:
"""The streamed-reply endpoint is newer than some deployed servers."""
if not config.STREAMING_REPLIES:
return Check("streamed replies", WARN, "disabled (STREAMING_REPLIES=false)")
if not deep:
return Check("streamed replies", OK, "enabled (endpoint not probed)")
try:
import requests
response = requests.post(
f"{config.SERVER_URL}/desk/converse_stream",
json={"session_id": config.SESSION_ID, "text": ""},
headers={"X-Desk-Api-Key": config.API_KEY}, timeout=8, stream=True,
)
if response.status_code == 404:
return Check("streamed replies", WARN, "server has no /desk/converse_stream",
"update the server, or set STREAMING_REPLIES=false to skip the probe")
return Check("streamed replies", OK, f"endpoint answered {response.status_code}")
except Exception as exc:
return Check("streamed replies", WARN, f"probe failed: {exc}")
def check_microphone(deep: bool = False) -> Check:
if not _module("sounddevice"):
return Check("microphone", FAIL, "sounddevice is not installed",
"pip install -r requirements.txt")
try:
import sounddevice as sd
devices = [d for d in sd.query_devices() if d.get("max_input_channels", 0) > 0]
if not devices:
return Check("microphone", FAIL, "no input devices",
"on Linux check PipeWire/PulseAudio is running as your user")
chosen = config.MIC_DEVICE or "system default"
if not deep:
return Check("microphone", OK, f"{len(devices)} input device(s), using {chosen}")
with sd.InputStream(samplerate=config.SAMPLE_RATE, channels=1, dtype="int16",
device=config.MIC_DEVICE, blocksize=config.FRAME_LEN):
pass
return Check("microphone", OK, f"opened at {config.SAMPLE_RATE} Hz ({chosen})")
except Exception as exc:
return Check("microphone", FAIL, f"could not open: {exc}",
"don't run the pet as root — PortAudio can't reach your PipeWire socket")
def check_wake_model() -> Check:
from pathlib import Path
path = Path(config.WAKE_MODEL_PATH)
if not path.exists():
return Check("wake word", FAIL, f"{path.name} is missing",
"it ships in the project root; check WAKE_MODEL_FILE")
if not _module("openwakeword"):
return Check("wake word", FAIL, "openwakeword is not installed",
"pip install -r requirements.txt")
return Check("wake word", OK, f"{path.name}, threshold {config.WAKE_WORD_THRESHOLD}")
def check_stt() -> Check:
if not config.DEEPGRAM_API_KEY:
return Check("speech-to-text", FAIL, "no DEEPGRAM_API_KEY",
"set it in .env — nothing you say can be transcribed without it")
if config.STT_STREAMING and not _module("websocket"):
return Check("speech-to-text", WARN, "streaming on, but websocket-client is missing",
"pip install websocket-client — it falls back to one-shot uploads")
mode = "streaming" if config.STT_STREAMING else "one-shot"
return Check("speech-to-text", OK, f"Deepgram {config.DEEPGRAM_MODEL}, {mode}")
def check_tts() -> Check:
if not (config.ELEVENLABS_API_KEY and config.ELEVENLABS_VOICE_ID):
if _module("pyttsx3"):
return Check("text-to-speech", WARN, "no ElevenLabs key/voice — offline voice only",
"set ELEVENLABS_API_KEY and ELEVENLABS_VOICE_ID for the real voice")
return Check("text-to-speech", FAIL, "no ElevenLabs config and no pyttsx3 fallback")
return Check("text-to-speech", OK,
f"ElevenLabs {config.ELEVENLABS_MODEL_ID}, voice …{config.ELEVENLABS_VOICE_ID[-6:]}")
def check_dialogue() -> Check:
if not config.DIALOGUE:
return Check("multi-voice scenes", WARN, "disabled (DIALOGUE=false)")
from . import dialogue
cast = dialogue.parse_voice_map(config.DIALOGUE_VOICES)
if not cast:
return Check("multi-voice scenes", WARN, "no cast configured — only 'self' works",
'set DIALOGUE_VOICES=narrator:<id>,villain:<id>')
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
+26 -16
View File
@@ -52,6 +52,8 @@ import json
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
from . import relay_json
_PREFIXES = ("filectl", "file") _PREFIXES = ("filectl", "file")
# Keeps a runaway read/write/list from blowing up the tool_result relay (and, # Keeps a runaway read/write/list from blowing up the tool_result relay (and,
@@ -95,17 +97,23 @@ def parse(command: str) -> Optional[dict]:
if not rest or rest.lower() in ("help", "-h", "--help"): if not rest or rest.lower() in ("help", "-h", "--help"):
return {"action": "help"} return {"action": "help"}
# Lenient on purpose — see relay_json. A stray quote in machine-written
# JSON should not cost a turn, but the repair is reported back so the model
# is told it sent something broken while it can still learn from it.
try: try:
payload = json.loads(rest) payload, repairs = relay_json.loads(rest)
except json.JSONDecodeError as exc: except relay_json.RelayJsonError as exc:
raise FileOpError(f"couldn't parse filectl JSON ({exc}); usage:\n{HELP}") from exc raise FileOpError(f"couldn't parse filectl JSON {exc}\nusage:\n{HELP}") from exc
if not isinstance(payload, dict): if not isinstance(payload, dict):
raise FileOpError(f"filectl payload must be a JSON object; usage:\n{HELP}") raise FileOpError(f"filectl payload must be a JSON object; usage:\n{HELP}")
op = str(payload.get("op") or "help").lower() op = str(payload.get("op") or "help").lower()
# Carried on the action so execute() can tell the model what it got wrong;
# a silent repair would fix today's call and guarantee tomorrow's.
tag = {"_repairs": repairs} if repairs else {}
if op == "help": if op == "help":
return {"action": "help"} return {"action": "help", **tag}
if op == "list": if op == "list":
path = _required_str(payload, "path") path = _required_str(payload, "path")
@@ -114,7 +122,7 @@ def parse(command: str) -> Optional[dict]:
raise FileOpError('"pattern" must be a non-empty string') raise FileOpError('"pattern" must be a non-empty string')
return { return {
"action": "list", "path": path, "action": "list", "path": path,
"pattern": pattern, "recursive": bool(payload.get("recursive")), "pattern": pattern, "recursive": bool(payload.get("recursive")), **tag,
} }
if op == "read": if op == "read":
@@ -122,14 +130,14 @@ def parse(command: str) -> Optional[dict]:
return { return {
"action": "read", "path": path, "action": "read", "path": path,
"start": _line_number(payload.get("start"), "start"), "start": _line_number(payload.get("start"), "start"),
"end": _line_number(payload.get("end"), "end"), "end": _line_number(payload.get("end"), "end"), **tag,
} }
if op == "write": if op == "write":
path = _required_str(payload, "path") path = _required_str(payload, "path")
if payload.get("content") is None: if payload.get("content") is None:
raise FileOpError('write needs "content"') raise FileOpError('write needs "content"')
return {"action": "write", "path": path, "content": str(payload["content"])} return {"action": "write", "path": path, "content": str(payload["content"]), **tag}
if op == "edit": if op == "edit":
path = _required_str(payload, "path") path = _required_str(payload, "path")
@@ -138,7 +146,7 @@ def parse(command: str) -> Optional[dict]:
old, new = str(payload["old"]), str(payload["new"]) old, new = str(payload["old"]), str(payload["new"])
if old == new: if old == new:
raise FileOpError("old and new text are identical — nothing to edit") raise FileOpError("old and new text are identical — nothing to edit")
return {"action": "edit", "path": path, "old": old, "new": new} return {"action": "edit", "path": path, "old": old, "new": new, **tag}
raise FileOpError(f"unknown filectl op {op!r}; usage:\n{HELP}") raise FileOpError(f"unknown filectl op {op!r}; usage:\n{HELP}")
@@ -175,14 +183,16 @@ def execute(action: dict) -> str:
if kind == "help": if kind == "help":
return HELP return HELP
if kind == "list": if kind == "list":
return _do_list(action) output = _do_list(action)
if kind == "read": elif kind == "read":
return _do_read(action) output = _do_read(action)
if kind == "write": elif kind == "write":
return _do_write(action) output = _do_write(action)
if kind == "edit": elif kind == "edit":
return _do_edit(action) output = _do_edit(action)
return "[filectl] ok" else:
output = "[filectl] ok"
return output + relay_json.repair_note(action.get("_repairs") or [])
def _resolve(path_str: str) -> Path: def _resolve(path_str: str) -> Path:
+213
View File
@@ -0,0 +1,213 @@
"""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}]"
+55 -1
View File
@@ -29,14 +29,32 @@ ANCHORS = (
EMOTES = ("wave", "hop", "spin", "nod", "shake", "bounce", "wiggle") 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 = ( HELP = (
"petctl move <x> <y> | <" + "|".join(ANCHORS) + ">\n" "petctl move <x> <y> | <" + "|".join(ANCHORS) + ">\n"
"petctl jump <monitor number|" + "|".join(MONITOR_SPECS) + "|name>\n"
"petctl monitors\n"
"petctl read [monitor number|here|all]\n"
"petctl emote <" + "|".join(EMOTES) + ">\n" "petctl emote <" + "|".join(EMOTES) + ">\n"
"petctl say <text>\n" "petctl say <text>\n"
"petctl wander on|off\n" "petctl wander on|off\n"
"petctl nap on|off" "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): class ActionError(Exception):
"""Bad petctl syntax — reported back to the server as command output.""" """Bad petctl syntax — reported back to the server as command output."""
@@ -82,6 +100,24 @@ def parse(command: str) -> Optional[dict]:
raise ActionError(f"unknown position {args[0]!r}; try one of: " + ", ".join(ANCHORS)) raise ActionError(f"unknown position {args[0]!r}; try one of: " + ", ".join(ANCHORS))
return {"action": "move", "anchor": anchor} 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 verb in ("emote", "do"):
if not args: if not args:
raise ActionError("emote needs a name: " + ", ".join(EMOTES)) raise ActionError("emote needs a name: " + ", ".join(EMOTES))
@@ -101,6 +137,22 @@ def parse(command: str) -> Optional[dict]:
raise ActionError("wander needs on or off") raise ActionError("wander needs on or off")
return {"action": "wander", "enabled": _bool_arg(args[0])} 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 verb in ("nap", "sleep", "dnd"):
if not args: if not args:
raise ActionError("nap needs on or off") raise ActionError("nap needs on or off")
@@ -124,6 +176,8 @@ def describe(action: dict) -> str:
if kind == "move": if kind == "move":
where = action.get("anchor") or f"({action.get('x')}, {action.get('y')})" where = action.get("anchor") or f"({action.get('x')}, {action.get('y')})"
return f"[pet] walking to {where}" return f"[pet] walking to {where}"
if kind == "jump":
return f"[pet] jumping to monitor {action['target']}"
if kind == "emote": if kind == "emote":
return f"[pet] {action['emote']}" return f"[pet] {action['emote']}"
if kind == "say": if kind == "say":
+148
View File
@@ -0,0 +1,148 @@
"""Lenient JSON for the relayed command channel — and honest about it.
`filectl` and `dialoguectl` both take a single line of compact JSON, hand-typed
by a language model into a tool marker. Models get that *nearly* right and then
get it wrong in a small, boringly repeatable set of ways:
{"op":"list","path":"/home/x","recursive":false"} ← stray quote after a literal
{"op": "read", "path": "/tmp/a.txt",} ← trailing comma
{'op': 'read', 'path': '/tmp/a.txt'} ← single quotes
{“op”: “read”, “path”: “/tmp/a.txt”} ← smart quotes
{"op": "list", "recursive": False} ← Python literals
```json {"op": "list"} ``` ← fenced
Observed live (2026-07-30): a stray quote after `false` cost an entire desk
turn — the call was rejected, the model re-sent the *identical* line, was
rejected again, and then gave up and told the user "I'll check now" without
ever calling anything. The user got a promise instead of an answer because of
one character.
Strict parsing is the wrong trade here. Nothing about a misplaced quote is
ambiguous, the payload is machine-written and machine-read, and the cost of
refusing is a wasted round trip that the model has already demonstrated it
won't recover from. So: try strict first, then apply narrow repairs, and
accept a repair **only if the result parses**.
Two rules keep this from becoming "guess what they meant":
1. **Repairs are conservative and named.** Each one fixes a known malformation,
is applied in isolation, and is reported by name.
2. **Repairs are never silent.** The caller appends the repair note to the tool
result, so the model is told it sent broken JSON *while it still has the
turn* — the fix works today and teaches within the conversation. Hiding it
would trade a visible failure for an invisible one.
When nothing parses, the error points at the exact character with a caret,
because "Expecting ',' delimiter: char 74" is not something a model can act on
and a pointed-at fragment is.
"""
from __future__ import annotations
import json
import re
from typing import Any, Callable
# Ordered, cheapest and safest first. Each entry is (name, transform); after
# each one the payload is re-parsed, so the first repair that works wins and
# nothing more aggressive gets applied than the input actually needed.
_REPAIRS: tuple[tuple[str, Callable[[str], str]], ...] = (
(
"stripped a markdown code fence",
lambda text: re.sub(r"^\s*```(?:json)?\s*|\s*```\s*$", "", text),
),
(
"replaced smart quotes with straight ones",
lambda text: text.translate(str.maketrans({"": '"', "": '"',
"": "'", "": "'"})),
),
(
"removed a stray quote after a bare value",
# {"recursive":false"} -> {"recursive":false}
lambda text: re.sub(
r"(:\s*(?:true|false|null|-?\d+(?:\.\d+)?))\s*\"(\s*[,}\]])", r"\1\2", text),
),
(
"removed a trailing comma",
lambda text: re.sub(r",(\s*[}\]])", r"\1", text),
),
(
"converted Python literals (True/False/None) to JSON",
lambda text: re.sub(r"(:\s*)(True|False|None)\b",
lambda m: m.group(1) + {"True": "true", "False": "false",
"None": "null"}[m.group(2)], text),
),
(
"converted single-quoted strings to double-quoted",
lambda text: re.sub(r"'([^'\"]*)'", r'"\1"', text),
),
)
class RelayJsonError(ValueError):
"""Unparseable even after repairs — carries a pointed-at fragment."""
def loads(payload: str) -> tuple[Any, list[str]]:
"""Parse *payload*, repairing common model mistakes.
Returns (data, repairs) where *repairs* names what had to be fixed — empty
when the input was already valid. Raises RelayJsonError with a caret at the
offending character when nothing works."""
text = str(payload or "").strip()
if not text:
raise RelayJsonError("empty payload")
try:
return json.loads(text), []
except json.JSONDecodeError as exc:
# Bound to a plain name: Python deletes the `as` target at the end of
# the except block, so referring to it further down would raise
# UnboundLocalError instead of reporting the parse failure.
first_error = exc
applied: list[str] = []
candidate = text
for name, repair in _REPAIRS:
repaired = repair(candidate)
if repaired == candidate:
continue
candidate = repaired
applied.append(name)
try:
return json.loads(candidate), applied
except json.JSONDecodeError:
continue # keep going: a payload can be broken in more than one way
raise RelayJsonError(point_at(text, first_error))
def point_at(text: str, error: json.JSONDecodeError, width: int = 28) -> str:
"""Show the failure where it happened.
A model can act on "you wrote `false\"}` here"; it cannot act on
"Expecting ',' delimiter: line 1 column 75"."""
position = max(0, min(len(text), getattr(error, "pos", 0)))
start = max(0, position - width)
end = min(len(text), position + width)
fragment = text[start:end]
caret = " " * (position - start) + "^"
lead = "" if start > 0 else ""
tail = "" if end < len(text) else ""
return (
f"{error.msg} at character {position}:\n"
f" {lead}{fragment}{tail}\n"
f" {' ' * len(lead)}{caret}"
)
def repair_note(repairs: list[str]) -> str:
"""The line appended to a tool result when repairs were needed.
Phrased as feedback rather than an apology: the model is the author of the
broken JSON and is the one who can stop sending it."""
if not repairs:
return ""
return (
" (note: your JSON was malformed — I " + "; ".join(repairs)
+ " and ran it anyway. Send valid single-line JSON next time.)"
)
+187
View File
@@ -0,0 +1,187 @@
"""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)
+235
View File
@@ -0,0 +1,235 @@
"""`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)
+141 -3
View File
@@ -9,14 +9,20 @@ memory, tools, and persona as Discord chat and the Linux voice client:
... -> POST /desk/tool_result (repeat until the server sends a reply) ... -> POST /desk/tool_result (repeat until the server sends a reply)
reply <- returned to caller 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. Kept dependency-free beyond `requests` so it's easy to unit test with mocks.
""" """
from __future__ import annotations from __future__ import annotations
import json
import subprocess import subprocess
from pathlib import Path from pathlib import Path
from typing import Callable, Optional from typing import Callable, NamedTuple, Optional
import requests import requests
@@ -29,6 +35,21 @@ class ServerError(Exception):
"""Raised when the server responds with an error payload or unreachable.""" """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: def _headers() -> dict:
return {"X-Desk-Api-Key": config.API_KEY} return {"X-Desk-Api-Key": config.API_KEY}
@@ -74,12 +95,20 @@ def converse(
text: str, text: str,
on_command: Callable[[str], str] = run_local_command, on_command: Callable[[str], str] = run_local_command,
timeout: float = 120.0, timeout: float = 120.0,
) -> str: on_say: Optional[Callable[[str], None]] = None,
) -> Reply:
"""Send one turn of conversation to the desk API, relaying any commands """Send one turn of conversation to the desk API, relaying any commands
the server sends back until it produces a final reply. the server sends back until it produces a final reply.
*on_command* is injectable for tests; defaults to actually running the *on_command* is injectable for tests; defaults to actually running the
command locally (matching bolt_desk.py's behavior). 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() headers = _headers()
try: try:
@@ -95,6 +124,13 @@ def converse(
for _ in range(_MAX_RELAY_HOPS): for _ in range(_MAX_RELAY_HOPS):
if payload.get("type") != "command": if payload.get("type") != "command":
break 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 "")) output = on_command(str(payload.get("command") or ""))
try: try:
response = requests.post( response = requests.post(
@@ -111,7 +147,109 @@ def converse(
raise ServerError(f"couldn't reach the server during tool relay: {exc}") from exc raise ServerError(f"couldn't reach the server during tool relay: {exc}") from exc
if payload.get("type") == "reply": if payload.get("type") == "reply":
return str(payload.get("text") or "") 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")) raise ServerError(str(payload.get("error") or "unknown server response"))
+194
View File
@@ -0,0 +1,194 @@
"""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"
+30 -3
View File
@@ -97,10 +97,34 @@ def _bullets_to_sentences(text: str) -> str:
return " ".join(line if line[-1] in ".!?:,;" else line + "." for line in lines) 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: def for_speech(text: str) -> str:
"""Plain prose for the TTS engine: no markdown, no emoji, no bare URLs, """Plain prose for the TTS engine: no markdown, no emoji, no bare URLs,
no stray symbols that would be read out character by character.""" no stray symbols that would be read out character by character."""
text = (text or "").strip() text = strip_audio_tags((text or "").strip())
if not text: if not text:
return "" return ""
for symbol, spoken in _PRE_SPOKEN_SYMBOLS.items(): for symbol, spoken in _PRE_SPOKEN_SYMBOLS.items():
@@ -136,8 +160,11 @@ def is_question(text: str) -> bool:
def for_display(text: str) -> str: def for_display(text: str) -> str:
"""What the speech bubble shows: markdown syntax removed (the bubble """What the speech bubble shows: markdown syntax removed (the bubble
can't render it) but emoji and layout-ish punctuation left alone.""" can't render it) but emoji and layout-ish punctuation left alone.
text = (text or "").strip()
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: if not text:
return "" return ""
text = _strip_markdown(text, keep_emoji=True) text = _strip_markdown(text, keep_emoji=True)
+6 -1
View File
@@ -26,11 +26,16 @@ class PetState(str, Enum):
# make the pet speak unprompted — a reminder firing, a nudge from the server # 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 # — without the user having said anything first, so there's no preceding
# LISTENING/THINKING leg for that turn. # 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]] = { _TRANSITIONS: dict[PetState, set[PetState]] = {
PetState.IDLE: {PetState.LISTENING, PetState.TALKING, PetState.ERROR}, PetState.IDLE: {PetState.LISTENING, PetState.TALKING, PetState.ERROR},
PetState.LISTENING: {PetState.THINKING, PetState.IDLE, PetState.ERROR}, PetState.LISTENING: {PetState.THINKING, PetState.IDLE, PetState.ERROR},
PetState.THINKING: {PetState.TALKING, PetState.IDLE, PetState.ERROR}, PetState.THINKING: {PetState.TALKING, PetState.IDLE, PetState.ERROR},
PetState.TALKING: {PetState.IDLE, PetState.ERROR}, PetState.TALKING: {PetState.IDLE, PetState.THINKING, PetState.ERROR},
PetState.ERROR: {PetState.IDLE}, PetState.ERROR: {PetState.IDLE},
} }
+13
View File
@@ -41,11 +41,20 @@ def run() -> int:
thread.started.connect(controller.run) thread.started.connect(controller.run)
controller.state_changed.connect(lambda value: window.set_state(PetState(value))) controller.state_changed.connect(lambda value: window.set_state(PetState(value)))
controller.said.connect(window.say) 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.log.connect(_log)
controller.action.connect(window.apply_action) # petctl move/emote/say/... controller.action.connect(window.apply_action) # petctl move/emote/say/...
controller.finished.connect(thread.quit) controller.finished.connect(thread.quit)
window.talk_requested.connect(controller.request_talk_now) 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]}")) window.copied.connect(lambda text: _log(f"Copied to clipboard: {text[:60]}"))
history_window = HistoryWindow(controller.history) history_window = HistoryWindow(controller.history)
@@ -73,6 +82,7 @@ def run() -> int:
on_set_nap=_set_nap, on_set_nap=_set_nap,
on_show_history=history_window.show_refreshed, on_show_history=history_window.show_refreshed,
on_show_wake_tuner=tuner_window.show_refreshed, on_show_wake_tuner=tuner_window.show_refreshed,
on_reset_voice=controller.reset_voice,
) )
def _handle_napping(napping: bool) -> None: def _handle_napping(napping: bool) -> None:
@@ -80,6 +90,9 @@ def run() -> int:
tray.set_napping(napping) tray.set_napping(napping)
controller.napping.connect(_handle_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. # 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 # request_talk_now() only sets a threading.Event, so it's safe to call
+246 -6
View File
@@ -19,9 +19,10 @@ from PySide6.QtGui import (
) )
from PySide6.QtWidgets import QApplication, QWidget from PySide6.QtWidgets import QApplication, QWidget
from .. import config from .. import config, window_state
from ..monitors import Monitor
from ..state import PetState from ..state import PetState
from .sprite import SpriteSet from .sprite import WALK, SpriteSet
_DRAG_THRESHOLD_PX = 4 _DRAG_THRESHOLD_PX = 4
# Movement runs on its own ~30fps timer, independent of the (slower) sprite # Movement runs on its own ~30fps timer, independent of the (slower) sprite
@@ -29,6 +30,17 @@ _DRAG_THRESHOLD_PX = 4
_WANDER_TICK_MS = 33 _WANDER_TICK_MS = 33
_EMOTE_TICKS = 36 # ~1.2s per emote at the tick rate above _EMOTE_TICKS = 36 # ~1.2s per emote at the tick rate above
_NAP_OPACITY = 0.35 _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]: def emote_transform(emote: str, progress: float) -> tuple[float, float, float, float]:
@@ -164,6 +176,12 @@ class SpeechBubble(QWidget):
class PetWindow(QWidget): class PetWindow(QWidget):
talk_requested = Signal() talk_requested = Signal()
copied = Signal(str) # bubble text the user just put on the clipboard 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): def __init__(self, sprite_dir: Optional[Path] = None, size: Optional[int] = None):
super().__init__() super().__init__()
@@ -200,7 +218,14 @@ class PetWindow(QWidget):
self._commanded_move = False # a petctl move — happens even mid-conversation self._commanded_move = False # a petctl move — happens even mid-conversation
self._next_wander_at = 0.0 self._next_wander_at = 0.0
self._bob_offset = 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._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._schedule_next_wander()
self._wander_timer = QTimer(self) self._wander_timer = QTimer(self)
self._wander_timer.timeout.connect(self._movement_tick) self._wander_timer.timeout.connect(self._movement_tick)
@@ -210,6 +235,18 @@ class PetWindow(QWidget):
self.set_click_through(config.PET_CLICK_THROUGH) self.set_click_through(config.PET_CLICK_THROUGH)
self._place_start_position() 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 ──────────────────────────────────────────────────────── # ── placement ────────────────────────────────────────────────────────
def _place_start_position(self) -> None: def _place_start_position(self) -> None:
@@ -220,6 +257,19 @@ class PetWindow(QWidget):
y = int(config.PET_START_Y) if config.PET_START_Y else None y = int(config.PET_START_Y) if config.PET_START_Y else None
except ValueError: except ValueError:
x = y = None 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: if geo is not None:
x = geo.right() - self.width() - 40 if x is None else x x = geo.right() - self.width() - 40 if x is None else x
y = geo.bottom() - self.height() - 60 if y is None else y y = geo.bottom() - self.height() - 60 if y is None else y
@@ -244,6 +294,8 @@ class PetWindow(QWidget):
if target is not None: if target is not None:
self._wander_target = target self._wander_target = target
self._commanded_move = True # overrides the idle-only rule self._commanded_move = True # overrides the idle-only rule
elif kind == "jump":
self.jump_to_monitor(int(action["monitor"]))
elif kind == "emote": elif kind == "emote":
self.start_emote(action["emote"]) self.start_emote(action["emote"])
elif kind == "say": elif kind == "say":
@@ -378,6 +430,98 @@ class PetWindow(QWidget):
"""Stroll immediately (tray menu / anything that wants a nudge).""" """Stroll immediately (tray menu / anything that wants a nudge)."""
self._next_wander_at = 0.0 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): def _screen_geometry(self):
# screenAt() so a multi-monitor setup keeps the pet on the screen # screenAt() so a multi-monitor setup keeps the pet on the screen
# it's currently standing on rather than yanking it to the primary. # it's currently standing on rather than yanking it to the primary.
@@ -389,12 +533,17 @@ class PetWindow(QWidget):
self._next_wander_at = time.monotonic() + random.uniform(0.5 * base, 1.5 * base) self._next_wander_at = time.monotonic() + random.uniform(0.5 * base, 1.5 * base)
def _stop_walking(self) -> None: def _stop_walking(self) -> None:
if self._wander_target is None and not self._bob_offset: if self._wander_target is None and not self._bob_offset and not self._walking:
return return
self._wander_target = None self._wander_target = None
self._commanded_move = False self._commanded_move = False
self._bob_phase = 0.0 self._bob_phase = 0.0
self._bob_offset = 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() self.update()
def snap_to_edge(self) -> bool: def snap_to_edge(self) -> bool:
@@ -447,6 +596,13 @@ class PetWindow(QWidget):
wandering only happens when it's otherwise unoccupied.""" wandering only happens when it's otherwise unoccupied."""
self._advance_emote() self._advance_emote()
self._wander_tick() 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: def _wander_tick(self) -> None:
# Only stroll while genuinely idle: not mid-drag, not napping, not # Only stroll while genuinely idle: not mid-drag, not napping, not
@@ -484,11 +640,51 @@ class PetWindow(QWidget):
self.snap_to_edge() self.snap_to_edge()
else: else:
self.move(round(here.x() + dx / distance * step), round(here.y() + dy / distance * step)) self.move(round(here.x() + dx / distance * step), round(here.y() + dy / distance * step))
self._bob_phase += 0.45 # little walk-cycle hop self._advance_walk(dx, dy, step)
self._bob_offset = int(round(-2.5 * abs(math.sin(self._bob_phase))))
self.update() self.update()
self._reposition_bubble() 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 ────────────────────────────────────────────────── # ── state / speech ──────────────────────────────────────────────────
def set_state(self, state: PetState) -> None: def set_state(self, state: PetState) -> None:
@@ -513,7 +709,40 @@ class PetWindow(QWidget):
# ── animation ──────────────────────────────────────────────────────── # ── 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: 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.sprites.get(self._current_state).advance()
self.update() self.update()
@@ -521,7 +750,15 @@ class PetWindow(QWidget):
painter = QPainter(self) painter = QPainter(self)
painter.setRenderHint(QPainter.Antialiasing) painter.setRenderHint(QPainter.Antialiasing)
painter.setRenderHint(QPainter.SmoothPixmapTransform) painter.setRenderHint(QPainter.SmoothPixmapTransform)
pixmap: Optional[QPixmap] = self.sprites.get(self._current_state).current() 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: if pixmap is None:
self._apply_input_mask(None, 0, 0) self._apply_input_mask(None, 0, 0)
return return
@@ -576,6 +813,9 @@ class PetWindow(QWidget):
was_click = not self._dragged was_click = not self._dragged
self._drag_offset = None self._drag_offset = None
self._press_pos = 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: if was_click:
self.talk_requested.emit() self.talk_requested.emit()
else: else:
+35 -6
View File
@@ -95,17 +95,46 @@ def _load_frames_from_dir(directory: Path, size: int) -> list[QPixmap]:
return frames 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: class SpriteSet:
"""All animations for every PetState, loaded from *sprite_dir*.""" """All animations for every PetState, plus the extras, from *sprite_dir*."""
def __init__(self, sprite_dir: Path = DEFAULT_SPRITE_DIR, size: int = 160): def __init__(self, sprite_dir: Path = DEFAULT_SPRITE_DIR, size: int = 160):
self.size = size self.size = size
self._animations: dict[PetState, SpriteAnimation] = {} self._animations: dict[str, SpriteAnimation] = {}
self._loaded: set[str] = set() # keys backed by real art, not placeholders
for state in PetState: for state in PetState:
frames = _load_frames_from_dir(sprite_dir / state.value, size) frames = _load_frames_from_dir(sprite_dir / state.value, size)
if not frames: if frames:
self._loaded.add(state.value)
else:
frames = _placeholder_frames(state, size) frames = _placeholder_frames(state, size)
self._animations[state] = SpriteAnimation(frames) 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)
def get(self, state: PetState) -> SpriteAnimation: @staticmethod
return self._animations[state] 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
+23 -1
View File
@@ -1,6 +1,7 @@
"""System tray icon — the pet window is frameless with no taskbar entry, so """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, this menu is the only always-available way to control or exit it: talk now,
mute, wander, click-through, nap, history, wake-word tuning, quit. 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 Every entry is a plain callback passed in by ui/app.py; this file knows
nothing about the controller or the pet window. nothing about the controller or the pet window.
@@ -46,6 +47,7 @@ class PetTray(QSystemTrayIcon):
on_set_nap: Optional[Callable[[bool], None]] = None, on_set_nap: Optional[Callable[[bool], None]] = None,
on_show_history: Optional[Callable[[], None]] = None, on_show_history: Optional[Callable[[], None]] = None,
on_show_wake_tuner: Optional[Callable[[], None]] = None, on_show_wake_tuner: Optional[Callable[[], None]] = None,
on_reset_voice: Optional[Callable[[], None]] = None,
parent=None, parent=None,
): ):
super().__init__(_make_icon(muted=False), parent) super().__init__(_make_icon(muted=False), parent)
@@ -102,6 +104,16 @@ class PetTray(QSystemTrayIcon):
tuner_action.triggered.connect(on_show_wake_tuner) tuner_action.triggered.connect(on_show_wake_tuner)
menu.addAction(tuner_action) menu.addAction(tuner_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() menu.addSeparator()
quit_action = QAction("Quit", menu) quit_action = QAction("Quit", menu)
quit_action.triggered.connect(on_quit) quit_action.triggered.connect(on_quit)
@@ -115,6 +127,16 @@ class PetTray(QSystemTrayIcon):
self._mute_action.setChecked(self._muted) self._mute_action.setChecked(self._muted)
self._refresh_icon() 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: def set_napping(self, napping: bool) -> None:
"""Reflect a nap the *controller* decided on (quiet hours, fullscreen, """Reflect a nap the *controller* decided on (quiet hours, fullscreen,
or a petctl command) not just ones clicked here.""" or a petctl command) not just ones clicked here."""
+75
View File
@@ -0,0 +1,75 @@
"""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
+15
View File
@@ -8,6 +8,9 @@ numpy>=1.24
# HTTP client to the Bolt desk API # HTTP client to the Bolt desk API
requests>=2.31 requests>=2.31
# Streaming speech-to-text (audio/stt_stream.py). Optional in practice: without
# it the pet falls back to uploading the finished clip, exactly as before.
websocket-client>=1.7
# Wake-word detection (local, offline after first run) — runs the # Wake-word detection (local, offline after first run) — runs the
# custom-trained thunderbolt.onnx model shipped in this repo, same runtime # custom-trained thunderbolt.onnx model shipped in this repo, same runtime
@@ -32,6 +35,18 @@ pynput>=1.7
# Not imported by the app itself. # Not imported by the app itself.
Pillow>=10.0 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; # Test runner (tests/ — pure logic, no audio hardware or display needed;
# run with QT_QPA_PLATFORM=offscreen). # run with QT_QPA_PLATFORM=offscreen).
pytest>=8.0 pytest>=8.0
+807
View File
@@ -0,0 +1,807 @@
"""Draw Bolt — the pet — as per-state PNG frame sequences.
Produces the `assets/sprites/<state>/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()
+13 -5
View File
@@ -22,6 +22,14 @@ def no_screen_probes(monkeypatch):
monkeypatch.setattr(controller_mod.screen_context, "is_fullscreen_active", lambda: False) 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 @pytest.fixture
def ctrl(): def ctrl():
return controller_mod.PetController() return controller_mod.PetController()
@@ -41,10 +49,10 @@ def test_full_turn_happy_path(monkeypatch, ctrl):
monkeypatch.setattr(controller_mod.mic, "record_utterance", lambda *a, **k: np.zeros(10, dtype=np.int16)) 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.stt, "transcribe", lambda pcm: "what's the weather")
monkeypatch.setattr(controller_mod.server_client, "converse", lambda text, on_command=None: "sunny and 72") monkeypatch.setattr(controller_mod.server_client, "converse", lambda text, on_command=None, on_say=None: controller_mod.server_client.Reply("sunny and 72"))
spoken = [] spoken = []
monkeypatch.setattr(controller_mod.tts, "speak", monkeypatch.setattr(controller_mod.tts, "speak",
lambda text, on_error=None, should_stop=None: (spoken.append(text), True)[1]) lambda text, on_error=None, should_stop=None, voice_id=None, on_level=None: (spoken.append(text), True)[1])
ctrl._handle_conversation_turn() ctrl._handle_conversation_turn()
@@ -59,7 +67,7 @@ def test_turn_with_nothing_heard_returns_to_idle_without_calling_server(monkeypa
monkeypatch.setattr(controller_mod.mic, "record_utterance", lambda *a, **k: None) monkeypatch.setattr(controller_mod.mic, "record_utterance", lambda *a, **k: None)
called = {"n": 0} called = {"n": 0}
monkeypatch.setattr(controller_mod.server_client, "converse", monkeypatch.setattr(controller_mod.server_client, "converse",
lambda text, on_command=None: called.__setitem__("n", called["n"] + 1)) lambda text, on_command=None, on_say=None: called.__setitem__("n", called["n"] + 1))
ctrl._handle_conversation_turn() ctrl._handle_conversation_turn()
@@ -97,7 +105,7 @@ def test_turn_with_server_error_flashes_error_then_idle(monkeypatch, ctrl):
monkeypatch.setattr(controller_mod.mic, "record_utterance", lambda *a, **k: np.zeros(10, dtype=np.int16)) 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") monkeypatch.setattr(controller_mod.stt, "transcribe", lambda pcm: "hello")
def boom(text, on_command=None): def boom(text, on_command=None, on_say=None):
raise controller_mod.server_client.ServerError("server is down") raise controller_mod.server_client.ServerError("server is down")
monkeypatch.setattr(controller_mod.server_client, "converse", boom) monkeypatch.setattr(controller_mod.server_client, "converse", boom)
@@ -150,7 +158,7 @@ def test_heartbeat_speaks_a_pending_announcement_when_idle(monkeypatch, ctrl):
monkeypatch.setattr(controller_mod.server_client, "report_status", lambda: "don't forget your 3pm") monkeypatch.setattr(controller_mod.server_client, "report_status", lambda: "don't forget your 3pm")
spoken = [] spoken = []
monkeypatch.setattr(controller_mod.tts, "speak", monkeypatch.setattr(controller_mod.tts, "speak",
lambda text, on_error=None, should_stop=None: (spoken.append(text), True)[1]) lambda text, on_error=None, should_stop=None, voice_id=None, on_level=None: (spoken.append(text), True)[1])
said = _capture(ctrl.said) said = _capture(ctrl.said)
ctrl._maybe_heartbeat() ctrl._maybe_heartbeat()
+537 -15
View File
@@ -15,6 +15,7 @@ from PySide6.QtWidgets import QApplication
from bolt_pet import controller as controller_mod from bolt_pet import controller as controller_mod
from bolt_pet.notifications import Notification from bolt_pet.notifications import Notification
from bolt_pet.server_client import Reply
from bolt_pet.state import PetState from bolt_pet.state import PetState
sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
@@ -28,6 +29,14 @@ def no_screen_probes(monkeypatch):
monkeypatch.setattr(controller_mod.screen_context, "context_for", lambda text: text) 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 @pytest.fixture
def ctrl(): def ctrl():
return controller_mod.PetController() return controller_mod.PetController()
@@ -159,7 +168,7 @@ def test_the_interrupt_log_reports_what_fired_not_the_reset_counters(monkeypatch
ctrl._barge_in = detector ctrl._barge_in = detector
logs = _capture(ctrl.log) logs = _capture(ctrl.log)
def interrupted_playback(text, on_error=None, should_stop=None): 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 # What really happens: frames get scored during playback, then one
# clears the threshold and playback aborts. # clears the threshold and playback aborts.
detector._frames, detector._peak, detector._last = 7, 0.81, 0.81 detector._frames, detector._peak, detector._last = 7, 0.81, 0.81
@@ -179,7 +188,7 @@ def test_the_interrupt_log_reports_what_fired_not_the_reset_counters(monkeypatch
def test_interrupted_playback_queues_an_immediate_next_turn(monkeypatch, ctrl): def test_interrupted_playback_queues_an_immediate_next_turn(monkeypatch, ctrl):
logs = _capture(ctrl.log) logs = _capture(ctrl.log)
monkeypatch.setattr(controller_mod.tts, "speak", monkeypatch.setattr(controller_mod.tts, "speak",
lambda text, on_error=None, should_stop=None: False) # interrupted lambda text, on_error=None, should_stop=None, voice_id=None, on_level=None: False) # interrupted
ctrl._speak("a very long explanation") ctrl._speak("a very long explanation")
@@ -189,7 +198,7 @@ def test_interrupted_playback_queues_an_immediate_next_turn(monkeypatch, ctrl):
def test_uninterrupted_playback_does_not_queue_a_turn(monkeypatch, ctrl): def test_uninterrupted_playback_does_not_queue_a_turn(monkeypatch, ctrl):
monkeypatch.setattr(controller_mod.tts, "speak", monkeypatch.setattr(controller_mod.tts, "speak",
lambda text, on_error=None, should_stop=None: True) lambda text, on_error=None, should_stop=None, voice_id=None, on_level=None: True)
ctrl._speak("short answer") ctrl._speak("short answer")
assert not ctrl._talk_now.is_set() assert not ctrl._talk_now.is_set()
@@ -201,7 +210,7 @@ def spoke(monkeypatch):
"""Playback that always completes, so only the follow-up rule decides """Playback that always completes, so only the follow-up rule decides
whether another turn is queued.""" whether another turn is queued."""
monkeypatch.setattr(controller_mod.tts, "speak", monkeypatch.setattr(controller_mod.tts, "speak",
lambda text, on_error=None, should_stop=None: True) 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): def test_a_reply_ending_in_a_question_keeps_listening(spoke, ctrl):
@@ -289,7 +298,7 @@ def test_follow_up_can_be_turned_off(spoke, monkeypatch, ctrl):
def test_being_interrupted_restarts_the_chain(monkeypatch, ctrl): def test_being_interrupted_restarts_the_chain(monkeypatch, ctrl):
monkeypatch.setattr(controller_mod.tts, "speak", monkeypatch.setattr(controller_mod.tts, "speak",
lambda text, on_error=None, should_stop=None: False) lambda text, on_error=None, should_stop=None, voice_id=None, on_level=None: False)
ctrl._follow_ups = 3 ctrl._follow_ups = 3
ctrl._speak("a very long explanation") ctrl._speak("a very long explanation")
@@ -300,7 +309,7 @@ def test_being_interrupted_restarts_the_chain(monkeypatch, ctrl):
def test_speech_is_recorded_in_the_history(monkeypatch, ctrl): def test_speech_is_recorded_in_the_history(monkeypatch, ctrl):
monkeypatch.setattr(controller_mod.tts, "speak", monkeypatch.setattr(controller_mod.tts, "speak",
lambda text, on_error=None, should_stop=None: True) lambda text, on_error=None, should_stop=None, voice_id=None, on_level=None: True)
ctrl._speak("**bold** reply") ctrl._speak("**bold** reply")
assert ctrl.history.last().text == "**bold** reply" # raw, for copy/paste assert ctrl.history.last().text == "**bold** reply" # raw, for copy/paste
@@ -314,10 +323,10 @@ def test_the_active_window_rides_along_with_the_utterance(monkeypatch, ctrl):
monkeypatch.setattr(controller_mod.screen_context, "context_for", monkeypatch.setattr(controller_mod.screen_context, "context_for",
lambda text: f"{text}\n\n[on screen right now: app.py]") lambda text: f"{text}\n\n[on screen right now: app.py]")
monkeypatch.setattr(controller_mod.tts, "speak", monkeypatch.setattr(controller_mod.tts, "speak",
lambda text, on_error=None, should_stop=None: True) lambda text, on_error=None, should_stop=None, voice_id=None, on_level=None: True)
sent = [] sent = []
monkeypatch.setattr(controller_mod.server_client, "converse", monkeypatch.setattr(controller_mod.server_client, "converse",
lambda text, on_command=None: sent.append(text) or "that's a KeyError") lambda text, on_command=None, on_say=None: sent.append(text) or Reply("that's a KeyError"))
ctrl._handle_conversation_turn() ctrl._handle_conversation_turn()
@@ -370,10 +379,10 @@ def test_napping_still_answers_when_spoken_to(monkeypatch, ctrl):
lambda *a, **k: np.zeros(10, dtype=np.int16)) lambda *a, **k: np.zeros(10, dtype=np.int16))
monkeypatch.setattr(controller_mod.stt, "transcribe", lambda pcm: "you awake?") monkeypatch.setattr(controller_mod.stt, "transcribe", lambda pcm: "you awake?")
monkeypatch.setattr(controller_mod.server_client, "converse", monkeypatch.setattr(controller_mod.server_client, "converse",
lambda text, on_command=None: "always") lambda text, on_command=None, on_say=None: Reply("always"))
spoken = [] spoken = []
monkeypatch.setattr(controller_mod.tts, "speak", monkeypatch.setattr(controller_mod.tts, "speak",
lambda text, on_error=None, should_stop=None: spoken.append(text) or True) lambda text, on_error=None, should_stop=None, voice_id=None, on_level=None: spoken.append(text) or True)
ctrl.set_napping(True) ctrl.set_napping(True)
ctrl._handle_conversation_turn() ctrl._handle_conversation_turn()
@@ -388,9 +397,9 @@ def test_notifications_are_forwarded_and_spoken(monkeypatch, ctrl):
ctrl._notification_gate = controller_mod.notifications.NotificationGate("", 0) ctrl._notification_gate = controller_mod.notifications.NotificationGate("", 0)
sent, spoken = [], [] sent, spoken = [], []
monkeypatch.setattr(controller_mod.server_client, "converse", monkeypatch.setattr(controller_mod.server_client, "converse",
lambda text, on_command=None: sent.append(text) or "your build is green") lambda text, on_command=None, on_say=None: sent.append(text) or Reply("your build is green"))
monkeypatch.setattr(controller_mod.tts, "speak", monkeypatch.setattr(controller_mod.tts, "speak",
lambda text, on_error=None, should_stop=None: spoken.append(text) or True) 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._queue_notification(Notification(app="CI", summary="Build finished", body=""))
ctrl._drain_notifications() ctrl._drain_notifications()
@@ -410,7 +419,7 @@ def test_notifications_are_not_forwarded_while_napping(monkeypatch, ctrl):
ctrl._notification_gate = controller_mod.notifications.NotificationGate("", 0) ctrl._notification_gate = controller_mod.notifications.NotificationGate("", 0)
called = {"n": 0} called = {"n": 0}
monkeypatch.setattr(controller_mod.server_client, "converse", monkeypatch.setattr(controller_mod.server_client, "converse",
lambda text, on_command=None: called.__setitem__("n", called["n"] + 1)) lambda text, on_command=None, on_say=None: called.__setitem__("n", called["n"] + 1))
ctrl.set_napping(True) ctrl.set_napping(True)
ctrl._queue_notification(Notification(app="CI", summary="Build finished", body="")) ctrl._queue_notification(Notification(app="CI", summary="Build finished", body=""))
@@ -506,9 +515,9 @@ def test_check_deliveries_runs_after_a_conversation_turn(monkeypatch, ctrl, tmp_
lambda *a, **k: np.zeros(10, dtype=np.int16)) 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.stt, "transcribe", lambda pcm: "send me that file")
monkeypatch.setattr(controller_mod.server_client, "converse", monkeypatch.setattr(controller_mod.server_client, "converse",
lambda text, on_command=None: "it's on the way") lambda text, on_command=None, on_say=None: Reply("it's on the way"))
monkeypatch.setattr(controller_mod.tts, "speak", monkeypatch.setattr(controller_mod.tts, "speak",
lambda text, on_error=None, should_stop=None: True) 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.config, "DELIVERED_FILES_DIR", tmp_path)
monkeypatch.setattr(controller_mod.server_client, "list_outbox_files", monkeypatch.setattr(controller_mod.server_client, "list_outbox_files",
lambda: [{"id": "abc", "name": "notes.txt", "size": 2}]) lambda: [{"id": "abc", "name": "notes.txt", "size": 2}])
@@ -518,3 +527,516 @@ def test_check_deliveries_runs_after_a_conversation_turn(monkeypatch, ctrl, tmp_
ctrl._handle_conversation_turn() ctrl._handle_conversation_turn()
assert (tmp_path / "notes.txt").read_bytes() == b"hi" 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
+236
View File
@@ -0,0 +1,236 @@
"""`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()
+115
View File
@@ -0,0 +1,115 @@
"""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
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", "DEEPGRAM_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", "DEEPGRAM_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()
+63
View File
@@ -264,3 +264,66 @@ def test_write_creates_parent_directories(tmp_path):
action = file_ops.parse(_cmd({"op": "write", "path": str(target), "content": "hi"})) action = file_ops.parse(_cmd({"op": "write", "path": str(target), "content": "hi"}))
file_ops.execute(action) file_ops.execute(action)
assert target.read_text() == "hi" assert target.read_text() == "hi"
# ── lenient JSON (see relay_json) ───────────────────────────────────────────
# Machine-written JSON fails in a small, repeatable set of ways. Observed live
# 2026-07-30: a stray quote after `false` cost a whole desk turn — rejected,
# re-sent identically, rejected again, then abandoned with a promise to the
# user that nothing fulfilled.
def test_the_stray_quote_that_cost_a_live_turn_now_parses():
action = file_ops.parse(
'filectl {"op":"list","path":"/home/maji/Documents","pattern":"*","recursive":false"}'
)
assert action["action"] == "list"
assert action["path"] == "/home/maji/Documents"
assert action["recursive"] is False
assert action["_repairs"] == ["removed a stray quote after a bare value"]
@pytest.mark.parametrize("payload,expected", [
('{"op": "read", "path": "/tmp/a.txt",}', "removed a trailing comma"),
("{'op': 'read', 'path': '/tmp/a.txt'}", "converted single-quoted strings to double-quoted"),
('{“op”: “read”, “path”: “/tmp/a.txt”}', "replaced smart quotes with straight ones"),
('```json {"op": "read", "path": "/tmp/a.txt"} ```', "stripped a markdown code fence"),
])
def test_common_model_json_mistakes_are_repaired(payload, expected):
action = file_ops.parse("filectl " + payload)
assert action["action"] == "read"
assert action["path"] == "/tmp/a.txt"
assert expected in action["_repairs"]
def test_python_literals_are_converted():
action = file_ops.parse('filectl {"op": "list", "path": "/tmp", "recursive": True}')
assert action["recursive"] is True
def test_a_repair_is_reported_in_the_output_never_hidden(tmp_path):
"""Silently fixing it would work today and guarantee the same broken call
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
)
output = file_ops.execute(action)
assert "a.txt" in output
assert "your JSON was malformed" in output
assert "stray quote" in output
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)
assert "_repairs" not in action
assert "malformed" not in file_ops.execute(action)
def test_genuinely_unparseable_json_points_at_the_character():
""""Expecting ',' delimiter: char 74" is not something a model can act on."""
with pytest.raises(file_ops.FileOpError) as excinfo:
file_ops.parse('filectl {"op": "read", "path": "/tmp/a.txt" "extra": 1}')
message = str(excinfo.value)
assert "^" in message # caret under the offending character
assert '"extra"' in message # ...and the fragment around it
+135
View File
@@ -0,0 +1,135 @@
"""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
+169
View File
@@ -0,0 +1,169 @@
"""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"
+14
View File
@@ -69,3 +69,17 @@ def test_describe_is_reported_back_to_the_server():
assert "top-left" in pet_actions.describe({"action": "move", "anchor": "top-left"}) assert "top-left" in pet_actions.describe({"action": "move", "anchor": "top-left"})
assert "wave" in pet_actions.describe({"action": "emote", "emote": "wave"}) assert "wave" in pet_actions.describe({"action": "emote", "emote": "wave"})
assert pet_actions.describe({"action": "help"}) == pet_actions.HELP 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")
+154 -1
View File
@@ -15,7 +15,10 @@ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from bolt_pet import config from bolt_pet import config
from bolt_pet.state import PetState from bolt_pet.state import PetState
from bolt_pet.ui.pet_window import _EMOTE_TICKS, PetWindow, emote_transform 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") @pytest.fixture(scope="module")
@@ -179,3 +182,153 @@ def test_click_through_toggles_mouse_transparency(pet):
pet.set_click_through(False) pet.set_click_through(False)
assert pet.testAttribute(Qt.WA_TransparentForMouseEvents) is 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()
+289
View File
@@ -0,0 +1,289 @@
"""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 == []
+168
View File
@@ -0,0 +1,168 @@
"""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)
+233
View File
@@ -0,0 +1,233 @@
"""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 == []
+158
View File
@@ -0,0 +1,158 @@
"""`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) == []
+16 -2
View File
@@ -27,7 +27,8 @@ def test_converse_returns_reply_directly():
with patch.object(server_client.requests, "post") as post: with patch.object(server_client.requests, "post") as post:
post.return_value = _mock_response({"type": "reply", "text": "hello there"}) post.return_value = _mock_response({"type": "reply", "text": "hello there"})
result = server_client.converse("hi") result = server_client.converse("hi")
assert result == "hello there" assert result.text == "hello there"
assert result.voice_id == "" # no speak_as on this reply
post.assert_called_once() post.assert_called_once()
args, kwargs = post.call_args args, kwargs = post.call_args
assert args[0] == "http://test-server:5002/desk/converse" assert args[0] == "http://test-server:5002/desk/converse"
@@ -43,7 +44,7 @@ def test_converse_relays_a_command_then_returns_reply():
with patch.object(server_client.requests, "post", side_effect=responses) as post: with patch.object(server_client.requests, "post", side_effect=responses) as post:
on_command = MagicMock(return_value="[exit 0]\nhi") on_command = MagicMock(return_value="[exit 0]\nhi")
result = server_client.converse("run echo hi", on_command=on_command) result = server_client.converse("run echo hi", on_command=on_command)
assert result == "done" assert result.text == "done"
on_command.assert_called_once_with("echo hi") on_command.assert_called_once_with("echo hi")
# second call was to /desk/tool_result with the command's output # second call was to /desk/tool_result with the command's output
second_call = post.call_args_list[1] second_call = post.call_args_list[1]
@@ -53,6 +54,19 @@ def test_converse_relays_a_command_then_returns_reply():
} }
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(): def test_converse_raises_server_error_on_error_payload():
with patch.object(server_client.requests, "post") as post: with patch.object(server_client.requests, "post") as post:
post.return_value = _mock_response({"type": "error", "error": "unauthorized"}) post.return_value = _mock_response({"type": "error", "error": "unauthorized"})
+260
View File
@@ -0,0 +1,260 @@
"""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
+32
View File
@@ -4,6 +4,8 @@
import sys import sys
from pathlib import Path from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from bolt_pet.speech_text import for_display, for_speech, is_question from bolt_pet.speech_text import for_display, for_speech, is_question
@@ -77,3 +79,33 @@ def test_is_question_ignores_question_marks_that_are_not_spoken():
assert not is_question("Docs are at https://example.com/x?y=1") assert not is_question("Docs are at https://example.com/x?y=1")
assert not is_question("") assert not is_question("")
assert not is_question(None) 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.")
+173
View File
@@ -0,0 +1,173 @@
"""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_a_key(monkeypatch):
from bolt_pet.audio import stt_stream
monkeypatch.setattr(stt_stream.config, "STT_STREAMING", False)
assert stt_stream.available() is False
monkeypatch.setattr(stt_stream.config, "STT_STREAMING", True)
monkeypatch.setattr(stt_stream.config, "DEEPGRAM_API_KEY", "")
assert stt_stream.available() is False
# ── 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
+26 -1
View File
@@ -8,7 +8,8 @@ import numpy as np
sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from bolt_pet.audio.tts import chunks_to_int16 from bolt_pet import config as tts_config
from bolt_pet.audio.tts import chunks_to_int16, model_for, voice_for
from bolt_pet.audio.wake_word import NearMissLog from bolt_pet.audio.wake_word import NearMissLog
@@ -80,3 +81,27 @@ def test_clear_resets_peak_and_entries():
log.observe(0.45, threshold=0.5, timestamp=1.0) log.observe(0.45, threshold=0.5, timestamp=1.0)
log.clear() log.clear()
assert log.entries() == [] and log.peak == 0.0 assert log.entries() == [] and log.peak == 0.0
# ── voice / model 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"
def test_english_replies_in_the_default_voice_use_the_default_model(monkeypatch):
monkeypatch.setattr(tts_config, "ELEVENLABS_MODEL_ID", "eleven_flash_v2")
monkeypatch.setattr(tts_config, "ELEVENLABS_MULTILINGUAL_MODEL_ID", "eleven_flash_v2_5")
assert model_for("all good here", None) == "eleven_flash_v2"
def test_a_picked_voice_or_non_english_text_uses_the_multilingual_model(monkeypatch):
# eleven_flash_v2 is English-only: it would read either of these as
# mangled phonetic English rather than failing outright.
monkeypatch.setattr(tts_config, "ELEVENLABS_MODEL_ID", "eleven_flash_v2")
monkeypatch.setattr(tts_config, "ELEVENLABS_MULTILINGUAL_MODEL_ID", "eleven_flash_v2_5")
assert model_for("all good here", "VOICE1") == "eleven_flash_v2_5"
assert model_for("こんにちは", None) == "eleven_flash_v2_5"
+84
View File
@@ -0,0 +1,84 @@
"""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, [])