Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3ee67cb4d6 | |||
| 8d4751d80f | |||
| c4e805defd | |||
| 96afc351ac |
@@ -52,7 +52,47 @@
|
|||||||
"Bash(docker exec bolt *)",
|
"Bash(docker exec bolt *)",
|
||||||
"Bash(QT_QPA_PLATFORM=offscreen /root/Documents/bolt-pet/.venv/bin/pytest /home/themajesticmagician/Documents/Bolt-Pet/tests/test_file_ops.py -q)",
|
"Bash(QT_QPA_PLATFORM=offscreen /root/Documents/bolt-pet/.venv/bin/pytest /home/themajesticmagician/Documents/Bolt-Pet/tests/test_file_ops.py -q)",
|
||||||
"Bash(grep -rn *)",
|
"Bash(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
|
#ELEVENLABS_MODEL_ID=eleven_flash_v2
|
||||||
#TTS_SAMPLE_RATE=24000
|
#TTS_SAMPLE_RATE=24000
|
||||||
|
|
||||||
|
# Ask Bolt to use a different voice (or another language) and the server
|
||||||
|
# picks one from the ElevenLabs voice library and tags the reply with it.
|
||||||
|
# It only tags one reply, and it can't remember the id afterwards — so the
|
||||||
|
# pet keeps using that voice until a new one is picked or you choose "Use
|
||||||
|
# default voice" in the tray. VOICE_STICKY=false makes each pick last for
|
||||||
|
# exactly the one reply it came with instead.
|
||||||
|
#VOICE_STICKY=true
|
||||||
|
|
||||||
|
# ── Multi-voice dialogue (ElevenLabs Text to Dialogue) ──────────────────────
|
||||||
|
# Lets Bolt play a short scene in several voices with delivery tags the v3
|
||||||
|
# model acts on ("[cheerfully] Hello", "[whispering] He is lying"), driven by
|
||||||
|
# the server through a relayed `dialoguectl` command. Name the cast here —
|
||||||
|
# "self" always means whatever voice the pet is currently using.
|
||||||
|
#DIALOGUE=true
|
||||||
|
#DIALOGUE_MODEL_ID=eleven_v3
|
||||||
|
#DIALOGUE_VOICES=narrator:9BWtsMINqrJLrRacOk9x,villain:IKne3meq5aSn9XLyUdCD
|
||||||
|
|
||||||
|
# ── Self-restart (optional) ─────────────────────────────────────────────────
|
||||||
|
# `petctl self_restart <why>` lets Bolt reload the pet after editing its own
|
||||||
|
# code, so he can check the change live. The code is import-checked first, the
|
||||||
|
# restart waits for the current turn to finish, and the reason is carried
|
||||||
|
# across so the new process reports back. The guard refuses more than
|
||||||
|
# SELF_RESTART_MAX restarts within SELF_RESTART_WINDOW_SECONDS.
|
||||||
|
#SELF_RESTART=true
|
||||||
|
#SELF_RESTART_MAX=5
|
||||||
|
#SELF_RESTART_WINDOW_SECONDS=900
|
||||||
|
# Used instead of ELEVENLABS_MODEL_ID whenever the server picked the voice
|
||||||
|
# or the reply has non-ASCII in it — the flash_v2 default is English-only.
|
||||||
|
#ELEVENLABS_MULTILINGUAL_MODEL_ID=eleven_flash_v2_5
|
||||||
|
|
||||||
# ── Audio devices (optional — leave blank for the system default) ──────────
|
# ── Audio devices (optional — leave blank for the system default) ──────────
|
||||||
#MIC_DEVICE=
|
#MIC_DEVICE=
|
||||||
#SPEAKER_DEVICE=
|
#SPEAKER_DEVICE=
|
||||||
@@ -42,8 +72,17 @@ ELEVENLABS_VOICE_ID=
|
|||||||
#VAD_MIN_UTTERANCE_SECONDS=0.4
|
#VAD_MIN_UTTERANCE_SECONDS=0.4
|
||||||
#VAD_GRACE_SECONDS=4 # how long to wait for you to start talking
|
#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) ──────────────────────────────────────────
|
# ── 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.
|
# 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
|
# 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
|
# 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_BRIDGE=false
|
||||||
#NOTIFICATION_FILTER=build|deploy|calendar
|
#NOTIFICATION_FILTER=build|deploy|calendar
|
||||||
#NOTIFICATION_MIN_INTERVAL_SECONDS=60
|
#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) ─────────────────────────────────────────────────
|
# ── Push-to-talk (optional) ─────────────────────────────────────────────────
|
||||||
# Global hotkey; needs pynput and a session that allows global key hooks
|
# 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
|
click → record utterance → Deepgram STT → + active-window + screen-layout
|
||||||
context → POST /desk/converse → [server may relay a shell command to run on
|
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
|
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
|
ElevenLabs streaming TTS (or offline pyttsx3 fallback) → speakers`, with the
|
||||||
pet sprite/speech bubble reflecting state throughout, and playback
|
pet sprite/speech bubble reflecting state throughout, and playback
|
||||||
interruptible by talking over it (barge-in).
|
interruptible by talking over it (barge-in).
|
||||||
@@ -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
|
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`
|
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`,
|
and fill in `BOLT_SERVER_URL` / `DESK_API_KEY` (+ `DEEPGRAM_API_KEY`,
|
||||||
`ELEVENLABS_API_KEY`) before running — without server config the controller
|
`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
|
queued desktop notifications. It owns the live wake-word threshold
|
||||||
(`wake_threshold()` is passed to `listen_for_wake_word` as a *callable* so
|
(`wake_threshold()` is passed to `listen_for_wake_word` as a *callable* so
|
||||||
the tray slider takes effect mid-listen) and the conversation `history`.
|
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
|
- **`server_client.py`** — HTTP client for the desk API, dependency-free
|
||||||
beyond `requests` so it's easy to mock in tests. `converse()` loops relaying
|
beyond `requests` so it's easy to mock in tests. `converse()` loops relaying
|
||||||
server-issued shell commands (`run_local_command`, executed via
|
server-issued shell commands (`run_local_command`, executed via a
|
||||||
`subprocess.run(shell=True)` as the desktop user, 30s default timeout) via
|
`shell=True` `Popen` as the desktop user, 30s default timeout) via
|
||||||
`/desk/tool_result` until the server sends a final `reply` (capped at
|
`/desk/tool_result` until the server sends a final `reply` (capped at
|
||||||
`_MAX_RELAY_HOPS`). This is the same "full desktop control" trust model as
|
`_MAX_RELAY_HOPS` — 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 server repo's other desk clients — commands only ever originate from
|
||||||
the user's own voice/click requests in their own session. `list_outbox_files`
|
the user's own voice/click requests in their own session. A final reply is
|
||||||
|
returned as a `Reply(text, voice_id, voice_name)` rather than a bare string,
|
||||||
|
because the server can tag it with a voice — see "Voices" below.
|
||||||
|
`list_outbox_files`
|
||||||
/ `download_outbox_file` hit the same `/desk/files` and `/desk/files/<id>`
|
/ `download_outbox_file` hit the same `/desk/files` and `/desk/files/<id>`
|
||||||
endpoints the server's `deliver_files` tool queues onto — see `file_delivery.py`.
|
endpoints the server's `deliver_files` tool queues onto — see `file_delivery.py`.
|
||||||
- **`file_delivery.py`** — the filesystem half of receiving files the server
|
- **`file_delivery.py`** — the filesystem half of receiving files the server
|
||||||
@@ -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
|
the name isn't always coming from someone as trusted as the owner. Toggle
|
||||||
off entirely with `RECEIVE_FILES=false`.
|
off entirely with `RECEIVE_FILES=false`.
|
||||||
- **`audio/`** — `mic.py` (energy-based VAD utterance capture, ported from the
|
- **`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`
|
detection + `NearMissLog` for threshold tuning — see below), `stt.py`
|
||||||
(Deepgram), `tts.py` (ElevenLabs, streaming by default — `stream_pcm()` +
|
(Deepgram), `tts.py` (ElevenLabs, streaming by default — `stream_pcm()` +
|
||||||
`play_stream()` start playback on the first chunk; `chunks_to_int16()`
|
`play_stream()` start playback on the first chunk; `chunks_to_int16()`
|
||||||
carries odd bytes across HTTP chunk boundaries, without which everything
|
carries odd bytes across HTTP chunk boundaries, without which everything
|
||||||
after the first split sample plays as static — falling back to whole-clip
|
after the first split sample plays as static — falling back to whole-clip
|
||||||
PCM then offline `pyttsx3`), `barge_in.py` (two detectors behind one
|
PCM then offline `pyttsx3`; every entry point takes an optional `voice_id`
|
||||||
|
overriding `ELEVENLABS_VOICE_ID`, and `model_for()` picks the multilingual
|
||||||
|
model whenever there's an override or non-ASCII text, since the default
|
||||||
|
`eleven_flash_v2` is English-only and would read either as garbled
|
||||||
|
phonetic English rather than failing), `barge_in.py` (two detectors behind one
|
||||||
`reset()`/`check()` shape, chosen by `BARGE_IN_MODE` via `make_detector`:
|
`reset()`/`check()` shape, chosen by `BARGE_IN_MODE` via `make_detector`:
|
||||||
**wake** (default) scores every frame with the same openWakeWord model the
|
**wake** (default) scores every frame with the same openWakeWord model the
|
||||||
idle listener uses, so only the wake phrase cuts playback; **energy** is the
|
idle listener uses, so only the wake phrase cuts playback; **energy** is the
|
||||||
@@ -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
|
accepts an injectable stream/model/protocol so tests don't need real audio
|
||||||
hardware or a display.
|
hardware or a display.
|
||||||
- **`pet_actions.py`** — `petctl` pseudo-commands (`petctl move top-left`,
|
- **`pet_actions.py`** — `petctl` pseudo-commands (`petctl move top-left`,
|
||||||
`petctl emote wave`, `say`/`wander`/`nap`, plus the screen verbs
|
`petctl emote wave`, `say`/`wander`/`nap`, the screen verbs
|
||||||
`jump`/`monitors`/`read`). The desk API has no "move the pet" payload type
|
`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
|
and this repo can't change the server, so these ride the existing
|
||||||
shell-command relay: `controller._handle_command` parses them and they never
|
shell-command relay: `controller._handle_command` parses them and they never
|
||||||
reach `subprocess`; anything else is a real shell command exactly as before.
|
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()`.
|
module doesn't have, so the spec passes through to `monitors.resolve()`.
|
||||||
Query verbs (`monitors`, `read`) are answered in `_handle_command` rather
|
Query verbs (`monitors`, `read`) are answered in `_handle_command` rather
|
||||||
than by `pet_actions.describe()`, because their output *is* the point: it
|
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`
|
- **`file_ops.py`** — `filectl` pseudo-commands, checked in `_handle_command`
|
||||||
right after petctl and before falling through to a real shell command.
|
right after petctl and before falling through to a real shell command.
|
||||||
Executing arbitrary commands already worked via the shell relay
|
Executing arbitrary commands already worked via the shell relay
|
||||||
@@ -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
|
notes below); it's a safer path to the same capability. Pure parsing
|
||||||
(`parse`) is separated from the filesystem I/O (`execute`), matching
|
(`parse`) is separated from the filesystem I/O (`execute`), matching
|
||||||
pet_actions.py's parse/describe split.
|
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,
|
- **`screen_context.py`** — active-window title (xprop/xdotool, Win32,
|
||||||
osascript) appended to each utterance via `context_for()`, plus
|
osascript) appended to each utterance via `context_for()`, plus
|
||||||
`is_fullscreen_active()` for do-not-disturb. Text only — the desk API takes
|
`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
|
`dbus-monitor`, parses Notify calls (pure `iter_notifications()`), filters
|
||||||
and rate-limits them (`NotificationGate`), and the controller forwards
|
and rate-limits them (`NotificationGate`), and the controller forwards
|
||||||
survivors through `converse()`. Off by default — each one is a round trip.
|
survivors through `converse()`. Off by default — each one is a round trip.
|
||||||
|
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
|
- **`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
|
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
|
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
|
`for_speech()` (called inside `tts.speak()`, so every path to the speakers is
|
||||||
covered) strips markdown, emoji, URLs and stray symbols the voice would read
|
covered) strips markdown, emoji, URLs and stray symbols the voice would read
|
||||||
literally ("asterisk asterisk"), turns bullet lists into full sentences, and
|
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
|
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*
|
whether a reply leaves the pet waiting on an answer: it tests the *spoken*
|
||||||
form (so a '?' inside a stripped code block or URL doesn't count) and only a
|
form (so a '?' inside a stripped code block or URL doesn't count) and a '?'
|
||||||
trailing one counts, since a question asked in passing isn't awaiting a
|
**anywhere** counts. That last part was once trailing-only, on the theory that
|
||||||
reply. `controller._should_follow_up` uses it to keep listening without the
|
"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
|
wake word, capped by `FOLLOW_UP_MAX_TURNS` so a server that ends every reply
|
||||||
with a question can't loop forever off mic noise. Pure string logic, no
|
with a question can't loop forever off mic noise. Pure string logic, no
|
||||||
Qt/audio imports.
|
Qt/audio imports.
|
||||||
|
- **`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
|
- **`ui/`** — `app.py` wires `QApplication` + `PetWindow` + `PetTray` + the
|
||||||
history/tuner windows + the push-to-talk hotkey + the controller thread
|
history/tuner windows + the push-to-talk hotkey + the controller thread
|
||||||
together; `pet_window.py` is the frameless/translucent/always-on-top sprite
|
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
|
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
|
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 /
|
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
|
click-through / history / wake-word tuning / use-default-voice / quit) — the
|
||||||
title bar or taskbar entry; `history_window.py` and `wake_tuner.py` are the
|
pet window has no title bar or taskbar entry; `history_window.py` and
|
||||||
two dialogs it opens.
|
`wake_tuner.py` are the two dialogs it opens.
|
||||||
|
|
||||||
|
### Voices (the server's `speak_as`)
|
||||||
|
|
||||||
|
Ask Bolt to talk like someone else, or in another language, and the *server*
|
||||||
|
does the picking: its desk-only `voice_search` marker browses the ElevenLabs
|
||||||
|
voice library, and `speak_as: <voice_id>` on the final reply tags that reply
|
||||||
|
with the chosen voice (adding a Voice Library pick to the ElevenLabs account
|
||||||
|
first, so the id is usable by the time it reaches us). Nothing about that is
|
||||||
|
this repo's to decide — all the client owes it is actually speaking in the
|
||||||
|
voice it was handed: `converse()` returns it on `Reply`, `_apply_voice()`
|
||||||
|
records it, and `_speak()` passes it to `tts.speak(voice_id=...)`.
|
||||||
|
|
||||||
|
Two things are decided *here*, though, because the server can't:
|
||||||
|
|
||||||
|
- **The voice sticks** (`VOICE_STICKY`, default on). The server tags one
|
||||||
|
reply and strips the marker before storing the turn, so it never sees the
|
||||||
|
id again — "keep talking like that" would send it searching for a voice all
|
||||||
|
over again, and it'd likely land on a different one. Holding the id
|
||||||
|
client-side is what makes the rest of the conversation stay in that voice.
|
||||||
|
An untagged reply therefore never *changes* the voice; only a new
|
||||||
|
`speak_as`, `VOICE_STICKY=false`, or a reset does.
|
||||||
|
- **There's a way back.** Since the server was never told Bolt's own voice
|
||||||
|
id, it can't ask for it back with `speak_as` — so reverting is local: the
|
||||||
|
tray's **Use default voice** entry (enabled only while a picked voice is
|
||||||
|
in use, kept in sync by the `voice_changed` signal), a restart, or
|
||||||
|
`petctl voice reset`, which is what lets Bolt honour "go back to your
|
||||||
|
normal voice" out loud. That last one needs the server's pet prompt block
|
||||||
|
(`ai/desk_api.py`, `pet_tools`) to mention the verb, or the model never
|
||||||
|
emits it — the desk API's prompt is where petctl is advertised.
|
||||||
|
|
||||||
### Wake-word detection
|
### Wake-word detection
|
||||||
|
|
||||||
@@ -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
|
`listen_for_wake_word` accepts a callable threshold. Set the threshold just
|
||||||
under the peak you can hit reliably, then persist it in `.env`.
|
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
|
### Testing conventions
|
||||||
|
|
||||||
`tests/` covers pure logic only (state machine, wake-word scoring loop, mic
|
`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`).
|
speaks, and barging in starts your next turn immediately (`BARGE_IN`).
|
||||||
- Right-click the tray icon for **Talk now**, **Mute mic**, **Nap**,
|
- Right-click the tray icon for **Talk now**, **Mute mic**, **Nap**,
|
||||||
**Wander around**, **Click through the pet**, **History…**, **Wake word
|
**Wander around**, **Click through the pet**, **History…**, **Wake word
|
||||||
tuning…** and **Quit** — the pet window itself has no title bar or taskbar
|
tuning…**, **Use default voice** and **Quit** — the pet window itself has
|
||||||
entry.
|
no title bar or taskbar entry.
|
||||||
- **Click the speech bubble** to copy what it just said; the tray's
|
- **Click the speech bubble** to copy what it just said; the tray's
|
||||||
**History…** window keeps the last `HISTORY_LIMIT` turns.
|
**History…** window keeps the last `HISTORY_LIMIT` turns.
|
||||||
|
|
||||||
@@ -80,8 +80,8 @@ limitations).
|
|||||||
listening/thinking/talking or while a bubble is up.
|
listening/thinking/talking or while a bubble is up.
|
||||||
- **Moves and emotes on command.** Bolt can relay `petctl move top-left`,
|
- **Moves and emotes on command.** Bolt can relay `petctl move top-left`,
|
||||||
`petctl emote wave|hop|spin|nod|shake`, `petctl say ...`, `petctl wander
|
`petctl emote wave|hop|spin|nod|shake`, `petctl say ...`, `petctl wander
|
||||||
on|off`, `petctl nap on|off`. These are intercepted here and never reach a
|
on|off`, `petctl nap on|off`, `petctl voice reset`, and `dialoguectl` for a
|
||||||
shell.
|
multi-voice scene. These are intercepted here and never reach a shell.
|
||||||
- **Naps** during `QUIET_HOURS` (e.g. `23:00-08:00`) or while a fullscreen
|
- **Naps** during `QUIET_HOURS` (e.g. `23:00-08:00`) or while a fullscreen
|
||||||
app is focused (`DND_ON_FULLSCREEN`) — it dims, stops wandering, and makes
|
app is focused (`DND_ON_FULLSCREEN`) — it dims, stops wandering, and makes
|
||||||
no proactive noise. It still answers when you speak to it.
|
no proactive noise. It still answers when you speak to it.
|
||||||
@@ -90,6 +90,41 @@ limitations).
|
|||||||
notifications get forwarded to the server, so it can tell you the deploy
|
notifications get forwarded to the server, so it can tell you the deploy
|
||||||
went green. Off by default: each one costs a round trip.
|
went green. Off by default: each one costs a round trip.
|
||||||
|
|
||||||
|
## Speaking in another voice
|
||||||
|
|
||||||
|
Ask for a different voice — "use a clearer voice", "talk like a pirate", "say
|
||||||
|
that in Japanese" — and Bolt searches the ElevenLabs voice library on the
|
||||||
|
server, picks one, and tags his reply with it (`speak_as`); the pet is what
|
||||||
|
actually speaks in it. A Voice Library pick is added to your ElevenLabs
|
||||||
|
account automatically the first time it's used, and non-English replies (or
|
||||||
|
any picked voice) go through `ELEVENLABS_MULTILINGUAL_MODEL_ID` rather than
|
||||||
|
the English-only `eleven_flash_v2` default.
|
||||||
|
|
||||||
|
The new voice **stays on** for the rest of the conversation, because the
|
||||||
|
server tags a single reply and doesn't remember which voice it chose — so
|
||||||
|
"keep talking like that" would otherwise send it hunting for a voice again.
|
||||||
|
To get his own voice back: ask him ("use your normal voice" — he relays
|
||||||
|
`petctl voice reset`), use **Use default voice** in the tray menu (greyed
|
||||||
|
out unless a picked voice is active), or restart the pet. Set
|
||||||
|
`VOICE_STICKY=false` in `.env` if you'd rather each pick lasted exactly one
|
||||||
|
reply.
|
||||||
|
|
||||||
|
## Multi-voice dialogue
|
||||||
|
|
||||||
|
Ask for a scene — "do the argument between the two of them", "read that back
|
||||||
|
as a radio play" — and Bolt can relay a `dialoguectl` command that the pet
|
||||||
|
renders through ElevenLabs' Text to Dialogue endpoint: several voices in one
|
||||||
|
take, with delivery tags the v3 model acts on (`[cheerfully]`, `[whispering]`,
|
||||||
|
`[stuttering]`). One request per scene, so the voices actually react to each
|
||||||
|
other instead of sounding like clips glued together.
|
||||||
|
|
||||||
|
Name the cast in `.env` (`DIALOGUE_VOICES=narrator:9BWts…,villain:IKne3…`);
|
||||||
|
the name `self` always means whatever voice the pet is currently using, so
|
||||||
|
Bolt sounds like himself in his own scenes — including after a `speak_as`
|
||||||
|
switch. Scenes show up in the speech bubble with the tags stripped, count as
|
||||||
|
normal speech for the transcript, and can be talked over like any other reply.
|
||||||
|
`DIALOGUE=false` turns the whole thing off on this device.
|
||||||
|
|
||||||
## Wake-word detection
|
## Wake-word detection
|
||||||
|
|
||||||
`bolt_pet/audio/wake_word.py` feeds every mic frame into `thunderbolt.onnx`
|
`bolt_pet/audio/wake_word.py` feeds every mic frame into `thunderbolt.onnx`
|
||||||
|
|||||||
@@ -37,6 +37,42 @@ def rms(frame: np.ndarray) -> float:
|
|||||||
return float(np.sqrt(np.mean(frame.astype(np.float64) ** 2)))
|
return float(np.sqrt(np.mean(frame.astype(np.float64) ** 2)))
|
||||||
|
|
||||||
|
|
||||||
|
def 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(
|
def record_utterance(
|
||||||
stream: AudioStream,
|
stream: AudioStream,
|
||||||
should_continue=lambda: True,
|
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
|
Falls back to pyttsx3 (offline, cross-platform: SAPI5 on Windows, NSSpeech
|
||||||
on macOS, espeak on Linux) if ElevenLabs isn't configured or the request
|
on macOS, espeak on Linux) if ElevenLabs isn't configured or the request
|
||||||
fails, so the pet can still talk with zero cloud config.
|
fails, so the pet can still talk with zero cloud config.
|
||||||
|
|
||||||
|
Every entry point takes an optional *voice_id* that overrides
|
||||||
|
`ELEVENLABS_VOICE_ID` for that call — that's how the server's `speak_as`
|
||||||
|
reply marker reaches the speakers (see controller._apply_voice). The offline
|
||||||
|
fallback has no such concept and always sounds like itself.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Iterable, Iterator
|
from typing import Iterable, Iterator, Optional
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import requests
|
import requests
|
||||||
@@ -21,18 +26,40 @@ class TtsError(Exception):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
def synthesize_pcm(text: str) -> tuple[np.ndarray, int]:
|
def voice_for(voice_id: Optional[str] = None) -> str:
|
||||||
|
"""The voice this call should use: an override (server `speak_as`) if
|
||||||
|
given, else the configured default."""
|
||||||
|
return (voice_id or "").strip() or config.ELEVENLABS_VOICE_ID
|
||||||
|
|
||||||
|
|
||||||
|
def model_for(text: str, voice_id: Optional[str] = None) -> str:
|
||||||
|
"""Which ElevenLabs model to synthesize with.
|
||||||
|
|
||||||
|
The default (`eleven_flash_v2`) is English-only, and both things that
|
||||||
|
reach this branch mean the reply probably isn't English: a voice the
|
||||||
|
server picked mid-conversation is nearly always about a language or an
|
||||||
|
accent, and non-ASCII text can't be English at all. Rendering either one
|
||||||
|
through the English model gets you a mangled phonetic reading rather
|
||||||
|
than a failure, which is worse — so those go through the multilingual
|
||||||
|
model instead."""
|
||||||
|
if (voice_id or "").strip() or not text.isascii():
|
||||||
|
return config.ELEVENLABS_MULTILINGUAL_MODEL_ID
|
||||||
|
return config.ELEVENLABS_MODEL_ID
|
||||||
|
|
||||||
|
|
||||||
|
def synthesize_pcm(text: str, voice_id: Optional[str] = None) -> tuple[np.ndarray, int]:
|
||||||
"""Returns (pcm_int16_mono, sample_rate). Raises TtsError on failure —
|
"""Returns (pcm_int16_mono, sample_rate). Raises TtsError on failure —
|
||||||
callers should fall back to speak_offline() rather than treating this
|
callers should fall back to speak_offline() rather than treating this
|
||||||
as fatal."""
|
as fatal."""
|
||||||
if not (config.ELEVENLABS_API_KEY and config.ELEVENLABS_VOICE_ID):
|
voice = voice_for(voice_id)
|
||||||
|
if not (config.ELEVENLABS_API_KEY and voice):
|
||||||
raise TtsError("ELEVENLABS_API_KEY / ELEVENLABS_VOICE_ID not set")
|
raise TtsError("ELEVENLABS_API_KEY / ELEVENLABS_VOICE_ID not set")
|
||||||
try:
|
try:
|
||||||
response = requests.post(
|
response = requests.post(
|
||||||
f"https://api.elevenlabs.io/v1/text-to-speech/{config.ELEVENLABS_VOICE_ID}",
|
f"https://api.elevenlabs.io/v1/text-to-speech/{voice}",
|
||||||
headers={"xi-api-key": config.ELEVENLABS_API_KEY},
|
headers={"xi-api-key": config.ELEVENLABS_API_KEY},
|
||||||
params={"output_format": f"pcm_{config.TTS_SAMPLE_RATE}"},
|
params={"output_format": f"pcm_{config.TTS_SAMPLE_RATE}"},
|
||||||
json={"text": text, "model_id": config.ELEVENLABS_MODEL_ID},
|
json={"text": text, "model_id": model_for(text, voice_id)},
|
||||||
timeout=60,
|
timeout=60,
|
||||||
)
|
)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
@@ -44,20 +71,23 @@ def synthesize_pcm(text: str) -> tuple[np.ndarray, int]:
|
|||||||
return pcm, config.TTS_SAMPLE_RATE
|
return pcm, config.TTS_SAMPLE_RATE
|
||||||
|
|
||||||
|
|
||||||
def stream_pcm(text: str, chunk_bytes: int = 4096) -> Iterator[np.ndarray]:
|
def stream_pcm(
|
||||||
|
text: str, chunk_bytes: int = 4096, voice_id: Optional[str] = None
|
||||||
|
) -> Iterator[np.ndarray]:
|
||||||
"""Same audio as synthesize_pcm(), but yielded as it arrives from
|
"""Same audio as synthesize_pcm(), but yielded as it arrives from
|
||||||
ElevenLabs' /stream endpoint so playback can start on the first chunk
|
ElevenLabs' /stream endpoint so playback can start on the first chunk
|
||||||
(~300ms) instead of after the whole clip is synthesized. Raises TtsError
|
(~300ms) instead of after the whole clip is synthesized. Raises TtsError
|
||||||
before yielding anything if the request itself fails, so callers can fall
|
before yielding anything if the request itself fails, so callers can fall
|
||||||
back cleanly; a mid-stream failure just ends the generator."""
|
back cleanly; a mid-stream failure just ends the generator."""
|
||||||
if not (config.ELEVENLABS_API_KEY and config.ELEVENLABS_VOICE_ID):
|
voice = voice_for(voice_id)
|
||||||
|
if not (config.ELEVENLABS_API_KEY and voice):
|
||||||
raise TtsError("ELEVENLABS_API_KEY / ELEVENLABS_VOICE_ID not set")
|
raise TtsError("ELEVENLABS_API_KEY / ELEVENLABS_VOICE_ID not set")
|
||||||
try:
|
try:
|
||||||
response = requests.post(
|
response = requests.post(
|
||||||
f"https://api.elevenlabs.io/v1/text-to-speech/{config.ELEVENLABS_VOICE_ID}/stream",
|
f"https://api.elevenlabs.io/v1/text-to-speech/{voice}/stream",
|
||||||
headers={"xi-api-key": config.ELEVENLABS_API_KEY},
|
headers={"xi-api-key": config.ELEVENLABS_API_KEY},
|
||||||
params={"output_format": f"pcm_{config.TTS_SAMPLE_RATE}"},
|
params={"output_format": f"pcm_{config.TTS_SAMPLE_RATE}"},
|
||||||
json={"text": text, "model_id": config.ELEVENLABS_MODEL_ID},
|
json={"text": text, "model_id": model_for(text, voice_id)},
|
||||||
timeout=60,
|
timeout=60,
|
||||||
stream=True,
|
stream=True,
|
||||||
)
|
)
|
||||||
@@ -83,6 +113,55 @@ def chunks_to_int16(byte_chunks: Iterable[bytes]) -> Iterator[np.ndarray]:
|
|||||||
yield np.frombuffer(data[:usable], dtype=np.int16)
|
yield np.frombuffer(data[:usable], dtype=np.int16)
|
||||||
|
|
||||||
|
|
||||||
|
def 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:
|
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*
|
"""Play a whole clip. Returns True if it finished, False if *should_stop*
|
||||||
(barge-in) cut it short. *should_stop* is polled while audio plays — each
|
(barge-in) cut it short. *should_stop* is polled while audio plays — each
|
||||||
@@ -134,11 +213,12 @@ def speak_offline(text: str) -> None:
|
|||||||
engine.runAndWait()
|
engine.runAndWait()
|
||||||
|
|
||||||
|
|
||||||
def speak(text: str, on_error=None, should_stop=None) -> bool:
|
def speak(text: str, on_error=None, should_stop=None, voice_id: Optional[str] = None) -> bool:
|
||||||
"""Speak *text*, preferring streaming ElevenLabs, then whole-clip
|
"""Speak *text*, preferring streaming ElevenLabs, then whole-clip
|
||||||
ElevenLabs, then offline TTS. *on_error*, if given, is called with the
|
ElevenLabs, then offline TTS. *on_error*, if given, is called with the
|
||||||
exception when ElevenLabs fails (useful for logging) — a fallback still
|
exception when ElevenLabs fails (useful for logging) — a fallback still
|
||||||
runs either way. Returns False if barge-in interrupted playback.
|
runs either way. Returns False if barge-in interrupted playback.
|
||||||
|
*voice_id* overrides the configured voice for this line only.
|
||||||
|
|
||||||
The text is sanitized first (speech_text.for_speech): server replies are
|
The text is sanitized first (speech_text.for_speech): server replies are
|
||||||
written for a chat window, and a voice reads markdown/emoji literally
|
written for a chat window, and a voice reads markdown/emoji literally
|
||||||
@@ -149,12 +229,16 @@ def speak(text: str, on_error=None, should_stop=None) -> bool:
|
|||||||
return True
|
return True
|
||||||
if config.TTS_STREAMING:
|
if config.TTS_STREAMING:
|
||||||
try:
|
try:
|
||||||
return play_stream(stream_pcm(text), config.TTS_SAMPLE_RATE, should_stop=should_stop)
|
return play_stream(
|
||||||
|
stream_pcm(text, voice_id=voice_id),
|
||||||
|
config.TTS_SAMPLE_RATE,
|
||||||
|
should_stop=should_stop,
|
||||||
|
)
|
||||||
except TtsError as exc:
|
except TtsError as exc:
|
||||||
if on_error is not None:
|
if on_error is not None:
|
||||||
on_error(exc)
|
on_error(exc)
|
||||||
try:
|
try:
|
||||||
pcm, sample_rate = synthesize_pcm(text)
|
pcm, sample_rate = synthesize_pcm(text, voice_id=voice_id)
|
||||||
return play_pcm(pcm, sample_rate, should_stop=should_stop)
|
return play_pcm(pcm, sample_rate, should_stop=should_stop)
|
||||||
except TtsError as exc:
|
except TtsError as exc:
|
||||||
if on_error is not None:
|
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_API_KEY = os.environ.get("ELEVENLABS_API_KEY", "")
|
||||||
ELEVENLABS_VOICE_ID = os.environ.get("ELEVENLABS_VOICE_ID", "")
|
ELEVENLABS_VOICE_ID = os.environ.get("ELEVENLABS_VOICE_ID", "")
|
||||||
ELEVENLABS_MODEL_ID = os.environ.get("ELEVENLABS_MODEL_ID", "eleven_flash_v2")
|
ELEVENLABS_MODEL_ID = os.environ.get("ELEVENLABS_MODEL_ID", "eleven_flash_v2")
|
||||||
|
# eleven_flash_v2 is English-only, and the two cases that swap the voice
|
||||||
|
# (server-picked `speak_as`, or a reply with non-ASCII in it) are usually
|
||||||
|
# exactly the cases where the reply isn't English — see tts.model_for().
|
||||||
|
ELEVENLABS_MULTILINGUAL_MODEL_ID = os.environ.get(
|
||||||
|
"ELEVENLABS_MULTILINGUAL_MODEL_ID", "eleven_flash_v2_5"
|
||||||
|
)
|
||||||
# ElevenLabs PCM output formats are named pcm_<sample_rate>.
|
# ElevenLabs PCM output formats are named pcm_<sample_rate>.
|
||||||
TTS_SAMPLE_RATE = int(os.environ.get("TTS_SAMPLE_RATE", "24000"))
|
TTS_SAMPLE_RATE = int(os.environ.get("TTS_SAMPLE_RATE", "24000"))
|
||||||
|
|
||||||
|
# Does a voice the server picks (its speak_as marker — "talk like a pirate",
|
||||||
|
# "say that in Japanese") stay on for later replies, or last one reply only?
|
||||||
|
# Sticky by default: the server tags a single reply and does *not* keep the
|
||||||
|
# voice id in its history, so a one-reply-only voice can't be re-used when
|
||||||
|
# you say "keep talking like that" — it would have to search for a voice
|
||||||
|
# again. Reset it from the tray ("Use default voice") or by restarting.
|
||||||
|
VOICE_STICKY = os.environ.get("VOICE_STICKY", "true").lower() in ("1", "true", "yes", "on")
|
||||||
|
|
||||||
|
# ── multi-voice dialogue (ElevenLabs Text to Dialogue) ──────────────────────
|
||||||
|
# Lets Bolt play a short scene in several voices with delivery tags the v3
|
||||||
|
# model acts on ("[cheerfully] Hello"), instead of one voice reading a line.
|
||||||
|
# Driven by the server through the `dialoguectl` relayed command — see
|
||||||
|
# dialogue.py. Costs a separate (slower, whole-clip) request per scene, so
|
||||||
|
# it's a set piece, not the normal reply path.
|
||||||
|
#
|
||||||
|
# DIALOGUE_VOICES names the cast: "narrator:9BWtsMINqrJLrRacOk9x,villain:IKne3meq5aSn9XLyUdCD".
|
||||||
|
# The name "self" always resolves to the voice the pet is currently using,
|
||||||
|
# including one the server picked with speak_as.
|
||||||
|
|
||||||
|
DIALOGUE = os.environ.get("DIALOGUE", "true").lower() in ("1", "true", "yes", "on")
|
||||||
|
DIALOGUE_MODEL_ID = os.environ.get("DIALOGUE_MODEL_ID", "eleven_v3")
|
||||||
|
DIALOGUE_VOICES = os.environ.get("DIALOGUE_VOICES", "")
|
||||||
|
|
||||||
# ── mic / VAD (same tuning knobs as bolt_desk.py) ───────────────────────────
|
# ── mic / VAD (same tuning knobs as bolt_desk.py) ───────────────────────────
|
||||||
|
|
||||||
MIC_DEVICE = os.environ.get("MIC_DEVICE", "") or None # sounddevice name/index
|
MIC_DEVICE = os.environ.get("MIC_DEVICE", "") or None # sounddevice name/index
|
||||||
@@ -91,9 +120,17 @@ GRACE_SECONDS = float(os.environ.get("VAD_GRACE_SECONDS", "4"))
|
|||||||
# keeps feeding it noise. 0 means no cap.
|
# keeps feeding it noise. 0 means no cap.
|
||||||
|
|
||||||
FOLLOW_UP_LISTEN = os.environ.get("FOLLOW_UP_LISTEN", "true").lower() in ("1", "true", "yes", "on")
|
FOLLOW_UP_LISTEN = os.environ.get("FOLLOW_UP_LISTEN", "true").lower() in ("1", "true", "yes", "on")
|
||||||
FOLLOW_UP_MAX_TURNS = int(os.environ.get("FOLLOW_UP_MAX_TURNS", "3"))
|
FOLLOW_UP_MAX_TURNS = int(os.environ.get("FOLLOW_UP_MAX_TURNS", "10"))
|
||||||
FOLLOW_UP_GRACE_SECONDS = float(os.environ.get("FOLLOW_UP_GRACE_SECONDS", "7"))
|
FOLLOW_UP_GRACE_SECONDS = float(os.environ.get("FOLLOW_UP_GRACE_SECONDS", "7"))
|
||||||
|
|
||||||
|
# ── 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"))
|
COMMAND_TIMEOUT_SECONDS = int(os.environ.get("COMMAND_TIMEOUT_SECONDS", "30"))
|
||||||
|
|
||||||
# ── sudo password prompts ───────────────────────────────────────────────────
|
# ── 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"))
|
SUDO_COMMAND_TIMEOUT_SECONDS = int(os.environ.get("SUDO_COMMAND_TIMEOUT_SECONDS", "180"))
|
||||||
HEARTBEAT_INTERVAL_SECONDS = float(os.environ.get("HEARTBEAT_INTERVAL_SECONDS", "60"))
|
HEARTBEAT_INTERVAL_SECONDS = float(os.environ.get("HEARTBEAT_INTERVAL_SECONDS", "60"))
|
||||||
|
|
||||||
|
# ── self-restart ────────────────────────────────────────────────────────────
|
||||||
|
# `petctl self_restart` lets Bolt restart the pet after editing its code, so
|
||||||
|
# he can see his own change running instead of waiting for someone to restart
|
||||||
|
# it by hand. The code is import-checked in a subprocess first, and the reason
|
||||||
|
# is carried across the restart so the new process can report back — see
|
||||||
|
# self_restart.py. SELF_RESTART_MAX/_WINDOW_SECONDS bound the crash-loop case.
|
||||||
|
SELF_RESTART = os.environ.get("SELF_RESTART", "true").lower() in ("1", "true", "yes", "on")
|
||||||
|
|
||||||
# ── barge-in (interrupt playback while the pet is talking) ──────────────────
|
# ── barge-in (interrupt playback while the pet is talking) ──────────────────
|
||||||
# The mic stays live while the pet talks. BARGE_IN_MODE decides what counts
|
# The mic stays live while the pet talks. BARGE_IN_MODE decides what counts
|
||||||
# as an interruption:
|
# as an interruption:
|
||||||
@@ -184,6 +229,14 @@ NOTIFICATION_BRIDGE = os.environ.get("NOTIFICATION_BRIDGE", "false").lower() in
|
|||||||
# Regex matched against "<app>: <summary> <body>"; empty means "everything".
|
# Regex matched against "<app>: <summary> <body>"; empty means "everything".
|
||||||
NOTIFICATION_FILTER = os.environ.get("NOTIFICATION_FILTER", "")
|
NOTIFICATION_FILTER = os.environ.get("NOTIFICATION_FILTER", "")
|
||||||
NOTIFICATION_MIN_INTERVAL_SECONDS = float(os.environ.get("NOTIFICATION_MIN_INTERVAL_SECONDS", "60"))
|
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 ────────────────────────────────────────────────────────
|
# ── file delivery ────────────────────────────────────────────────────────
|
||||||
# The server's deliver_files tool (ai/desk_api.py in the main tmn-api repo)
|
# 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 threading
|
||||||
import time
|
import time
|
||||||
|
from collections import deque
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from PySide6.QtCore import QObject, Signal
|
from PySide6.QtCore import QObject, Signal
|
||||||
|
|
||||||
from . import (
|
from . import (
|
||||||
config, file_delivery, file_ops, history as history_mod,
|
config, dialogue as dialogue_mod, file_delivery, file_ops,
|
||||||
monitors as monitors_mod, notifications, pet_actions, quiet,
|
history as history_mod, intents as intents_mod, monitors as monitors_mod,
|
||||||
screen_context, screen_text, server_client, speech_text, updater,
|
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 .audio import barge_in, mic, stt, tts, wake_word
|
||||||
from .state import PetState, PetStateMachine
|
from .state import PetState, PetStateMachine
|
||||||
|
|
||||||
@@ -38,6 +41,7 @@ class PetController(QObject):
|
|||||||
log = Signal(str)
|
log = Signal(str)
|
||||||
action = Signal(dict) # parsed petctl action for the UI to perform
|
action = Signal(dict) # parsed petctl action for the UI to perform
|
||||||
napping = Signal(bool) # quiet hours / fullscreen do-not-disturb
|
napping = Signal(bool) # quiet hours / fullscreen do-not-disturb
|
||||||
|
voice_changed = Signal(str) # name of the server-picked voice ("" = default)
|
||||||
restart_requested = Signal(str) # version we just updated to
|
restart_requested = Signal(str) # version we just updated to
|
||||||
finished = Signal()
|
finished = Signal()
|
||||||
|
|
||||||
@@ -66,6 +70,13 @@ class PetController(QObject):
|
|||||||
self._wake_threshold = config.WAKE_WORD_THRESHOLD
|
self._wake_threshold = config.WAKE_WORD_THRESHOLD
|
||||||
self._near_misses = wake_word.NearMissLog()
|
self._near_misses = wake_word.NearMissLog()
|
||||||
|
|
||||||
|
# The voice the server last picked for us with `speak_as` ("" = the
|
||||||
|
# configured default). Held here rather than passed straight through
|
||||||
|
# to one tts.speak() call because it's sticky by default — see
|
||||||
|
# _apply_voice for why.
|
||||||
|
self._voice_id = ""
|
||||||
|
self._voice_name = ""
|
||||||
|
|
||||||
self._barge_in: Optional[barge_in.BargeInDetector] = None
|
self._barge_in: Optional[barge_in.BargeInDetector] = None
|
||||||
self._napping = False
|
self._napping = False
|
||||||
self._nap_forced: Optional[bool] = None # petctl nap on/off overrides the schedule
|
self._nap_forced: Optional[bool] = None # petctl nap on/off overrides the schedule
|
||||||
@@ -80,12 +91,22 @@ class PetController(QObject):
|
|||||||
|
|
||||||
self._last_update_check = 0.0
|
self._last_update_check = 0.0
|
||||||
self._update_pending = False # applied on disk, waiting for the restart
|
self._update_pending = False # applied on disk, waiting for the restart
|
||||||
|
# Armed by `petctl self_restart`, fired after the turn it was asked in
|
||||||
|
# (see _arm_self_restart for why it can't happen inline).
|
||||||
|
self._restart_context = None
|
||||||
|
|
||||||
self._notification_watcher: Optional[notifications.NotificationWatcher] = None
|
self._notification_watcher: Optional[notifications.NotificationWatcher] = None
|
||||||
self._notification_gate = notifications.NotificationGate(
|
self._notification_gate = notifications.NotificationGate(
|
||||||
config.NOTIFICATION_FILTER, config.NOTIFICATION_MIN_INTERVAL_SECONDS
|
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()
|
self._notification_lock = threading.Lock()
|
||||||
|
|
||||||
# ── external controls (safe to call from the Qt/UI thread) ─────────
|
# ── external controls (safe to call from the Qt/UI thread) ─────────
|
||||||
@@ -117,6 +138,20 @@ class PetController(QObject):
|
|||||||
def reset_wake_stats(self) -> None:
|
def reset_wake_stats(self) -> None:
|
||||||
self._near_misses.clear()
|
self._near_misses.clear()
|
||||||
|
|
||||||
|
def current_voice(self) -> str:
|
||||||
|
"""Name (or id) of the server-picked voice in use, "" for the default."""
|
||||||
|
return self._voice_name or self._voice_id
|
||||||
|
|
||||||
|
def reset_voice(self) -> None:
|
||||||
|
"""Drop a server-picked voice and go back to Bolt's own. The tray's
|
||||||
|
way out of a voice you didn't want to keep — the server has no way to
|
||||||
|
ask for the default back, since it never learns what it is."""
|
||||||
|
if not self._voice_id:
|
||||||
|
return
|
||||||
|
self._voice_id = self._voice_name = ""
|
||||||
|
self.log.emit("Voice: back to the default.")
|
||||||
|
self.voice_changed.emit("")
|
||||||
|
|
||||||
def stop(self) -> None:
|
def stop(self) -> None:
|
||||||
self._running = False
|
self._running = False
|
||||||
self._talk_now.set() # wake up anything blocked waiting on it
|
self._talk_now.set() # wake up anything blocked waiting on it
|
||||||
@@ -160,6 +195,13 @@ class PetController(QObject):
|
|||||||
)
|
)
|
||||||
self.log.emit(f"Barge-in: {config.BARGE_IN_MODE} mode.")
|
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:
|
with self._stream:
|
||||||
try:
|
try:
|
||||||
health = server_client.check_health()
|
health = server_client.check_health()
|
||||||
@@ -167,11 +209,34 @@ class PetController(QObject):
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
self.log.emit(f"Server not reachable yet ({exc}) — will keep trying per-request.")
|
self.log.emit(f"Server not reachable yet ({exc}) — will keep trying per-request.")
|
||||||
self._start_notification_bridge()
|
self._start_notification_bridge()
|
||||||
|
self._guarded(self._report_self_restart, "restart report")
|
||||||
self._loop()
|
self._loop()
|
||||||
|
except Exception as exc:
|
||||||
|
self.log.emit(f"Pipeline stopped unexpectedly: {exc!r}")
|
||||||
|
finally:
|
||||||
if self._notification_watcher is not None:
|
if self._notification_watcher is not None:
|
||||||
self._notification_watcher.stop()
|
self._notification_watcher.stop()
|
||||||
self.finished.emit()
|
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:
|
def _loop(self) -> None:
|
||||||
while self._running:
|
while self._running:
|
||||||
if self._muted:
|
if self._muted:
|
||||||
@@ -186,7 +251,7 @@ class PetController(QObject):
|
|||||||
if not self._running:
|
if not self._running:
|
||||||
return
|
return
|
||||||
continue
|
continue
|
||||||
self._handle_conversation_turn()
|
self._guarded(self._handle_conversation_turn, "conversation turn")
|
||||||
|
|
||||||
def _wait_for_wake_or_click(self) -> bool:
|
def _wait_for_wake_or_click(self) -> bool:
|
||||||
"""True once either the wake phrase was heard or a click-to-talk
|
"""True once either the wake phrase was heard or a click-to-talk
|
||||||
@@ -198,7 +263,12 @@ class PetController(QObject):
|
|||||||
self._stream,
|
self._stream,
|
||||||
should_continue=should_continue,
|
should_continue=should_continue,
|
||||||
threshold=self.wake_threshold, # callable: the tuner slider is live
|
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,
|
on_score=self._observe_wake_score,
|
||||||
)
|
)
|
||||||
if not self._running:
|
if not self._running:
|
||||||
@@ -247,6 +317,13 @@ class PetController(QObject):
|
|||||||
self.log.emit(f"You: {text}")
|
self.log.emit(f"You: {text}")
|
||||||
self.history.add(history_mod.USER, text, time.time())
|
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:
|
try:
|
||||||
# What's focused right now rides along, so "what's this error?"
|
# What's focused right now rides along, so "what's this error?"
|
||||||
# has a referent without you having to describe the window.
|
# has a referent without you having to describe the window.
|
||||||
@@ -260,8 +337,64 @@ class PetController(QObject):
|
|||||||
return
|
return
|
||||||
|
|
||||||
self._check_deliveries()
|
self._check_deliveries()
|
||||||
self._speak(reply)
|
self._apply_voice(reply)
|
||||||
|
self._speak(reply.text)
|
||||||
self._state.transition(PetState.IDLE)
|
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:
|
def _with_context(self, text: str) -> str:
|
||||||
"""Everything the server gets alongside what you actually said: the
|
"""Everything the server gets alongside what you actually said: the
|
||||||
@@ -289,9 +422,26 @@ class PetController(QObject):
|
|||||||
self._pet_monitor = int(index)
|
self._pet_monitor = int(index)
|
||||||
|
|
||||||
def _handle_command(self, command: str) -> str:
|
def _handle_command(self, command: str) -> str:
|
||||||
"""Server-relayed command. `petctl ...` drives the pet's body and
|
"""Server-relayed command, with the guarantee the relay depends on: this
|
||||||
`filectl ...` does local file read/write/edit — neither ever reaches
|
always returns a string.
|
||||||
a shell; everything else is a real command, exactly as before (see
|
|
||||||
|
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)."""
|
the security notes in the README)."""
|
||||||
try:
|
try:
|
||||||
action = pet_actions.parse(command)
|
action = pet_actions.parse(command)
|
||||||
@@ -307,6 +457,18 @@ class PetController(QObject):
|
|||||||
kind = action["action"]
|
kind = action["action"]
|
||||||
if kind == "monitors":
|
if kind == "monitors":
|
||||||
return monitors_mod.describe(self._monitors, self._pet_monitor)
|
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":
|
if kind == "read":
|
||||||
return self._read_screen(action["target"])
|
return self._read_screen(action["target"])
|
||||||
if kind == "jump":
|
if kind == "jump":
|
||||||
@@ -327,6 +489,14 @@ class PetController(QObject):
|
|||||||
self.action.emit(action)
|
self.action.emit(action)
|
||||||
return pet_actions.describe(action)
|
return pet_actions.describe(action)
|
||||||
|
|
||||||
|
try:
|
||||||
|
scene = dialogue_mod.parse(command)
|
||||||
|
except dialogue_mod.DialogueError as exc:
|
||||||
|
self.log.emit(f"dialoguectl: {exc}")
|
||||||
|
return f"[dialogue] {exc}"
|
||||||
|
if scene is not None:
|
||||||
|
return self._play_dialogue(scene)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
file_action = file_ops.parse(command)
|
file_action = file_ops.parse(command)
|
||||||
except file_ops.FileOpError as exc:
|
except file_ops.FileOpError as exc:
|
||||||
@@ -365,13 +535,167 @@ class PetController(QObject):
|
|||||||
self.log.emit(f"Reading monitor {monitor.number} ({monitor.name})…")
|
self.log.emit(f"Reading monitor {monitor.number} ({monitor.name})…")
|
||||||
return screen_text.read_monitor(monitor, limit)
|
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)
|
self._state.transition(PetState.TALKING)
|
||||||
# Bubble gets the markdown stripped but emoji kept (it can't render
|
# Bubble gets the markdown stripped but emoji kept (it can't render
|
||||||
# **bold** but draws emoji fine); tts.speak() does its own, stricter
|
# **bold** but draws emoji fine); tts.speak() does its own, stricter
|
||||||
# sanitizing for the voice.
|
# sanitizing for the voice.
|
||||||
self.said.emit(speech_text.for_display(text))
|
self.said.emit(speech_text.for_display(text))
|
||||||
self.log.emit(f"Bolt: {text}")
|
self.log.emit(f"Bolt: {text}")
|
||||||
|
if remember:
|
||||||
self.history.add(history_mod.PET, text, time.time())
|
self.history.add(history_mod.PET, text, time.time())
|
||||||
|
|
||||||
should_stop = None
|
should_stop = None
|
||||||
@@ -382,6 +706,7 @@ class PetController(QObject):
|
|||||||
text,
|
text,
|
||||||
on_error=lambda exc: self.log.emit(f"TTS failed: {exc}"),
|
on_error=lambda exc: self.log.emit(f"TTS failed: {exc}"),
|
||||||
should_stop=should_stop,
|
should_stop=should_stop,
|
||||||
|
voice_id=self._voice_id or None,
|
||||||
)
|
)
|
||||||
# Read the scoring history *before* resetting, or the log reports the
|
# Read the scoring history *before* resetting, or the log reports the
|
||||||
# blank counters instead of what actually fired.
|
# blank counters instead of what actually fired.
|
||||||
@@ -404,6 +729,15 @@ class PetController(QObject):
|
|||||||
f"Asked a question — listening for your answer "
|
f"Asked a question — listening for your answer "
|
||||||
f"({self._follow_ups}{'/' + str(cap) if cap > 0 else ''})."
|
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._pending_follow_up = True
|
||||||
self._talk_now.set()
|
self._talk_now.set()
|
||||||
|
|
||||||
@@ -482,16 +816,39 @@ class PetController(QObject):
|
|||||||
def _queue_notification(self, notification: notifications.Notification) -> None:
|
def _queue_notification(self, notification: notifications.Notification) -> None:
|
||||||
"""Called on the watcher thread — just queue it; forwarding happens on
|
"""Called on the watcher thread — just queue it; forwarding happens on
|
||||||
the pipeline thread where it can't collide with a live conversation."""
|
the pipeline thread where it can't collide with a live conversation."""
|
||||||
if not self._notification_gate.should_forward(notification, time.monotonic()):
|
now = time.monotonic()
|
||||||
|
if not self._notification_gate.should_forward(notification, now):
|
||||||
return
|
return
|
||||||
with self._notification_lock:
|
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:
|
def _drain_notifications(self) -> None:
|
||||||
with self._notification_lock:
|
with self._notification_lock:
|
||||||
pending, self._pending_notifications = self._pending_notifications, []
|
pending = list(self._pending_notifications)
|
||||||
for notification in pending:
|
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:
|
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
|
return
|
||||||
self.log.emit(f"Notification: {notification.as_text()}")
|
self.log.emit(f"Notification: {notification.as_text()}")
|
||||||
self.history.add(history_mod.SYSTEM, notification.as_text(), time.time())
|
self.history.add(history_mod.SYSTEM, notification.as_text(), time.time())
|
||||||
@@ -501,13 +858,27 @@ class PetController(QObject):
|
|||||||
on_command=self._handle_command,
|
on_command=self._handle_command,
|
||||||
)
|
)
|
||||||
except server_client.ServerError as exc:
|
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.log.emit(f"Couldn't forward notification: {exc}")
|
||||||
|
self._requeue_notifications(pending[index:])
|
||||||
return
|
return
|
||||||
self._check_deliveries()
|
self._check_deliveries()
|
||||||
if reply.strip():
|
self._apply_voice(reply)
|
||||||
self._speak(reply)
|
if reply.text.strip():
|
||||||
|
self._speak(reply.text)
|
||||||
self._state.transition(PetState.IDLE)
|
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 ────────────────────────────────────────────────────
|
# ── file delivery ────────────────────────────────────────────────────
|
||||||
|
|
||||||
def _check_deliveries(self) -> None:
|
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 pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
|
from . import relay_json
|
||||||
|
|
||||||
_PREFIXES = ("filectl", "file")
|
_PREFIXES = ("filectl", "file")
|
||||||
|
|
||||||
# Keeps a runaway read/write/list from blowing up the tool_result relay (and,
|
# Keeps a runaway read/write/list from blowing up the tool_result relay (and,
|
||||||
@@ -95,17 +97,23 @@ def parse(command: str) -> Optional[dict]:
|
|||||||
if not rest or rest.lower() in ("help", "-h", "--help"):
|
if not rest or rest.lower() in ("help", "-h", "--help"):
|
||||||
return {"action": "help"}
|
return {"action": "help"}
|
||||||
|
|
||||||
|
# Lenient on purpose — see relay_json. A stray quote in machine-written
|
||||||
|
# JSON should not cost a turn, but the repair is reported back so the model
|
||||||
|
# is told it sent something broken while it can still learn from it.
|
||||||
try:
|
try:
|
||||||
payload = json.loads(rest)
|
payload, repairs = relay_json.loads(rest)
|
||||||
except json.JSONDecodeError as exc:
|
except relay_json.RelayJsonError as exc:
|
||||||
raise FileOpError(f"couldn't parse filectl JSON ({exc}); usage:\n{HELP}") from exc
|
raise FileOpError(f"couldn't parse filectl JSON — {exc}\nusage:\n{HELP}") from exc
|
||||||
if not isinstance(payload, dict):
|
if not isinstance(payload, dict):
|
||||||
raise FileOpError(f"filectl payload must be a JSON object; usage:\n{HELP}")
|
raise FileOpError(f"filectl payload must be a JSON object; usage:\n{HELP}")
|
||||||
|
|
||||||
op = str(payload.get("op") or "help").lower()
|
op = str(payload.get("op") or "help").lower()
|
||||||
|
# Carried on the action so execute() can tell the model what it got wrong;
|
||||||
|
# a silent repair would fix today's call and guarantee tomorrow's.
|
||||||
|
tag = {"_repairs": repairs} if repairs else {}
|
||||||
|
|
||||||
if op == "help":
|
if op == "help":
|
||||||
return {"action": "help"}
|
return {"action": "help", **tag}
|
||||||
|
|
||||||
if op == "list":
|
if op == "list":
|
||||||
path = _required_str(payload, "path")
|
path = _required_str(payload, "path")
|
||||||
@@ -114,7 +122,7 @@ def parse(command: str) -> Optional[dict]:
|
|||||||
raise FileOpError('"pattern" must be a non-empty string')
|
raise FileOpError('"pattern" must be a non-empty string')
|
||||||
return {
|
return {
|
||||||
"action": "list", "path": path,
|
"action": "list", "path": path,
|
||||||
"pattern": pattern, "recursive": bool(payload.get("recursive")),
|
"pattern": pattern, "recursive": bool(payload.get("recursive")), **tag,
|
||||||
}
|
}
|
||||||
|
|
||||||
if op == "read":
|
if op == "read":
|
||||||
@@ -122,14 +130,14 @@ def parse(command: str) -> Optional[dict]:
|
|||||||
return {
|
return {
|
||||||
"action": "read", "path": path,
|
"action": "read", "path": path,
|
||||||
"start": _line_number(payload.get("start"), "start"),
|
"start": _line_number(payload.get("start"), "start"),
|
||||||
"end": _line_number(payload.get("end"), "end"),
|
"end": _line_number(payload.get("end"), "end"), **tag,
|
||||||
}
|
}
|
||||||
|
|
||||||
if op == "write":
|
if op == "write":
|
||||||
path = _required_str(payload, "path")
|
path = _required_str(payload, "path")
|
||||||
if payload.get("content") is None:
|
if payload.get("content") is None:
|
||||||
raise FileOpError('write needs "content"')
|
raise FileOpError('write needs "content"')
|
||||||
return {"action": "write", "path": path, "content": str(payload["content"])}
|
return {"action": "write", "path": path, "content": str(payload["content"]), **tag}
|
||||||
|
|
||||||
if op == "edit":
|
if op == "edit":
|
||||||
path = _required_str(payload, "path")
|
path = _required_str(payload, "path")
|
||||||
@@ -138,7 +146,7 @@ def parse(command: str) -> Optional[dict]:
|
|||||||
old, new = str(payload["old"]), str(payload["new"])
|
old, new = str(payload["old"]), str(payload["new"])
|
||||||
if old == new:
|
if old == new:
|
||||||
raise FileOpError("old and new text are identical — nothing to edit")
|
raise FileOpError("old and new text are identical — nothing to edit")
|
||||||
return {"action": "edit", "path": path, "old": old, "new": new}
|
return {"action": "edit", "path": path, "old": old, "new": new, **tag}
|
||||||
|
|
||||||
raise FileOpError(f"unknown filectl op {op!r}; usage:\n{HELP}")
|
raise FileOpError(f"unknown filectl op {op!r}; usage:\n{HELP}")
|
||||||
|
|
||||||
@@ -175,14 +183,16 @@ def execute(action: dict) -> str:
|
|||||||
if kind == "help":
|
if kind == "help":
|
||||||
return HELP
|
return HELP
|
||||||
if kind == "list":
|
if kind == "list":
|
||||||
return _do_list(action)
|
output = _do_list(action)
|
||||||
if kind == "read":
|
elif kind == "read":
|
||||||
return _do_read(action)
|
output = _do_read(action)
|
||||||
if kind == "write":
|
elif kind == "write":
|
||||||
return _do_write(action)
|
output = _do_write(action)
|
||||||
if kind == "edit":
|
elif kind == "edit":
|
||||||
return _do_edit(action)
|
output = _do_edit(action)
|
||||||
return "[filectl] ok"
|
else:
|
||||||
|
output = "[filectl] ok"
|
||||||
|
return output + relay_json.repair_note(action.get("_repairs") or [])
|
||||||
|
|
||||||
|
|
||||||
def _resolve(path_str: str) -> Path:
|
def _resolve(path_str: str) -> Path:
|
||||||
|
|||||||
@@ -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 emote <" + "|".join(EMOTES) + ">\n"
|
||||||
"petctl say <text>\n"
|
"petctl say <text>\n"
|
||||||
"petctl wander on|off\n"
|
"petctl wander on|off\n"
|
||||||
"petctl nap on|off"
|
"petctl nap on|off\n"
|
||||||
|
"petctl voice reset\n"
|
||||||
|
"petctl self_restart [why]"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# `petctl voice` only ever goes one way: back to the configured voice. Picking
|
||||||
|
# a *different* one is the server's job (its speak_as reply marker), and it
|
||||||
|
# already knows how — what it has no way to say is "never mind, be yourself
|
||||||
|
# again", because it was never told which voice that is.
|
||||||
|
VOICE_RESETS = ("reset", "default", "normal", "own", "back", "mine", "yours")
|
||||||
|
|
||||||
|
|
||||||
class ActionError(Exception):
|
class ActionError(Exception):
|
||||||
"""Bad petctl syntax — reported back to the server as command output."""
|
"""Bad petctl syntax — reported back to the server as command output."""
|
||||||
@@ -129,6 +137,22 @@ def parse(command: str) -> Optional[dict]:
|
|||||||
raise ActionError("wander needs on or off")
|
raise ActionError("wander needs on or off")
|
||||||
return {"action": "wander", "enabled": _bool_arg(args[0])}
|
return {"action": "wander", "enabled": _bool_arg(args[0])}
|
||||||
|
|
||||||
|
if verb == "voice":
|
||||||
|
target = (args[0].lower() if args else "reset").lstrip("-")
|
||||||
|
if target not in VOICE_RESETS:
|
||||||
|
raise ActionError(
|
||||||
|
f"can't set a voice from petctl (got {args[0]!r}); "
|
||||||
|
"use the speak_as reply marker to pick one. "
|
||||||
|
"petctl voice reset goes back to the default voice."
|
||||||
|
)
|
||||||
|
return {"action": "voice", "voice": "default"}
|
||||||
|
|
||||||
|
if verb in ("self_restart", "restart", "reboot"):
|
||||||
|
# Free text, not a fixed grammar: the argument is a note to the pet's
|
||||||
|
# *next* process about why it died and what to look at when it comes
|
||||||
|
# back, so anything the model wants to tell future-itself is valid.
|
||||||
|
return {"action": "self_restart", "reason": " ".join(args).strip()}
|
||||||
|
|
||||||
if verb in ("nap", "sleep", "dnd"):
|
if verb in ("nap", "sleep", "dnd"):
|
||||||
if not args:
|
if not args:
|
||||||
raise ActionError("nap needs on or off")
|
raise ActionError("nap needs on or off")
|
||||||
|
|||||||
@@ -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)
|
... -> POST /desk/tool_result (repeat until the server sends a reply)
|
||||||
reply <- returned to caller
|
reply <- returned to caller
|
||||||
|
|
||||||
|
A reply can also carry a voice (`voice_id`/`voice_name`), which is how the
|
||||||
|
server's `speak_as` marker reaches us: Bolt searched the ElevenLabs voice
|
||||||
|
library, picked one, and tagged the reply with it — the client is what
|
||||||
|
actually speaks in it. See `Reply` and controller._apply_voice.
|
||||||
|
|
||||||
Kept dependency-free beyond `requests` so it's easy to unit test with mocks.
|
Kept dependency-free beyond `requests` so it's easy to unit test with mocks.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import signal
|
||||||
import subprocess
|
import subprocess
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Callable, Optional
|
from typing import Callable, NamedTuple, Optional
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
@@ -24,11 +31,26 @@ from . import config, sudo_askpass
|
|||||||
|
|
||||||
_MAX_RELAY_HOPS = 16
|
_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):
|
class ServerError(Exception):
|
||||||
"""Raised when the server responds with an error payload or unreachable."""
|
"""Raised when the server responds with an error payload or unreachable."""
|
||||||
|
|
||||||
|
|
||||||
|
class Reply(NamedTuple):
|
||||||
|
"""One final reply from the desk API. *voice_id* is set only when the
|
||||||
|
server tagged this reply with a `speak_as` voice; *voice_name* is the
|
||||||
|
human-readable name that came with it (may be empty even when the id
|
||||||
|
isn't). Both empty means "say it in the usual voice"."""
|
||||||
|
|
||||||
|
text: str
|
||||||
|
voice_id: str = ""
|
||||||
|
voice_name: str = ""
|
||||||
|
|
||||||
|
|
||||||
def _headers() -> dict:
|
def _headers() -> dict:
|
||||||
return {"X-Desk-Api-Key": config.API_KEY}
|
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.SUDO_COMMAND_TIMEOUT_SECONDS
|
||||||
timeout = timeout or config.COMMAND_TIMEOUT_SECONDS
|
timeout = timeout or config.COMMAND_TIMEOUT_SECONDS
|
||||||
try:
|
try:
|
||||||
completed = subprocess.run(
|
# start_new_session puts the shell in its own process group so a timeout
|
||||||
command, shell=True, capture_output=True, text=True,
|
# can kill the whole tree. subprocess.run() would only SIGKILL the `sh`
|
||||||
timeout=timeout, cwd=str(Path.home()), env=env,
|
# 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:
|
except Exception as exc:
|
||||||
return f"[command failed: {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(
|
def converse(
|
||||||
text: str,
|
text: str,
|
||||||
on_command: Callable[[str], str] = run_local_command,
|
on_command: Callable[[str], str] = run_local_command,
|
||||||
timeout: float = 120.0,
|
timeout: float = 120.0,
|
||||||
) -> str:
|
) -> Reply:
|
||||||
"""Send one turn of conversation to the desk API, relaying any commands
|
"""Send one turn of conversation to the desk API, relaying any commands
|
||||||
the server sends back until it produces a final reply.
|
the server sends back until it produces a final reply.
|
||||||
|
|
||||||
@@ -111,7 +177,20 @@ def converse(
|
|||||||
raise ServerError(f"couldn't reach the server during tool relay: {exc}") from exc
|
raise ServerError(f"couldn't reach the server during tool relay: {exc}") from exc
|
||||||
|
|
||||||
if payload.get("type") == "reply":
|
if payload.get("type") == "reply":
|
||||||
return str(payload.get("text") or "")
|
return Reply(
|
||||||
|
text=str(payload.get("text") or ""),
|
||||||
|
voice_id=str(payload.get("voice_id") or ""),
|
||||||
|
voice_name=str(payload.get("voice_name") or ""),
|
||||||
|
)
|
||||||
|
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"))
|
raise ServerError(str(payload.get("error") or "unknown server response"))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+39
-12
@@ -67,6 +67,27 @@ _SPOKEN_SYMBOLS = {
|
|||||||
"=": " equals ",
|
"=": " 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_SPACE = re.compile(r"[ \t]+")
|
||||||
_MULTI_PUNCT = re.compile(r"(?:\s*\.){2,}")
|
_MULTI_PUNCT = re.compile(r"(?:\s*\.){2,}")
|
||||||
|
|
||||||
@@ -105,6 +126,10 @@ def for_speech(text: str) -> str:
|
|||||||
return ""
|
return ""
|
||||||
for symbol, spoken in _PRE_SPOKEN_SYMBOLS.items():
|
for symbol, spoken in _PRE_SPOKEN_SYMBOLS.items():
|
||||||
text = text.replace(symbol, spoken)
|
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 = _strip_markdown(text, keep_emoji=False)
|
||||||
text = _URL.sub(" link ", text)
|
text = _URL.sub(" link ", text)
|
||||||
text = _TABLE_PIPE.sub(", ", text)
|
text = _TABLE_PIPE.sub(", ", text)
|
||||||
@@ -119,19 +144,21 @@ def for_speech(text: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def is_question(text: str) -> bool:
|
def is_question(text: str) -> bool:
|
||||||
"""True if the reply *ends* by asking the user something — the cue for
|
"""True if the reply asks the user anything — the cue for the pet to keep
|
||||||
the pet to keep listening instead of making you say the wake word again.
|
listening instead of making you say the wake word again.
|
||||||
|
|
||||||
Deliberately only looks at the end. A reply that asks something in
|
Anywhere in the reply counts, not only the end. An earlier version required
|
||||||
passing ("What time is it? It's 7:15.") isn't waiting on an answer,
|
a *trailing* '?' on the theory that "What time is it? It's 7:15." isn't
|
||||||
whereas one that finishes on a question mark is. The test runs on the
|
waiting on an answer, and that's true of that sentence but wrong far more
|
||||||
spoken form, so a '?' that only exists inside a stripped code block or a
|
often: Bolt routinely asks first and then keeps talking ("Want me to fix
|
||||||
URL doesn't count, and trailing decoration (emoji, quotes, brackets) is
|
it? I'd start with the config."), and refusing to listen there is the case
|
||||||
peeled off first so "Ready to go? 🚀" still reads as a question."""
|
that actually costs you a wake word. The cheap failure is the other
|
||||||
spoken = for_speech(text)
|
direction — an unwanted extra listen ends itself on `VAD_GRACE_SECONDS` of
|
||||||
while spoken and not (spoken[-1].isalnum() or spoken[-1] == "?"):
|
silence, and `FOLLOW_UP_MAX_TURNS` caps the chain.
|
||||||
spoken = spoken[:-1]
|
|
||||||
return spoken.endswith("?")
|
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:
|
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
|
# make the pet speak unprompted — a reminder firing, a nudge from the server
|
||||||
# — without the user having said anything first, so there's no preceding
|
# — without the user having said anything first, so there's no preceding
|
||||||
# LISTENING/THINKING leg for that turn.
|
# LISTENING/THINKING leg for that turn.
|
||||||
|
#
|
||||||
|
# TALKING -> THINKING is the mirror case: a `dialoguectl` scene is played
|
||||||
|
# *mid-turn*, while the server is still waiting on the tool result, so the pet
|
||||||
|
# talks and then goes back to waiting rather than falling to IDLE (which would
|
||||||
|
# make it look like the turn had ended).
|
||||||
_TRANSITIONS: dict[PetState, set[PetState]] = {
|
_TRANSITIONS: dict[PetState, set[PetState]] = {
|
||||||
PetState.IDLE: {PetState.LISTENING, PetState.TALKING, PetState.ERROR},
|
PetState.IDLE: {PetState.LISTENING, PetState.TALKING, PetState.ERROR},
|
||||||
PetState.LISTENING: {PetState.THINKING, PetState.IDLE, PetState.ERROR},
|
PetState.LISTENING: {PetState.THINKING, PetState.IDLE, PetState.ERROR},
|
||||||
PetState.THINKING: {PetState.TALKING, PetState.IDLE, PetState.ERROR},
|
PetState.THINKING: {PetState.TALKING, PetState.IDLE, PetState.ERROR},
|
||||||
PetState.TALKING: {PetState.IDLE, PetState.ERROR},
|
PetState.TALKING: {PetState.IDLE, PetState.THINKING, PetState.ERROR},
|
||||||
PetState.ERROR: {PetState.IDLE},
|
PetState.ERROR: {PetState.IDLE},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ def run() -> int:
|
|||||||
on_set_nap=_set_nap,
|
on_set_nap=_set_nap,
|
||||||
on_show_history=history_window.show_refreshed,
|
on_show_history=history_window.show_refreshed,
|
||||||
on_show_wake_tuner=tuner_window.show_refreshed,
|
on_show_wake_tuner=tuner_window.show_refreshed,
|
||||||
|
on_reset_voice=controller.reset_voice,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _handle_napping(napping: bool) -> None:
|
def _handle_napping(napping: bool) -> None:
|
||||||
@@ -87,6 +88,9 @@ def run() -> int:
|
|||||||
tray.set_napping(napping)
|
tray.set_napping(napping)
|
||||||
|
|
||||||
controller.napping.connect(_handle_napping)
|
controller.napping.connect(_handle_napping)
|
||||||
|
# The server can hand Bolt a different voice mid-conversation (speak_as);
|
||||||
|
# the tray is where you get his own back.
|
||||||
|
controller.voice_changed.connect(tray.set_voice)
|
||||||
|
|
||||||
# Push-to-talk: a global hook, because the pet window never has focus.
|
# Push-to-talk: a global hook, because the pet window never has focus.
|
||||||
# request_talk_now() only sets a threading.Event, so it's safe to call
|
# request_talk_now() only sets a threading.Event, so it's safe to call
|
||||||
|
|||||||
+23
-1
@@ -1,6 +1,7 @@
|
|||||||
"""System tray icon — the pet window is frameless with no taskbar entry, so
|
"""System tray icon — the pet window is frameless with no taskbar entry, so
|
||||||
this menu is the only always-available way to control or exit it: talk now,
|
this menu is the only always-available way to control or exit it: talk now,
|
||||||
mute, wander, click-through, nap, history, wake-word tuning, quit.
|
mute, wander, click-through, nap, history, wake-word tuning, voice reset,
|
||||||
|
quit.
|
||||||
|
|
||||||
Every entry is a plain callback passed in by ui/app.py; this file knows
|
Every entry is a plain callback passed in by ui/app.py; this file knows
|
||||||
nothing about the controller or the pet window.
|
nothing about the controller or the pet window.
|
||||||
@@ -46,6 +47,7 @@ class PetTray(QSystemTrayIcon):
|
|||||||
on_set_nap: Optional[Callable[[bool], None]] = None,
|
on_set_nap: Optional[Callable[[bool], None]] = None,
|
||||||
on_show_history: Optional[Callable[[], None]] = None,
|
on_show_history: Optional[Callable[[], None]] = None,
|
||||||
on_show_wake_tuner: Optional[Callable[[], None]] = None,
|
on_show_wake_tuner: Optional[Callable[[], None]] = None,
|
||||||
|
on_reset_voice: Optional[Callable[[], None]] = None,
|
||||||
parent=None,
|
parent=None,
|
||||||
):
|
):
|
||||||
super().__init__(_make_icon(muted=False), parent)
|
super().__init__(_make_icon(muted=False), parent)
|
||||||
@@ -102,6 +104,16 @@ class PetTray(QSystemTrayIcon):
|
|||||||
tuner_action.triggered.connect(on_show_wake_tuner)
|
tuner_action.triggered.connect(on_show_wake_tuner)
|
||||||
menu.addAction(tuner_action)
|
menu.addAction(tuner_action)
|
||||||
|
|
||||||
|
# Only ever enabled while a server-picked voice (speak_as) is in use —
|
||||||
|
# it's the way back from "talk like a pirate", which nothing else
|
||||||
|
# undoes short of a restart.
|
||||||
|
self._voice_action = None
|
||||||
|
if on_reset_voice is not None:
|
||||||
|
self._voice_action = QAction("Use default voice", menu)
|
||||||
|
self._voice_action.setEnabled(False)
|
||||||
|
self._voice_action.triggered.connect(on_reset_voice)
|
||||||
|
menu.addAction(self._voice_action)
|
||||||
|
|
||||||
menu.addSeparator()
|
menu.addSeparator()
|
||||||
quit_action = QAction("Quit", menu)
|
quit_action = QAction("Quit", menu)
|
||||||
quit_action.triggered.connect(on_quit)
|
quit_action.triggered.connect(on_quit)
|
||||||
@@ -115,6 +127,16 @@ class PetTray(QSystemTrayIcon):
|
|||||||
self._mute_action.setChecked(self._muted)
|
self._mute_action.setChecked(self._muted)
|
||||||
self._refresh_icon()
|
self._refresh_icon()
|
||||||
|
|
||||||
|
def set_voice(self, voice: str) -> None:
|
||||||
|
"""Reflect the voice the controller is speaking in — a name (or id)
|
||||||
|
when the server picked one, "" for Bolt's own."""
|
||||||
|
if self._voice_action is None:
|
||||||
|
return
|
||||||
|
self._voice_action.setEnabled(bool(voice))
|
||||||
|
self._voice_action.setText(
|
||||||
|
f"Use default voice (now: {voice})" if voice else "Use default voice"
|
||||||
|
)
|
||||||
|
|
||||||
def set_napping(self, napping: bool) -> None:
|
def set_napping(self, napping: bool) -> None:
|
||||||
"""Reflect a nap the *controller* decided on (quiet hours, fullscreen,
|
"""Reflect a nap the *controller* decided on (quiet hours, fullscreen,
|
||||||
or a petctl command) — not just ones clicked here."""
|
or a petctl command) — not just ones clicked here."""
|
||||||
|
|||||||
@@ -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.mic, "record_utterance", lambda *a, **k: np.zeros(10, dtype=np.int16))
|
||||||
monkeypatch.setattr(controller_mod.stt, "transcribe", lambda pcm: "what's the weather")
|
monkeypatch.setattr(controller_mod.stt, "transcribe", lambda pcm: "what's the weather")
|
||||||
monkeypatch.setattr(controller_mod.server_client, "converse", lambda text, on_command=None: "sunny and 72")
|
monkeypatch.setattr(controller_mod.server_client, "converse", lambda text, on_command=None: controller_mod.server_client.Reply("sunny and 72"))
|
||||||
spoken = []
|
spoken = []
|
||||||
monkeypatch.setattr(controller_mod.tts, "speak",
|
monkeypatch.setattr(controller_mod.tts, "speak",
|
||||||
lambda text, on_error=None, should_stop=None: (spoken.append(text), True)[1])
|
lambda text, on_error=None, should_stop=None, voice_id=None: (spoken.append(text), True)[1])
|
||||||
|
|
||||||
ctrl._handle_conversation_turn()
|
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")
|
monkeypatch.setattr(controller_mod.server_client, "report_status", lambda: "don't forget your 3pm")
|
||||||
spoken = []
|
spoken = []
|
||||||
monkeypatch.setattr(controller_mod.tts, "speak",
|
monkeypatch.setattr(controller_mod.tts, "speak",
|
||||||
lambda text, on_error=None, should_stop=None: (spoken.append(text), True)[1])
|
lambda text, on_error=None, should_stop=None, voice_id=None: (spoken.append(text), True)[1])
|
||||||
said = _capture(ctrl.said)
|
said = _capture(ctrl.said)
|
||||||
|
|
||||||
ctrl._maybe_heartbeat()
|
ctrl._maybe_heartbeat()
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ from PySide6.QtWidgets import QApplication
|
|||||||
|
|
||||||
from bolt_pet import controller as controller_mod
|
from bolt_pet import controller as controller_mod
|
||||||
from bolt_pet.notifications import Notification
|
from bolt_pet.notifications import Notification
|
||||||
|
from bolt_pet.server_client import Reply
|
||||||
from bolt_pet.state import PetState
|
from bolt_pet.state import PetState
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
@@ -159,7 +160,7 @@ def test_the_interrupt_log_reports_what_fired_not_the_reset_counters(monkeypatch
|
|||||||
ctrl._barge_in = detector
|
ctrl._barge_in = detector
|
||||||
logs = _capture(ctrl.log)
|
logs = _capture(ctrl.log)
|
||||||
|
|
||||||
def interrupted_playback(text, on_error=None, should_stop=None):
|
def interrupted_playback(text, on_error=None, should_stop=None, voice_id=None):
|
||||||
# What really happens: frames get scored during playback, then one
|
# What really happens: frames get scored during playback, then one
|
||||||
# clears the threshold and playback aborts.
|
# clears the threshold and playback aborts.
|
||||||
detector._frames, detector._peak, detector._last = 7, 0.81, 0.81
|
detector._frames, detector._peak, detector._last = 7, 0.81, 0.81
|
||||||
@@ -179,7 +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):
|
def test_interrupted_playback_queues_an_immediate_next_turn(monkeypatch, ctrl):
|
||||||
logs = _capture(ctrl.log)
|
logs = _capture(ctrl.log)
|
||||||
monkeypatch.setattr(controller_mod.tts, "speak",
|
monkeypatch.setattr(controller_mod.tts, "speak",
|
||||||
lambda text, on_error=None, should_stop=None: False) # interrupted
|
lambda text, on_error=None, should_stop=None, voice_id=None: False) # interrupted
|
||||||
|
|
||||||
ctrl._speak("a very long explanation")
|
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):
|
def test_uninterrupted_playback_does_not_queue_a_turn(monkeypatch, ctrl):
|
||||||
monkeypatch.setattr(controller_mod.tts, "speak",
|
monkeypatch.setattr(controller_mod.tts, "speak",
|
||||||
lambda text, on_error=None, should_stop=None: True)
|
lambda text, on_error=None, should_stop=None, voice_id=None: True)
|
||||||
ctrl._speak("short answer")
|
ctrl._speak("short answer")
|
||||||
assert not ctrl._talk_now.is_set()
|
assert not ctrl._talk_now.is_set()
|
||||||
|
|
||||||
@@ -201,7 +202,7 @@ def spoke(monkeypatch):
|
|||||||
"""Playback that always completes, so only the follow-up rule decides
|
"""Playback that always completes, so only the follow-up rule decides
|
||||||
whether another turn is queued."""
|
whether another turn is queued."""
|
||||||
monkeypatch.setattr(controller_mod.tts, "speak",
|
monkeypatch.setattr(controller_mod.tts, "speak",
|
||||||
lambda text, on_error=None, should_stop=None: True)
|
lambda text, on_error=None, should_stop=None, voice_id=None: True)
|
||||||
|
|
||||||
|
|
||||||
def test_a_reply_ending_in_a_question_keeps_listening(spoke, ctrl):
|
def test_a_reply_ending_in_a_question_keeps_listening(spoke, ctrl):
|
||||||
@@ -220,10 +221,13 @@ def test_a_statement_does_not_keep_listening(spoke, ctrl):
|
|||||||
assert not ctrl._pending_follow_up
|
assert not ctrl._pending_follow_up
|
||||||
|
|
||||||
|
|
||||||
def test_a_question_in_passing_does_not_count(spoke, ctrl):
|
def test_a_question_anywhere_in_the_reply_keeps_listening(spoke, ctrl):
|
||||||
"""Only a reply that *ends* on a question is waiting for an answer."""
|
"""Bolt often asks and then keeps talking ("Want me to fix it? I'd start
|
||||||
ctrl._speak("What time is it? It's 7:15 AM.")
|
with the config."), so the question mark doesn't have to be last. An
|
||||||
assert not ctrl._talk_now.is_set()
|
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):
|
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):
|
def test_being_interrupted_restarts_the_chain(monkeypatch, ctrl):
|
||||||
monkeypatch.setattr(controller_mod.tts, "speak",
|
monkeypatch.setattr(controller_mod.tts, "speak",
|
||||||
lambda text, on_error=None, should_stop=None: False)
|
lambda text, on_error=None, should_stop=None, voice_id=None: False)
|
||||||
ctrl._follow_ups = 3
|
ctrl._follow_ups = 3
|
||||||
|
|
||||||
ctrl._speak("a very long explanation")
|
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):
|
def test_speech_is_recorded_in_the_history(monkeypatch, ctrl):
|
||||||
monkeypatch.setattr(controller_mod.tts, "speak",
|
monkeypatch.setattr(controller_mod.tts, "speak",
|
||||||
lambda text, on_error=None, should_stop=None: True)
|
lambda text, on_error=None, should_stop=None, voice_id=None: True)
|
||||||
ctrl._speak("**bold** reply")
|
ctrl._speak("**bold** reply")
|
||||||
assert ctrl.history.last().text == "**bold** reply" # raw, for copy/paste
|
assert ctrl.history.last().text == "**bold** reply" # raw, for copy/paste
|
||||||
|
|
||||||
@@ -314,10 +318,10 @@ def test_the_active_window_rides_along_with_the_utterance(monkeypatch, ctrl):
|
|||||||
monkeypatch.setattr(controller_mod.screen_context, "context_for",
|
monkeypatch.setattr(controller_mod.screen_context, "context_for",
|
||||||
lambda text: f"{text}\n\n[on screen right now: app.py]")
|
lambda text: f"{text}\n\n[on screen right now: app.py]")
|
||||||
monkeypatch.setattr(controller_mod.tts, "speak",
|
monkeypatch.setattr(controller_mod.tts, "speak",
|
||||||
lambda text, on_error=None, should_stop=None: True)
|
lambda text, on_error=None, should_stop=None, voice_id=None: True)
|
||||||
sent = []
|
sent = []
|
||||||
monkeypatch.setattr(controller_mod.server_client, "converse",
|
monkeypatch.setattr(controller_mod.server_client, "converse",
|
||||||
lambda text, on_command=None: sent.append(text) or "that's a KeyError")
|
lambda text, on_command=None: sent.append(text) or Reply("that's a KeyError"))
|
||||||
|
|
||||||
ctrl._handle_conversation_turn()
|
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))
|
lambda *a, **k: np.zeros(10, dtype=np.int16))
|
||||||
monkeypatch.setattr(controller_mod.stt, "transcribe", lambda pcm: "you awake?")
|
monkeypatch.setattr(controller_mod.stt, "transcribe", lambda pcm: "you awake?")
|
||||||
monkeypatch.setattr(controller_mod.server_client, "converse",
|
monkeypatch.setattr(controller_mod.server_client, "converse",
|
||||||
lambda text, on_command=None: "always")
|
lambda text, on_command=None: Reply("always"))
|
||||||
spoken = []
|
spoken = []
|
||||||
monkeypatch.setattr(controller_mod.tts, "speak",
|
monkeypatch.setattr(controller_mod.tts, "speak",
|
||||||
lambda text, on_error=None, should_stop=None: spoken.append(text) or True)
|
lambda text, on_error=None, should_stop=None, voice_id=None: spoken.append(text) or True)
|
||||||
ctrl.set_napping(True)
|
ctrl.set_napping(True)
|
||||||
|
|
||||||
ctrl._handle_conversation_turn()
|
ctrl._handle_conversation_turn()
|
||||||
@@ -381,6 +385,148 @@ def test_napping_still_answers_when_spoken_to(monkeypatch, ctrl):
|
|||||||
assert spoken == ["always"]
|
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 ─────────────────────────────────────────────────────
|
# ── notification bridge ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
def test_notifications_are_forwarded_and_spoken(monkeypatch, ctrl):
|
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)
|
ctrl._notification_gate = controller_mod.notifications.NotificationGate("", 0)
|
||||||
sent, spoken = [], []
|
sent, spoken = [], []
|
||||||
monkeypatch.setattr(controller_mod.server_client, "converse",
|
monkeypatch.setattr(controller_mod.server_client, "converse",
|
||||||
lambda text, on_command=None: sent.append(text) or "your build is green")
|
lambda text, on_command=None: sent.append(text) or Reply("your build is green"))
|
||||||
monkeypatch.setattr(controller_mod.tts, "speak",
|
monkeypatch.setattr(controller_mod.tts, "speak",
|
||||||
lambda text, on_error=None, should_stop=None: spoken.append(text) or True)
|
lambda text, on_error=None, should_stop=None, voice_id=None: spoken.append(text) or True)
|
||||||
|
|
||||||
ctrl._queue_notification(Notification(app="CI", summary="Build finished", body=""))
|
ctrl._queue_notification(Notification(app="CI", summary="Build finished", body=""))
|
||||||
ctrl._drain_notifications()
|
ctrl._drain_notifications()
|
||||||
@@ -403,7 +549,54 @@ def test_notifications_are_forwarded_and_spoken(monkeypatch, ctrl):
|
|||||||
def test_filtered_out_notifications_are_never_queued(ctrl):
|
def test_filtered_out_notifications_are_never_queued(ctrl):
|
||||||
ctrl._notification_gate = controller_mod.notifications.NotificationGate("deploy", 0)
|
ctrl._notification_gate = controller_mod.notifications.NotificationGate("deploy", 0)
|
||||||
ctrl._queue_notification(Notification(app="Chat", summary="lunch?", body=""))
|
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):
|
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))
|
lambda *a, **k: np.zeros(10, dtype=np.int16))
|
||||||
monkeypatch.setattr(controller_mod.stt, "transcribe", lambda pcm: "send me that file")
|
monkeypatch.setattr(controller_mod.stt, "transcribe", lambda pcm: "send me that file")
|
||||||
monkeypatch.setattr(controller_mod.server_client, "converse",
|
monkeypatch.setattr(controller_mod.server_client, "converse",
|
||||||
lambda text, on_command=None: "it's on the way")
|
lambda text, on_command=None: Reply("it's on the way"))
|
||||||
monkeypatch.setattr(controller_mod.tts, "speak",
|
monkeypatch.setattr(controller_mod.tts, "speak",
|
||||||
lambda text, on_error=None, should_stop=None: True)
|
lambda text, on_error=None, should_stop=None, voice_id=None: True)
|
||||||
monkeypatch.setattr(controller_mod.config, "DELIVERED_FILES_DIR", tmp_path)
|
monkeypatch.setattr(controller_mod.config, "DELIVERED_FILES_DIR", tmp_path)
|
||||||
monkeypatch.setattr(controller_mod.server_client, "list_outbox_files",
|
monkeypatch.setattr(controller_mod.server_client, "list_outbox_files",
|
||||||
lambda: [{"id": "abc", "name": "notes.txt", "size": 2}])
|
lambda: [{"id": "abc", "name": "notes.txt", "size": 2}])
|
||||||
@@ -518,3 +711,294 @@ def test_check_deliveries_runs_after_a_conversation_turn(monkeypatch, ctrl, tmp_
|
|||||||
ctrl._handle_conversation_turn()
|
ctrl._handle_conversation_turn()
|
||||||
|
|
||||||
assert (tmp_path / "notes.txt").read_bytes() == b"hi"
|
assert (tmp_path / "notes.txt").read_bytes() == b"hi"
|
||||||
|
|
||||||
|
|
||||||
|
# ── server-picked voice (the desk API's speak_as marker) ────────────────────
|
||||||
|
|
||||||
|
def _voice_turn(monkeypatch, ctrl, reply, said="talk like a pirate"):
|
||||||
|
"""Run one full conversation turn whose reply is *reply*, returning the
|
||||||
|
voice_id each tts.speak() call was given."""
|
||||||
|
voices = []
|
||||||
|
monkeypatch.setattr(controller_mod.mic, "record_utterance",
|
||||||
|
lambda *a, **k: np.zeros(10, dtype=np.int16))
|
||||||
|
monkeypatch.setattr(controller_mod.stt, "transcribe", lambda pcm: said)
|
||||||
|
monkeypatch.setattr(controller_mod.server_client, "converse",
|
||||||
|
lambda text, on_command=None: 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"}))
|
action = file_ops.parse(_cmd({"op": "write", "path": str(target), "content": "hi"}))
|
||||||
file_ops.execute(action)
|
file_ops.execute(action)
|
||||||
assert target.read_text() == "hi"
|
assert target.read_text() == "hi"
|
||||||
|
|
||||||
|
|
||||||
|
# ── lenient JSON (see relay_json) ───────────────────────────────────────────
|
||||||
|
# Machine-written JSON fails in a small, repeatable set of ways. Observed live
|
||||||
|
# 2026-07-30: a stray quote after `false` cost a whole desk turn — rejected,
|
||||||
|
# re-sent identically, rejected again, then abandoned with a promise to the
|
||||||
|
# user that nothing fulfilled.
|
||||||
|
|
||||||
|
def test_the_stray_quote_that_cost_a_live_turn_now_parses():
|
||||||
|
action = file_ops.parse(
|
||||||
|
'filectl {"op":"list","path":"/home/maji/Documents","pattern":"*","recursive":false"}'
|
||||||
|
)
|
||||||
|
assert action["action"] == "list"
|
||||||
|
assert action["path"] == "/home/maji/Documents"
|
||||||
|
assert action["recursive"] is False
|
||||||
|
assert action["_repairs"] == ["removed a stray quote after a bare value"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("payload,expected", [
|
||||||
|
('{"op": "read", "path": "/tmp/a.txt",}', "removed a trailing comma"),
|
||||||
|
("{'op': 'read', 'path': '/tmp/a.txt'}", "converted single-quoted strings to double-quoted"),
|
||||||
|
('{“op”: “read”, “path”: “/tmp/a.txt”}', "replaced smart quotes with straight ones"),
|
||||||
|
('```json {"op": "read", "path": "/tmp/a.txt"} ```', "stripped a markdown code fence"),
|
||||||
|
])
|
||||||
|
def test_common_model_json_mistakes_are_repaired(payload, expected):
|
||||||
|
action = file_ops.parse("filectl " + payload)
|
||||||
|
assert action["action"] == "read"
|
||||||
|
assert action["path"] == "/tmp/a.txt"
|
||||||
|
assert expected in action["_repairs"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_python_literals_are_converted():
|
||||||
|
action = file_ops.parse('filectl {"op": "list", "path": "/tmp", "recursive": True}')
|
||||||
|
assert action["recursive"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_repair_is_reported_in_the_output_never_hidden(tmp_path):
|
||||||
|
"""Silently fixing it would work today and guarantee the same broken call
|
||||||
|
tomorrow — the model has to be told while it still has the turn."""
|
||||||
|
(tmp_path / "a.txt").write_text("hello", encoding="utf-8")
|
||||||
|
action = file_ops.parse(
|
||||||
|
'filectl {"op":"list","path":"%s","recursive":false"}' % tmp_path
|
||||||
|
)
|
||||||
|
output = file_ops.execute(action)
|
||||||
|
assert "a.txt" in output
|
||||||
|
assert "your JSON was malformed" in output
|
||||||
|
assert "stray quote" in output
|
||||||
|
|
||||||
|
|
||||||
|
def test_valid_json_gets_no_repair_note(tmp_path):
|
||||||
|
(tmp_path / "a.txt").write_text("hello", encoding="utf-8")
|
||||||
|
action = file_ops.parse('filectl {"op": "list", "path": "%s"}' % tmp_path)
|
||||||
|
assert "_repairs" not in action
|
||||||
|
assert "malformed" not in file_ops.execute(action)
|
||||||
|
|
||||||
|
|
||||||
|
def test_genuinely_unparseable_json_points_at_the_character():
|
||||||
|
""""Expecting ',' delimiter: char 74" is not something a model can act on."""
|
||||||
|
with pytest.raises(file_ops.FileOpError) as excinfo:
|
||||||
|
file_ops.parse('filectl {"op": "read", "path": "/tmp/a.txt" "extra": 1}')
|
||||||
|
message = str(excinfo.value)
|
||||||
|
assert "^" in message # caret under the offending character
|
||||||
|
assert '"extra"' in message # ...and the fragment around it
|
||||||
|
|||||||
@@ -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
|
assert wf.getframerate() == 16000
|
||||||
frames = wf.readframes(wf.getnframes())
|
frames = wf.readframes(wf.getnframes())
|
||||||
assert np.frombuffer(frames, dtype=np.int16).tolist() == pcm.tolist()
|
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 "top-left" in pet_actions.describe({"action": "move", "anchor": "top-left"})
|
||||||
assert "wave" in pet_actions.describe({"action": "emote", "emote": "wave"})
|
assert "wave" in pet_actions.describe({"action": "emote", "emote": "wave"})
|
||||||
assert pet_actions.describe({"action": "help"}) == pet_actions.HELP
|
assert pet_actions.describe({"action": "help"}) == pet_actions.HELP
|
||||||
|
|
||||||
|
|
||||||
|
def test_voice_reset_parses_with_or_without_the_word_reset():
|
||||||
|
assert pet_actions.parse("petctl voice reset") == {"action": "voice", "voice": "default"}
|
||||||
|
assert pet_actions.parse("petctl voice default") == {"action": "voice", "voice": "default"}
|
||||||
|
assert pet_actions.parse("petctl voice") == {"action": "voice", "voice": "default"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_petctl_cannot_be_used_to_pick_a_voice():
|
||||||
|
"""Choosing a voice is the server's job (speak_as) — it has the voice
|
||||||
|
library. petctl only ever undoes one, so an attempt to set a voice here
|
||||||
|
is pointed back at the marker that works."""
|
||||||
|
with pytest.raises(pet_actions.ActionError, match="speak_as"):
|
||||||
|
pet_actions.parse("petctl voice Terence")
|
||||||
|
|||||||
@@ -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 sys
|
||||||
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import MagicMock, patch
|
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:
|
with patch.object(server_client.requests, "post") as post:
|
||||||
post.return_value = _mock_response({"type": "reply", "text": "hello there"})
|
post.return_value = _mock_response({"type": "reply", "text": "hello there"})
|
||||||
result = server_client.converse("hi")
|
result = server_client.converse("hi")
|
||||||
assert result == "hello there"
|
assert result.text == "hello there"
|
||||||
|
assert result.voice_id == "" # no speak_as on this reply
|
||||||
post.assert_called_once()
|
post.assert_called_once()
|
||||||
args, kwargs = post.call_args
|
args, kwargs = post.call_args
|
||||||
assert args[0] == "http://test-server:5002/desk/converse"
|
assert args[0] == "http://test-server:5002/desk/converse"
|
||||||
@@ -43,7 +46,7 @@ def test_converse_relays_a_command_then_returns_reply():
|
|||||||
with patch.object(server_client.requests, "post", side_effect=responses) as post:
|
with patch.object(server_client.requests, "post", side_effect=responses) as post:
|
||||||
on_command = MagicMock(return_value="[exit 0]\nhi")
|
on_command = MagicMock(return_value="[exit 0]\nhi")
|
||||||
result = server_client.converse("run echo hi", on_command=on_command)
|
result = server_client.converse("run echo hi", on_command=on_command)
|
||||||
assert result == "done"
|
assert result.text == "done"
|
||||||
on_command.assert_called_once_with("echo hi")
|
on_command.assert_called_once_with("echo hi")
|
||||||
# second call was to /desk/tool_result with the command's output
|
# second call was to /desk/tool_result with the command's output
|
||||||
second_call = post.call_args_list[1]
|
second_call = post.call_args_list[1]
|
||||||
@@ -53,6 +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():
|
def test_converse_raises_server_error_on_error_payload():
|
||||||
with patch.object(server_client.requests, "post") as post:
|
with patch.object(server_client.requests, "post") as post:
|
||||||
post.return_value = _mock_response({"type": "error", "error": "unauthorized"})
|
post.return_value = _mock_response({"type": "error", "error": "unauthorized"})
|
||||||
@@ -128,3 +144,52 @@ def test_download_outbox_file_raises_server_error_on_http_failure():
|
|||||||
get.return_value = _mock_response({}, ok=False)
|
get.return_value = _mock_response({}, ok=False)
|
||||||
with pytest.raises(server_client.ServerError, match="abc"):
|
with pytest.raises(server_client.ServerError, match="abc"):
|
||||||
server_client.download_outbox_file("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"
|
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("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("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("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():
|
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("Docs are at https://example.com/x?y=1")
|
||||||
assert not is_question("")
|
assert not is_question("")
|
||||||
assert not is_question(None)
|
assert not is_question(None)
|
||||||
|
|
||||||
|
|
||||||
|
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))
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
from bolt_pet.audio.tts import chunks_to_int16
|
from bolt_pet import config as tts_config
|
||||||
|
from bolt_pet.audio.tts import chunks_to_int16, model_for, voice_for
|
||||||
from bolt_pet.audio.wake_word import NearMissLog
|
from bolt_pet.audio.wake_word import NearMissLog
|
||||||
|
|
||||||
|
|
||||||
@@ -80,3 +81,27 @@ def test_clear_resets_peak_and_entries():
|
|||||||
log.observe(0.45, threshold=0.5, timestamp=1.0)
|
log.observe(0.45, threshold=0.5, timestamp=1.0)
|
||||||
log.clear()
|
log.clear()
|
||||||
assert log.entries() == [] and log.peak == 0.0
|
assert log.entries() == [] and log.peak == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
# ── voice / model selection (server speak_as) ───────────────────────────────
|
||||||
|
|
||||||
|
def test_the_override_voice_wins_over_the_configured_one(monkeypatch):
|
||||||
|
monkeypatch.setattr(tts_config, "ELEVENLABS_VOICE_ID", "DEFAULT")
|
||||||
|
assert voice_for("VOICE1") == "VOICE1"
|
||||||
|
assert voice_for("") == "DEFAULT"
|
||||||
|
assert voice_for(None) == "DEFAULT"
|
||||||
|
|
||||||
|
|
||||||
|
def test_english_replies_in_the_default_voice_use_the_default_model(monkeypatch):
|
||||||
|
monkeypatch.setattr(tts_config, "ELEVENLABS_MODEL_ID", "eleven_flash_v2")
|
||||||
|
monkeypatch.setattr(tts_config, "ELEVENLABS_MULTILINGUAL_MODEL_ID", "eleven_flash_v2_5")
|
||||||
|
assert model_for("all good here", None) == "eleven_flash_v2"
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_picked_voice_or_non_english_text_uses_the_multilingual_model(monkeypatch):
|
||||||
|
# eleven_flash_v2 is English-only: it would read either of these as
|
||||||
|
# mangled phonetic English rather than failing outright.
|
||||||
|
monkeypatch.setattr(tts_config, "ELEVENLABS_MODEL_ID", "eleven_flash_v2")
|
||||||
|
monkeypatch.setattr(tts_config, "ELEVENLABS_MULTILINGUAL_MODEL_ID", "eleven_flash_v2_5")
|
||||||
|
assert model_for("all good here", "VOICE1") == "eleven_flash_v2_5"
|
||||||
|
assert model_for("こんにちは", None) == "eleven_flash_v2_5"
|
||||||
|
|||||||
Reference in New Issue
Block a user