fix(tui): restore voice/panic handlers + scope fuzzy paths to cwd
Two fixes on top of the fuzzy-@ branch:
(1) Rebase artefact: re-apply only the fuzzy additions on top of
fresh `tui_gateway/server.py`. The earlier commit was cut from a
base 58 commits behind main and clobbered ~170 lines of
voice.toggle / voice.record handlers and the gateway crash hooks
(`_panic_hook`, `_thread_panic_hook`). Reset server.py to
origin/main and re-add only:
- `_FUZZY_*` constants + `_list_repo_files` + `_fuzzy_basename_rank`
- the new fuzzy branch in the `complete.path` handler
(2) Path scoping (Copilot review): `git ls-files` returns repo-root-
relative paths, but completions need to resolve under the gateway's
cwd. When hermes is launched from a subdirectory, the previous
code surfaced `@file:apps/web/src/foo.tsx` even though the agent
would resolve that relative to `apps/web/` and miss. Fix:
- `git -C root rev-parse --show-toplevel` to get repo top
- `git -C top ls-files …` for the listing
- `os.path.relpath(top + p, root)` per result, dropping anything
starting with `../` so the picker stays scoped to cwd-and-below
(matches Cmd-P workspace semantics)
`apps/web/src/foo.tsx` ends up as `@file:src/foo.tsx` from inside
`apps/web/`, and sibling subtrees + parent-of-cwd files don't leak.
New test `test_fuzzy_paths_relative_to_cwd_inside_subdir` builds a
3-package mono-repo, runs from `apps/web/`, and verifies completion
paths are subtree-relative + outside-of-cwd files don't appear.
Copilot review threads addressed: #3134675504 (path scoping),
#3134675532 (`voice.toggle` regression), #3134675541 (`voice.record`
regression — both were stale-base artefacts, not behavioural changes).
This commit is contained in:
@@ -231,3 +231,49 @@ def test_fuzzy_caps_results(tmp_path, monkeypatch):
|
|||||||
items = _items("@mod")
|
items = _items("@mod")
|
||||||
|
|
||||||
assert len(items) == 30
|
assert len(items) == 30
|
||||||
|
|
||||||
|
|
||||||
|
def test_fuzzy_paths_relative_to_cwd_inside_subdir(tmp_path, monkeypatch):
|
||||||
|
"""When the gateway runs from a subdirectory of a git repo, fuzzy
|
||||||
|
completion paths must resolve under that cwd — not under the repo root.
|
||||||
|
|
||||||
|
Without this, `@appChrome` from inside `apps/web/` would suggest
|
||||||
|
`@file:apps/web/src/foo.tsx` but the agent (resolving from cwd) would
|
||||||
|
look for `apps/web/apps/web/src/foo.tsx` and fail. We translate every
|
||||||
|
`git ls-files` result back to a `relpath(root)` and drop anything
|
||||||
|
outside `root` so the completion contract stays "paths are cwd-relative".
|
||||||
|
"""
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
subprocess.run(["git", "init", "-q"], cwd=tmp_path, check=True)
|
||||||
|
subprocess.run(["git", "config", "user.email", "test@example.com"], cwd=tmp_path, check=True)
|
||||||
|
subprocess.run(["git", "config", "user.name", "test"], cwd=tmp_path, check=True)
|
||||||
|
|
||||||
|
(tmp_path / "apps" / "web" / "src").mkdir(parents=True)
|
||||||
|
(tmp_path / "apps" / "web" / "src" / "appChrome.tsx").write_text("x")
|
||||||
|
(tmp_path / "apps" / "api" / "src").mkdir(parents=True)
|
||||||
|
(tmp_path / "apps" / "api" / "src" / "server.ts").write_text("x")
|
||||||
|
(tmp_path / "README.md").write_text("x")
|
||||||
|
|
||||||
|
subprocess.run(["git", "add", "."], cwd=tmp_path, check=True)
|
||||||
|
subprocess.run(["git", "commit", "-q", "-m", "init"], cwd=tmp_path, check=True)
|
||||||
|
|
||||||
|
# Run from `apps/web/` — completions should be relative to here, and
|
||||||
|
# files outside this subtree (apps/api, README.md at root) shouldn't
|
||||||
|
# appear at all.
|
||||||
|
monkeypatch.chdir(tmp_path / "apps" / "web")
|
||||||
|
|
||||||
|
texts = [t for t, _, _ in _items("@appChrome")]
|
||||||
|
|
||||||
|
assert "@file:src/appChrome.tsx" in texts, texts
|
||||||
|
assert not any("apps/web/" in t for t in texts), texts
|
||||||
|
|
||||||
|
server._fuzzy_cache.clear()
|
||||||
|
other_texts = [t for t, _, _ in _items("@server")]
|
||||||
|
|
||||||
|
assert not any("server.ts" in t for t in other_texts), other_texts
|
||||||
|
|
||||||
|
server._fuzzy_cache.clear()
|
||||||
|
readme_texts = [t for t, _, _ in _items("@README")]
|
||||||
|
|
||||||
|
assert not any("README.md" in t for t in readme_texts), readme_texts
|
||||||
|
|||||||
@@ -23,6 +23,75 @@ load_hermes_dotenv(
|
|||||||
hermes_home=_hermes_home, project_env=Path(__file__).parent.parent / ".env"
|
hermes_home=_hermes_home, project_env=Path(__file__).parent.parent / ".env"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Panic logger ─────────────────────────────────────────────────────
|
||||||
|
# Gateway crashes in a TUI session leave no forensics: stdout is the
|
||||||
|
# JSON-RPC pipe (TUI side parses it, doesn't log raw), the root logger
|
||||||
|
# only catches handled warnings, and the subprocess exits before stderr
|
||||||
|
# flushes through the stderr->gateway.stderr event pump. This hook
|
||||||
|
# appends every unhandled exception to ~/.hermes/logs/tui_gateway_crash.log
|
||||||
|
# AND re-emits a one-line summary to stderr so the TUI can surface it in
|
||||||
|
# Activity — exactly what was missing when the voice-mode turns started
|
||||||
|
# exiting the gateway mid-TTS.
|
||||||
|
_CRASH_LOG = os.path.join(_hermes_home, "logs", "tui_gateway_crash.log")
|
||||||
|
|
||||||
|
|
||||||
|
def _panic_hook(exc_type, exc_value, exc_tb):
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
trace = "".join(traceback.format_exception(exc_type, exc_value, exc_tb))
|
||||||
|
try:
|
||||||
|
os.makedirs(os.path.dirname(_CRASH_LOG), exist_ok=True)
|
||||||
|
with open(_CRASH_LOG, "a", encoding="utf-8") as f:
|
||||||
|
f.write(
|
||||||
|
f"\n=== unhandled exception · {time.strftime('%Y-%m-%d %H:%M:%S')} ===\n"
|
||||||
|
)
|
||||||
|
f.write(trace)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
# Stderr goes through to the TUI as a gateway.stderr Activity line —
|
||||||
|
# the first line here is what the user will see without opening any
|
||||||
|
# log files. Rest of the stack is still in the log for full context.
|
||||||
|
first = str(exc_value).strip().splitlines()[0] if str(exc_value).strip() else exc_type.__name__
|
||||||
|
print(f"[gateway-crash] {exc_type.__name__}: {first}", file=sys.stderr, flush=True)
|
||||||
|
# Chain to the default hook so the process still terminates normally.
|
||||||
|
sys.__excepthook__(exc_type, exc_value, exc_tb)
|
||||||
|
|
||||||
|
|
||||||
|
sys.excepthook = _panic_hook
|
||||||
|
|
||||||
|
|
||||||
|
def _thread_panic_hook(args):
|
||||||
|
# threading.excepthook signature: SimpleNamespace(exc_type, exc_value, exc_traceback, thread)
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
trace = "".join(
|
||||||
|
traceback.format_exception(args.exc_type, args.exc_value, args.exc_traceback)
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
os.makedirs(os.path.dirname(_CRASH_LOG), exist_ok=True)
|
||||||
|
with open(_CRASH_LOG, "a", encoding="utf-8") as f:
|
||||||
|
f.write(
|
||||||
|
f"\n=== thread exception · {time.strftime('%Y-%m-%d %H:%M:%S')} "
|
||||||
|
f"· thread={args.thread.name} ===\n"
|
||||||
|
)
|
||||||
|
f.write(trace)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
first_line = (
|
||||||
|
str(args.exc_value).strip().splitlines()[0]
|
||||||
|
if str(args.exc_value).strip()
|
||||||
|
else args.exc_type.__name__
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
f"[gateway-crash] thread {args.thread.name} raised {args.exc_type.__name__}: {first_line}",
|
||||||
|
file=sys.stderr,
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
threading.excepthook = _thread_panic_hook
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from hermes_cli.banner import prefetch_update_check
|
from hermes_cli.banner import prefetch_update_check
|
||||||
|
|
||||||
@@ -2126,7 +2195,43 @@ def _(rid, params: dict) -> dict:
|
|||||||
if rendered:
|
if rendered:
|
||||||
payload["rendered"] = rendered
|
payload["rendered"] = rendered
|
||||||
_emit("message.complete", sid, payload)
|
_emit("message.complete", sid, payload)
|
||||||
|
|
||||||
|
# CLI parity: when voice-mode TTS is on, speak the agent reply
|
||||||
|
# (cli.py:_voice_speak_response). Only the final text — tool
|
||||||
|
# calls / reasoning already stream separately and would be
|
||||||
|
# noisy to read aloud.
|
||||||
|
if (
|
||||||
|
status == "complete"
|
||||||
|
and isinstance(raw, str)
|
||||||
|
and raw.strip()
|
||||||
|
and _voice_tts_enabled()
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
from hermes_cli.voice import speak_text
|
||||||
|
|
||||||
|
spoken = raw
|
||||||
|
threading.Thread(
|
||||||
|
target=speak_text, args=(spoken,), daemon=True
|
||||||
|
).start()
|
||||||
|
except ImportError:
|
||||||
|
logger.warning("voice TTS skipped: hermes_cli.voice unavailable")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("voice TTS dispatch failed: %s", e)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
trace = traceback.format_exc()
|
||||||
|
try:
|
||||||
|
os.makedirs(os.path.dirname(_CRASH_LOG), exist_ok=True)
|
||||||
|
with open(_CRASH_LOG, "a", encoding="utf-8") as f:
|
||||||
|
f.write(
|
||||||
|
f"\n=== turn-dispatcher exception · "
|
||||||
|
f"{time.strftime('%Y-%m-%d %H:%M:%S')} · sid={sid} ===\n"
|
||||||
|
)
|
||||||
|
f.write(trace)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
print(f"[gateway-turn] {type(e).__name__}: {e}", file=sys.stderr, flush=True)
|
||||||
_emit("error", sid, {"message": str(e)})
|
_emit("error", sid, {"message": str(e)})
|
||||||
finally:
|
finally:
|
||||||
try:
|
try:
|
||||||
@@ -3177,12 +3282,16 @@ _fuzzy_cache: dict[str, tuple[float, list[str]]] = {}
|
|||||||
|
|
||||||
|
|
||||||
def _list_repo_files(root: str) -> list[str]:
|
def _list_repo_files(root: str) -> list[str]:
|
||||||
"""Return repo-relative file paths rooted at ``root``.
|
"""Return file paths relative to ``root``.
|
||||||
|
|
||||||
Uses ``git ls-files`` when available (fast, honours .gitignore) and falls
|
Uses ``git ls-files`` from the repo top (resolved via
|
||||||
back to a bounded ``os.walk`` that skips common vendor/build dirs. The
|
``rev-parse --show-toplevel``) so the listing covers tracked + untracked
|
||||||
result is cached per-root for ``_FUZZY_CACHE_TTL_S`` so rapid keystrokes
|
files anywhere in the repo, then converts each path back to be relative
|
||||||
don't respawn git processes.
|
to ``root``. Files outside ``root`` (parent directories of cwd, sibling
|
||||||
|
subtrees) are excluded so the picker stays scoped to what's reachable
|
||||||
|
from the gateway's cwd. Falls back to a bounded ``os.walk(root)`` when
|
||||||
|
``root`` isn't inside a git repo. Result cached per-root for
|
||||||
|
``_FUZZY_CACHE_TTL_S`` so rapid keystrokes don't respawn git processes.
|
||||||
"""
|
"""
|
||||||
now = time.monotonic()
|
now = time.monotonic()
|
||||||
with _fuzzy_cache_lock:
|
with _fuzzy_cache_lock:
|
||||||
@@ -3192,19 +3301,32 @@ def _list_repo_files(root: str) -> list[str]:
|
|||||||
|
|
||||||
files: list[str] = []
|
files: list[str] = []
|
||||||
try:
|
try:
|
||||||
result = subprocess.run(
|
top_result = subprocess.run(
|
||||||
["git", "ls-files", "-z", "--cached", "--others", "--exclude-standard"],
|
["git", "-C", root, "rev-parse", "--show-toplevel"],
|
||||||
cwd=root,
|
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
timeout=2.0,
|
timeout=2.0,
|
||||||
check=False,
|
check=False,
|
||||||
)
|
)
|
||||||
if result.returncode == 0:
|
if top_result.returncode == 0:
|
||||||
files = [
|
top = top_result.stdout.decode("utf-8", "replace").strip()
|
||||||
p
|
list_result = subprocess.run(
|
||||||
for p in result.stdout.decode("utf-8", "replace").split("\0")
|
["git", "-C", top, "ls-files", "-z", "--cached", "--others", "--exclude-standard"],
|
||||||
if p
|
capture_output=True,
|
||||||
][:_FUZZY_CACHE_MAX_FILES]
|
timeout=2.0,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
if list_result.returncode == 0:
|
||||||
|
for p in list_result.stdout.decode("utf-8", "replace").split("\0"):
|
||||||
|
if not p:
|
||||||
|
continue
|
||||||
|
rel = os.path.relpath(os.path.join(top, p), root).replace(os.sep, "/")
|
||||||
|
# Skip parents/siblings of cwd — keep the picker scoped
|
||||||
|
# to root-and-below, matching Cmd-P workspace semantics.
|
||||||
|
if rel.startswith("../"):
|
||||||
|
continue
|
||||||
|
files.append(rel)
|
||||||
|
if len(files) >= _FUZZY_CACHE_MAX_FILES:
|
||||||
|
break
|
||||||
except (OSError, subprocess.TimeoutExpired):
|
except (OSError, subprocess.TimeoutExpired):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -3351,12 +3473,11 @@ def _(rid, params: dict) -> dict:
|
|||||||
ranked.sort(key=lambda r: (r[0], len(r[1]), r[1]))
|
ranked.sort(key=lambda r: (r[0], len(r[1]), r[1]))
|
||||||
tag = prefix_tag or "file"
|
tag = prefix_tag or "file"
|
||||||
for _, rel, basename in ranked[:30]:
|
for _, rel, basename in ranked[:30]:
|
||||||
directory = os.path.dirname(rel)
|
|
||||||
items.append(
|
items.append(
|
||||||
{
|
{
|
||||||
"text": f"@{tag}:{rel}",
|
"text": f"@{tag}:{rel}",
|
||||||
"display": basename,
|
"display": basename,
|
||||||
"meta": directory,
|
"meta": os.path.dirname(rel),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -3631,43 +3752,155 @@ def _(rid, params: dict) -> dict:
|
|||||||
# ── Methods: voice ───────────────────────────────────────────────────
|
# ── Methods: voice ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
_voice_sid_lock = threading.Lock()
|
||||||
|
_voice_event_sid: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
def _voice_emit(event: str, payload: dict | None = None) -> None:
|
||||||
|
"""Emit a voice event toward the session that most recently turned the
|
||||||
|
mode on. Voice is process-global (one microphone), so there's only ever
|
||||||
|
one sid to target; the TUI handler treats an empty sid as "active
|
||||||
|
session". Kept separate from _emit to make the lack of per-call sid
|
||||||
|
argument explicit."""
|
||||||
|
with _voice_sid_lock:
|
||||||
|
sid = _voice_event_sid
|
||||||
|
_emit(event, sid, payload)
|
||||||
|
|
||||||
|
|
||||||
|
def _voice_mode_enabled() -> bool:
|
||||||
|
"""Current voice-mode flag (runtime-only, CLI parity).
|
||||||
|
|
||||||
|
cli.py initialises ``_voice_mode = False`` at startup and only flips
|
||||||
|
it via ``/voice on``; it never reads a persisted enable bit from
|
||||||
|
config.yaml. We match that: no config lookup, env var only. This
|
||||||
|
avoids the TUI auto-starting in REC the next time the user opens it
|
||||||
|
just because they happened to enable voice in a prior session.
|
||||||
|
"""
|
||||||
|
return os.environ.get("HERMES_VOICE", "").strip() == "1"
|
||||||
|
|
||||||
|
|
||||||
|
def _voice_tts_enabled() -> bool:
|
||||||
|
"""Whether agent replies should be spoken back via TTS (runtime only)."""
|
||||||
|
return os.environ.get("HERMES_VOICE_TTS", "").strip() == "1"
|
||||||
|
|
||||||
|
|
||||||
@method("voice.toggle")
|
@method("voice.toggle")
|
||||||
def _(rid, params: dict) -> dict:
|
def _(rid, params: dict) -> dict:
|
||||||
|
"""CLI parity for the ``/voice`` slash command.
|
||||||
|
|
||||||
|
Subcommands:
|
||||||
|
|
||||||
|
* ``status`` — report mode + TTS flags (default when action is unknown).
|
||||||
|
* ``on`` / ``off`` — flip voice *mode* (the umbrella bit). Turning it
|
||||||
|
off also tears down any active continuous recording loop. Does NOT
|
||||||
|
start recording on its own; recording is driven by ``voice.record``
|
||||||
|
(Ctrl+B) after mode is on, matching cli.py's enable/Ctrl+B split.
|
||||||
|
* ``tts`` — toggle speech-output of agent replies. Requires mode on
|
||||||
|
(mirrors CLI's _toggle_voice_tts guard).
|
||||||
|
"""
|
||||||
action = params.get("action", "status")
|
action = params.get("action", "status")
|
||||||
|
|
||||||
if action == "status":
|
if action == "status":
|
||||||
env = os.environ.get("HERMES_VOICE", "").strip()
|
# Mirror CLI's _show_voice_status: include STT/TTS provider
|
||||||
if env in {"0", "1"}:
|
# availability so the user can tell at a glance *why* voice mode
|
||||||
return _ok(rid, {"enabled": env == "1"})
|
# isn't working ("STT provider: MISSING ..." is the common case).
|
||||||
return _ok(
|
payload: dict = {
|
||||||
rid,
|
"enabled": _voice_mode_enabled(),
|
||||||
{
|
"tts": _voice_tts_enabled(),
|
||||||
"enabled": bool(
|
}
|
||||||
_load_cfg().get("display", {}).get("voice_enabled", False)
|
try:
|
||||||
)
|
from tools.voice_mode import check_voice_requirements
|
||||||
},
|
|
||||||
)
|
reqs = check_voice_requirements()
|
||||||
|
payload["available"] = bool(reqs.get("available"))
|
||||||
|
payload["audio_available"] = bool(reqs.get("audio_available"))
|
||||||
|
payload["stt_available"] = bool(reqs.get("stt_available"))
|
||||||
|
payload["details"] = reqs.get("details") or ""
|
||||||
|
except Exception as e:
|
||||||
|
# check_voice_requirements pulls optional transcription deps —
|
||||||
|
# swallow so /voice status always returns something useful.
|
||||||
|
logger.warning("voice.toggle status: requirements probe failed: %s", e)
|
||||||
|
|
||||||
|
return _ok(rid, payload)
|
||||||
|
|
||||||
if action in ("on", "off"):
|
if action in ("on", "off"):
|
||||||
enabled = action == "on"
|
enabled = action == "on"
|
||||||
|
# Runtime-only flag (CLI parity) — no _write_config_key, so the
|
||||||
|
# next TUI launch starts with voice OFF instead of auto-REC from a
|
||||||
|
# persisted stale toggle.
|
||||||
os.environ["HERMES_VOICE"] = "1" if enabled else "0"
|
os.environ["HERMES_VOICE"] = "1" if enabled else "0"
|
||||||
_write_config_key("display.voice_enabled", enabled)
|
|
||||||
return _ok(rid, {"enabled": action == "on"})
|
if not enabled:
|
||||||
|
# Disabling the mode must tear the continuous loop down; the
|
||||||
|
# loop holds the microphone and would otherwise keep running.
|
||||||
|
try:
|
||||||
|
from hermes_cli.voice import stop_continuous
|
||||||
|
|
||||||
|
stop_continuous()
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("voice: stop_continuous failed during toggle off: %s", e)
|
||||||
|
|
||||||
|
return _ok(rid, {"enabled": enabled, "tts": _voice_tts_enabled()})
|
||||||
|
|
||||||
|
if action == "tts":
|
||||||
|
if not _voice_mode_enabled():
|
||||||
|
return _err(rid, 4014, "enable voice mode first: /voice on")
|
||||||
|
new_value = not _voice_tts_enabled()
|
||||||
|
# Runtime-only flag (CLI parity) — see voice.toggle on/off above.
|
||||||
|
os.environ["HERMES_VOICE_TTS"] = "1" if new_value else "0"
|
||||||
|
return _ok(rid, {"enabled": True, "tts": new_value})
|
||||||
|
|
||||||
return _err(rid, 4013, f"unknown voice action: {action}")
|
return _err(rid, 4013, f"unknown voice action: {action}")
|
||||||
|
|
||||||
|
|
||||||
@method("voice.record")
|
@method("voice.record")
|
||||||
def _(rid, params: dict) -> dict:
|
def _(rid, params: dict) -> dict:
|
||||||
|
"""VAD-driven continuous record loop, CLI-parity.
|
||||||
|
|
||||||
|
``start`` turns on a VAD loop that emits ``voice.transcript`` events
|
||||||
|
for each detected utterance and auto-restarts for the next turn.
|
||||||
|
``stop`` halts the loop (manual stop; matches cli.py's Ctrl+B-while-
|
||||||
|
recording branch clearing ``_voice_continuous``). Three consecutive
|
||||||
|
silent cycles stop the loop automatically and emit a
|
||||||
|
``voice.transcript`` with ``no_speech_limit=True``.
|
||||||
|
"""
|
||||||
action = params.get("action", "start")
|
action = params.get("action", "start")
|
||||||
|
|
||||||
|
if action not in {"start", "stop"}:
|
||||||
|
return _err(rid, 4019, f"unknown voice action: {action}")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if action == "start":
|
if action == "start":
|
||||||
from hermes_cli.voice import start_recording
|
if not _voice_mode_enabled():
|
||||||
|
return _err(rid, 4015, "voice mode is off — enable with /voice on")
|
||||||
|
|
||||||
start_recording()
|
with _voice_sid_lock:
|
||||||
|
global _voice_event_sid
|
||||||
|
_voice_event_sid = params.get("session_id") or _voice_event_sid
|
||||||
|
|
||||||
|
from hermes_cli.voice import start_continuous
|
||||||
|
|
||||||
|
voice_cfg = _load_cfg().get("voice", {})
|
||||||
|
start_continuous(
|
||||||
|
on_transcript=lambda t: _voice_emit(
|
||||||
|
"voice.transcript", {"text": t}
|
||||||
|
),
|
||||||
|
on_status=lambda s: _voice_emit("voice.status", {"state": s}),
|
||||||
|
on_silent_limit=lambda: _voice_emit(
|
||||||
|
"voice.transcript", {"no_speech_limit": True}
|
||||||
|
),
|
||||||
|
silence_threshold=voice_cfg.get("silence_threshold", 200),
|
||||||
|
silence_duration=voice_cfg.get("silence_duration", 3.0),
|
||||||
|
)
|
||||||
return _ok(rid, {"status": "recording"})
|
return _ok(rid, {"status": "recording"})
|
||||||
if action == "stop":
|
|
||||||
from hermes_cli.voice import stop_and_transcribe
|
|
||||||
|
|
||||||
return _ok(rid, {"text": stop_and_transcribe() or ""})
|
# action == "stop"
|
||||||
return _err(rid, 4019, f"unknown voice action: {action}")
|
from hermes_cli.voice import stop_continuous
|
||||||
|
|
||||||
|
stop_continuous()
|
||||||
|
return _ok(rid, {"status": "stopped"})
|
||||||
except ImportError:
|
except ImportError:
|
||||||
return _err(
|
return _err(
|
||||||
rid, 5025, "voice module not available — install audio dependencies"
|
rid, 5025, "voice module not available — install audio dependencies"
|
||||||
|
|||||||
Reference in New Issue
Block a user