diff --git a/tools/voice_mode_transcript.py b/tools/voice_mode_transcript.py index 3c44955069..621caa32ea 100644 --- a/tools/voice_mode_transcript.py +++ b/tools/voice_mode_transcript.py @@ -36,9 +36,7 @@ _HALLUCINATION_REPEAT_RE = re.compile(r'^(?:thank you|thanks|bye|you|ok|okay|the def is_whisper_hallucination(transcript: str) -> bool: """Check if a transcript is a known Whisper hallucination on silence.""" cleaned = transcript.strip().lower() - if not cleaned: - return True - return cleaned.rstrip('.!') in WHISPER_HALLUCINATIONS or bool(_HALLUCINATION_REPEAT_RE.match(cleaned)) + return not cleaned or cleaned.rstrip('.!') in WHISPER_HALLUCINATIONS or bool(_HALLUCINATION_REPEAT_RE.match(cleaned)) DEFAULT_VOICE_STOP_PHRASES = ("stop",) @@ -72,9 +70,7 @@ def is_voice_stop_phrase(transcript: str, stop_phrases: Optional[tuple] = None) 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 - return cleaned in (_configured_stop_phrases() if stop_phrases is None else stop_phrases) + return bool(cleaned) and cleaned in (_configured_stop_phrases() if stop_phrases is None else stop_phrases) # Similarity ratio (difflib.SequenceMatcher) above which a playback-phase barge transcript @@ -105,10 +101,7 @@ def is_tts_echo(transcript: str, spoken_text: str, shorter than `MIN_FRAGMENT_LENGTH_FOR_ECHO` skip the fallback (a short interjection trivially matches a short window). """ - if not transcript or not spoken_text: - return False - a = _normalize_for_echo_compare(transcript) - b = _normalize_for_echo_compare(spoken_text) + a, b = _normalize_for_echo_compare(transcript or ""), _normalize_for_echo_compare(spoken_text or "") if not a or not b: return False diff --git a/tools/wake_word.py b/tools/wake_word.py index e72819054b..2c9ea85a4b 100644 --- a/tools/wake_word.py +++ b/tools/wake_word.py @@ -12,6 +12,7 @@ from __future__ import annotations import logging import os +import queue import sys import threading import time @@ -206,26 +207,19 @@ def resolve_capture_mode(cfg: Optional[Dict[str, Any]] = None, *, prefer_client: def _input_channels(info: Any) -> int: - channels = info.get("max_input_channels") if isinstance(info, dict) else None - if channels is None: - channels = getattr(info, "max_input_channels", 0) - return int(channels or 0) + ch = info.get("max_input_channels") if isinstance(info, dict) else getattr(info, "max_input_channels", 0) + return int(ch or 0) 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 - if any(_input_channels(dev) > 0 for dev in devices): - return True # Also accept a resolvable default input (some hosts list devices oddly). - return _input_channels(sd.query_devices(None, "input")) > 0 + return any(_input_channels(d) > 0 for d in devices) or _input_channels(sd.query_devices(None, "input")) > 0 except Exception: return False @@ -311,18 +305,16 @@ def _describe_input_device(sd, selector: int | str | None) -> Dict[str, Any]: if "hostapi_index" in details: try: hostapi = sd.query_hostapis(details["hostapi_index"]) - hostapi_name = hostapi.get("name") if isinstance(hostapi, dict) else None - if hostapi_name: - details["hostapi"] = str(hostapi_name) + if isinstance(hostapi, dict) and hostapi.get("name"): + details["hostapi"] = str(hostapi["name"]) except Exception: pass return details def _device_label(details: Dict[str, Any]) -> str: - name = str(details.get("name") or "").strip() selector = details.get("selector") - label = name or ("system default" if selector is None else str(selector)) + label = str(details.get("name") or "").strip() or ("system default" if selector is None else str(selector)) hostapi = str(details.get("hostapi") or "").strip() return f"{label} ({hostapi})" if hostapi else label @@ -401,20 +393,12 @@ def _tts_ready() -> bool: ``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 - feature = _LAZY_TTS_FEATURES.get(provider) - if feature is not None: - try: + from tools.tts_tool import _get_provider, _load_tts_config, check_tts_requirements + feature = _LAZY_TTS_FEATURES.get(_get_provider(_load_tts_config())) + if feature is not None: 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 @@ -517,7 +501,6 @@ class WakeWordDetector: on_failure: Optional[Callable[["WakeWordDetector"], None]] = None, input_device: int | str | None = None, external_audio: bool = False): - import queue as _queue 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] = ( @@ -528,7 +511,7 @@ class WakeWordDetector: 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) + 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, self._silent_frames = False, 0 @@ -570,7 +553,7 @@ class WakeWordDetector: def start(self) -> None: """Open the mic (or client feeder) and begin listening. Idempotent.""" with self._lock: - if self._thread is not None and self._thread.is_alive(): + if self.running: return self._stop.clear() ready = threading.Event() @@ -647,17 +630,19 @@ class WakeWordDetector: return cap def _note_silence(self, frame, silent_alert_frames: int) -> None: - """Track consecutive near-zero frames; flag/unflag ``audio_silent``.""" + """Track consecutive near-zero frames; flag/unflag ``audio_silent``. ``frame`` is None when + no client frame arrived (counts as silence for status, but is never logged as a dead mic).""" try: - peak = int(abs(frame).max()) if len(frame) else 0 + peak = 0 if frame is None or not len(frame) else int(abs(frame).max()) except Exception: peak = _SILENCE_PEAK + 1 if peak <= _SILENCE_PEAK: self._silent_frames += 1 if self._silent_frames == silent_alert_frames: self.audio_silent = True - logger.warning("wake word: mic delivers only silence (peak<=%d for %ds); %s", _SILENCE_PEAK, - _SILENCE_ALERT_SECONDS, silent_audio_hint(self.input_device_details)) + if frame is not None: + logger.warning("wake word: mic delivers only silence (peak<=%d for %ds); %s", _SILENCE_PEAK, + _SILENCE_ALERT_SECONDS, silent_audio_hint(self.input_device_details)) elif self._silent_frames: if self.audio_silent: logger.info("wake word: mic audio detected — stream healthy") @@ -704,21 +689,17 @@ class WakeWordDetector: failed = not self._stop.is_set() break if data is None: # no client frames yet — counts as silence for status - self._silent_frames += 1 - if self._silent_frames == silent_alert_frames: - self.audio_silent = True + self._note_silence(None, silent_alert_frames) continue frame = data[:, 0] if getattr(data, "ndim", 1) == 2 else data if cap.rate != SAMPLE_RATE: frame = _resample_audio_frame(cap.np, frame, frame_length) self._note_silence(frame, silent_alert_frames) try: - fired = self.engine.process(frame) + if self.engine.process(frame): + self._fire() except Exception as e: logger.debug("wake word: engine error: %s", e) - continue - if fired: - self._fire() finally: cap.close() logger.info("wake word: stream closed") @@ -779,12 +760,15 @@ def _release_machine_lock(handle) -> None: handle.close() -def _clear_singleton_locked() -> tuple[Optional[WakeWordDetector], Any]: - """Forget the armed detector (caller holds ``_detector_lock``); returns (detector, lock handle).""" +def _teardown_locked(close: Callable[[], None]) -> None: + """Forget the singleton and run ``close``, always releasing the machine lease (caller holds the lock).""" global _detector, _detector_owner, _detector_file_lock - det, handle = _detector, _detector_file_lock + lock_handle = _detector_file_lock _detector = _detector_owner = _detector_file_lock = None - return det, handle + try: + close() + finally: + _release_machine_lock(lock_handle) def _owned_detector(owner: object) -> Optional[WakeWordDetector]: @@ -792,15 +776,6 @@ 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: @@ -824,39 +799,40 @@ def start_listening(on_wake: Callable[[], None], *, owner: object, config: Optio _detector.on_wake = on_wake _detector.resume() return _detector - lock_handle = _acquire_machine_lock() + _detector_file_lock = _acquire_machine_lock() try: cfg = config if config is not None else load_wake_word_config() - detector = WakeWordDetector(_build_engine(cfg), on_wake, on_failure=_detector_failed, - input_device=_input_device(cfg), external_audio=external_audio) - _detector, _detector_owner, _detector_file_lock = detector, owner, lock_handle - detector.start() - return detector + _detector = WakeWordDetector(_build_engine(cfg), on_wake, on_failure=_detector_failed, + input_device=_input_device(cfg), external_audio=external_audio) + _detector_owner = owner + _detector.start() + return _detector except Exception: - det, _ = _clear_singleton_locked() + det = _detector try: - if det is not None: - det.stop() + _teardown_locked(det.stop if det is not None else lambda: None) except Exception: pass - _release_machine_lock(lock_handle) raise -def owns_listener(owner: object) -> bool: - with _detector_lock: - return _owned_detector(owner) is not None - - -def _owned_call(owner: object, method: str) -> bool: +def _owned_call(owner: object, method: Optional[str] = None) -> bool: + """Under the lock, True iff ``owner`` holds the lease; also invokes ``detector.()`` when given.""" with _detector_lock: det = _owned_detector(owner) if det is None: return False - getattr(det, method)() + if method == "stop": + _teardown_locked(det.stop) + elif method: + getattr(det, method)() return True +def owns_listener(owner: object) -> bool: + return _owned_call(owner) + + def pause_listening(*, owner: object) -> bool: """Release the microphone only when ``owner`` holds the lease.""" return _owned_call(owner, "pause") @@ -869,12 +845,7 @@ 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: - det = _owned_detector(owner) - if det is None: - return False - _teardown_locked(det.stop) - return True + return _owned_call(owner, "stop") def _current_detector() -> Optional[WakeWordDetector]: diff --git a/tools/wake_word_engines.py b/tools/wake_word_engines.py index a36a9c4e39..d332fca6e1 100644 --- a/tools/wake_word_engines.py +++ b/tools/wake_word_engines.py @@ -41,7 +41,7 @@ class _Engine: """Clear any internal audio/feature buffer (called on every (re)start).""" def close(self) -> None: - pass + """Release engine resources (called once on stop).""" def _looks_like_path(value: str) -> bool: @@ -103,11 +103,8 @@ class _OpenWakeWordEngine(_Engine): return "onnx" def process(self, frame) -> bool: - scores = self._model.predict(frame) - if not any(score >= self._threshold for score in scores.values()): - self._confirm_streak = 0 - return False - self._confirm_streak += 1 + hit = any(score >= self._threshold for score in self._model.predict(frame).values()) + self._confirm_streak = self._confirm_streak + 1 if hit else 0 if self._confirm_streak < self._confirm_needed: return False self._confirm_streak = 0 @@ -185,8 +182,8 @@ class _SherpaKwsEngine(_Engine): for prof, p in ww.enrolled_profile_phrases().items(): phrase_map.setdefault(p.strip(), prof) phrases = list(phrase_map) - tokens = text2token([p.upper() for p in phrases], tokens=str(d / "tokens.txt"), - tokens_type="bpe", bpe_model=str(d / "bpe.model")) + tokens = text2token([p.upper() for p in phrases], tokens=str(d / "tokens.txt"), tokens_type="bpe", + bpe_model=str(d / "bpe.model")) # sherpa keyword entries reject spaces in the @display-name; underscore them and # map display → profile for match routing. self._display_to_profile: Dict[str, str] = {} @@ -198,7 +195,6 @@ class _SherpaKwsEngine(_Engine): kw.write(" ".join(toks) + f" @{display}\n") kw.close() self._keywords_file = kw.name - self.last_match: Optional[tuple[str, str]] = None # Shared 0..1 sensitivity → sherpa keywords_threshold. 0.5 lands on sherpa's # recommended 0.25; a stricter 0.35 missed ~12% of true positives in live TTS @@ -226,10 +222,8 @@ class _SherpaKwsEngine(_Engine): self._spotter.decode_stream(self._stream) result = self._spotter.get_result(self._stream) if result: - fired = True - display = str(result) - self.last_match = (display.replace("_", " ").lower(), - self._display_to_profile.get(display, "")) + fired, display = True, str(result) + self.last_match = (display.replace("_", " ").lower(), self._display_to_profile.get(display, "")) self._spotter.reset_stream(self._stream) # one utterance must not fire repeatedly return fired diff --git a/tools/working_diff.py b/tools/working_diff.py index ae61a1fc21..2bff3bf231 100644 --- a/tools/working_diff.py +++ b/tools/working_diff.py @@ -88,8 +88,7 @@ def collect_working_diff(cwd: str, mode: str = "working", paths: List[str] | Non except OSError as e: return {"success": False, "error": f"git failed: {e}"} - stat = stat_out.strip() - diff = diff_out.strip() + stat, diff = stat_out.strip(), diff_out.strip() if untracked_diff: diff = f"{diff}\n{untracked_diff}".strip() result = {"success": True, "stat": stat, "diff": diff, "untracked": untracked} diff --git a/tools/write_approval.py b/tools/write_approval.py index 7ee4d68493..200b2c7b88 100644 --- a/tools/write_approval.py +++ b/tools/write_approval.py @@ -45,10 +45,9 @@ def write_approval_enabled(subsystem: str) -> bool: return False try: from hermes_cli.config import load_config, cfg_get - raw = cfg_get(load_config(), subsystem, CONFIG_KEY, default=False) + return _normalize_enabled(cfg_get(load_config(), subsystem, CONFIG_KEY, default=False)) except Exception: return False - return _normalize_enabled(raw) def _normalize_enabled(value: Any) -> bool: @@ -73,6 +72,11 @@ def _read_record(path: Path) -> Dict[str, Any]: return json.loads(path.read_text(encoding="utf-8")) +def _pending_files(subsystem: str) -> list: + d = _pending_dir(subsystem) + return list(d.glob("*.json")) if d.exists() else [] + + 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``. @@ -97,11 +101,8 @@ def stage_write(subsystem: str, payload: Dict[str, Any], *, summary: str, origin def list_pending(subsystem: str) -> List[Dict[str, Any]]: """Return all pending records for ``subsystem``, oldest first.""" - d = _pending_dir(subsystem) - if not d.exists(): - return [] records: List[Dict[str, Any]] = [] - for p in d.glob("*.json"): + for p in _pending_files(subsystem): try: records.append(_read_record(p)) except Exception: @@ -133,9 +134,8 @@ def discard_pending(subsystem: str, pending_id: str) -> bool: def pending_count(subsystem: str) -> int: """Cheap count of pending records (for notification badges).""" - d = _pending_dir(subsystem) try: - return sum(1 for _ in d.glob("*.json")) if d.exists() else 0 + return len(_pending_files(subsystem)) except Exception: return 0 @@ -183,11 +183,11 @@ def evaluate_gate(subsystem: str, *, inline_summary: str = "", inline_detail: st if subsystem == SKILLS or current_origin() == "background_review": return _staged(subsystem) granted = _prompt_inline_memory_approval(inline_summary, inline_detail) - if granted is True: + if granted is None: + return _staged(MEMORY) + if granted: return GateDecision(allow=True) - if granted is False: - return GateDecision(blocked=True, message="Memory write denied by user. The change was not saved.") - return _staged(MEMORY) + return GateDecision(blocked=True, message="Memory write denied by user. The change was not saved.") def _prompt_inline_memory_approval(summary: str, detail: str) -> Optional[bool]: @@ -209,9 +209,8 @@ def _prompt_inline_memory_approval(summary: str, detail: str) -> Optional[bool]: except Exception as e: logger.error("Inline memory approval prompt failed: %s", e) return None - if choice in {"once", "session"}: - return True - return False if choice == "deny" else None # unknown outcome → stage rather than drop + # unknown outcome → stage rather than drop + return {"once": True, "session": True, "deny": False}.get(choice) # --- Skill-specific helpers (gist + diff for the review affordances) --- @@ -272,8 +271,7 @@ def skill_pending_diff(record: Dict[str, Any]) -> str: return f"({action} on '{name}')" # patch/write_file target a file inside the skill; edit always targets SKILL.md. - target_label = "SKILL.md" - current = "" + target_label, current = "SKILL.md", "" skill_dir = _find_skill_path(name) if skill_dir: if action != "edit": @@ -284,13 +282,11 @@ def skill_pending_diff(record: Dict[str, Any]) -> str: except Exception: current = "" - if action == "edit": - new = payload.get("content") or "" - elif action == "patch": + if action == "patch": old_s, new_s = payload.get("old_string") or "", payload.get("new_string") or "" new = current.replace(old_s, new_s) if current else f"(patch {old_s!r} → {new_s!r})" else: - new = payload.get("file_content") or "" + new = payload.get("content" if action == "edit" else "file_content") or "" diff = difflib.unified_diff(current.splitlines(keepends=True), new.splitlines(keepends=True), fromfile=f"a/{target_label}", tofile=f"b/{target_label}") return "".join(diff) or "(no textual change)" diff --git a/tools/yuanbao_tools.py b/tools/yuanbao_tools.py index 80c5a7db99..9e1352011b 100644 --- a/tools/yuanbao_tools.py +++ b/tools/yuanbao_tools.py @@ -80,8 +80,8 @@ def _session_env(name: str) -> str: return "" -def _nick(m: dict) -> str: - return m.get("nickname", m.get("nick_name", "")) +def _nick(m: dict, default: str = "") -> str: + return m.get("nickname", m.get("nick_name", default)) async def _members(adapter, group_code: str) -> list: @@ -101,10 +101,8 @@ async def _resolve_dm_recipient(adapter, group_code: str, name: str) -> Tuple[st if not name: raise _YbError("name is required when user_id is not provided") filt = name.strip().lower() - matched = [ - m for m in await _members(adapter, group_code) - if filt in (m.get("nickname") or m.get("nick_name") or "").lower() - ] + matched = [m for m in await _members(adapter, group_code) + if filt in (m.get("nickname") or m.get("nick_name") or "").lower()] if not matched: raise _YbError(f'No member matching "{name}" found in group {group_code}.') if len(matched) > 1: @@ -113,7 +111,7 @@ async def _resolve_dm_recipient(adapter, group_code: str, name: str) -> Tuple[st candidates=[{"user_id": m.get("user_id", ""), "nickname": _nick(m)} for m in matched], ) m = matched[0] - return m.get("user_id", ""), m.get("nickname", m.get("nick_name", name)) + return m.get("user_id", ""), _nick(m, name) @_yb_tool("get_group_info") @@ -146,11 +144,8 @@ async def query_group_members(args) -> dict: if not group_code: return _err("group_code is required") all_members = [ - { - "user_id": m.get("user_id", ""), - "nickname": _nick(m), - "role": _USER_TYPE_LABEL.get(m.get("user_type", m.get("role", 0)), "unknown"), - } + {"user_id": m.get("user_id", ""), "nickname": _nick(m), + "role": _USER_TYPE_LABEL.get(m.get("user_type", m.get("role", 0)), "unknown")} for m in await _members(_adapter(), group_code) ] if not all_members: @@ -189,13 +184,8 @@ async def search_sticker(args) -> dict: safe_limit = 10 matches = search_stickers(query or "", limit=safe_limit) return { - "success": True, - "query": query or "", - "count": len(matches), - "results": [ - {k: s.get(k, "") for k in ("sticker_id", "name", "description", "package_id")} - for s in matches - ], + "success": True, "query": query or "", "count": len(matches), + "results": [{k: s.get(k, "") for k in ("sticker_id", "name", "description", "package_id")} for s in matches], } @@ -217,24 +207,21 @@ async def send_sticker(args) -> dict: if not raw: sticker_obj = get_random_sticker() else: - sticker_obj = get_sticker_by_id(raw) if raw.isdigit() else None - if sticker_obj is None: - sticker_obj = get_sticker_by_name(raw) + sticker_obj = (get_sticker_by_id(raw) if raw.isdigit() else None) or get_sticker_by_name(raw) if sticker_obj is None: return _err(f"Sticker not found: {raw!r}. Use search_sticker first to discover available stickers.") result = await adapter.send_sticker( chat_id=target, sticker_name=sticker_obj.get("name", ""), reply_to=args.get("reply_to", "") or None, ) - if getattr(result, "success", False): - return { - "success": True, - "chat_id": target, - "sticker": {"sticker_id": sticker_obj.get("sticker_id", ""), "name": sticker_obj.get("name", "")}, - "message_id": getattr(result, "message_id", None), - "note": "Sticker delivered to the chat. If you have additional text to say, reply now; otherwise end your turn without generating text.", - } - return _err(getattr(result, "error", "send_sticker failed")) + if not getattr(result, "success", False): + return _err(getattr(result, "error", "send_sticker failed")) + return { + "success": True, "chat_id": target, + "sticker": {"sticker_id": sticker_obj.get("sticker_id", ""), "name": sticker_obj.get("name", "")}, + "message_id": getattr(result, "message_id", None), + "note": "Sticker delivered to the chat. If you have additional text to say, reply now; otherwise end your turn without generating text.", + } @_yb_tool("send_dm") @@ -294,16 +281,11 @@ async def send_dm(args) -> dict: if errors and not last_result.success: return _err("; ".join(errors)) - result = { - "success": True, - "user_id": resolved_user_id, - "nickname": resolved_nickname, - "message_id": last_result.message_id, - "note": f'DM sent to "{resolved_nickname}" successfully.', - } + note = f'DM sent to "{resolved_nickname}" successfully.' if errors: - result["note"] += f" (partial failure: {'; '.join(errors)})" - return result + note += f" (partial failure: {'; '.join(errors)})" + return {"success": True, "user_id": resolved_user_id, "nickname": resolved_nickname, + "message_id": last_result.message_id, "note": note} def _check_yuanbao():