Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3ee67cb4d6 | |||
| 8d4751d80f |
@@ -89,7 +89,10 @@
|
||||
"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(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)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+16
-1
@@ -72,8 +72,17 @@ ELEVENLABS_VOICE_ID=
|
||||
#VAD_MIN_UTTERANCE_SECONDS=0.4
|
||||
#VAD_GRACE_SECONDS=4 # how long to wait for you to start talking
|
||||
|
||||
# ── Local intents (optional) ────────────────────────────────────────────────
|
||||
# A short, closed list of things the pet answers itself, with no server round
|
||||
# trip: "stop", "be quiet", "come here", "go away", "go to sleep", "wake up",
|
||||
# "say that again", "sit"/"stay", "go for a walk", "use your normal voice".
|
||||
# Matched whole and exact, and never while you're answering a question Bolt
|
||||
# asked, so a real request ("stop the docker container") still goes to him.
|
||||
# Set to false to route absolutely everything through the server.
|
||||
#LOCAL_INTENTS=true
|
||||
|
||||
# ── Follow-up listening (optional) ──────────────────────────────────────────
|
||||
# When a reply ends on a question, the pet keeps listening for your answer
|
||||
# When a reply asks you something, the pet keeps listening for your answer
|
||||
# instead of dropping back to idle and making you say the wake word again.
|
||||
# FOLLOW_UP_MAX_TURNS caps how many question-and-answer rounds can chain
|
||||
# without you re-triggering it (0 = no cap) — a stop on runaway loops if the
|
||||
@@ -181,6 +190,12 @@ ELEVENLABS_VOICE_ID=
|
||||
#NOTIFICATION_BRIDGE=false
|
||||
#NOTIFICATION_FILTER=build|deploy|calendar
|
||||
#NOTIFICATION_MIN_INTERVAL_SECONDS=60
|
||||
# Notifications queue while the pet is napping (the heartbeat that forwards them
|
||||
# doesn't run). These two stop an overnight backlog becoming a monologue at 8am:
|
||||
# the queue drops its oldest past the limit, and anything staler than the age
|
||||
# limit is discarded rather than read out.
|
||||
#NOTIFICATION_QUEUE_LIMIT=20
|
||||
#NOTIFICATION_MAX_AGE_SECONDS=900
|
||||
|
||||
# ── Push-to-talk (optional) ─────────────────────────────────────────────────
|
||||
# Global hotkey; needs pynput and a session that allows global key hooks
|
||||
|
||||
@@ -47,6 +47,12 @@ python scripts/generate_bolt_sprites.py # --out /tmp/x to preview fi
|
||||
python scripts/slice_spritesheet.py path/to/sheet.png assets/sprites/idle --cols 6 --rows 1
|
||||
```
|
||||
|
||||
**Cutting a release:** bump `__version__` in `bolt_pet/__init__.py` in the same
|
||||
commit you tag, because that string — not the git history — is what every
|
||||
already-installed pet compares against the newest Gitea tag (`updater.py`). A
|
||||
tag without the bump means nobody updates; a bump without the tag means the
|
||||
next tag looks older than what's running.
|
||||
|
||||
There is no lint/build step configured beyond pytest. `cp .env.example .env`
|
||||
and fill in `BOLT_SERVER_URL` / `DESK_API_KEY` (+ `DEEPGRAM_API_KEY`,
|
||||
`ELEVENLABS_API_KEY`) before running — without server config the controller
|
||||
@@ -73,12 +79,41 @@ logs a missing-config message and exits its thread instead of starting.
|
||||
queued desktop notifications. It owns the live wake-word threshold
|
||||
(`wake_threshold()` is passed to `listen_for_wake_word` as a *callable* so
|
||||
the tray slider takes effect mid-listen) and the conversation `history`.
|
||||
|
||||
**One thread drives all of it, so failure containment is structural.** Every
|
||||
entry point that can raise runs inside `_guarded(work, label)`, which logs and
|
||||
forces the machine back to IDLE (the only state it's always safe to resume
|
||||
from): the conversation turn, the heartbeat tick — which matters most, since
|
||||
`on_tick` is the one place control returns to us during a listen that blocks
|
||||
for minutes, and everything it drives touches the network or shells out — and
|
||||
the post-restart report. `run()` wraps the lot in try/finally because
|
||||
`finished` is what `ui/app.py` waits on to quit the thread and to run a
|
||||
pending `os.execv`; an exception escaping `_loop` used to skip it, so the
|
||||
failure mode of any bug below was "the pet goes deaf with the mic still open
|
||||
and the tray won't quit" rather than "one turn failed". `_handle_command` has
|
||||
the same shape for a different reason: it must **always return a string**,
|
||||
because the server is blocked on `/desk/tool_result` while it runs and an
|
||||
exception there means the relay never posts and the server sits out its own
|
||||
timeout on a turn that can't finish — silent on both ends. Handed back as
|
||||
command output instead, Bolt can read what broke and say so in the same turn.
|
||||
- **`server_client.py`** — HTTP client for the desk API, dependency-free
|
||||
beyond `requests` so it's easy to mock in tests. `converse()` loops relaying
|
||||
server-issued shell commands (`run_local_command`, executed via
|
||||
`subprocess.run(shell=True)` as the desktop user, 30s default timeout) via
|
||||
server-issued shell commands (`run_local_command`, executed via a
|
||||
`shell=True` `Popen` as the desktop user, 30s default timeout) via
|
||||
`/desk/tool_result` until the server sends a final `reply` (capped at
|
||||
`_MAX_RELAY_HOPS`). This is the same "full desktop control" trust model as
|
||||
`_MAX_RELAY_HOPS` — exhausting which is reported as its own error, because
|
||||
"unknown server response" sent everyone looking at the payload shape when what
|
||||
happened is a model that kept calling tools and never answered).
|
||||
`run_local_command` is `Popen` rather than `subprocess.run` for the timeout
|
||||
path: the command is a shell, and `run()`'s timeout would kill only that
|
||||
shell, leaving whatever it spawned (a build, a `tail -f`, an ffmpeg) alive for
|
||||
the rest of the session with no parent watching — so the child gets its own
|
||||
process group (`start_new_session`, POSIX) and a timeout SIGTERMs the group,
|
||||
SIGKILLs it two seconds later, then drains the pipes *with its own timeout* so
|
||||
a grandchild holding stdout can't turn a timeout into a hang. Whatever the
|
||||
command printed before it hung is returned alongside the timeout notice, since
|
||||
the last line usually says exactly what it was stuck waiting for. This is the
|
||||
same "full desktop control" trust model as
|
||||
the server repo's other desk clients — commands only ever originate from
|
||||
the user's own voice/click requests in their own session. A final reply is
|
||||
returned as a `Reply(text, voice_id, voice_name)` rather than a bare string,
|
||||
@@ -102,7 +137,8 @@ logs a missing-config message and exits its thread instead of starting.
|
||||
the name isn't always coming from someone as trusted as the owner. Toggle
|
||||
off entirely with `RECEIVE_FILES=false`.
|
||||
- **`audio/`** — `mic.py` (energy-based VAD utterance capture, ported from the
|
||||
server repo's `bolt_desk.py`), `wake_word.py` (openWakeWord `thunderbolt.onnx`
|
||||
server repo's `bolt_desk.py`, plus `flush()` — see the note below on the pet
|
||||
hearing itself), `wake_word.py` (openWakeWord `thunderbolt.onnx`
|
||||
detection + `NearMissLog` for threshold tuning — see below), `stt.py`
|
||||
(Deepgram), `tts.py` (ElevenLabs, streaming by default — `stream_pcm()` +
|
||||
`play_stream()` start playback on the first chunk; `chunks_to_int16()`
|
||||
@@ -207,6 +243,23 @@ logs a missing-config message and exits its thread instead of starting.
|
||||
notes below); it's a safer path to the same capability. Pure parsing
|
||||
(`parse`) is separated from the filesystem I/O (`execute`), matching
|
||||
pet_actions.py's parse/describe split.
|
||||
- **`relay_json.py`** — the JSON parser both `filectl` and `dialoguectl` use
|
||||
instead of `json.loads`, because their payload is hand-typed by a model into
|
||||
a tool marker and fails in a small, repeatable set of ways (stray quote after
|
||||
a bare literal, trailing comma, single or smart quotes, Python `True`/`False`,
|
||||
a markdown fence). Strict parsing already cost a live turn: the call was
|
||||
rejected, the model re-sent the identical line, was rejected again, and then
|
||||
told the user "I'll check now" without ever calling anything. So `loads()`
|
||||
tries strict first, then applies **named, individually-narrow repairs** and
|
||||
accepts one only if the result parses — and on total failure raises
|
||||
`RelayJsonError` carrying a caret pointed at the offending character, since
|
||||
a model can act on a pointed-at fragment but not on "Expecting ',' delimiter:
|
||||
char 74". Two conventions matter for any new relayed-JSON command: repairs
|
||||
are **never silent** — `parse` stashes them on the action as `_repairs` and
|
||||
`describe` appends `relay_json.repair_note(...)` to the tool result, so the
|
||||
model is told it sent something broken while it still has the turn — and new
|
||||
repairs go in the `_REPAIRS` tuple ordered cheapest/safest first. Tested
|
||||
inside `tests/test_file_ops.py`, not a file of its own.
|
||||
- **`screen_context.py`** — active-window title (xprop/xdotool, Win32,
|
||||
osascript) appended to each utterance via `context_for()`, plus
|
||||
`is_fullscreen_active()` for do-not-disturb. Text only — the desk API takes
|
||||
@@ -243,6 +296,14 @@ logs a missing-config message and exits its thread instead of starting.
|
||||
`dbus-monitor`, parses Notify calls (pure `iter_notifications()`), filters
|
||||
and rate-limits them (`NotificationGate`), and the controller forwards
|
||||
survivors through `converse()`. Off by default — each one is a round trip.
|
||||
Note where the queue between the two threads lives: notifications arrive on
|
||||
the watcher thread and are forwarded from the heartbeat, which **doesn't run
|
||||
while the pet is napping** — so they accumulate overnight. The controller's
|
||||
queue is therefore a bounded `deque` stamped on arrival, and the drain
|
||||
discards anything older than `NOTIFICATION_MAX_AGE_SECONDS` rather than
|
||||
reading a nine-hour-old backlog out at 8am. A drain that stops early (a nap
|
||||
starting mid-loop, or the server going down) re-queues what it didn't forward
|
||||
instead of dropping it, which the original swap-and-return did silently.
|
||||
- **`sudo_askpass.py`** — makes server-relayed `sudo` usable from a process
|
||||
with no terminal, by pointing sudo's `SUDO_ASKPASS` at a GUI helper and
|
||||
rewriting bare `sudo` to `sudo -A` (`add_askpass_flag`, a conservative regex
|
||||
@@ -279,15 +340,43 @@ logs a missing-config message and exits its thread instead of starting.
|
||||
`for_speech()` (called inside `tts.speak()`, so every path to the speakers is
|
||||
covered) strips markdown, emoji, URLs and stray symbols the voice would read
|
||||
literally ("asterisk asterisk"), turns bullet lists into full sentences, and
|
||||
words a few symbols (`&` → "and"). `for_display()` is the looser version for
|
||||
words a few symbols (`&` → "and"), abbreviations the voice would spell out
|
||||
letter by letter (`e.g.` → "for example", `etc.` → "and so on") and a long
|
||||
option's leading `--` (heard as "dash dash force"; the single hyphen has to
|
||||
survive for "bolt-pet"). `for_display()` is the looser version for
|
||||
the speech bubble — markdown syntax gone, emoji kept. `is_question()` decides
|
||||
whether a reply leaves the pet waiting on an answer: it tests the *spoken*
|
||||
form (so a '?' inside a stripped code block or URL doesn't count) and only a
|
||||
trailing one counts, since a question asked in passing isn't awaiting a
|
||||
reply. `controller._should_follow_up` uses it to keep listening without the
|
||||
form (so a '?' inside a stripped code block or URL doesn't count) and a '?'
|
||||
**anywhere** counts. That last part was once trailing-only, on the theory that
|
||||
"What time is it? It's 7:15." isn't awaiting a reply — true of that sentence
|
||||
and wrong more often, since Bolt routinely asks and then keeps talking ("Want
|
||||
me to fix it? I'd start with the config"), which is the case that actually
|
||||
costs you a wake word. The asymmetry is the argument: an unwanted extra listen
|
||||
ends itself on `VAD_GRACE_SECONDS` of silence, a missed one makes you start
|
||||
over. `controller._should_follow_up` uses it to keep listening without the
|
||||
wake word, capped by `FOLLOW_UP_MAX_TURNS` so a server that ends every reply
|
||||
with a question can't loop forever off mic noise. Pure string logic, no
|
||||
Qt/audio imports.
|
||||
- **`intents.py`** — the handful of utterances answered *without* the server.
|
||||
"stop", "come here", "go to sleep", "say that again", "use your normal voice"
|
||||
are commands to the body, and routing them through the desk API costs two to
|
||||
four seconds and three network hops to make the pet walk left — and only works
|
||||
if the server's prompt happens to advertise the matching `petctl` verb (which
|
||||
is why `voice reset` needs a block in `ai/desk_api.py`'s pet prompt; see the
|
||||
Voices section). Recognising the phrase here removes both the latency and that
|
||||
coupling. The design problem is *not stealing real requests*, and three rules
|
||||
cover it: whole-utterance exact match after normalisation (so "stop" is an
|
||||
intent and "stop the docker container" is a question for Bolt), a closed table
|
||||
with nothing arguable in it, and **never on a follow-up turn** — if Bolt just
|
||||
asked you something your answer is his, and swallowing "never mind" locally
|
||||
would leave the server holding a question it never got an answer to. Both
|
||||
sides of the comparison go through `normalize()` (the table is canonicalised
|
||||
at import, and `_build()` refuses to build one where two intents claim the
|
||||
same normalised phrase, or where a phrase reduces to "" and would match pure
|
||||
filler like "hey bolt"). Actions come back in the **same shape
|
||||
`pet_actions.parse` produces**, so `PetWindow.apply_action` needs no new
|
||||
vocabulary; the effects live in `controller._handle_local_intent`. Off switch:
|
||||
`LOCAL_INTENTS=false`.
|
||||
- **`ui/`** — `app.py` wires `QApplication` + `PetWindow` + `PetTray` + the
|
||||
history/tuner windows + the push-to-talk hotkey + the controller thread
|
||||
together; `pet_window.py` is the frameless/translucent/always-on-top sprite
|
||||
@@ -394,6 +483,29 @@ the times it nearly heard you), and its slider is read per frame because
|
||||
`listen_for_wake_word` accepts a callable threshold. Set the threshold just
|
||||
under the peak you can hit reliably, then persist it in `.env`.
|
||||
|
||||
### The mic keeps recording while nothing is reading it
|
||||
|
||||
Same family of bug as the openwakeword one above, one layer down: PortAudio
|
||||
captures into a ring buffer continuously, so audio from a stretch where the
|
||||
pipeline thread was busy elsewhere is still queued when the next read happens.
|
||||
It bites in exactly one place. At the end of a reply that asked you something,
|
||||
`_speak` sets `_talk_now` and the next turn starts recording immediately — with
|
||||
the tail of the pet's own TTS sitting in that buffer, above the VAD threshold.
|
||||
The VAD takes it for the start of your answer, Deepgram transcribes it, and Bolt
|
||||
is handed his own last sentence as if you had said it. With barge-in on the
|
||||
detector was draining the stream during playback so the window is small; with
|
||||
`BARGE_IN=false` nothing drains it at all.
|
||||
|
||||
`mic.flush(stream)` drops what's buffered, and `_speak` calls it on the
|
||||
follow-up branch only. **That placement is the whole correctness argument** —
|
||||
flushing is only safe where the buffer is known to hold nothing *you* said:
|
||||
playback ran to completion, so if you had spoken, barge-in would have cut it and
|
||||
taken the interrupted branch instead. Never flush before a wake-triggered
|
||||
recording, where the rest of "thunderbolt, what time is it" is legitimately
|
||||
queued and dropping it clips the request. A single call is bounded by
|
||||
`max_seconds` so it can't chase a stream filling as fast as it drains, and it
|
||||
no-ops on a stream with no `read_available` (i.e. every fake stream in tests).
|
||||
|
||||
### Testing conventions
|
||||
|
||||
`tests/` covers pure logic only (state machine, wake-word scoring loop, mic
|
||||
|
||||
@@ -37,6 +37,42 @@ def rms(frame: np.ndarray) -> float:
|
||||
return float(np.sqrt(np.mean(frame.astype(np.float64) ** 2)))
|
||||
|
||||
|
||||
def flush(stream, max_seconds: float = 10.0, sample_rate: int = config.SAMPLE_RATE) -> int:
|
||||
"""Throw away whatever is already sitting in the mic's buffer. Returns the
|
||||
number of frames dropped.
|
||||
|
||||
PortAudio keeps capturing into a ring buffer while nothing is reading it, so
|
||||
audio recorded during a long blocking stretch is still queued when the next
|
||||
read happens. That matters exactly once: at the end of a reply the pet is
|
||||
about to listen for an answer, and the last fraction of a second of its own
|
||||
TTS is in that buffer. It's above the VAD threshold, so `record_utterance`
|
||||
treats it as the start of your answer, Deepgram transcribes it, and Bolt is
|
||||
handed his own sentence as if you had said it. With barge-in on, the
|
||||
detector was draining the stream during playback and the window is small;
|
||||
with `BARGE_IN=false` nothing drains it at all.
|
||||
|
||||
Only safe where the buffer is known to hold *nothing you said* — never
|
||||
before a wake-triggered recording, where the rest of "thunderbolt, what
|
||||
time is it" is legitimately queued and dropping it clips the request.
|
||||
|
||||
*max_seconds* bounds a single call so this can't chase a stream that's
|
||||
filling as fast as it's read. Best-effort: a fake stream in tests has no
|
||||
`read_available` and this is a no-op, which is the correct behaviour for
|
||||
one."""
|
||||
try:
|
||||
available = int(getattr(stream, "read_available", 0) or 0)
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
if available <= 0:
|
||||
return 0
|
||||
frames = min(available, int(max_seconds * sample_rate))
|
||||
try:
|
||||
stream.read(frames)
|
||||
except Exception:
|
||||
return 0 # a mid-flush device error is the reader's problem, not ours
|
||||
return frames
|
||||
|
||||
|
||||
def record_utterance(
|
||||
stream: AudioStream,
|
||||
should_continue=lambda: True,
|
||||
|
||||
@@ -123,6 +123,14 @@ FOLLOW_UP_LISTEN = os.environ.get("FOLLOW_UP_LISTEN", "true").lower() in ("1", "
|
||||
FOLLOW_UP_MAX_TURNS = int(os.environ.get("FOLLOW_UP_MAX_TURNS", "10"))
|
||||
FOLLOW_UP_GRACE_SECONDS = float(os.environ.get("FOLLOW_UP_GRACE_SECONDS", "7"))
|
||||
|
||||
# ── local intents ───────────────────────────────────────────────────────────
|
||||
# A short, closed list of utterances the pet answers itself instead of paying a
|
||||
# server round trip for: "stop", "come here", "go to sleep", "say that again",
|
||||
# "use your normal voice". Matched whole and exact (see intents.py), never
|
||||
# during a follow-up turn, so a real request is never swallowed. Turn it off to
|
||||
# route absolutely everything through Bolt.
|
||||
LOCAL_INTENTS = os.environ.get("LOCAL_INTENTS", "true").lower() in ("1", "true", "yes", "on")
|
||||
|
||||
COMMAND_TIMEOUT_SECONDS = int(os.environ.get("COMMAND_TIMEOUT_SECONDS", "30"))
|
||||
|
||||
# ── sudo password prompts ───────────────────────────────────────────────────
|
||||
@@ -221,6 +229,14 @@ NOTIFICATION_BRIDGE = os.environ.get("NOTIFICATION_BRIDGE", "false").lower() in
|
||||
# Regex matched against "<app>: <summary> <body>"; empty means "everything".
|
||||
NOTIFICATION_FILTER = os.environ.get("NOTIFICATION_FILTER", "")
|
||||
NOTIFICATION_MIN_INTERVAL_SECONDS = float(os.environ.get("NOTIFICATION_MIN_INTERVAL_SECONDS", "60"))
|
||||
# Notifications arrive on the watcher thread and are forwarded from the
|
||||
# heartbeat, which doesn't run while the pet is napping — so they queue. Both
|
||||
# limits exist to stop an overnight backlog turning into a burst of round trips
|
||||
# and a monologue at 8am: the queue is bounded (oldest dropped first) and
|
||||
# anything staler than the age limit is discarded at drain time, because
|
||||
# "Firefox finished downloading" is not news nine hours later.
|
||||
NOTIFICATION_QUEUE_LIMIT = int(os.environ.get("NOTIFICATION_QUEUE_LIMIT", "20"))
|
||||
NOTIFICATION_MAX_AGE_SECONDS = float(os.environ.get("NOTIFICATION_MAX_AGE_SECONDS", "900"))
|
||||
|
||||
# ── file delivery ────────────────────────────────────────────────────────
|
||||
# The server's deliver_files tool (ai/desk_api.py in the main tmn-api repo)
|
||||
|
||||
+181
-15
@@ -15,15 +15,16 @@ from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import time
|
||||
from collections import deque
|
||||
from typing import Optional
|
||||
|
||||
from PySide6.QtCore import QObject, Signal
|
||||
|
||||
from . import (
|
||||
config, dialogue as dialogue_mod, file_delivery, file_ops,
|
||||
history as history_mod, monitors as monitors_mod, notifications,
|
||||
pet_actions, quiet, screen_context, screen_text, self_restart,
|
||||
server_client, speech_text, updater,
|
||||
history as history_mod, intents as intents_mod, monitors as monitors_mod,
|
||||
notifications, pet_actions, quiet, screen_context, screen_text,
|
||||
self_restart, server_client, speech_text, updater,
|
||||
)
|
||||
from . import __version__
|
||||
from .audio import barge_in, mic, stt, tts, wake_word
|
||||
@@ -98,7 +99,14 @@ class PetController(QObject):
|
||||
self._notification_gate = notifications.NotificationGate(
|
||||
config.NOTIFICATION_FILTER, config.NOTIFICATION_MIN_INTERVAL_SECONDS
|
||||
)
|
||||
self._pending_notifications: list[notifications.Notification] = []
|
||||
# Bounded, and stamped on arrival: the drain only runs from the
|
||||
# heartbeat, which doesn't run while napping, so this fills up
|
||||
# overnight. maxlen drops the oldest rather than growing without limit,
|
||||
# and the stamp lets the drain discard a backlog nobody wants read out
|
||||
# at 8am (see _drain_notifications).
|
||||
self._pending_notifications: deque[tuple[float, notifications.Notification]] = deque(
|
||||
maxlen=max(1, config.NOTIFICATION_QUEUE_LIMIT)
|
||||
)
|
||||
self._notification_lock = threading.Lock()
|
||||
|
||||
# ── external controls (safe to call from the Qt/UI thread) ─────────
|
||||
@@ -187,6 +195,13 @@ class PetController(QObject):
|
||||
)
|
||||
self.log.emit(f"Barge-in: {config.BARGE_IN_MODE} mode.")
|
||||
|
||||
# Everything past here is in try/finally because `finished` is what
|
||||
# ui/app.py waits on to quit the QThread and to run a pending
|
||||
# os.execv. An exception escaping _loop used to skip it, leaving the
|
||||
# thread wedged with the mic still open and no restart — so the failure
|
||||
# mode of any bug below was "the pet goes deaf and the tray won't quit"
|
||||
# rather than "one turn failed".
|
||||
try:
|
||||
with self._stream:
|
||||
try:
|
||||
health = server_client.check_health()
|
||||
@@ -194,12 +209,34 @@ class PetController(QObject):
|
||||
except Exception as exc:
|
||||
self.log.emit(f"Server not reachable yet ({exc}) — will keep trying per-request.")
|
||||
self._start_notification_bridge()
|
||||
self._report_self_restart()
|
||||
self._guarded(self._report_self_restart, "restart report")
|
||||
self._loop()
|
||||
except Exception as exc:
|
||||
self.log.emit(f"Pipeline stopped unexpectedly: {exc!r}")
|
||||
finally:
|
||||
if self._notification_watcher is not None:
|
||||
self._notification_watcher.stop()
|
||||
self.finished.emit()
|
||||
|
||||
def _guarded(self, work, label: str) -> bool:
|
||||
"""Run *work*, absorbing anything it raises.
|
||||
|
||||
The pipeline is one thread driving a state machine that raises on an
|
||||
illegal transition (deliberately — see state.py), plus a dozen
|
||||
best-effort subsystems that shell out, hit the network, or touch the
|
||||
filesystem. Any one of them raising something unforeseen used to end the
|
||||
whole session. Here, it costs a log line and a forced return to IDLE,
|
||||
which is the only state it's always safe to resume from.
|
||||
|
||||
Returns True if *work* completed without raising."""
|
||||
try:
|
||||
work()
|
||||
return True
|
||||
except Exception as exc:
|
||||
self.log.emit(f"Recovered from a {label} failure: {exc!r}")
|
||||
self._state.force(PetState.IDLE)
|
||||
return False
|
||||
|
||||
def _loop(self) -> None:
|
||||
while self._running:
|
||||
if self._muted:
|
||||
@@ -214,7 +251,7 @@ class PetController(QObject):
|
||||
if not self._running:
|
||||
return
|
||||
continue
|
||||
self._handle_conversation_turn()
|
||||
self._guarded(self._handle_conversation_turn, "conversation turn")
|
||||
|
||||
def _wait_for_wake_or_click(self) -> bool:
|
||||
"""True once either the wake phrase was heard or a click-to-talk
|
||||
@@ -226,7 +263,12 @@ class PetController(QObject):
|
||||
self._stream,
|
||||
should_continue=should_continue,
|
||||
threshold=self.wake_threshold, # callable: the tuner slider is live
|
||||
on_tick=self._maybe_heartbeat,
|
||||
# Guarded: on_tick is the one place control returns to us during a
|
||||
# listen that can block for minutes, and everything it drives
|
||||
# (update check, nap probe, notification forwarding) touches the
|
||||
# network or shells out. Unguarded, any of them raising would unwind
|
||||
# the listen loop and end the session.
|
||||
on_tick=lambda: self._guarded(self._maybe_heartbeat, "heartbeat"),
|
||||
on_score=self._observe_wake_score,
|
||||
)
|
||||
if not self._running:
|
||||
@@ -275,6 +317,13 @@ class PetController(QObject):
|
||||
self.log.emit(f"You: {text}")
|
||||
self.history.add(history_mod.USER, text, time.time())
|
||||
|
||||
# "stop", "come here", "say that again" — answered here, without the
|
||||
# round trip. Never on a follow-up turn: Bolt asked you something and
|
||||
# the answer is his, even if it happens to look like a body command.
|
||||
if not following_up and self._handle_local_intent(text):
|
||||
self._state.transition(PetState.IDLE)
|
||||
return
|
||||
|
||||
try:
|
||||
# What's focused right now rides along, so "what's this error?"
|
||||
# has a referent without you having to describe the window.
|
||||
@@ -293,6 +342,60 @@ class PetController(QObject):
|
||||
self._state.transition(PetState.IDLE)
|
||||
self._maybe_self_restart()
|
||||
|
||||
def _handle_local_intent(self, text: str) -> bool:
|
||||
"""Answer *text* locally if it's one of the closed set of body commands
|
||||
in intents.py. Returns True if it was handled (no server call).
|
||||
|
||||
The effects live here rather than in intents.py for the same reason
|
||||
pet_actions splits parse from describe: recognising the phrase is pure
|
||||
and testable, doing the thing needs the controller's state, the tray's
|
||||
nap override and a Qt signal to the window."""
|
||||
if not config.LOCAL_INTENTS:
|
||||
return False
|
||||
intent = intents_mod.recognize(text)
|
||||
if intent is None:
|
||||
return False
|
||||
self.log.emit(f"Local intent: {intent.name} (answered without the server)")
|
||||
|
||||
if intent.name == "stop":
|
||||
# Nothing to say and nothing to do: silence is the acknowledgement.
|
||||
# Also ends any follow-up chain — "never mind" means the
|
||||
# conversation is over, not that we should keep the mic open.
|
||||
self._follow_ups = 0
|
||||
self._pending_follow_up = False
|
||||
self._talk_now.clear()
|
||||
return True
|
||||
|
||||
if intent.name == "repeat":
|
||||
last = self.history.last(history_mod.PET)
|
||||
if last is None:
|
||||
self._speak("I haven't said anything yet.")
|
||||
else:
|
||||
# remember=False: replaying a line isn't a new turn. Appending it
|
||||
# would make "say that again" twice over read back as a
|
||||
# conversation where Bolt volunteered the same thing three times.
|
||||
self._speak(last.text, remember=False)
|
||||
return True
|
||||
|
||||
if intent.name == "voice_reset":
|
||||
had_voice = bool(self._voice_id)
|
||||
self.reset_voice()
|
||||
self._speak(intent.speak if had_voice else "That is my normal voice.")
|
||||
return True
|
||||
|
||||
action = intent.action
|
||||
if action is not None:
|
||||
if action.get("action") == "nap":
|
||||
# Through set_napping, not just the signal, so a spoken "go to
|
||||
# sleep" overrides the quiet-hours schedule exactly like the
|
||||
# tray's Nap entry and `petctl nap` do — otherwise the next
|
||||
# schedule check would undo it within ten seconds.
|
||||
self.set_napping(bool(action["enabled"]))
|
||||
self.action.emit(dict(action))
|
||||
if intent.speak:
|
||||
self._speak(intent.speak)
|
||||
return True
|
||||
|
||||
def _with_context(self, text: str) -> str:
|
||||
"""Everything the server gets alongside what you actually said: the
|
||||
focused window title, and a one-line note about the screen layout so
|
||||
@@ -319,9 +422,26 @@ class PetController(QObject):
|
||||
self._pet_monitor = int(index)
|
||||
|
||||
def _handle_command(self, command: str) -> str:
|
||||
"""Server-relayed command. `petctl ...` drives the pet's body and
|
||||
`filectl ...` does local file read/write/edit — neither ever reaches
|
||||
a shell; everything else is a real command, exactly as before (see
|
||||
"""Server-relayed command, with the guarantee the relay depends on: this
|
||||
always returns a string.
|
||||
|
||||
The server is blocked on `/desk/tool_result` while this runs. If it
|
||||
raises instead of answering, the relay never posts, the turn dies
|
||||
mid-flight, and the server sits out its own timeout on a conversation it
|
||||
can't finish — the worst available failure mode, because it's silent on
|
||||
both ends. Handing the exception back as command output instead means
|
||||
Bolt can read what went wrong and say so, or try something else, inside
|
||||
the same turn."""
|
||||
try:
|
||||
return self._dispatch_command(command)
|
||||
except Exception as exc:
|
||||
self.log.emit(f"Command handler failed: {exc!r}")
|
||||
return f"[error] the pet couldn't run that: {exc}"
|
||||
|
||||
def _dispatch_command(self, command: str) -> str:
|
||||
"""`petctl ...` drives the pet's body, `dialoguectl ...` plays a scene
|
||||
and `filectl ...` does local file read/write/edit — none of them ever
|
||||
reach a shell; everything else is a real command, exactly as before (see
|
||||
the security notes in the README)."""
|
||||
try:
|
||||
action = pet_actions.parse(command)
|
||||
@@ -568,13 +688,14 @@ class PetController(QObject):
|
||||
elif not config.VOICE_STICKY:
|
||||
self.reset_voice()
|
||||
|
||||
def _speak(self, text: str) -> None:
|
||||
def _speak(self, text: str, remember: bool = True) -> None:
|
||||
self._state.transition(PetState.TALKING)
|
||||
# Bubble gets the markdown stripped but emoji kept (it can't render
|
||||
# **bold** but draws emoji fine); tts.speak() does its own, stricter
|
||||
# sanitizing for the voice.
|
||||
self.said.emit(speech_text.for_display(text))
|
||||
self.log.emit(f"Bolt: {text}")
|
||||
if remember:
|
||||
self.history.add(history_mod.PET, text, time.time())
|
||||
|
||||
should_stop = None
|
||||
@@ -608,6 +729,15 @@ class PetController(QObject):
|
||||
f"Asked a question — listening for your answer "
|
||||
f"({self._follow_ups}{'/' + str(cap) if cap > 0 else ''})."
|
||||
)
|
||||
# The tail of the reply we just played is still in the mic's ring
|
||||
# buffer, and we're about to start recording with a VAD that will
|
||||
# take it for the start of your answer — Bolt's own last words,
|
||||
# transcribed and sent back to him as if you'd said them. Nothing you
|
||||
# said can be in there: playback ran to completion, so if you had
|
||||
# spoken, barge-in would have cut it and taken the other branch.
|
||||
dropped = mic.flush(self._stream)
|
||||
if dropped:
|
||||
self.log.emit(f"Dropped {dropped} buffered frames of my own voice.")
|
||||
self._pending_follow_up = True
|
||||
self._talk_now.set()
|
||||
|
||||
@@ -686,16 +816,39 @@ class PetController(QObject):
|
||||
def _queue_notification(self, notification: notifications.Notification) -> None:
|
||||
"""Called on the watcher thread — just queue it; forwarding happens on
|
||||
the pipeline thread where it can't collide with a live conversation."""
|
||||
if not self._notification_gate.should_forward(notification, time.monotonic()):
|
||||
now = time.monotonic()
|
||||
if not self._notification_gate.should_forward(notification, now):
|
||||
return
|
||||
with self._notification_lock:
|
||||
self._pending_notifications.append(notification)
|
||||
if len(self._pending_notifications) == self._pending_notifications.maxlen:
|
||||
# Say so rather than dropping in silence: a full queue means the
|
||||
# bridge is matching more than the pet can plausibly speak, and
|
||||
# the filter is what wants tightening.
|
||||
self.log.emit("Notification queue full — dropping the oldest.")
|
||||
self._pending_notifications.append((now, notification))
|
||||
|
||||
def _drain_notifications(self) -> None:
|
||||
with self._notification_lock:
|
||||
pending, self._pending_notifications = self._pending_notifications, []
|
||||
for notification in pending:
|
||||
pending = list(self._pending_notifications)
|
||||
self._pending_notifications.clear()
|
||||
|
||||
now = time.monotonic()
|
||||
max_age = config.NOTIFICATION_MAX_AGE_SECONDS
|
||||
if max_age > 0:
|
||||
fresh = [entry for entry in pending if now - entry[0] <= max_age]
|
||||
if len(fresh) != len(pending):
|
||||
self.log.emit(
|
||||
f"Skipping {len(pending) - len(fresh)} notification(s) older than "
|
||||
f"{int(max_age)}s."
|
||||
)
|
||||
pending = fresh
|
||||
|
||||
for index, (_stamped, notification) in enumerate(pending):
|
||||
if not self._running or self._napping:
|
||||
# Put back what we haven't forwarded — the old code swapped the
|
||||
# queue out and then returned, silently dropping the remainder
|
||||
# the moment a nap started mid-drain.
|
||||
self._requeue_notifications(pending[index:])
|
||||
return
|
||||
self.log.emit(f"Notification: {notification.as_text()}")
|
||||
self.history.add(history_mod.SYSTEM, notification.as_text(), time.time())
|
||||
@@ -705,7 +858,11 @@ class PetController(QObject):
|
||||
on_command=self._handle_command,
|
||||
)
|
||||
except server_client.ServerError as exc:
|
||||
# Keep this one and everything behind it for the next heartbeat:
|
||||
# the server being briefly down shouldn't silently eat the
|
||||
# backlog. The age limit is what stops that retrying forever.
|
||||
self.log.emit(f"Couldn't forward notification: {exc}")
|
||||
self._requeue_notifications(pending[index:])
|
||||
return
|
||||
self._check_deliveries()
|
||||
self._apply_voice(reply)
|
||||
@@ -713,6 +870,15 @@ class PetController(QObject):
|
||||
self._speak(reply.text)
|
||||
self._state.transition(PetState.IDLE)
|
||||
|
||||
def _requeue_notifications(self, entries: list) -> None:
|
||||
"""Push undelivered notifications back on the front, oldest first, so a
|
||||
retry keeps their original order (and their original timestamps, so a
|
||||
retry loop can't keep a stale one alive indefinitely)."""
|
||||
if not entries:
|
||||
return
|
||||
with self._notification_lock:
|
||||
self._pending_notifications.extendleft(reversed(entries))
|
||||
|
||||
# ── file delivery ────────────────────────────────────────────────────
|
||||
|
||||
def _check_deliveries(self) -> None:
|
||||
|
||||
@@ -0,0 +1,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
|
||||
@@ -19,6 +19,8 @@ Kept dependency-free beyond `requests` so it's easy to unit test with mocks.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import signal
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Callable, NamedTuple, Optional
|
||||
@@ -29,6 +31,10 @@ from . import config, sudo_askpass
|
||||
|
||||
_MAX_RELAY_HOPS = 16
|
||||
|
||||
# Command output handed back up the relay is capped: it becomes part of the
|
||||
# server's prompt, and a runaway `find /` would blow the context window.
|
||||
_MAX_COMMAND_OUTPUT = 6000
|
||||
|
||||
|
||||
class ServerError(Exception):
|
||||
"""Raised when the server responds with an error payload or unreachable."""
|
||||
@@ -74,17 +80,61 @@ def run_local_command(command: str, timeout: int = None) -> str:
|
||||
timeout = timeout or config.SUDO_COMMAND_TIMEOUT_SECONDS
|
||||
timeout = timeout or config.COMMAND_TIMEOUT_SECONDS
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
command, shell=True, capture_output=True, text=True,
|
||||
timeout=timeout, cwd=str(Path.home()), env=env,
|
||||
# start_new_session puts the shell in its own process group so a timeout
|
||||
# can kill the whole tree. subprocess.run() would only SIGKILL the `sh`
|
||||
# itself, leaving whatever it spawned (a build, a `tail -f`, an ffmpeg)
|
||||
# running forever with no parent watching — one relayed command that
|
||||
# hangs shouldn't leak a process for the rest of the session.
|
||||
process = subprocess.Popen(
|
||||
command, shell=True, cwd=str(Path.home()), env=env, text=True,
|
||||
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
||||
start_new_session=(os.name == "posix"),
|
||||
)
|
||||
output = (completed.stdout or "") + (completed.stderr or "")
|
||||
return f"[exit {completed.returncode}]\n{output}"[:6000]
|
||||
except subprocess.TimeoutExpired:
|
||||
return f"[command timed out after {timeout}s]"
|
||||
except Exception as exc:
|
||||
return f"[command failed: {exc}]"
|
||||
|
||||
try:
|
||||
stdout, stderr = process.communicate(timeout=timeout)
|
||||
return _command_output(f"[exit {process.returncode}]", stdout, stderr)
|
||||
except subprocess.TimeoutExpired:
|
||||
stdout, stderr = _terminate(process)
|
||||
# Whatever it managed to print before it hung is the useful part — a
|
||||
# bare "timed out" tells the model nothing it can act on, and the last
|
||||
# line of output usually says exactly what it was stuck waiting for.
|
||||
return _command_output(f"[command timed out after {timeout}s]", stdout, stderr)
|
||||
except Exception as exc:
|
||||
_terminate(process)
|
||||
return f"[command failed: {exc}]"
|
||||
|
||||
|
||||
def _terminate(process: subprocess.Popen) -> tuple[str, str]:
|
||||
"""Kill a timed-out command's whole process group and collect what it wrote.
|
||||
|
||||
SIGTERM first so a shell script can clean up, SIGKILL a moment later for
|
||||
anything that ignores it. The final drain is itself time-boxed: a
|
||||
grandchild holding the pipe open must not turn a timeout into a hang."""
|
||||
try:
|
||||
if os.name == "posix":
|
||||
group = os.getpgid(process.pid)
|
||||
os.killpg(group, signal.SIGTERM)
|
||||
try:
|
||||
process.wait(timeout=2)
|
||||
except subprocess.TimeoutExpired:
|
||||
os.killpg(group, signal.SIGKILL)
|
||||
else:
|
||||
process.kill()
|
||||
except (ProcessLookupError, PermissionError, OSError):
|
||||
pass # already gone, or never had its own group
|
||||
try:
|
||||
return process.communicate(timeout=2)
|
||||
except Exception:
|
||||
return "", ""
|
||||
|
||||
|
||||
def _command_output(header: str, stdout: Optional[str], stderr: Optional[str]) -> str:
|
||||
body = (stdout or "") + (stderr or "")
|
||||
return f"{header}\n{body}"[:_MAX_COMMAND_OUTPUT]
|
||||
|
||||
|
||||
def converse(
|
||||
text: str,
|
||||
@@ -132,6 +182,15 @@ def converse(
|
||||
voice_id=str(payload.get("voice_id") or ""),
|
||||
voice_name=str(payload.get("voice_name") or ""),
|
||||
)
|
||||
if payload.get("type") == "command":
|
||||
# Fell out of the loop still being handed commands. Worth its own
|
||||
# message: "unknown server response" sent everyone looking at the
|
||||
# payload shape, when what actually happened is a model that kept
|
||||
# calling tools and never answered.
|
||||
raise ServerError(
|
||||
f"the server kept relaying commands past the {_MAX_RELAY_HOPS}-hop cap "
|
||||
"without producing a reply"
|
||||
)
|
||||
raise ServerError(str(payload.get("error") or "unknown server response"))
|
||||
|
||||
|
||||
|
||||
+39
-12
@@ -67,6 +67,27 @@ _SPOKEN_SYMBOLS = {
|
||||
"=": " equals ",
|
||||
}
|
||||
|
||||
# Abbreviations a voice spells out letter by letter ("eee gee") because the
|
||||
# periods make them look like sentence boundaries. Written out instead — this
|
||||
# has to run before _UNSPEAKABLE strips anything, and the trailing \.? keeps
|
||||
# "etc" working with or without its period. Word-bounded so "vs" inside a
|
||||
# filename is left alone.
|
||||
_SPOKEN_ABBREVIATIONS = (
|
||||
(re.compile(r"\be\.g\.?(?=\s|$)", re.IGNORECASE), "for example"),
|
||||
(re.compile(r"\bi\.e\.?(?=\s|$)", re.IGNORECASE), "that is"),
|
||||
(re.compile(r"\betc\.?(?=\s|$)", re.IGNORECASE), "and so on"),
|
||||
(re.compile(r"\bvs\.?(?=\s|$)", re.IGNORECASE), "versus"),
|
||||
(re.compile(r"\baka\b", re.IGNORECASE), "also known as"),
|
||||
(re.compile(r"\bw/(?=\s)", re.IGNORECASE), "with"),
|
||||
# "PR #42" -> "PR number 42"; a bare "#" is markup and _UNSPEAKABLE drops it.
|
||||
(re.compile(r"#(?=\d)"), "number "),
|
||||
# A long option's dashes are punctuation to the eye and syllables to the ear
|
||||
# ("dash dash force"). Only the doubled form: a single hyphen has to survive
|
||||
# for "bolt-pet" and "up-to-date", and requiring a word character after it
|
||||
# keeps a "---" horizontal rule intact for _RULE to strip.
|
||||
(re.compile(r"(?<!\w)--(?=\w)"), ""),
|
||||
)
|
||||
|
||||
_MULTI_SPACE = re.compile(r"[ \t]+")
|
||||
_MULTI_PUNCT = re.compile(r"(?:\s*\.){2,}")
|
||||
|
||||
@@ -105,6 +126,10 @@ def for_speech(text: str) -> str:
|
||||
return ""
|
||||
for symbol, spoken in _PRE_SPOKEN_SYMBOLS.items():
|
||||
text = text.replace(symbol, spoken)
|
||||
# Before the markdown pass, so "#42" still has its "#" to word and a real
|
||||
# "## Heading" (no digit after the hashes) is left for _HEADING to strip.
|
||||
for pattern, spoken in _SPOKEN_ABBREVIATIONS:
|
||||
text = pattern.sub(spoken, text)
|
||||
text = _strip_markdown(text, keep_emoji=False)
|
||||
text = _URL.sub(" link ", text)
|
||||
text = _TABLE_PIPE.sub(", ", text)
|
||||
@@ -119,19 +144,21 @@ def for_speech(text: str) -> str:
|
||||
|
||||
|
||||
def is_question(text: str) -> bool:
|
||||
"""True if the reply *ends* by asking the user something — the cue for
|
||||
the pet to keep listening instead of making you say the wake word again.
|
||||
"""True if the reply asks the user anything — the cue for the pet to keep
|
||||
listening instead of making you say the wake word again.
|
||||
|
||||
Deliberately only looks at the end. A reply that asks something in
|
||||
passing ("What time is it? It's 7:15.") isn't waiting on an answer,
|
||||
whereas one that finishes on a question mark is. The test runs on the
|
||||
spoken form, so a '?' that only exists inside a stripped code block or a
|
||||
URL doesn't count, and trailing decoration (emoji, quotes, brackets) is
|
||||
peeled off first so "Ready to go? 🚀" still reads as a question."""
|
||||
spoken = for_speech(text)
|
||||
while spoken and not (spoken[-1].isalnum() or spoken[-1] == "?"):
|
||||
spoken = spoken[:-1]
|
||||
return spoken.endswith("?")
|
||||
Anywhere in the reply counts, not only the end. An earlier version required
|
||||
a *trailing* '?' on the theory that "What time is it? It's 7:15." isn't
|
||||
waiting on an answer, and that's true of that sentence but wrong far more
|
||||
often: Bolt routinely asks first and then keeps talking ("Want me to fix
|
||||
it? I'd start with the config."), and refusing to listen there is the case
|
||||
that actually costs you a wake word. The cheap failure is the other
|
||||
direction — an unwanted extra listen ends itself on `VAD_GRACE_SECONDS` of
|
||||
silence, and `FOLLOW_UP_MAX_TURNS` caps the chain.
|
||||
|
||||
The test runs on the *spoken* form, so a '?' that only exists inside a
|
||||
stripped code block, a URL, or a markdown link target doesn't count."""
|
||||
return "?" in for_speech(text)
|
||||
|
||||
|
||||
def for_display(text: str) -> str:
|
||||
|
||||
@@ -221,10 +221,13 @@ def test_a_statement_does_not_keep_listening(spoke, ctrl):
|
||||
assert not ctrl._pending_follow_up
|
||||
|
||||
|
||||
def test_a_question_in_passing_does_not_count(spoke, ctrl):
|
||||
"""Only a reply that *ends* on a question is waiting for an answer."""
|
||||
ctrl._speak("What time is it? It's 7:15 AM.")
|
||||
assert not ctrl._talk_now.is_set()
|
||||
def test_a_question_anywhere_in_the_reply_keeps_listening(spoke, ctrl):
|
||||
"""Bolt often asks and then keeps talking ("Want me to fix it? I'd start
|
||||
with the config."), so the question mark doesn't have to be last. An
|
||||
unwanted extra listen ends itself on VAD_GRACE_SECONDS of silence; a missed
|
||||
one costs you a wake word, which is the more expensive mistake."""
|
||||
ctrl._speak("Want me to restart it? It's been up for 40 days.")
|
||||
assert ctrl._talk_now.is_set()
|
||||
|
||||
|
||||
def test_follow_ups_stop_at_the_cap(spoke, monkeypatch, ctrl):
|
||||
@@ -382,6 +385,148 @@ def test_napping_still_answers_when_spoken_to(monkeypatch, ctrl):
|
||||
assert spoken == ["always"]
|
||||
|
||||
|
||||
# ── local intents ───────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.fixture
|
||||
def heard(monkeypatch):
|
||||
"""A turn where you said something, with the server and TTS recorded.
|
||||
Returns (utterance_setter, sent, spoken)."""
|
||||
said = {"text": ""}
|
||||
sent, spoken = [], []
|
||||
monkeypatch.setattr(controller_mod.mic, "record_utterance",
|
||||
lambda *a, **k: np.zeros(10, dtype=np.int16))
|
||||
monkeypatch.setattr(controller_mod.stt, "transcribe", lambda pcm: said["text"])
|
||||
monkeypatch.setattr(controller_mod.server_client, "converse",
|
||||
lambda text, on_command=None: sent.append(text) or Reply("from the server"))
|
||||
monkeypatch.setattr(controller_mod.tts, "speak",
|
||||
lambda text, on_error=None, should_stop=None, voice_id=None:
|
||||
spoken.append(text) or True)
|
||||
return said, sent, spoken
|
||||
|
||||
|
||||
def test_a_local_intent_never_reaches_the_server(heard, ctrl):
|
||||
said, sent, spoken = heard
|
||||
said["text"] = "come here"
|
||||
actions = _capture(ctrl.action)
|
||||
|
||||
ctrl._handle_conversation_turn()
|
||||
|
||||
assert sent == [] # no round trip at all
|
||||
assert actions == [{"action": "move", "anchor": "cursor"}]
|
||||
assert spoken == [] # walking over is the reply
|
||||
assert ctrl._state.state == PetState.IDLE
|
||||
|
||||
|
||||
def test_stop_is_answered_with_silence(heard, ctrl):
|
||||
said, sent, spoken = heard
|
||||
said["text"] = "be quiet"
|
||||
ctrl._handle_conversation_turn()
|
||||
assert (sent, spoken) == ([], [])
|
||||
|
||||
|
||||
def test_a_request_that_merely_starts_with_an_intent_word_goes_to_the_server(heard, ctrl):
|
||||
said, sent, spoken = heard
|
||||
said["text"] = "stop the docker container"
|
||||
ctrl._handle_conversation_turn()
|
||||
assert sent and "stop the docker container" in sent[0]
|
||||
assert spoken == ["from the server"]
|
||||
|
||||
|
||||
def test_local_intents_are_skipped_while_answering_a_question(heard, ctrl):
|
||||
"""Bolt asked something; "never mind" is an answer to him, not a body
|
||||
command. Swallowing it locally would leave the server holding a question it
|
||||
never got a reply to."""
|
||||
said, sent, spoken = heard
|
||||
said["text"] = "never mind"
|
||||
ctrl._pending_follow_up = True
|
||||
|
||||
ctrl._handle_conversation_turn()
|
||||
|
||||
assert sent and "never mind" in sent[0]
|
||||
|
||||
|
||||
def test_local_intents_can_be_turned_off(monkeypatch, heard, ctrl):
|
||||
monkeypatch.setattr(controller_mod.config, "LOCAL_INTENTS", False)
|
||||
said, sent, spoken = heard
|
||||
said["text"] = "come here"
|
||||
ctrl._handle_conversation_turn()
|
||||
assert sent and "come here" in sent[0]
|
||||
|
||||
|
||||
def test_say_that_again_replays_the_last_line_without_duplicating_history(heard, ctrl):
|
||||
said, sent, spoken = heard
|
||||
ctrl.history.add(controller_mod.history_mod.PET, "it's 7:15 AM", 0.0)
|
||||
said["text"] = "what did you say?"
|
||||
|
||||
ctrl._handle_conversation_turn()
|
||||
|
||||
assert spoken == ["it's 7:15 AM"]
|
||||
assert sent == []
|
||||
pet_lines = [e.text for e in ctrl.history.entries()
|
||||
if e.role == controller_mod.history_mod.PET]
|
||||
assert pet_lines == ["it's 7:15 AM"] # replayed, not re-recorded
|
||||
|
||||
|
||||
def test_repeat_with_nothing_to_repeat_says_so(heard, ctrl):
|
||||
said, sent, spoken = heard
|
||||
said["text"] = "say that again"
|
||||
ctrl._handle_conversation_turn()
|
||||
assert spoken == ["I haven't said anything yet."]
|
||||
|
||||
|
||||
def test_going_back_to_the_normal_voice_needs_no_server_prompt_support(heard, ctrl):
|
||||
"""The server can only offer `petctl voice reset` if its prompt happens to
|
||||
advertise the verb; recognising the phrase here works regardless."""
|
||||
said, sent, spoken = heard
|
||||
ctrl._voice_id, ctrl._voice_name = "voice-123", "Brian"
|
||||
changed = _capture(ctrl.voice_changed)
|
||||
said["text"] = "go back to your normal voice"
|
||||
|
||||
ctrl._handle_conversation_turn()
|
||||
|
||||
assert ctrl._voice_id == ""
|
||||
assert changed == [""]
|
||||
assert spoken == ["Back to my own voice."]
|
||||
assert sent == []
|
||||
|
||||
|
||||
def test_go_to_sleep_overrides_the_quiet_hours_schedule(heard, ctrl):
|
||||
said, sent, spoken = heard
|
||||
said["text"] = "go to sleep"
|
||||
|
||||
ctrl._handle_conversation_turn()
|
||||
|
||||
assert ctrl._napping is True
|
||||
assert ctrl._nap_forced is True # not undone by the next schedule check
|
||||
assert spoken == ["Night."]
|
||||
|
||||
|
||||
# ── failure containment ─────────────────────────────────────────────────────
|
||||
|
||||
def test_one_bad_turn_does_not_end_the_session(monkeypatch, ctrl):
|
||||
"""A turn raising something unforeseen used to unwind _loop and kill the
|
||||
thread — the pet would go deaf until it was restarted by hand."""
|
||||
logs = _capture(ctrl.log)
|
||||
|
||||
def explode():
|
||||
raise RuntimeError("numpy said no")
|
||||
|
||||
assert ctrl._guarded(explode, "conversation turn") is False
|
||||
assert ctrl._state.state == PetState.IDLE
|
||||
assert any("Recovered from a conversation turn failure" in m for m in logs)
|
||||
|
||||
|
||||
def test_a_command_handler_crash_is_reported_up_the_relay(monkeypatch, ctrl):
|
||||
"""The server is blocked on /desk/tool_result while this runs. Raising would
|
||||
leave it waiting out its own timeout on a turn that can never finish."""
|
||||
monkeypatch.setattr(controller_mod.pet_actions, "parse",
|
||||
lambda command: (_ for _ in ()).throw(KeyError("boom")))
|
||||
|
||||
output = ctrl._handle_command("petctl move top-left")
|
||||
|
||||
assert output.startswith("[error]") and "boom" in output
|
||||
|
||||
|
||||
# ── notification bridge ─────────────────────────────────────────────────────
|
||||
|
||||
def test_notifications_are_forwarded_and_spoken(monkeypatch, ctrl):
|
||||
@@ -404,7 +549,54 @@ def test_notifications_are_forwarded_and_spoken(monkeypatch, ctrl):
|
||||
def test_filtered_out_notifications_are_never_queued(ctrl):
|
||||
ctrl._notification_gate = controller_mod.notifications.NotificationGate("deploy", 0)
|
||||
ctrl._queue_notification(Notification(app="Chat", summary="lunch?", body=""))
|
||||
assert ctrl._pending_notifications == []
|
||||
assert not ctrl._pending_notifications
|
||||
|
||||
|
||||
def test_the_notification_queue_is_bounded(monkeypatch, ctrl):
|
||||
"""An overnight nap can't grow the queue without limit — the drain only runs
|
||||
from the heartbeat, and the heartbeat doesn't run while napping."""
|
||||
monkeypatch.setattr(controller_mod.config, "NOTIFICATION_QUEUE_LIMIT", 3)
|
||||
ctrl = controller_mod.PetController()
|
||||
ctrl._notification_gate = controller_mod.notifications.NotificationGate("", 0)
|
||||
|
||||
for index in range(10):
|
||||
ctrl._queue_notification(Notification(app="CI", summary=f"build {index}", body=""))
|
||||
|
||||
queued = [notification.summary for _stamp, notification in ctrl._pending_notifications]
|
||||
assert queued == ["build 7", "build 8", "build 9"] # oldest dropped
|
||||
|
||||
|
||||
def test_stale_notifications_are_dropped_instead_of_read_out(monkeypatch, ctrl):
|
||||
monkeypatch.setattr(controller_mod.config, "NOTIFICATION_MAX_AGE_SECONDS", 900)
|
||||
sent = []
|
||||
monkeypatch.setattr(controller_mod.server_client, "converse",
|
||||
lambda text, on_command=None: sent.append(text) or Reply(""))
|
||||
ctrl._notification_gate = controller_mod.notifications.NotificationGate("", 0)
|
||||
ctrl._queue_notification(Notification(app="CI", summary="fresh", body=""))
|
||||
# Backdate it past the age limit, as an overnight backlog would be.
|
||||
stamp, notification = ctrl._pending_notifications.pop()
|
||||
ctrl._pending_notifications.append((stamp - 4000, notification))
|
||||
|
||||
ctrl._drain_notifications()
|
||||
|
||||
assert sent == []
|
||||
|
||||
|
||||
def test_a_nap_starting_mid_drain_keeps_the_rest_queued(monkeypatch, ctrl):
|
||||
"""The old code swapped the queue out and returned, losing the remainder."""
|
||||
def converse(text, on_command=None):
|
||||
ctrl._napping = True # e.g. quiet hours began, or a fullscreen app opened
|
||||
return Reply("")
|
||||
|
||||
monkeypatch.setattr(controller_mod.server_client, "converse", converse)
|
||||
ctrl._notification_gate = controller_mod.notifications.NotificationGate("", 0)
|
||||
for index in range(3):
|
||||
ctrl._queue_notification(Notification(app="CI", summary=f"build {index}", body=""))
|
||||
|
||||
ctrl._drain_notifications()
|
||||
|
||||
remaining = [notification.summary for _stamp, notification in ctrl._pending_notifications]
|
||||
assert remaining == ["build 1", "build 2"]
|
||||
|
||||
|
||||
def test_notifications_are_not_forwarded_while_napping(monkeypatch, ctrl):
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
"""Local intent recognition — pure string logic, no hardware or display.
|
||||
|
||||
The interesting tests are the negative ones: this feature's whole risk is
|
||||
swallowing something that was meant for the server.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from bolt_pet import intents
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("said, expected", [
|
||||
("stop", "stop"),
|
||||
("Stop!", "stop"),
|
||||
("never mind", "stop"),
|
||||
("be quiet", "stop"),
|
||||
("go to sleep", "nap"),
|
||||
("take a nap", "nap"),
|
||||
("goodnight", "nap"),
|
||||
("wake up", "wake"),
|
||||
("come here", "come"),
|
||||
("follow my cursor", "come"),
|
||||
("get out of the way", "go_away"),
|
||||
("hide", "go_away"),
|
||||
("say that again", "repeat"),
|
||||
("what did you say?", "repeat"),
|
||||
("go for a walk", "wander_on"),
|
||||
("stay put", "wander_off"),
|
||||
("sit", "wander_off"),
|
||||
("use your normal voice", "voice_reset"),
|
||||
("go back to your normal voice", "voice_reset"),
|
||||
("be yourself again", "voice_reset"),
|
||||
])
|
||||
def test_recognized_phrases(said, expected):
|
||||
intent = intents.recognize(said)
|
||||
assert intent is not None and intent.name == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize("said", [
|
||||
# Each of these starts with (or contains) an intent phrase, and every one is
|
||||
# a real request. A substring match would eat all of them.
|
||||
"stop the docker container",
|
||||
"stop the deploy and tell me what broke",
|
||||
"can you hide the window that's covering my terminal",
|
||||
"come up with a name for this branch",
|
||||
"repeat the last command but with sudo",
|
||||
"what did you say the disk usage was on the server",
|
||||
"move the config file to the backup directory",
|
||||
"sit down and write me a haiku about kubernetes",
|
||||
"what time is it",
|
||||
"go to sleep mode on the server",
|
||||
"",
|
||||
" ",
|
||||
# Pure filler leaves an empty string, which must not match anything.
|
||||
"hey bolt",
|
||||
"okay bolt please",
|
||||
])
|
||||
def test_real_requests_are_left_for_the_server(said):
|
||||
assert intents.recognize(said) is None
|
||||
|
||||
|
||||
def test_filler_is_stripped_from_both_ends():
|
||||
assert intents.normalize("Hey Bolt, could you please just stop now?") == "stop"
|
||||
assert intents.normalize("okay, come here buddy") == "come here"
|
||||
|
||||
|
||||
def test_normalize_returns_empty_for_pure_filler():
|
||||
assert intents.normalize("hey bolt") == ""
|
||||
assert intents.normalize("...") == ""
|
||||
|
||||
|
||||
def test_intents_carry_ui_actions_in_the_pet_actions_shape():
|
||||
"""The action dicts go straight to PetWindow.apply_action, so they have to
|
||||
match the vocabulary pet_actions.parse produces — no new UI cases."""
|
||||
assert intents.recognize("come here").action == {"action": "move", "anchor": "cursor"}
|
||||
assert intents.recognize("stay put").action == {"action": "wander", "enabled": False}
|
||||
assert intents.recognize("go to sleep").action == {"action": "nap", "enabled": True}
|
||||
|
||||
|
||||
def test_stop_says_nothing():
|
||||
"""Answering "okay!" when told to be quiet defeats the purpose."""
|
||||
intent = intents.recognize("be quiet")
|
||||
assert intent.speak == "" and intent.action is None
|
||||
|
||||
|
||||
def test_a_phrase_claimed_by_two_intents_fails_at_import(monkeypatch):
|
||||
"""Without this guard the phrase would silently bind to whichever intent was
|
||||
declared last — a table edit that looks fine and misbehaves on a mic."""
|
||||
monkeypatch.setattr(intents, "_TABLE", (
|
||||
(intents.Intent("stop"), ("enough",)),
|
||||
(intents.Intent("nap"), ("enough",)),
|
||||
))
|
||||
with pytest.raises(ValueError, match="claimed by both"):
|
||||
intents._build()
|
||||
|
||||
|
||||
def test_a_phrase_of_pure_filler_fails_at_import(monkeypatch):
|
||||
"""It would normalise to "" and then match any all-filler utterance."""
|
||||
monkeypatch.setattr(intents, "_TABLE", ((intents.Intent("stop"), ("please bolt",)),))
|
||||
with pytest.raises(ValueError, match="normalises to nothing"):
|
||||
intents._build()
|
||||
|
||||
|
||||
def test_every_table_phrase_round_trips():
|
||||
for phrase, intent in intents._BY_PHRASE.items():
|
||||
assert phrase, "a phrase normalised to nothing"
|
||||
assert intents.recognize(phrase) is intent
|
||||
@@ -112,3 +112,51 @@ def test_pcm_to_wav_bytes_round_trips_via_wave_module():
|
||||
assert wf.getframerate() == 16000
|
||||
frames = wf.readframes(wf.getnframes())
|
||||
assert np.frombuffer(frames, dtype=np.int16).tolist() == pcm.tolist()
|
||||
|
||||
|
||||
# ── flushing buffered audio ─────────────────────────────────────────────────
|
||||
|
||||
class _BufferedStream:
|
||||
"""A stream with a backlog, like PortAudio's ring buffer after the reader
|
||||
was blocked on a network call for a while."""
|
||||
|
||||
def __init__(self, available):
|
||||
self.read_available = available
|
||||
self.reads = []
|
||||
|
||||
def read(self, frames):
|
||||
self.reads.append(frames)
|
||||
self.read_available = max(0, self.read_available - frames)
|
||||
return np.zeros((frames, 1), dtype=np.int16), False
|
||||
|
||||
|
||||
def test_flush_drops_exactly_what_was_buffered():
|
||||
stream = _BufferedStream(4096)
|
||||
assert mic.flush(stream) == 4096
|
||||
assert stream.reads == [4096]
|
||||
assert stream.read_available == 0
|
||||
|
||||
|
||||
def test_flush_is_bounded_so_it_cannot_chase_a_live_stream():
|
||||
"""A stream filling as fast as it drains must not spin forever."""
|
||||
stream = _BufferedStream(10 ** 9)
|
||||
dropped = mic.flush(stream, max_seconds=1.0, sample_rate=16000)
|
||||
assert dropped == 16000
|
||||
|
||||
|
||||
def test_flush_is_a_noop_on_an_empty_or_fake_stream():
|
||||
stream = _BufferedStream(0)
|
||||
assert mic.flush(stream) == 0
|
||||
assert stream.reads == []
|
||||
assert mic.flush(_ScriptedStream([])) == 0 # no read_available at all
|
||||
assert mic.flush(None) == 0
|
||||
|
||||
|
||||
def test_flush_swallows_a_device_error():
|
||||
class _Broken:
|
||||
read_available = 1024
|
||||
|
||||
def read(self, frames):
|
||||
raise RuntimeError("device disappeared")
|
||||
|
||||
assert mic.flush(_Broken()) == 0
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
@@ -142,3 +144,52 @@ def test_download_outbox_file_raises_server_error_on_http_failure():
|
||||
get.return_value = _mock_response({}, ok=False)
|
||||
with pytest.raises(server_client.ServerError, match="abc"):
|
||||
server_client.download_outbox_file("abc")
|
||||
|
||||
|
||||
def test_converse_reports_a_relay_that_never_produced_a_reply():
|
||||
"""Hitting the hop cap used to surface as "unknown server response", which
|
||||
sent everyone looking at the payload shape instead of at a model that kept
|
||||
calling tools and never answered."""
|
||||
command = {"type": "command", "command": "echo hi", "token": "t"}
|
||||
with patch.object(server_client.requests, "post") as post:
|
||||
post.return_value = _mock_response(command)
|
||||
with pytest.raises(server_client.ServerError, match="hop cap"):
|
||||
server_client.converse("hi", on_command=lambda cmd: "ok")
|
||||
|
||||
|
||||
# ── relayed shell commands ──────────────────────────────────────────────────
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _no_sudo_prompt(monkeypatch):
|
||||
monkeypatch.setattr(server_client.config, "SUDO_ASKPASS_PROMPT", False)
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.name != "posix", reason="POSIX shell and process groups")
|
||||
def test_a_successful_command_returns_its_output_and_exit_code():
|
||||
output = server_client.run_local_command("echo hello; exit 3")
|
||||
assert output.startswith("[exit 3]")
|
||||
assert "hello" in output
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.name != "posix", reason="POSIX shell and process groups")
|
||||
def test_a_timed_out_command_still_reports_what_it_printed():
|
||||
"""A bare "timed out" tells the model nothing; the last line of output
|
||||
usually says exactly what it was stuck waiting for."""
|
||||
output = server_client.run_local_command("echo working on it; sleep 30", timeout=1)
|
||||
assert "timed out after 1s" in output
|
||||
assert "working on it" in output
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.name != "posix", reason="POSIX shell and process groups")
|
||||
def test_a_timed_out_command_takes_its_children_with_it(tmp_path):
|
||||
"""subprocess.run() would only kill the `sh`, leaving whatever it spawned
|
||||
running for the rest of the session with no parent watching."""
|
||||
marker = tmp_path / "ticks"
|
||||
server_client.run_local_command(
|
||||
f"(while true; do echo tick >> {marker}; sleep 0.05; done) & sleep 30",
|
||||
timeout=1,
|
||||
)
|
||||
settled = marker.stat().st_size if marker.exists() else 0
|
||||
time.sleep(0.4)
|
||||
grew = (marker.stat().st_size if marker.exists() else 0) - settled
|
||||
assert grew == 0, "a grandchild survived the timeout and is still writing"
|
||||
|
||||
@@ -59,11 +59,12 @@ def test_display_keeps_emoji_but_drops_markdown():
|
||||
assert for_display("* one\n* two") == "• one • two"
|
||||
|
||||
|
||||
def test_is_question_only_fires_on_a_trailing_question():
|
||||
def test_is_question_fires_when_a_question_mark_appears_anywhere():
|
||||
assert is_question("Ready to run a command or start a project?")
|
||||
assert is_question("It's 7:15 AM. Want me to set a timer?")
|
||||
assert is_question("What time is it? It's 7:15 AM.")
|
||||
assert is_question("Can you help me with this? I need a quick answer.")
|
||||
assert not is_question("It's 7:15 AM on July 23, 2026.")
|
||||
assert not is_question("What time is it? It's 7:15 AM.") # asked in passing
|
||||
|
||||
|
||||
def test_is_question_ignores_trailing_decoration():
|
||||
@@ -77,3 +78,29 @@ def test_is_question_ignores_question_marks_that_are_not_spoken():
|
||||
assert not is_question("Docs are at https://example.com/x?y=1")
|
||||
assert not is_question("")
|
||||
assert not is_question(None)
|
||||
|
||||
|
||||
def test_abbreviations_are_worded_instead_of_spelled_out():
|
||||
"""The periods make these look like sentence boundaries, so the voice reads
|
||||
them letter by letter ("eee gee")."""
|
||||
assert for_speech("Use a flag, e.g. --force") == "Use a flag, for example force"
|
||||
assert for_speech("i.e. the config file") == "that is the config file"
|
||||
assert for_speech("logs, configs, etc.") == "logs, configs, and so on"
|
||||
assert for_speech("docker vs. podman") == "docker versus podman"
|
||||
assert for_speech("Fixed in PR #42") == "Fixed in PR number 42"
|
||||
|
||||
|
||||
def test_abbreviation_wording_is_word_bounded():
|
||||
""""vs" inside a word or filename isn't an abbreviation."""
|
||||
assert "versus" not in for_speech("the vscode window")
|
||||
assert "versus" not in for_speech("revs per minute")
|
||||
# A markdown heading has no digit after the hashes, so it's still a heading.
|
||||
assert for_speech("## Results") == "Results"
|
||||
|
||||
|
||||
def test_long_option_dashes_are_dropped_but_hyphens_survive():
|
||||
assert for_speech("run it with --force") == "run it with force"
|
||||
assert for_speech("check bolt-pet is up-to-date") == "check bolt-pet is up-to-date"
|
||||
# The rule line is gone; the full stops are _bullets_to_sentences giving the
|
||||
# voice a pause where the eye saw a line break.
|
||||
assert for_speech("one\n---\ntwo") == "one. two."
|
||||
|
||||
Reference in New Issue
Block a user