Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3ee67cb4d6 | |||
| 8d4751d80f |
@@ -89,7 +89,10 @@
|
|||||||
"Bash(dig +short themajesticnetwork.com)",
|
"Bash(dig +short themajesticnetwork.com)",
|
||||||
"Bash(dig +short api.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(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_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
|
||||||
@@ -181,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
|
||||||
|
|||||||
@@ -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
|
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
|
||||||
@@ -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
|
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. A final reply is
|
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,
|
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
|
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()`
|
||||||
@@ -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
|
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
|
||||||
@@ -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
|
`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
|
||||||
@@ -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
|
`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
|
||||||
@@ -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
|
`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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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_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 ───────────────────────────────────────────────────
|
||||||
@@ -221,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)
|
||||||
|
|||||||
+181
-15
@@ -15,15 +15,16 @@ 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, dialogue as dialogue_mod, file_delivery, file_ops,
|
config, dialogue as dialogue_mod, file_delivery, file_ops,
|
||||||
history as history_mod, monitors as monitors_mod, notifications,
|
history as history_mod, intents as intents_mod, monitors as monitors_mod,
|
||||||
pet_actions, quiet, screen_context, screen_text, self_restart,
|
notifications, pet_actions, quiet, screen_context, screen_text,
|
||||||
server_client, speech_text, updater,
|
self_restart, server_client, speech_text, updater,
|
||||||
)
|
)
|
||||||
from . import __version__
|
from . import __version__
|
||||||
from .audio import barge_in, mic, stt, tts, wake_word
|
from .audio import barge_in, mic, stt, tts, wake_word
|
||||||
@@ -98,7 +99,14 @@ class PetController(QObject):
|
|||||||
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) ─────────
|
||||||
@@ -187,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()
|
||||||
@@ -194,12 +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._report_self_restart()
|
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:
|
||||||
@@ -214,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
|
||||||
@@ -226,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:
|
||||||
@@ -275,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.
|
||||||
@@ -293,6 +342,60 @@ class PetController(QObject):
|
|||||||
self._state.transition(PetState.IDLE)
|
self._state.transition(PetState.IDLE)
|
||||||
self._maybe_self_restart()
|
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
|
||||||
focused window title, and a one-line note about the screen layout so
|
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)
|
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)
|
||||||
@@ -568,13 +688,14 @@ class PetController(QObject):
|
|||||||
elif not config.VOICE_STICKY:
|
elif not config.VOICE_STICKY:
|
||||||
self.reset_voice()
|
self.reset_voice()
|
||||||
|
|
||||||
def _speak(self, text: str) -> None:
|
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
|
||||||
@@ -608,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()
|
||||||
|
|
||||||
@@ -686,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())
|
||||||
@@ -705,7 +858,11 @@ 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()
|
||||||
self._apply_voice(reply)
|
self._apply_voice(reply)
|
||||||
@@ -713,6 +870,15 @@ class PetController(QObject):
|
|||||||
self._speak(reply.text)
|
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,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
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import signal
|
||||||
import subprocess
|
import subprocess
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Callable, NamedTuple, Optional
|
from typing import Callable, NamedTuple, Optional
|
||||||
@@ -29,6 +31,10 @@ 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."""
|
||||||
@@ -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.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,
|
||||||
@@ -132,6 +182,15 @@ def converse(
|
|||||||
voice_id=str(payload.get("voice_id") or ""),
|
voice_id=str(payload.get("voice_id") or ""),
|
||||||
voice_name=str(payload.get("voice_name") 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:
|
||||||
|
|||||||
@@ -221,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):
|
||||||
@@ -382,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):
|
||||||
@@ -404,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):
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
@@ -142,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."
|
||||||
|
|||||||
Reference in New Issue
Block a user