Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3a0959f55d | |||
| c4e805defd | |||
| 96afc351ac | |||
| 5b49670983 | |||
| 311fe8b709 | |||
| b121bbba17 | |||
| ccb3aeb7ae | |||
| c16fada8d8 |
@@ -30,7 +30,66 @@
|
||||
"Bash(git push *)",
|
||||
"Bash(git remote *)",
|
||||
"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(.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(python3 -m json.tool)",
|
||||
"Bash(docker restart bolt *)",
|
||||
"Bash(curl -s -m 5 \"http://localhost:5002/desk/health\")",
|
||||
"Bash(python3 -c ' *)",
|
||||
"Bash(QT_QPA_PLATFORM=offscreen /root/Documents/bolt-pet/.venv/bin/pytest /home/themajesticmagician/Documents/Bolt-Pet/tests/ -q)",
|
||||
"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(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__)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,36 @@ ELEVENLABS_VOICE_ID=
|
||||
#ELEVENLABS_MODEL_ID=eleven_flash_v2
|
||||
#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) ──────────
|
||||
#MIC_DEVICE=
|
||||
#SPEAKER_DEVICE=
|
||||
@@ -73,6 +103,9 @@ ELEVENLABS_VOICE_ID=
|
||||
#PET_CLICK_THROUGH=false
|
||||
#PET_EDGE_SNAP=true
|
||||
#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_MODE decides what counts as an interruption:
|
||||
@@ -108,11 +141,45 @@ ELEVENLABS_VOICE_ID=
|
||||
# ── Streaming TTS (optional) — starts talking on the first chunk ────────────
|
||||
#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) ───────────────────────────────────────────────
|
||||
# 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.
|
||||
#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) ────────────────────────────────
|
||||
# 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
|
||||
@@ -152,6 +219,12 @@ ELEVENLABS_VOICE_ID=
|
||||
#SUDO_ASKPASS_HELPER= # blank = auto-detect
|
||||
#SUDO_COMMAND_TIMEOUT_SECONDS=180 # long enough for a human to answer
|
||||
|
||||
# ── File delivery (optional) ────────────────────────────────────────────────
|
||||
# The server's deliver_files tool ("send me that report") queues workspace
|
||||
# files on this session; the pet fetches and saves them automatically.
|
||||
#RECEIVE_FILES=true
|
||||
#DELIVERED_FILES_DIR=~/Downloads/Bolt
|
||||
|
||||
# ── Misc (optional) ──────────────────────────────────────────────────────────
|
||||
#COMMAND_TIMEOUT_SECONDS=30
|
||||
#HEARTBEAT_INTERVAL_SECONDS=60
|
||||
|
||||
@@ -11,13 +11,19 @@ dependency** on the server repo; it's a standalone HTTP client configured via
|
||||
its own `.env`.
|
||||
|
||||
Pipeline: `mic → openWakeWord ("thunderbolt", on-device) / push-to-talk /
|
||||
click → record utterance → Deepgram STT → + active-window context → POST
|
||||
/desk/converse → [server may relay a shell command to run on this machine, or
|
||||
a `petctl` pseudo-command that moves/emotes the pet instead] → reply →
|
||||
click → record utterance → Deepgram STT → + active-window + screen-layout
|
||||
context → POST /desk/converse → [server may relay a shell command to run on
|
||||
this machine, or a `petctl` pseudo-command that moves/emotes the pet, jumps it
|
||||
to another monitor, reads a screen's text back, or plays a multi-voice scene
|
||||
instead] → reply (optionally tagged with a voice the server picked for it) →
|
||||
ElevenLabs streaming TTS (or offline pyttsx3 fallback) → speakers`, with the
|
||||
pet sprite/speech bubble reflecting state throughout, and playback
|
||||
interruptible by talking over it (barge-in).
|
||||
|
||||
Because a relayed command's output goes back up the tool-result relay before
|
||||
the final reply, a `petctl read` mid-turn means Bolt can look at a monitor and
|
||||
then talk about what's on it in the same answer.
|
||||
|
||||
Side channels that let the pet act between turns: the heartbeat (proactive
|
||||
announcements), the desktop notification bridge, and autonomous wandering —
|
||||
all suppressed while it's napping (quiet hours / fullscreen DND).
|
||||
@@ -29,11 +35,18 @@ all suppressed while it's napping (quiet hours / fullscreen DND).
|
||||
./run.sh # macOS/Linux
|
||||
run.bat # Windows
|
||||
|
||||
# Preflight: is this install actually going to work? (config, mic, keys, sprites…)
|
||||
python -m bolt_pet --doctor # shallow: no network, no mic
|
||||
python -m bolt_pet --doctor --deep # contacts the server and opens the microphone
|
||||
|
||||
# Run tests (no pytest config file — tests self-insert repo root via sys.path).
|
||||
# QT_QPA_PLATFORM=offscreen avoids a QApplication segfault on headless/no-display hosts.
|
||||
QT_QPA_PLATFORM=offscreen .venv/bin/pytest tests/
|
||||
.venv/bin/pytest tests/test_state.py::test_happy_path_transitions # single test
|
||||
|
||||
# Redraw the pet's sprite frames (the committed PNGs are this script's output)
|
||||
python scripts/generate_bolt_sprites.py # --out /tmp/x to preview first
|
||||
|
||||
# Convert a grid sprite sheet into the per-frame-PNG convention sprite.py expects
|
||||
python scripts/slice_spritesheet.py path/to/sheet.png assets/sprites/idle --cols 6 --rows 1
|
||||
```
|
||||
@@ -60,10 +73,10 @@ logs a missing-config message and exits its thread instead of starting.
|
||||
a `QWidget` directly. Also drives the periodic heartbeat
|
||||
(`_maybe_heartbeat`, gated by `HEARTBEAT_INTERVAL_SECONDS`) which lets the
|
||||
server push proactive spoken announcements between user turns, and on the
|
||||
same tick re-evaluates nap state and drains queued desktop notifications.
|
||||
It owns the live wake-word threshold (`wake_threshold()` is passed to
|
||||
`listen_for_wake_word` as a *callable* so the tray slider takes effect
|
||||
mid-listen) and the conversation `history`.
|
||||
same tick re-evaluates nap state, checks for delivered files, and drains
|
||||
queued desktop notifications. It owns the live wake-word threshold
|
||||
(`wake_threshold()` is passed to `listen_for_wake_word` as a *callable* so
|
||||
the tray slider takes effect mid-listen) and the conversation `history`.
|
||||
- **`server_client.py`** — HTTP client for the desk API, dependency-free
|
||||
beyond `requests` so it's easy to mock in tests. `converse()` loops relaying
|
||||
server-issued shell commands (`run_local_command`, executed via
|
||||
@@ -71,7 +84,27 @@ logs a missing-config message and exits its thread instead of starting.
|
||||
`/desk/tool_result` until the server sends a final `reply` (capped at
|
||||
`_MAX_RELAY_HOPS`). This is the same "full desktop control" trust model as
|
||||
the server repo's other desk clients — commands only ever originate from
|
||||
the user's own voice/click requests in their own session.
|
||||
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>`
|
||||
endpoints the server's `deliver_files` tool queues onto — see `file_delivery.py`.
|
||||
- **`file_delivery.py`** — the filesystem half of receiving files the server
|
||||
queues via its `deliver_files` tool (`ai/desk_api.py` in the main tmn-api
|
||||
repo — "send me that report" during a conversation spools the matched
|
||||
workspace files, zipping multiple into one, onto the session's outbox).
|
||||
`controller._check_deliveries` lists `/desk/files` and downloads anything
|
||||
queued — right after a conversation/notification turn (the common case)
|
||||
and once per heartbeat tick for anything queued out-of-band — saving each
|
||||
under `DELIVERED_FILES_DIR` (default `~/Downloads/Bolt`). Downloading a
|
||||
file dequeues it server-side, so it's only ever handed out once; `save()`
|
||||
never overwrites an existing download, suffixing `" (1)"`, `" (2)"`, ... on
|
||||
a name collision. `sanitize_filename()` reduces a server-supplied name to
|
||||
its bare filename (`Path(...).name`), which is defense-in-depth against a
|
||||
delivered name that's secretly a path, since a per-user desk API key means
|
||||
the name isn't always coming from someone as trusted as the owner. Toggle
|
||||
off entirely with `RECEIVE_FILES=false`.
|
||||
- **`audio/`** — `mic.py` (energy-based VAD utterance capture, ported from the
|
||||
server repo's `bolt_desk.py`), `wake_word.py` (openWakeWord `thunderbolt.onnx`
|
||||
detection + `NearMissLog` for threshold tuning — see below), `stt.py`
|
||||
@@ -79,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()`
|
||||
carries odd bytes across HTTP chunk boundaries, without which everything
|
||||
after the first split sample plays as static — falling back to whole-clip
|
||||
PCM then offline `pyttsx3`), `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`:
|
||||
**wake** (default) scores every frame with the same openWakeWord model the
|
||||
idle listener uses, so only the wake phrase cuts playback; **energy** is the
|
||||
@@ -90,21 +127,134 @@ 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
|
||||
hardware or a display.
|
||||
- **`pet_actions.py`** — `petctl` pseudo-commands (`petctl move top-left`,
|
||||
`petctl emote wave`, `say`/`wander`/`nap`). The desk API has no "move the
|
||||
pet" payload type and this repo can't change the server, so these ride the
|
||||
existing shell-command relay: `controller._handle_command` parses them and
|
||||
they never reach `subprocess`; anything else is a real shell command exactly
|
||||
as before. Pure parsing; the UI half is `PetWindow.apply_action`.
|
||||
`petctl emote wave`, `say`/`wander`/`nap`, the screen verbs
|
||||
`jump`/`monitors`/`read`, and `voice reset`). The desk API has no "move the pet" payload type
|
||||
and this repo can't change the server, so these ride the existing
|
||||
shell-command relay: `controller._handle_command` parses them and they never
|
||||
reach `subprocess`; anything else is a real shell command exactly as before.
|
||||
Pure parsing; the UI half is `PetWindow.apply_action`. Note `jump`'s target
|
||||
is *not* validated here — which monitors exist is a runtime fact this pure
|
||||
module doesn't have, so the spec passes through to `monitors.resolve()`.
|
||||
Query verbs (`monitors`, `read`) are answered in `_handle_command` rather
|
||||
than by `pet_actions.describe()`, because their output *is* the point: it
|
||||
goes back up the tool-result relay for Bolt to use in his reply — as is
|
||||
`voice reset`, which reports what it dropped since the server can't see
|
||||
which voice is in use. `voice` only ever resets: picking one is the
|
||||
server's job (`speak_as`, which it already knows how to use), so a
|
||||
`petctl voice <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`
|
||||
right after petctl and before falling through to a real shell command.
|
||||
Executing arbitrary commands already worked via the shell relay
|
||||
(`run_local_command` — see `server_client.py` below); what filectl adds is
|
||||
a *reliable* way to do the read/write/edit/list slice of that, since
|
||||
getting the model to hand-roll a shell heredoc for multi-line content full
|
||||
of quotes/`$`/backticks is failure-prone — and `list` exists as its own op
|
||||
(rather than relying on the model shelling out to `ls`/`dir`) because this
|
||||
project is cross-platform and the model shouldn't have to guess which
|
||||
listing command applies on Windows vs. Linux vs. macOS; one glob-based op
|
||||
(`pattern`, default `*`; `recursive` for `rglob` instead of `glob`) covers
|
||||
all three. Wire format is `filectl <json>` where `<json>` is a
|
||||
**single-line** compact JSON object —
|
||||
`{"op": "list"|"read"|"write"|"edit", "path": ..., ...}` — not a multi-line
|
||||
marker block (an earlier design): the server relays this as the argument
|
||||
to the ordinary `command` tool marker, and that marker's extractor
|
||||
(`ai/agents/default.py` in the main repo) only captures up to the next
|
||||
newline, so anything genuinely multi-line silently got truncated no matter
|
||||
how the prompt worded it. JSON sidesteps that for free — `json.dumps`
|
||||
already encodes embedded newlines as the two characters `\n`, not a real
|
||||
line break, so multi-line file content still fits on the one physical line
|
||||
the extractor sees. `edit` requires the old text to match exactly once —
|
||||
same discipline as this project's own code-editing tool — and raises
|
||||
rather than guessing if it's missing or ambiguous. This doesn't expand
|
||||
what the server can do to this machine (a relayed shell command could
|
||||
already overwrite anything the desktop user can write — see the security
|
||||
notes below); it's a safer path to the same capability. Pure parsing
|
||||
(`parse`) is separated from the filesystem I/O (`execute`), matching
|
||||
pet_actions.py's parse/describe split.
|
||||
- **`screen_context.py`** — active-window title (xprop/xdotool, Win32,
|
||||
osascript) appended to each utterance via `context_for()`, plus
|
||||
`is_fullscreen_active()` for do-not-disturb. Text only — the desk API takes
|
||||
no images. Every probe is best-effort and returns None/False rather than
|
||||
raising; the parsing is split into pure functions that are tested without a
|
||||
display server.
|
||||
- **`monitors.py`** — the screen layout, and resolving `petctl jump` targets
|
||||
(a 1-based number, a name, `next`/`prev`/`primary`/`other`, or a direction
|
||||
like `left`/`up` worked out from the actual geometry). Pure — no Qt, no
|
||||
subprocess. The monitor list is *published by the UI*
|
||||
(`PetWindow.publish_monitors` builds it from `QGuiApplication.screens()` and
|
||||
emits it over a queued signal to `controller.set_monitors`), because the
|
||||
controller and the window must agree on what "monitor 2" means: enumerating
|
||||
with `xrandr` on one side and Qt's screen list on the other gives different
|
||||
orderings on the same machine, and Bolt would announce one screen and land
|
||||
on another. Qt is the single source of truth; `Monitor.index` is 0-based and
|
||||
`.number` is the 1-based value used in every string a human or the model
|
||||
sees. The controller resolves a jump to a concrete index *before* emitting
|
||||
it, so the window can't re-resolve against a different list.
|
||||
- **`screen_text.py`** — OCR, so Bolt can read what's on a monitor
|
||||
(`petctl read [n|here|all]`). **Pull, not push**: nothing captures on its own
|
||||
— the server has to ask, and the text goes back as that command's output.
|
||||
That's deliberate; OCR of a 4K screen costs a second or two that would
|
||||
otherwise be added to *every* utterance, and screen contents leaving the
|
||||
machine should be a visible decision rather than a constant. Capture needs
|
||||
`mss` (X11/Win32/macOS, **not** Wayland), recognition needs Tesseract or
|
||||
RapidOCR; both are optional and soft-fail with a reason the way `hotkey.py`
|
||||
does, and `read_monitor()` never raises because its return value is command
|
||||
output. Engine selection takes injected probes so it's testable wherever.
|
||||
- **`quiet.py`** — quiet-hours spec parsing (`23:00-08:00`, wraps midnight,
|
||||
comma-separated). Napping suppresses *proactive* noise and wandering only;
|
||||
wake word / click / push-to-talk still work.
|
||||
- **`notifications.py`** — Linux/D-Bus notification bridge: 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
|
||||
and rate-limits them (`NotificationGate`), and the controller forwards
|
||||
survivors through `converse()`. Off by default — each one is a round trip.
|
||||
@@ -140,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
|
||||
reason (Wayland, missing package, macOS permissions) since the wake word is
|
||||
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.
|
||||
`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
|
||||
@@ -149,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*
|
||||
form (so a '?' inside a stripped code block or URL doesn't count) and only a
|
||||
trailing one counts, since a question asked in passing isn't awaiting a
|
||||
reply. `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
|
||||
with a question can't loop forever off mic noise. Pure string logic, no
|
||||
Qt/audio imports.
|
||||
@@ -162,6 +387,15 @@ logs a missing-config message and exits its thread instead of starting.
|
||||
target every `PET_WANDER_INTERVAL_SECONDS` (randomized), suppressed
|
||||
whenever the pet is non-IDLE, napping, dragged, or has a bubble up. A
|
||||
commanded `petctl move` overrides all of that except the drag.
|
||||
- **the walk cycle** — while actually travelling, `_animation_key()` swaps
|
||||
the state animation for the side-view `walk/` frames (not a `PetState` —
|
||||
see the sprites README). It is stepped by *distance travelled*
|
||||
(`_WALK_PIXELS_PER_FRAME`), never by the animation timer, so the planted
|
||||
paw tracks backwards at exactly the speed the window moves forwards;
|
||||
`_advance_frame` deliberately no-ops while walking so the two can't
|
||||
double-step it. The art is drawn facing right and `_oriented()` mirrors it
|
||||
(cached per frame) when heading left. No `walk/` art → falls back to the
|
||||
old coded bob rather than a placeholder blob.
|
||||
- **emotes** — `emote_transform()` is pure maths (dx, dy, rotation, scale
|
||||
from a 0..1 progress) kept out of `paintEvent` so the curves are unit
|
||||
tested; every emote must return to the identity transform at progress 1.0
|
||||
@@ -174,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
|
||||
dimming** (`set_napping`).
|
||||
`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
|
||||
frames; `tray.py` is the system tray menu (talk now / mute / nap / wander /
|
||||
click-through / history / wake-word tuning / quit) — the pet window has no
|
||||
title bar or taskbar entry; `history_window.py` and `wake_tuner.py` are the
|
||||
two dialogs it opens.
|
||||
frames. It also loads `EXTRA_ANIMATIONS` — currently just `walk/` — keyed by
|
||||
name rather than by `PetState`, with `has()` reporting whether a key is
|
||||
backed by real art so callers can decline a placeholder instead of trotting
|
||||
a blob across the desktop; `tray.py` is the system tray menu (talk now / mute / nap / wander /
|
||||
click-through / history / wake-word tuning / use-default-voice / quit) — the
|
||||
pet window has no title bar or taskbar entry; `history_window.py` and
|
||||
`wake_tuner.py` are the two dialogs it opens.
|
||||
|
||||
### 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
|
||||
|
||||
@@ -236,6 +504,20 @@ HTTP chunk reassembly by `chunks_to_int16`, emote motion by
|
||||
`screen_context.context_for` / `is_fullscreen_active`, otherwise they shell
|
||||
out to xprop on a headless box.
|
||||
|
||||
**`test_pipeline_smoke.py` is the exception, deliberately.** Unit tests inject
|
||||
a fake at the seam they care about, and a run of production bugs — notifications
|
||||
sitting unspoken for minutes, the pet saying things twice, `[laughing]` read
|
||||
aloud, a device command arriving as prose — got through with every one of them
|
||||
green, because each was an *interaction* between two individually-correct
|
||||
units. So that file stands up a real threaded HTTP server on a loopback port,
|
||||
speaks the desk protocol at it, and drives whole turns through the real
|
||||
`server_client` (including NDJSON streaming), the real controller and the real
|
||||
state machine, faking only the mic stream and the speakers. When a bug crosses
|
||||
a module boundary, add the case there; when it lives inside one module, the
|
||||
pure-function pattern above is still the cheaper test. It is also worth
|
||||
mutation-checking a new case — break the source line it is meant to catch and
|
||||
confirm it actually goes red.
|
||||
|
||||
## Security notes
|
||||
|
||||
The server can relay a shell command back to this machine to execute as the
|
||||
@@ -245,6 +527,13 @@ matches the trust model of the server repo's other desk clients. Keep
|
||||
`DESK_API_KEY` private and don't expose the desk API port to the open
|
||||
internet.
|
||||
|
||||
`file_ops.py`'s `filectl` read/write/edit pseudo-commands ride that same
|
||||
relay and are bound by the same trust model — no path is off-limits beyond
|
||||
normal filesystem permissions for the desktop user, exactly like a relayed
|
||||
`cat`/`sed`/`rm` already isn't. They don't grant the server anything a shell
|
||||
command couldn't already do; they just make the read/write/edit path
|
||||
reliable instead of relying on the model getting shell quoting right.
|
||||
|
||||
`sudo_askpass.py` widens that further, by design: with `SUDO_ASKPASS_PROMPT`
|
||||
on (the default), a relayed bare `sudo` is rewritten to `sudo -A` and the
|
||||
password is collected in a desktop dialog, so commands can escalate to root
|
||||
@@ -262,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
|
||||
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
|
||||
(titles often contain file paths, document names, or subject lines), and
|
||||
`NOTIFICATION_BRIDGE` (off by default) forwards matching desktop
|
||||
notifications to the server. Neither sends screenshots or notification
|
||||
contents you haven't matched with `NOTIFICATION_FILTER`.
|
||||
(titles often contain file paths, document names, or subject lines),
|
||||
`MONITOR_CONTEXT` appends the screen layout (sizes and names only — no
|
||||
contents), and `NOTIFICATION_BRIDGE` (off by default) forwards matching
|
||||
desktop notifications to the server. None of those send screenshots or
|
||||
notification contents you haven't matched with `NOTIFICATION_FILTER`.
|
||||
|
||||
`SCREEN_TEXT` is the biggest of them: `petctl read` OCRs a whole monitor and
|
||||
sends the recognised text to the server — everything visible, not just the
|
||||
focused window. Two things keep it honest. It's **pull-only**: no capture
|
||||
happens unless the server explicitly asks, so it can't leak in the background
|
||||
the way a per-turn annotation would, and each read is logged. And it is
|
||||
strictly *not* a new capability — the shell relay could already run a
|
||||
screenshot tool and pipe it through OCR — it just makes a thing the trust
|
||||
model already allowed reliable, bounded (`SCREEN_TEXT_MAX_CHARS`) and
|
||||
visible. It is nonetheless far easier to reach for than the shell route, so
|
||||
if that trade isn't one you want, `SCREEN_TEXT=false` removes it and
|
||||
`petctl read` starts reporting that it's disabled. Capture is `mss`-based and
|
||||
therefore silently unavailable on Wayland.
|
||||
|
||||
@@ -69,8 +69,8 @@ limitations).
|
||||
speaks, and barging in starts your next turn immediately (`BARGE_IN`).
|
||||
- Right-click the tray icon for **Talk now**, **Mute mic**, **Nap**,
|
||||
**Wander around**, **Click through the pet**, **History…**, **Wake word
|
||||
tuning…** and **Quit** — the pet window itself has no title bar or taskbar
|
||||
entry.
|
||||
tuning…**, **Use default voice** and **Quit** — the pet window itself has
|
||||
no title bar or taskbar entry.
|
||||
- **Click the speech bubble** to copy what it just said; the tray's
|
||||
**History…** window keeps the last `HISTORY_LIMIT` turns.
|
||||
|
||||
@@ -80,8 +80,8 @@ limitations).
|
||||
listening/thinking/talking or while a bubble is up.
|
||||
- **Moves and emotes on command.** Bolt can relay `petctl move top-left`,
|
||||
`petctl emote wave|hop|spin|nod|shake`, `petctl say ...`, `petctl wander
|
||||
on|off`, `petctl nap on|off`. These are intercepted here and never reach a
|
||||
shell.
|
||||
on|off`, `petctl nap on|off`, `petctl voice reset`, and `dialoguectl` for a
|
||||
multi-voice scene. These are intercepted here and never reach a shell.
|
||||
- **Naps** during `QUIET_HOURS` (e.g. `23:00-08:00`) or while a fullscreen
|
||||
app is focused (`DND_ON_FULLSCREEN`) — it dims, stops wandering, and makes
|
||||
no proactive noise. It still answers when you speak to it.
|
||||
@@ -90,6 +90,41 @@ limitations).
|
||||
notifications get forwarded to the server, so it can tell you the deploy
|
||||
went green. Off by default: each one costs a round trip.
|
||||
|
||||
## Speaking in another voice
|
||||
|
||||
Ask for a different voice — "use a clearer voice", "talk like a pirate", "say
|
||||
that in Japanese" — and Bolt searches the ElevenLabs voice library on the
|
||||
server, picks one, and tags his reply with it (`speak_as`); the pet is what
|
||||
actually speaks in it. A Voice Library pick is added to your ElevenLabs
|
||||
account automatically the first time it's used, 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
|
||||
|
||||
`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
|
||||
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
|
||||
|
||||
```
|
||||
@@ -113,18 +175,23 @@ bolt_pet/
|
||||
state.py PetState enum + a small transition-checked state machine
|
||||
server_client.py /desk/converse, /desk/tool_result, /desk/report_status
|
||||
controller.py the pipeline: wake word -> STT -> server -> TTS, on a QThread
|
||||
speech_text.py strips markdown/emoji/URLs so the voice never says "asterisk"
|
||||
speech.py what the pet says and how each kind of saying behaves
|
||||
speech_text.py strips markdown/emoji/URLs so the voice never says "asterisk"
|
||||
pet_actions.py petctl move/emote/say/wander/nap parsing
|
||||
screen_context.py active-window title + fullscreen detection
|
||||
quiet.py quiet-hours schedule
|
||||
notifications.py desktop notification bridge (Linux/D-Bus)
|
||||
history.py rolling conversation transcript
|
||||
hotkey.py global push-to-talk (pynput, optional)
|
||||
window_state.py remembers where you left the pet
|
||||
doctor.py `--doctor` preflight: is this install going to work?
|
||||
audio/
|
||||
mic.py input stream + energy-based VAD utterance capture
|
||||
wake_word.py openWakeWord thunderbolt.onnx detection (see above)
|
||||
stt.py Deepgram
|
||||
tts.py ElevenLabs streaming PCM, offline pyttsx3 fallback
|
||||
stt.py Deepgram (one-shot)
|
||||
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
|
||||
ui/
|
||||
app.py wires QApplication + window + tray + controller thread together
|
||||
@@ -137,8 +204,10 @@ bolt_pet/
|
||||
scripts/
|
||||
slice_spritesheet.py cuts a grid sprite sheet into the per-frame convention
|
||||
tests/ pure-logic unit tests (state machine, wake-phrase
|
||||
matching, HTTP client against mocks) — nothing here
|
||||
needs real audio hardware or a display
|
||||
matching, HTTP client against mocks) plus one
|
||||
end-to-end smoke test that drives whole turns against
|
||||
a real local HTTP server — nothing here needs real
|
||||
audio hardware or a display
|
||||
```
|
||||
|
||||
## Security notes
|
||||
@@ -162,7 +231,6 @@ notifications. No screenshots or images are ever sent.
|
||||
|
||||
## 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,
|
||||
wall-climbing or falling.
|
||||
- Push-to-talk and the notification bridge are platform-limited: the hotkey
|
||||
|
||||
@@ -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.
|
||||
"""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
__version__ = "0.2.3"
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
"""Entry point: python -m bolt_pet"""
|
||||
"""Entry point: python -m bolt_pet [--doctor [--deep]]"""
|
||||
|
||||
import sys
|
||||
|
||||
from .ui.app import run
|
||||
|
||||
if __name__ == "__main__":
|
||||
if "--doctor" in sys.argv:
|
||||
# Imported inside the branch, not at module scope: the doctor exists to
|
||||
# diagnose installs where importing the UI would itself blow up.
|
||||
from .doctor import main as doctor
|
||||
|
||||
sys.exit(doctor(sys.argv[1:]))
|
||||
|
||||
from .ui.app import run
|
||||
|
||||
sys.exit(run())
|
||||
|
||||
@@ -1,37 +1,84 @@
|
||||
# Sprite assets
|
||||
|
||||
Art: [Kenney's Robot Pack](https://kenney.nl/assets/robot-pack) (CC0 — no
|
||||
attribution required, credited here anyway), the green side-view robot.
|
||||
Source pack lives at `~/Documents/kenney_robot-pack`; only the frames listed
|
||||
below were copied in.
|
||||
Art: Bolt himself — a cream shepherd pup with a slate cap, a lightning blaze
|
||||
on his forehead and a bolt tag on his collar. The frames are **generated, not
|
||||
hand-drawn**: `scripts/generate_bolt_sprites.py` draws every one of them with
|
||||
Pillow and writes this folder.
|
||||
|
||||
```bash
|
||||
python scripts/generate_bolt_sprites.py # rewrite this folder
|
||||
python scripts/generate_bolt_sprites.py --out /tmp/prev # preview elsewhere first
|
||||
python scripts/generate_bolt_sprites.py --states idle # just one state
|
||||
```
|
||||
|
||||
That means tweaking the art is editing code, not 24 PNGs: the palette is a
|
||||
block of constants at the top of the script, the body/head/ear/tail shapes are
|
||||
one function each in normalised 0..1 coordinates, and each state's animation is
|
||||
a list of pose dicts in `frames_for()`. Everything is super-sampled 4x and
|
||||
downscaled on save, because PIL's draw primitives have no antialiasing.
|
||||
|
||||
**Regenerate after editing** — the PNGs here are committed, so a change to the
|
||||
script alone doesn't move the pet.
|
||||
|
||||
Convention the loader (`bolt_pet/ui/sprite.py`) expects:
|
||||
|
||||
```
|
||||
assets/sprites/
|
||||
idle/ frame_00.png robot_greenBody (standing)
|
||||
listening/ frame_00.png, frame_01.png robot_greenDrive1/2 (tracks rolling — "leaning in")
|
||||
thinking/ frame_00.png, frame_01.png robot_greenDamage1/2 (flicker — "processing")
|
||||
talking/ frame_00.png, frame_01.png robot_greenBody, robot_greenJump (bounce)
|
||||
error/ frame_00.png robot_greenHurt
|
||||
idle/ frame_00..07.png breathing, tail wag, blink on frame 06
|
||||
listening/ frame_00..03.png ears perked, head tilted in, collar tag lit, sound arcs
|
||||
thinking/ frame_00..05.png eyes up, head cocked, cycling dots
|
||||
talking/ frame_00..03.png mouth open/close with tongue, ears bouncing
|
||||
error/ frame_00..01.png X eyes, ears drooped, red spark
|
||||
walk/ frame_00..07.png side-view walk cycle (see below)
|
||||
```
|
||||
|
||||
- One subfolder per pet state (matches `bolt_pet.state.PetState`).
|
||||
- One subfolder per pet state (matches `bolt_pet.state.PetState`), **plus
|
||||
`walk/`**, which is not a state — see below.
|
||||
- Any `*.png` filenames work — they're played back in alphabetical-sort
|
||||
order, looping, at `IDLE_ANIMATION_FPS` (see `.env`).
|
||||
- Frames are scaled to fit within `PET_SIZE` (default 160px), keeping aspect
|
||||
ratio, and centered in the (square) pet window — the source art here isn't
|
||||
square, so don't assume it fills the frame edge-to-edge.
|
||||
order, looping, at `IDLE_ANIMATION_FPS` (see `.env`). At the default 6fps
|
||||
the 8-frame idle loop runs about 1.3s.
|
||||
- Frames are square (320px, 2x the default `PET_SIZE` of 160) so they
|
||||
downscale cleanly; the loader scales to fit `PET_SIZE` keeping aspect ratio
|
||||
and centres them in the square pet window.
|
||||
- A state directory with no frames in it falls back to a small
|
||||
procedurally-drawn placeholder blob (see `_placeholder_frames` in
|
||||
`sprite.py`).
|
||||
|
||||
## The walk cycle
|
||||
|
||||
`walk/` is the one animation that isn't a `PetState`. Walking is a property of
|
||||
*movement* — orthogonal to whether he's idle, listening or talking — so it
|
||||
stays out of the state machine and is keyed by name instead
|
||||
(`sprite.EXTRA_ANIMATIONS`). `PetWindow` uses it whenever the pet is actually
|
||||
travelling and falls back to the state animation the moment it stops.
|
||||
|
||||
Three things about it are load-bearing if you redraw it:
|
||||
|
||||
- **It's a side view, drawn facing right.** The other poses are a
|
||||
front-facing sit, which is fine standing still but slides like a chess
|
||||
piece when moving. `PetWindow._oriented()` mirrors the frames (cached) when
|
||||
he walks left, so only the right-facing version exists on disk.
|
||||
- **The cycle is advanced by distance travelled, not by the animation
|
||||
timer** (`_WALK_PIXELS_PER_FRAME`, one frame per ~13px). That's what keeps
|
||||
a planted paw tracking backwards at exactly the speed the window moves
|
||||
forwards. Drive it off the clock and the feet skate whenever
|
||||
`PET_WANDER_SPEED` doesn't happen to match `IDLE_ANIMATION_FPS`. If you
|
||||
change the number of frames or the stride length in
|
||||
`paw_position()`, retune that constant to match or he'll moonwalk.
|
||||
- **The frames carry their own vertical bob**, so the window's own bob is
|
||||
switched off while they're in use. Only the no-walk-art fallback still
|
||||
bobs in code.
|
||||
|
||||
Delete `walk/` and everything still runs — he reverts to sliding with a small
|
||||
coded bob, which is what the pet did before the cycle existed.
|
||||
|
||||
## Swapping in different art
|
||||
|
||||
Replace any state's PNGs (same alphabetical-order-loops convention) to
|
||||
change its look — no code changes needed. If your source is a single grid
|
||||
spritesheet (rows/cols of frames in one PNG) rather than one-file-per-frame,
|
||||
use `scripts/slice_spritesheet.py` to cut it into this folder-of-frames
|
||||
change its look — no code changes needed, and nothing forces you to keep
|
||||
using the generator. If your source is a single grid spritesheet (rows/cols
|
||||
of frames in one PNG) rather than one-file-per-frame, use
|
||||
`scripts/slice_spritesheet.py` to cut it into this folder-of-frames
|
||||
convention:
|
||||
|
||||
```bash
|
||||
|
||||
|
Before Width: | Height: | Size: 5.6 KiB After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 65 KiB |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 63 KiB |
|
After Width: | Height: | Size: 63 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 43 KiB |
@@ -37,6 +37,20 @@ def rms(frame: np.ndarray) -> float:
|
||||
return float(np.sqrt(np.mean(frame.astype(np.float64) ** 2)))
|
||||
|
||||
|
||||
def _emit(on_frame, frame) -> None:
|
||||
"""Hand a frame to a listener without letting it break the capture.
|
||||
|
||||
Streaming STT is an optimisation riding along with recording; if its
|
||||
socket dies mid-utterance the recording must carry on untouched, because
|
||||
the one-shot fallback is about to need the full buffer."""
|
||||
if on_frame is None:
|
||||
return
|
||||
try:
|
||||
on_frame(frame)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def record_utterance(
|
||||
stream: AudioStream,
|
||||
should_continue=lambda: True,
|
||||
@@ -47,6 +61,7 @@ def record_utterance(
|
||||
grace_s: float = None,
|
||||
frame_len: int = config.FRAME_LEN,
|
||||
sample_rate: int = config.SAMPLE_RATE,
|
||||
on_frame=None,
|
||||
) -> Optional[np.ndarray]:
|
||||
"""Capture one utterance from *stream*: wait for speech to start, stop
|
||||
after trailing silence. Returns None if nothing usable was heard.
|
||||
@@ -55,6 +70,11 @@ def record_utterance(
|
||||
(e.g. the pet window was closed) without needing threading primitives
|
||||
baked into this function.
|
||||
|
||||
*on_frame*, if given, is called with each captured frame while speech is
|
||||
in progress — that is how streaming STT transcribes as you talk rather
|
||||
than after (see audio/stt_stream.py). It is fire-and-forget: this function
|
||||
still returns the full buffer, so a failed stream costs nothing.
|
||||
|
||||
*grace_s* is how long to wait for speech to *begin* before giving up.
|
||||
The controller stretches it for follow-up questions, where you're being
|
||||
asked something and need a moment to think rather than having just said
|
||||
@@ -83,10 +103,12 @@ def record_utterance(
|
||||
if frame_rms >= rms_threshold:
|
||||
started = True
|
||||
frames.append(frame)
|
||||
_emit(on_frame, frame)
|
||||
elif waited > grace_frames:
|
||||
return None # woke it up but said nothing
|
||||
continue
|
||||
frames.append(frame)
|
||||
_emit(on_frame, frame)
|
||||
if frame_rms < rms_threshold:
|
||||
silence_frames += 1
|
||||
if silence_frames >= silence_limit:
|
||||
|
||||
@@ -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"))
|
||||
@@ -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
|
||||
on macOS, espeak on Linux) if ElevenLabs isn't configured or the request
|
||||
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 typing import Iterable, Iterator
|
||||
import time
|
||||
from typing import Iterable, Iterator, Optional
|
||||
|
||||
import numpy as np
|
||||
import requests
|
||||
@@ -21,18 +27,40 @@ class TtsError(Exception):
|
||||
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 —
|
||||
callers should fall back to speak_offline() rather than treating this
|
||||
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")
|
||||
try:
|
||||
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},
|
||||
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,
|
||||
)
|
||||
response.raise_for_status()
|
||||
@@ -44,20 +72,23 @@ def synthesize_pcm(text: str) -> tuple[np.ndarray, int]:
|
||||
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
|
||||
ElevenLabs' /stream endpoint so playback can start on the first chunk
|
||||
(~300ms) instead of after the whole clip is synthesized. Raises TtsError
|
||||
before yielding anything if the request itself fails, so callers can fall
|
||||
back cleanly; a mid-stream failure just ends the generator."""
|
||||
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")
|
||||
try:
|
||||
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},
|
||||
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,
|
||||
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)
|
||||
|
||||
|
||||
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*
|
||||
(barge-in) cut it short. *should_stop* is polled while audio plays — each
|
||||
poll consumes one mic frame, which is what paces this loop."""
|
||||
import sounddevice as sd
|
||||
|
||||
levels = envelope(pcm, sample_rate) if on_level is not None else []
|
||||
started = time.monotonic()
|
||||
sd.play(pcm, samplerate=sample_rate, device=config.SPEAKER_DEVICE)
|
||||
if not blocking:
|
||||
return True
|
||||
if should_stop is None:
|
||||
if should_stop is None and on_level is None:
|
||||
sd.wait()
|
||||
return True
|
||||
while True:
|
||||
if levels:
|
||||
# Indexed by elapsed time rather than by chunk, because this path
|
||||
# hands the whole clip to the device at once and never sees it
|
||||
# again — wall clock is the only position we have.
|
||||
index = int((time.monotonic() - started) * 30)
|
||||
if index < len(levels):
|
||||
try:
|
||||
on_level(levels[index])
|
||||
except Exception:
|
||||
levels = []
|
||||
try:
|
||||
if not sd.get_stream().active:
|
||||
break
|
||||
except Exception:
|
||||
break # stream already torn down — playback is over
|
||||
if should_stop():
|
||||
if should_stop is not None and should_stop():
|
||||
sd.stop()
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def play_stream(chunks: Iterable[np.ndarray], sample_rate: int, should_stop=None) -> bool:
|
||||
"""Play int16 chunks as they arrive. Returns False if interrupted."""
|
||||
def play_stream(chunks: Iterable[np.ndarray], sample_rate: int, should_stop=None,
|
||||
on_level=None) -> bool:
|
||||
"""Play int16 chunks as they arrive. Returns False if interrupted.
|
||||
|
||||
*on_level* receives each chunk's loudness (0..1) just before it is written,
|
||||
which is what drives the mouth: the sprite is animated by the same audio
|
||||
the speakers are getting, not by a guess about how long a word takes."""
|
||||
import sounddevice as sd
|
||||
|
||||
with sd.OutputStream(
|
||||
@@ -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.
|
||||
out.abort()
|
||||
return False
|
||||
if on_level is not None:
|
||||
try:
|
||||
on_level(level_of(chunk))
|
||||
except Exception:
|
||||
on_level = None # a broken listener must not stop playback
|
||||
out.write(chunk)
|
||||
return True
|
||||
|
||||
@@ -134,11 +268,13 @@ def speak_offline(text: str) -> None:
|
||||
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
|
||||
ElevenLabs, then offline TTS. *on_error*, if given, is called with the
|
||||
exception when ElevenLabs fails (useful for logging) — a fallback still
|
||||
runs either way. Returns False if barge-in interrupted playback.
|
||||
*voice_id* overrides the configured voice for this line only.
|
||||
|
||||
The text is sanitized first (speech_text.for_speech): server replies are
|
||||
written for a chat window, and a voice reads markdown/emoji literally
|
||||
@@ -149,13 +285,18 @@ def speak(text: str, on_error=None, should_stop=None) -> bool:
|
||||
return True
|
||||
if config.TTS_STREAMING:
|
||||
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:
|
||||
if on_error is not None:
|
||||
on_error(exc)
|
||||
try:
|
||||
pcm, sample_rate = synthesize_pcm(text)
|
||||
return play_pcm(pcm, sample_rate, should_stop=should_stop)
|
||||
pcm, sample_rate = synthesize_pcm(text, voice_id=voice_id)
|
||||
return play_pcm(pcm, sample_rate, should_stop=should_stop, on_level=on_level)
|
||||
except TtsError as exc:
|
||||
if on_error is not None:
|
||||
on_error(exc)
|
||||
|
||||
@@ -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_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
|
||||
# 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_VOICE_ID = os.environ.get("ELEVENLABS_VOICE_ID", "")
|
||||
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>.
|
||||
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_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.
|
||||
|
||||
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"))
|
||||
|
||||
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"))
|
||||
HEARTBEAT_INTERVAL_SECONDS = float(os.environ.get("HEARTBEAT_INTERVAL_SECONDS", "60"))
|
||||
|
||||
# ── self-restart ────────────────────────────────────────────────────────────
|
||||
# `petctl self_restart` lets Bolt restart the pet after editing its code, so
|
||||
# he can see his own change running instead of waiting for someone to restart
|
||||
# it by hand. The code is import-checked in a subprocess first, and the reason
|
||||
# is carried across the restart so the new process can report back — see
|
||||
# self_restart.py. SELF_RESTART_MAX/_WINDOW_SECONDS bound the crash-loop case.
|
||||
SELF_RESTART = os.environ.get("SELF_RESTART", "true").lower() in ("1", "true", "yes", "on")
|
||||
|
||||
# ── barge-in (interrupt playback while the pet is talking) ──────────────────
|
||||
# The mic stays live while the pet talks. BARGE_IN_MODE decides what counts
|
||||
# as an interruption:
|
||||
@@ -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).
|
||||
BARGE_IN_WAKE_THRESHOLD = float(os.environ.get("BARGE_IN_WAKE_THRESHOLD") or 0) or None
|
||||
|
||||
# ── streaming the reply ─────────────────────────────────────────────────────
|
||||
# Speak each sentence as the server produces it, instead of waiting out the
|
||||
# whole model call before the first word. Falls back to the ordinary
|
||||
# request/response path automatically if the server has no streaming endpoint
|
||||
# or the stream fails before anything has been spoken.
|
||||
STREAMING_REPLIES = os.environ.get("STREAMING_REPLIES", "true").lower() in ("1", "true", "yes", "on")
|
||||
|
||||
# ── streaming TTS ───────────────────────────────────────────────────────────
|
||||
# ElevenLabs' /stream endpoint + chunked playback: the pet starts talking
|
||||
# after the first PCM chunk instead of after the whole clip is synthesized.
|
||||
@@ -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")
|
||||
|
||||
# ── monitors ────────────────────────────────────────────────────────────────
|
||||
# A one-line note about the screen layout (how many, their sizes, which one
|
||||
# the pet is standing on) rides along with each utterance, so Bolt can decide
|
||||
# to `petctl jump` somewhere without asking you what you've got plugged in.
|
||||
# Cheap — the list comes from the UI, nothing is probed per turn.
|
||||
|
||||
MONITOR_CONTEXT = os.environ.get("MONITOR_CONTEXT", "true").lower() in (
|
||||
"1", "true", "yes", "on"
|
||||
)
|
||||
|
||||
# ── screen text (OCR) ───────────────────────────────────────────────────────
|
||||
# Lets Bolt actually read a monitor, via `petctl read`. Pull-only: nothing is
|
||||
# captured unless the server asks for it, and every read is logged. Needs the
|
||||
# optional capture/OCR extras — see the comments in requirements.txt.
|
||||
#
|
||||
# This widens what can leave the machine more than any other switch here: the
|
||||
# recognised text of a whole screen goes to the server. It is *not* a new
|
||||
# capability (the shell relay could already run a screenshot tool and OCR it),
|
||||
# but it is a much easier one to use by accident. Set SCREEN_TEXT=false to
|
||||
# take it away entirely.
|
||||
|
||||
SCREEN_TEXT = os.environ.get("SCREEN_TEXT", "true").lower() in ("1", "true", "yes", "on")
|
||||
SCREEN_TEXT_MAX_CHARS = int(os.environ.get("SCREEN_TEXT_MAX_CHARS", "4000"))
|
||||
|
||||
# ── quiet hours / do-not-disturb ────────────────────────────────────────────
|
||||
# Comma-separated HH:MM-HH:MM ranges (wrapping midnight is fine). While
|
||||
# napping the pet dims, stops wandering, and makes no proactive noise —
|
||||
@@ -161,6 +234,17 @@ NOTIFICATION_BRIDGE = os.environ.get("NOTIFICATION_BRIDGE", "false").lower() in
|
||||
NOTIFICATION_FILTER = os.environ.get("NOTIFICATION_FILTER", "")
|
||||
NOTIFICATION_MIN_INTERVAL_SECONDS = float(os.environ.get("NOTIFICATION_MIN_INTERVAL_SECONDS", "60"))
|
||||
|
||||
# ── file delivery ────────────────────────────────────────────────────────
|
||||
# The server's deliver_files tool (ai/desk_api.py in the main tmn-api repo)
|
||||
# queues workspace files on this session — e.g. "send me that report" — for
|
||||
# the client to fetch via GET /desk/files. Downloading a file dequeues it
|
||||
# server-side, so each one lands here exactly once.
|
||||
|
||||
RECEIVE_FILES = os.environ.get("RECEIVE_FILES", "true").lower() in ("1", "true", "yes", "on")
|
||||
DELIVERED_FILES_DIR = Path(
|
||||
os.environ.get("DELIVERED_FILES_DIR") or str(Path.home() / "Downloads" / "Bolt")
|
||||
).expanduser()
|
||||
|
||||
# ── conversation history ────────────────────────────────────────────────────
|
||||
|
||||
HISTORY_LIMIT = int(os.environ.get("HISTORY_LIMIT", "100"))
|
||||
@@ -235,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_CLICK_THROUGH = os.environ.get("PET_CLICK_THROUGH", "false").lower() in ("1", "true", "yes", "on")
|
||||
# Snap flush to a screen edge when dropped/parked within this many pixels of it.
|
||||
# Start where it was left rather than in the bottom-right corner. Ignored when
|
||||
# PET_START_X/Y pin it explicitly, and a saved position on a monitor that is no
|
||||
# longer plugged in is discarded rather than hiding the pet offscreen.
|
||||
PET_REMEMBER_POSITION = os.environ.get("PET_REMEMBER_POSITION", "true").lower() in ("1", "true", "yes", "on")
|
||||
PET_EDGE_SNAP = os.environ.get("PET_EDGE_SNAP", "true").lower() in ("1", "true", "yes", "on")
|
||||
PET_SNAP_MARGIN = int(os.environ.get("PET_SNAP_MARGIN", "48"))
|
||||
|
||||
|
||||
@@ -20,12 +20,20 @@ from typing import Optional
|
||||
from PySide6.QtCore import QObject, Signal
|
||||
|
||||
from . import (
|
||||
config, history as history_mod, notifications, pet_actions, quiet,
|
||||
screen_context, server_client, speech_text, updater,
|
||||
config, dialogue as dialogue_mod, speech, file_delivery, file_ops,
|
||||
history as history_mod, monitors as monitors_mod, notifications,
|
||||
pet_actions, quiet, screen_context, screen_text, self_restart,
|
||||
server_client, speech_text, updater,
|
||||
)
|
||||
from .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
|
||||
|
||||
# A burst while the pet is busy is batched into one turn, but the queue still
|
||||
# needs a ceiling — a notification storm must not become an unbounded backlog
|
||||
# that gets read out minutes later.
|
||||
_MAX_PENDING_NOTIFICATIONS = 12
|
||||
|
||||
# How often to re-check whether the pet should be napping. The fullscreen
|
||||
# probe shells out to xprop, so this deliberately isn't every heartbeat tick.
|
||||
_NAP_CHECK_INTERVAL_SECONDS = 10.0
|
||||
@@ -37,6 +45,8 @@ class PetController(QObject):
|
||||
log = Signal(str)
|
||||
action = Signal(dict) # parsed petctl action for the UI to perform
|
||||
napping = Signal(bool) # quiet hours / fullscreen do-not-disturb
|
||||
voice_changed = Signal(str) # name of the server-picked voice ("" = default)
|
||||
mouth = Signal(float) # 0..1 speech loudness, for lip-sync while talking
|
||||
restart_requested = Signal(str) # version we just updated to
|
||||
finished = Signal()
|
||||
|
||||
@@ -53,12 +63,37 @@ class PetController(QObject):
|
||||
# window. Append-only from this thread; the UI only ever snapshots it.
|
||||
self.history = history_mod.ConversationHistory(limit=config.HISTORY_LIMIT)
|
||||
|
||||
# The screen layout, as published by the UI (see set_monitors). Held
|
||||
# here rather than probed, so "monitor 2" means the same thing to the
|
||||
# controller and to the window that has to jump there — see
|
||||
# monitors.py for why that matters.
|
||||
self._monitors: list[monitors_mod.Monitor] = []
|
||||
self._pet_monitor: Optional[int] = None
|
||||
|
||||
# Wake-word sensitivity is live-tunable (tray tuner), so it's read
|
||||
# through a callable on every frame rather than captured per listen.
|
||||
self._wake_threshold = config.WAKE_WORD_THRESHOLD
|
||||
self._near_misses = wake_word.NearMissLog()
|
||||
|
||||
# The voice the server last picked for us with `speak_as` ("" = the
|
||||
# configured default). Held here rather than passed straight through
|
||||
# to one tts.speak() call because it's sticky by default — see
|
||||
# _apply_voice for why.
|
||||
self._voice_id = ""
|
||||
self._voice_name = ""
|
||||
|
||||
self._barge_in: Optional[barge_in.BargeInDetector] = None
|
||||
# Everything the pet says goes through here; see speech.py for why the
|
||||
# four hand-rolled copies of this became one.
|
||||
self._speaker = speech.Speaker(
|
||||
state=self._state, tts=tts,
|
||||
history=lambda text: self.history.add(history_mod.PET, text, time.time()),
|
||||
on_said=self.said.emit, on_log=self.log.emit,
|
||||
barge_in=lambda: self._barge_in,
|
||||
detail_of=self._barge_in_detail,
|
||||
voice_id=lambda: self._voice_id,
|
||||
on_level=self.mouth.emit,
|
||||
)
|
||||
self._napping = False
|
||||
self._nap_forced: Optional[bool] = None # petctl nap on/off overrides the schedule
|
||||
self._last_nap_check = 0.0
|
||||
@@ -72,6 +107,9 @@ class PetController(QObject):
|
||||
|
||||
self._last_update_check = 0.0
|
||||
self._update_pending = False # applied on disk, waiting for the restart
|
||||
# Armed by `petctl self_restart`, fired after the turn it was asked in
|
||||
# (see _arm_self_restart for why it can't happen inline).
|
||||
self._restart_context = None
|
||||
|
||||
self._notification_watcher: Optional[notifications.NotificationWatcher] = None
|
||||
self._notification_gate = notifications.NotificationGate(
|
||||
@@ -109,6 +147,20 @@ class PetController(QObject):
|
||||
def reset_wake_stats(self) -> None:
|
||||
self._near_misses.clear()
|
||||
|
||||
def current_voice(self) -> str:
|
||||
"""Name (or id) of the server-picked voice in use, "" for the default."""
|
||||
return self._voice_name or self._voice_id
|
||||
|
||||
def reset_voice(self) -> None:
|
||||
"""Drop a server-picked voice and go back to Bolt's own. The tray's
|
||||
way out of a voice you didn't want to keep — the server has no way to
|
||||
ask for the default back, since it never learns what it is."""
|
||||
if not self._voice_id:
|
||||
return
|
||||
self._voice_id = self._voice_name = ""
|
||||
self.log.emit("Voice: back to the default.")
|
||||
self.voice_changed.emit("")
|
||||
|
||||
def stop(self) -> None:
|
||||
self._running = False
|
||||
self._talk_now.set() # wake up anything blocked waiting on it
|
||||
@@ -159,6 +211,7 @@ class PetController(QObject):
|
||||
except Exception as exc:
|
||||
self.log.emit(f"Server not reachable yet ({exc}) — will keep trying per-request.")
|
||||
self._start_notification_bridge()
|
||||
self._report_self_restart()
|
||||
self._loop()
|
||||
if self._notification_watcher is not None:
|
||||
self._notification_watcher.stop()
|
||||
@@ -213,21 +266,28 @@ class PetController(QObject):
|
||||
self._follow_ups = 0
|
||||
|
||||
self._state.transition(PetState.LISTENING)
|
||||
# Transcribe while they talk rather than after: frames go to Deepgram
|
||||
# as they are captured, so the text is ready the moment the VAD says
|
||||
# they stopped. None here just means the one-shot path will do it.
|
||||
streamed = stt_stream.StreamingTranscriber.open()
|
||||
pcm = mic.record_utterance(
|
||||
self._stream,
|
||||
should_continue=self._should_continue,
|
||||
# Answering a question deserves longer than saying the wake word
|
||||
# on purpose does — you were just asked something.
|
||||
grace_s=config.FOLLOW_UP_GRACE_SECONDS if following_up else None,
|
||||
on_frame=streamed.feed if streamed is not None else None,
|
||||
)
|
||||
if pcm is None:
|
||||
if streamed is not None:
|
||||
streamed.finish()
|
||||
self._follow_ups = 0 # silence ends the chain
|
||||
self._state.transition(PetState.IDLE)
|
||||
return
|
||||
|
||||
self._state.transition(PetState.THINKING)
|
||||
try:
|
||||
text = stt.transcribe(pcm)
|
||||
text = self._transcribe(pcm, streamed)
|
||||
except stt.SttError as exc:
|
||||
self.log.emit(f"STT failed: {exc}")
|
||||
self._state.transition(PetState.ERROR)
|
||||
@@ -242,61 +302,351 @@ class PetController(QObject):
|
||||
try:
|
||||
# What's focused right now rides along, so "what's this error?"
|
||||
# has a referent without you having to describe the window.
|
||||
reply = server_client.converse(
|
||||
screen_context.context_for(text), on_command=self._handle_command
|
||||
)
|
||||
reply = self._ask_server(self._with_context(text))
|
||||
except server_client.ServerError as exc:
|
||||
self.log.emit(f"Server error: {exc}")
|
||||
self._state.transition(PetState.ERROR)
|
||||
self._state.transition(PetState.IDLE)
|
||||
return
|
||||
|
||||
self._speak(reply)
|
||||
self._check_deliveries()
|
||||
self._apply_voice(reply)
|
||||
if reply.spoken:
|
||||
# Streamed: every sentence was spoken and logged as it arrived.
|
||||
# The text still matters — a reply ending on a question should keep
|
||||
# the mic open — but saying it again would repeat the whole answer.
|
||||
self._after_speaking(reply.text, completed=True)
|
||||
else:
|
||||
self._speak(reply.text)
|
||||
self._state.transition(PetState.IDLE)
|
||||
self._maybe_self_restart()
|
||||
|
||||
def _transcribe(self, pcm, streamed) -> str:
|
||||
"""The transcript, from the live stream if it produced one.
|
||||
|
||||
The fallback is not a rare path to be tolerated — it is the safety net
|
||||
that lets streaming be switched on at all. Whatever happened to the
|
||||
socket, the full audio is still buffered here, so a failed stream costs
|
||||
one ordinary upload and nothing else."""
|
||||
if streamed is not None:
|
||||
try:
|
||||
text = streamed.finish()
|
||||
except Exception:
|
||||
self.log.emit("Streaming STT failed — falling back.")
|
||||
text = ""
|
||||
if text:
|
||||
self.log.emit("(transcribed while you spoke)")
|
||||
return text
|
||||
return stt.transcribe(pcm)
|
||||
|
||||
def _with_context(self, text: str) -> str:
|
||||
"""Everything the server gets alongside what you actually said: the
|
||||
focused window title, and a one-line note about the screen layout so
|
||||
Bolt knows how many monitors there are and where he's standing
|
||||
without having to ask. Only the *layout* rides along for free — the
|
||||
text on those screens costs an OCR pass, so it stays behind
|
||||
`petctl read`."""
|
||||
text = screen_context.context_for(text)
|
||||
if config.MONITOR_CONTEXT:
|
||||
text = monitors_mod.annotate(text, self._monitors, self._pet_monitor)
|
||||
return text
|
||||
|
||||
# ── screen layout, published by the UI ───────────────────────────────
|
||||
|
||||
def set_monitors(self, monitors: list) -> None:
|
||||
"""Slot: the window telling us what screens exist (queued signal)."""
|
||||
self._monitors = list(monitors)
|
||||
self.log.emit(
|
||||
"Screens: " + (monitors_mod.summary(self._monitors) or "none reported")
|
||||
)
|
||||
|
||||
def set_pet_monitor(self, index: int) -> None:
|
||||
"""Slot: the window telling us which screen the pet is standing on."""
|
||||
self._pet_monitor = int(index)
|
||||
|
||||
def _handle_command(self, command: str) -> str:
|
||||
"""Server-relayed command. `petctl ...` drives the pet's body and
|
||||
never reaches a shell; everything else is a real command, exactly as
|
||||
before (see the security notes in the README)."""
|
||||
`filectl ...` does local file read/write/edit — neither ever reaches
|
||||
a shell; everything else is a real command, exactly as before (see
|
||||
the security notes in the README)."""
|
||||
try:
|
||||
action = pet_actions.parse(command)
|
||||
except pet_actions.ActionError as exc:
|
||||
self.log.emit(f"petctl: {exc}")
|
||||
return f"[pet] {exc}"
|
||||
if action is None:
|
||||
return server_client.run_local_command(command)
|
||||
self.log.emit(f"Pet action: {action}")
|
||||
if action["action"] == "nap":
|
||||
self.set_napping(bool(action["enabled"]))
|
||||
self.action.emit(action)
|
||||
return pet_actions.describe(action)
|
||||
if action is not None:
|
||||
self.log.emit(f"Pet action: {action}")
|
||||
|
||||
# Queries answer from here rather than from pet_actions.describe():
|
||||
# their output *is* the useful part, and it's what the server reads
|
||||
# back off the tool-result relay.
|
||||
kind = action["action"]
|
||||
if kind == "monitors":
|
||||
return monitors_mod.describe(self._monitors, self._pet_monitor)
|
||||
if kind == "self_restart":
|
||||
return self._arm_self_restart(action.get("reason") or "")
|
||||
if kind == "voice":
|
||||
# Answered here, not by describe(): the UI has no part in it,
|
||||
# and the server needs to hear whether there was anything to
|
||||
# drop — it can't see which voice we're using.
|
||||
previous = self.current_voice()
|
||||
self.reset_voice()
|
||||
return (
|
||||
f"[pet] back to your own voice (was {previous})" if previous
|
||||
else "[pet] already using your own voice"
|
||||
)
|
||||
if kind == "read":
|
||||
return self._read_screen(action["target"])
|
||||
if kind == "jump":
|
||||
try:
|
||||
target = monitors_mod.resolve(
|
||||
self._monitors, action["target"], self._pet_monitor
|
||||
)
|
||||
except ValueError as exc:
|
||||
self.log.emit(f"petctl jump: {exc}")
|
||||
return f"[pet] {exc}"
|
||||
# Hand the window a resolved index, so it can't re-resolve the
|
||||
# spec against a different screen ordering.
|
||||
self.action.emit({"action": "jump", "monitor": target.index})
|
||||
return f"[pet] jumped to monitor {target.label}"
|
||||
|
||||
if kind == "nap":
|
||||
self.set_napping(bool(action["enabled"]))
|
||||
self.action.emit(action)
|
||||
return pet_actions.describe(action)
|
||||
|
||||
try:
|
||||
scene = dialogue_mod.parse(command)
|
||||
except dialogue_mod.DialogueError as exc:
|
||||
self.log.emit(f"dialoguectl: {exc}")
|
||||
return f"[dialogue] {exc}"
|
||||
if scene is not None:
|
||||
return self._play_dialogue(scene)
|
||||
|
||||
try:
|
||||
file_action = file_ops.parse(command)
|
||||
except file_ops.FileOpError as exc:
|
||||
self.log.emit(f"filectl: {exc}")
|
||||
return f"[filectl] {exc}"
|
||||
if file_action is not None:
|
||||
self.log.emit(file_ops.describe(file_action))
|
||||
try:
|
||||
return file_ops.execute(file_action)
|
||||
except file_ops.FileOpError as exc:
|
||||
self.log.emit(f"filectl: {exc}")
|
||||
return f"[filectl] {exc}"
|
||||
|
||||
return server_client.run_local_command(command)
|
||||
|
||||
def _read_screen(self, target: str) -> str:
|
||||
"""`petctl read` — OCR a screen and hand the text back to the server."""
|
||||
if not config.SCREEN_TEXT:
|
||||
return "[pet] screen reading is disabled (set SCREEN_TEXT=true in .env)"
|
||||
if not self._monitors:
|
||||
return "[pet] no monitor information available"
|
||||
limit = config.SCREEN_TEXT_MAX_CHARS
|
||||
if target in ("all", "everything", "*"):
|
||||
self.log.emit(f"Reading all {len(self._monitors)} screens…")
|
||||
return screen_text.read_monitors(self._monitors, limit)
|
||||
if target in ("here", "", "this", "current"):
|
||||
index = self._pet_monitor if self._pet_monitor is not None else 0
|
||||
monitor = self._monitors[min(index, len(self._monitors) - 1)]
|
||||
else:
|
||||
try:
|
||||
monitor = monitors_mod.resolve(
|
||||
self._monitors, target, self._pet_monitor
|
||||
)
|
||||
except ValueError as exc:
|
||||
return f"[pet] {exc}"
|
||||
self.log.emit(f"Reading monitor {monitor.number} ({monitor.name})…")
|
||||
return screen_text.read_monitor(monitor, limit)
|
||||
|
||||
def _arm_self_restart(self, reason: str) -> str:
|
||||
"""`petctl self_restart` — check the code, then arm a restart.
|
||||
|
||||
Nothing restarts here. The tool result has to get back up the relay
|
||||
before this process can die (otherwise the server waits out its
|
||||
timeout on a turn that will never finish), so the restart is armed and
|
||||
`_maybe_self_restart` fires it once the turn has been spoken. The
|
||||
preflight import runs *now*, in this turn, so a syntax error Bolt just
|
||||
introduced comes back as something he can read and fix rather than as
|
||||
a pet that never comes back."""
|
||||
if not config.SELF_RESTART:
|
||||
return "[pet] self-restart is disabled on this device (SELF_RESTART=false)"
|
||||
if self._restart_context is not None:
|
||||
return "[pet] a restart is already armed for the end of this turn"
|
||||
try:
|
||||
self_restart.check_loop_guard(self_restart.load())
|
||||
self.log.emit("Self-restart requested — checking the code imports first…")
|
||||
self_restart.preflight()
|
||||
except self_restart.RestartError as exc:
|
||||
self.log.emit(f"Self-restart refused: {exc}")
|
||||
return f"[pet] restart refused — {exc}"
|
||||
|
||||
recent = [entry.text[:120] for entry in self.history.entries()[-4:]]
|
||||
self._restart_context = self_restart.arm(
|
||||
reason or "no reason given",
|
||||
verify=reason,
|
||||
version=__version__,
|
||||
session=config.SESSION_ID,
|
||||
recent=recent,
|
||||
)
|
||||
self.log.emit("Self-restart armed; it happens after this turn.")
|
||||
return (
|
||||
"[pet] code imports cleanly; restarting as soon as this turn finishes. "
|
||||
"I'll come back and tell you what version I'm on and what I found — "
|
||||
"wrap up your reply now, the next thing you hear from me is the report."
|
||||
)
|
||||
|
||||
def _maybe_self_restart(self) -> bool:
|
||||
"""Fire an armed restart, once the turn is over and the reply spoken.
|
||||
|
||||
Returns True if a restart was requested, so the caller can stop
|
||||
driving the pipeline — the process is on its way out."""
|
||||
if self._restart_context is None:
|
||||
return False
|
||||
self._update_pending = True # same latch the updater uses: no double restart
|
||||
self.log.emit("Restarting now.")
|
||||
self._state.force(PetState.IDLE)
|
||||
self.restart_requested.emit(f"self-restart: {self._restart_context.reason[:60]}")
|
||||
return True
|
||||
|
||||
def _report_self_restart(self) -> None:
|
||||
"""On the way up: tell the server we're back, and why we left.
|
||||
|
||||
Runs once, before the listen loop starts, and only when a context file
|
||||
was left behind. The report goes through the ordinary conversation
|
||||
path, so Bolt's answer is spoken out loud like any other turn — which
|
||||
is what makes "restart and check the sprites load" finish as a
|
||||
sentence instead of a silence."""
|
||||
context = self_restart.load()
|
||||
if context is None:
|
||||
return
|
||||
self_restart.clear()
|
||||
message = self_restart.report(context, version=__version__)
|
||||
self.log.emit(f"Back from a self-restart ({context.reason[:80]}).")
|
||||
self.history.add(history_mod.SYSTEM, message, time.time())
|
||||
try:
|
||||
reply = server_client.converse(message, on_command=self._handle_command)
|
||||
except server_client.ServerError as exc:
|
||||
# The restart still worked; only the report failed. Say so locally
|
||||
# rather than pretending nothing happened.
|
||||
self.log.emit(f"Couldn't report the restart to the server: {exc}")
|
||||
return
|
||||
self._apply_voice(reply)
|
||||
if reply.text.strip() and not self._napping:
|
||||
self._speak(reply.text)
|
||||
self._state.force(PetState.IDLE)
|
||||
|
||||
def _ask_server(self, text: str):
|
||||
"""One turn with the server, streamed when possible.
|
||||
|
||||
Streaming speaks each sentence as it is generated, so the wait is
|
||||
time-to-first-sentence rather than the whole model call. It falls back
|
||||
to the ordinary request/response path when the server has no streaming
|
||||
endpoint, or when a stream dies *before* anything was spoken — after
|
||||
that, retrying would say the first half twice."""
|
||||
if config.STREAMING_REPLIES:
|
||||
try:
|
||||
return server_client.converse_stream(
|
||||
text, on_say=self._speak_stream_chunk,
|
||||
on_command=self._handle_command,
|
||||
)
|
||||
except server_client.ServerError as exc:
|
||||
self.log.emit(f"Streaming unavailable ({exc}) — using the plain path.")
|
||||
return server_client.converse(
|
||||
text, on_command=self._handle_command, on_say=self._speak_holding,
|
||||
)
|
||||
|
||||
def _play_dialogue(self, scene: dict) -> str:
|
||||
"""Play a `dialoguectl` scene and report back up the relay.
|
||||
|
||||
This runs *mid-turn* (the server is still waiting on the tool result),
|
||||
so the pet has to look like it's talking and then go back to waiting —
|
||||
hence the TALKING → THINKING leg rather than the usual return to IDLE.
|
||||
Everything a normal reply gets, a scene gets too: the bubble, the
|
||||
transcript, and barge-in, so a long scene can be talked over exactly
|
||||
like a long answer."""
|
||||
if not config.DIALOGUE:
|
||||
return "[dialogue] disabled on this device (DIALOGUE=false)"
|
||||
try:
|
||||
inputs = dialogue_mod.resolve(
|
||||
scene,
|
||||
voices=dialogue_mod.parse_voice_map(config.DIALOGUE_VOICES),
|
||||
self_voice=self._voice_id or config.ELEVENLABS_VOICE_ID,
|
||||
)
|
||||
except dialogue_mod.DialogueError as exc:
|
||||
self.log.emit(f"dialoguectl: {exc}")
|
||||
return f"[dialogue] {exc}"
|
||||
|
||||
text = dialogue_mod.spoken_text(scene)
|
||||
self.log.emit(f"Dialogue ({len(inputs)} lines): {text[:120]}")
|
||||
try:
|
||||
pcm, sample_rate = tts.synthesize_dialogue(
|
||||
inputs, model_id=scene.get("model"), stability=scene.get("stability")
|
||||
)
|
||||
except tts.TtsError as exc:
|
||||
self.log.emit(f"Dialogue failed: {exc}")
|
||||
# Reported, not raised: the server can read this, shorten the
|
||||
# scene or fix the voice, and try again inside the same turn.
|
||||
return f"[dialogue] couldn't synthesize it: {exc}"
|
||||
|
||||
completed = self._speaker.say_pcm(
|
||||
speech.Utterance.scene(text), pcm=pcm, sample_rate=sample_rate)
|
||||
|
||||
if not completed:
|
||||
return dialogue_mod.describe(scene) + " (interrupted — they talked over it)"
|
||||
return dialogue_mod.describe(scene)
|
||||
|
||||
def _apply_voice(self, reply) -> None:
|
||||
"""Adopt (or drop) the voice the server tagged this reply with.
|
||||
|
||||
The server's `speak_as` marker names an ElevenLabs voice it just
|
||||
picked — and it adds a Voice Library pick to the account first, so by
|
||||
the time the id gets here it's usable for TTS. It tags *one* reply,
|
||||
but the voice sticks by default: the server strips the marker before
|
||||
storing the turn, so it can't recall the id later, and "keep talking
|
||||
like that" would otherwise send it searching for a voice all over
|
||||
again. `VOICE_STICKY=false` makes each pick last exactly one reply.
|
||||
|
||||
Untagged replies never *change* the voice — with stickiness on they
|
||||
just keep whatever's in use, which is what makes the rest of the
|
||||
conversation stay in the requested voice."""
|
||||
voice_id = getattr(reply, "voice_id", "")
|
||||
if voice_id:
|
||||
if voice_id != self._voice_id:
|
||||
self._voice_id = voice_id
|
||||
self._voice_name = getattr(reply, "voice_name", "") or ""
|
||||
self.log.emit(f"Voice: {self.current_voice()}")
|
||||
self.voice_changed.emit(self.current_voice())
|
||||
elif not config.VOICE_STICKY:
|
||||
self.reset_voice()
|
||||
|
||||
def _speak(self, text: str) -> None:
|
||||
self._state.transition(PetState.TALKING)
|
||||
# Bubble gets the markdown stripped but emoji kept (it can't render
|
||||
# **bold** but draws emoji fine); tts.speak() does its own, stricter
|
||||
# sanitizing for the voice.
|
||||
self.said.emit(speech_text.for_display(text))
|
||||
self.log.emit(f"Bolt: {text}")
|
||||
self.history.add(history_mod.PET, text, time.time())
|
||||
"""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)
|
||||
|
||||
should_stop = None
|
||||
if self._barge_in is not None:
|
||||
self._barge_in.reset()
|
||||
should_stop = self._barge_in.check
|
||||
completed = tts.speak(
|
||||
text,
|
||||
on_error=lambda exc: self.log.emit(f"TTS failed: {exc}"),
|
||||
should_stop=should_stop,
|
||||
)
|
||||
# Read the scoring history *before* resetting, or the log reports the
|
||||
# blank counters instead of what actually fired.
|
||||
detail = self._barge_in_detail()
|
||||
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 starts scoring again,
|
||||
# or Bolt's last sentence is still in there being re-scored.
|
||||
self._barge_in.reset()
|
||||
def _speak_holding(self, text: str) -> None:
|
||||
""""Give me a sec" while a tool runs — filler, so no transcript, and it
|
||||
resumes the state it interrupted because the turn isn't over."""
|
||||
self._speaker.say(speech.Utterance.holding(text))
|
||||
|
||||
def _speak_stream_chunk(self, text: str) -> None:
|
||||
"""One sentence of a streamed answer, spoken the moment it arrives."""
|
||||
completed = self._speaker.say(speech.Utterance.stream_chunk(text))
|
||||
if not completed:
|
||||
self.log.emit("Interrupted — listening.")
|
||||
self._follow_ups = 0
|
||||
self._talk_now.set()
|
||||
|
||||
def _after_speaking(self, text: str, *, completed: bool, detail: str = "") -> None:
|
||||
"""What happens once an answer has been said, however it was said.
|
||||
|
||||
Shared by the plain and streamed paths: a streamed reply is spoken
|
||||
sentence by sentence, but it still has to obey the same rules about
|
||||
keeping the mic open when it ended on a question."""
|
||||
if not completed:
|
||||
# You talked over it — take that as the start of the next turn
|
||||
# rather than making you say the wake word again.
|
||||
@@ -316,20 +666,22 @@ class PetController(QObject):
|
||||
def _should_follow_up(self, text: str) -> bool:
|
||||
"""Whether *text* leaves the pet waiting on an answer.
|
||||
|
||||
Muted is excluded because mute means "don't listen to me" — an
|
||||
automatic turn would walk straight past it. Napping isn't: quiet
|
||||
hours suppress the pet *starting* something, and a question is only
|
||||
ever asked in reply to you."""
|
||||
if not config.FOLLOW_UP_LISTEN or self._muted:
|
||||
The rule itself — question, cap, off switch — is
|
||||
`speech.follow_up_decision`, because it is a rule with an off-by-one in
|
||||
it and deserves a test that needs no audio. What stays here is the part
|
||||
that is genuinely the controller's: mute, and the log line. Muted is
|
||||
excluded because mute means "don't listen to me" and an automatic turn
|
||||
would walk straight past it. Napping isn't: quiet hours suppress the
|
||||
pet *starting* something, and a question is only ever asked in reply to
|
||||
you."""
|
||||
if self._muted:
|
||||
return False
|
||||
if not speech_text.is_question(text):
|
||||
return False
|
||||
# Only worth mentioning the cap on a reply that would otherwise have
|
||||
# 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:
|
||||
keep, why = speech.follow_up_decision(text, completed=True, follow_ups=self._follow_ups)
|
||||
if not keep and "cap" in why:
|
||||
# Only worth mentioning the cap on a reply that would otherwise have
|
||||
# kept listening, or it fires on every statement the pet makes.
|
||||
self.log.emit("Follow-up limit reached — say the wake word to keep going.")
|
||||
return False
|
||||
return True
|
||||
return keep
|
||||
|
||||
def _barge_in_detail(self) -> str:
|
||||
"""Why the interruption fired, for the log. How far into playback it
|
||||
@@ -387,31 +739,82 @@ class PetController(QObject):
|
||||
|
||||
def _queue_notification(self, notification: notifications.Notification) -> None:
|
||||
"""Called on the watcher thread — just queue it; forwarding happens on
|
||||
the pipeline thread where it can't collide with a live conversation."""
|
||||
if not self._notification_gate.should_forward(notification, time.monotonic()):
|
||||
the pipeline thread where it can't collide with a live conversation.
|
||||
|
||||
Only the *filter* applies here. The rate limit used to as well, which
|
||||
meant a second message arriving inside the window was silently thrown
|
||||
away — with NOTIFICATION_MIN_INTERVAL_SECONDS=60, two texts a minute
|
||||
apart and you only ever heard about one of them. Losing a message from
|
||||
a person to save a round trip is the wrong trade; they are batched at
|
||||
the far end instead, which costs the same one round trip and keeps
|
||||
them all."""
|
||||
if not self._notification_gate.matches(notification):
|
||||
return
|
||||
with self._notification_lock:
|
||||
if len(self._pending_notifications) >= _MAX_PENDING_NOTIFICATIONS:
|
||||
self._pending_notifications.pop(0) # bound it; oldest goes first
|
||||
self._pending_notifications.append(notification)
|
||||
|
||||
def _drain_notifications(self) -> None:
|
||||
"""Forward everything waiting as ONE turn.
|
||||
|
||||
Batching is what makes it safe to keep every notification: five that
|
||||
arrived while the pet was mid-conversation become one message and one
|
||||
round trip, instead of five separate interruptions queued up to fire
|
||||
back to back."""
|
||||
with self._notification_lock:
|
||||
pending, self._pending_notifications = self._pending_notifications, []
|
||||
if not pending or not self._running or self._napping:
|
||||
return
|
||||
for notification in pending:
|
||||
if not self._running or self._napping:
|
||||
return
|
||||
self.log.emit(f"Notification: {notification.as_text()}")
|
||||
self.history.add(history_mod.SYSTEM, notification.as_text(), time.time())
|
||||
|
||||
if len(pending) == 1:
|
||||
message = f"[desktop notification] {pending[0].as_text()}"
|
||||
else:
|
||||
lines = "\n".join(f"- {n.as_text()}" for n in pending)
|
||||
message = f"[{len(pending)} desktop notifications]\n{lines}"
|
||||
try:
|
||||
reply = server_client.converse(
|
||||
message, on_command=self._handle_command, on_say=self._speak_holding,
|
||||
)
|
||||
except server_client.ServerError as exc:
|
||||
self.log.emit(f"Couldn't forward notification: {exc}")
|
||||
return
|
||||
self._check_deliveries()
|
||||
self._apply_voice(reply)
|
||||
if reply.text.strip() and not reply.spoken:
|
||||
self._speak(reply.text)
|
||||
self._state.transition(PetState.IDLE)
|
||||
|
||||
# ── file delivery ────────────────────────────────────────────────────
|
||||
|
||||
def _check_deliveries(self) -> None:
|
||||
"""Download anything the server has queued via deliver_files —
|
||||
called right after a conversation/notification turn (the common
|
||||
case: "send me that file") and once per heartbeat for anything
|
||||
queued out-of-band. Best-effort: a failure here is logged, not
|
||||
raised, so it can't sour a turn that already got its spoken reply."""
|
||||
if not config.RECEIVE_FILES:
|
||||
return
|
||||
try:
|
||||
queued = server_client.list_outbox_files()
|
||||
except server_client.ServerError as exc:
|
||||
self.log.emit(f"Couldn't check for delivered files: {exc}")
|
||||
return
|
||||
for entry in queued:
|
||||
file_id = entry.get("id")
|
||||
name = entry.get("name") or file_id
|
||||
if not file_id:
|
||||
continue
|
||||
try:
|
||||
reply = server_client.converse(
|
||||
f"[desktop notification] {notification.as_text()}",
|
||||
on_command=self._handle_command,
|
||||
)
|
||||
data = server_client.download_outbox_file(file_id)
|
||||
except server_client.ServerError as exc:
|
||||
self.log.emit(f"Couldn't forward notification: {exc}")
|
||||
return
|
||||
if reply.strip():
|
||||
self._speak(reply)
|
||||
self._state.transition(PetState.IDLE)
|
||||
self.log.emit(f"Couldn't download {name}: {exc}")
|
||||
continue
|
||||
path = file_delivery.save(config.DELIVERED_FILES_DIR, name, data)
|
||||
self.log.emit(f"Received file: {path}")
|
||||
|
||||
# ── auto-update ──────────────────────────────────────────────────────
|
||||
|
||||
@@ -456,19 +859,36 @@ class PetController(QObject):
|
||||
# ── heartbeat ────────────────────────────────────────────────────────
|
||||
|
||||
def _maybe_heartbeat(self) -> None:
|
||||
"""Called from the wake listener's tick (~every WAKE_CHECK_INTERVAL_SECONDS).
|
||||
|
||||
Two different cadences live here, and conflating them was costing
|
||||
minutes. A *notification* is an event that already happened — it should
|
||||
go out as soon as the pet is free, which is the next tick. The
|
||||
*heartbeat* is a poll, and polling the server every 1.2s would be
|
||||
absurd, so it stays on its own interval."""
|
||||
self._refresh_nap_state()
|
||||
self._maybe_update()
|
||||
if self._update_pending:
|
||||
return # on the way out — don't start a conversation now
|
||||
|
||||
# Notifications: every tick, not every heartbeat.
|
||||
if self._state.state == PetState.IDLE and not self._napping:
|
||||
self._drain_notifications()
|
||||
|
||||
now = time.monotonic()
|
||||
if now - self._last_heartbeat < config.HEARTBEAT_INTERVAL_SECONDS:
|
||||
return
|
||||
self._last_heartbeat = now
|
||||
if self._state.state != PetState.IDLE:
|
||||
# Mid-conversation. Do NOT stamp the clock — an earlier version
|
||||
# did, so a heartbeat that landed while the pet was talking burned
|
||||
# its slot and waited another full interval. With follow-up
|
||||
# listening that could repeat for several cycles, which is why a
|
||||
# notification could sit unspoken for five or ten minutes.
|
||||
return
|
||||
if self._napping:
|
||||
return # quiet hours: still answers when spoken to, just doesn't start
|
||||
self._drain_notifications()
|
||||
self._last_heartbeat = now
|
||||
self._check_deliveries()
|
||||
if self._state.state != PetState.IDLE:
|
||||
return
|
||||
try:
|
||||
|
||||
@@ -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."
|
||||
)
|
||||
@@ -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
|
||||
@@ -0,0 +1,54 @@
|
||||
"""Saves files the server queues via its deliver_files tool (ai/desk_api.py
|
||||
in the main tmn-api repo) to a local downloads folder.
|
||||
|
||||
The server side of this is already generic — any desk client can list
|
||||
GET /desk/files and fetch GET /desk/files/<id> (see server_client.
|
||||
list_outbox_files / download_outbox_file) — so this module is just the
|
||||
filesystem half: turn a server-supplied display name into a safe path and
|
||||
write the bytes.
|
||||
|
||||
Pure filename/path logic lives here so it's testable without touching a real
|
||||
mic/network; the only I/O is the final write in save().
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
_FALLBACK_NAME = "delivered_file"
|
||||
|
||||
|
||||
def sanitize_filename(name: str) -> str:
|
||||
"""Reduce a server-supplied name to a bare filename. Defends against a
|
||||
delivered name that's actually a path (../../etc, an absolute path, ...)
|
||||
— Path(...).name strips every directory component, and anything that
|
||||
collapses to nothing (or "." / "..") falls back to a generic name."""
|
||||
candidate = Path(str(name or "").strip()).name
|
||||
if candidate in ("", ".", ".."):
|
||||
return _FALLBACK_NAME
|
||||
return candidate
|
||||
|
||||
|
||||
def unique_path(directory: Path, name: str) -> Path:
|
||||
"""*name* under *directory*, suffixed " (1)", " (2)", ... if that name is
|
||||
already taken — a delivered file never overwrites an earlier download."""
|
||||
directory = Path(directory)
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
candidate = directory / name
|
||||
if not candidate.exists():
|
||||
return candidate
|
||||
stem, suffix = candidate.stem, candidate.suffix
|
||||
n = 1
|
||||
while True:
|
||||
candidate = directory / f"{stem} ({n}){suffix}"
|
||||
if not candidate.exists():
|
||||
return candidate
|
||||
n += 1
|
||||
|
||||
|
||||
def save(directory: Path, name: str, data: bytes) -> Path:
|
||||
"""Write *data* under *directory* as *name* (sanitized + uniquified),
|
||||
returning the path written."""
|
||||
path = unique_path(directory, sanitize_filename(name))
|
||||
path.write_bytes(data)
|
||||
return path
|
||||
@@ -0,0 +1,290 @@
|
||||
"""Local file read/edit/write, intercepted from the server-relayed command
|
||||
channel the same way pet_actions.py intercepts petctl (see server_client.
|
||||
run_local_command / controller._handle_command). Whatever text the server's
|
||||
"command" tool sends is just a string this repo is free to interpret before
|
||||
it ever reaches subprocess — a `filectl` pseudo-command is one such
|
||||
interpretation, giving the model a way to read/write/edit files on this
|
||||
machine without constructing a raw shell heredoc, where quoting, `$`,
|
||||
backticks, and embedded quotes make anything beyond a one-liner failure-prone.
|
||||
|
||||
Executing arbitrary commands already works today (that's exactly what
|
||||
run_local_command/subprocess.run does) — filectl doesn't add that ability,
|
||||
it only makes the read/write/edit slice of it reliable. It also doesn't
|
||||
expand what the server can already do to this machine: a relayed shell
|
||||
command could already overwrite any file the desktop user can write (see the
|
||||
security notes in CLAUDE.md) — filectl is a safer *path* to the same
|
||||
capability, not a new capability.
|
||||
|
||||
Wire format: `filectl <json>`, where <json> is a single-line, compact JSON
|
||||
object — critically, ONE LINE. The server's "command" tool marker only
|
||||
captures the argument up to the next newline (see TOOL_SPECS/
|
||||
_extract_all_tool_calls in the main repo's ai/agents/default.py — "command"
|
||||
is not declared multiline), so a marker-delimited multi-line payload (this
|
||||
module's first design) silently got truncated at the first line no matter
|
||||
how the prompt worded it. JSON sidesteps that for free: json.dumps() already
|
||||
encodes embedded newlines as the two characters "\n", not an actual line
|
||||
break, so arbitrarily multi-line file content still fits on the one physical
|
||||
line the extractor captures.
|
||||
|
||||
filectl {"op": "list", "path": "<dir>", "pattern": "<glob>", "recursive": <bool>}
|
||||
filectl {"op": "read", "path": "<path>", "start": <line>, "end": <line>}
|
||||
filectl {"op": "write", "path": "<path>", "content": "<text>"}
|
||||
filectl {"op": "edit", "path": "<path>", "old": "<text>", "new": "<text>"}
|
||||
|
||||
"pattern"/"recursive" (list) and "start"/"end" (read) are optional. `list`
|
||||
exists even though a real `ls`/`dir` shell command already works, because
|
||||
this repo is cross-platform (Windows/macOS/Linux) and the model shouldn't
|
||||
have to guess which listing command applies on this machine — one glob-based
|
||||
op covers all three. Must be invoked as the argument to the
|
||||
ordinary `command` tool marker (e.g. `command: filectl {"op": "write", ...}`)
|
||||
— see the pet-only paragraph in the main repo's ai/desk_api.py
|
||||
_system_context for the exact instruction the model is given, including the
|
||||
"keep it one line" requirement.
|
||||
|
||||
Pure parsing (parse) is separated from the filesystem I/O (execute) so the
|
||||
syntax is unit-testable without touching disk, matching pet_actions.py's
|
||||
parse/describe split.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from . import relay_json
|
||||
|
||||
_PREFIXES = ("filectl", "file")
|
||||
|
||||
# Keeps a runaway read/write/list from blowing up the tool_result relay (and,
|
||||
# for read/list, from flooding the model's context with a giant response).
|
||||
_MAX_READ_BYTES = 200_000
|
||||
_MAX_WRITE_BYTES = 2_000_000
|
||||
_MAX_LIST_ENTRIES = 500
|
||||
|
||||
HELP = (
|
||||
'filectl {"op": "list", "path": "<dir>", "pattern": "<glob>", "recursive": <bool>}\n'
|
||||
'filectl {"op": "read", "path": "<path>", "start": <line>, "end": <line>}\n'
|
||||
'filectl {"op": "write", "path": "<path>", "content": "<text>"}\n'
|
||||
'filectl {"op": "edit", "path": "<path>", "old": "<text>", "new": "<text>"}\n'
|
||||
"Must be all on one line — this rides the single-line \"command\" marker."
|
||||
)
|
||||
|
||||
|
||||
class FileOpError(Exception):
|
||||
"""Bad filectl syntax or a filesystem error — reported back to the
|
||||
server as command output, exactly like ActionError in pet_actions.py."""
|
||||
|
||||
|
||||
def is_file_command(command: str) -> bool:
|
||||
stripped = (command or "").strip()
|
||||
if not stripped:
|
||||
return False
|
||||
first_word = stripped.split(None, 1)[0]
|
||||
return first_word.lower() in _PREFIXES
|
||||
|
||||
|
||||
def parse(command: str) -> Optional[dict]:
|
||||
"""Parse a `filectl <json>` string into an action dict, or None if this
|
||||
isn't a filectl command at all (caller should try the next handler, or
|
||||
fall back to a real shell command). Raises FileOpError on a filectl
|
||||
command that doesn't make sense."""
|
||||
if not is_file_command(command):
|
||||
return None
|
||||
stripped = command.strip()
|
||||
_, _, rest = stripped.partition(" ")
|
||||
rest = rest.strip()
|
||||
if not rest or rest.lower() in ("help", "-h", "--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:
|
||||
payload, repairs = relay_json.loads(rest)
|
||||
except relay_json.RelayJsonError as exc:
|
||||
raise FileOpError(f"couldn't parse filectl JSON — {exc}\nusage:\n{HELP}") from exc
|
||||
if not isinstance(payload, dict):
|
||||
raise FileOpError(f"filectl payload must be a JSON object; usage:\n{HELP}")
|
||||
|
||||
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":
|
||||
return {"action": "help", **tag}
|
||||
|
||||
if op == "list":
|
||||
path = _required_str(payload, "path")
|
||||
pattern = payload.get("pattern", "*")
|
||||
if not isinstance(pattern, str) or not pattern:
|
||||
raise FileOpError('"pattern" must be a non-empty string')
|
||||
return {
|
||||
"action": "list", "path": path,
|
||||
"pattern": pattern, "recursive": bool(payload.get("recursive")), **tag,
|
||||
}
|
||||
|
||||
if op == "read":
|
||||
path = _required_str(payload, "path")
|
||||
return {
|
||||
"action": "read", "path": path,
|
||||
"start": _line_number(payload.get("start"), "start"),
|
||||
"end": _line_number(payload.get("end"), "end"), **tag,
|
||||
}
|
||||
|
||||
if op == "write":
|
||||
path = _required_str(payload, "path")
|
||||
if payload.get("content") is None:
|
||||
raise FileOpError('write needs "content"')
|
||||
return {"action": "write", "path": path, "content": str(payload["content"]), **tag}
|
||||
|
||||
if op == "edit":
|
||||
path = _required_str(payload, "path")
|
||||
if payload.get("old") is None or payload.get("new") is None:
|
||||
raise FileOpError('edit needs "old" and "new"')
|
||||
old, new = str(payload["old"]), str(payload["new"])
|
||||
if old == new:
|
||||
raise FileOpError("old and new text are identical — nothing to edit")
|
||||
return {"action": "edit", "path": path, "old": old, "new": new, **tag}
|
||||
|
||||
raise FileOpError(f"unknown filectl op {op!r}; usage:\n{HELP}")
|
||||
|
||||
|
||||
def _required_str(payload: dict, key: str) -> str:
|
||||
value = payload.get(key)
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
raise FileOpError(f'filectl needs a non-empty "{key}"')
|
||||
return value
|
||||
|
||||
|
||||
def _line_number(value, label: str) -> Optional[int]:
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, bool) or not isinstance(value, int):
|
||||
raise FileOpError(f"{label} must be an integer line number, got {value!r}")
|
||||
return value
|
||||
|
||||
|
||||
def describe(action: dict) -> str:
|
||||
"""Text handed back to the server before execute() runs — mirrors
|
||||
pet_actions.describe, used only for the log line."""
|
||||
kind = action.get("action")
|
||||
if kind == "help":
|
||||
return "[filectl] help"
|
||||
return f"[filectl] {kind} {action.get('path', '')}"
|
||||
|
||||
|
||||
def execute(action: dict) -> str:
|
||||
"""Actually perform a parsed filectl action, returning the text to send
|
||||
back to the server as the command's output. Raises FileOpError on any
|
||||
filesystem problem, same as a bad-syntax parse error."""
|
||||
kind = action.get("action")
|
||||
if kind == "help":
|
||||
return HELP
|
||||
if kind == "list":
|
||||
output = _do_list(action)
|
||||
elif kind == "read":
|
||||
output = _do_read(action)
|
||||
elif kind == "write":
|
||||
output = _do_write(action)
|
||||
elif kind == "edit":
|
||||
output = _do_edit(action)
|
||||
else:
|
||||
output = "[filectl] ok"
|
||||
return output + relay_json.repair_note(action.get("_repairs") or [])
|
||||
|
||||
|
||||
def _resolve(path_str: str) -> Path:
|
||||
return Path(path_str).expanduser()
|
||||
|
||||
|
||||
def _do_list(action: dict) -> str:
|
||||
path = _resolve(action["path"])
|
||||
if not path.is_dir():
|
||||
raise FileOpError(f"no such directory: {path}")
|
||||
pattern = action["pattern"]
|
||||
glob = path.rglob if action["recursive"] else path.glob
|
||||
try:
|
||||
entries = sorted(glob(pattern), key=lambda p: str(p).lower())
|
||||
except OSError as exc:
|
||||
raise FileOpError(f"couldn't list {path}: {exc}") from exc
|
||||
if not entries:
|
||||
return f"[no entries matching {pattern!r} in {path}]"
|
||||
truncated = len(entries) > _MAX_LIST_ENTRIES
|
||||
lines = []
|
||||
for entry in entries[:_MAX_LIST_ENTRIES]:
|
||||
rel = entry.relative_to(path)
|
||||
if entry.is_dir():
|
||||
lines.append(f"{rel}/")
|
||||
continue
|
||||
try:
|
||||
size = entry.stat().st_size
|
||||
except OSError:
|
||||
size = -1
|
||||
lines.append(f"{rel}\t{size}B")
|
||||
if truncated:
|
||||
lines.append(f"... truncated at {_MAX_LIST_ENTRIES} entries (of {len(entries)}) — narrow \"pattern\"")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _do_read(action: dict) -> str:
|
||||
path = _resolve(action["path"])
|
||||
if not path.is_file():
|
||||
raise FileOpError(f"no such file: {path}")
|
||||
try:
|
||||
data = path.read_bytes()
|
||||
except OSError as exc:
|
||||
raise FileOpError(f"couldn't read {path}: {exc}") from exc
|
||||
if len(data) > _MAX_READ_BYTES:
|
||||
raise FileOpError(
|
||||
f"{path} is {len(data)} bytes, over the {_MAX_READ_BYTES}-byte filectl read limit — "
|
||||
'pass "start"/"end" to read a slice instead'
|
||||
)
|
||||
try:
|
||||
text = data.decode("utf-8")
|
||||
except UnicodeDecodeError as exc:
|
||||
raise FileOpError(f"{path} isn't valid UTF-8 text: {exc}") from exc
|
||||
lines = text.splitlines()
|
||||
start = max(1, action.get("start") or 1)
|
||||
end = min(len(lines), action.get("end") or len(lines))
|
||||
if not lines:
|
||||
return "[empty file]"
|
||||
return "\n".join(f"{i:>6}\t{lines[i - 1]}" for i in range(start, end + 1))
|
||||
|
||||
|
||||
def _do_write(action: dict) -> str:
|
||||
path = _resolve(action["path"])
|
||||
content = action["content"]
|
||||
if len(content.encode("utf-8")) > _MAX_WRITE_BYTES:
|
||||
raise FileOpError(f"content is over the {_MAX_WRITE_BYTES}-byte filectl write limit")
|
||||
try:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(content, encoding="utf-8")
|
||||
except OSError as exc:
|
||||
raise FileOpError(f"couldn't write {path}: {exc}") from exc
|
||||
return f"[filectl] wrote {len(content)} chars to {path}"
|
||||
|
||||
|
||||
def _do_edit(action: dict) -> str:
|
||||
path = _resolve(action["path"])
|
||||
old, new = action["old"], action["new"]
|
||||
if not path.is_file():
|
||||
raise FileOpError(f"no such file: {path}")
|
||||
try:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
except OSError as exc:
|
||||
raise FileOpError(f"couldn't read {path}: {exc}") from exc
|
||||
count = text.count(old)
|
||||
if count == 0:
|
||||
raise FileOpError(f"didn't find that text in {path} — nothing changed")
|
||||
if count > 1:
|
||||
raise FileOpError(
|
||||
f"that text appears {count} times in {path} — filectl edit needs a unique match; "
|
||||
"include more surrounding context"
|
||||
)
|
||||
try:
|
||||
path.write_text(text.replace(old, new, 1), encoding="utf-8")
|
||||
except OSError as exc:
|
||||
raise FileOpError(f"couldn't write {path}: {exc}") from exc
|
||||
return f"[filectl] edited {path}"
|
||||
@@ -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}]"
|
||||
@@ -29,14 +29,32 @@ ANCHORS = (
|
||||
|
||||
EMOTES = ("wave", "hop", "spin", "nod", "shake", "bounce", "wiggle")
|
||||
|
||||
# Where `petctl jump` can be aimed. A bare number (1-based) works too, as does
|
||||
# any unique part of a monitor's name — resolution lives in monitors.resolve().
|
||||
MONITOR_SPECS = (
|
||||
"next", "prev", "primary", "other", "random",
|
||||
"left", "right", "up", "down",
|
||||
)
|
||||
|
||||
HELP = (
|
||||
"petctl move <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 say <text>\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):
|
||||
"""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))
|
||||
return {"action": "move", "anchor": anchor}
|
||||
|
||||
if verb in ("jump", "monitor", "screen"):
|
||||
if not args:
|
||||
raise ActionError(
|
||||
"jump needs a monitor: a number, a name, or one of "
|
||||
+ ", ".join(MONITOR_SPECS)
|
||||
)
|
||||
# The spec isn't validated here on purpose: which monitors exist is a
|
||||
# runtime fact this pure module doesn't have. monitors.resolve() does
|
||||
# it once the published screen list is in hand.
|
||||
return {"action": "jump", "target": " ".join(args).strip()}
|
||||
|
||||
if verb in ("monitors", "screens", "displays"):
|
||||
return {"action": "monitors"}
|
||||
|
||||
if verb in ("read", "look", "ocr", "see"):
|
||||
target = (" ".join(args).strip() or "here").lower()
|
||||
return {"action": "read", "target": target}
|
||||
|
||||
if verb in ("emote", "do"):
|
||||
if not args:
|
||||
raise ActionError("emote needs a name: " + ", ".join(EMOTES))
|
||||
@@ -101,6 +137,22 @@ def parse(command: str) -> Optional[dict]:
|
||||
raise ActionError("wander needs on or off")
|
||||
return {"action": "wander", "enabled": _bool_arg(args[0])}
|
||||
|
||||
if verb == "voice":
|
||||
target = (args[0].lower() if args else "reset").lstrip("-")
|
||||
if target not in VOICE_RESETS:
|
||||
raise ActionError(
|
||||
f"can't set a voice from petctl (got {args[0]!r}); "
|
||||
"use the speak_as reply marker to pick one. "
|
||||
"petctl voice reset goes back to the default voice."
|
||||
)
|
||||
return {"action": "voice", "voice": "default"}
|
||||
|
||||
if verb in ("self_restart", "restart", "reboot"):
|
||||
# Free text, not a fixed grammar: the argument is a note to the pet's
|
||||
# *next* process about why it died and what to look at when it comes
|
||||
# back, so anything the model wants to tell future-itself is valid.
|
||||
return {"action": "self_restart", "reason": " ".join(args).strip()}
|
||||
|
||||
if verb in ("nap", "sleep", "dnd"):
|
||||
if not args:
|
||||
raise ActionError("nap needs on or off")
|
||||
@@ -124,6 +176,8 @@ def describe(action: dict) -> str:
|
||||
if kind == "move":
|
||||
where = action.get("anchor") or f"({action.get('x')}, {action.get('y')})"
|
||||
return f"[pet] walking to {where}"
|
||||
if kind == "jump":
|
||||
return f"[pet] jumping to monitor {action['target']}"
|
||||
if kind == "emote":
|
||||
return f"[pet] {action['emote']}"
|
||||
if kind == "say":
|
||||
|
||||
@@ -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.)"
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
reply <- returned to caller
|
||||
|
||||
A reply can also carry a voice (`voice_id`/`voice_name`), which is how the
|
||||
server's `speak_as` marker reaches us: Bolt searched the ElevenLabs voice
|
||||
library, picked one, and tagged the reply with it — the client is what
|
||||
actually speaks in it. See `Reply` and controller._apply_voice.
|
||||
|
||||
Kept dependency-free beyond `requests` so it's easy to unit test with mocks.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Callable, Optional
|
||||
from typing import Callable, NamedTuple, Optional
|
||||
|
||||
import requests
|
||||
|
||||
@@ -29,6 +35,21 @@ class ServerError(Exception):
|
||||
"""Raised when the server responds with an error payload or unreachable."""
|
||||
|
||||
|
||||
class Reply(NamedTuple):
|
||||
"""One final reply from the desk API. *voice_id* is set only when the
|
||||
server tagged this reply with a `speak_as` voice; *voice_name* is the
|
||||
human-readable name that came with it (may be empty even when the id
|
||||
isn't). Both empty means "say it in the usual voice"."""
|
||||
|
||||
text: str
|
||||
voice_id: str = ""
|
||||
voice_name: str = ""
|
||||
# True when the sentences were already spoken as they streamed in. The
|
||||
# text is still carried — the follow-up rule needs to see whether the
|
||||
# answer ended on a question — it just must not be read out again.
|
||||
spoken: bool = False
|
||||
|
||||
|
||||
def _headers() -> dict:
|
||||
return {"X-Desk-Api-Key": config.API_KEY}
|
||||
|
||||
@@ -74,12 +95,20 @@ def converse(
|
||||
text: str,
|
||||
on_command: Callable[[str], str] = run_local_command,
|
||||
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
|
||||
the server sends back until it produces a final reply.
|
||||
|
||||
*on_command* is injectable for tests; defaults to actually running the
|
||||
command locally (matching bolt_desk.py's behavior).
|
||||
|
||||
*on_say* is called with a short holding line ("give me a sec") when the
|
||||
server sends one alongside a command. It is the difference between silence
|
||||
and an answer while a tool runs: the model's acknowledgement used to be
|
||||
discarded server-side, so the whole round trip was dead air and the model
|
||||
then repeated itself in the final reply. Optional, so an older pet against
|
||||
a newer server simply stays quiet as before.
|
||||
"""
|
||||
headers = _headers()
|
||||
try:
|
||||
@@ -95,6 +124,13 @@ def converse(
|
||||
for _ in range(_MAX_RELAY_HOPS):
|
||||
if payload.get("type") != "command":
|
||||
break
|
||||
holding = str(payload.get("say") or "").strip()
|
||||
if holding and on_say is not None:
|
||||
# Spoken *before* the command runs — that is the whole point.
|
||||
try:
|
||||
on_say(holding)
|
||||
except Exception:
|
||||
pass # a failed acknowledgement must not cost the tool call
|
||||
output = on_command(str(payload.get("command") or ""))
|
||||
try:
|
||||
response = requests.post(
|
||||
@@ -111,10 +147,143 @@ def converse(
|
||||
raise ServerError(f"couldn't reach the server during tool relay: {exc}") from exc
|
||||
|
||||
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"))
|
||||
|
||||
|
||||
def list_outbox_files(timeout: float = 15.0) -> list:
|
||||
"""Files the server has queued for this session via its deliver_files
|
||||
tool (e.g. "send me that report" during a conversation) — each entry has
|
||||
id/name/size. Downloading one (download_outbox_file) dequeues it
|
||||
server-side, so a file is only ever handed out once."""
|
||||
try:
|
||||
response = requests.get(
|
||||
f"{config.SERVER_URL}/desk/files",
|
||||
params={"session_id": config.SESSION_ID},
|
||||
headers=_headers(), timeout=timeout,
|
||||
)
|
||||
response.raise_for_status()
|
||||
return list(response.json().get("files") or [])
|
||||
except Exception as exc:
|
||||
raise ServerError(f"couldn't list delivered files: {exc}") from exc
|
||||
|
||||
|
||||
def download_outbox_file(file_id: str, timeout: float = 60.0) -> bytes:
|
||||
"""Fetches and dequeues one file listed by list_outbox_files()."""
|
||||
try:
|
||||
response = requests.get(
|
||||
f"{config.SERVER_URL}/desk/files/{file_id}",
|
||||
params={"session_id": config.SESSION_ID},
|
||||
headers=_headers(), timeout=timeout,
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.content
|
||||
except Exception as exc:
|
||||
raise ServerError(f"couldn't download delivered file {file_id!r}: {exc}") from exc
|
||||
|
||||
|
||||
def report_status(timeout: float = 15.0) -> Optional[str]:
|
||||
"""Heartbeat — lets the desk API attach a pending spoken announcement
|
||||
(proactive nudges, reminders fired since the last heartbeat) that the pet
|
||||
|
||||
@@ -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"
|
||||
@@ -97,10 +97,34 @@ def _bullets_to_sentences(text: str) -> str:
|
||||
return " ".join(line if line[-1] in ".!?:,;" else line + "." for line in lines)
|
||||
|
||||
|
||||
# ElevenLabs v3 delivery tags — "[laughing]", "[whispering]", "[sighs]". They
|
||||
# are instructions to the *dialogue* model (see dialogue.py), and only inside a
|
||||
# dialoguectl scene. Left in an ordinary reply they reach eleven_flash_v2,
|
||||
# which has no idea what they mean and simply reads the word: observed live
|
||||
# 2026-07-31, the pet announcing "laughing That one came through clean".
|
||||
#
|
||||
# Matched narrowly — a bracketed adverb/gerund, or one of the noise words that
|
||||
# aren't either — so real bracketed text ("[1]", "[see the docs]") survives.
|
||||
_AUDIO_TAG_RE = re.compile(
|
||||
r"\[\s*(?:"
|
||||
r"[a-z]+(?:ly|ing)"
|
||||
r"|laughs?|chuckles?|sighs?|exhales?|inhales?|gulps?|pause|beat"
|
||||
r"|clears throat|shouts?|whispers?|cries|sings?|gasps?|snorts?"
|
||||
r"|sarcastic|excited|curious|nervous|angry|sad|happy|deadpan|flat"
|
||||
r")\s*\]",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def strip_audio_tags(text: str) -> str:
|
||||
"""Remove v3 delivery tags from text destined for the ordinary voice."""
|
||||
return _AUDIO_TAG_RE.sub(" ", str(text or ""))
|
||||
|
||||
|
||||
def for_speech(text: str) -> str:
|
||||
"""Plain prose for the TTS engine: no markdown, no emoji, no bare URLs,
|
||||
no stray symbols that would be read out character by character."""
|
||||
text = (text or "").strip()
|
||||
text = strip_audio_tags((text or "").strip())
|
||||
if not text:
|
||||
return ""
|
||||
for symbol, spoken in _PRE_SPOKEN_SYMBOLS.items():
|
||||
@@ -136,8 +160,11 @@ def is_question(text: str) -> bool:
|
||||
|
||||
def for_display(text: str) -> str:
|
||||
"""What the speech bubble shows: markdown syntax removed (the bubble
|
||||
can't render it) but emoji and layout-ish punctuation left alone."""
|
||||
text = (text or "").strip()
|
||||
can't render it) but emoji and layout-ish punctuation left alone.
|
||||
|
||||
Delivery tags go too — the voice no longer says them, so showing them
|
||||
would caption a laugh nobody heard."""
|
||||
text = strip_audio_tags((text or "").strip())
|
||||
if not text:
|
||||
return ""
|
||||
text = _strip_markdown(text, keep_emoji=True)
|
||||
|
||||
@@ -26,11 +26,16 @@ class PetState(str, Enum):
|
||||
# make the pet speak unprompted — a reminder firing, a nudge from the server
|
||||
# — without the user having said anything first, so there's no preceding
|
||||
# LISTENING/THINKING leg for that turn.
|
||||
#
|
||||
# TALKING -> THINKING is the mirror case: a `dialoguectl` scene is played
|
||||
# *mid-turn*, while the server is still waiting on the tool result, so the pet
|
||||
# talks and then goes back to waiting rather than falling to IDLE (which would
|
||||
# make it look like the turn had ended).
|
||||
_TRANSITIONS: dict[PetState, set[PetState]] = {
|
||||
PetState.IDLE: {PetState.LISTENING, PetState.TALKING, PetState.ERROR},
|
||||
PetState.LISTENING: {PetState.THINKING, PetState.IDLE, PetState.ERROR},
|
||||
PetState.THINKING: {PetState.TALKING, PetState.IDLE, PetState.ERROR},
|
||||
PetState.TALKING: {PetState.IDLE, PetState.ERROR},
|
||||
PetState.TALKING: {PetState.IDLE, PetState.THINKING, PetState.ERROR},
|
||||
PetState.ERROR: {PetState.IDLE},
|
||||
}
|
||||
|
||||
|
||||
@@ -41,11 +41,20 @@ def run() -> int:
|
||||
thread.started.connect(controller.run)
|
||||
controller.state_changed.connect(lambda value: window.set_state(PetState(value)))
|
||||
controller.said.connect(window.say)
|
||||
# Lip-sync: the loudness of the audio actually going to the speakers.
|
||||
controller.mouth.connect(window.set_mouth)
|
||||
controller.log.connect(_log)
|
||||
controller.action.connect(window.apply_action) # petctl move/emote/say/...
|
||||
controller.finished.connect(thread.quit)
|
||||
|
||||
window.talk_requested.connect(controller.request_talk_now)
|
||||
# The window owns the screen list and tells the controller about it, so
|
||||
# both ends agree on what "monitor 2" means (see monitors.py).
|
||||
window.monitors_changed.connect(controller.set_monitors)
|
||||
window.pet_monitor_changed.connect(controller.set_pet_monitor)
|
||||
# PetWindow publishes once in its constructor, which ran before those
|
||||
# connections existed — so say it again now that anyone is listening.
|
||||
window.publish_monitors()
|
||||
window.copied.connect(lambda text: _log(f"Copied to clipboard: {text[:60]}"))
|
||||
|
||||
history_window = HistoryWindow(controller.history)
|
||||
@@ -73,6 +82,7 @@ def run() -> int:
|
||||
on_set_nap=_set_nap,
|
||||
on_show_history=history_window.show_refreshed,
|
||||
on_show_wake_tuner=tuner_window.show_refreshed,
|
||||
on_reset_voice=controller.reset_voice,
|
||||
)
|
||||
|
||||
def _handle_napping(napping: bool) -> None:
|
||||
@@ -80,6 +90,9 @@ def run() -> int:
|
||||
tray.set_napping(napping)
|
||||
|
||||
controller.napping.connect(_handle_napping)
|
||||
# The server can hand Bolt a different voice mid-conversation (speak_as);
|
||||
# the tray is where you get his own back.
|
||||
controller.voice_changed.connect(tray.set_voice)
|
||||
|
||||
# Push-to-talk: a global hook, because the pet window never has focus.
|
||||
# request_talk_now() only sets a threading.Event, so it's safe to call
|
||||
|
||||
@@ -19,9 +19,10 @@ from PySide6.QtGui import (
|
||||
)
|
||||
from PySide6.QtWidgets import QApplication, QWidget
|
||||
|
||||
from .. import config
|
||||
from .. import config, window_state
|
||||
from ..monitors import Monitor
|
||||
from ..state import PetState
|
||||
from .sprite import SpriteSet
|
||||
from .sprite import WALK, SpriteSet
|
||||
|
||||
_DRAG_THRESHOLD_PX = 4
|
||||
# Movement runs on its own ~30fps timer, independent of the (slower) sprite
|
||||
@@ -29,6 +30,17 @@ _DRAG_THRESHOLD_PX = 4
|
||||
_WANDER_TICK_MS = 33
|
||||
_EMOTE_TICKS = 36 # ~1.2s per emote at the tick rate above
|
||||
_NAP_OPACITY = 0.35
|
||||
# How far the pet travels per walk-cycle frame. The cycle is advanced by
|
||||
# distance rather than by the animation clock so a planted paw tracks backwards
|
||||
# at exactly the speed the window moves forwards — drive it off a timer instead
|
||||
# and the feet skate whenever PET_WANDER_SPEED doesn't happen to match the fps.
|
||||
# Eight frames at 13px is a ~104px stride cycle, a bit under the pet's width.
|
||||
_WALK_PIXELS_PER_FRAME = 13.0
|
||||
# How long a loudness level stays believable. The audio thread sends one per
|
||||
# ~30ms while a clip plays; if they stop arriving (offline TTS has no envelope,
|
||||
# or playback died) the mouth must not stay frozen mid-syllable, so after this
|
||||
# long the ordinary looping animation takes back over.
|
||||
_MOUTH_STALE_SECONDS = 0.35
|
||||
|
||||
|
||||
def emote_transform(emote: str, progress: float) -> tuple[float, float, float, float]:
|
||||
@@ -164,6 +176,12 @@ class SpeechBubble(QWidget):
|
||||
class PetWindow(QWidget):
|
||||
talk_requested = Signal()
|
||||
copied = Signal(str) # bubble text the user just put on the clipboard
|
||||
# The screen layout, published *to* the controller (queued, cross-thread).
|
||||
# The window is the only thing allowed to ask Qt about screens, so the
|
||||
# controller and the window can never disagree about what "monitor 2"
|
||||
# means — see monitors.py.
|
||||
monitors_changed = Signal(list) # list[monitors.Monitor]
|
||||
pet_monitor_changed = Signal(int) # 0-based index the pet is standing on
|
||||
|
||||
def __init__(self, sprite_dir: Optional[Path] = None, size: Optional[int] = None):
|
||||
super().__init__()
|
||||
@@ -200,7 +218,14 @@ class PetWindow(QWidget):
|
||||
self._commanded_move = False # a petctl move — happens even mid-conversation
|
||||
self._next_wander_at = 0.0
|
||||
self._bob_offset = 0
|
||||
# Lip-sync: loudness of what is playing right now, and when it arrived.
|
||||
self._mouth_level: Optional[float] = None
|
||||
self._mouth_at = 0.0
|
||||
self._bob_phase = 0.0
|
||||
self._walking = False
|
||||
self._facing = 1 # +1 right, -1 left; the walk art is drawn facing right
|
||||
self._walk_distance = 0.0
|
||||
self._mirror_cache: dict[int, QPixmap] = {}
|
||||
self._schedule_next_wander()
|
||||
self._wander_timer = QTimer(self)
|
||||
self._wander_timer.timeout.connect(self._movement_tick)
|
||||
@@ -210,6 +235,18 @@ class PetWindow(QWidget):
|
||||
self.set_click_through(config.PET_CLICK_THROUGH)
|
||||
self._place_start_position()
|
||||
|
||||
self._monitors: list[Monitor] = []
|
||||
self._pet_monitor: Optional[int] = None
|
||||
self._last_published_pos: Optional[QPoint] = None
|
||||
app = QApplication.instance()
|
||||
if app is not None:
|
||||
# Screens come and go — a laptop docking, a TV waking up. Republish
|
||||
# rather than letting Bolt jump to a monitor that's been unplugged.
|
||||
app.screenAdded.connect(lambda _s: self.publish_monitors())
|
||||
app.screenRemoved.connect(lambda _s: self.publish_monitors())
|
||||
app.primaryScreenChanged.connect(lambda _s: self.publish_monitors())
|
||||
self.publish_monitors()
|
||||
|
||||
# ── placement ────────────────────────────────────────────────────────
|
||||
|
||||
def _place_start_position(self) -> None:
|
||||
@@ -220,6 +257,19 @@ class PetWindow(QWidget):
|
||||
y = int(config.PET_START_Y) if config.PET_START_Y else None
|
||||
except ValueError:
|
||||
x = y = None
|
||||
if x is None and y is None and config.PET_REMEMBER_POSITION:
|
||||
remembered = window_state.load()
|
||||
# Only if it still lands on a screen that exists — the usual reason
|
||||
# a saved position is stale is that the monitor it was on has been
|
||||
# unplugged, and restoring it faithfully would hide the pet.
|
||||
if remembered is not None:
|
||||
rectangles = [
|
||||
(s.availableGeometry().left(), s.availableGeometry().top(),
|
||||
s.availableGeometry().right(), s.availableGeometry().bottom())
|
||||
for s in QApplication.screens()
|
||||
]
|
||||
if window_state.is_visible_on(*remembered, self.width(), rectangles):
|
||||
x, y = remembered
|
||||
if geo is not None:
|
||||
x = geo.right() - self.width() - 40 if x is None else x
|
||||
y = geo.bottom() - self.height() - 60 if y is None else y
|
||||
@@ -244,6 +294,8 @@ class PetWindow(QWidget):
|
||||
if target is not None:
|
||||
self._wander_target = target
|
||||
self._commanded_move = True # overrides the idle-only rule
|
||||
elif kind == "jump":
|
||||
self.jump_to_monitor(int(action["monitor"]))
|
||||
elif kind == "emote":
|
||||
self.start_emote(action["emote"])
|
||||
elif kind == "say":
|
||||
@@ -378,6 +430,98 @@ class PetWindow(QWidget):
|
||||
"""Stroll immediately (tray menu / anything that wants a nudge)."""
|
||||
self._next_wander_at = 0.0
|
||||
|
||||
# ── monitors ─────────────────────────────────────────────────────────
|
||||
|
||||
def _build_monitors(self) -> list[Monitor]:
|
||||
"""Snapshot Qt's screen list as plain dataclasses.
|
||||
|
||||
Full `geometry()`, not `availableGeometry()`: these coordinates are
|
||||
what a screen grab gets cropped to, and a grab doesn't stop at the
|
||||
taskbar. Placement uses availableGeometry separately.
|
||||
"""
|
||||
primary = QApplication.primaryScreen()
|
||||
out = []
|
||||
for index, screen in enumerate(QApplication.screens()):
|
||||
geo = screen.geometry()
|
||||
out.append(
|
||||
Monitor(
|
||||
index=index,
|
||||
name=screen.name() or f"screen-{index + 1}",
|
||||
x=geo.x(),
|
||||
y=geo.y(),
|
||||
width=geo.width(),
|
||||
height=geo.height(),
|
||||
primary=screen is primary,
|
||||
)
|
||||
)
|
||||
return out
|
||||
|
||||
def publish_monitors(self, force: bool = True) -> None:
|
||||
"""Push the current layout to whoever's listening (the controller).
|
||||
|
||||
*force* re-emits even when nothing changed, which is what the initial
|
||||
wiring in ui/app.py needs: this window is built before the controller
|
||||
exists, so the constructor's first publish goes to nobody.
|
||||
"""
|
||||
monitors = self._build_monitors()
|
||||
changed = monitors != self._monitors
|
||||
self._monitors = monitors
|
||||
if changed or force:
|
||||
self.monitors_changed.emit(monitors)
|
||||
self._publish_pet_monitor(force=True)
|
||||
|
||||
def monitors(self) -> list[Monitor]:
|
||||
return list(self._monitors)
|
||||
|
||||
def current_monitor_index(self) -> Optional[int]:
|
||||
center = self.frameGeometry().center()
|
||||
screens = QApplication.screens()
|
||||
if not screens:
|
||||
return None
|
||||
screen = QApplication.screenAt(center)
|
||||
if screen is not None:
|
||||
try:
|
||||
return screens.index(screen)
|
||||
except ValueError:
|
||||
pass
|
||||
# Straddling a gap or dragged off the desktop entirely — fall back to
|
||||
# whichever screen centre is nearest rather than reporting nothing.
|
||||
best = min(
|
||||
range(len(screens)),
|
||||
key=lambda i: (screens[i].geometry().center() - center).manhattanLength(),
|
||||
)
|
||||
return best
|
||||
|
||||
def _publish_pet_monitor(self, force: bool = False) -> None:
|
||||
index = self.current_monitor_index()
|
||||
if index is None:
|
||||
return
|
||||
if force or index != self._pet_monitor:
|
||||
self._pet_monitor = index
|
||||
self.pet_monitor_changed.emit(index)
|
||||
|
||||
def jump_to_monitor(self, index: int) -> None:
|
||||
"""Teleport to *index* (0-based, resolved by the controller) and land
|
||||
with a hop. Instant rather than a stroll — Bolt asked to *jump*, and
|
||||
walking between screens would take the long way across the desktop."""
|
||||
screens = QApplication.screens()
|
||||
if not (0 <= index < len(screens)):
|
||||
return
|
||||
geo = screens[index].availableGeometry()
|
||||
point = self._clamp_to_screen(
|
||||
QPoint(
|
||||
geo.left() + (geo.width() - self.width()) // 2,
|
||||
geo.top() + (geo.height() - self.height()) // 2,
|
||||
),
|
||||
geo,
|
||||
)
|
||||
self._stop_walking() # drop any stroll in flight, or it walks straight back
|
||||
self.move(point)
|
||||
self._schedule_next_wander()
|
||||
self._publish_pet_monitor(force=True)
|
||||
self.start_emote("hop")
|
||||
self.update()
|
||||
|
||||
def _screen_geometry(self):
|
||||
# screenAt() so a multi-monitor setup keeps the pet on the screen
|
||||
# it's currently standing on rather than yanking it to the primary.
|
||||
@@ -389,12 +533,17 @@ class PetWindow(QWidget):
|
||||
self._next_wander_at = time.monotonic() + random.uniform(0.5 * base, 1.5 * base)
|
||||
|
||||
def _stop_walking(self) -> None:
|
||||
if self._wander_target is None and not self._bob_offset:
|
||||
if self._wander_target is None and not self._bob_offset and not self._walking:
|
||||
return
|
||||
self._wander_target = None
|
||||
self._commanded_move = False
|
||||
self._bob_phase = 0.0
|
||||
self._bob_offset = 0
|
||||
self._walking = False
|
||||
self._walk_distance = 0.0
|
||||
# Back to a standing frame, so the next stroll starts from a contact
|
||||
# pose instead of mid-stride.
|
||||
self.sprites.get(WALK).reset()
|
||||
self.update()
|
||||
|
||||
def snap_to_edge(self) -> bool:
|
||||
@@ -447,6 +596,13 @@ class PetWindow(QWidget):
|
||||
wandering only happens when it's otherwise unoccupied."""
|
||||
self._advance_emote()
|
||||
self._wander_tick()
|
||||
# Report crossing a screen boundary — by strolling, by being dragged,
|
||||
# by anything. Guarded on the position actually changing so the common
|
||||
# case (a stationary pet, 30x a second) costs one comparison.
|
||||
position = self.pos()
|
||||
if position != self._last_published_pos:
|
||||
self._last_published_pos = position
|
||||
self._publish_pet_monitor()
|
||||
|
||||
def _wander_tick(self) -> None:
|
||||
# Only stroll while genuinely idle: not mid-drag, not napping, not
|
||||
@@ -484,11 +640,51 @@ class PetWindow(QWidget):
|
||||
self.snap_to_edge()
|
||||
else:
|
||||
self.move(round(here.x() + dx / distance * step), round(here.y() + dy / distance * step))
|
||||
self._bob_phase += 0.45 # little walk-cycle hop
|
||||
self._bob_offset = int(round(-2.5 * abs(math.sin(self._bob_phase))))
|
||||
self._advance_walk(dx, dy, step)
|
||||
self.update()
|
||||
self._reposition_bubble()
|
||||
|
||||
def _advance_walk(self, dx: float, dy: float, step: float) -> None:
|
||||
"""Drive the walk cycle from distance travelled (see the constant).
|
||||
|
||||
Falls back to the old bob-in-code if there's no walk art, so a sprite
|
||||
folder without a walk/ directory still looks like it's moving rather
|
||||
than sliding perfectly flat.
|
||||
"""
|
||||
self._walking = True
|
||||
# Only turn on meaningful horizontal travel: a near-vertical stroll
|
||||
# would otherwise flip him back and forth on rounding noise.
|
||||
if abs(dx) > 1.0:
|
||||
self._facing = 1 if dx > 0 else -1
|
||||
if not self.sprites.has(WALK):
|
||||
self._bob_phase += 0.45
|
||||
self._bob_offset = int(round(-2.5 * abs(math.sin(self._bob_phase))))
|
||||
return
|
||||
self._bob_offset = 0 # the walk frames carry their own weight shift
|
||||
self._walk_distance += step
|
||||
while self._walk_distance >= _WALK_PIXELS_PER_FRAME:
|
||||
self._walk_distance -= _WALK_PIXELS_PER_FRAME
|
||||
self.sprites.get(WALK).advance()
|
||||
|
||||
def _animation_key(self):
|
||||
"""Walking overrides the state animation — but only while genuinely
|
||||
idle-and-moving, so he doesn't trot on the spot mid-sentence."""
|
||||
if self._walking and self.sprites.has(WALK):
|
||||
return WALK
|
||||
return self._current_state
|
||||
|
||||
def _oriented(self, pixmap: Optional[QPixmap]) -> Optional[QPixmap]:
|
||||
"""Mirror the (right-facing) walk art when he's heading left. Cached
|
||||
per source frame — flipping on every paint would be wasteful at 30fps."""
|
||||
if pixmap is None or self._facing >= 0:
|
||||
return pixmap
|
||||
key = pixmap.cacheKey()
|
||||
mirrored = self._mirror_cache.get(key)
|
||||
if mirrored is None:
|
||||
mirrored = pixmap.transformed(QTransform().scale(-1, 1), Qt.SmoothTransformation)
|
||||
self._mirror_cache[key] = mirrored
|
||||
return mirrored
|
||||
|
||||
# ── state / speech ──────────────────────────────────────────────────
|
||||
|
||||
def set_state(self, state: PetState) -> None:
|
||||
@@ -513,7 +709,40 @@ class PetWindow(QWidget):
|
||||
|
||||
# ── animation ────────────────────────────────────────────────────────
|
||||
|
||||
def set_mouth(self, level: float) -> None:
|
||||
"""How loud the pet is *right now* (0..1), straight off the PCM going
|
||||
to the speakers (audio/tts.level_of).
|
||||
|
||||
The talking frames are ordered by mouth openness, so this indexes them
|
||||
directly: the mouth moves with the actual waveform instead of flapping
|
||||
on a timer, which is the difference between a talking sprite and a
|
||||
dubbed one."""
|
||||
self._mouth_level = max(0.0, min(1.0, float(level)))
|
||||
self._mouth_at = time.monotonic()
|
||||
if self._current_state == PetState.TALKING:
|
||||
self.update()
|
||||
|
||||
def _mouth_frame(self, animation) -> Optional[QPixmap]:
|
||||
"""The frame matching the current loudness, or None to use the timer.
|
||||
|
||||
Falls back the moment the levels go stale — offline TTS has no
|
||||
envelope, and a mouth frozen mid-syllable is worse than a timed loop."""
|
||||
if self._mouth_level is None or animation is None:
|
||||
return None
|
||||
if time.monotonic() - self._mouth_at > _MOUTH_STALE_SECONDS:
|
||||
return None
|
||||
frames = animation.frames
|
||||
if len(frames) < 2:
|
||||
return None
|
||||
index = int(round(self._mouth_level * (len(frames) - 1)))
|
||||
return frames[max(0, min(len(frames) - 1, index))]
|
||||
|
||||
def _advance_frame(self) -> None:
|
||||
# While walking the cycle is stepped by _advance_walk from distance
|
||||
# travelled; letting this timer also advance it would double-step it
|
||||
# and put the feet out of sync with the movement.
|
||||
if self._walking and self.sprites.has(WALK):
|
||||
return
|
||||
self.sprites.get(self._current_state).advance()
|
||||
self.update()
|
||||
|
||||
@@ -521,7 +750,15 @@ class PetWindow(QWidget):
|
||||
painter = QPainter(self)
|
||||
painter.setRenderHint(QPainter.Antialiasing)
|
||||
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:
|
||||
self._apply_input_mask(None, 0, 0)
|
||||
return
|
||||
@@ -576,6 +813,9 @@ class PetWindow(QWidget):
|
||||
was_click = not self._dragged
|
||||
self._drag_offset = None
|
||||
self._press_pos = None
|
||||
if not was_click and config.PET_REMEMBER_POSITION:
|
||||
position = self.geometry().topLeft()
|
||||
window_state.save(position.x(), position.y())
|
||||
if was_click:
|
||||
self.talk_requested.emit()
|
||||
else:
|
||||
|
||||
@@ -95,17 +95,46 @@ def _load_frames_from_dir(directory: Path, size: int) -> list[QPixmap]:
|
||||
return frames
|
||||
|
||||
|
||||
WALK = "walk"
|
||||
|
||||
# Animations that aren't pipeline states. Walking is a property of *movement*,
|
||||
# orthogonal to whether the pet is idle/listening/talking, so it deliberately
|
||||
# isn't a PetState — state.py stays a description of the conversation, not of
|
||||
# the body. Loaded the same way, keyed by name.
|
||||
EXTRA_ANIMATIONS = (WALK,)
|
||||
|
||||
|
||||
class SpriteSet:
|
||||
"""All animations for every PetState, 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):
|
||||
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:
|
||||
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)
|
||||
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:
|
||||
return self._animations[state]
|
||||
@staticmethod
|
||||
def _key(key) -> str:
|
||||
return key.value if isinstance(key, PetState) else str(key)
|
||||
|
||||
def get(self, key) -> SpriteAnimation:
|
||||
"""Animation for a PetState or an extra name. Unknown/absent extras
|
||||
fall back to idle, so a sprite folder with no walk/ still runs."""
|
||||
return self._animations.get(self._key(key)) or self._animations[PetState.IDLE.value]
|
||||
|
||||
def has(self, key) -> bool:
|
||||
"""True only when real frames were found — the caller uses this to
|
||||
decide whether to use an extra animation at all, rather than being
|
||||
handed a placeholder blob that looks nothing like walking."""
|
||||
return self._key(key) in self._loaded
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""System tray icon — the pet window is frameless with no taskbar entry, so
|
||||
this menu is the only always-available way to control or exit it: talk now,
|
||||
mute, wander, click-through, nap, history, wake-word tuning, 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
|
||||
nothing about the controller or the pet window.
|
||||
@@ -46,6 +47,7 @@ class PetTray(QSystemTrayIcon):
|
||||
on_set_nap: Optional[Callable[[bool], None]] = None,
|
||||
on_show_history: Optional[Callable[[], None]] = None,
|
||||
on_show_wake_tuner: Optional[Callable[[], None]] = None,
|
||||
on_reset_voice: Optional[Callable[[], None]] = None,
|
||||
parent=None,
|
||||
):
|
||||
super().__init__(_make_icon(muted=False), parent)
|
||||
@@ -102,6 +104,16 @@ class PetTray(QSystemTrayIcon):
|
||||
tuner_action.triggered.connect(on_show_wake_tuner)
|
||||
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()
|
||||
quit_action = QAction("Quit", menu)
|
||||
quit_action.triggered.connect(on_quit)
|
||||
@@ -115,6 +127,16 @@ class PetTray(QSystemTrayIcon):
|
||||
self._mute_action.setChecked(self._muted)
|
||||
self._refresh_icon()
|
||||
|
||||
def set_voice(self, voice: str) -> None:
|
||||
"""Reflect the voice the controller is speaking in — a name (or id)
|
||||
when the server picked one, "" for Bolt's own."""
|
||||
if self._voice_action is None:
|
||||
return
|
||||
self._voice_action.setEnabled(bool(voice))
|
||||
self._voice_action.setText(
|
||||
f"Use default voice (now: {voice})" if voice else "Use default voice"
|
||||
)
|
||||
|
||||
def set_napping(self, napping: bool) -> None:
|
||||
"""Reflect a nap the *controller* decided on (quiet hours, fullscreen,
|
||||
or a petctl command) — not just ones clicked here."""
|
||||
|
||||
@@ -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
|
||||
@@ -8,6 +8,9 @@ numpy>=1.24
|
||||
|
||||
# HTTP client to the Bolt desk API
|
||||
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
|
||||
# custom-trained thunderbolt.onnx model shipped in this repo, same runtime
|
||||
@@ -32,6 +35,18 @@ pynput>=1.7
|
||||
# Not imported by the app itself.
|
||||
Pillow>=10.0
|
||||
|
||||
# Screen reading (`petctl read`) — Bolt OCRs a monitor and uses the text in
|
||||
# his reply. Both optional: without them `petctl read` reports what's missing
|
||||
# and the rest of the pet is unaffected.
|
||||
# mss screen capture. X11/Win32/macOS — NOT Wayland.
|
||||
# pytesseract a thin wrapper; the actual engine is a system package:
|
||||
# sudo apt install tesseract-ocr
|
||||
# No-sudo alternative to those two lines: pip install rapidocr-onnxruntime
|
||||
# (pure pip, reuses the onnxruntime openwakeword already pulls in, slower to
|
||||
# start). screen_text.resolve_engine() picks whichever is present.
|
||||
mss>=9.0
|
||||
pytesseract>=0.3.10
|
||||
|
||||
# Test runner (tests/ — pure logic, no audio hardware or display needed;
|
||||
# run with QT_QPA_PLATFORM=offscreen).
|
||||
pytest>=8.0
|
||||
|
||||
@@ -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()
|
||||
@@ -22,6 +22,14 @@ def no_screen_probes(monkeypatch):
|
||||
monkeypatch.setattr(controller_mod.screen_context, "is_fullscreen_active", lambda: False)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _no_streaming(monkeypatch):
|
||||
"""Most tests drive the plain request/response path; leaving streaming on
|
||||
would have them attempt a real HTTP call, fail, and fall back — passing,
|
||||
slowly, for the wrong reason. The streaming path has its own tests."""
|
||||
monkeypatch.setattr(controller_mod.config, "STREAMING_REPLIES", False)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ctrl():
|
||||
return controller_mod.PetController()
|
||||
@@ -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.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 = []
|
||||
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()
|
||||
|
||||
@@ -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)
|
||||
called = {"n": 0}
|
||||
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()
|
||||
|
||||
@@ -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.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")
|
||||
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")
|
||||
spoken = []
|
||||
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)
|
||||
|
||||
ctrl._maybe_heartbeat()
|
||||
|
||||
@@ -5,6 +5,7 @@ the live wake threshold.
|
||||
Needs a QApplication (signals), so run with QT_QPA_PLATFORM=offscreen.
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
@@ -14,6 +15,7 @@ from PySide6.QtWidgets import QApplication
|
||||
|
||||
from bolt_pet import controller as controller_mod
|
||||
from bolt_pet.notifications import Notification
|
||||
from bolt_pet.server_client import Reply
|
||||
from bolt_pet.state import PetState
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
@@ -27,6 +29,14 @@ def no_screen_probes(monkeypatch):
|
||||
monkeypatch.setattr(controller_mod.screen_context, "context_for", lambda text: text)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _no_streaming(monkeypatch):
|
||||
"""Most tests drive the plain request/response path; leaving streaming on
|
||||
would have them attempt a real HTTP call, fail, and fall back — passing,
|
||||
slowly, for the wrong reason. The streaming path has its own tests."""
|
||||
monkeypatch.setattr(controller_mod.config, "STREAMING_REPLIES", False)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ctrl():
|
||||
return controller_mod.PetController()
|
||||
@@ -79,6 +89,62 @@ def test_petctl_nap_also_flips_the_controller_state(ctrl):
|
||||
assert ctrl._napping is True
|
||||
|
||||
|
||||
# ── filectl routing ──────────────────────────────────────────────────────────
|
||||
# filectl's wire format is a single-line JSON envelope (see file_ops.py's
|
||||
# module docstring for why) — build commands with json.dumps so the tests
|
||||
# don't hardcode escaping by hand.
|
||||
|
||||
def _filectl(payload: dict) -> str:
|
||||
return "filectl " + json.dumps(payload)
|
||||
|
||||
|
||||
def test_filectl_commands_never_reach_the_shell(monkeypatch, ctrl, tmp_path):
|
||||
ran = []
|
||||
monkeypatch.setattr(controller_mod.server_client, "run_local_command", lambda cmd: ran.append(cmd))
|
||||
target = tmp_path / "a.txt"
|
||||
|
||||
output = ctrl._handle_command(_filectl({"op": "write", "path": str(target), "content": "hello"}))
|
||||
|
||||
assert ran == []
|
||||
assert target.read_text() == "hello"
|
||||
assert str(target) in output
|
||||
|
||||
|
||||
def test_filectl_edit_round_trips_through_the_relay(monkeypatch, ctrl, tmp_path):
|
||||
monkeypatch.setattr(controller_mod.server_client, "run_local_command",
|
||||
lambda cmd: (_ for _ in ()).throw(AssertionError("should not shell out")))
|
||||
target = tmp_path / "a.py"
|
||||
target.write_text("x = 1\n")
|
||||
|
||||
output = ctrl._handle_command(
|
||||
_filectl({"op": "edit", "path": str(target), "old": "x = 1", "new": "x = 2"})
|
||||
)
|
||||
|
||||
assert target.read_text() == "x = 2\n"
|
||||
assert str(target) in output
|
||||
|
||||
|
||||
def test_bad_filectl_syntax_is_reported_back_not_executed(monkeypatch, ctrl):
|
||||
ran = []
|
||||
monkeypatch.setattr(controller_mod.server_client, "run_local_command", lambda cmd: ran.append(cmd))
|
||||
output = ctrl._handle_command("filectl {not valid json")
|
||||
assert ran == []
|
||||
assert "[filectl]" in output
|
||||
|
||||
|
||||
def test_filectl_execution_failure_is_reported_back_not_raised(monkeypatch, ctrl):
|
||||
output = ctrl._handle_command(_filectl({"op": "read", "path": "/no/such/file.txt"}))
|
||||
assert "[filectl]" in output
|
||||
|
||||
|
||||
def test_ordinary_commands_still_run_locally_alongside_filectl(monkeypatch, ctrl):
|
||||
ran = []
|
||||
monkeypatch.setattr(controller_mod.server_client, "run_local_command",
|
||||
lambda cmd: ran.append(cmd) or "[exit 0]\n")
|
||||
ctrl._handle_command("df -h /")
|
||||
assert ran == ["df -h /"]
|
||||
|
||||
|
||||
# ── barge-in ────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_the_interrupt_log_reports_what_fired_not_the_reset_counters(monkeypatch, ctrl):
|
||||
@@ -102,7 +168,7 @@ def test_the_interrupt_log_reports_what_fired_not_the_reset_counters(monkeypatch
|
||||
ctrl._barge_in = detector
|
||||
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
|
||||
# clears the threshold and playback aborts.
|
||||
detector._frames, detector._peak, detector._last = 7, 0.81, 0.81
|
||||
@@ -122,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):
|
||||
logs = _capture(ctrl.log)
|
||||
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")
|
||||
|
||||
@@ -132,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):
|
||||
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")
|
||||
assert not ctrl._talk_now.is_set()
|
||||
|
||||
@@ -144,7 +210,7 @@ def spoke(monkeypatch):
|
||||
"""Playback that always completes, so only the follow-up rule decides
|
||||
whether another turn is queued."""
|
||||
monkeypatch.setattr(controller_mod.tts, "speak",
|
||||
lambda text, on_error=None, should_stop=None: 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):
|
||||
@@ -232,7 +298,7 @@ def test_follow_up_can_be_turned_off(spoke, monkeypatch, ctrl):
|
||||
|
||||
def test_being_interrupted_restarts_the_chain(monkeypatch, ctrl):
|
||||
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._speak("a very long explanation")
|
||||
@@ -243,7 +309,7 @@ def test_being_interrupted_restarts_the_chain(monkeypatch, ctrl):
|
||||
|
||||
def test_speech_is_recorded_in_the_history(monkeypatch, ctrl):
|
||||
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")
|
||||
assert ctrl.history.last().text == "**bold** reply" # raw, for copy/paste
|
||||
|
||||
@@ -257,10 +323,10 @@ def test_the_active_window_rides_along_with_the_utterance(monkeypatch, ctrl):
|
||||
monkeypatch.setattr(controller_mod.screen_context, "context_for",
|
||||
lambda text: f"{text}\n\n[on screen right now: app.py]")
|
||||
monkeypatch.setattr(controller_mod.tts, "speak",
|
||||
lambda text, on_error=None, should_stop=None: True)
|
||||
lambda text, on_error=None, should_stop=None, voice_id=None, on_level=None: True)
|
||||
sent = []
|
||||
monkeypatch.setattr(controller_mod.server_client, "converse",
|
||||
lambda text, on_command=None: 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()
|
||||
|
||||
@@ -313,10 +379,10 @@ def test_napping_still_answers_when_spoken_to(monkeypatch, ctrl):
|
||||
lambda *a, **k: np.zeros(10, dtype=np.int16))
|
||||
monkeypatch.setattr(controller_mod.stt, "transcribe", lambda pcm: "you awake?")
|
||||
monkeypatch.setattr(controller_mod.server_client, "converse",
|
||||
lambda text, on_command=None: "always")
|
||||
lambda text, on_command=None, on_say=None: Reply("always"))
|
||||
spoken = []
|
||||
monkeypatch.setattr(controller_mod.tts, "speak",
|
||||
lambda text, on_error=None, should_stop=None: 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._handle_conversation_turn()
|
||||
@@ -331,9 +397,9 @@ def test_notifications_are_forwarded_and_spoken(monkeypatch, ctrl):
|
||||
ctrl._notification_gate = controller_mod.notifications.NotificationGate("", 0)
|
||||
sent, spoken = [], []
|
||||
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",
|
||||
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._drain_notifications()
|
||||
@@ -353,7 +419,7 @@ def test_notifications_are_not_forwarded_while_napping(monkeypatch, ctrl):
|
||||
ctrl._notification_gate = controller_mod.notifications.NotificationGate("", 0)
|
||||
called = {"n": 0}
|
||||
monkeypatch.setattr(controller_mod.server_client, "converse",
|
||||
lambda text, on_command=None: 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._queue_notification(Notification(app="CI", summary="Build finished", body=""))
|
||||
@@ -383,3 +449,594 @@ def test_near_misses_are_recorded_for_the_tuner(ctrl):
|
||||
|
||||
ctrl.reset_wake_stats()
|
||||
assert ctrl.wake_stats()["near_misses"] == []
|
||||
|
||||
|
||||
# ── file delivery ────────────────────────────────────────────────────────────
|
||||
|
||||
def test_check_deliveries_downloads_and_saves_queued_files(monkeypatch, ctrl, tmp_path):
|
||||
monkeypatch.setattr(controller_mod.config, "DELIVERED_FILES_DIR", tmp_path)
|
||||
monkeypatch.setattr(controller_mod.server_client, "list_outbox_files",
|
||||
lambda: [{"id": "abc", "name": "report.pdf", "size": 5}])
|
||||
monkeypatch.setattr(controller_mod.server_client, "download_outbox_file",
|
||||
lambda file_id: b"hello" if file_id == "abc" else b"")
|
||||
|
||||
ctrl._check_deliveries()
|
||||
|
||||
assert (tmp_path / "report.pdf").read_bytes() == b"hello"
|
||||
|
||||
|
||||
def test_check_deliveries_is_a_noop_when_nothing_queued(monkeypatch, ctrl, tmp_path):
|
||||
monkeypatch.setattr(controller_mod.config, "DELIVERED_FILES_DIR", tmp_path)
|
||||
monkeypatch.setattr(controller_mod.server_client, "list_outbox_files", lambda: [])
|
||||
downloaded = []
|
||||
monkeypatch.setattr(controller_mod.server_client, "download_outbox_file",
|
||||
lambda file_id: downloaded.append(file_id))
|
||||
|
||||
ctrl._check_deliveries()
|
||||
|
||||
assert downloaded == []
|
||||
|
||||
|
||||
def test_check_deliveries_can_be_disabled(monkeypatch, ctrl):
|
||||
monkeypatch.setattr(controller_mod.config, "RECEIVE_FILES", False)
|
||||
called = {"n": 0}
|
||||
monkeypatch.setattr(controller_mod.server_client, "list_outbox_files",
|
||||
lambda: called.__setitem__("n", called["n"] + 1))
|
||||
|
||||
ctrl._check_deliveries()
|
||||
|
||||
assert called["n"] == 0
|
||||
|
||||
|
||||
def test_check_deliveries_logs_and_continues_on_download_failure(monkeypatch, ctrl, tmp_path):
|
||||
monkeypatch.setattr(controller_mod.config, "DELIVERED_FILES_DIR", tmp_path)
|
||||
monkeypatch.setattr(controller_mod.server_client, "list_outbox_files", lambda: [
|
||||
{"id": "bad", "name": "a.txt", "size": 1},
|
||||
{"id": "good", "name": "b.txt", "size": 1},
|
||||
])
|
||||
|
||||
def fake_download(file_id):
|
||||
if file_id == "bad":
|
||||
raise controller_mod.server_client.ServerError("gone")
|
||||
return b"ok"
|
||||
|
||||
monkeypatch.setattr(controller_mod.server_client, "download_outbox_file", fake_download)
|
||||
logs = _capture(ctrl.log)
|
||||
|
||||
ctrl._check_deliveries()
|
||||
|
||||
assert (tmp_path / "b.txt").read_bytes() == b"ok"
|
||||
assert not (tmp_path / "a.txt").exists()
|
||||
assert any("bad" in msg or "a.txt" in msg for msg in logs)
|
||||
|
||||
|
||||
def test_check_deliveries_runs_after_a_conversation_turn(monkeypatch, ctrl, tmp_path):
|
||||
monkeypatch.setattr(controller_mod.mic, "record_utterance",
|
||||
lambda *a, **k: np.zeros(10, dtype=np.int16))
|
||||
monkeypatch.setattr(controller_mod.stt, "transcribe", lambda pcm: "send me that file")
|
||||
monkeypatch.setattr(controller_mod.server_client, "converse",
|
||||
lambda text, on_command=None, on_say=None: Reply("it's on the way"))
|
||||
monkeypatch.setattr(controller_mod.tts, "speak",
|
||||
lambda text, on_error=None, should_stop=None, voice_id=None, on_level=None: True)
|
||||
monkeypatch.setattr(controller_mod.config, "DELIVERED_FILES_DIR", tmp_path)
|
||||
monkeypatch.setattr(controller_mod.server_client, "list_outbox_files",
|
||||
lambda: [{"id": "abc", "name": "notes.txt", "size": 2}])
|
||||
monkeypatch.setattr(controller_mod.server_client, "download_outbox_file",
|
||||
lambda file_id: b"hi")
|
||||
|
||||
ctrl._handle_conversation_turn()
|
||||
|
||||
assert (tmp_path / "notes.txt").read_bytes() == b"hi"
|
||||
|
||||
|
||||
# ── server-picked voice (the desk API's speak_as marker) ────────────────────
|
||||
|
||||
def _voice_turn(monkeypatch, ctrl, reply, said="talk like a pirate"):
|
||||
"""Run one full conversation turn whose reply is *reply*, returning the
|
||||
voice_id each tts.speak() call was given."""
|
||||
voices = []
|
||||
monkeypatch.setattr(controller_mod.mic, "record_utterance",
|
||||
lambda *a, **k: np.zeros(10, dtype=np.int16))
|
||||
monkeypatch.setattr(controller_mod.stt, "transcribe", lambda pcm: said)
|
||||
monkeypatch.setattr(controller_mod.server_client, "converse",
|
||||
lambda text, on_command=None, on_say=None: reply)
|
||||
monkeypatch.setattr(
|
||||
controller_mod.tts, "speak",
|
||||
lambda text, on_error=None, should_stop=None, voice_id=None, on_level=None:
|
||||
voices.append(voice_id) or True,
|
||||
)
|
||||
ctrl._handle_conversation_turn()
|
||||
return voices
|
||||
|
||||
|
||||
def test_a_speak_as_reply_is_spoken_in_that_voice(monkeypatch, ctrl):
|
||||
voices = _voice_turn(monkeypatch, ctrl, Reply("Ahoy.", "VOICE1", "Terence"))
|
||||
assert voices == ["VOICE1"]
|
||||
assert ctrl.current_voice() == "Terence"
|
||||
|
||||
|
||||
def test_the_picked_voice_sticks_for_later_replies(monkeypatch, ctrl):
|
||||
"""The server tags one reply and doesn't keep the id in its history, so
|
||||
it can't re-request the voice when you say "keep talking like that"."""
|
||||
monkeypatch.setattr(controller_mod.config, "VOICE_STICKY", True)
|
||||
_voice_turn(monkeypatch, ctrl, Reply("Ahoy.", "VOICE1", "Terence"))
|
||||
voices = _voice_turn(monkeypatch, ctrl, Reply("Still me."), said="and now?")
|
||||
assert voices == ["VOICE1"]
|
||||
|
||||
|
||||
def test_voice_stickiness_can_be_turned_off(monkeypatch, ctrl):
|
||||
monkeypatch.setattr(controller_mod.config, "VOICE_STICKY", False)
|
||||
_voice_turn(monkeypatch, ctrl, Reply("Ahoy.", "VOICE1", "Terence"))
|
||||
voices = _voice_turn(monkeypatch, ctrl, Reply("Back to normal."), said="and now?")
|
||||
assert voices == [None]
|
||||
assert ctrl.current_voice() == ""
|
||||
|
||||
|
||||
def test_a_new_pick_replaces_the_old_one(monkeypatch, ctrl):
|
||||
changes = _capture(ctrl.voice_changed)
|
||||
_voice_turn(monkeypatch, ctrl, Reply("Ahoy.", "VOICE1", "Terence"))
|
||||
voices = _voice_turn(monkeypatch, ctrl, Reply("こんにちは。", "VOICE2", "Asahi"),
|
||||
said="say that in Japanese")
|
||||
assert voices == ["VOICE2"]
|
||||
assert changes == ["Terence", "Asahi"]
|
||||
|
||||
|
||||
def test_resetting_the_voice_goes_back_to_the_default(monkeypatch, ctrl):
|
||||
_voice_turn(monkeypatch, ctrl, Reply("Ahoy.", "VOICE1", "Terence"))
|
||||
changes = _capture(ctrl.voice_changed)
|
||||
|
||||
ctrl.reset_voice()
|
||||
|
||||
assert ctrl.current_voice() == ""
|
||||
assert changes == [""] # the tray's menu entry follows this signal
|
||||
voices = _voice_turn(monkeypatch, ctrl, Reply("Normal again."), said="hi")
|
||||
assert voices == [None]
|
||||
|
||||
|
||||
def test_an_unnamed_voice_still_reports_something_resettable(monkeypatch, ctrl):
|
||||
"""voice_name is optional server-side — falling back to the id keeps the
|
||||
tray entry from reading "now: " with nothing after it."""
|
||||
_voice_turn(monkeypatch, ctrl, Reply("Ahoy.", "VOICE1"))
|
||||
assert ctrl.current_voice() == "VOICE1"
|
||||
|
||||
|
||||
def test_petctl_voice_reset_returns_bolt_to_his_own_voice(monkeypatch, ctrl):
|
||||
"""The server can pick a voice but can't ask for the default back — it
|
||||
was never told what Bolt's own voice id is. This is how it asks."""
|
||||
ran = []
|
||||
monkeypatch.setattr(controller_mod.server_client, "run_local_command", lambda cmd: ran.append(cmd))
|
||||
_voice_turn(monkeypatch, ctrl, Reply("Ahoy.", "VOICE1", "Terence"))
|
||||
|
||||
output = ctrl._handle_command("petctl voice reset")
|
||||
|
||||
assert ran == [] # never reaches a shell, like every other petctl verb
|
||||
assert "Terence" in output # the server can't see the voice; tell it what changed
|
||||
assert ctrl.current_voice() == ""
|
||||
assert _voice_turn(monkeypatch, ctrl, Reply("Normal again."), said="hi") == [None]
|
||||
|
||||
|
||||
def test_petctl_voice_reset_says_so_when_there_was_nothing_to_reset(ctrl):
|
||||
assert "already" in ctrl._handle_command("petctl voice reset")
|
||||
|
||||
|
||||
# ── dialoguectl (multi-voice scenes) ────────────────────────────────────────
|
||||
|
||||
def _dialogue_command(*lines):
|
||||
import json
|
||||
return "dialoguectl " + json.dumps({"lines": list(lines)})
|
||||
|
||||
|
||||
def test_dialoguectl_never_reaches_the_shell(monkeypatch, ctrl):
|
||||
ran = []
|
||||
monkeypatch.setattr(controller_mod.server_client, "run_local_command", lambda cmd: ran.append(cmd))
|
||||
monkeypatch.setattr(controller_mod.config, "ELEVENLABS_VOICE_ID", "aaorr6ZHIL88gEexu7dC")
|
||||
monkeypatch.setattr(controller_mod.tts, "synthesize_dialogue",
|
||||
lambda inputs, model_id=None, stability=None: (np.zeros(4, dtype=np.int16), 24000))
|
||||
monkeypatch.setattr(controller_mod.tts, "play_pcm",
|
||||
lambda pcm, rate, should_stop=None, on_level=None: True)
|
||||
|
||||
output = ctrl._handle_command(_dialogue_command({"voice": "self", "text": "[cheerfully] hi"}))
|
||||
|
||||
assert ran == []
|
||||
assert "[dialogue] played 1 line" in output
|
||||
|
||||
|
||||
def test_a_scene_shows_in_the_bubble_with_the_delivery_tags_stripped(monkeypatch, ctrl):
|
||||
monkeypatch.setattr(controller_mod.config, "ELEVENLABS_VOICE_ID", "aaorr6ZHIL88gEexu7dC")
|
||||
monkeypatch.setattr(controller_mod.config, "DIALOGUE_VOICES", "narrator:9BWtsMINqrJLrRacOk9x")
|
||||
monkeypatch.setattr(controller_mod.tts, "synthesize_dialogue",
|
||||
lambda inputs, model_id=None, stability=None: (np.zeros(4, dtype=np.int16), 24000))
|
||||
monkeypatch.setattr(controller_mod.tts, "play_pcm", lambda pcm, rate, should_stop=None, on_level=None: True)
|
||||
said = _capture(ctrl.said)
|
||||
|
||||
ctrl._handle_command(_dialogue_command(
|
||||
{"voice": "self", "text": "[cheerfully] Hello there!"},
|
||||
{"voice": "narrator", "text": "[whispering] He is lying."},
|
||||
))
|
||||
|
||||
assert said == ["Hello there! He is lying."]
|
||||
assert ctrl.history.last().text == "Hello there! He is lying."
|
||||
|
||||
|
||||
def test_a_mid_turn_scene_returns_to_thinking_not_idle(monkeypatch, ctrl):
|
||||
"""The server is still waiting on the tool result, so the pet talks and
|
||||
goes back to waiting — dropping to IDLE would look like the turn ended."""
|
||||
monkeypatch.setattr(controller_mod.config, "ELEVENLABS_VOICE_ID", "aaorr6ZHIL88gEexu7dC")
|
||||
monkeypatch.setattr(controller_mod.tts, "synthesize_dialogue",
|
||||
lambda inputs, model_id=None, stability=None: (np.zeros(4, dtype=np.int16), 24000))
|
||||
monkeypatch.setattr(controller_mod.tts, "play_pcm", lambda pcm, rate, should_stop=None, on_level=None: True)
|
||||
ctrl._state.transition(PetState.LISTENING)
|
||||
ctrl._state.transition(PetState.THINKING)
|
||||
states = _capture(ctrl.state_changed)
|
||||
|
||||
ctrl._handle_command(_dialogue_command({"voice": "self", "text": "hi"}))
|
||||
|
||||
assert states == ["talking", "thinking"]
|
||||
assert ctrl._state.state == PetState.THINKING
|
||||
|
||||
|
||||
def test_the_scene_uses_a_voice_the_server_picked_with_speak_as(monkeypatch, ctrl):
|
||||
monkeypatch.setattr(controller_mod.config, "ELEVENLABS_VOICE_ID", "aaorr6ZHIL88gEexu7dC")
|
||||
seen = {}
|
||||
|
||||
def capture(inputs, model_id=None, stability=None):
|
||||
seen["inputs"] = inputs
|
||||
return np.zeros(4, dtype=np.int16), 24000
|
||||
|
||||
monkeypatch.setattr(controller_mod.tts, "synthesize_dialogue", capture)
|
||||
monkeypatch.setattr(controller_mod.tts, "play_pcm", lambda pcm, rate, should_stop=None, on_level=None: True)
|
||||
ctrl._apply_voice(controller_mod.server_client.Reply("ok", "PICKEDvoice123456789", "Terence"))
|
||||
|
||||
ctrl._handle_command(_dialogue_command({"voice": "self", "text": "hi"}))
|
||||
|
||||
assert seen["inputs"][0]["voice_id"] == "PICKEDvoice123456789"
|
||||
|
||||
|
||||
def test_a_synthesis_failure_is_reported_back_for_bolt_to_retry(monkeypatch, ctrl):
|
||||
monkeypatch.setattr(controller_mod.config, "ELEVENLABS_VOICE_ID", "aaorr6ZHIL88gEexu7dC")
|
||||
|
||||
def boom(inputs, model_id=None, stability=None):
|
||||
raise controller_mod.tts.TtsError("voice_id not found")
|
||||
|
||||
monkeypatch.setattr(controller_mod.tts, "synthesize_dialogue", boom)
|
||||
|
||||
output = ctrl._handle_command(_dialogue_command({"voice": "self", "text": "hi"}))
|
||||
|
||||
assert "couldn't synthesize" in output and "voice_id not found" in output
|
||||
assert ctrl._state.state == PetState.IDLE # nothing left half-transitioned
|
||||
|
||||
|
||||
def test_a_bad_voice_name_comes_back_as_advice_not_an_exception(monkeypatch, ctrl):
|
||||
monkeypatch.setattr(controller_mod.config, "DIALOGUE_VOICES", "narrator:9BWtsMINqrJLrRacOk9x")
|
||||
output = ctrl._handle_command(_dialogue_command({"voice": "wizard", "text": "hi"}))
|
||||
assert "unknown voice" in output and "narrator" in output
|
||||
|
||||
|
||||
def test_dialogue_can_be_switched_off_on_this_device(monkeypatch, ctrl):
|
||||
monkeypatch.setattr(controller_mod.config, "DIALOGUE", False)
|
||||
called = []
|
||||
monkeypatch.setattr(controller_mod.tts, "synthesize_dialogue",
|
||||
lambda *a, **k: called.append(1))
|
||||
output = ctrl._handle_command(_dialogue_command({"voice": "self", "text": "hi"}))
|
||||
assert "disabled" in output and called == []
|
||||
|
||||
|
||||
def test_talking_over_a_scene_is_reported_up_the_relay(monkeypatch, ctrl):
|
||||
monkeypatch.setattr(controller_mod.config, "ELEVENLABS_VOICE_ID", "aaorr6ZHIL88gEexu7dC")
|
||||
monkeypatch.setattr(controller_mod.tts, "synthesize_dialogue",
|
||||
lambda inputs, model_id=None, stability=None: (np.zeros(4, dtype=np.int16), 24000))
|
||||
monkeypatch.setattr(controller_mod.tts, "play_pcm",
|
||||
lambda pcm, rate, should_stop=None, on_level=None: False) # barge-in
|
||||
|
||||
output = ctrl._handle_command(_dialogue_command({"voice": "self", "text": "hi"}))
|
||||
|
||||
assert "interrupted" in output
|
||||
|
||||
|
||||
# ── petctl self_restart ─────────────────────────────────────────────────────
|
||||
|
||||
def test_self_restart_arms_after_the_turn_rather_than_dying_mid_relay(monkeypatch, ctrl, tmp_path):
|
||||
"""Restarting inline would kill the HTTP tool relay before the result was
|
||||
posted, and the server would wait out its timeout on a turn that can never
|
||||
finish. So the command returns, the turn completes, *then* the pet dies."""
|
||||
monkeypatch.setattr(controller_mod.self_restart, "DEFAULT_STATE_PATH", tmp_path / "ctx.json")
|
||||
monkeypatch.setattr(controller_mod.self_restart, "preflight", lambda *a, **k: None)
|
||||
restarts = _capture(ctrl.restart_requested)
|
||||
|
||||
output = ctrl._handle_command("petctl self_restart check the new dialogue code")
|
||||
|
||||
assert "restarting as soon as this turn finishes" in output
|
||||
assert restarts == [] # nothing has happened yet
|
||||
|
||||
assert ctrl._maybe_self_restart() is True
|
||||
assert restarts and "check the new dialogue code" in restarts[0]
|
||||
|
||||
|
||||
def test_a_broken_edit_is_reported_instead_of_leaving_nothing_running(monkeypatch, ctrl, tmp_path):
|
||||
monkeypatch.setattr(controller_mod.self_restart, "DEFAULT_STATE_PATH", tmp_path / "ctx.json")
|
||||
|
||||
def boom(*args, **kwargs):
|
||||
raise controller_mod.self_restart.RestartError(
|
||||
"the current code does not import, so restarting would leave you with "
|
||||
"nothing running. Fix this first:\nSyntaxError: invalid syntax"
|
||||
)
|
||||
|
||||
monkeypatch.setattr(controller_mod.self_restart, "preflight", boom)
|
||||
restarts = _capture(ctrl.restart_requested)
|
||||
|
||||
output = ctrl._handle_command("petctl self_restart try the new code")
|
||||
|
||||
assert "SyntaxError" in output and "refused" in output
|
||||
assert ctrl._maybe_self_restart() is False
|
||||
assert restarts == []
|
||||
|
||||
|
||||
def test_a_second_restart_request_in_one_turn_is_a_no_op(monkeypatch, ctrl, tmp_path):
|
||||
monkeypatch.setattr(controller_mod.self_restart, "DEFAULT_STATE_PATH", tmp_path / "ctx.json")
|
||||
monkeypatch.setattr(controller_mod.self_restart, "preflight", lambda *a, **k: None)
|
||||
ctrl._handle_command("petctl self_restart first")
|
||||
assert "already armed" in ctrl._handle_command("petctl self_restart second")
|
||||
|
||||
|
||||
def test_self_restart_can_be_switched_off_on_this_device(monkeypatch, ctrl):
|
||||
monkeypatch.setattr(controller_mod.config, "SELF_RESTART", False)
|
||||
checked = []
|
||||
monkeypatch.setattr(controller_mod.self_restart, "preflight",
|
||||
lambda *a, **k: checked.append(1))
|
||||
assert "disabled" in ctrl._handle_command("petctl self_restart go")
|
||||
assert checked == []
|
||||
|
||||
|
||||
def test_coming_back_up_reports_to_the_server_and_speaks_the_reply(monkeypatch, ctrl, tmp_path):
|
||||
"""The half that makes it a loop: the new process tells Bolt it's back and
|
||||
why, and his answer is spoken like any other turn."""
|
||||
state = tmp_path / "ctx.json"
|
||||
monkeypatch.setattr(controller_mod.self_restart, "DEFAULT_STATE_PATH", state)
|
||||
controller_mod.self_restart.arm("check the walk cycle", version="0.2.3",
|
||||
path=state, now=1000.0)
|
||||
sent, spoken = [], []
|
||||
monkeypatch.setattr(controller_mod.server_client, "converse",
|
||||
lambda text, on_command=None, on_say=None: sent.append(text) or Reply("Good, it's up."))
|
||||
monkeypatch.setattr(controller_mod.tts, "speak",
|
||||
lambda text, on_error=None, should_stop=None, voice_id=None, on_level=None:
|
||||
spoken.append(text) or True)
|
||||
|
||||
ctrl._report_self_restart()
|
||||
|
||||
assert "[pet self-restart]" in sent[0] and "check the walk cycle" in sent[0]
|
||||
assert spoken == ["Good, it's up."]
|
||||
# Consumed, so the next start doesn't announce the same restart again.
|
||||
assert controller_mod.self_restart.load(state) is None
|
||||
|
||||
|
||||
def test_an_ordinary_start_reports_nothing(monkeypatch, ctrl, tmp_path):
|
||||
monkeypatch.setattr(controller_mod.self_restart, "DEFAULT_STATE_PATH", tmp_path / "none.json")
|
||||
called = []
|
||||
monkeypatch.setattr(controller_mod.server_client, "converse",
|
||||
lambda text, on_command=None, on_say=None: called.append(text))
|
||||
|
||||
ctrl._report_self_restart()
|
||||
|
||||
assert called == []
|
||||
|
||||
|
||||
# ── holding line: "give me a sec" while a tool runs ────────────────────────
|
||||
# The server used to discard whatever the model wrote alongside a tool call,
|
||||
# so the whole round trip was silence — and the model, with no evidence its
|
||||
# sentence landed, said it again in the final reply.
|
||||
|
||||
def test_a_holding_line_is_spoken_before_the_tool_runs(monkeypatch, ctrl):
|
||||
order = []
|
||||
monkeypatch.setattr(controller_mod.tts, "speak",
|
||||
lambda text, on_error=None, should_stop=None, voice_id=None, on_level=None:
|
||||
order.append(("spoke", text)) or True)
|
||||
monkeypatch.setattr(controller_mod.server_client, "run_local_command",
|
||||
lambda cmd: order.append(("ran", cmd)) or "[exit 0]")
|
||||
|
||||
ctrl._speak_holding("Give me a sec.")
|
||||
ctrl._handle_command("df -h /")
|
||||
|
||||
assert order == [("spoke", "Give me a sec."), ("ran", "df -h /")]
|
||||
|
||||
|
||||
def test_the_holding_line_shows_in_the_bubble_but_not_the_transcript(monkeypatch, ctrl):
|
||||
"""It's filler. The transcript should keep the actual answer."""
|
||||
monkeypatch.setattr(controller_mod.tts, "speak",
|
||||
lambda text, on_error=None, should_stop=None, voice_id=None, on_level=None: True)
|
||||
said = _capture(ctrl.said)
|
||||
|
||||
ctrl._speak_holding("I'll check on that — one moment.")
|
||||
|
||||
assert said == ["I'll check on that — one moment."]
|
||||
assert ctrl.history.entries() == []
|
||||
|
||||
|
||||
def test_a_holding_line_returns_to_waiting_not_idle(monkeypatch, ctrl):
|
||||
monkeypatch.setattr(controller_mod.tts, "speak",
|
||||
lambda text, on_error=None, should_stop=None, voice_id=None, on_level=None: True)
|
||||
ctrl._state.transition(PetState.LISTENING)
|
||||
ctrl._state.transition(PetState.THINKING)
|
||||
states = _capture(ctrl.state_changed)
|
||||
|
||||
ctrl._speak_holding("one sec")
|
||||
|
||||
assert states == ["talking", "thinking"]
|
||||
|
||||
|
||||
def test_the_relay_speaks_whatever_the_server_attaches(monkeypatch, ctrl):
|
||||
spoken = []
|
||||
monkeypatch.setattr(controller_mod.tts, "speak",
|
||||
lambda text, on_error=None, should_stop=None, voice_id=None, on_level=None:
|
||||
spoken.append(text) or True)
|
||||
monkeypatch.setattr(controller_mod.mic, "record_utterance",
|
||||
lambda *a, **k: np.zeros(10, dtype=np.int16))
|
||||
monkeypatch.setattr(controller_mod.stt, "transcribe", lambda pcm: "what's in that folder?")
|
||||
|
||||
def fake_converse(text, on_command=None, on_say=None):
|
||||
on_say("Let me check that for you.") # what the server now sends
|
||||
on_command("filectl {\"op\": \"list\", \"path\": \"/tmp\"}")
|
||||
return Reply("It's got three files in it.")
|
||||
|
||||
monkeypatch.setattr(controller_mod.server_client, "converse", fake_converse)
|
||||
|
||||
ctrl._handle_conversation_turn()
|
||||
|
||||
assert spoken == ["Let me check that for you.", "It's got three files in it."]
|
||||
|
||||
|
||||
# ── streamed replies ───────────────────────────────────────────────────────
|
||||
# Without streaming the pet waits out the entire model call before saying a
|
||||
# word. With it, the wait is time-to-first-sentence.
|
||||
|
||||
def test_each_sentence_is_spoken_as_it_arrives(monkeypatch, ctrl):
|
||||
monkeypatch.setattr(controller_mod.config, "STREAMING_REPLIES", True)
|
||||
spoken = []
|
||||
monkeypatch.setattr(controller_mod.tts, "speak",
|
||||
lambda text, on_error=None, should_stop=None, voice_id=None, on_level=None:
|
||||
spoken.append(text) or True)
|
||||
|
||||
def fake_stream(text, on_say, on_command=None, timeout=180.0):
|
||||
on_say("The disk is fine.")
|
||||
on_say("About sixty percent used.")
|
||||
return controller_mod.server_client.Reply(
|
||||
"The disk is fine. About sixty percent used.", spoken=True)
|
||||
|
||||
monkeypatch.setattr(controller_mod.server_client, "converse_stream", fake_stream)
|
||||
monkeypatch.setattr(controller_mod.mic, "record_utterance",
|
||||
lambda *a, **k: np.zeros(10, dtype=np.int16))
|
||||
monkeypatch.setattr(controller_mod.stt, "transcribe", lambda pcm: "how's the disk?")
|
||||
|
||||
ctrl._handle_conversation_turn()
|
||||
|
||||
assert spoken == ["The disk is fine.", "About sixty percent used."]
|
||||
# ...and the empty final reply is not spoken as an extra blank utterance.
|
||||
assert ctrl.history.entries()[-1].text == "About sixty percent used."
|
||||
|
||||
|
||||
def test_a_stream_that_fails_before_speaking_falls_back_silently(monkeypatch, ctrl):
|
||||
monkeypatch.setattr(controller_mod.config, "STREAMING_REPLIES", True)
|
||||
spoken = []
|
||||
monkeypatch.setattr(controller_mod.tts, "speak",
|
||||
lambda text, on_error=None, should_stop=None, voice_id=None, on_level=None:
|
||||
spoken.append(text) or True)
|
||||
|
||||
def broken_stream(text, on_say, on_command=None, timeout=180.0):
|
||||
raise controller_mod.server_client.ServerError("no streaming endpoint")
|
||||
|
||||
monkeypatch.setattr(controller_mod.server_client, "converse_stream", broken_stream)
|
||||
monkeypatch.setattr(controller_mod.server_client, "converse",
|
||||
lambda text, on_command=None, on_say=None: Reply("Fell back fine."))
|
||||
|
||||
assert ctrl._ask_server("hello").text == "Fell back fine."
|
||||
assert spoken == [] # nothing was said twice
|
||||
|
||||
|
||||
def test_streaming_can_be_switched_off(monkeypatch, ctrl):
|
||||
monkeypatch.setattr(controller_mod.config, "STREAMING_REPLIES", False)
|
||||
called = []
|
||||
monkeypatch.setattr(controller_mod.server_client, "converse_stream",
|
||||
lambda *a, **k: called.append(1))
|
||||
monkeypatch.setattr(controller_mod.server_client, "converse",
|
||||
lambda text, on_command=None, on_say=None: Reply("plain path"))
|
||||
|
||||
assert ctrl._ask_server("hi").text == "plain path"
|
||||
assert called == []
|
||||
|
||||
|
||||
def test_talking_over_a_streamed_reply_still_interrupts(monkeypatch, ctrl):
|
||||
monkeypatch.setattr(controller_mod.config, "STREAMING_REPLIES", True)
|
||||
monkeypatch.setattr(controller_mod.tts, "speak",
|
||||
lambda text, on_error=None, should_stop=None, voice_id=None, on_level=None: False)
|
||||
|
||||
ctrl._speak_stream_chunk("A long explanation you cut short.")
|
||||
|
||||
assert ctrl._talk_now.is_set()
|
||||
|
||||
|
||||
# ── notification latency (reported 2026-08-02: 5-10 minutes) ───────────────
|
||||
# Draining was wired to the heartbeat's 60s interval, and a heartbeat that
|
||||
# landed mid-conversation stamped its clock before noticing — so it burned the
|
||||
# slot and waited another full interval. Several of those in a row is minutes.
|
||||
|
||||
def test_a_notification_goes_out_on_the_next_tick_not_the_next_heartbeat(monkeypatch, ctrl):
|
||||
monkeypatch.setattr(controller_mod.config, "NOTIFICATION_MIN_INTERVAL_SECONDS", 0)
|
||||
ctrl._notification_gate = controller_mod.notifications.NotificationGate("", 0)
|
||||
sent = []
|
||||
monkeypatch.setattr(controller_mod.server_client, "converse",
|
||||
lambda text, on_command=None, on_say=None:
|
||||
sent.append(text) or Reply("noted"))
|
||||
monkeypatch.setattr(controller_mod.tts, "speak",
|
||||
lambda text, on_error=None, should_stop=None, voice_id=None, on_level=None: True)
|
||||
monkeypatch.setattr(controller_mod.server_client, "report_status", lambda: None)
|
||||
# A heartbeat has just run, so the next one is a full interval away.
|
||||
ctrl._last_heartbeat = controller_mod.time.monotonic()
|
||||
|
||||
ctrl._queue_notification(Notification(app="Signal", summary="Harry", body="you there?"))
|
||||
ctrl._maybe_heartbeat()
|
||||
|
||||
assert sent and "Harry" in sent[0]
|
||||
|
||||
|
||||
def test_a_heartbeat_skipped_mid_conversation_does_not_burn_its_slot(monkeypatch, ctrl):
|
||||
monkeypatch.setattr(controller_mod.server_client, "report_status", lambda: None)
|
||||
ctrl._last_heartbeat = 0.0
|
||||
ctrl._state.transition(PetState.LISTENING)
|
||||
ctrl._state.transition(PetState.THINKING)
|
||||
|
||||
ctrl._maybe_heartbeat() # due, but the pet is busy
|
||||
assert ctrl._last_heartbeat == 0.0, "the clock must not advance on a skipped tick"
|
||||
|
||||
ctrl._state.transition(PetState.IDLE)
|
||||
ctrl._maybe_heartbeat() # free now — runs immediately
|
||||
assert ctrl._last_heartbeat > 0.0
|
||||
|
||||
|
||||
def test_notifications_wait_while_the_pet_is_mid_turn(monkeypatch, ctrl):
|
||||
"""Speaking over the answer they're already getting would be worse than
|
||||
waiting a couple of seconds."""
|
||||
ctrl._notification_gate = controller_mod.notifications.NotificationGate("", 0)
|
||||
sent = []
|
||||
monkeypatch.setattr(controller_mod.server_client, "converse",
|
||||
lambda text, on_command=None, on_say=None: sent.append(text))
|
||||
ctrl._state.transition(PetState.LISTENING)
|
||||
|
||||
ctrl._queue_notification(Notification(app="CI", summary="Build finished", body=""))
|
||||
ctrl._maybe_heartbeat()
|
||||
|
||||
assert sent == []
|
||||
assert len(ctrl._pending_notifications) == 1 # kept, not dropped
|
||||
|
||||
|
||||
def test_a_second_message_inside_the_rate_limit_is_no_longer_lost(monkeypatch, ctrl):
|
||||
"""The gate used to drop it. With NOTIFICATION_MIN_INTERVAL_SECONDS=60,
|
||||
two texts a minute apart meant you heard about one of them."""
|
||||
ctrl._notification_gate = controller_mod.notifications.NotificationGate("", 60)
|
||||
sent = []
|
||||
monkeypatch.setattr(controller_mod.server_client, "converse",
|
||||
lambda text, on_command=None, on_say=None:
|
||||
sent.append(text) or Reply("ok"))
|
||||
monkeypatch.setattr(controller_mod.tts, "speak",
|
||||
lambda text, on_error=None, should_stop=None, voice_id=None, on_level=None: True)
|
||||
|
||||
ctrl._queue_notification(Notification(app="Signal", summary="Harry", body="you there?"))
|
||||
ctrl._queue_notification(Notification(app="Signal", summary="Harry", body="it's urgent"))
|
||||
ctrl._drain_notifications()
|
||||
|
||||
assert len(sent) == 1 # one round trip...
|
||||
assert "you there?" in sent[0] and "it's urgent" in sent[0] # ...both messages
|
||||
assert "2 desktop notifications" in sent[0]
|
||||
|
||||
|
||||
def test_the_filter_still_applies(ctrl):
|
||||
ctrl._notification_gate = controller_mod.notifications.NotificationGate("deploy", 0)
|
||||
ctrl._queue_notification(Notification(app="Chat", summary="lunch?", body=""))
|
||||
assert ctrl._pending_notifications == []
|
||||
|
||||
|
||||
def test_a_notification_storm_cannot_become_an_unbounded_backlog(ctrl):
|
||||
ctrl._notification_gate = controller_mod.notifications.NotificationGate("", 0)
|
||||
for index in range(40):
|
||||
ctrl._queue_notification(Notification(app="Spam", summary=f"#{index}", body=""))
|
||||
|
||||
assert len(ctrl._pending_notifications) == controller_mod._MAX_PENDING_NOTIFICATIONS
|
||||
assert "#39" in ctrl._pending_notifications[-1].as_text() # newest kept
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -0,0 +1,54 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from bolt_pet import file_delivery
|
||||
|
||||
|
||||
def test_sanitize_filename_strips_directory_components():
|
||||
assert file_delivery.sanitize_filename("../../etc/passwd") == "passwd"
|
||||
assert file_delivery.sanitize_filename("/absolute/path/report.pdf") == "report.pdf"
|
||||
assert file_delivery.sanitize_filename("plain.txt") == "plain.txt"
|
||||
|
||||
|
||||
def test_sanitize_filename_falls_back_on_empty_or_dots():
|
||||
assert file_delivery.sanitize_filename("") == "delivered_file"
|
||||
assert file_delivery.sanitize_filename("..") == "delivered_file"
|
||||
assert file_delivery.sanitize_filename(".") == "delivered_file"
|
||||
assert file_delivery.sanitize_filename(None) == "delivered_file"
|
||||
|
||||
|
||||
def test_unique_path_returns_the_plain_name_when_free(tmp_path):
|
||||
path = file_delivery.unique_path(tmp_path, "report.pdf")
|
||||
assert path == tmp_path / "report.pdf"
|
||||
|
||||
|
||||
def test_unique_path_suffixes_on_collision(tmp_path):
|
||||
(tmp_path / "report.pdf").write_bytes(b"existing")
|
||||
path = file_delivery.unique_path(tmp_path, "report.pdf")
|
||||
assert path == tmp_path / "report (1).pdf"
|
||||
|
||||
(tmp_path / "report (1).pdf").write_bytes(b"also existing")
|
||||
path = file_delivery.unique_path(tmp_path, "report.pdf")
|
||||
assert path == tmp_path / "report (2).pdf"
|
||||
|
||||
|
||||
def test_unique_path_creates_the_directory(tmp_path):
|
||||
target = tmp_path / "nested" / "dir"
|
||||
file_delivery.unique_path(target, "a.txt")
|
||||
assert target.is_dir()
|
||||
|
||||
|
||||
def test_save_writes_bytes_and_sanitizes_the_name(tmp_path):
|
||||
path = file_delivery.save(tmp_path, "../sneaky/report.pdf", b"hello")
|
||||
assert path == tmp_path / "report.pdf"
|
||||
assert path.read_bytes() == b"hello"
|
||||
|
||||
|
||||
def test_save_never_overwrites_an_existing_download(tmp_path):
|
||||
first = file_delivery.save(tmp_path, "notes.txt", b"first")
|
||||
second = file_delivery.save(tmp_path, "notes.txt", b"second")
|
||||
assert first != second
|
||||
assert first.read_bytes() == b"first"
|
||||
assert second.read_bytes() == b"second"
|
||||
@@ -0,0 +1,329 @@
|
||||
"""filectl parsing + execution — the pseudo-commands the server can relay to
|
||||
read/write/edit local files instead of a raw shell heredoc.
|
||||
|
||||
filectl {"op": ...} is a single-line JSON envelope (not a multi-line
|
||||
marker block) because it rides the "command" tool marker, which the main
|
||||
repo's tool-call extractor only captures up to the next newline — see the
|
||||
module docstring in bolt_pet/file_ops.py."""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from bolt_pet import file_ops
|
||||
|
||||
|
||||
def _cmd(payload: dict) -> str:
|
||||
return "filectl " + json.dumps(payload)
|
||||
|
||||
|
||||
# ── parsing ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_non_file_commands_are_left_alone():
|
||||
assert file_ops.parse("ls -la") is None
|
||||
assert file_ops.parse("systemctl restart nginx") is None
|
||||
assert file_ops.parse("") is None
|
||||
# "filed" must not be mistaken for the "file" prefix
|
||||
assert file_ops.parse("filed --list") is None
|
||||
|
||||
|
||||
def test_list_parses_defaults():
|
||||
assert file_ops.parse(_cmd({"op": "list", "path": "/tmp"})) == {
|
||||
"action": "list", "path": "/tmp", "pattern": "*", "recursive": False,
|
||||
}
|
||||
|
||||
|
||||
def test_list_parses_pattern_and_recursive():
|
||||
assert file_ops.parse(_cmd({"op": "list", "path": "/tmp", "pattern": "*.py", "recursive": True})) == {
|
||||
"action": "list", "path": "/tmp", "pattern": "*.py", "recursive": True,
|
||||
}
|
||||
|
||||
|
||||
def test_list_requires_a_path():
|
||||
with pytest.raises(file_ops.FileOpError):
|
||||
file_ops.parse(_cmd({"op": "list"}))
|
||||
|
||||
|
||||
def test_list_rejects_empty_pattern():
|
||||
with pytest.raises(file_ops.FileOpError):
|
||||
file_ops.parse(_cmd({"op": "list", "path": "/tmp", "pattern": ""}))
|
||||
|
||||
|
||||
def test_read_parses_path_and_optional_line_range():
|
||||
assert file_ops.parse(_cmd({"op": "read", "path": "/tmp/a.txt"})) == {
|
||||
"action": "read", "path": "/tmp/a.txt", "start": None, "end": None,
|
||||
}
|
||||
assert file_ops.parse(_cmd({"op": "read", "path": "/tmp/a.txt", "start": 10, "end": 40})) == {
|
||||
"action": "read", "path": "/tmp/a.txt", "start": 10, "end": 40,
|
||||
}
|
||||
|
||||
|
||||
def test_read_requires_a_path():
|
||||
with pytest.raises(file_ops.FileOpError):
|
||||
file_ops.parse(_cmd({"op": "read"}))
|
||||
|
||||
|
||||
def test_read_rejects_non_numeric_line_args():
|
||||
with pytest.raises(file_ops.FileOpError):
|
||||
file_ops.parse('filectl {"op": "read", "path": "/tmp/a.txt", "start": "start"}')
|
||||
|
||||
|
||||
def test_write_parses_path_and_content():
|
||||
assert file_ops.parse(_cmd({"op": "write", "path": "/tmp/a.txt", "content": "hello\nworld"})) == {
|
||||
"action": "write", "path": "/tmp/a.txt", "content": "hello\nworld",
|
||||
}
|
||||
|
||||
|
||||
def test_write_allows_empty_content():
|
||||
assert file_ops.parse(_cmd({"op": "write", "path": "/tmp/a.txt", "content": ""})) == {
|
||||
"action": "write", "path": "/tmp/a.txt", "content": "",
|
||||
}
|
||||
|
||||
|
||||
def test_write_without_content_raises():
|
||||
with pytest.raises(file_ops.FileOpError):
|
||||
file_ops.parse(_cmd({"op": "write", "path": "/tmp/a.txt"}))
|
||||
|
||||
|
||||
def test_edit_parses_old_and_new():
|
||||
assert file_ops.parse(_cmd({"op": "edit", "path": "/tmp/a.txt", "old": "foo\nbar", "new": "baz"})) == {
|
||||
"action": "edit", "path": "/tmp/a.txt", "old": "foo\nbar", "new": "baz",
|
||||
}
|
||||
|
||||
|
||||
def test_edit_rejects_identical_old_and_new():
|
||||
with pytest.raises(file_ops.FileOpError):
|
||||
file_ops.parse(_cmd({"op": "edit", "path": "/tmp/a.txt", "old": "same", "new": "same"}))
|
||||
|
||||
|
||||
def test_edit_missing_fields_raises():
|
||||
with pytest.raises(file_ops.FileOpError):
|
||||
file_ops.parse(_cmd({"op": "edit", "path": "/tmp/a.txt"}))
|
||||
|
||||
|
||||
def test_content_with_embedded_quotes_and_shell_metacharacters_survives():
|
||||
payload = 'echo "hi $USER" `whoami` && rm -rf /'
|
||||
command = _cmd({"op": "write", "path": "/tmp/a.txt", "content": payload})
|
||||
assert "\n" not in command # stays on one line, as the command marker requires
|
||||
assert file_ops.parse(command) == {"action": "write", "path": "/tmp/a.txt", "content": payload}
|
||||
|
||||
|
||||
def test_multiline_content_stays_on_one_physical_line():
|
||||
content = "line one\nline two\nline three with \"quotes\" and \\backslashes\\"
|
||||
command = _cmd({"op": "write", "path": "/tmp/a.txt", "content": content})
|
||||
assert "\n" not in command
|
||||
assert file_ops.parse(command)["content"] == content
|
||||
|
||||
|
||||
def test_help():
|
||||
assert file_ops.parse("filectl help") == {"action": "help"}
|
||||
assert file_ops.parse("filectl") == {"action": "help"}
|
||||
assert file_ops.parse(_cmd({"op": "help"})) == {"action": "help"}
|
||||
|
||||
|
||||
def test_invalid_json_raises():
|
||||
with pytest.raises(file_ops.FileOpError):
|
||||
file_ops.parse("filectl {not valid json")
|
||||
|
||||
|
||||
def test_non_object_json_raises():
|
||||
with pytest.raises(file_ops.FileOpError):
|
||||
file_ops.parse("filectl [1, 2, 3]")
|
||||
|
||||
|
||||
def test_unknown_op_raises():
|
||||
with pytest.raises(file_ops.FileOpError):
|
||||
file_ops.parse(_cmd({"op": "frobnicate", "path": "/tmp/a.txt"}))
|
||||
|
||||
|
||||
# ── execution ────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_list_shows_files_and_subdirectories(tmp_path):
|
||||
(tmp_path / "a.txt").write_text("hi")
|
||||
(tmp_path / "sub").mkdir()
|
||||
(tmp_path / "sub" / "b.txt").write_text("nested")
|
||||
|
||||
action = file_ops.parse(_cmd({"op": "list", "path": str(tmp_path)}))
|
||||
output = file_ops.execute(action)
|
||||
|
||||
assert "a.txt\t2B" in output
|
||||
assert "sub/" in output
|
||||
assert "b.txt" not in output # non-recursive: nested file not shown
|
||||
|
||||
|
||||
def test_list_recursive_finds_nested_files(tmp_path):
|
||||
(tmp_path / "sub").mkdir()
|
||||
(tmp_path / "sub" / "b.txt").write_text("nested")
|
||||
|
||||
action = file_ops.parse(_cmd({"op": "list", "path": str(tmp_path), "recursive": True}))
|
||||
output = file_ops.execute(action)
|
||||
|
||||
assert "sub/b.txt" in output or "sub\\b.txt" in output # os-dependent separator
|
||||
|
||||
|
||||
def test_list_pattern_filters_entries(tmp_path):
|
||||
(tmp_path / "a.py").write_text("x")
|
||||
(tmp_path / "b.txt").write_text("y")
|
||||
|
||||
action = file_ops.parse(_cmd({"op": "list", "path": str(tmp_path), "pattern": "*.py"}))
|
||||
output = file_ops.execute(action)
|
||||
|
||||
assert "a.py" in output
|
||||
assert "b.txt" not in output
|
||||
|
||||
|
||||
def test_list_empty_directory_says_so(tmp_path):
|
||||
action = file_ops.parse(_cmd({"op": "list", "path": str(tmp_path)}))
|
||||
output = file_ops.execute(action)
|
||||
assert "no entries" in output
|
||||
|
||||
|
||||
def test_list_missing_directory_raises():
|
||||
with pytest.raises(file_ops.FileOpError):
|
||||
file_ops.execute({"action": "list", "path": "/no/such/dir", "pattern": "*", "recursive": False})
|
||||
|
||||
|
||||
def test_list_rejects_a_file_path(tmp_path):
|
||||
target = tmp_path / "a.txt"
|
||||
target.write_text("hi")
|
||||
with pytest.raises(file_ops.FileOpError):
|
||||
file_ops.execute({"action": "list", "path": str(target), "pattern": "*", "recursive": False})
|
||||
|
||||
|
||||
def test_list_truncates_past_the_entry_cap(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(file_ops, "_MAX_LIST_ENTRIES", 3)
|
||||
for i in range(5):
|
||||
(tmp_path / f"f{i}.txt").write_text("x")
|
||||
|
||||
action = file_ops.parse(_cmd({"op": "list", "path": str(tmp_path)}))
|
||||
output = file_ops.execute(action)
|
||||
|
||||
assert "truncated" in output
|
||||
assert output.count(".txt") == 3
|
||||
|
||||
|
||||
def test_write_then_read_round_trips(tmp_path):
|
||||
target = tmp_path / "notes.txt"
|
||||
write_action = file_ops.parse(_cmd({"op": "write", "path": str(target), "content": "line one\nline two"}))
|
||||
result = file_ops.execute(write_action)
|
||||
assert target.read_text() == "line one\nline two"
|
||||
assert str(target) in result
|
||||
|
||||
read_action = file_ops.parse(_cmd({"op": "read", "path": str(target)}))
|
||||
output = file_ops.execute(read_action)
|
||||
assert "line one" in output
|
||||
assert "line two" in output
|
||||
|
||||
|
||||
def test_read_missing_file_raises():
|
||||
with pytest.raises(file_ops.FileOpError):
|
||||
file_ops.execute({"action": "read", "path": "/no/such/file.txt", "start": None, "end": None})
|
||||
|
||||
|
||||
def test_read_respects_line_range(tmp_path):
|
||||
target = tmp_path / "a.txt"
|
||||
target.write_text("\n".join(f"line{i}" for i in range(1, 11)))
|
||||
action = file_ops.parse(_cmd({"op": "read", "path": str(target), "start": 3, "end": 5}))
|
||||
output = file_ops.execute(action)
|
||||
assert "line3" in output and "line5" in output
|
||||
assert "line1" not in output and "line6" not in output
|
||||
|
||||
|
||||
def test_edit_replaces_a_unique_match(tmp_path):
|
||||
target = tmp_path / "a.py"
|
||||
target.write_text("def foo():\n return 1\n")
|
||||
action = file_ops.parse(_cmd({"op": "edit", "path": str(target), "old": "return 1", "new": "return 2"}))
|
||||
file_ops.execute(action)
|
||||
assert target.read_text() == "def foo():\n return 2\n"
|
||||
|
||||
|
||||
def test_edit_fails_when_text_not_found(tmp_path):
|
||||
target = tmp_path / "a.py"
|
||||
target.write_text("def foo():\n return 1\n")
|
||||
action = file_ops.parse(_cmd({"op": "edit", "path": str(target), "old": "nope", "new": "x"}))
|
||||
with pytest.raises(file_ops.FileOpError):
|
||||
file_ops.execute(action)
|
||||
assert target.read_text() == "def foo():\n return 1\n" # untouched
|
||||
|
||||
|
||||
def test_edit_fails_when_text_is_ambiguous(tmp_path):
|
||||
target = tmp_path / "a.py"
|
||||
target.write_text("x = 1\nx = 1\n")
|
||||
action = file_ops.parse(_cmd({"op": "edit", "path": str(target), "old": "x = 1", "new": "x = 2"}))
|
||||
with pytest.raises(file_ops.FileOpError):
|
||||
file_ops.execute(action)
|
||||
assert target.read_text() == "x = 1\nx = 1\n" # untouched
|
||||
|
||||
|
||||
def test_write_creates_parent_directories(tmp_path):
|
||||
target = tmp_path / "nested" / "dir" / "a.txt"
|
||||
action = file_ops.parse(_cmd({"op": "write", "path": str(target), "content": "hi"}))
|
||||
file_ops.execute(action)
|
||||
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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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 "wave" in pet_actions.describe({"action": "emote", "emote": "wave"})
|
||||
assert pet_actions.describe({"action": "help"}) == pet_actions.HELP
|
||||
|
||||
|
||||
def test_voice_reset_parses_with_or_without_the_word_reset():
|
||||
assert pet_actions.parse("petctl voice reset") == {"action": "voice", "voice": "default"}
|
||||
assert pet_actions.parse("petctl voice default") == {"action": "voice", "voice": "default"}
|
||||
assert pet_actions.parse("petctl voice") == {"action": "voice", "voice": "default"}
|
||||
|
||||
|
||||
def test_petctl_cannot_be_used_to_pick_a_voice():
|
||||
"""Choosing a voice is the server's job (speak_as) — it has the voice
|
||||
library. petctl only ever undoes one, so an attempt to set a voice here
|
||||
is pointed back at the marker that works."""
|
||||
with pytest.raises(pet_actions.ActionError, match="speak_as"):
|
||||
pet_actions.parse("petctl voice Terence")
|
||||
|
||||
@@ -15,7 +15,10 @@ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from bolt_pet import config
|
||||
from bolt_pet.state import PetState
|
||||
from bolt_pet.ui.pet_window import _EMOTE_TICKS, 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")
|
||||
@@ -179,3 +182,153 @@ def test_click_through_toggles_mouse_transparency(pet):
|
||||
|
||||
pet.set_click_through(False)
|
||||
assert pet.testAttribute(Qt.WA_TransparentForMouseEvents) is False
|
||||
|
||||
|
||||
# ── monitors ────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_window_publishes_a_monitor_list(pet):
|
||||
"""Whatever the test host's screen setup is, the window must describe it
|
||||
in the shape the controller expects."""
|
||||
monitors = pet.monitors()
|
||||
assert monitors, "offscreen Qt still reports at least one screen"
|
||||
assert [m.index for m in monitors] == list(range(len(monitors)))
|
||||
assert all(m.width > 0 and m.height > 0 for m in monitors)
|
||||
assert all(m.name for m in monitors)
|
||||
assert sum(1 for m in monitors if m.primary) <= 1
|
||||
|
||||
|
||||
def test_publish_monitors_re_emits_when_forced(pet):
|
||||
"""ui/app.py relies on this: the window is built before the controller
|
||||
exists, so its constructor's publish reaches nobody and has to be redone."""
|
||||
seen = []
|
||||
pet.monitors_changed.connect(seen.append)
|
||||
pet.publish_monitors() # force defaults to True
|
||||
assert len(seen) == 1
|
||||
pet.publish_monitors(force=False) # nothing changed -> stays quiet
|
||||
assert len(seen) == 1
|
||||
|
||||
|
||||
def test_pet_reports_which_monitor_it_is_on(pet):
|
||||
seen = []
|
||||
pet.pet_monitor_changed.connect(seen.append)
|
||||
pet.publish_monitors()
|
||||
assert seen and seen[-1] == pet.current_monitor_index()
|
||||
assert 0 <= seen[-1] < len(pet.monitors())
|
||||
|
||||
|
||||
def test_jump_moves_the_window_onto_the_target_screen(pet):
|
||||
monitors = pet.monitors()
|
||||
target = len(monitors) - 1
|
||||
pet.apply_action({"action": "jump", "monitor": target})
|
||||
assert pet.current_monitor_index() == target
|
||||
# a jump lands with a hop rather than sliding there
|
||||
assert pet._emote == "hop"
|
||||
|
||||
|
||||
def test_jump_cancels_a_stroll_so_it_does_not_walk_back(pet):
|
||||
pet.apply_action({"action": "move", "anchor": "top-left"})
|
||||
assert pet._wander_target is not None
|
||||
pet.apply_action({"action": "jump", "monitor": 0})
|
||||
assert pet._wander_target is None
|
||||
assert pet._commanded_move is False
|
||||
|
||||
|
||||
def test_jump_to_a_bogus_index_is_a_no_op(pet):
|
||||
before = pet.pos()
|
||||
pet.apply_action({"action": "jump", "monitor": 99})
|
||||
pet.apply_action({"action": "jump", "monitor": -1})
|
||||
assert pet.pos() == before
|
||||
|
||||
|
||||
# ── walk cycle ──────────────────────────────────────────────────────────────
|
||||
|
||||
def test_walk_art_loads_as_a_non_state_animation(pet):
|
||||
"""Walking is a property of movement, not a PetState, so it lives outside
|
||||
the state machine but still loads like any other animation."""
|
||||
assert pet.sprites.has(WALK)
|
||||
assert len(pet.sprites.get(WALK).frames) == 8
|
||||
assert pet.sprites.get("nonsense") is pet.sprites.get(PetState.IDLE)
|
||||
assert not pet.sprites.has("nonsense")
|
||||
|
||||
|
||||
def test_walking_overrides_the_state_animation(pet):
|
||||
assert pet._animation_key() == pet._current_state
|
||||
pet._advance_walk(50, 0, 1.0)
|
||||
assert pet._animation_key() == WALK
|
||||
|
||||
|
||||
def test_walk_cycle_advances_by_distance_not_by_the_clock(pet):
|
||||
"""The planted paw tracks backwards at the speed the window moves
|
||||
forwards; drive it off the animation timer instead and the feet skate."""
|
||||
anim = pet.sprites.get(WALK)
|
||||
anim.reset()
|
||||
pet._advance_walk(100, 0, _WALK_PIXELS_PER_FRAME * 3)
|
||||
assert anim._index == 3
|
||||
# a step too small to cross the threshold banks the distance instead
|
||||
pet._advance_walk(100, 0, _WALK_PIXELS_PER_FRAME * 0.5)
|
||||
assert anim._index == 3
|
||||
pet._advance_walk(100, 0, _WALK_PIXELS_PER_FRAME * 0.5)
|
||||
assert anim._index == 4
|
||||
|
||||
|
||||
def test_the_animation_timer_does_not_double_step_the_walk(pet):
|
||||
anim = pet.sprites.get(WALK)
|
||||
pet._advance_walk(50, 0, 1.0)
|
||||
anim.reset()
|
||||
pet._advance_frame()
|
||||
assert anim._index == 0
|
||||
|
||||
|
||||
def test_facing_follows_horizontal_travel(pet):
|
||||
pet._advance_walk(50, 0, 1.0)
|
||||
assert pet._facing == 1
|
||||
pet._advance_walk(-50, 0, 1.0)
|
||||
assert pet._facing == -1
|
||||
|
||||
|
||||
def test_a_near_vertical_stroll_does_not_flip_him(pet):
|
||||
"""Rounding noise on dx would otherwise flip him back and forth every
|
||||
tick on a straight-up walk."""
|
||||
pet._facing = 1
|
||||
pet._advance_walk(0.4, 60, 1.0)
|
||||
assert pet._facing == 1
|
||||
|
||||
|
||||
def test_walking_left_paints_a_mirrored_frame(pet):
|
||||
frame = pet.sprites.get(WALK).current()
|
||||
pet._facing = 1
|
||||
assert pet._oriented(frame) is frame # art is drawn facing right
|
||||
pet._facing = -1
|
||||
flipped = pet._oriented(frame)
|
||||
assert flipped is not frame
|
||||
assert flipped.size() == frame.size()
|
||||
assert pet._oriented(frame) is flipped # cached, not re-flipped per paint
|
||||
|
||||
|
||||
def test_stopping_resets_the_cycle_to_a_standing_frame(pet):
|
||||
pet._advance_walk(50, 0, _WALK_PIXELS_PER_FRAME * 2)
|
||||
assert pet._walking
|
||||
pet._stop_walking()
|
||||
assert not pet._walking
|
||||
assert pet._walk_distance == 0.0
|
||||
assert pet.sprites.get(WALK)._index == 0
|
||||
assert pet._animation_key() == pet._current_state
|
||||
|
||||
|
||||
def test_walk_art_suppresses_the_hard_coded_bob(pet):
|
||||
"""The frames carry their own weight shift — bobbing the window as well
|
||||
would double it up."""
|
||||
pet._advance_walk(50, 0, 5.0)
|
||||
assert pet._bob_offset == 0
|
||||
|
||||
|
||||
def test_without_walk_art_it_falls_back_to_the_old_bob(qt_app, tmp_path):
|
||||
window = PetWindow(sprite_dir=tmp_path)
|
||||
try:
|
||||
assert not window.sprites.has(WALK)
|
||||
window._advance_walk(50, 0, 5.0)
|
||||
assert window._walking
|
||||
assert window._animation_key() == window._current_state
|
||||
assert window._bob_offset < 0 # still visibly moving
|
||||
finally:
|
||||
window.close()
|
||||
|
||||
@@ -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 == []
|
||||
@@ -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)
|
||||
@@ -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 == []
|
||||
@@ -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) == []
|
||||
@@ -27,7 +27,8 @@ def test_converse_returns_reply_directly():
|
||||
with patch.object(server_client.requests, "post") as post:
|
||||
post.return_value = _mock_response({"type": "reply", "text": "hello there"})
|
||||
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()
|
||||
args, kwargs = post.call_args
|
||||
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:
|
||||
on_command = MagicMock(return_value="[exit 0]\nhi")
|
||||
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")
|
||||
# second call was to /desk/tool_result with the command's output
|
||||
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():
|
||||
with patch.object(server_client.requests, "post") as post:
|
||||
post.return_value = _mock_response({"type": "error", "error": "unauthorized"})
|
||||
@@ -84,3 +98,47 @@ def test_check_health_returns_parsed_json():
|
||||
get.return_value = _mock_response({"ok": True, "service": "bolt-desk-api"})
|
||||
result = server_client.check_health()
|
||||
assert result == {"ok": True, "service": "bolt-desk-api"}
|
||||
|
||||
|
||||
def test_list_outbox_files_returns_the_queue():
|
||||
with patch.object(server_client.requests, "get") as get:
|
||||
get.return_value = _mock_response(
|
||||
{"files": [{"id": "abc", "name": "report.pdf", "size": 9}]}
|
||||
)
|
||||
result = server_client.list_outbox_files()
|
||||
assert result == [{"id": "abc", "name": "report.pdf", "size": 9}]
|
||||
args, kwargs = get.call_args
|
||||
assert args[0] == "http://test-server:5002/desk/files"
|
||||
assert kwargs["params"] == {"session_id": "pet-test"}
|
||||
assert kwargs["headers"] == {"X-Desk-Api-Key": "test-key"}
|
||||
|
||||
|
||||
def test_list_outbox_files_defaults_to_empty_list():
|
||||
with patch.object(server_client.requests, "get") as get:
|
||||
get.return_value = _mock_response({})
|
||||
assert server_client.list_outbox_files() == []
|
||||
|
||||
|
||||
def test_list_outbox_files_raises_server_error_when_unreachable():
|
||||
with patch.object(server_client.requests, "get", side_effect=ConnectionError("no route")):
|
||||
with pytest.raises(server_client.ServerError):
|
||||
server_client.list_outbox_files()
|
||||
|
||||
|
||||
def test_download_outbox_file_returns_raw_bytes():
|
||||
with patch.object(server_client.requests, "get") as get:
|
||||
resp = _mock_response({})
|
||||
resp.content = b"%PDF fake bytes"
|
||||
get.return_value = resp
|
||||
result = server_client.download_outbox_file("abc")
|
||||
assert result == b"%PDF fake bytes"
|
||||
args, kwargs = get.call_args
|
||||
assert args[0] == "http://test-server:5002/desk/files/abc"
|
||||
assert kwargs["params"] == {"session_id": "pet-test"}
|
||||
|
||||
|
||||
def test_download_outbox_file_raises_server_error_on_http_failure():
|
||||
with patch.object(server_client.requests, "get") as get:
|
||||
get.return_value = _mock_response({}, ok=False)
|
||||
with pytest.raises(server_client.ServerError, match="abc"):
|
||||
server_client.download_outbox_file("abc")
|
||||
|
||||
@@ -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
|
||||
@@ -4,6 +4,8 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from bolt_pet.speech_text import for_display, for_speech, is_question
|
||||
@@ -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("")
|
||||
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.")
|
||||
|
||||
@@ -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
|
||||
@@ -8,7 +8,8 @@ import numpy as np
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -80,3 +81,27 @@ def test_clear_resets_peak_and_entries():
|
||||
log.observe(0.45, threshold=0.5, timestamp=1.0)
|
||||
log.clear()
|
||||
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"
|
||||
|
||||
@@ -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, [])
|
||||