refactor(tools): unify wake_word singleton teardown, tighten docstrings across slice A

This commit is contained in:
Teknium
2026-09-02 22:28:19 -07:00
parent 3969d10e40
commit 20e4603bf7
5 changed files with 111 additions and 229 deletions
+6 -12
View File
@@ -68,12 +68,9 @@ def _configured_stop_phrases() -> tuple:
def is_voice_stop_phrase(transcript: str, stop_phrases: Optional[tuple] = None) -> bool:
"""True when *transcript* is EXACTLY a configured stop phrase.
Deliberately strict: the whole utterance — lowercased, surrounding punctuation
stripped — must equal a phrase, so "stop doing that and try again" still reaches
the agent. ``voice.stop_phrases: []`` disables.
"""
"""True when *transcript* is EXACTLY a configured stop phrase. Deliberately strict: the whole
utterance — lowercased, surrounding punctuation stripped — must equal a phrase, so "stop doing
that and try again" still reaches the agent. ``voice.stop_phrases: []`` disables."""
cleaned = transcript.strip().lower().strip(".,!?;: \t\n\"'") if transcript else ""
if not cleaned:
return False
@@ -126,11 +123,8 @@ def is_tts_echo(transcript: str, spoken_text: str,
def voice_stop_hint() -> str:
"""One-line 'Say "stop" to end the voice chat.' hint for voice-mode start.
Uses the first ``voice.stop_phrases`` entry so a custom phrase renders correctly;
"" when stop phrases are disabled. Every surface announcing voice-mode start (CLI,
TUI, desktop) uses this one owner instead of hardcoding the wording.
"""
"""One-line 'Say "stop" to end the voice chat.' hint for voice-mode start, using the first
``voice.stop_phrases`` entry ("" when disabled). Every surface announcing voice-mode start
(CLI, TUI, desktop) uses this one owner instead of hardcoding the wording."""
phrases = _configured_stop_phrases()
return f'Say "{phrases[0]}" to end the voice chat.' if phrases else ""
+62 -125
View File
@@ -58,18 +58,12 @@ class WakeWordInUse(RuntimeError):
# ── Config ──
# capture: "local" (PortAudio on the backend host), "client" (desktop/TUI streams int16
# frames via wake.feed), or "auto" (local when a device exists, else client).
_DEFAULTS: Dict[str, Any] = {
"enabled": False,
"surface": "auto",
"input_device": None,
# capture: "local" (PortAudio on the backend host), "client" (desktop/TUI streams
# int16 frames via wake.feed), or "auto" (local when a device exists, else client).
"capture": "auto",
"provider": "openwakeword",
"phrase": "hey hermes",
"sensitivity": 0.6,
"confirmation_frames": _DEFAULT_CONFIRMATION_FRAMES,
"start_new_session": True,
"enabled": False, "surface": "auto", "input_device": None, "capture": "auto",
"provider": "openwakeword", "phrase": "hey hermes", "sensitivity": 0.6,
"confirmation_frames": _DEFAULT_CONFIRMATION_FRAMES, "start_new_session": True,
}
# Bundled "hey hermes" model (tools/wakewords/) — the default; alias names resolve
@@ -86,7 +80,6 @@ def _bundled_wakeword_path(framework: str = "onnx") -> str:
def _is_macos_arm64() -> bool:
import platform
return sys.platform == "darwin" and platform.machine() == "arm64"
@@ -123,14 +116,11 @@ def resolve_inference_framework(cfg: Dict[str, Any]) -> str:
def ensure_tflite_runtime() -> bool:
"""Make ``import tflite_runtime.interpreter`` resolve, returning success.
openWakeWord hardcodes that import but only declares ``tflite-runtime`` on Linux;
on macOS the wheel is ``ai-edge-litert``. Alias it in-process (site-packages untouched).
"""
"""Make ``import tflite_runtime.interpreter`` resolve, returning success. openWakeWord hardcodes
that import but only declares ``tflite-runtime`` on Linux; on macOS the wheel is ``ai-edge-litert``,
so alias it in-process (site-packages untouched)."""
try:
import tflite_runtime.interpreter # noqa: F401
return True
except ImportError:
pass
@@ -139,7 +129,6 @@ def ensure_tflite_runtime() -> bool:
except ImportError:
return False
import types
pkg = types.ModuleType("tflite_runtime")
pkg.__path__ = [] # type: ignore[attr-defined] # mark as package
sys.modules.setdefault("tflite_runtime", pkg)
@@ -152,7 +141,6 @@ def load_wake_word_config() -> Dict[str, Any]:
"""Return the ``wake_word`` config section, shape-guarded to a dict."""
try:
from hermes_cli.config import load_config
cfg = load_config().get("wake_word")
except Exception:
cfg = None
@@ -202,13 +190,10 @@ def wake_phrase(cfg: Optional[Dict[str, Any]] = None) -> str:
def resolve_capture_mode(cfg: Optional[Dict[str, Any]] = None, *, prefer_client: bool = False,
force_local: bool = False) -> str:
"""Return ``local`` or ``client`` capture mode for this arm.
``prefer_client`` is set by remote desktop; ``force_local`` keeps CLI/TUI on the
process mic. Under ``auto`` a working backend input always wins; client is the
fallback only for a preferring surface with no usable backend mic — CLI/TUI stay
local so status reports the real requirement rather than a path nothing will feed.
"""
"""Return ``local`` or ``client`` capture mode for this arm. ``prefer_client`` is set by remote
desktop; ``force_local`` keeps CLI/TUI on the process mic. Under ``auto`` a working backend input
always wins; client is the fallback only for a preferring surface with no usable backend mic —
CLI/TUI stay local so status reports the real requirement rather than a path nothing will feed."""
cfg = cfg if cfg is not None else load_wake_word_config()
if force_local:
return "local"
@@ -231,6 +216,9 @@ def _local_input_device_ready() -> bool:
"""True when PortAudio is importable and at least one input device exists."""
try:
sd, _ = _import_audio()
except (ImportError, OSError):
return False
try:
devices = sd.query_devices()
if isinstance(devices, dict):
return _input_channels(devices) > 0
@@ -243,11 +231,8 @@ def _local_input_device_ready() -> bool:
def wake_surface_enabled(surface: str, cfg: Optional[Dict[str, Any]] = None) -> bool:
"""Should ``surface`` (``cli`` / ``tui`` / ``gui``) host the listener?
True when enabled and the configured ``surface`` is ``auto`` or this exact surface.
``auto`` only makes a surface eligible; the ownership lock still admits one claimant.
"""
"""Should ``surface`` (cli/tui/gui) host the listener? True when enabled and the configured
surface is ``auto`` or this one; ``auto`` only makes it eligible — the lock admits one claimant."""
cfg = cfg if cfg is not None else load_wake_word_config()
if not cfg.get("enabled"):
return False
@@ -260,24 +245,19 @@ def wake_surface_enabled(surface: str, cfg: Optional[Dict[str, Any]] = None) ->
def _active_profile_name() -> str:
try:
from hermes_cli.profiles import get_active_profile_name
return get_active_profile_name() or "default"
except Exception:
return "default"
def enrolled_profile_phrases() -> Dict[str, str]:
"""Map ``profile name -> wake phrase`` for every wake-enabled profile.
Reads each profile's own ``config.yaml`` raw (``load_config()`` targets only the
ACTIVE profile). Phrase defaults to ``"hey <profile>"``; the sherpa engine listens
for all of them and routes the wake to the matching profile. Unreadable → skipped.
"""
"""Map ``profile name -> wake phrase`` for every wake-enabled profile, reading each profile's own
``config.yaml`` raw (``load_config()`` targets only the ACTIVE profile). Phrase defaults to
``"hey <profile>"``; the sherpa engine listens for all and routes to the match. Unreadable → skipped."""
phrases: Dict[str, str] = {}
try:
from hermes_cli.config import read_user_config_raw
from hermes_cli.profiles import get_profile_dir, list_profiles
for info in list_profiles():
name = getattr(info, "name", None) or str(info)
try:
@@ -299,7 +279,6 @@ def enrolled_profile_phrases() -> Dict[str, str]:
def _import_audio():
import numpy as np
import sounddevice as sd
return sd, np
@@ -312,10 +291,8 @@ def _audio_available() -> bool:
def _describe_input_device(sd, selector: int | str | None) -> Dict[str, Any]:
"""Resolve a PortAudio selector into JSON-safe diagnostics.
Diagnostic only: ``InputStream`` stays the authority on whether the device opens.
"""
"""Resolve a PortAudio selector into JSON-safe diagnostics (``InputStream`` stays the
authority on whether the device actually opens)."""
details: Dict[str, Any] = {"selector": selector}
try:
info = sd.query_devices(selector, "input")
@@ -410,7 +387,6 @@ def _stt_ready() -> bool:
mic but every utterance dies at transcription — same bar as ``check_voice_requirements``.)"""
try:
from tools.transcription_tools import _get_provider, _load_stt_config, is_stt_enabled
stt_config = _load_stt_config()
return is_stt_enabled(stt_config) and _get_provider(stt_config) != "none"
except Exception:
@@ -421,15 +397,11 @@ _LAZY_TTS_FEATURES = {"edge": "tts.edge", "elevenlabs": "tts.elevenlabs", "mistr
def _tts_ready() -> bool:
"""Can the configured TTS provider run (or install at first use)?
PROBE, not an installer: ``check_tts_requirements`` lazily pip-installs the provider
SDK, which froze wake.status polls for a whole pip run. Uninstalled deps count as
ready iff lazy installs are allowed; pip is never touched from here.
"""
"""Can the configured TTS provider run (or install at first use)? PROBE, not an installer:
``check_tts_requirements`` lazily pip-installs the SDK, which froze wake.status polls for a whole
pip run. Uninstalled deps count as ready iff lazy installs are allowed; pip is never touched here."""
try:
from tools.tts_tool import _get_provider, _load_tts_config
provider = _get_provider(_load_tts_config())
except Exception:
return False
@@ -437,14 +409,12 @@ def _tts_ready() -> bool:
if feature is not None:
try:
from tools import lazy_deps
if not lazy_deps.is_available(feature):
return lazy_deps._allow_lazy_installs()
except Exception:
return False
try:
from tools.tts_tool import check_tts_requirements
return bool(check_tts_requirements())
except Exception:
return False
@@ -455,7 +425,6 @@ def check_wake_word_requirements(cfg: Optional[Dict[str, Any]] = None) -> Dict[s
cfg = cfg if cfg is not None else load_wake_word_config()
provider = _provider(cfg)
from tools import lazy_deps
feature = _PROVIDERS.get(provider, ("", "wake.openwakeword"))[1]
deps_ok = lazy_deps.is_available(feature)
lazy_ok = lazy_deps._allow_lazy_installs()
@@ -501,17 +470,11 @@ def check_wake_word_requirements(cfg: Optional[Dict[str, Any]] = None) -> Dict[s
)
return {
"available": key_ok and stt_ok and tts_ok and tflite_ok and mic_ok,
"provider": provider,
"deps_available": deps_ok,
"audio_available": audio_ok,
"available": key_ok and stt_ok and tts_ok and tflite_ok and mic_ok, "provider": provider,
"deps_available": deps_ok, "audio_available": audio_ok,
"local_input_available": _local_input_device_ready() if deps_ok else False,
"capture": capture_mode,
"access_key_set": key_ok,
"stt_available": stt_ok,
"tts_available": tts_ok,
"phrase": wake_phrase(cfg),
"hint": hint,
"capture": capture_mode, "access_key_set": key_ok, "stt_available": stt_ok, "tts_available": tts_ok,
"phrase": wake_phrase(cfg), "hint": hint,
}
@@ -546,11 +509,8 @@ class _Capture:
class WakeWordDetector:
"""Background hotword listener. Fires ``on_wake()`` when the phrase is heard.
The engine is built once and kept alive across pause/resume; only the audio stream
+ reader thread cycle, so toggling the mic for a voice turn is cheap.
"""
"""Background hotword listener; fires ``on_wake()`` when the phrase is heard. The engine is built
once and kept across pause/resume — only the stream + reader thread cycle, so mic toggles are cheap."""
def __init__(self, engine: _Engine, on_wake: Callable[[], None],
cooldown: float = _FIRE_COOLDOWN_SECONDS,
@@ -558,28 +518,20 @@ class WakeWordDetector:
input_device: int | str | None = None,
external_audio: bool = False):
import queue as _queue
self.engine = engine
self.on_wake = on_wake
self.cooldown = cooldown
self.on_failure = on_failure
self.input_device = input_device
self.external_audio = bool(external_audio)
self.engine, self.on_wake, self.cooldown, self.on_failure = engine, on_wake, cooldown, on_failure
self.input_device, self.external_audio = input_device, bool(external_audio)
self.input_device_details: Dict[str, Any] = (
{"selector": "client", "name": "client capture", "hostapi": "remote"}
if self.external_audio else {"selector": input_device}
)
self._thread: Optional[threading.Thread] = None
self._stop = threading.Event()
self._callback_inflight = threading.Event()
self._last_fire = 0.0
self._lock = threading.Lock()
self._stop, self._callback_inflight = threading.Event(), threading.Event()
self._lock, self._last_fire = threading.Lock(), 0.0
# Client-capture PCM queue (int16 mono frames). Local mode ignores this.
self._audio_q: "_queue.Queue[Any]" = _queue.Queue(maxsize=64)
# True when the stream is open but every frame is (near-)silence, so status
# surfaces can tell "armed" from "deaf".
self.audio_silent = False
self._silent_frames = 0
self.audio_silent, self._silent_frames = False, 0
@property
def running(self) -> bool:
@@ -587,11 +539,8 @@ class WakeWordDetector:
return t is not None and t.is_alive()
def feed(self, pcm_int16) -> None:
"""Enqueue one int16 mono frame (or raw bytes) for client capture.
Short frames are zero-padded to ``engine.frame_length``; long frames are split.
On queue overflow the oldest frame is dropped to stay real-time.
"""
"""Enqueue one int16 mono frame (or raw bytes) for client capture. Short frames are
zero-padded to ``engine.frame_length``, long ones split; on overflow the oldest is dropped."""
if not self.external_audio:
return
try:
@@ -608,9 +557,7 @@ class WakeWordDetector:
for offset in range(0, int(arr.shape[0]), fl):
chunk = arr[offset : offset + fl]
if chunk.shape[0] < fl:
pad = np.zeros(fl, dtype=np.int16)
pad[: chunk.shape[0]] = chunk
chunk = pad
chunk = np.pad(chunk, (0, fl - chunk.shape[0]))
try:
self._audio_q.put_nowait(chunk)
except Exception:
@@ -686,12 +633,10 @@ class WakeWordDetector:
details = self.input_device_details = _describe_input_device(sd, self.input_device)
cap = _Capture(np=np, rate=_capture_sample_rate(details))
cap.frame_length = max(1, int(round(frame_length * cap.rate / SAMPLE_RATE)))
logger.info(
"wake word: opening microphone device=%s selector=%r hostapi=%s "
"default_rate=%s capture_rate=%d engine_rate=%d",
details.get("name") or "system default", self.input_device, details.get("hostapi") or "unknown",
details.get("default_samplerate") or "unknown", cap.rate, SAMPLE_RATE,
)
logger.info("wake word: opening microphone device=%s selector=%r hostapi=%s "
"default_rate=%s capture_rate=%d engine_rate=%d", details.get("name") or "system default",
self.input_device, details.get("hostapi") or "unknown",
details.get("default_samplerate") or "unknown", cap.rate, SAMPLE_RATE)
try:
cap.stream = sd.InputStream(device=self.input_device, samplerate=cap.rate, channels=1,
dtype="int16", blocksize=cap.frame_length)
@@ -791,7 +736,6 @@ _detector_lock = threading.Lock()
def _lock_path() -> Path:
from hermes_constants import get_default_hermes_root
return get_default_hermes_root() / "runtime" / "wake-word.lock"
@@ -799,7 +743,6 @@ def _flock(handle, acquire: bool) -> None:
"""Non-blocking exclusive lock (or unlock) of one byte / whole file, per OS."""
if os.name == "nt":
import msvcrt
if acquire: # msvcrt needs at least one byte to lock
handle.seek(0, os.SEEK_END)
if handle.tell() == 0:
@@ -809,7 +752,6 @@ def _flock(handle, acquire: bool) -> None:
msvcrt.locking(handle.fileno(), msvcrt.LK_NBLCK if acquire else msvcrt.LK_UNLCK, 1)
else:
import fcntl
fcntl.flock(handle.fileno(), (fcntl.LOCK_EX | fcntl.LOCK_NB) if acquire else fcntl.LOCK_UN)
@@ -850,26 +792,27 @@ def _owned_detector(owner: object) -> Optional[WakeWordDetector]:
return _detector if _detector is not None and _detector_owner is owner else None
def _teardown_locked(close: Callable[[], None]) -> None:
"""Forget the singleton and run ``close``, always releasing the machine lease (caller holds the lock)."""
_, lock_handle = _clear_singleton_locked()
try:
close()
finally:
_release_machine_lock(lock_handle)
def _detector_failed(detector: WakeWordDetector) -> None:
"""Release ownership if the active microphone stream dies unexpectedly."""
with _detector_lock:
if _detector is not detector:
return
_, lock_handle = _clear_singleton_locked()
try:
detector.engine.close()
finally:
_release_machine_lock(lock_handle)
if _detector is detector:
_teardown_locked(detector.engine.close)
def start_listening(on_wake: Callable[[], None], *, owner: object, config: Optional[Dict[str, Any]] = None,
external_audio: bool = False) -> WakeWordDetector:
"""Claim, build, and start the detector. Idempotent for the same owner.
Raises if engine construction fails (missing deps / access key / model); callers
should probe :func:`check_wake_word_requirements` first. A different owner,
including another process, receives :class:`WakeWordInUse`.
"""
"""Claim, build, and start the detector. Idempotent for the same owner; a different owner
(or process) gets :class:`WakeWordInUse`. Raises if engine construction fails (missing deps /
access key / model) — callers should probe :func:`check_wake_word_requirements` first."""
if owner is None:
raise ValueError("wake-word owner must not be None")
@@ -927,13 +870,10 @@ def resume_listening(*, owner: object) -> bool:
def stop_listening(*, owner: object) -> bool:
"""Fully stop the detector only when ``owner`` holds the lease."""
with _detector_lock:
if _owned_detector(owner) is None:
det = _owned_detector(owner)
if det is None:
return False
det, lock_handle = _clear_singleton_locked()
try:
det.stop()
finally:
_release_machine_lock(lock_handle)
_teardown_locked(det.stop)
return True
@@ -990,8 +930,5 @@ def detector_frame_info() -> Dict[str, Any]:
det = _current_detector()
if det is None:
return {"sample_rate": SAMPLE_RATE, "frame_length": 1280}
return {
"sample_rate": SAMPLE_RATE,
"frame_length": int(getattr(det.engine, "frame_length", 1280) or 1280),
"external_audio": bool(det.external_audio),
}
return {"sample_rate": SAMPLE_RATE, "external_audio": bool(det.external_audio),
"frame_length": int(getattr(det.engine, "frame_length", 1280) or 1280)}
+15 -41
View File
@@ -17,13 +17,11 @@ logger = logging.getLogger("tools.wake_word")
def _ww():
from tools import wake_word
return wake_word
def _ensure_dep(feature: str) -> None:
from tools import lazy_deps
lazy_deps.ensure(feature, prompt=False)
@@ -56,12 +54,9 @@ def _sub(cfg: Dict[str, Any], key: str) -> Dict[str, Any]:
class _OpenWakeWordEngine(_Engine):
"""openWakeWord — free, local ONNX/tflite hotword detection.
Scores one ~80 ms frame at a time; ``sensitivity`` IS the raw 0..1 threshold
(higher = stricter). A real utterance holds the score high across frames while a
stray phoneme spikes one, so ``confirmation_frames`` consecutive hits are required.
"""
"""openWakeWord — free, local ONNX/tflite hotword detection. Scores one ~80 ms frame at a time;
``sensitivity`` IS the raw 0..1 threshold (higher = stricter). A real utterance holds the score
high across frames while a stray phoneme spikes one, so ``confirmation_frames`` hits are required."""
frame_length = 1280 # openWakeWord recommends 80 ms frames.
@@ -69,7 +64,6 @@ class _OpenWakeWordEngine(_Engine):
_ensure_dep("wake.openwakeword")
import openwakeword
from openwakeword.model import Model
ww = _ww()
model_ref = str(_sub(cfg, "openwakeword").get("model") or ww._BUNDLED_MODEL_NAME).strip()
framework = self._usable_framework(ww.resolve_inference_framework(cfg))
@@ -90,13 +84,9 @@ class _OpenWakeWordEngine(_Engine):
@staticmethod
def _usable_framework(framework: str) -> str:
"""Refuse openWakeWord's silent tflite→onnx downgrade.
Without a tflite runtime openWakeWord falls back to onnx, which on macOS ARM64
never fires — the listener would arm and stay deaf. Install + bridge the runtime
first (the platform gate lives here because dep specs can't carry PEP 508
markers); on that Mac raise instead of downgrading.
"""
"""Refuse openWakeWord's silent tflite→onnx downgrade: without a tflite runtime it falls back
to onnx, which on macOS ARM64 never fires (armed but deaf). Install + bridge the runtime first
(gate lives here because dep specs can't carry PEP 508 markers); on that Mac raise instead."""
ww = _ww()
if framework != "tflite" or ww.ensure_tflite_runtime():
return framework
@@ -107,10 +97,8 @@ class _OpenWakeWordEngine(_Engine):
if ww.ensure_tflite_runtime():
return framework
if ww._is_macos_arm64():
raise RuntimeError(
"The wake word needs the tflite backend on this Mac, but its "
"runtime is missing. Install it with: pip install ai-edge-litert"
)
raise RuntimeError("The wake word needs the tflite backend on this Mac, but its "
"runtime is missing. Install it with: pip install ai-edge-litert")
logger.warning("wake word: no tflite runtime available — falling back to onnx")
return "onnx"
@@ -149,7 +137,6 @@ _SHERPA_KWS_MODEL_DIR = "sherpa-onnx-kws-zipformer-gigaspeech-3.3M-2024-01-01"
def _sherpa_model_root() -> Path:
from hermes_constants import get_hermes_home
return get_hermes_home() / "cache" / "wakewords"
@@ -161,7 +148,6 @@ def _ensure_sherpa_model(root: Optional[Path] = None) -> Path:
return target
import tarfile
import urllib.request
root.mkdir(parents=True, exist_ok=True)
archive = root / f"{_SHERPA_KWS_MODEL_DIR}.tar.bz2"
logger.info("wake word: downloading sherpa KWS model (one-time, ~13 MB)")
@@ -175,11 +161,8 @@ def _ensure_sherpa_model(root: Optional[Path] = None) -> Path:
class _SherpaKwsEngine(_Engine):
"""sherpa-onnx open-vocabulary keyword spotting — any typed phrase, zero training.
``wake_word.phrase`` is BPE-tokenized at runtime against the model's vocabulary,
so here ``phrase`` is DETECTION config, not a cosmetic label.
"""
"""sherpa-onnx open-vocabulary keyword spotting — any typed phrase, zero training. ``wake_word.phrase``
is BPE-tokenized at runtime against the model's vocabulary: DETECTION config, not a cosmetic label."""
frame_length = 1280 # streaming zipformer accepts any chunk; match capture path.
@@ -188,7 +171,6 @@ class _SherpaKwsEngine(_Engine):
import sherpa_onnx
import tempfile
from sherpa_onnx import text2token
ww = _ww()
model_dir = str(_sub(cfg, "sherpa").get("model_dir") or "").strip()
d = Path(model_dir) if model_dir else _ensure_sherpa_model()
@@ -230,19 +212,14 @@ class _SherpaKwsEngine(_Engine):
return str(hits[0])
self._spotter = sherpa_onnx.KeywordSpotter(
tokens=str(d / "tokens.txt"),
encoder=_model_file("encoder-*[!8].onnx"),
decoder=_model_file("decoder-*[!8].onnx"),
joiner=_model_file("joiner-*[!8].onnx"),
keywords_file=self._keywords_file,
keywords_threshold=threshold,
num_threads=1,
tokens=str(d / "tokens.txt"), encoder=_model_file("encoder-*[!8].onnx"),
decoder=_model_file("decoder-*[!8].onnx"), joiner=_model_file("joiner-*[!8].onnx"),
keywords_file=self._keywords_file, keywords_threshold=threshold, num_threads=1,
)
self._stream = self._spotter.create_stream()
def process(self, frame) -> bool:
import numpy as np
self._stream.accept_waveform(_ww().SAMPLE_RATE, np.asarray(frame, dtype=np.float32) / 32768.0)
fired = False
while self._spotter.is_ready(self._stream):
@@ -276,13 +253,10 @@ class _PorcupineEngine(_Engine):
def __init__(self, cfg: Dict[str, Any]):
_ensure_dep("wake.porcupine")
import pvporcupine
access_key = (os.getenv("PORCUPINE_ACCESS_KEY") or "").strip()
if not access_key:
raise RuntimeError(
"Porcupine wake word requires PORCUPINE_ACCESS_KEY "
"(get a free key at https://console.picovoice.ai)."
)
raise RuntimeError("Porcupine wake word requires PORCUPINE_ACCESS_KEY "
"(get a free key at https://console.picovoice.ai).")
keyword = str(_sub(cfg, "porcupine").get("keyword") or "jarvis").strip()
# Porcupine's `sensitivities` runs the OPPOSITE way to our shared knob (higher =
# looser); invert so "higher = stricter" holds for every engine.
+7 -13
View File
@@ -28,13 +28,10 @@ VALID_MODES = tuple(_MODE_ARGS)
def _run(args: List[str], cwd: str, timeout: int = _GIT_TIMEOUT):
"""Run git, returning (returncode, stdout). Never raises on git failure.
Hardened against a malicious repo's ``.git/config`` (GHSA-7x36-8jrh-v4pw):
``noninteractive_git_env`` disables fsmonitor/hooks/pager/editor/credential sinks,
and ``harden_git_argv`` appends ``--no-ext-diff --no-textconv`` to diff-rendering
subcommands so attribute-scoped diff/textconv drivers can't execute either.
"""
"""Run git, returning (returncode, stdout). Never raises on git failure. Hardened against a
malicious repo's ``.git/config`` (GHSA-7x36-8jrh-v4pw): ``noninteractive_git_env`` disables
fsmonitor/hooks/pager/editor/credential sinks and ``harden_git_argv`` appends ``--no-ext-diff
--no-textconv`` to diff-rendering subcommands so attribute-scoped drivers can't execute either."""
proc = subprocess.run(
["git", "-c", "core.quotePath=false", *harden_git_argv(args)],
cwd=cwd, capture_output=True, text=True, timeout=timeout, encoding="utf-8", errors="replace",
@@ -65,12 +62,9 @@ def _untracked_diff(cwd: str, files: List[str]) -> str:
def collect_working_diff(cwd: str, mode: str = "working", paths: List[str] | None = None) -> Dict:
"""Collect a git diff of the working directory.
Returns ``{"success", "stat", "diff", "untracked", "empty"}`` on success or
``{"success": False, "error": ...}`` when git is unavailable / not a repo. ``paths``
restricts the diff to pathspecs (passed verbatim); untracked files are then skipped.
"""
"""Collect a git diff of the working directory: ``{"success", "stat", "diff", "untracked", "empty"}``
on success or ``{"success": False, "error": ...}`` when git is unavailable / not a repo. ``paths``
restricts the diff to pathspecs (passed verbatim); untracked files are then skipped."""
if mode not in _MODE_ARGS:
return {"success": False, "error": f"Unknown mode '{mode}'. Use: {', '.join(VALID_MODES)}"}
if not shutil.which("git"):
+21 -38
View File
@@ -34,6 +34,7 @@ _SUBSYSTEMS = (MEMORY, SKILLS)
# Per-subsystem config key. Intentionally a single boolean with no "block all writes"
# state — to disable a subsystem use its own enable flag (e.g. ``memory.memory_enabled``).
CONFIG_KEY = "write_approval"
_TRUTHY_STRINGS = frozenset({"on", "true", "yes", "1", "approve", "enabled"})
# --- Config resolution ---
@@ -55,9 +56,7 @@ def _normalize_enabled(value: Any) -> bool:
covers hand-edited configs (YAML already parses bare on/off/yes/no)."""
if isinstance(value, bool):
return value
if isinstance(value, str):
return value.strip().lower() in {"on", "true", "yes", "1", "approve", "enabled"}
return False
return isinstance(value, str) and value.strip().lower() in _TRUTHY_STRINGS
# --- Pending store (file-backed) ---
@@ -75,22 +74,15 @@ def _read_record(path: Path) -> Dict[str, Any]:
def stage_write(subsystem: str, payload: Dict[str, Any], *, summary: str, origin: str) -> Dict[str, Any]:
"""Persist a pending write and return its record (``id`` + metadata).
``payload`` is the exact kwargs to replay the write on approval; ``origin`` is
``foreground`` or ``background_review`` (audit). Best-effort: on disk failure it
logs and still returns a record — the write is lost, which is the safe failure
for an approval gate (nothing silently committed).
"""
"""Persist a pending write and return its record (``id`` + metadata). ``payload`` is the exact
kwargs to replay the write on approval; ``origin`` is ``foreground`` or ``background_review``.
Best-effort: on disk failure it logs and still returns a record — the write is lost, which is
the safe failure for an approval gate (nothing silently committed)."""
pid = uuid.uuid4().hex[:8]
record = {
"id": pid,
"subsystem": subsystem,
"action": payload.get("action", ""),
"summary": (summary or "").strip(),
"origin": origin or "foreground",
"created_at": time.time(),
"payload": payload,
"id": pid, "subsystem": subsystem, "action": payload.get("action", ""),
"summary": (summary or "").strip(), "origin": origin or "foreground",
"created_at": time.time(), "payload": payload,
}
try:
path = _pending_path(subsystem, pid)
@@ -164,12 +156,9 @@ def current_origin() -> str:
@dataclass(slots=True, kw_only=True)
class GateDecision:
"""Result of evaluating the write gate. Exactly one flag is True.
``allow`` proceed with the real write; ``blocked`` the user denied an inline prompt
(``message`` explains why); ``stage`` the caller must ``stage_write`` the payload
(``message`` is the user-facing "staged for approval" note).
"""
"""Result of evaluating the write gate; exactly one flag is True. ``allow``: do the real write;
``blocked``: user denied the inline prompt (``message`` says why); ``stage``: caller must
``stage_write`` the payload (``message`` is the user-facing "staged for approval" note)."""
allow: bool = False
blocked: bool = False
@@ -184,13 +173,10 @@ def _staged(subsystem: str) -> GateDecision:
def evaluate_gate(subsystem: str, *, inline_summary: str = "", inline_detail: str = "") -> GateDecision:
"""Decide what to do with a pending write for ``subsystem``.
gate off → allow; gate on + skills (any origin) or background → stage; gate on +
memory + foreground → inline prompt when an interactive channel exists, else stage.
The gate only ever delays a write, never silently refuses it; ``blocked`` is
produced only when the user actively denies the inline prompt.
"""
"""Decide what to do with a pending write: gate off → allow; gate on + skills (any origin) or
background → stage; gate on + memory + foreground → inline prompt when an interactive channel
exists, else stage. The gate only ever delays a write, never silently refuses it; ``blocked``
is produced only when the user actively denies the inline prompt."""
if not write_approval_enabled(subsystem):
return GateDecision(allow=True)
# Skills are too big to review inline; a background write runs in a daemon thread with no user.
@@ -205,14 +191,11 @@ def evaluate_gate(subsystem: str, *, inline_summary: str = "", inline_detail: st
def _prompt_inline_memory_approval(summary: str, detail: str) -> Optional[bool]:
"""Prompt inline for a memory write: True approved, False denied, None → stage.
Uses the per-thread CLI approval callback (``tools.terminal_tool.set_approval_callback``)
directly rather than ``prompt_dangerous_approval``: that wrapper falls back to
``input()`` (deadlock-prone under prompt_toolkit; silent deny in gateway sessions)
and turns callback errors into a deny, whereas here a missing channel or failed
prompt must stage instead.
"""
"""Prompt inline for a memory write: True approved, False denied, None → stage. Uses the per-thread
CLI approval callback (``tools.terminal_tool.set_approval_callback``) directly, not
``prompt_dangerous_approval``: that wrapper falls back to ``input()`` (deadlock-prone under
prompt_toolkit; silent deny in gateway sessions) and turns callback errors into a deny, whereas
here a missing channel or failed prompt must stage instead."""
try:
from tools.terminal_tool import _get_approval_callback
except Exception: