Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
77f10fa611 |
33
cli.py
33
cli.py
@@ -6852,12 +6852,11 @@ class HermesCLI:
|
||||
self._voice_stop_and_transcribe()
|
||||
|
||||
# Audio cue: single beep BEFORE starting stream (avoid CoreAudio conflict)
|
||||
if self._voice_beeps_enabled():
|
||||
try:
|
||||
from tools.voice_mode import play_beep
|
||||
play_beep(frequency=880, count=1)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
from tools.voice_mode import play_beep
|
||||
play_beep(frequency=880, count=1)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
self._voice_recorder.start(on_silence_stop=_on_silence)
|
||||
@@ -6905,12 +6904,11 @@ class HermesCLI:
|
||||
wav_path = self._voice_recorder.stop()
|
||||
|
||||
# Audio cue: double beep after stream stopped (no CoreAudio conflict)
|
||||
if self._voice_beeps_enabled():
|
||||
try:
|
||||
from tools.voice_mode import play_beep
|
||||
play_beep(frequency=660, count=2)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
from tools.voice_mode import play_beep
|
||||
play_beep(frequency=660, count=2)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if wav_path is None:
|
||||
_cprint(f"{_DIM}No speech detected.{_RST}")
|
||||
@@ -7061,17 +7059,6 @@ class HermesCLI:
|
||||
_cprint(f"Unknown voice subcommand: {subcommand}")
|
||||
_cprint("Usage: /voice [on|off|tts|status]")
|
||||
|
||||
def _voice_beeps_enabled(self) -> bool:
|
||||
"""Return whether CLI voice mode should play record start/stop beeps."""
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
voice_cfg = load_config().get("voice", {})
|
||||
if isinstance(voice_cfg, dict):
|
||||
return bool(voice_cfg.get("beep_enabled", True))
|
||||
except Exception:
|
||||
pass
|
||||
return True
|
||||
|
||||
def _enable_voice_mode(self):
|
||||
"""Enable voice mode after checking requirements."""
|
||||
if self._voice_mode:
|
||||
|
||||
@@ -38,6 +38,18 @@ def _cron_api(**kwargs):
|
||||
return json.loads(cronjob_tool(**kwargs))
|
||||
|
||||
|
||||
def _print_runtime_overrides(job: dict) -> None:
|
||||
model = job.get("model")
|
||||
provider = job.get("provider")
|
||||
base_url = job.get("base_url")
|
||||
if model:
|
||||
print(f" Model: {model}")
|
||||
if provider:
|
||||
print(f" Provider: {provider}")
|
||||
if base_url:
|
||||
print(f" Base URL: {base_url}")
|
||||
|
||||
|
||||
def cron_list(show_all: bool = False):
|
||||
"""List all scheduled jobs."""
|
||||
from cron.jobs import list_jobs
|
||||
@@ -93,6 +105,7 @@ def cron_list(show_all: bool = False):
|
||||
script = job.get("script")
|
||||
if script:
|
||||
print(f" Script: {script}")
|
||||
_print_runtime_overrides(job)
|
||||
|
||||
# Execution history
|
||||
last_status = job.get("last_status")
|
||||
@@ -167,6 +180,9 @@ def cron_create(args):
|
||||
repeat=getattr(args, "repeat", None),
|
||||
skill=getattr(args, "skill", None),
|
||||
skills=_normalize_skills(getattr(args, "skill", None), getattr(args, "skills", None)),
|
||||
model=getattr(args, "model", None),
|
||||
provider=getattr(args, "provider", None),
|
||||
base_url=getattr(args, "base_url", None),
|
||||
script=getattr(args, "script", None),
|
||||
)
|
||||
if not result.get("success"):
|
||||
@@ -180,6 +196,8 @@ def cron_create(args):
|
||||
job_data = result.get("job", {})
|
||||
if job_data.get("script"):
|
||||
print(f" Script: {job_data['script']}")
|
||||
if job_data:
|
||||
_print_runtime_overrides(job_data)
|
||||
print(f" Next run: {result['next_run_at']}")
|
||||
return 0
|
||||
|
||||
@@ -217,6 +235,9 @@ def cron_edit(args):
|
||||
deliver=getattr(args, "deliver", None),
|
||||
repeat=getattr(args, "repeat", None),
|
||||
skills=final_skills,
|
||||
model=getattr(args, "model", None),
|
||||
provider=getattr(args, "provider", None),
|
||||
base_url=getattr(args, "base_url", None),
|
||||
script=getattr(args, "script", None),
|
||||
)
|
||||
if not result.get("success"):
|
||||
@@ -233,6 +254,7 @@ def cron_edit(args):
|
||||
print(" Skills: none")
|
||||
if updated.get("script"):
|
||||
print(f" Script: {updated['script']}")
|
||||
_print_runtime_overrides(updated)
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
@@ -4958,6 +4958,9 @@ For more help on a command:
|
||||
cron_create.add_argument("--deliver", help="Delivery target: origin, local, telegram, discord, signal, or platform:chat_id")
|
||||
cron_create.add_argument("--repeat", type=int, help="Optional repeat count")
|
||||
cron_create.add_argument("--skill", dest="skills", action="append", help="Attach a skill. Repeat to add multiple skills.")
|
||||
cron_create.add_argument("--model", help="Pin this job to a specific model (for example: google/gemma-4-31b-it)")
|
||||
cron_create.add_argument("--provider", help="Pin this job to a specific provider (for example: openrouter)")
|
||||
cron_create.add_argument("--base-url", dest="base_url", help="Optional base URL override for the job's runtime provider")
|
||||
cron_create.add_argument("--script", help="Path to a Python script whose stdout is injected into the prompt each run")
|
||||
|
||||
# cron edit
|
||||
@@ -4972,6 +4975,9 @@ For more help on a command:
|
||||
cron_edit.add_argument("--add-skill", dest="add_skills", action="append", help="Append a skill without replacing the existing list. Repeatable.")
|
||||
cron_edit.add_argument("--remove-skill", dest="remove_skills", action="append", help="Remove a specific attached skill. Repeatable.")
|
||||
cron_edit.add_argument("--clear-skills", action="store_true", help="Remove all attached skills from the job")
|
||||
cron_edit.add_argument("--model", help="Update the job's pinned model")
|
||||
cron_edit.add_argument("--provider", help="Update the job's pinned provider")
|
||||
cron_edit.add_argument("--base-url", dest="base_url", help="Update the job's pinned base URL. Pass an empty string to clear it.")
|
||||
cron_edit.add_argument("--script", help="Path to a Python script whose stdout is injected into the prompt each run. Pass empty string to clear.")
|
||||
|
||||
# lifecycle actions
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Tests for hermes_cli.cron command handling."""
|
||||
|
||||
from argparse import Namespace
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -105,3 +106,135 @@ class TestCronCommandLifecycle:
|
||||
assert len(jobs) == 1
|
||||
assert jobs[0]["skills"] == ["blogwatcher", "find-nearby"]
|
||||
assert jobs[0]["name"] == "Skill combo"
|
||||
|
||||
def test_create_can_pin_runtime_model_provider_and_base_url(self, tmp_cron_dir, capsys):
|
||||
cron_command(
|
||||
Namespace(
|
||||
cron_command="create",
|
||||
schedule="every 1h",
|
||||
prompt="Run the burn loop",
|
||||
name="Gemma burn",
|
||||
deliver=None,
|
||||
repeat=None,
|
||||
skill=None,
|
||||
skills=None,
|
||||
script=None,
|
||||
model="google/gemma-4-31b-it",
|
||||
provider="openrouter",
|
||||
base_url="https://openrouter.ai/api/v1",
|
||||
)
|
||||
)
|
||||
|
||||
job = list_jobs()[0]
|
||||
assert job["model"] == "google/gemma-4-31b-it"
|
||||
assert job["provider"] == "openrouter"
|
||||
assert job["base_url"] == "https://openrouter.ai/api/v1"
|
||||
|
||||
out = capsys.readouterr().out
|
||||
assert "Created job" in out
|
||||
assert "Model: google/gemma-4-31b-it" in out
|
||||
assert "Provider: openrouter" in out
|
||||
|
||||
def test_edit_can_update_runtime_model_provider_and_clear_base_url(self, tmp_cron_dir, capsys):
|
||||
job = create_job(prompt="Check server status", schedule="every 1h")
|
||||
|
||||
cron_command(
|
||||
Namespace(
|
||||
cron_command="edit",
|
||||
job_id=job["id"],
|
||||
schedule=None,
|
||||
prompt=None,
|
||||
name=None,
|
||||
deliver=None,
|
||||
repeat=None,
|
||||
skill=None,
|
||||
skills=None,
|
||||
add_skills=None,
|
||||
remove_skills=None,
|
||||
clear_skills=False,
|
||||
script=None,
|
||||
model="google/gemma-4-31b-it",
|
||||
provider="openrouter",
|
||||
base_url="",
|
||||
)
|
||||
)
|
||||
|
||||
updated = get_job(job["id"])
|
||||
assert updated["model"] == "google/gemma-4-31b-it"
|
||||
assert updated["provider"] == "openrouter"
|
||||
assert updated["base_url"] is None
|
||||
|
||||
out = capsys.readouterr().out
|
||||
assert "Updated job" in out
|
||||
assert "Model: google/gemma-4-31b-it" in out
|
||||
assert "Provider: openrouter" in out
|
||||
|
||||
|
||||
class TestCronParserRuntimeOverrideFlags:
|
||||
def test_main_parses_create_runtime_override_flags(self, monkeypatch):
|
||||
from hermes_cli import main as main_mod
|
||||
|
||||
captured = {}
|
||||
|
||||
def fake_cmd_cron(args):
|
||||
captured["args"] = args
|
||||
|
||||
monkeypatch.setattr(main_mod, "cmd_cron", fake_cmd_cron)
|
||||
monkeypatch.setattr(
|
||||
"sys.argv",
|
||||
[
|
||||
"hermes",
|
||||
"cron",
|
||||
"create",
|
||||
"every 1h",
|
||||
"Run the burn loop",
|
||||
"--model",
|
||||
"google/gemma-4-31b-it",
|
||||
"--provider",
|
||||
"openrouter",
|
||||
"--base-url",
|
||||
"https://openrouter.ai/api/v1",
|
||||
],
|
||||
)
|
||||
|
||||
main_mod.main()
|
||||
|
||||
args = captured["args"]
|
||||
assert args.cron_command == "create"
|
||||
assert args.model == "google/gemma-4-31b-it"
|
||||
assert args.provider == "openrouter"
|
||||
assert args.base_url == "https://openrouter.ai/api/v1"
|
||||
|
||||
def test_main_parses_edit_runtime_override_flags(self, monkeypatch):
|
||||
from hermes_cli import main as main_mod
|
||||
|
||||
captured = {}
|
||||
|
||||
def fake_cmd_cron(args):
|
||||
captured["args"] = args
|
||||
|
||||
monkeypatch.setattr(main_mod, "cmd_cron", fake_cmd_cron)
|
||||
monkeypatch.setattr(
|
||||
"sys.argv",
|
||||
[
|
||||
"hermes",
|
||||
"cron",
|
||||
"edit",
|
||||
"job123",
|
||||
"--model",
|
||||
"google/gemma-4-31b-it",
|
||||
"--provider",
|
||||
"openrouter",
|
||||
"--base-url",
|
||||
"",
|
||||
],
|
||||
)
|
||||
|
||||
main_mod.main()
|
||||
|
||||
args = captured["args"]
|
||||
assert args.cron_command == "edit"
|
||||
assert args.job_id == "job123"
|
||||
assert args.model == "google/gemma-4-31b-it"
|
||||
assert args.provider == "openrouter"
|
||||
assert args.base_url == ""
|
||||
|
||||
@@ -4,31 +4,13 @@ state management, streaming TTS activation, voice message prefix, _vprint."""
|
||||
import ast
|
||||
import os
|
||||
import queue
|
||||
import sys
|
||||
import threading
|
||||
import types
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _ensure_cli_import_shims():
|
||||
sys.modules.setdefault(
|
||||
"agent.auxiliary_client",
|
||||
types.SimpleNamespace(
|
||||
call_llm=lambda *args, **kwargs: "",
|
||||
async_call_llm=lambda *args, **kwargs: "",
|
||||
extract_content_or_reasoning=lambda *args, **kwargs: "",
|
||||
resolve_provider_client=lambda *args, **kwargs: (None, None, None, None),
|
||||
get_async_text_auxiliary_client=lambda *args, **kwargs: None,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
_ensure_cli_import_shims()
|
||||
|
||||
|
||||
def _make_voice_cli(**overrides):
|
||||
"""Create a minimal HermesCLI with only voice-related attrs initialized.
|
||||
|
||||
@@ -36,7 +18,6 @@ def _make_voice_cli(**overrides):
|
||||
needed. Only the voice state attributes (from __init__ lines 3749-3758)
|
||||
are populated.
|
||||
"""
|
||||
_ensure_cli_import_shims()
|
||||
from cli import HermesCLI
|
||||
|
||||
cli = HermesCLI.__new__(HermesCLI)
|
||||
@@ -952,58 +933,6 @@ class TestEnableVoiceModeReal:
|
||||
assert cli._voice_mode is True
|
||||
|
||||
|
||||
class TestVoiceBeepConfigReal:
|
||||
"""Tests the CLI voice beep toggle."""
|
||||
|
||||
@patch("hermes_cli.config.load_config", return_value={"voice": {}})
|
||||
def test_beeps_enabled_by_default(self, _cfg):
|
||||
cli = _make_voice_cli()
|
||||
assert cli._voice_beeps_enabled() is True
|
||||
|
||||
@patch("hermes_cli.config.load_config", return_value={"voice": {"beep_enabled": False}})
|
||||
def test_beeps_can_be_disabled(self, _cfg):
|
||||
cli = _make_voice_cli()
|
||||
assert cli._voice_beeps_enabled() is False
|
||||
|
||||
@patch("cli._cprint")
|
||||
@patch("cli.threading.Thread")
|
||||
@patch("tools.voice_mode.play_beep")
|
||||
@patch("tools.voice_mode.create_audio_recorder")
|
||||
@patch(
|
||||
"tools.voice_mode.check_voice_requirements",
|
||||
return_value={
|
||||
"available": True,
|
||||
"audio_available": True,
|
||||
"stt_available": True,
|
||||
"details": "OK",
|
||||
"missing_packages": [],
|
||||
},
|
||||
)
|
||||
@patch(
|
||||
"hermes_cli.config.load_config",
|
||||
return_value={
|
||||
"voice": {
|
||||
"beep_enabled": False,
|
||||
"silence_threshold": 200,
|
||||
"silence_duration": 3.0,
|
||||
}
|
||||
},
|
||||
)
|
||||
def test_start_recording_skips_beep_when_disabled(
|
||||
self, _cfg, _req, mock_create, mock_beep, mock_thread, _cp
|
||||
):
|
||||
recorder = MagicMock()
|
||||
recorder.supports_silence_autostop = True
|
||||
mock_create.return_value = recorder
|
||||
mock_thread.return_value = MagicMock(start=MagicMock())
|
||||
|
||||
cli = _make_voice_cli()
|
||||
cli._voice_start_recording()
|
||||
|
||||
recorder.start.assert_called_once()
|
||||
mock_beep.assert_not_called()
|
||||
|
||||
|
||||
class TestDisableVoiceModeReal:
|
||||
"""Tests _disable_voice_mode with real CLI instance."""
|
||||
|
||||
@@ -1158,16 +1087,6 @@ class TestVoiceStopAndTranscribeReal:
|
||||
cli._voice_stop_and_transcribe()
|
||||
assert cli._pending_input.empty()
|
||||
|
||||
@patch("cli._cprint")
|
||||
@patch("hermes_cli.config.load_config", return_value={"voice": {"beep_enabled": False}})
|
||||
@patch("tools.voice_mode.play_beep")
|
||||
def test_no_speech_detected_skips_beep_when_disabled(self, mock_beep, _cfg, _cp):
|
||||
recorder = MagicMock()
|
||||
recorder.stop.return_value = None
|
||||
cli = _make_voice_cli(_voice_recording=True, _voice_recorder=recorder)
|
||||
cli._voice_stop_and_transcribe()
|
||||
mock_beep.assert_not_called()
|
||||
|
||||
@patch("cli._cprint")
|
||||
@patch("cli.os.unlink")
|
||||
@patch("cli.os.path.isfile", return_value=True)
|
||||
@@ -1237,18 +1156,12 @@ class TestVoiceStopAndTranscribeReal:
|
||||
@patch("cli._cprint")
|
||||
@patch("tools.voice_mode.play_beep")
|
||||
def test_continuous_restarts_on_no_speech(self, _beep, _cp):
|
||||
import time
|
||||
|
||||
recorder = MagicMock()
|
||||
recorder.stop.return_value = None
|
||||
cli = _make_voice_cli(_voice_recording=True, _voice_recorder=recorder,
|
||||
_voice_continuous=True)
|
||||
cli._voice_start_recording = MagicMock()
|
||||
cli._voice_stop_and_transcribe()
|
||||
for _ in range(50):
|
||||
if cli._voice_start_recording.call_count:
|
||||
break
|
||||
time.sleep(0.01)
|
||||
cli._voice_start_recording.assert_called_once()
|
||||
|
||||
@patch("cli._cprint")
|
||||
|
||||
@@ -149,7 +149,7 @@ Two-stage algorithm detects when you've finished speaking:
|
||||
|
||||
If no speech is detected at all for 15 seconds, recording stops automatically.
|
||||
|
||||
Both `silence_threshold` and `silence_duration` are configurable in `config.yaml`. You can also disable the record start/stop beeps with `voice.beep_enabled: false`.
|
||||
Both `silence_threshold` and `silence_duration` are configurable in `config.yaml`.
|
||||
|
||||
### Streaming TTS
|
||||
|
||||
@@ -383,7 +383,6 @@ voice:
|
||||
record_key: "ctrl+b" # Key to start/stop recording
|
||||
max_recording_seconds: 120 # Maximum recording length
|
||||
auto_tts: false # Auto-enable TTS when voice mode starts
|
||||
beep_enabled: true # Play record start/stop beeps
|
||||
silence_threshold: 200 # RMS level (0-32767) below which counts as silence
|
||||
silence_duration: 3.0 # Seconds of silence before auto-stop
|
||||
|
||||
|
||||
Reference in New Issue
Block a user