Merge remote-tracking branch 'origin/main' into wake-toggle-config

# Conflicts:
#	tests/test_tui_gateway_server.py
#	tui_gateway/server.py
This commit is contained in:
Teknium 2026-07-28 12:37:35 -07:00
commit 0cf58de85e
No known key found for this signature in database
251 changed files with 27112 additions and 3467 deletions

212
cli.py
View file

@ -1274,9 +1274,8 @@ def _notify_session_finalize(
reason: str = "shutdown",
) -> None:
try:
from hermes_cli.plugins import invoke_hook as _invoke_hook
_invoke_hook(
"on_session_finalize",
from hermes_cli.lifecycle import finalize_session
finalize_session(
session_id=session_id,
platform=platform,
reason=reason,
@ -1304,7 +1303,7 @@ def _emit_interrupted_session_end(cli, *, reason: str = "keyboard_interrupt") ->
pass
try:
from hermes_cli.plugins import invoke_hook as _invoke_hook
from hermes_cli.lifecycle import invoke_hook as _invoke_hook
_invoke_hook(
"on_session_end",
session_id=session_id,
@ -4093,6 +4092,23 @@ def _normalize_moa_model(model: Optional[str]) -> tuple[Optional[str], Optional[
return None, model
class _VoiceInputMessage:
"""Sentinel wrapper for voice-transcribed messages in ``_pending_input``.
Distinguishes STT output from manually typed text while voice mode is
active, so the concise-voice-response prefix is applied only to messages
that actually came from the microphone (#65827).
"""
__slots__ = ("text",)
def __init__(self, text: str):
self.text = text
def __str__(self) -> str:
return self.text
class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
"""
Interactive CLI for the Hermes Agent.
@ -7721,13 +7737,21 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
lifecycle point (shutdown, /new, /reset).
"""
try:
from hermes_cli.plugins import invoke_hook as _invoke_hook
_invoke_hook(
event_type,
session_id=self.agent.session_id if self.agent else None,
platform=getattr(self, "platform", None) or "cli",
reason="new_session" if event_type == "on_session_reset" else "session_boundary",
)
from hermes_cli.lifecycle import finalize_session, invoke_hook
context = {
"session_id": self.agent.session_id if self.agent else None,
"platform": getattr(self, "platform", None) or "cli",
"reason": (
"new_session"
if event_type == "on_session_reset"
else "session_boundary"
),
}
if event_type == "on_session_finalize":
finalize_session(**context)
else:
invoke_hook(event_type, **context)
except Exception:
pass
@ -11778,6 +11802,19 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
self._voice_recorder._silence_duration = (
_duration if isinstance(_duration, (int, float)) and not isinstance(_duration, bool) else 3.0
)
# voice.max_recording_seconds — hard cap on a single recording's length.
# Same numeric guard as the silence params (bool excluded: a hand-edited
# ``max_recording_seconds: true`` must not become ``1`` — it falls back
# to the documented 120 default, mirroring the silence-param handling).
# An explicit numeric value <= 0 disables the cap. Previously this
# documented key was never read (dead config); wiring it here makes it
# take effect.
_max_rec = voice_cfg.get("max_recording_seconds")
self._voice_recorder._max_recording_seconds = (
(_max_rec if _max_rec > 0 else 0.0)
if isinstance(_max_rec, (int, float)) and not isinstance(_max_rec, bool)
else 120.0
)
def _on_silence():
"""Called by AudioRecorder when silence is detected after speech."""
@ -11825,14 +11862,37 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
threading.Thread(target=_refresh_level, daemon=True).start()
def _voice_stt_model(self) -> Optional[str]:
"""STT model override from config, or None for the provider default."""
"""STT model override from config, or None for the provider default.
For the local provider, prefer stt.local.model (default ``base``) so the
CLI passes a real model name into the local STT backend.
"""
try:
from hermes_cli.config import load_config
stt_config = load_config().get("stt", {})
return stt_config.get("model") if isinstance(stt_config, dict) else None
if not isinstance(stt_config, dict):
return None
provider = str(stt_config.get("provider") or "").strip().lower()
if provider == "local":
local_config = stt_config.get("local") or {}
if not isinstance(local_config, dict):
local_config = {}
return local_config.get("model") or "base"
return stt_config.get("model")
except Exception:
return None
def _voice_stt_provider(self) -> str:
"""Configured STT provider name (lowercased), or empty string."""
try:
from hermes_cli.config import load_config
stt_config = load_config().get("stt", {})
if not isinstance(stt_config, dict):
return ""
return str(stt_config.get("provider") or "").strip().lower()
except Exception:
return ""
def _voice_restart_recording_async(self) -> None:
"""Restart continuous-mode recording off-thread (start() can block)."""
def _restart_recording():
@ -11879,10 +11939,18 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
# _voice_processing is already True (set atomically above)
if hasattr(self, '_app') and self._app:
self._app.invalidate()
_cprint(f"{_DIM}Transcribing...{_RST}")
stt_model = self._voice_stt_model()
if self._voice_stt_provider() == "local":
_cprint(
f"{_DIM}Preparing local STT model '{stt_model}' "
f"(first use may download it from Hugging Face)...{_RST}"
)
else:
_cprint(f"{_DIM}Transcribing...{_RST}")
from tools.voice_mode import transcribe_recording
result = transcribe_recording(wav_path, model=self._voice_stt_model())
result = transcribe_recording(wav_path, model=stt_model)
if result.get("success") and result.get("transcript", "").strip():
transcript = result["transcript"].strip()
@ -11896,7 +11964,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
self._attached_images.clear()
if hasattr(self, '_app') and self._app:
self._app.invalidate()
self._pending_input.put(transcript)
self._pending_input.put(_VoiceInputMessage(transcript))
submitted = True
elif result.get("success"):
_cprint(f"{_DIM}No speech detected.{_RST}")
@ -11925,13 +11993,14 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
pass
# Track consecutive no-speech cycles to avoid infinite restart loops.
stop_continuous_restart = False
if not submitted:
self._no_speech_count = getattr(self, '_no_speech_count', 0) + 1
if self._no_speech_count >= 3:
self._voice_continuous = False
self._no_speech_count = 0
_cprint(f"{_DIM}No speech detected 3 times, continuous mode stopped.{_RST}")
return
stop_continuous_restart = True
else:
self._no_speech_count = 0
@ -11939,7 +12008,12 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
# restart recording so the user can keep talking.
# (When transcript IS submitted, process_loop handles restart
# after chat() completes.)
if self._voice_continuous and not submitted and not self._voice_recording:
if (
self._voice_continuous
and not submitted
and not self._voice_recording
and not stop_continuous_restart
):
self._voice_restart_recording_async()
def _voice_speak_response_async(self, text: str) -> None:
@ -11962,19 +12036,26 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
from tools.tts_tool import text_to_speech_tool
from tools.voice_mode import play_audio_file
# Strip markdown and non-speech content for cleaner TTS
tts_text = text[:4000] if len(text) > 4000 else text
tts_text = re.sub(r'```[\s\S]*?```', ' ', tts_text) # fenced code blocks
tts_text = re.sub(r'\[([^\]]+)\]\([^)]+\)', r'\1', tts_text) # [text](url) -> text
tts_text = re.sub(r'https?://\S+', '', tts_text) # URLs
tts_text = re.sub(r'\*\*(.+?)\*\*', r'\1', tts_text) # bold
tts_text = re.sub(r'\*(.+?)\*', r'\1', tts_text) # italic
tts_text = re.sub(r'`(.+?)`', r'\1', tts_text) # inline code
tts_text = re.sub(r'^#+\s*', '', tts_text, flags=re.MULTILINE) # headers
tts_text = re.sub(r'^\s*[-*]\s+', '', tts_text, flags=re.MULTILINE) # list items
tts_text = re.sub(r'---+', '', tts_text) # horizontal rules
tts_text = re.sub(r'\n{3,}', '\n\n', tts_text) # excessive newlines
tts_text = tts_text.strip()
# Strip markdown and non-speech content for cleaner TTS via the
# shared cleaner (tools/tts_text_normalize): markdown, emoji,
# <think> blocks, verifier footer, units, newline flattening.
try:
from tools.tts_text_normalize import prepare_spoken_text
tts_text = prepare_spoken_text(text, max_chars=4000)
except Exception:
# Legacy fallback pipeline — keep voice replies best-effort.
tts_text = text[:4000] if len(text) > 4000 else text
tts_text = re.sub(r'```[\s\S]*?```', ' ', tts_text) # fenced code blocks
tts_text = re.sub(r'\[([^\]]+)\]\([^)]+\)', r'\1', tts_text) # [text](url) -> text
tts_text = re.sub(r'https?://\S+', '', tts_text) # URLs
tts_text = re.sub(r'\*\*(.+?)\*\*', r'\1', tts_text) # bold
tts_text = re.sub(r'\*(.+?)\*', r'\1', tts_text) # italic
tts_text = re.sub(r'`(.+?)`', r'\1', tts_text) # inline code
tts_text = re.sub(r'^#+\s*', '', tts_text, flags=re.MULTILINE) # headers
tts_text = re.sub(r'^\s*[-*]\s+', '', tts_text, flags=re.MULTILINE) # list items
tts_text = re.sub(r'---+', '', tts_text) # horizontal rules
tts_text = re.sub(r'\n{3,}', '\n\n', tts_text) # excessive newlines
tts_text = tts_text.strip()
if not tts_text:
return
@ -11986,17 +12067,30 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
f"tts_{time.strftime('%Y%m%d_%H%M%S')}.mp3",
)
text_to_speech_tool(text=tts_text, output_path=mp3_path)
raw_result = text_to_speech_tool(text=tts_text, output_path=mp3_path)
try:
tts_result = json.loads(raw_result) if isinstance(raw_result, str) else {}
except Exception:
tts_result = {}
# Play the MP3 directly (the TTS tool returns OGG path but MP3 still exists)
if os.path.isfile(mp3_path) and os.path.getsize(mp3_path) > 0:
play_audio_file(mp3_path)
# Prefer the requested MP3 when the provider produced it. This
# preserves reliable local playback while still supporting
# providers that write to and return a different path.
audio_path = mp3_path
if not os.path.isfile(mp3_path) or os.path.getsize(mp3_path) == 0:
audio_path = tts_result.get("file_path") or mp3_path
if os.path.isfile(audio_path) and os.path.getsize(audio_path) > 0:
play_audio_file(audio_path)
# Clean up
try:
os.unlink(mp3_path)
ogg_path = mp3_path.rsplit(".", 1)[0] + ".ogg"
if os.path.isfile(ogg_path):
os.unlink(ogg_path)
cleanup_paths = {audio_path, mp3_path}
for path in list(cleanup_paths):
ogg_path = path.rsplit(".", 1)[0] + ".ogg"
cleanup_paths.add(ogg_path)
for path in cleanup_paths:
if os.path.isfile(path):
os.unlink(path)
except OSError:
pass
except Exception as e:
@ -12058,7 +12152,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
_cprint(f"\n{_DIM}Stop phrase detected — ending voice chat.{_RST}")
self._disable_voice_mode()
return
self._pending_input.put(transcript)
self._pending_input.put(_VoiceInputMessage(transcript))
submitted = True
elif not result.get("success"):
_cprint(f"\n{_DIM}Transcription failed: {result.get('error', 'Unknown error')}{_RST}")
@ -12079,9 +12173,12 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
"""Return whether CLI voice mode should play record start/stop beeps."""
try:
from hermes_cli.config import load_config
from utils import is_truthy_value
voice_cfg = load_config().get("voice", {})
if isinstance(voice_cfg, dict):
return bool(voice_cfg.get("beep_enabled", True))
# is_truthy_value handles quoted YAML strings like "false"
# which bool() would misread as True (#49883).
return is_truthy_value(voice_cfg.get("beep_enabled", True), default=True)
except Exception:
pass
return True
@ -12986,7 +13083,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
except Exception:
pass
def chat(self, message, images: list = None) -> Optional[str]:
def chat(self, message, images: list = None, voice_input: bool = False) -> Optional[str]:
"""
Send a message to the agent and get a response.
@ -13001,6 +13098,8 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
Args:
message: The user's message (str or multimodal content list)
images: Optional list of Path objects for attached images
voice_input: True when the message came from voice transcription
(gates the concise voice-response prefix, #65827)
Returns:
The agent's response, or None on error
@ -13228,7 +13327,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
# model responds concisely. The prefix is API-call-local only —
# run_conversation persists the original clean user message.
_voice_prefix = ""
if self._voice_mode and isinstance(message, str):
if voice_input and isinstance(message, str):
_voice_prefix = (
"[Voice input — respond concisely and conversationally, "
"2-3 sentences max. No code blocks or markdown.] "
@ -15257,16 +15356,17 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
daemon=True,
).start()
else:
# Guard: don't START recording during agent run or interactive prompts
if cli_ref._agent_running:
# Allow disarming continuous mode even when the agent is
# running or transcribing — otherwise the user is stuck in
# an auto-restart loop until /voice off (#67545).
if cli_ref._agent_running or cli_ref._voice_processing:
with cli_ref._voice_lock:
cli_ref._voice_continuous = False
event.app.invalidate()
return
# Guard: don't START recording during interactive prompts
if cli_ref._clarify_state or cli_ref._sudo_state or cli_ref._approval_state or cli_ref._slash_confirm_state:
return
# Guard: don't start while a previous stop/transcribe cycle is
# still running — recorder.stop() holds AudioRecorder._lock and
# start() would block the event-loop thread waiting for it.
if cli_ref._voice_processing:
return
# Interrupt TTS if playing, so user can start talking.
# stop_playback() is fast (just terminates a subprocess);
@ -16391,7 +16491,13 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
except Exception:
pass
continue
# Voice-transcribed messages arrive wrapped in a sentinel
# so only genuine STT output gets the voice prefix (#65827).
is_voice_input = isinstance(user_input, _VoiceInputMessage)
if is_voice_input:
user_input = user_input.text
if not user_input:
continue
@ -16487,7 +16593,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
app.invalidate() # Refresh status line
try:
self.chat(user_input, images=submit_images or None)
self.chat(user_input, images=submit_images or None, voice_input=is_voice_input)
finally:
self._agent_running = False
self._spinner_text = ""
@ -16876,7 +16982,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
# the exit occurred, meaning run_conversation's hook didn't fire.
if self.agent and getattr(self, '_agent_running', False):
try:
from hermes_cli.plugins import invoke_hook as _invoke_hook
from hermes_cli.lifecycle import invoke_hook as _invoke_hook
_invoke_hook(
"on_session_end",
session_id=self.agent.session_id,