This commit is contained in:
2026-07-23 07:55:43 -06:00
parent 843f52c507
commit e9d92b0ba2
14 changed files with 657 additions and 11 deletions
+8 -1
View File
@@ -44,6 +44,7 @@ def record_utterance(
silence_end_sec: float = None,
max_utterance_s: float = None,
min_utterance_s: float = None,
grace_s: float = None,
frame_len: int = config.FRAME_LEN,
sample_rate: int = config.SAMPLE_RATE,
) -> Optional[np.ndarray]:
@@ -53,18 +54,24 @@ def record_utterance(
*should_continue* is polled each frame so a caller can cancel recording
(e.g. the pet window was closed) without needing threading primitives
baked into this function.
*grace_s* is how long to wait for speech to *begin* before giving up.
The controller stretches it for follow-up questions, where you're being
asked something and need a moment to think rather than having just said
the wake word on purpose.
"""
rms_threshold = config.RMS_THRESHOLD if rms_threshold is None else rms_threshold
silence_end_sec = config.SILENCE_END_SEC if silence_end_sec is None else silence_end_sec
max_utterance_s = config.MAX_UTTERANCE_S if max_utterance_s is None else max_utterance_s
min_utterance_s = config.MIN_UTTERANCE_S if min_utterance_s is None else min_utterance_s
grace_s = config.GRACE_SECONDS if grace_s is None else grace_s
frames: list[np.ndarray] = []
started = False
silence_frames = 0
silence_limit = int(silence_end_sec * sample_rate / frame_len)
max_frames = int(max_utterance_s * sample_rate / frame_len)
grace_frames = int(4.0 * sample_rate / frame_len) # wait up to 4s for speech to begin
grace_frames = int(grace_s * sample_rate / frame_len) # how long to wait for speech to begin
waited = 0
while should_continue():
+28
View File
@@ -78,8 +78,36 @@ RMS_THRESHOLD = int(os.environ.get("VAD_RMS_THRESHOLD", "300"))
SILENCE_END_SEC = float(os.environ.get("VAD_SILENCE_END_SEC", "1.2"))
MAX_UTTERANCE_S = float(os.environ.get("VAD_MAX_UTTERANCE_SECONDS", "15"))
MIN_UTTERANCE_S = float(os.environ.get("VAD_MIN_UTTERANCE_SECONDS", "0.4"))
# How long to wait for you to *start* talking before giving up on a turn.
GRACE_SECONDS = float(os.environ.get("VAD_GRACE_SECONDS", "4"))
# ── follow-up listening ─────────────────────────────────────────────────────
# When a reply ends on a question, the pet keeps listening for the answer
# instead of dropping back to idle and making you say the wake word again.
# The grace period is longer than a normal turn's because you were asked
# something and may need a beat to think. FOLLOW_UP_MAX_TURNS caps how many
# question-and-answer rounds can chain without you re-triggering it — a stop
# on runaway loops if the server ends every reply with a question and the mic
# keeps feeding it noise. 0 means no cap.
FOLLOW_UP_LISTEN = os.environ.get("FOLLOW_UP_LISTEN", "true").lower() in ("1", "true", "yes", "on")
FOLLOW_UP_MAX_TURNS = int(os.environ.get("FOLLOW_UP_MAX_TURNS", "3"))
FOLLOW_UP_GRACE_SECONDS = float(os.environ.get("FOLLOW_UP_GRACE_SECONDS", "7"))
COMMAND_TIMEOUT_SECONDS = int(os.environ.get("COMMAND_TIMEOUT_SECONDS", "30"))
# ── sudo password prompts ───────────────────────────────────────────────────
# The pet has no terminal, so a relayed `sudo` would block on a tty nobody is
# watching. With this on, bare `sudo` is rewritten to `sudo -A` and the
# password is collected in a desktop dialog (a real askpass binary if one is
# installed, otherwise a generated zenity/kdialog wrapper). Turn it off and
# sudo commands simply fail, which is the safer default if you'd rather Bolt
# never be able to ask for root at all.
SUDO_ASKPASS_PROMPT = os.environ.get("SUDO_ASKPASS_PROMPT", "true").lower() in ("1", "true", "yes", "on")
SUDO_ASKPASS_HELPER = os.environ.get("SUDO_ASKPASS_HELPER", "") # blank = auto-detect
# Longer than COMMAND_TIMEOUT_SECONDS because a person has to notice the
# dialog, read it, and type — 30s is nowhere near enough for that.
SUDO_COMMAND_TIMEOUT_SECONDS = int(os.environ.get("SUDO_COMMAND_TIMEOUT_SECONDS", "180"))
HEARTBEAT_INTERVAL_SECONDS = float(os.environ.get("HEARTBEAT_INTERVAL_SECONDS", "60"))
# ── barge-in (interrupt playback while the pet is talking) ──────────────────
+52 -2
View File
@@ -63,6 +63,13 @@ class PetController(QObject):
self._nap_forced: Optional[bool] = None # petctl nap on/off overrides the schedule
self._last_nap_check = 0.0
# When a reply ends on a question the pet keeps listening for the
# answer. _follow_ups counts how many have chained without you
# re-triggering, so a server that ends every reply with "?" can't
# loop forever off mic noise.
self._pending_follow_up = False
self._follow_ups = 0
self._last_update_check = 0.0
self._update_pending = False # applied on disk, waiting for the restart
@@ -200,9 +207,21 @@ class PetController(QObject):
self._near_misses.observe(score, threshold, time.time())
def _handle_conversation_turn(self) -> None:
# A turn you started yourself ends any follow-up chain in progress.
following_up, self._pending_follow_up = self._pending_follow_up, False
if not following_up:
self._follow_ups = 0
self._state.transition(PetState.LISTENING)
pcm = mic.record_utterance(self._stream, should_continue=self._should_continue)
pcm = mic.record_utterance(
self._stream,
should_continue=self._should_continue,
# Answering a question deserves longer than saying the wake word
# on purpose does — you were just asked something.
grace_s=config.FOLLOW_UP_GRACE_SECONDS if following_up else None,
)
if pcm is None:
self._follow_ups = 0 # silence ends the chain
self._state.transition(PetState.IDLE)
return
@@ -270,6 +289,9 @@ class PetController(QObject):
on_error=lambda exc: self.log.emit(f"TTS failed: {exc}"),
should_stop=should_stop,
)
# Read the scoring history *before* resetting, or the log reports the
# blank counters instead of what actually fired.
detail = self._barge_in_detail()
if self._barge_in is not None:
# Playback fed the pet's own voice into the wake model's rolling
# window. Clear it before the idle listener starts scoring again,
@@ -278,8 +300,36 @@ class PetController(QObject):
if not completed:
# You talked over it — take that as the start of the next turn
# rather than making you say the wake word again.
self.log.emit(f"Interrupted — listening. {self._barge_in_detail()}")
self.log.emit(f"Interrupted — listening. {detail}")
self._follow_ups = 0 # you're clearly engaged; start the count over
self._talk_now.set()
elif self._should_follow_up(text):
self._follow_ups += 1
cap = config.FOLLOW_UP_MAX_TURNS
self.log.emit(
f"Asked a question — listening for your answer "
f"({self._follow_ups}{'/' + str(cap) if cap > 0 else ''})."
)
self._pending_follow_up = True
self._talk_now.set()
def _should_follow_up(self, text: str) -> bool:
"""Whether *text* leaves the pet waiting on an answer.
Muted is excluded because mute means "don't listen to me" — an
automatic turn would walk straight past it. Napping isn't: quiet
hours suppress the pet *starting* something, and a question is only
ever asked in reply to you."""
if not config.FOLLOW_UP_LISTEN or self._muted:
return False
if not speech_text.is_question(text):
return False
# Only worth mentioning the cap on a reply that would otherwise have
# kept listening, or it fires on every statement the pet makes.
if config.FOLLOW_UP_MAX_TURNS > 0 and self._follow_ups >= config.FOLLOW_UP_MAX_TURNS:
self.log.emit("Follow-up limit reached — say the wake word to keep going.")
return False
return True
def _barge_in_detail(self) -> str:
"""Why the interruption fired, for the log. How far into playback it
+16 -3
View File
@@ -20,7 +20,7 @@ from typing import Callable, Optional
import requests
from . import config
from . import config, sudo_askpass
_MAX_RELAY_HOPS = 16
@@ -42,12 +42,25 @@ def check_health(timeout: float = 10.0) -> dict:
def run_local_command(command: str, timeout: int = None) -> str:
"""Execute a command relayed by the server, exactly as bolt_desk.py does —
"full desktop control" for things like "open firefox" or "how full is my
disk". Runs as the current desktop user. See README security notes."""
disk". Runs as the current desktop user. See README security notes.
`sudo` gets special handling: the pet has no terminal, so sudo would sit
waiting on a tty that nobody is looking at. With SUDO_ASKPASS_PROMPT on,
bare `sudo` becomes `sudo -A` and the password is collected in a desktop
dialog you have to answer — which also gives those commands a longer
timeout, since a human has to notice the window and type."""
env = None
if config.SUDO_ASKPASS_PROMPT and sudo_askpass.needs_password_prompt(command):
helper = sudo_askpass.find_helper()
if helper:
env = sudo_askpass.environment(helper)
command = sudo_askpass.add_askpass_flag(command)
timeout = timeout or config.SUDO_COMMAND_TIMEOUT_SECONDS
timeout = timeout or config.COMMAND_TIMEOUT_SECONDS
try:
completed = subprocess.run(
command, shell=True, capture_output=True, text=True,
timeout=timeout, cwd=str(Path.home()),
timeout=timeout, cwd=str(Path.home()), env=env,
)
output = (completed.stdout or "") + (completed.stderr or "")
return f"[exit {completed.returncode}]\n{output}"[:6000]
+16
View File
@@ -118,6 +118,22 @@ def for_speech(text: str) -> str:
return text.strip()
def is_question(text: str) -> bool:
"""True if the reply *ends* by asking the user something — the cue for
the pet to keep listening instead of making you say the wake word again.
Deliberately only looks at the end. A reply that asks something in
passing ("What time is it? It's 7:15.") isn't waiting on an answer,
whereas one that finishes on a question mark is. The test runs on the
spoken form, so a '?' that only exists inside a stripped code block or a
URL doesn't count, and trailing decoration (emoji, quotes, brackets) is
peeled off first so "Ready to go? 🚀" still reads as a question."""
spoken = for_speech(text)
while spoken and not (spoken[-1].isalnum() or spoken[-1] == "?"):
spoken = spoken[:-1]
return spoken.endswith("?")
def for_display(text: str) -> str:
"""What the speech bubble shows: markdown syntax removed (the bubble
can't render it) but emoji and layout-ish punctuation left alone."""
+145
View File
@@ -0,0 +1,145 @@
"""Graphical password prompts for server-relayed `sudo` commands.
The pet has no terminal. When the server relays something like
`sudo apt update`, sudo tries to read a password from a tty, finds none (or
finds the terminal the pet was launched from, which you're not looking at),
and the command fails with no way to answer it.
sudo's own answer to this is SUDO_ASKPASS: with `-A`, it runs a helper
program and reads the password from the helper's stdout instead of a tty.
Any GUI prompt that prints what was typed works, so this module finds a real
askpass binary if one is installed and otherwise generates a one-line wrapper
around zenity/kdialog, which every desktop has one of.
Worth being clear about what this changes: the prompt is a *feature*, not
just plumbing. Server-relayed commands already run as your desktop user (see
server_client.run_local_command); this lets them ask to run as root, and the
dialog is the only thing standing between "Bolt decided to run sudo" and it
happening. Leave SUDO_ASKPASS_PROMPT on, and read the dialogs.
The parts that decide *what* to run are pure functions so they're tested
without a display, a password, or a working sudo.
"""
from __future__ import annotations
import os
import re
import shutil
import stat
from pathlib import Path
from typing import Callable, Optional
from . import config
# Real askpass binaries, in preference order. These are purpose-built for
# this (they grab the keyboard, hide the input, and don't leave the password
# in a process argument), so they win over a generated wrapper.
_KNOWN_HELPERS = (
"/usr/bin/ssh-askpass",
"/usr/lib/ssh/ssh-askpass",
"/usr/lib/openssh/gnome-ssh-askpass3",
"/usr/lib/openssh/gnome-ssh-askpass",
"/usr/libexec/openssh/ssh-askpass",
"/usr/bin/ksshaskpass",
"/usr/bin/lxqt-openssh-askpass",
)
# Dialog tools we can wrap when no askpass binary exists. Each must print the
# typed password to stdout and nothing else.
_WRAPPABLE = {
"zenity": '{tool} --password --title="Bolt" --text="$1" 2>/dev/null',
"kdialog": '{tool} --password "$1" --title "Bolt" 2>/dev/null',
}
# `sudo` at the start of the command or right after a shell separator, not
# already carrying a flag. Deliberately conservative: a `sudo` inside a
# quoted string or a heredoc is left alone, because rewriting it could change
# what the command means.
_SUDO = re.compile(r"(^|[;&|]\s*|\n\s*)(sudo)(\s+)(?!-)")
def add_askpass_flag(command: str) -> str:
"""Insert `-A` after each bare `sudo`, so it prompts through the helper
instead of a tty. Commands that already pass a flag (`sudo -n`, `sudo -A`,
`sudo -u bob`) are left exactly as they are — the caller was explicit."""
return _SUDO.sub(r"\1\2 -A\3", command or "")
def needs_password_prompt(command: str) -> bool:
"""True if *command* has a `sudo` that might sit waiting on a dialog.
Used to give those commands a longer timeout — 30 seconds is fine for a
shell command and nowhere near enough for a human to notice a window,
read it, and type a password."""
return bool(_SUDO.search(command or ""))
def helper_script(tool_path: str) -> str:
"""The wrapper script for a dialog *tool_path*. sudo passes its prompt
("[sudo] password for maji:") as $1, which is worth showing — it names
the user the password is for."""
name = Path(tool_path).name
body = _WRAPPABLE[name].format(tool=tool_path)
return f"#!/bin/sh\n# Generated by bolt-pet. Prints the typed password on stdout for sudo -A.\n{body}\n"
def _default_cache_dir() -> Path:
base = os.environ.get("XDG_CACHE_HOME") or (Path.home() / ".cache")
return Path(base) / "bolt-pet"
def find_helper(
configured: str = None,
is_executable: Callable[[str], bool] = None,
which: Callable[[str], Optional[str]] = None,
cache_dir: Path = None,
write: bool = True,
) -> Optional[str]:
"""Path to an askpass helper, or None if the desktop has nothing we can
use. Order: whatever SUDO_ASKPASS_HELPER names, then a real askpass
binary, then a generated wrapper around zenity/kdialog.
The lookups are injectable so the resolution order is testable on a box
with a different set of these installed than yours."""
configured = config.SUDO_ASKPASS_HELPER if configured is None else configured
is_executable = is_executable or (lambda path: os.path.isfile(path) and os.access(path, os.X_OK))
which = which or shutil.which
if configured:
return configured if is_executable(configured) else None
for candidate in _KNOWN_HELPERS:
if is_executable(candidate):
return candidate
for tool in _WRAPPABLE:
tool_path = which(tool)
if not tool_path:
continue
if not write:
return tool_path
return _write_wrapper(tool_path, cache_dir or _default_cache_dir())
return None
def _write_wrapper(tool_path: str, cache_dir: Path) -> Optional[str]:
"""Drop the wrapper script somewhere sudo can execute it. Mode 0700: it
isn't secret, but it's a thing that pops up a password box, so nobody
else on the machine gets to edit it."""
try:
cache_dir.mkdir(parents=True, exist_ok=True)
script = cache_dir / "askpass.sh"
source = helper_script(tool_path)
if not script.exists() or script.read_text(encoding="utf-8") != source:
script.write_text(source, encoding="utf-8")
script.chmod(stat.S_IRWXU)
return str(script)
except Exception:
return None # no prompt is better than a crashed command relay
def environment(helper: str, base: dict = None) -> dict:
"""The subprocess environment with SUDO_ASKPASS pointed at *helper*."""
env = dict(os.environ if base is None else base)
env["SUDO_ASKPASS"] = helper
return env
+24
View File
@@ -183,6 +183,22 @@ def current_ref(run: GitRunner) -> str:
return output.strip()
def already_at_tag(run: GitRunner, tag: str) -> bool:
"""True if HEAD is already the commit *tag* points at.
Guards the loop you get when a release is cut without bumping
__version__ in the tagged commit: the checkout succeeds (it's a no-op),
the pet restarts, reads the same old __version__, sees the same "newer"
tag, and does it again — a restart every check, forever."""
code, head = run(["rev-parse", "HEAD"])
if code != 0 or not head.strip():
return False
code, target = run(["rev-parse", f"tags/{tag}^{{commit}}"])
if code != 0 or not target.strip():
return False # tag isn't known locally yet, so we're certainly not on it
return head.strip() == target.strip()
def _requirements_changed(run: GitRunner, before: str, after: str) -> bool:
code, output = run(["diff", "--name-only", before, after, "--", "requirements.txt"])
return code == 0 and bool(output.strip())
@@ -236,6 +252,14 @@ def apply_update(
if code != 0:
raise UpdateError(f"git fetch failed: {output}")
if already_at_tag(run, tag):
from . import __version__
raise UpdateError(
f"already checked out {tag}, but __version__ still reads {__version__}"
f"bump it in the tagged commit, or every check re-applies the same release"
)
code, output = run(["checkout", "--force", f"tags/{tag}"])
if code != 0:
raise UpdateError(f"git checkout {tag} failed: {output}")