Add heartbeat: reminders and delivered files reach Bolt Assistant
The client never called /desk/report_status, so Bolt's reminders and any files he delivered to this session sat in the server's outbox forever - the same dead-air failure the pet client showed when its heartbeat stopped. Now a heartbeat thread (HEARTBEAT_SECONDS, default 60; 0 disables) reports in, speaks announcements through the assistant's queue when the mic is idle, and downloads the outbox to BOLT_DOWNLOAD_DIR (default ~/Downloads/Bolt) via .part-then-rename, never overwriting. A startup health probe warns loudly when the server refuses the DESK_API_KEY. Also: add the .env.example the README's setup step references (it was missing, so `cp .env.example .env` failed for everyone), and gitignore .claude/ so Claude Code's worktree directory stays out of the repo. Tests: tests/test_heartbeat.py (12) plus announcement tests in tests/test_assistant.py (6); full suite 135 passed. Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Code
parent
a8c7db9520
commit
751d1c8b69
@@ -20,6 +20,10 @@ background thread, so the mic keeps being segmented meanwhile and whatever
|
||||
is said next becomes its own utterance and is sent off too. Finished
|
||||
transcripts are delivered oldest-first between mic frames.
|
||||
|
||||
Announcements (Bolt's reminders, delivered-file notices) ride the heartbeat
|
||||
thread and wait in a queue; the run loop speaks them one at a time whenever
|
||||
it is idle, then flushes the echo of Bolt's own voice like after a reply.
|
||||
|
||||
Everything that touches hardware or the network is injected, so the flow
|
||||
is tested with fakes; __main__ wires in the real mic, server and speaker.
|
||||
"""
|
||||
@@ -115,6 +119,7 @@ class Assistant:
|
||||
self._mute_until = 0.0
|
||||
self._voice = "" # a voice the server picked (speak_as) sticks for later replies
|
||||
self._tts_failed = False
|
||||
self._announcements: "queue.Queue[str]" = queue.Queue() # heartbeat notices, spoken when idle
|
||||
|
||||
# ── audio in ─────────────────────────────────────────────────────────
|
||||
|
||||
@@ -135,6 +140,7 @@ class Assistant:
|
||||
else:
|
||||
self.process(frame)
|
||||
self.handle_transcripts()
|
||||
self._drain_announcements()
|
||||
|
||||
def listening_banner(self) -> str:
|
||||
if not self.wake_required:
|
||||
@@ -208,6 +214,29 @@ class Assistant:
|
||||
self.log(" (stopped waiting)")
|
||||
self.state = "idle"
|
||||
|
||||
# ── heartbeat announcements ──────────────────────────────────────────
|
||||
|
||||
def announce(self, text: str) -> None:
|
||||
"""Queue something to say once the room is free — a reminder Bolt
|
||||
fired, a delivered-file notice. Never interrupts: it waits for idle."""
|
||||
text = (text or "").strip()
|
||||
if text:
|
||||
self._announcements.put(text)
|
||||
|
||||
def _drain_announcements(self) -> None:
|
||||
"""Say what the heartbeat brought, one at a time, while idle — never
|
||||
mid-sentence, while a transcript is still in flight, or during the
|
||||
echo guard after Bolt's own voice."""
|
||||
if (self.state != "idle" or self._pending or self.segmenter.in_speech
|
||||
or self.clock() < self._mute_until):
|
||||
return
|
||||
try:
|
||||
text = self._announcements.get_nowait()
|
||||
except queue.Empty:
|
||||
return
|
||||
self.speak(text)
|
||||
self._back_to_listening("idle") # flush the mic's echo of Bolt's own voice
|
||||
|
||||
# ── what was said ────────────────────────────────────────────────────
|
||||
|
||||
def heard(self, text: str) -> None:
|
||||
|
||||
Reference in New Issue
Block a user