Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3ee67cb4d6 | |||
| 8d4751d80f | |||
| c4e805defd | |||
| 96afc351ac |
@@ -52,7 +52,47 @@
|
||||
"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(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__)",
|
||||
"Bash(QT_QPA_PLATFORM=offscreen timeout 300 .venv/bin/pytest tests/ -q)",
|
||||
"Bash(QT_QPA_PLATFORM=offscreen timeout 120 .venv/bin/pytest tests/test_intents.py -q)",
|
||||
"Bash(QT_QPA_PLATFORM=offscreen timeout 120 .venv/bin/pytest tests/test_speech_text.py -q)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+46
-1
@@ -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=
|
||||
@@ -42,8 +72,17 @@ ELEVENLABS_VOICE_ID=
|
||||
#VAD_MIN_UTTERANCE_SECONDS=0.4
|
||||
#VAD_GRACE_SECONDS=4 # how long to wait for you to start talking
|
||||
|
||||
# ── Local intents (optional) ────────────────────────────────────────────────
|
||||
# A short, closed list of things the pet answers itself, with no server round
|
||||
# trip: "stop", "be quiet", "come here", "go away", "go to sleep", "wake up",
|
||||
# "say that again", "sit"/"stay", "go for a walk", "use your normal voice".
|
||||
# Matched whole and exact, and never while you're answering a question Bolt
|
||||
# asked, so a real request ("stop the docker container") still goes to him.
|
||||
# Set to false to route absolutely everything through the server.
|
||||
#LOCAL_INTENTS=true
|
||||
|
||||
# ── Follow-up listening (optional) ──────────────────────────────────────────
|
||||
# When a reply ends on a question, the pet keeps listening for your answer
|
||||
# When a reply asks you something, the pet keeps listening for your answer
|
||||
# instead of dropping back to idle and making you say the wake word again.
|
||||
# FOLLOW_UP_MAX_TURNS caps how many question-and-answer rounds can chain
|
||||
# without you re-triggering it (0 = no cap) — a stop on runaway loops if the
|
||||
@@ -151,6 +190,12 @@ ELEVENLABS_VOICE_ID=
|
||||
#NOTIFICATION_BRIDGE=false
|
||||
#NOTIFICATION_FILTER=build|deploy|calendar
|
||||
#NOTIFICATION_MIN_INTERVAL_SECONDS=60
|
||||
# Notifications queue while the pet is napping (the heartbeat that forwards them
|
||||
# doesn't run). These two stop an overnight backlog becoming a monologue at 8am:
|
||||
# the queue drops its oldest past the limit, and anything staler than the age
|
||||
# limit is discarded rather than read out.
|
||||
#NOTIFICATION_QUEUE_LIMIT=20
|
||||
#NOTIFICATION_MAX_AGE_SECONDS=900
|
||||
|
||||
# ── Push-to-talk (optional) ─────────────────────────────────────────────────
|
||||
# Global hotkey; needs pynput and a session that allows global key hooks
|
||||
|
||||
@@ -14,7 +14,8 @@ Pipeline: `mic → openWakeWord ("thunderbolt", on-device) / push-to-talk /
|
||||
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, or reads a screen's text back instead] → reply →
|
||||
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).
|
||||
@@ -46,6 +47,12 @@ python scripts/generate_bolt_sprites.py # --out /tmp/x to preview fi
|
||||
python scripts/slice_spritesheet.py path/to/sheet.png assets/sprites/idle --cols 6 --rows 1
|
||||
```
|
||||
|
||||
**Cutting a release:** bump `__version__` in `bolt_pet/__init__.py` in the same
|
||||
commit you tag, because that string — not the git history — is what every
|
||||
already-installed pet compares against the newest Gitea tag (`updater.py`). A
|
||||
tag without the bump means nobody updates; a bump without the tag means the
|
||||
next tag looks older than what's running.
|
||||
|
||||
There is no lint/build step configured beyond pytest. `cp .env.example .env`
|
||||
and fill in `BOLT_SERVER_URL` / `DESK_API_KEY` (+ `DEEPGRAM_API_KEY`,
|
||||
`ELEVENLABS_API_KEY`) before running — without server config the controller
|
||||
@@ -72,14 +79,46 @@ logs a missing-config message and exits its thread instead of starting.
|
||||
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`.
|
||||
|
||||
**One thread drives all of it, so failure containment is structural.** Every
|
||||
entry point that can raise runs inside `_guarded(work, label)`, which logs and
|
||||
forces the machine back to IDLE (the only state it's always safe to resume
|
||||
from): the conversation turn, the heartbeat tick — which matters most, since
|
||||
`on_tick` is the one place control returns to us during a listen that blocks
|
||||
for minutes, and everything it drives touches the network or shells out — and
|
||||
the post-restart report. `run()` wraps the lot in try/finally because
|
||||
`finished` is what `ui/app.py` waits on to quit the thread and to run a
|
||||
pending `os.execv`; an exception escaping `_loop` used to skip it, so the
|
||||
failure mode of any bug below was "the pet goes deaf with the mic still open
|
||||
and the tray won't quit" rather than "one turn failed". `_handle_command` has
|
||||
the same shape for a different reason: it must **always return a string**,
|
||||
because the server is blocked on `/desk/tool_result` while it runs and an
|
||||
exception there means the relay never posts and the server sits out its own
|
||||
timeout on a turn that can't finish — silent on both ends. Handed back as
|
||||
command output instead, Bolt can read what broke and say so in the same turn.
|
||||
- **`server_client.py`** — HTTP client for the desk API, dependency-free
|
||||
beyond `requests` so it's easy to mock in tests. `converse()` loops relaying
|
||||
server-issued shell commands (`run_local_command`, executed via
|
||||
`subprocess.run(shell=True)` as the desktop user, 30s default timeout) via
|
||||
server-issued shell commands (`run_local_command`, executed via a
|
||||
`shell=True` `Popen` as the desktop user, 30s default timeout) via
|
||||
`/desk/tool_result` until the server sends a final `reply` (capped at
|
||||
`_MAX_RELAY_HOPS`). This is the same "full desktop control" trust model as
|
||||
`_MAX_RELAY_HOPS` — exhausting which is reported as its own error, because
|
||||
"unknown server response" sent everyone looking at the payload shape when what
|
||||
happened is a model that kept calling tools and never answered).
|
||||
`run_local_command` is `Popen` rather than `subprocess.run` for the timeout
|
||||
path: the command is a shell, and `run()`'s timeout would kill only that
|
||||
shell, leaving whatever it spawned (a build, a `tail -f`, an ffmpeg) alive for
|
||||
the rest of the session with no parent watching — so the child gets its own
|
||||
process group (`start_new_session`, POSIX) and a timeout SIGTERMs the group,
|
||||
SIGKILLs it two seconds later, then drains the pipes *with its own timeout* so
|
||||
a grandchild holding stdout can't turn a timeout into a hang. Whatever the
|
||||
command printed before it hung is returned alongside the timeout notice, since
|
||||
the last line usually says exactly what it was stuck waiting for. 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. `list_outbox_files`
|
||||
the user's own voice/click requests in their own session. A final reply is
|
||||
returned as a `Reply(text, voice_id, voice_name)` rather than a bare string,
|
||||
because the server can tag it with a voice — see "Voices" below.
|
||||
`list_outbox_files`
|
||||
/ `download_outbox_file` hit the same `/desk/files` and `/desk/files/<id>`
|
||||
endpoints the server's `deliver_files` tool queues onto — see `file_delivery.py`.
|
||||
- **`file_delivery.py`** — the filesystem half of receiving files the server
|
||||
@@ -98,13 +137,18 @@ logs a missing-config message and exits its thread instead of starting.
|
||||
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`
|
||||
server repo's `bolt_desk.py`, plus `flush()` — see the note below on the pet
|
||||
hearing itself), `wake_word.py` (openWakeWord `thunderbolt.onnx`
|
||||
detection + `NearMissLog` for threshold tuning — see below), `stt.py`
|
||||
(Deepgram), `tts.py` (ElevenLabs, streaming by default — `stream_pcm()` +
|
||||
`play_stream()` start playback on the first chunk; `chunks_to_int16()`
|
||||
carries odd bytes across HTTP chunk boundaries, without which everything
|
||||
after the first split sample plays as static — falling back to whole-clip
|
||||
PCM then offline `pyttsx3`), `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
|
||||
@@ -115,8 +159,8 @@ 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`, plus the screen verbs
|
||||
`jump`/`monitors`/`read`). The desk API has no "move the pet" payload type
|
||||
`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.
|
||||
@@ -125,7 +169,51 @@ logs a missing-config message and exits its thread instead of starting.
|
||||
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.
|
||||
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
|
||||
@@ -155,6 +243,23 @@ logs a missing-config message and exits its thread instead of starting.
|
||||
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.
|
||||
- **`relay_json.py`** — the JSON parser both `filectl` and `dialoguectl` use
|
||||
instead of `json.loads`, because their payload is hand-typed by a model into
|
||||
a tool marker and fails in a small, repeatable set of ways (stray quote after
|
||||
a bare literal, trailing comma, single or smart quotes, Python `True`/`False`,
|
||||
a markdown fence). Strict parsing already cost a live turn: the call was
|
||||
rejected, the model re-sent the identical line, was rejected again, and then
|
||||
told the user "I'll check now" without ever calling anything. So `loads()`
|
||||
tries strict first, then applies **named, individually-narrow repairs** and
|
||||
accepts one only if the result parses — and on total failure raises
|
||||
`RelayJsonError` carrying a caret pointed at the offending character, since
|
||||
a model can act on a pointed-at fragment but not on "Expecting ',' delimiter:
|
||||
char 74". Two conventions matter for any new relayed-JSON command: repairs
|
||||
are **never silent** — `parse` stashes them on the action as `_repairs` and
|
||||
`describe` appends `relay_json.repair_note(...)` to the tool result, so the
|
||||
model is told it sent something broken while it still has the turn — and new
|
||||
repairs go in the `_REPAIRS` tuple ordered cheapest/safest first. Tested
|
||||
inside `tests/test_file_ops.py`, not a file of its own.
|
||||
- **`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
|
||||
@@ -191,6 +296,14 @@ logs a missing-config message and exits its thread instead of starting.
|
||||
`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.
|
||||
Note where the queue between the two threads lives: notifications arrive on
|
||||
the watcher thread and are forwarded from the heartbeat, which **doesn't run
|
||||
while the pet is napping** — so they accumulate overnight. The controller's
|
||||
queue is therefore a bounded `deque` stamped on arrival, and the drain
|
||||
discards anything older than `NOTIFICATION_MAX_AGE_SECONDS` rather than
|
||||
reading a nine-hour-old backlog out at 8am. A drain that stops early (a nap
|
||||
starting mid-loop, or the server going down) re-queues what it didn't forward
|
||||
instead of dropping it, which the original swap-and-return did silently.
|
||||
- **`sudo_askpass.py`** — makes server-relayed `sudo` usable from a process
|
||||
with no terminal, by pointing sudo's `SUDO_ASKPASS` at a GUI helper and
|
||||
rewriting bare `sudo` to `sudo -A` (`add_askpass_flag`, a conservative regex
|
||||
@@ -227,15 +340,43 @@ logs a missing-config message and exits its thread instead of starting.
|
||||
`for_speech()` (called inside `tts.speak()`, so every path to the speakers is
|
||||
covered) strips markdown, emoji, URLs and stray symbols the voice would read
|
||||
literally ("asterisk asterisk"), turns bullet lists into full sentences, and
|
||||
words a few symbols (`&` → "and"). `for_display()` is the looser version for
|
||||
words a few symbols (`&` → "and"), abbreviations the voice would spell out
|
||||
letter by letter (`e.g.` → "for example", `etc.` → "and so on") and a long
|
||||
option's leading `--` (heard as "dash dash force"; the single hyphen has to
|
||||
survive for "bolt-pet"). `for_display()` is the looser version for
|
||||
the speech bubble — markdown syntax gone, emoji kept. `is_question()` decides
|
||||
whether a reply leaves the pet waiting on an answer: it tests the *spoken*
|
||||
form (so a '?' inside a stripped code block or URL doesn't count) and only a
|
||||
trailing one counts, since a question asked in passing isn't awaiting a
|
||||
reply. `controller._should_follow_up` uses it to keep listening without the
|
||||
form (so a '?' inside a stripped code block or URL doesn't count) and a '?'
|
||||
**anywhere** counts. That last part was once trailing-only, on the theory that
|
||||
"What time is it? It's 7:15." isn't awaiting a reply — true of that sentence
|
||||
and wrong more often, since Bolt routinely asks and then keeps talking ("Want
|
||||
me to fix it? I'd start with the config"), which is the case that actually
|
||||
costs you a wake word. The asymmetry is the argument: an unwanted extra listen
|
||||
ends itself on `VAD_GRACE_SECONDS` of silence, a missed one makes you start
|
||||
over. `controller._should_follow_up` 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.
|
||||
- **`intents.py`** — the handful of utterances answered *without* the server.
|
||||
"stop", "come here", "go to sleep", "say that again", "use your normal voice"
|
||||
are commands to the body, and routing them through the desk API costs two to
|
||||
four seconds and three network hops to make the pet walk left — and only works
|
||||
if the server's prompt happens to advertise the matching `petctl` verb (which
|
||||
is why `voice reset` needs a block in `ai/desk_api.py`'s pet prompt; see the
|
||||
Voices section). Recognising the phrase here removes both the latency and that
|
||||
coupling. The design problem is *not stealing real requests*, and three rules
|
||||
cover it: whole-utterance exact match after normalisation (so "stop" is an
|
||||
intent and "stop the docker container" is a question for Bolt), a closed table
|
||||
with nothing arguable in it, and **never on a follow-up turn** — if Bolt just
|
||||
asked you something your answer is his, and swallowing "never mind" locally
|
||||
would leave the server holding a question it never got an answer to. Both
|
||||
sides of the comparison go through `normalize()` (the table is canonicalised
|
||||
at import, and `_build()` refuses to build one where two intents claim the
|
||||
same normalised phrase, or where a phrase reduces to "" and would match pure
|
||||
filler like "hey bolt"). Actions come back in the **same shape
|
||||
`pet_actions.parse` produces**, so `PetWindow.apply_action` needs no new
|
||||
vocabulary; the effects live in `controller._handle_local_intent`. Off switch:
|
||||
`LOCAL_INTENTS=false`.
|
||||
- **`ui/`** — `app.py` wires `QApplication` + `PetWindow` + `PetTray` + the
|
||||
history/tuner windows + the push-to-talk hotkey + the controller thread
|
||||
together; `pet_window.py` is the frameless/translucent/always-on-top sprite
|
||||
@@ -274,9 +415,38 @@ logs a missing-config message and exits its thread instead of starting.
|
||||
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 / quit) — the pet window has no
|
||||
title bar or taskbar entry; `history_window.py` and `wake_tuner.py` are the
|
||||
two dialogs it opens.
|
||||
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
|
||||
|
||||
@@ -313,6 +483,29 @@ the times it nearly heard you), and its slider is read per frame because
|
||||
`listen_for_wake_word` accepts a callable threshold. Set the threshold just
|
||||
under the peak you can hit reliably, then persist it in `.env`.
|
||||
|
||||
### The mic keeps recording while nothing is reading it
|
||||
|
||||
Same family of bug as the openwakeword one above, one layer down: PortAudio
|
||||
captures into a ring buffer continuously, so audio from a stretch where the
|
||||
pipeline thread was busy elsewhere is still queued when the next read happens.
|
||||
It bites in exactly one place. At the end of a reply that asked you something,
|
||||
`_speak` sets `_talk_now` and the next turn starts recording immediately — with
|
||||
the tail of the pet's own TTS sitting in that buffer, above the VAD threshold.
|
||||
The VAD takes it for the start of your answer, Deepgram transcribes it, and Bolt
|
||||
is handed his own last sentence as if you had said it. With barge-in on the
|
||||
detector was draining the stream during playback so the window is small; with
|
||||
`BARGE_IN=false` nothing drains it at all.
|
||||
|
||||
`mic.flush(stream)` drops what's buffered, and `_speak` calls it on the
|
||||
follow-up branch only. **That placement is the whole correctness argument** —
|
||||
flushing is only safe where the buffer is known to hold nothing *you* said:
|
||||
playback ran to completion, so if you had spoken, barge-in would have cut it and
|
||||
taken the interrupted branch instead. Never flush before a wake-triggered
|
||||
recording, where the rest of "thunderbolt, what time is it" is legitimately
|
||||
queued and dropping it clips the request. A single call is bounded by
|
||||
`max_seconds` so it can't chase a stream filling as fast as it drains, and it
|
||||
no-ops on a stream with no `read_available` (i.e. every fake stream in tests).
|
||||
|
||||
### Testing conventions
|
||||
|
||||
`tests/` covers pure logic only (state machine, wake-word scoring loop, mic
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -37,6 +37,42 @@ def rms(frame: np.ndarray) -> float:
|
||||
return float(np.sqrt(np.mean(frame.astype(np.float64) ** 2)))
|
||||
|
||||
|
||||
def flush(stream, max_seconds: float = 10.0, sample_rate: int = config.SAMPLE_RATE) -> int:
|
||||
"""Throw away whatever is already sitting in the mic's buffer. Returns the
|
||||
number of frames dropped.
|
||||
|
||||
PortAudio keeps capturing into a ring buffer while nothing is reading it, so
|
||||
audio recorded during a long blocking stretch is still queued when the next
|
||||
read happens. That matters exactly once: at the end of a reply the pet is
|
||||
about to listen for an answer, and the last fraction of a second of its own
|
||||
TTS is in that buffer. It's above the VAD threshold, so `record_utterance`
|
||||
treats it as the start of your answer, Deepgram transcribes it, and Bolt is
|
||||
handed his own sentence as if you had said it. With barge-in on, the
|
||||
detector was draining the stream during playback and the window is small;
|
||||
with `BARGE_IN=false` nothing drains it at all.
|
||||
|
||||
Only safe where the buffer is known to hold *nothing you said* — never
|
||||
before a wake-triggered recording, where the rest of "thunderbolt, what
|
||||
time is it" is legitimately queued and dropping it clips the request.
|
||||
|
||||
*max_seconds* bounds a single call so this can't chase a stream that's
|
||||
filling as fast as it's read. Best-effort: a fake stream in tests has no
|
||||
`read_available` and this is a no-op, which is the correct behaviour for
|
||||
one."""
|
||||
try:
|
||||
available = int(getattr(stream, "read_available", 0) or 0)
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
if available <= 0:
|
||||
return 0
|
||||
frames = min(available, int(max_seconds * sample_rate))
|
||||
try:
|
||||
stream.read(frames)
|
||||
except Exception:
|
||||
return 0 # a mid-flush device error is the reader's problem, not ours
|
||||
return frames
|
||||
|
||||
|
||||
def record_utterance(
|
||||
stream: AudioStream,
|
||||
should_continue=lambda: True,
|
||||
|
||||
+96
-12
@@ -5,11 +5,16 @@ 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
|
||||
from typing import Iterable, Iterator, Optional
|
||||
|
||||
import numpy as np
|
||||
import requests
|
||||
@@ -21,18 +26,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 +71,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,6 +113,55 @@ def chunks_to_int16(byte_chunks: Iterable[bytes]) -> Iterator[np.ndarray]:
|
||||
yield np.frombuffer(data[:usable], dtype=np.int16)
|
||||
|
||||
|
||||
def synthesize_dialogue(
|
||||
inputs: list, model_id: Optional[str] = None, stability: Optional[float] = None
|
||||
) -> tuple[np.ndarray, int]:
|
||||
"""Multi-voice scene via ElevenLabs Text to Dialogue.
|
||||
|
||||
One request, one take: the whole exchange is synthesized together, which
|
||||
is the point — the model hears the previous line, so reactions and timing
|
||||
land instead of sounding like separately-rendered clips.
|
||||
|
||||
Same PCM-over-`requests` posture as the rest of this module (no SDK, no
|
||||
`play()` shelling out to ffplay), so playback is the same sounddevice path
|
||||
everything else uses and barge-in works on it unchanged. There is no
|
||||
documented streaming variant, and a scene is a short set piece anyway, so
|
||||
this is whole-clip only.
|
||||
"""
|
||||
if not (config.ELEVENLABS_API_KEY and inputs):
|
||||
raise TtsError("ELEVENLABS_API_KEY not set (or no dialogue lines)")
|
||||
body: dict = {
|
||||
"inputs": [
|
||||
{"text": str(entry.get("text") or ""), "voice_id": str(entry.get("voice_id") or "")}
|
||||
for entry in inputs
|
||||
],
|
||||
"model_id": model_id or config.DIALOGUE_MODEL_ID,
|
||||
}
|
||||
if stability is not None:
|
||||
body["settings"] = {"stability": float(stability)}
|
||||
try:
|
||||
response = requests.post(
|
||||
"https://api.elevenlabs.io/v1/text-to-dialogue",
|
||||
headers={"xi-api-key": config.ELEVENLABS_API_KEY},
|
||||
params={"output_format": f"pcm_{config.TTS_SAMPLE_RATE}"},
|
||||
json=body,
|
||||
timeout=120, # a multi-voice take is slower to render than one line
|
||||
)
|
||||
response.raise_for_status()
|
||||
except Exception as exc:
|
||||
detail = ""
|
||||
# The API explains refusals (character limit, unknown voice) in the
|
||||
# body; surfacing it is what lets Bolt fix the call and retry.
|
||||
body_text = getattr(getattr(exc, "response", None), "text", "")
|
||||
if body_text:
|
||||
detail = f" — {body_text[:300]}"
|
||||
raise TtsError(f"ElevenLabs dialogue request failed: {exc}{detail}") from exc
|
||||
pcm = np.frombuffer(response.content, dtype=np.int16)
|
||||
if pcm.size == 0:
|
||||
raise TtsError("ElevenLabs returned no dialogue audio")
|
||||
return pcm, config.TTS_SAMPLE_RATE
|
||||
|
||||
|
||||
def play_pcm(pcm: np.ndarray, sample_rate: int, blocking: bool = True, should_stop=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
|
||||
@@ -134,11 +213,12 @@ 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) -> 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,12 +229,16 @@ 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,
|
||||
)
|
||||
except TtsError as exc:
|
||||
if on_error is not None:
|
||||
on_error(exc)
|
||||
try:
|
||||
pcm, sample_rate = synthesize_pcm(text)
|
||||
pcm, sample_rate = synthesize_pcm(text, voice_id=voice_id)
|
||||
return play_pcm(pcm, sample_rate, should_stop=should_stop)
|
||||
except TtsError as exc:
|
||||
if on_error is not None:
|
||||
|
||||
+54
-1
@@ -66,9 +66,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,9 +120,17 @@ 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"))
|
||||
|
||||
# ── local intents ───────────────────────────────────────────────────────────
|
||||
# A short, closed list of utterances the pet answers itself instead of paying a
|
||||
# server round trip for: "stop", "come here", "go to sleep", "say that again",
|
||||
# "use your normal voice". Matched whole and exact (see intents.py), never
|
||||
# during a follow-up turn, so a real request is never swallowed. Turn it off to
|
||||
# route absolutely everything through Bolt.
|
||||
LOCAL_INTENTS = os.environ.get("LOCAL_INTENTS", "true").lower() in ("1", "true", "yes", "on")
|
||||
|
||||
COMMAND_TIMEOUT_SECONDS = int(os.environ.get("COMMAND_TIMEOUT_SECONDS", "30"))
|
||||
|
||||
# ── sudo password prompts ───────────────────────────────────────────────────
|
||||
@@ -110,6 +147,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:
|
||||
@@ -184,6 +229,14 @@ NOTIFICATION_BRIDGE = os.environ.get("NOTIFICATION_BRIDGE", "false").lower() in
|
||||
# Regex matched against "<app>: <summary> <body>"; empty means "everything".
|
||||
NOTIFICATION_FILTER = os.environ.get("NOTIFICATION_FILTER", "")
|
||||
NOTIFICATION_MIN_INTERVAL_SECONDS = float(os.environ.get("NOTIFICATION_MIN_INTERVAL_SECONDS", "60"))
|
||||
# Notifications arrive on the watcher thread and are forwarded from the
|
||||
# heartbeat, which doesn't run while the pet is napping — so they queue. Both
|
||||
# limits exist to stop an overnight backlog turning into a burst of round trips
|
||||
# and a monologue at 8am: the queue is bounded (oldest dropped first) and
|
||||
# anything staler than the age limit is discarded at drain time, because
|
||||
# "Firefox finished downloading" is not news nine hours later.
|
||||
NOTIFICATION_QUEUE_LIMIT = int(os.environ.get("NOTIFICATION_QUEUE_LIMIT", "20"))
|
||||
NOTIFICATION_MAX_AGE_SECONDS = float(os.environ.get("NOTIFICATION_MAX_AGE_SECONDS", "900"))
|
||||
|
||||
# ── file delivery ────────────────────────────────────────────────────────
|
||||
# The server's deliver_files tool (ai/desk_api.py in the main tmn-api repo)
|
||||
|
||||
+388
-17
@@ -15,15 +15,18 @@ from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import time
|
||||
from collections import deque
|
||||
from typing import Optional
|
||||
|
||||
from PySide6.QtCore import QObject, Signal
|
||||
|
||||
from . import (
|
||||
config, file_delivery, file_ops, history as history_mod,
|
||||
monitors as monitors_mod, notifications, pet_actions, quiet,
|
||||
screen_context, screen_text, server_client, speech_text, updater,
|
||||
config, dialogue as dialogue_mod, file_delivery, file_ops,
|
||||
history as history_mod, intents as intents_mod, monitors as monitors_mod,
|
||||
notifications, pet_actions, quiet, screen_context, screen_text,
|
||||
self_restart, server_client, speech_text, updater,
|
||||
)
|
||||
from . import __version__
|
||||
from .audio import barge_in, mic, stt, tts, wake_word
|
||||
from .state import PetState, PetStateMachine
|
||||
|
||||
@@ -38,6 +41,7 @@ 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)
|
||||
restart_requested = Signal(str) # version we just updated to
|
||||
finished = Signal()
|
||||
|
||||
@@ -66,6 +70,13 @@ class PetController(QObject):
|
||||
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
|
||||
self._napping = False
|
||||
self._nap_forced: Optional[bool] = None # petctl nap on/off overrides the schedule
|
||||
@@ -80,12 +91,22 @@ 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(
|
||||
config.NOTIFICATION_FILTER, config.NOTIFICATION_MIN_INTERVAL_SECONDS
|
||||
)
|
||||
self._pending_notifications: list[notifications.Notification] = []
|
||||
# Bounded, and stamped on arrival: the drain only runs from the
|
||||
# heartbeat, which doesn't run while napping, so this fills up
|
||||
# overnight. maxlen drops the oldest rather than growing without limit,
|
||||
# and the stamp lets the drain discard a backlog nobody wants read out
|
||||
# at 8am (see _drain_notifications).
|
||||
self._pending_notifications: deque[tuple[float, notifications.Notification]] = deque(
|
||||
maxlen=max(1, config.NOTIFICATION_QUEUE_LIMIT)
|
||||
)
|
||||
self._notification_lock = threading.Lock()
|
||||
|
||||
# ── external controls (safe to call from the Qt/UI thread) ─────────
|
||||
@@ -117,6 +138,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
|
||||
@@ -160,6 +195,13 @@ class PetController(QObject):
|
||||
)
|
||||
self.log.emit(f"Barge-in: {config.BARGE_IN_MODE} mode.")
|
||||
|
||||
# Everything past here is in try/finally because `finished` is what
|
||||
# ui/app.py waits on to quit the QThread and to run a pending
|
||||
# os.execv. An exception escaping _loop used to skip it, leaving the
|
||||
# thread wedged with the mic still open and no restart — so the failure
|
||||
# mode of any bug below was "the pet goes deaf and the tray won't quit"
|
||||
# rather than "one turn failed".
|
||||
try:
|
||||
with self._stream:
|
||||
try:
|
||||
health = server_client.check_health()
|
||||
@@ -167,11 +209,34 @@ 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._guarded(self._report_self_restart, "restart report")
|
||||
self._loop()
|
||||
except Exception as exc:
|
||||
self.log.emit(f"Pipeline stopped unexpectedly: {exc!r}")
|
||||
finally:
|
||||
if self._notification_watcher is not None:
|
||||
self._notification_watcher.stop()
|
||||
self.finished.emit()
|
||||
|
||||
def _guarded(self, work, label: str) -> bool:
|
||||
"""Run *work*, absorbing anything it raises.
|
||||
|
||||
The pipeline is one thread driving a state machine that raises on an
|
||||
illegal transition (deliberately — see state.py), plus a dozen
|
||||
best-effort subsystems that shell out, hit the network, or touch the
|
||||
filesystem. Any one of them raising something unforeseen used to end the
|
||||
whole session. Here, it costs a log line and a forced return to IDLE,
|
||||
which is the only state it's always safe to resume from.
|
||||
|
||||
Returns True if *work* completed without raising."""
|
||||
try:
|
||||
work()
|
||||
return True
|
||||
except Exception as exc:
|
||||
self.log.emit(f"Recovered from a {label} failure: {exc!r}")
|
||||
self._state.force(PetState.IDLE)
|
||||
return False
|
||||
|
||||
def _loop(self) -> None:
|
||||
while self._running:
|
||||
if self._muted:
|
||||
@@ -186,7 +251,7 @@ class PetController(QObject):
|
||||
if not self._running:
|
||||
return
|
||||
continue
|
||||
self._handle_conversation_turn()
|
||||
self._guarded(self._handle_conversation_turn, "conversation turn")
|
||||
|
||||
def _wait_for_wake_or_click(self) -> bool:
|
||||
"""True once either the wake phrase was heard or a click-to-talk
|
||||
@@ -198,7 +263,12 @@ class PetController(QObject):
|
||||
self._stream,
|
||||
should_continue=should_continue,
|
||||
threshold=self.wake_threshold, # callable: the tuner slider is live
|
||||
on_tick=self._maybe_heartbeat,
|
||||
# Guarded: on_tick is the one place control returns to us during a
|
||||
# listen that can block for minutes, and everything it drives
|
||||
# (update check, nap probe, notification forwarding) touches the
|
||||
# network or shells out. Unguarded, any of them raising would unwind
|
||||
# the listen loop and end the session.
|
||||
on_tick=lambda: self._guarded(self._maybe_heartbeat, "heartbeat"),
|
||||
on_score=self._observe_wake_score,
|
||||
)
|
||||
if not self._running:
|
||||
@@ -247,6 +317,13 @@ class PetController(QObject):
|
||||
self.log.emit(f"You: {text}")
|
||||
self.history.add(history_mod.USER, text, time.time())
|
||||
|
||||
# "stop", "come here", "say that again" — answered here, without the
|
||||
# round trip. Never on a follow-up turn: Bolt asked you something and
|
||||
# the answer is his, even if it happens to look like a body command.
|
||||
if not following_up and self._handle_local_intent(text):
|
||||
self._state.transition(PetState.IDLE)
|
||||
return
|
||||
|
||||
try:
|
||||
# What's focused right now rides along, so "what's this error?"
|
||||
# has a referent without you having to describe the window.
|
||||
@@ -260,8 +337,64 @@ class PetController(QObject):
|
||||
return
|
||||
|
||||
self._check_deliveries()
|
||||
self._speak(reply)
|
||||
self._apply_voice(reply)
|
||||
self._speak(reply.text)
|
||||
self._state.transition(PetState.IDLE)
|
||||
self._maybe_self_restart()
|
||||
|
||||
def _handle_local_intent(self, text: str) -> bool:
|
||||
"""Answer *text* locally if it's one of the closed set of body commands
|
||||
in intents.py. Returns True if it was handled (no server call).
|
||||
|
||||
The effects live here rather than in intents.py for the same reason
|
||||
pet_actions splits parse from describe: recognising the phrase is pure
|
||||
and testable, doing the thing needs the controller's state, the tray's
|
||||
nap override and a Qt signal to the window."""
|
||||
if not config.LOCAL_INTENTS:
|
||||
return False
|
||||
intent = intents_mod.recognize(text)
|
||||
if intent is None:
|
||||
return False
|
||||
self.log.emit(f"Local intent: {intent.name} (answered without the server)")
|
||||
|
||||
if intent.name == "stop":
|
||||
# Nothing to say and nothing to do: silence is the acknowledgement.
|
||||
# Also ends any follow-up chain — "never mind" means the
|
||||
# conversation is over, not that we should keep the mic open.
|
||||
self._follow_ups = 0
|
||||
self._pending_follow_up = False
|
||||
self._talk_now.clear()
|
||||
return True
|
||||
|
||||
if intent.name == "repeat":
|
||||
last = self.history.last(history_mod.PET)
|
||||
if last is None:
|
||||
self._speak("I haven't said anything yet.")
|
||||
else:
|
||||
# remember=False: replaying a line isn't a new turn. Appending it
|
||||
# would make "say that again" twice over read back as a
|
||||
# conversation where Bolt volunteered the same thing three times.
|
||||
self._speak(last.text, remember=False)
|
||||
return True
|
||||
|
||||
if intent.name == "voice_reset":
|
||||
had_voice = bool(self._voice_id)
|
||||
self.reset_voice()
|
||||
self._speak(intent.speak if had_voice else "That is my normal voice.")
|
||||
return True
|
||||
|
||||
action = intent.action
|
||||
if action is not None:
|
||||
if action.get("action") == "nap":
|
||||
# Through set_napping, not just the signal, so a spoken "go to
|
||||
# sleep" overrides the quiet-hours schedule exactly like the
|
||||
# tray's Nap entry and `petctl nap` do — otherwise the next
|
||||
# schedule check would undo it within ten seconds.
|
||||
self.set_napping(bool(action["enabled"]))
|
||||
self.action.emit(dict(action))
|
||||
if intent.speak:
|
||||
self._speak(intent.speak)
|
||||
return True
|
||||
|
||||
def _with_context(self, text: str) -> str:
|
||||
"""Everything the server gets alongside what you actually said: the
|
||||
@@ -289,9 +422,26 @@ class PetController(QObject):
|
||||
self._pet_monitor = int(index)
|
||||
|
||||
def _handle_command(self, command: str) -> str:
|
||||
"""Server-relayed command. `petctl ...` drives the pet's body and
|
||||
`filectl ...` does local file read/write/edit — neither ever reaches
|
||||
a shell; everything else is a real command, exactly as before (see
|
||||
"""Server-relayed command, with the guarantee the relay depends on: this
|
||||
always returns a string.
|
||||
|
||||
The server is blocked on `/desk/tool_result` while this runs. If it
|
||||
raises instead of answering, the relay never posts, the turn dies
|
||||
mid-flight, and the server sits out its own timeout on a conversation it
|
||||
can't finish — the worst available failure mode, because it's silent on
|
||||
both ends. Handing the exception back as command output instead means
|
||||
Bolt can read what went wrong and say so, or try something else, inside
|
||||
the same turn."""
|
||||
try:
|
||||
return self._dispatch_command(command)
|
||||
except Exception as exc:
|
||||
self.log.emit(f"Command handler failed: {exc!r}")
|
||||
return f"[error] the pet couldn't run that: {exc}"
|
||||
|
||||
def _dispatch_command(self, command: str) -> str:
|
||||
"""`petctl ...` drives the pet's body, `dialoguectl ...` plays a scene
|
||||
and `filectl ...` does local file read/write/edit — none of them ever
|
||||
reach a shell; everything else is a real command, exactly as before (see
|
||||
the security notes in the README)."""
|
||||
try:
|
||||
action = pet_actions.parse(command)
|
||||
@@ -307,6 +457,18 @@ class PetController(QObject):
|
||||
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":
|
||||
@@ -327,6 +489,14 @@ class PetController(QObject):
|
||||
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:
|
||||
@@ -365,13 +535,167 @@ class PetController(QObject):
|
||||
self.log.emit(f"Reading monitor {monitor.number} ({monitor.name})…")
|
||||
return screen_text.read_monitor(monitor, limit)
|
||||
|
||||
def _speak(self, text: str) -> None:
|
||||
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 _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}"
|
||||
|
||||
resume = self._state.state
|
||||
self._state.transition(PetState.TALKING)
|
||||
self.said.emit(speech_text.for_display(text))
|
||||
self.history.add(history_mod.PET, text, time.time())
|
||||
|
||||
should_stop = None
|
||||
if self._barge_in is not None:
|
||||
self._barge_in.reset()
|
||||
should_stop = self._barge_in.check
|
||||
completed = tts.play_pcm(pcm, sample_rate, should_stop=should_stop)
|
||||
if self._barge_in is not None:
|
||||
self._barge_in.reset() # the pet's own voices are in the wake window
|
||||
if resume in (PetState.THINKING, PetState.IDLE):
|
||||
self._state.transition(resume)
|
||||
|
||||
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, remember: bool = True) -> 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}")
|
||||
if remember:
|
||||
self.history.add(history_mod.PET, text, time.time())
|
||||
|
||||
should_stop = None
|
||||
@@ -382,6 +706,7 @@ class PetController(QObject):
|
||||
text,
|
||||
on_error=lambda exc: self.log.emit(f"TTS failed: {exc}"),
|
||||
should_stop=should_stop,
|
||||
voice_id=self._voice_id or None,
|
||||
)
|
||||
# Read the scoring history *before* resetting, or the log reports the
|
||||
# blank counters instead of what actually fired.
|
||||
@@ -404,6 +729,15 @@ class PetController(QObject):
|
||||
f"Asked a question — listening for your answer "
|
||||
f"({self._follow_ups}{'/' + str(cap) if cap > 0 else ''})."
|
||||
)
|
||||
# The tail of the reply we just played is still in the mic's ring
|
||||
# buffer, and we're about to start recording with a VAD that will
|
||||
# take it for the start of your answer — Bolt's own last words,
|
||||
# transcribed and sent back to him as if you'd said them. Nothing you
|
||||
# said can be in there: playback ran to completion, so if you had
|
||||
# spoken, barge-in would have cut it and taken the other branch.
|
||||
dropped = mic.flush(self._stream)
|
||||
if dropped:
|
||||
self.log.emit(f"Dropped {dropped} buffered frames of my own voice.")
|
||||
self._pending_follow_up = True
|
||||
self._talk_now.set()
|
||||
|
||||
@@ -482,16 +816,39 @@ 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()):
|
||||
now = time.monotonic()
|
||||
if not self._notification_gate.should_forward(notification, now):
|
||||
return
|
||||
with self._notification_lock:
|
||||
self._pending_notifications.append(notification)
|
||||
if len(self._pending_notifications) == self._pending_notifications.maxlen:
|
||||
# Say so rather than dropping in silence: a full queue means the
|
||||
# bridge is matching more than the pet can plausibly speak, and
|
||||
# the filter is what wants tightening.
|
||||
self.log.emit("Notification queue full — dropping the oldest.")
|
||||
self._pending_notifications.append((now, notification))
|
||||
|
||||
def _drain_notifications(self) -> None:
|
||||
with self._notification_lock:
|
||||
pending, self._pending_notifications = self._pending_notifications, []
|
||||
for notification in pending:
|
||||
pending = list(self._pending_notifications)
|
||||
self._pending_notifications.clear()
|
||||
|
||||
now = time.monotonic()
|
||||
max_age = config.NOTIFICATION_MAX_AGE_SECONDS
|
||||
if max_age > 0:
|
||||
fresh = [entry for entry in pending if now - entry[0] <= max_age]
|
||||
if len(fresh) != len(pending):
|
||||
self.log.emit(
|
||||
f"Skipping {len(pending) - len(fresh)} notification(s) older than "
|
||||
f"{int(max_age)}s."
|
||||
)
|
||||
pending = fresh
|
||||
|
||||
for index, (_stamped, notification) in enumerate(pending):
|
||||
if not self._running or self._napping:
|
||||
# Put back what we haven't forwarded — the old code swapped the
|
||||
# queue out and then returned, silently dropping the remainder
|
||||
# the moment a nap started mid-drain.
|
||||
self._requeue_notifications(pending[index:])
|
||||
return
|
||||
self.log.emit(f"Notification: {notification.as_text()}")
|
||||
self.history.add(history_mod.SYSTEM, notification.as_text(), time.time())
|
||||
@@ -501,13 +858,27 @@ class PetController(QObject):
|
||||
on_command=self._handle_command,
|
||||
)
|
||||
except server_client.ServerError as exc:
|
||||
# Keep this one and everything behind it for the next heartbeat:
|
||||
# the server being briefly down shouldn't silently eat the
|
||||
# backlog. The age limit is what stops that retrying forever.
|
||||
self.log.emit(f"Couldn't forward notification: {exc}")
|
||||
self._requeue_notifications(pending[index:])
|
||||
return
|
||||
self._check_deliveries()
|
||||
if reply.strip():
|
||||
self._speak(reply)
|
||||
self._apply_voice(reply)
|
||||
if reply.text.strip():
|
||||
self._speak(reply.text)
|
||||
self._state.transition(PetState.IDLE)
|
||||
|
||||
def _requeue_notifications(self, entries: list) -> None:
|
||||
"""Push undelivered notifications back on the front, oldest first, so a
|
||||
retry keeps their original order (and their original timestamps, so a
|
||||
retry loop can't keep a stale one alive indefinitely)."""
|
||||
if not entries:
|
||||
return
|
||||
with self._notification_lock:
|
||||
self._pending_notifications.extendleft(reversed(entries))
|
||||
|
||||
# ── file delivery ────────────────────────────────────────────────────
|
||||
|
||||
def _check_deliveries(self) -> None:
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
"""`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}"
|
||||
)
|
||||
+26
-16
@@ -52,6 +52,8 @@ 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,
|
||||
@@ -95,17 +97,23 @@ def parse(command: str) -> Optional[dict]:
|
||||
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 = json.loads(rest)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise FileOpError(f"couldn't parse filectl JSON ({exc}); usage:\n{HELP}") from exc
|
||||
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"}
|
||||
return {"action": "help", **tag}
|
||||
|
||||
if op == "list":
|
||||
path = _required_str(payload, "path")
|
||||
@@ -114,7 +122,7 @@ def parse(command: str) -> Optional[dict]:
|
||||
raise FileOpError('"pattern" must be a non-empty string')
|
||||
return {
|
||||
"action": "list", "path": path,
|
||||
"pattern": pattern, "recursive": bool(payload.get("recursive")),
|
||||
"pattern": pattern, "recursive": bool(payload.get("recursive")), **tag,
|
||||
}
|
||||
|
||||
if op == "read":
|
||||
@@ -122,14 +130,14 @@ def parse(command: str) -> Optional[dict]:
|
||||
return {
|
||||
"action": "read", "path": path,
|
||||
"start": _line_number(payload.get("start"), "start"),
|
||||
"end": _line_number(payload.get("end"), "end"),
|
||||
"end": _line_number(payload.get("end"), "end"), **tag,
|
||||
}
|
||||
|
||||
if op == "write":
|
||||
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"])}
|
||||
return {"action": "write", "path": path, "content": str(payload["content"]), **tag}
|
||||
|
||||
if op == "edit":
|
||||
path = _required_str(payload, "path")
|
||||
@@ -138,7 +146,7 @@ def parse(command: str) -> Optional[dict]:
|
||||
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}
|
||||
return {"action": "edit", "path": path, "old": old, "new": new, **tag}
|
||||
|
||||
raise FileOpError(f"unknown filectl op {op!r}; usage:\n{HELP}")
|
||||
|
||||
@@ -175,14 +183,16 @@ def execute(action: dict) -> str:
|
||||
if kind == "help":
|
||||
return HELP
|
||||
if kind == "list":
|
||||
return _do_list(action)
|
||||
if kind == "read":
|
||||
return _do_read(action)
|
||||
if kind == "write":
|
||||
return _do_write(action)
|
||||
if kind == "edit":
|
||||
return _do_edit(action)
|
||||
return "[filectl] ok"
|
||||
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:
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
"""Things you say to the pet that the server has no business answering.
|
||||
|
||||
"stop", "come here", "go to sleep", "say that again", "use your normal voice" —
|
||||
none of these are questions for Bolt's brain. They're commands to the *body*,
|
||||
and today every one of them costs a full turn: Deepgram, a `/desk/converse`
|
||||
round trip, a model deciding to emit `petctl`, then ElevenLabs. Two to four
|
||||
seconds and three network hops to make the pet walk left, and it only works at
|
||||
all if the server's prompt happens to advertise the right verb — which is
|
||||
exactly why `petctl voice reset` needs a block in the server's pet prompt (see
|
||||
CLAUDE.md) or the model never emits it. Recognising the phrase here removes
|
||||
both the latency and that coupling: "go back to your normal voice" works
|
||||
whether or not the server was ever told the voice can be reset.
|
||||
|
||||
The whole design problem is **not stealing real requests**. Three rules keep
|
||||
it honest:
|
||||
|
||||
1. **Whole-utterance, exact match after normalisation.** Never substring. So
|
||||
"stop" is an intent and "stop the docker container" is a question for the
|
||||
server — the distinction a substring match would destroy.
|
||||
2. **The phrase table is closed and small.** Every entry is something with no
|
||||
plausible reading as a request for Bolt to *do work*. Anything arguable
|
||||
("no thanks", "nothing") is deliberately absent — see rule 3 for why a
|
||||
wrong guess is expensive.
|
||||
3. **Nothing is recognised mid-conversation.** The controller skips this
|
||||
entirely on a follow-up turn: if Bolt just asked you something, your answer
|
||||
belongs to him, and swallowing "never mind" locally would leave the server
|
||||
holding a question it never got an answer to. Local intents are only ever
|
||||
for turns *you* started.
|
||||
|
||||
Both sides of the comparison go through `normalize()` — the table is
|
||||
canonicalised at import — so phrases can be written the way a person says them
|
||||
("go back to your normal voice") without every variant having to be spelled
|
||||
out. Filler is dropped from anywhere, not just the ends, because STT scatters
|
||||
it ("hey bolt, could you please just stop now").
|
||||
|
||||
Pure classification, like pet_actions.parse: this module decides *what was
|
||||
meant* and hands back an action in the same shape pet_actions produces, so
|
||||
`controller.action` and `PetWindow.apply_action` need no new vocabulary. The
|
||||
effects live in controller._handle_local_intent.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
# Words with no bearing on any command in the table, dropped wherever they
|
||||
# appear. Kept deliberately short: every entry here is a word that can't
|
||||
# distinguish one of these phrases from another, and adding one that can is how
|
||||
# two intents quietly collide (the builder below raises if that happens).
|
||||
_FILLER = frozenset({
|
||||
"the", "a", "an", "my", "your", "yours", "its", "to", "of", "and",
|
||||
"please", "just", "that", "some", "bolt", "thunderbolt", "pet", "buddy",
|
||||
})
|
||||
|
||||
# Dropped only from the front — the politeness/address ramp STT reliably
|
||||
# prefixes. Not safe to drop mid-phrase (a bare "do" or "go" carries meaning
|
||||
# elsewhere), which is why this is separate from _FILLER.
|
||||
_LEADING_FILLER = frozenset({
|
||||
"hey", "hi", "hello", "yo", "ok", "okay", "um", "uh", "er", "so",
|
||||
"can", "could", "would", "will", "you", "i", "id", "like", "lets",
|
||||
"let", "us", "do", "go", "then", "now",
|
||||
})
|
||||
|
||||
_TRAILING_FILLER = frozenset({
|
||||
"ok", "okay", "thanks", "thank", "you", "boy", "already", "now",
|
||||
})
|
||||
|
||||
_KEEP = re.compile(r"[^a-z0-9 ]+")
|
||||
|
||||
|
||||
def normalize(text: str) -> str:
|
||||
"""Reduce an utterance to the bare command, or "" if nothing is left.
|
||||
|
||||
Lowercase, punctuation stripped (STT punctuates inconsistently), filler
|
||||
dropped. Not a stemmer and deliberately not clever — its only job is to
|
||||
make the same command spoken two ways land on the same string, without
|
||||
ever turning one command into a different one."""
|
||||
words = [word for word in _KEEP.sub(" ", (text or "").lower()).split()
|
||||
if word not in _FILLER]
|
||||
while words and words[0] in _LEADING_FILLER:
|
||||
words.pop(0)
|
||||
while words and words[-1] in _TRAILING_FILLER:
|
||||
words.pop()
|
||||
return " ".join(words)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Intent:
|
||||
"""One recognised local command.
|
||||
|
||||
*action* is a pet_actions-shaped dict for the UI (or None when there's
|
||||
nothing for the body to do); *speak* is what to say out loud, empty for the
|
||||
intents where doing the thing silently *is* the acknowledgement — the pet
|
||||
visibly moves, and a spoken confirmation would only make it slower. "stop"
|
||||
in particular has to be silent: answering "okay!" when told to be quiet is
|
||||
a comedy sketch, not a feature.
|
||||
"""
|
||||
|
||||
name: str
|
||||
action: Optional[dict] = None
|
||||
speak: str = ""
|
||||
|
||||
|
||||
# Intent -> (the Intent, the phrases that mean it, written as spoken).
|
||||
_TABLE: tuple[tuple[Intent, tuple[str, ...]], ...] = (
|
||||
(
|
||||
Intent("stop"),
|
||||
("stop", "stop talking", "stop it", "be quiet", "quiet", "shut up",
|
||||
"hush", "never mind", "nevermind", "forget it", "cancel",
|
||||
"cancel that", "drop it", "enough"),
|
||||
),
|
||||
(
|
||||
Intent("nap", {"action": "nap", "enabled": True}, "Night."),
|
||||
("go to sleep", "take a nap", "have a nap", "go to bed", "bedtime",
|
||||
"goodnight", "good night", "get some rest"),
|
||||
),
|
||||
(
|
||||
Intent("wake", {"action": "nap", "enabled": False}, "I'm up."),
|
||||
("wake up", "get up", "rise and shine", "you're awake", "are you awake"),
|
||||
),
|
||||
(
|
||||
Intent("come", {"action": "move", "anchor": "cursor"}),
|
||||
("come here", "come to me", "come back", "over here", "follow me",
|
||||
"follow my cursor"),
|
||||
),
|
||||
(
|
||||
Intent("go_away", {"action": "move", "anchor": "bottom-right"}),
|
||||
("go away", "move over", "move out of the way", "get out of the way",
|
||||
"out of the way", "hide", "get lost", "shoo", "scram",
|
||||
"go somewhere else"),
|
||||
),
|
||||
(
|
||||
Intent("repeat"), # answered from history by the controller
|
||||
("say that again", "say again", "repeat that", "repeat",
|
||||
"what did you say", "what was that", "come again", "one more time",
|
||||
"again", "sorry what"),
|
||||
),
|
||||
(
|
||||
Intent("wander_on", {"action": "wander", "enabled": True}),
|
||||
("go for a walk", "wander", "wander around", "walk around", "explore",
|
||||
"stretch your legs", "roam"),
|
||||
),
|
||||
(
|
||||
Intent("wander_off", {"action": "wander", "enabled": False}),
|
||||
("stay still", "stay put", "stop moving", "stop wandering",
|
||||
"don't move", "sit", "sit still", "stay", "settle down", "hold still"),
|
||||
),
|
||||
(
|
||||
# Reachable from the server too (petctl voice reset), but only if its
|
||||
# prompt mentions the verb. Recognising it here is what makes the
|
||||
# phrase work regardless of what the server was told.
|
||||
Intent("voice_reset", None, "Back to my own voice."),
|
||||
("use your normal voice", "use your own voice", "your normal voice",
|
||||
"go back to your normal voice", "be yourself", "be yourself again",
|
||||
"stop doing that voice", "drop the voice", "talk normally",
|
||||
"speak normally", "use your real voice"),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _build() -> dict[str, Intent]:
|
||||
"""Canonicalise the table, refusing to build an ambiguous one.
|
||||
|
||||
A phrase that normalises to "" would match an utterance of pure filler
|
||||
("hey bolt"), and one that lands on the same string as a phrase from
|
||||
another intent would silently bind to whichever was declared last. Both are
|
||||
edit-time mistakes, so they fail at import rather than at 3am on a mic."""
|
||||
table: dict[str, Intent] = {}
|
||||
for intent, phrases in _TABLE:
|
||||
for phrase in phrases:
|
||||
key = normalize(phrase)
|
||||
if not key:
|
||||
raise ValueError(f"intent phrase {phrase!r} normalises to nothing")
|
||||
existing = table.get(key)
|
||||
if existing is not None and existing.name != intent.name:
|
||||
raise ValueError(
|
||||
f"phrase {phrase!r} ({key!r}) is claimed by both "
|
||||
f"{existing.name} and {intent.name}"
|
||||
)
|
||||
table[key] = intent
|
||||
return table
|
||||
|
||||
|
||||
_BY_PHRASE = _build()
|
||||
|
||||
# Longest phrase in the table, in words. Anything longer can't match, so a real
|
||||
# request skips normalisation entirely — this runs on every turn.
|
||||
_MAX_WORDS = max(len(phrase.split()) for phrase in _BY_PHRASE)
|
||||
|
||||
|
||||
def recognize(text: str) -> Optional[Intent]:
|
||||
"""The intent *text* expresses, or None to send it to the server.
|
||||
|
||||
None is the safe answer and the common one: anything not matched verbatim
|
||||
against the table belongs to Bolt."""
|
||||
raw = (text or "").strip()
|
||||
if not raw:
|
||||
return None
|
||||
# +6 words of slack for the filler about to be stripped ("hey bolt, could
|
||||
# you please stop" is six words to reach a one-word command).
|
||||
if len(raw.split()) > _MAX_WORDS + 6:
|
||||
return None
|
||||
intent = _BY_PHRASE.get(normalize(raw))
|
||||
return intent
|
||||
+25
-1
@@ -44,9 +44,17 @@ HELP = (
|
||||
"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."""
|
||||
@@ -129,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")
|
||||
|
||||
@@ -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,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)
|
||||
+89
-10
@@ -9,14 +9,21 @@ 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 os
|
||||
import signal
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Callable, Optional
|
||||
from typing import Callable, NamedTuple, Optional
|
||||
|
||||
import requests
|
||||
|
||||
@@ -24,11 +31,26 @@ from . import config, sudo_askpass
|
||||
|
||||
_MAX_RELAY_HOPS = 16
|
||||
|
||||
# Command output handed back up the relay is capped: it becomes part of the
|
||||
# server's prompt, and a runaway `find /` would blow the context window.
|
||||
_MAX_COMMAND_OUTPUT = 6000
|
||||
|
||||
|
||||
class ServerError(Exception):
|
||||
"""Raised when the server responds with an error payload or unreachable."""
|
||||
|
||||
|
||||
class Reply(NamedTuple):
|
||||
"""One final reply from the desk API. *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 = ""
|
||||
|
||||
|
||||
def _headers() -> dict:
|
||||
return {"X-Desk-Api-Key": config.API_KEY}
|
||||
|
||||
@@ -58,23 +80,67 @@ def run_local_command(command: str, timeout: int = None) -> str:
|
||||
timeout = timeout or config.SUDO_COMMAND_TIMEOUT_SECONDS
|
||||
timeout = timeout or config.COMMAND_TIMEOUT_SECONDS
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
command, shell=True, capture_output=True, text=True,
|
||||
timeout=timeout, cwd=str(Path.home()), env=env,
|
||||
# start_new_session puts the shell in its own process group so a timeout
|
||||
# can kill the whole tree. subprocess.run() would only SIGKILL the `sh`
|
||||
# itself, leaving whatever it spawned (a build, a `tail -f`, an ffmpeg)
|
||||
# running forever with no parent watching — one relayed command that
|
||||
# hangs shouldn't leak a process for the rest of the session.
|
||||
process = subprocess.Popen(
|
||||
command, shell=True, cwd=str(Path.home()), env=env, text=True,
|
||||
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
||||
start_new_session=(os.name == "posix"),
|
||||
)
|
||||
output = (completed.stdout or "") + (completed.stderr or "")
|
||||
return f"[exit {completed.returncode}]\n{output}"[:6000]
|
||||
except subprocess.TimeoutExpired:
|
||||
return f"[command timed out after {timeout}s]"
|
||||
except Exception as exc:
|
||||
return f"[command failed: {exc}]"
|
||||
|
||||
try:
|
||||
stdout, stderr = process.communicate(timeout=timeout)
|
||||
return _command_output(f"[exit {process.returncode}]", stdout, stderr)
|
||||
except subprocess.TimeoutExpired:
|
||||
stdout, stderr = _terminate(process)
|
||||
# Whatever it managed to print before it hung is the useful part — a
|
||||
# bare "timed out" tells the model nothing it can act on, and the last
|
||||
# line of output usually says exactly what it was stuck waiting for.
|
||||
return _command_output(f"[command timed out after {timeout}s]", stdout, stderr)
|
||||
except Exception as exc:
|
||||
_terminate(process)
|
||||
return f"[command failed: {exc}]"
|
||||
|
||||
|
||||
def _terminate(process: subprocess.Popen) -> tuple[str, str]:
|
||||
"""Kill a timed-out command's whole process group and collect what it wrote.
|
||||
|
||||
SIGTERM first so a shell script can clean up, SIGKILL a moment later for
|
||||
anything that ignores it. The final drain is itself time-boxed: a
|
||||
grandchild holding the pipe open must not turn a timeout into a hang."""
|
||||
try:
|
||||
if os.name == "posix":
|
||||
group = os.getpgid(process.pid)
|
||||
os.killpg(group, signal.SIGTERM)
|
||||
try:
|
||||
process.wait(timeout=2)
|
||||
except subprocess.TimeoutExpired:
|
||||
os.killpg(group, signal.SIGKILL)
|
||||
else:
|
||||
process.kill()
|
||||
except (ProcessLookupError, PermissionError, OSError):
|
||||
pass # already gone, or never had its own group
|
||||
try:
|
||||
return process.communicate(timeout=2)
|
||||
except Exception:
|
||||
return "", ""
|
||||
|
||||
|
||||
def _command_output(header: str, stdout: Optional[str], stderr: Optional[str]) -> str:
|
||||
body = (stdout or "") + (stderr or "")
|
||||
return f"{header}\n{body}"[:_MAX_COMMAND_OUTPUT]
|
||||
|
||||
|
||||
def converse(
|
||||
text: str,
|
||||
on_command: Callable[[str], str] = run_local_command,
|
||||
timeout: float = 120.0,
|
||||
) -> str:
|
||||
) -> Reply:
|
||||
"""Send one turn of conversation to the desk API, relaying any commands
|
||||
the server sends back until it produces a final reply.
|
||||
|
||||
@@ -111,7 +177,20 @@ 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 ""),
|
||||
)
|
||||
if payload.get("type") == "command":
|
||||
# Fell out of the loop still being handed commands. Worth its own
|
||||
# message: "unknown server response" sent everyone looking at the
|
||||
# payload shape, when what actually happened is a model that kept
|
||||
# calling tools and never answered.
|
||||
raise ServerError(
|
||||
f"the server kept relaying commands past the {_MAX_RELAY_HOPS}-hop cap "
|
||||
"without producing a reply"
|
||||
)
|
||||
raise ServerError(str(payload.get("error") or "unknown server response"))
|
||||
|
||||
|
||||
|
||||
+39
-12
@@ -67,6 +67,27 @@ _SPOKEN_SYMBOLS = {
|
||||
"=": " equals ",
|
||||
}
|
||||
|
||||
# Abbreviations a voice spells out letter by letter ("eee gee") because the
|
||||
# periods make them look like sentence boundaries. Written out instead — this
|
||||
# has to run before _UNSPEAKABLE strips anything, and the trailing \.? keeps
|
||||
# "etc" working with or without its period. Word-bounded so "vs" inside a
|
||||
# filename is left alone.
|
||||
_SPOKEN_ABBREVIATIONS = (
|
||||
(re.compile(r"\be\.g\.?(?=\s|$)", re.IGNORECASE), "for example"),
|
||||
(re.compile(r"\bi\.e\.?(?=\s|$)", re.IGNORECASE), "that is"),
|
||||
(re.compile(r"\betc\.?(?=\s|$)", re.IGNORECASE), "and so on"),
|
||||
(re.compile(r"\bvs\.?(?=\s|$)", re.IGNORECASE), "versus"),
|
||||
(re.compile(r"\baka\b", re.IGNORECASE), "also known as"),
|
||||
(re.compile(r"\bw/(?=\s)", re.IGNORECASE), "with"),
|
||||
# "PR #42" -> "PR number 42"; a bare "#" is markup and _UNSPEAKABLE drops it.
|
||||
(re.compile(r"#(?=\d)"), "number "),
|
||||
# A long option's dashes are punctuation to the eye and syllables to the ear
|
||||
# ("dash dash force"). Only the doubled form: a single hyphen has to survive
|
||||
# for "bolt-pet" and "up-to-date", and requiring a word character after it
|
||||
# keeps a "---" horizontal rule intact for _RULE to strip.
|
||||
(re.compile(r"(?<!\w)--(?=\w)"), ""),
|
||||
)
|
||||
|
||||
_MULTI_SPACE = re.compile(r"[ \t]+")
|
||||
_MULTI_PUNCT = re.compile(r"(?:\s*\.){2,}")
|
||||
|
||||
@@ -105,6 +126,10 @@ def for_speech(text: str) -> str:
|
||||
return ""
|
||||
for symbol, spoken in _PRE_SPOKEN_SYMBOLS.items():
|
||||
text = text.replace(symbol, spoken)
|
||||
# Before the markdown pass, so "#42" still has its "#" to word and a real
|
||||
# "## Heading" (no digit after the hashes) is left for _HEADING to strip.
|
||||
for pattern, spoken in _SPOKEN_ABBREVIATIONS:
|
||||
text = pattern.sub(spoken, text)
|
||||
text = _strip_markdown(text, keep_emoji=False)
|
||||
text = _URL.sub(" link ", text)
|
||||
text = _TABLE_PIPE.sub(", ", text)
|
||||
@@ -119,19 +144,21 @@ def for_speech(text: str) -> str:
|
||||
|
||||
|
||||
def is_question(text: str) -> bool:
|
||||
"""True if the reply *ends* by asking the user something — the cue for
|
||||
the pet to keep listening instead of making you say the wake word again.
|
||||
"""True if the reply asks the user anything — the cue for the pet to keep
|
||||
listening instead of making you say the wake word again.
|
||||
|
||||
Deliberately only looks at the end. A reply that asks something in
|
||||
passing ("What time is it? It's 7:15.") isn't waiting on an answer,
|
||||
whereas one that finishes on a question mark is. The test runs on the
|
||||
spoken form, so a '?' that only exists inside a stripped code block or a
|
||||
URL doesn't count, and trailing decoration (emoji, quotes, brackets) is
|
||||
peeled off first so "Ready to go? 🚀" still reads as a question."""
|
||||
spoken = for_speech(text)
|
||||
while spoken and not (spoken[-1].isalnum() or spoken[-1] == "?"):
|
||||
spoken = spoken[:-1]
|
||||
return spoken.endswith("?")
|
||||
Anywhere in the reply counts, not only the end. An earlier version required
|
||||
a *trailing* '?' on the theory that "What time is it? It's 7:15." isn't
|
||||
waiting on an answer, and that's true of that sentence but wrong far more
|
||||
often: Bolt routinely asks first and then keeps talking ("Want me to fix
|
||||
it? I'd start with the config."), and refusing to listen there is the case
|
||||
that actually costs you a wake word. The cheap failure is the other
|
||||
direction — an unwanted extra listen ends itself on `VAD_GRACE_SECONDS` of
|
||||
silence, and `FOLLOW_UP_MAX_TURNS` caps the chain.
|
||||
|
||||
The test runs on the *spoken* form, so a '?' that only exists inside a
|
||||
stripped code block, a URL, or a markdown link target doesn't count."""
|
||||
return "?" in for_speech(text)
|
||||
|
||||
|
||||
def for_display(text: str) -> str:
|
||||
|
||||
+6
-1
@@ -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},
|
||||
}
|
||||
|
||||
|
||||
@@ -80,6 +80,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:
|
||||
@@ -87,6 +88,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
|
||||
|
||||
+23
-1
@@ -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."""
|
||||
|
||||
@@ -41,10 +41,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: 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: (spoken.append(text), True)[1])
|
||||
|
||||
ctrl._handle_conversation_turn()
|
||||
|
||||
@@ -150,7 +150,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: (spoken.append(text), True)[1])
|
||||
said = _capture(ctrl.said)
|
||||
|
||||
ctrl._maybe_heartbeat()
|
||||
|
||||
@@ -15,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))
|
||||
@@ -159,7 +160,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):
|
||||
# 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
|
||||
@@ -179,7 +180,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: False) # interrupted
|
||||
|
||||
ctrl._speak("a very long explanation")
|
||||
|
||||
@@ -189,7 +190,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: True)
|
||||
ctrl._speak("short answer")
|
||||
assert not ctrl._talk_now.is_set()
|
||||
|
||||
@@ -201,7 +202,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: True)
|
||||
|
||||
|
||||
def test_a_reply_ending_in_a_question_keeps_listening(spoke, ctrl):
|
||||
@@ -220,10 +221,13 @@ def test_a_statement_does_not_keep_listening(spoke, ctrl):
|
||||
assert not ctrl._pending_follow_up
|
||||
|
||||
|
||||
def test_a_question_in_passing_does_not_count(spoke, ctrl):
|
||||
"""Only a reply that *ends* on a question is waiting for an answer."""
|
||||
ctrl._speak("What time is it? It's 7:15 AM.")
|
||||
assert not ctrl._talk_now.is_set()
|
||||
def test_a_question_anywhere_in_the_reply_keeps_listening(spoke, ctrl):
|
||||
"""Bolt often asks and then keeps talking ("Want me to fix it? I'd start
|
||||
with the config."), so the question mark doesn't have to be last. An
|
||||
unwanted extra listen ends itself on VAD_GRACE_SECONDS of silence; a missed
|
||||
one costs you a wake word, which is the more expensive mistake."""
|
||||
ctrl._speak("Want me to restart it? It's been up for 40 days.")
|
||||
assert ctrl._talk_now.is_set()
|
||||
|
||||
|
||||
def test_follow_ups_stop_at_the_cap(spoke, monkeypatch, ctrl):
|
||||
@@ -289,7 +293,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: False)
|
||||
ctrl._follow_ups = 3
|
||||
|
||||
ctrl._speak("a very long explanation")
|
||||
@@ -300,7 +304,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: True)
|
||||
ctrl._speak("**bold** reply")
|
||||
assert ctrl.history.last().text == "**bold** reply" # raw, for copy/paste
|
||||
|
||||
@@ -314,10 +318,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: 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: sent.append(text) or Reply("that's a KeyError"))
|
||||
|
||||
ctrl._handle_conversation_turn()
|
||||
|
||||
@@ -370,10 +374,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: 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: spoken.append(text) or True)
|
||||
ctrl.set_napping(True)
|
||||
|
||||
ctrl._handle_conversation_turn()
|
||||
@@ -381,6 +385,148 @@ def test_napping_still_answers_when_spoken_to(monkeypatch, ctrl):
|
||||
assert spoken == ["always"]
|
||||
|
||||
|
||||
# ── local intents ───────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.fixture
|
||||
def heard(monkeypatch):
|
||||
"""A turn where you said something, with the server and TTS recorded.
|
||||
Returns (utterance_setter, sent, spoken)."""
|
||||
said = {"text": ""}
|
||||
sent, spoken = [], []
|
||||
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["text"])
|
||||
monkeypatch.setattr(controller_mod.server_client, "converse",
|
||||
lambda text, on_command=None: sent.append(text) or Reply("from the server"))
|
||||
monkeypatch.setattr(controller_mod.tts, "speak",
|
||||
lambda text, on_error=None, should_stop=None, voice_id=None:
|
||||
spoken.append(text) or True)
|
||||
return said, sent, spoken
|
||||
|
||||
|
||||
def test_a_local_intent_never_reaches_the_server(heard, ctrl):
|
||||
said, sent, spoken = heard
|
||||
said["text"] = "come here"
|
||||
actions = _capture(ctrl.action)
|
||||
|
||||
ctrl._handle_conversation_turn()
|
||||
|
||||
assert sent == [] # no round trip at all
|
||||
assert actions == [{"action": "move", "anchor": "cursor"}]
|
||||
assert spoken == [] # walking over is the reply
|
||||
assert ctrl._state.state == PetState.IDLE
|
||||
|
||||
|
||||
def test_stop_is_answered_with_silence(heard, ctrl):
|
||||
said, sent, spoken = heard
|
||||
said["text"] = "be quiet"
|
||||
ctrl._handle_conversation_turn()
|
||||
assert (sent, spoken) == ([], [])
|
||||
|
||||
|
||||
def test_a_request_that_merely_starts_with_an_intent_word_goes_to_the_server(heard, ctrl):
|
||||
said, sent, spoken = heard
|
||||
said["text"] = "stop the docker container"
|
||||
ctrl._handle_conversation_turn()
|
||||
assert sent and "stop the docker container" in sent[0]
|
||||
assert spoken == ["from the server"]
|
||||
|
||||
|
||||
def test_local_intents_are_skipped_while_answering_a_question(heard, ctrl):
|
||||
"""Bolt asked something; "never mind" is an answer to him, not a body
|
||||
command. Swallowing it locally would leave the server holding a question it
|
||||
never got a reply to."""
|
||||
said, sent, spoken = heard
|
||||
said["text"] = "never mind"
|
||||
ctrl._pending_follow_up = True
|
||||
|
||||
ctrl._handle_conversation_turn()
|
||||
|
||||
assert sent and "never mind" in sent[0]
|
||||
|
||||
|
||||
def test_local_intents_can_be_turned_off(monkeypatch, heard, ctrl):
|
||||
monkeypatch.setattr(controller_mod.config, "LOCAL_INTENTS", False)
|
||||
said, sent, spoken = heard
|
||||
said["text"] = "come here"
|
||||
ctrl._handle_conversation_turn()
|
||||
assert sent and "come here" in sent[0]
|
||||
|
||||
|
||||
def test_say_that_again_replays_the_last_line_without_duplicating_history(heard, ctrl):
|
||||
said, sent, spoken = heard
|
||||
ctrl.history.add(controller_mod.history_mod.PET, "it's 7:15 AM", 0.0)
|
||||
said["text"] = "what did you say?"
|
||||
|
||||
ctrl._handle_conversation_turn()
|
||||
|
||||
assert spoken == ["it's 7:15 AM"]
|
||||
assert sent == []
|
||||
pet_lines = [e.text for e in ctrl.history.entries()
|
||||
if e.role == controller_mod.history_mod.PET]
|
||||
assert pet_lines == ["it's 7:15 AM"] # replayed, not re-recorded
|
||||
|
||||
|
||||
def test_repeat_with_nothing_to_repeat_says_so(heard, ctrl):
|
||||
said, sent, spoken = heard
|
||||
said["text"] = "say that again"
|
||||
ctrl._handle_conversation_turn()
|
||||
assert spoken == ["I haven't said anything yet."]
|
||||
|
||||
|
||||
def test_going_back_to_the_normal_voice_needs_no_server_prompt_support(heard, ctrl):
|
||||
"""The server can only offer `petctl voice reset` if its prompt happens to
|
||||
advertise the verb; recognising the phrase here works regardless."""
|
||||
said, sent, spoken = heard
|
||||
ctrl._voice_id, ctrl._voice_name = "voice-123", "Brian"
|
||||
changed = _capture(ctrl.voice_changed)
|
||||
said["text"] = "go back to your normal voice"
|
||||
|
||||
ctrl._handle_conversation_turn()
|
||||
|
||||
assert ctrl._voice_id == ""
|
||||
assert changed == [""]
|
||||
assert spoken == ["Back to my own voice."]
|
||||
assert sent == []
|
||||
|
||||
|
||||
def test_go_to_sleep_overrides_the_quiet_hours_schedule(heard, ctrl):
|
||||
said, sent, spoken = heard
|
||||
said["text"] = "go to sleep"
|
||||
|
||||
ctrl._handle_conversation_turn()
|
||||
|
||||
assert ctrl._napping is True
|
||||
assert ctrl._nap_forced is True # not undone by the next schedule check
|
||||
assert spoken == ["Night."]
|
||||
|
||||
|
||||
# ── failure containment ─────────────────────────────────────────────────────
|
||||
|
||||
def test_one_bad_turn_does_not_end_the_session(monkeypatch, ctrl):
|
||||
"""A turn raising something unforeseen used to unwind _loop and kill the
|
||||
thread — the pet would go deaf until it was restarted by hand."""
|
||||
logs = _capture(ctrl.log)
|
||||
|
||||
def explode():
|
||||
raise RuntimeError("numpy said no")
|
||||
|
||||
assert ctrl._guarded(explode, "conversation turn") is False
|
||||
assert ctrl._state.state == PetState.IDLE
|
||||
assert any("Recovered from a conversation turn failure" in m for m in logs)
|
||||
|
||||
|
||||
def test_a_command_handler_crash_is_reported_up_the_relay(monkeypatch, ctrl):
|
||||
"""The server is blocked on /desk/tool_result while this runs. Raising would
|
||||
leave it waiting out its own timeout on a turn that can never finish."""
|
||||
monkeypatch.setattr(controller_mod.pet_actions, "parse",
|
||||
lambda command: (_ for _ in ()).throw(KeyError("boom")))
|
||||
|
||||
output = ctrl._handle_command("petctl move top-left")
|
||||
|
||||
assert output.startswith("[error]") and "boom" in output
|
||||
|
||||
|
||||
# ── notification bridge ─────────────────────────────────────────────────────
|
||||
|
||||
def test_notifications_are_forwarded_and_spoken(monkeypatch, ctrl):
|
||||
@@ -388,9 +534,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: 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: spoken.append(text) or True)
|
||||
|
||||
ctrl._queue_notification(Notification(app="CI", summary="Build finished", body=""))
|
||||
ctrl._drain_notifications()
|
||||
@@ -403,7 +549,54 @@ def test_notifications_are_forwarded_and_spoken(monkeypatch, ctrl):
|
||||
def test_filtered_out_notifications_are_never_queued(ctrl):
|
||||
ctrl._notification_gate = controller_mod.notifications.NotificationGate("deploy", 0)
|
||||
ctrl._queue_notification(Notification(app="Chat", summary="lunch?", body=""))
|
||||
assert ctrl._pending_notifications == []
|
||||
assert not ctrl._pending_notifications
|
||||
|
||||
|
||||
def test_the_notification_queue_is_bounded(monkeypatch, ctrl):
|
||||
"""An overnight nap can't grow the queue without limit — the drain only runs
|
||||
from the heartbeat, and the heartbeat doesn't run while napping."""
|
||||
monkeypatch.setattr(controller_mod.config, "NOTIFICATION_QUEUE_LIMIT", 3)
|
||||
ctrl = controller_mod.PetController()
|
||||
ctrl._notification_gate = controller_mod.notifications.NotificationGate("", 0)
|
||||
|
||||
for index in range(10):
|
||||
ctrl._queue_notification(Notification(app="CI", summary=f"build {index}", body=""))
|
||||
|
||||
queued = [notification.summary for _stamp, notification in ctrl._pending_notifications]
|
||||
assert queued == ["build 7", "build 8", "build 9"] # oldest dropped
|
||||
|
||||
|
||||
def test_stale_notifications_are_dropped_instead_of_read_out(monkeypatch, ctrl):
|
||||
monkeypatch.setattr(controller_mod.config, "NOTIFICATION_MAX_AGE_SECONDS", 900)
|
||||
sent = []
|
||||
monkeypatch.setattr(controller_mod.server_client, "converse",
|
||||
lambda text, on_command=None: sent.append(text) or Reply(""))
|
||||
ctrl._notification_gate = controller_mod.notifications.NotificationGate("", 0)
|
||||
ctrl._queue_notification(Notification(app="CI", summary="fresh", body=""))
|
||||
# Backdate it past the age limit, as an overnight backlog would be.
|
||||
stamp, notification = ctrl._pending_notifications.pop()
|
||||
ctrl._pending_notifications.append((stamp - 4000, notification))
|
||||
|
||||
ctrl._drain_notifications()
|
||||
|
||||
assert sent == []
|
||||
|
||||
|
||||
def test_a_nap_starting_mid_drain_keeps_the_rest_queued(monkeypatch, ctrl):
|
||||
"""The old code swapped the queue out and returned, losing the remainder."""
|
||||
def converse(text, on_command=None):
|
||||
ctrl._napping = True # e.g. quiet hours began, or a fullscreen app opened
|
||||
return Reply("")
|
||||
|
||||
monkeypatch.setattr(controller_mod.server_client, "converse", converse)
|
||||
ctrl._notification_gate = controller_mod.notifications.NotificationGate("", 0)
|
||||
for index in range(3):
|
||||
ctrl._queue_notification(Notification(app="CI", summary=f"build {index}", body=""))
|
||||
|
||||
ctrl._drain_notifications()
|
||||
|
||||
remaining = [notification.summary for _stamp, notification in ctrl._pending_notifications]
|
||||
assert remaining == ["build 1", "build 2"]
|
||||
|
||||
|
||||
def test_notifications_are_not_forwarded_while_napping(monkeypatch, ctrl):
|
||||
@@ -506,9 +699,9 @@ def test_check_deliveries_runs_after_a_conversation_turn(monkeypatch, ctrl, tmp_
|
||||
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: "it's on the way")
|
||||
lambda text, on_command=None: Reply("it's on the way"))
|
||||
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: 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}])
|
||||
@@ -518,3 +711,294 @@ def test_check_deliveries_runs_after_a_conversation_turn(monkeypatch, ctrl, tmp_
|
||||
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: reply)
|
||||
monkeypatch.setattr(
|
||||
controller_mod.tts, "speak",
|
||||
lambda text, on_error=None, should_stop=None, voice_id=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: 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: 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: 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: 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: 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: 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:
|
||||
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: called.append(text))
|
||||
|
||||
ctrl._report_self_restart()
|
||||
|
||||
assert called == []
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
"""`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) == "[dialogue] played 2 lines in 2 voices: narrator, self"
|
||||
|
||||
|
||||
# ── 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()
|
||||
@@ -264,3 +264,66 @@ def test_write_creates_parent_directories(tmp_path):
|
||||
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,112 @@
|
||||
"""Local intent recognition — pure string logic, no hardware or display.
|
||||
|
||||
The interesting tests are the negative ones: this feature's whole risk is
|
||||
swallowing something that was meant for the server.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from bolt_pet import intents
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("said, expected", [
|
||||
("stop", "stop"),
|
||||
("Stop!", "stop"),
|
||||
("never mind", "stop"),
|
||||
("be quiet", "stop"),
|
||||
("go to sleep", "nap"),
|
||||
("take a nap", "nap"),
|
||||
("goodnight", "nap"),
|
||||
("wake up", "wake"),
|
||||
("come here", "come"),
|
||||
("follow my cursor", "come"),
|
||||
("get out of the way", "go_away"),
|
||||
("hide", "go_away"),
|
||||
("say that again", "repeat"),
|
||||
("what did you say?", "repeat"),
|
||||
("go for a walk", "wander_on"),
|
||||
("stay put", "wander_off"),
|
||||
("sit", "wander_off"),
|
||||
("use your normal voice", "voice_reset"),
|
||||
("go back to your normal voice", "voice_reset"),
|
||||
("be yourself again", "voice_reset"),
|
||||
])
|
||||
def test_recognized_phrases(said, expected):
|
||||
intent = intents.recognize(said)
|
||||
assert intent is not None and intent.name == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize("said", [
|
||||
# Each of these starts with (or contains) an intent phrase, and every one is
|
||||
# a real request. A substring match would eat all of them.
|
||||
"stop the docker container",
|
||||
"stop the deploy and tell me what broke",
|
||||
"can you hide the window that's covering my terminal",
|
||||
"come up with a name for this branch",
|
||||
"repeat the last command but with sudo",
|
||||
"what did you say the disk usage was on the server",
|
||||
"move the config file to the backup directory",
|
||||
"sit down and write me a haiku about kubernetes",
|
||||
"what time is it",
|
||||
"go to sleep mode on the server",
|
||||
"",
|
||||
" ",
|
||||
# Pure filler leaves an empty string, which must not match anything.
|
||||
"hey bolt",
|
||||
"okay bolt please",
|
||||
])
|
||||
def test_real_requests_are_left_for_the_server(said):
|
||||
assert intents.recognize(said) is None
|
||||
|
||||
|
||||
def test_filler_is_stripped_from_both_ends():
|
||||
assert intents.normalize("Hey Bolt, could you please just stop now?") == "stop"
|
||||
assert intents.normalize("okay, come here buddy") == "come here"
|
||||
|
||||
|
||||
def test_normalize_returns_empty_for_pure_filler():
|
||||
assert intents.normalize("hey bolt") == ""
|
||||
assert intents.normalize("...") == ""
|
||||
|
||||
|
||||
def test_intents_carry_ui_actions_in_the_pet_actions_shape():
|
||||
"""The action dicts go straight to PetWindow.apply_action, so they have to
|
||||
match the vocabulary pet_actions.parse produces — no new UI cases."""
|
||||
assert intents.recognize("come here").action == {"action": "move", "anchor": "cursor"}
|
||||
assert intents.recognize("stay put").action == {"action": "wander", "enabled": False}
|
||||
assert intents.recognize("go to sleep").action == {"action": "nap", "enabled": True}
|
||||
|
||||
|
||||
def test_stop_says_nothing():
|
||||
"""Answering "okay!" when told to be quiet defeats the purpose."""
|
||||
intent = intents.recognize("be quiet")
|
||||
assert intent.speak == "" and intent.action is None
|
||||
|
||||
|
||||
def test_a_phrase_claimed_by_two_intents_fails_at_import(monkeypatch):
|
||||
"""Without this guard the phrase would silently bind to whichever intent was
|
||||
declared last — a table edit that looks fine and misbehaves on a mic."""
|
||||
monkeypatch.setattr(intents, "_TABLE", (
|
||||
(intents.Intent("stop"), ("enough",)),
|
||||
(intents.Intent("nap"), ("enough",)),
|
||||
))
|
||||
with pytest.raises(ValueError, match="claimed by both"):
|
||||
intents._build()
|
||||
|
||||
|
||||
def test_a_phrase_of_pure_filler_fails_at_import(monkeypatch):
|
||||
"""It would normalise to "" and then match any all-filler utterance."""
|
||||
monkeypatch.setattr(intents, "_TABLE", ((intents.Intent("stop"), ("please bolt",)),))
|
||||
with pytest.raises(ValueError, match="normalises to nothing"):
|
||||
intents._build()
|
||||
|
||||
|
||||
def test_every_table_phrase_round_trips():
|
||||
for phrase, intent in intents._BY_PHRASE.items():
|
||||
assert phrase, "a phrase normalised to nothing"
|
||||
assert intents.recognize(phrase) is intent
|
||||
@@ -112,3 +112,51 @@ def test_pcm_to_wav_bytes_round_trips_via_wave_module():
|
||||
assert wf.getframerate() == 16000
|
||||
frames = wf.readframes(wf.getnframes())
|
||||
assert np.frombuffer(frames, dtype=np.int16).tolist() == pcm.tolist()
|
||||
|
||||
|
||||
# ── flushing buffered audio ─────────────────────────────────────────────────
|
||||
|
||||
class _BufferedStream:
|
||||
"""A stream with a backlog, like PortAudio's ring buffer after the reader
|
||||
was blocked on a network call for a while."""
|
||||
|
||||
def __init__(self, available):
|
||||
self.read_available = available
|
||||
self.reads = []
|
||||
|
||||
def read(self, frames):
|
||||
self.reads.append(frames)
|
||||
self.read_available = max(0, self.read_available - frames)
|
||||
return np.zeros((frames, 1), dtype=np.int16), False
|
||||
|
||||
|
||||
def test_flush_drops_exactly_what_was_buffered():
|
||||
stream = _BufferedStream(4096)
|
||||
assert mic.flush(stream) == 4096
|
||||
assert stream.reads == [4096]
|
||||
assert stream.read_available == 0
|
||||
|
||||
|
||||
def test_flush_is_bounded_so_it_cannot_chase_a_live_stream():
|
||||
"""A stream filling as fast as it drains must not spin forever."""
|
||||
stream = _BufferedStream(10 ** 9)
|
||||
dropped = mic.flush(stream, max_seconds=1.0, sample_rate=16000)
|
||||
assert dropped == 16000
|
||||
|
||||
|
||||
def test_flush_is_a_noop_on_an_empty_or_fake_stream():
|
||||
stream = _BufferedStream(0)
|
||||
assert mic.flush(stream) == 0
|
||||
assert stream.reads == []
|
||||
assert mic.flush(_ScriptedStream([])) == 0 # no read_available at all
|
||||
assert mic.flush(None) == 0
|
||||
|
||||
|
||||
def test_flush_swallows_a_device_error():
|
||||
class _Broken:
|
||||
read_available = 1024
|
||||
|
||||
def read(self, frames):
|
||||
raise RuntimeError("device disappeared")
|
||||
|
||||
assert mic.flush(_Broken()) == 0
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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) == []
|
||||
@@ -1,4 +1,6 @@
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
@@ -27,7 +29,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 +46,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 +56,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"})
|
||||
@@ -128,3 +144,52 @@ def test_download_outbox_file_raises_server_error_on_http_failure():
|
||||
get.return_value = _mock_response({}, ok=False)
|
||||
with pytest.raises(server_client.ServerError, match="abc"):
|
||||
server_client.download_outbox_file("abc")
|
||||
|
||||
|
||||
def test_converse_reports_a_relay_that_never_produced_a_reply():
|
||||
"""Hitting the hop cap used to surface as "unknown server response", which
|
||||
sent everyone looking at the payload shape instead of at a model that kept
|
||||
calling tools and never answered."""
|
||||
command = {"type": "command", "command": "echo hi", "token": "t"}
|
||||
with patch.object(server_client.requests, "post") as post:
|
||||
post.return_value = _mock_response(command)
|
||||
with pytest.raises(server_client.ServerError, match="hop cap"):
|
||||
server_client.converse("hi", on_command=lambda cmd: "ok")
|
||||
|
||||
|
||||
# ── relayed shell commands ──────────────────────────────────────────────────
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _no_sudo_prompt(monkeypatch):
|
||||
monkeypatch.setattr(server_client.config, "SUDO_ASKPASS_PROMPT", False)
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.name != "posix", reason="POSIX shell and process groups")
|
||||
def test_a_successful_command_returns_its_output_and_exit_code():
|
||||
output = server_client.run_local_command("echo hello; exit 3")
|
||||
assert output.startswith("[exit 3]")
|
||||
assert "hello" in output
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.name != "posix", reason="POSIX shell and process groups")
|
||||
def test_a_timed_out_command_still_reports_what_it_printed():
|
||||
"""A bare "timed out" tells the model nothing; the last line of output
|
||||
usually says exactly what it was stuck waiting for."""
|
||||
output = server_client.run_local_command("echo working on it; sleep 30", timeout=1)
|
||||
assert "timed out after 1s" in output
|
||||
assert "working on it" in output
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.name != "posix", reason="POSIX shell and process groups")
|
||||
def test_a_timed_out_command_takes_its_children_with_it(tmp_path):
|
||||
"""subprocess.run() would only kill the `sh`, leaving whatever it spawned
|
||||
running for the rest of the session with no parent watching."""
|
||||
marker = tmp_path / "ticks"
|
||||
server_client.run_local_command(
|
||||
f"(while true; do echo tick >> {marker}; sleep 0.05; done) & sleep 30",
|
||||
timeout=1,
|
||||
)
|
||||
settled = marker.stat().st_size if marker.exists() else 0
|
||||
time.sleep(0.4)
|
||||
grew = (marker.stat().st_size if marker.exists() else 0) - settled
|
||||
assert grew == 0, "a grandchild survived the timeout and is still writing"
|
||||
|
||||
@@ -59,11 +59,12 @@ def test_display_keeps_emoji_but_drops_markdown():
|
||||
assert for_display("* one\n* two") == "• one • two"
|
||||
|
||||
|
||||
def test_is_question_only_fires_on_a_trailing_question():
|
||||
def test_is_question_fires_when_a_question_mark_appears_anywhere():
|
||||
assert is_question("Ready to run a command or start a project?")
|
||||
assert is_question("It's 7:15 AM. Want me to set a timer?")
|
||||
assert is_question("What time is it? It's 7:15 AM.")
|
||||
assert is_question("Can you help me with this? I need a quick answer.")
|
||||
assert not is_question("It's 7:15 AM on July 23, 2026.")
|
||||
assert not is_question("What time is it? It's 7:15 AM.") # asked in passing
|
||||
|
||||
|
||||
def test_is_question_ignores_trailing_decoration():
|
||||
@@ -77,3 +78,29 @@ 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)
|
||||
|
||||
|
||||
def test_abbreviations_are_worded_instead_of_spelled_out():
|
||||
"""The periods make these look like sentence boundaries, so the voice reads
|
||||
them letter by letter ("eee gee")."""
|
||||
assert for_speech("Use a flag, e.g. --force") == "Use a flag, for example force"
|
||||
assert for_speech("i.e. the config file") == "that is the config file"
|
||||
assert for_speech("logs, configs, etc.") == "logs, configs, and so on"
|
||||
assert for_speech("docker vs. podman") == "docker versus podman"
|
||||
assert for_speech("Fixed in PR #42") == "Fixed in PR number 42"
|
||||
|
||||
|
||||
def test_abbreviation_wording_is_word_bounded():
|
||||
""""vs" inside a word or filename isn't an abbreviation."""
|
||||
assert "versus" not in for_speech("the vscode window")
|
||||
assert "versus" not in for_speech("revs per minute")
|
||||
# A markdown heading has no digit after the hashes, so it's still a heading.
|
||||
assert for_speech("## Results") == "Results"
|
||||
|
||||
|
||||
def test_long_option_dashes_are_dropped_but_hyphens_survive():
|
||||
assert for_speech("run it with --force") == "run it with force"
|
||||
assert for_speech("check bolt-pet is up-to-date") == "check bolt-pet is up-to-date"
|
||||
# The rule line is gone; the full stops are _bullets_to_sentences giving the
|
||||
# voice a pause where the eye saw a line break.
|
||||
assert for_speech("one\n---\ntwo") == "one. two."
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user