...
This commit is contained in:
@@ -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():
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
@@ -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}")
|
||||
|
||||
Reference in New Issue
Block a user