mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-11-06 05:17:15 +00:00
fix(pm): voice enables audio-io through PM; public plugin-state readers; PM tests isolate the machine home
- voice_mode: `_import_audio` asks `pm.ensure_import("audio-io")` before importing
sounddevice, so `/voice on` turns the feature on (or reports exactly why PM
refused: lazy installs off, platform gate, restart needed) instead of printing a
`python -c "from pm import sync_venv…"` one-liner for the user to run.
- pm.plugins_state: `read_home_selection` and `dependency_homes` are the public
readers; memory_provider_migration and plugins_cmd stop importing underscored names.
- pm.store: the `sha256_file` shim is gone; the authority test asserts the Store has no
file hash of its own rather than patching one.
- tests/pm/conftest: `isolated_machine_home` is autouse (Path.home, HOME, USERPROFILE,
HERMES_HOME all under tmp_path); `@pytest.mark.real_machine_home` opts a module out
— the four suites that spawn children with a home they build themselves.
- activate / activate.ps1 / Dockerfile followed hermes_cli.runtime_paths to
pm.environments (the earlier sweep only covered .py).
- pm.registry loads builtins through importlib so a test patching `pm.packages` in
sys.modules still registers the security packages.
This commit is contained in:
1 parent
7f6ec2749e
commit
a00ca233f8
30 files changed
+105
-79
No files matched your search
+2
-2
@@ -183,8 +183,8 @@ COPY pm/ pm/
|
||||
# `python3 -m pm.cli install` fails with "No module named
|
||||
# 'hermes_constants'" without it on the path. Copy the module next to pm.
|
||||
COPY hermes_constants.py hermes_constants.py
|
||||
# PM imports the shared stdlib runtime path and locking owners before deps exist.
|
||||
COPY hermes_cli/__init__.py hermes_cli/runtime_paths.py hermes_cli/runtime_state.py hermes_cli/
|
||||
# PM imports the shared stdlib locking owner before deps exist.
|
||||
COPY hermes_cli/__init__.py hermes_cli/runtime_state.py hermes_cli/
|
||||
COPY scripts/bundles/payload.py scripts/bundles/payload.py
|
||||
RUN set -eu; \
|
||||
python3 -c 'from pm import ensure; [ensure(name, explicit=True) for name in ("uv", "chromium", "npm")]'; \
|
||||
|
||||
@@ -50,7 +50,7 @@ if [ -z "$_hermes_py" ]; then
|
||||
fi
|
||||
|
||||
# --- emit export lines from `pm env` ---
|
||||
_hermes_json="$(PYTHONHOME= PYTHONPATH="$_hermes_repo" "$_hermes_py" -m hermes_cli.runtime_paths)"
|
||||
_hermes_json="$(PYTHONHOME= PYTHONPATH="$_hermes_repo" "$_hermes_py" -m pm.environments)"
|
||||
if [ -z "$_hermes_json" ] || [ "${_hermes_json#*{}" = "$_hermes_json" ]; then
|
||||
echo "activate: could not read pm env (run ./setup-hermes.sh first)" >&2
|
||||
unset _hermes_repo _hermes_os _hermes_arch _hermes_target _hermes_store _hermes_py _hermes_json
|
||||
|
||||
+1
-1
@@ -45,7 +45,7 @@ $priorHome = $env:PYTHONHOME
|
||||
try {
|
||||
$env:PYTHONPATH = $repo
|
||||
Remove-Item env:PYTHONHOME -ErrorAction SilentlyContinue
|
||||
$json = (& $py -m hermes_cli.runtime_paths) -join "`n"
|
||||
$json = (& $py -m pm.environments) -join "`n"
|
||||
if ($LASTEXITCODE -ne 0) { throw 'activate: could not read the installed environment' }
|
||||
$composed = $json | ConvertFrom-Json
|
||||
} finally {
|
||||
|
||||
@@ -78,11 +78,9 @@ class CLIVoiceMixin:
|
||||
"Run pkg install termux-api and install the Termux:API Android app, "
|
||||
"then retry /voice on."
|
||||
)
|
||||
raise RuntimeError(
|
||||
"Voice mode requires sounddevice and numpy.\n"
|
||||
"From the Hermes environment, run: "
|
||||
"python -c \"from pm import sync_venv; sync_venv(['audio-io'], explicit=True)\". "
|
||||
"Then restart Hermes.")
|
||||
# check_voice_requirements already asked PM to enable audio-io; its detail line
|
||||
# says why that did not happen (lazy installs off, needs restart, platform gate).
|
||||
raise RuntimeError("Voice mode requires audio capture.\n" + reqs.get("details", ""))
|
||||
if not reqs.get("stt_available", reqs.get("stt_key_set")):
|
||||
raise RuntimeError(
|
||||
"Voice mode requires an STT provider for transcription.\n"
|
||||
@@ -504,11 +502,6 @@ class CLIVoiceMixin:
|
||||
if _is_termux_environment():
|
||||
_cprint(f"\n {_BOLD}Run: pkg install termux-api{_RST}")
|
||||
_cprint(f" {_DIM}Then install/update the Termux:API Android app for microphone capture{_RST}")
|
||||
else:
|
||||
_cprint(f"\n {_BOLD}From the Hermes environment, run: "
|
||||
"python -c \"from pm import sync_venv; sync_venv(['audio-io'], explicit=True)\""
|
||||
f"{_RST}")
|
||||
_cprint(f" {_DIM}Then restart Hermes.{_RST}")
|
||||
return
|
||||
|
||||
with self._voice_lock:
|
||||
|
||||
@@ -27,8 +27,8 @@ _attempted: set[str] = set()
|
||||
|
||||
def configured_provider(home: Path) -> str:
|
||||
"""``memory.provider`` of *home*'s effective config, or ``""``."""
|
||||
from pm.plugins_state import _read_home_config
|
||||
memory = (_read_home_config(home) or {}).get("memory") or {}
|
||||
from pm.plugins_state import read_home_selection
|
||||
memory = (read_home_selection(home) or {}).get("memory") or {}
|
||||
return str(memory.get("provider") or "").strip()
|
||||
|
||||
|
||||
@@ -93,9 +93,9 @@ def _install_into(home: Path) -> Callable[[str], dict]:
|
||||
|
||||
def migrate_all_homes(*, say: Callable[[str], None] = print) -> list[str]:
|
||||
"""``hermes update`` hook: every profile home sharing this venv. Returns installed plugin names."""
|
||||
from pm.plugins_state import _all_homes
|
||||
from pm.plugins_state import dependency_homes
|
||||
installed: list[str] = []
|
||||
for home in _all_homes():
|
||||
for home in dependency_homes():
|
||||
try:
|
||||
name = migrate_home(home, install=_install_into(home), say=say)
|
||||
except Exception as exc:
|
||||
|
||||
@@ -1146,10 +1146,10 @@ def _discard_key_and_leaf(names: set, key: str) -> None:
|
||||
|
||||
def _set_plugin_enabled(name: str, *, enable: bool, aliases=(), console=None) -> None:
|
||||
"""Submit the command's delta with the version of the selection it read."""
|
||||
from pm.plugins_state import _read_home_config
|
||||
from pm.plugins_state import read_home_selection
|
||||
|
||||
expected_config = _plugin_selection_version()
|
||||
config = _read_home_config(get_hermes_home()) or {}
|
||||
config = read_home_selection(get_hermes_home()) or {}
|
||||
plugins = config.get("plugins") or {}
|
||||
enabled = set(plugins.get("enabled") or ())
|
||||
disabled = set(plugins.get("disabled") or ())
|
||||
|
||||
+10
-8
@@ -16,11 +16,12 @@ def _profiles_root() -> Path:
|
||||
return dependency_home_root() / "profiles"
|
||||
|
||||
|
||||
def _read_home_config(home: Path) -> Optional[dict[str, Any]]:
|
||||
"""Read a selection once; only an absent file means an unknown home.
|
||||
def read_home_selection(home: Path) -> Optional[dict[str, Any]]:
|
||||
"""The plugin/memory selection a home's config.yaml declares (None: no config yet).
|
||||
|
||||
An unreadable selection must not shrink the next dependency generation.
|
||||
Empty YAML is an explicit empty configuration, as in the CLI loader.
|
||||
The public reader for anything that must agree with what PM installs for that home.
|
||||
An unreadable selection raises rather than shrinking the next dependency generation;
|
||||
empty YAML is an explicit empty configuration, as in the CLI loader.
|
||||
"""
|
||||
config_path = home / "config.yaml"
|
||||
try:
|
||||
@@ -86,8 +87,9 @@ def _is_directory(path: Path) -> bool:
|
||||
raise ValueError(f"could not inspect plugin directory: {path}") from exc
|
||||
|
||||
|
||||
def _all_homes() -> list[Path]:
|
||||
"""Enumerate the complete union or refuse; a partial scan cannot remove members."""
|
||||
def dependency_homes() -> list[Path]:
|
||||
"""Every home whose selection feeds the shared venv: the default home plus each profile.
|
||||
Enumerates the complete union or refuses; a partial scan cannot remove members."""
|
||||
from pm.environments import dependency_home_root
|
||||
|
||||
homes = [dependency_home_root()]
|
||||
@@ -112,9 +114,9 @@ def enabled_plugins_ordered(*, proposed_home=None, enabled=None, disabled=None,
|
||||
the union. Admission refuses a conflicting candidate without changing
|
||||
the active environment or disabling an existing provider."""
|
||||
out: dict[Path, list[str]] = {}
|
||||
for home in _all_homes():
|
||||
for home in dependency_homes():
|
||||
# ONE parse per home feeds both queries (enabled + provider).
|
||||
config = _read_home_config(home)
|
||||
config = read_home_selection(home)
|
||||
config = config or {}
|
||||
if proposed_home is not None and home.resolve() == Path(proposed_home).resolve():
|
||||
config = {**config, "plugins": {"enabled": list(enabled or ()), "disabled": list(disabled or ())}}
|
||||
|
||||
+2
-2
@@ -29,8 +29,8 @@ def candidate_members(extra_dirs=(), **selection):
|
||||
|
||||
|
||||
def selection_snapshot() -> dict[Path, bytes | None]:
|
||||
from pm.plugins_state import _all_homes
|
||||
return {home / "config.yaml": _bytes(home / "config.yaml") for home in _all_homes()}
|
||||
from pm.plugins_state import dependency_homes
|
||||
return {home / "config.yaml": _bytes(home / "config.yaml") for home in dependency_homes()}
|
||||
|
||||
|
||||
def validate_manifest(source: Path) -> dict:
|
||||
|
||||
+6
-3
@@ -14,6 +14,9 @@ from pm.package import InstallError, Package
|
||||
_packages: dict[str, Package] = {}
|
||||
|
||||
|
||||
_BUILTIN_MODULES = ("pm.packages", "pm.security_packages")
|
||||
|
||||
|
||||
def _builtins_loaded() -> dict[str, Package]:
|
||||
"""Register the built-in definitions on first read, not on ``import pm``.
|
||||
|
||||
@@ -22,9 +25,9 @@ def _builtins_loaded() -> dict[str, Package]:
|
||||
cost every launch ~40ms and break the stripped payloads that ship only the
|
||||
pre-import files.
|
||||
"""
|
||||
if "pm.packages" not in sys.modules:
|
||||
import pm.packages # noqa: F401 (registers the built-in definitions)
|
||||
import pm.security_packages # noqa: F401
|
||||
for module in _BUILTIN_MODULES:
|
||||
if module not in sys.modules:
|
||||
importlib.import_module(module)
|
||||
return _packages
|
||||
|
||||
|
||||
|
||||
@@ -103,12 +103,6 @@ def current_target() -> str:
|
||||
return f"linux-{arch}"
|
||||
|
||||
|
||||
def sha256_file(path: Path) -> str:
|
||||
from pm.downloader import _sha256_file
|
||||
|
||||
return _sha256_file(path)
|
||||
|
||||
|
||||
def hash_url(url: str) -> str:
|
||||
"""sha256 of a url's content, streamed. `pm lock` uses this to pin."""
|
||||
import hashlib
|
||||
|
||||
+17
-5
@@ -4,12 +4,24 @@ from pathlib import Path
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def isolated_machine_home(tmp_path, monkeypatch):
|
||||
def pytest_configure(config):
|
||||
config.addinivalue_line(
|
||||
"markers", "real_machine_home: keep Path.home() intact (a subprocess or stage_runtime needs the real one)")
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def isolated_machine_home(request, tmp_path, monkeypatch):
|
||||
"""Machine-scoped PM state (store, caches, profiles root) lands under tmp_path.
|
||||
|
||||
Opt in per module with ``pytestmark = pytest.mark.usefixtures("isolated_machine_home")``.
|
||||
Does not cross a subprocess boundary — tests that spawn set HOME/USERPROFILE themselves.
|
||||
The root conftest already sandboxes HERMES_HOME; PM additionally keys its machine cache
|
||||
off the home directory, so that moves too — in this process (Path.home) and in any child
|
||||
(HOME / USERPROFILE). Opt out with ``@pytest.mark.real_machine_home``.
|
||||
"""
|
||||
if request.node.get_closest_marker("real_machine_home"):
|
||||
return
|
||||
home = tmp_path / ".hermes"
|
||||
home.mkdir(exist_ok=True)
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes"))
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
monkeypatch.setenv("USERPROFILE", str(tmp_path))
|
||||
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||
@@ -12,6 +12,9 @@ from tests.pm.test_activate_scripts import (
|
||||
_bash, _bash_env, _isolated_checkout, _posix, _powershell, _spawnable_python,
|
||||
)
|
||||
|
||||
# Spawns children with a home it builds itself; the parent's must stay real.
|
||||
pytestmark = pytest.mark.real_machine_home
|
||||
|
||||
|
||||
def _sync_checkout(tmp_path: Path):
|
||||
root = _isolated_checkout(tmp_path)
|
||||
|
||||
@@ -22,6 +22,9 @@ import tomllib
|
||||
|
||||
import pytest
|
||||
|
||||
# Spawns children with a home it builds itself; the parent's must stay real.
|
||||
pytestmark = pytest.mark.real_machine_home
|
||||
|
||||
|
||||
class _ArchiveHandler(SimpleHTTPRequestHandler):
|
||||
def copyfile(self, source, outputfile):
|
||||
|
||||
@@ -43,7 +43,7 @@ def test_home_layout_joins_sibling_union(tmp_path, monkeypatch, layout):
|
||||
monkeypatch.delenv("HERMES_HOME", raising=False)
|
||||
else:
|
||||
monkeypatch.setenv("HERMES_HOME", str(active))
|
||||
active.mkdir(parents=True)
|
||||
active.mkdir(parents=True, exist_ok=True)
|
||||
sibling = root / "profiles/sibling"
|
||||
_write_enabled(sibling, ["dep-plug"])
|
||||
member = _make_dep_plugin(sibling / "plugins", "dep-plug")
|
||||
|
||||
@@ -42,9 +42,8 @@ def test_features_path_in_bundle_uses_payload_root(rooted):
|
||||
def test_sync_venv_refuses_outside_frozen_extras(rooted, monkeypatch):
|
||||
feats.write_features(["web", "acp"])
|
||||
|
||||
import sys
|
||||
|
||||
ensure_mod = sys.modules["pm.install"]
|
||||
import pm.install as ensure_mod
|
||||
from pm.package import InstallError
|
||||
|
||||
monkeypatch.setattr(ensure_mod, "lazy_installs_allowed", lambda: False)
|
||||
@@ -63,12 +62,11 @@ def test_sync_venv_refuses_outside_frozen_extras(rooted, monkeypatch):
|
||||
def test_sync_venv_allows_frozen_extras_when_lazy_off(rooted, monkeypatch):
|
||||
feats.write_features(["web"])
|
||||
|
||||
import sys
|
||||
from pm import paths
|
||||
from pm.lock import Facts
|
||||
from pm.environments import install_state_dir, runtime_facts_path
|
||||
|
||||
ensure_mod = sys.modules["pm.install"]
|
||||
import pm.install as ensure_mod
|
||||
|
||||
# Matching stamp alone cannot certify a vanished environment. Reuse only
|
||||
# the recorded selection while retaining the disabled acquisition policy.
|
||||
|
||||
@@ -33,7 +33,6 @@ def archive(files: dict[str, bytes]) -> bytes:
|
||||
return output.getvalue()
|
||||
|
||||
|
||||
pytestmark = pytest.mark.usefixtures("isolated_machine_home")
|
||||
|
||||
|
||||
@pytest.fixture(params=["install", "stage"])
|
||||
|
||||
@@ -314,7 +314,8 @@ def test_fetch_cache_verification_is_owned_by_downloader(pm_env, monkeypatch, po
|
||||
if poisoned:
|
||||
first.write_bytes(b"poisoned")
|
||||
|
||||
monkeypatch.setattr(store_mod, "sha256_file", lambda *args: pytest.fail("Store rehashed downloader-verified bytes"))
|
||||
# Verification is the downloader's alone: the Store carries no file-hash helper to reach for.
|
||||
assert not any(name.startswith("sha256") for name in vars(store_mod))
|
||||
with store.scratch() as scratch:
|
||||
second = store.fetch(url, digest, scratch)
|
||||
assert second == first
|
||||
|
||||
@@ -15,7 +15,6 @@ from pm.lock import Facts
|
||||
from pm.runtime import runtime_environment
|
||||
from tests.pm._fixtures import _wheel
|
||||
|
||||
pytestmark = pytest.mark.usefixtures("isolated_machine_home")
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -30,9 +30,9 @@ def test_pm_runtime_discovers_plugins_without_application_dependencies(tmp_path,
|
||||
import importlib.util, json, sys
|
||||
sys.path.insert(0, {str(repo)!r})
|
||||
from pm.workspace import enabled_member_dirs
|
||||
from pm.plugins_state import _read_home_config
|
||||
from pm.plugins_state import read_home_selection
|
||||
from pathlib import Path
|
||||
assert _read_home_config(Path({str(home)!r}))["plugins"]["enabled"] == []
|
||||
assert read_home_selection(Path({str(home)!r}))["plugins"]["enabled"] == []
|
||||
assert enabled_member_dirs() == []
|
||||
assert importlib.util.find_spec("openai") is None
|
||||
assert importlib.util.find_spec("yaml") is None
|
||||
|
||||
@@ -25,6 +25,9 @@ from pm.lock import Facts
|
||||
from pm.package import InstallError
|
||||
from tests.pm._fixtures import isolated_python # noqa: F401
|
||||
|
||||
# Spawns children with a home it builds itself; the parent's must stay real.
|
||||
pytestmark = pytest.mark.real_machine_home
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def source_launch(tmp_path, monkeypatch, isolated_python):
|
||||
|
||||
@@ -6,6 +6,7 @@ must defer to recorded-graph recovery, not resolve today's application inputs.
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
@@ -17,7 +18,7 @@ import pytest
|
||||
|
||||
from pm.lock import Facts, Lockfile
|
||||
from pm.runtime import runtime_environment
|
||||
from pm.store import current_target, sha256_file, tree_digest
|
||||
from pm.store import current_target, tree_digest
|
||||
from tests.pm._fixtures import _wheel
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
@@ -127,7 +128,7 @@ def test_bootstrap_repairs_before_dependency_activation(tmp_path, monkeypatch, m
|
||||
assert not package.verify(directory, target), (
|
||||
f"startup recovery requires native {target} tools: {binary}"
|
||||
)
|
||||
digest = sha256_file(binary)
|
||||
digest = hashlib.sha256(binary.read_bytes()).hexdigest()
|
||||
lock.set_pin(name, "fixture", {target: {"url": binary.as_uri(), "sha256": digest}})
|
||||
facts.record(name, "fixture", str(directory), {}, tools,
|
||||
target=target, artifacts=[digest], digest=tree_digest(directory))
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
"""A bundle reuses shipped bytes and adds missing pinned tools outside its seal."""
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
import pm.paths as paths
|
||||
from pm.lock import Facts, Lockfile
|
||||
from pm.lock import Facts
|
||||
from tests.pm.test_pm_authority import core_env, pm_env, served # noqa: F401 — fixtures
|
||||
|
||||
|
||||
|
||||
@@ -3,11 +3,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
import pytest
|
||||
|
||||
import pm.packages as pkgs
|
||||
|
||||
pytestmark = pytest.mark.usefixtures("isolated_machine_home")
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -16,7 +16,6 @@ from pm.package import InstallError
|
||||
from pm.packages import Python, Uv
|
||||
from pm.store import current_target
|
||||
|
||||
pytestmark = pytest.mark.usefixtures("isolated_machine_home")
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -7,10 +7,8 @@ import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
pytestmark = pytest.mark.usefixtures("isolated_machine_home")
|
||||
|
||||
|
||||
def test_ambient_uv_config_does_not_affect_pm_venv_sync(tmp_path, monkeypatch):
|
||||
|
||||
@@ -21,6 +21,9 @@ from tests.pm._fixtures import (
|
||||
isolated_python as isolated_python,
|
||||
)
|
||||
|
||||
# Spawns children with a home it builds itself; the parent's must stay real.
|
||||
pytestmark = pytest.mark.real_machine_home
|
||||
|
||||
|
||||
def test_isolated_worker_preserves_install_error(client, monkeypatch):
|
||||
engine = importlib.import_module("pm.install")
|
||||
|
||||
@@ -368,12 +368,12 @@ def test_stale_enablement_cannot_replace_a_newer_selection(client, tmp_path, mon
|
||||
config.write_text("plugins: {enabled: [], disabled: []}\n")
|
||||
from pm import plugins_state
|
||||
|
||||
read = plugins_state._read_home_config
|
||||
read = plugins_state.read_home_selection
|
||||
def concurrent_commit(home):
|
||||
stale = read(home)
|
||||
config.write_text("plugins: {enabled: [first], disabled: []}\n")
|
||||
return stale
|
||||
monkeypatch.setattr(plugins_state, "_read_home_config", concurrent_commit)
|
||||
monkeypatch.setattr(plugins_state, "read_home_selection", concurrent_commit)
|
||||
with pytest.raises(AdmissionRefused, match="changed"):
|
||||
pc._set_plugin_enabled("second", enable=True)
|
||||
assert fast_safe_load(config.read_bytes())["plugins"]["enabled"] == ["first"]
|
||||
@@ -22,7 +22,6 @@ from pm.environment import managed_environment
|
||||
from tests.pm.test_environment_build import locked_project # noqa: F401
|
||||
|
||||
|
||||
pytestmark = pytest.mark.usefixtures("isolated_machine_home")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
||||
@@ -12,7 +12,6 @@ from tests.pm import _fixtures
|
||||
from pm.environment import managed_environment
|
||||
|
||||
|
||||
pytestmark = pytest.mark.usefixtures("isolated_machine_home")
|
||||
|
||||
|
||||
def _buildable_source(plugin):
|
||||
|
||||
+32
-11
@@ -42,7 +42,19 @@ _TEMP_DIR = os.path.join(tempfile.gettempdir(), "hermes_voice")
|
||||
# WSL, no PortAudio).
|
||||
|
||||
def _import_audio():
|
||||
"""Lazy-import (sounddevice, numpy); raises ImportError/OSError when unavailable."""
|
||||
"""Lazy-import (sounddevice, numpy), enabling the ``audio-io`` extra through PM first.
|
||||
|
||||
Raises ImportError when the extra cannot be enabled here (lazy installs off, platform
|
||||
gate, or installed-but-needs-restart) and OSError when PortAudio's shared library is
|
||||
missing — pip can't fix that one, so it is reported separately.
|
||||
"""
|
||||
import pm
|
||||
|
||||
if not pm.available("audio-io"):
|
||||
try:
|
||||
pm.ensure_import("audio-io")
|
||||
except pm.InstallError as exc:
|
||||
raise ImportError(str(exc)) from exc
|
||||
import sounddevice as sd
|
||||
import numpy as np
|
||||
return sd, np
|
||||
@@ -90,6 +102,16 @@ def _unlink_quietly(path: Optional[str]) -> None:
|
||||
os.unlink(path)
|
||||
|
||||
|
||||
def _audio_unavailable_reason() -> str:
|
||||
try:
|
||||
_import_audio()
|
||||
except ImportError as exc:
|
||||
return _voice_capture_install_hint(exc)
|
||||
except OSError:
|
||||
return _portaudio_missing_message().splitlines()[0]
|
||||
return ""
|
||||
|
||||
|
||||
def _audio_available() -> bool:
|
||||
try:
|
||||
_import_audio()
|
||||
@@ -113,12 +135,13 @@ def _default_input_samplerate(sd) -> int:
|
||||
|
||||
|
||||
# ── Environment detection ──
|
||||
def _voice_capture_install_hint() -> str:
|
||||
# On Termux PortAudio is a system package a pip install can't provide (#18432); everywhere
|
||||
# else the audio-io extra goes through PM so the install lands in the venv Hermes runs.
|
||||
def _voice_capture_install_hint(error: BaseException | None = None) -> str:
|
||||
"""Why audio capture is unavailable. ``_import_audio`` already tried to enable the
|
||||
``audio-io`` extra through PM, so the ImportError it raised IS the remediation."""
|
||||
# On Termux PortAudio is a system package a pip install can't provide (#18432).
|
||||
if _is_termux_environment():
|
||||
return "pkg install python-numpy portaudio && python -m pip install sounddevice"
|
||||
return "python -c \"from pm import sync_venv; sync_venv(['audio-io'], explicit=True)\""
|
||||
return str(error) if error else "audio-io extra unavailable"
|
||||
|
||||
|
||||
def _portaudio_missing_message() -> str:
|
||||
@@ -245,9 +268,9 @@ def _probe_audio_libraries(warnings: List[str], notices: List[str], *, has_forwa
|
||||
|
||||
try:
|
||||
sd, _ = _import_audio()
|
||||
except ImportError:
|
||||
except ImportError as exc:
|
||||
return outcome("Termux:API microphone recording available (sounddevice not required)",
|
||||
f"Audio libraries not installed ({_voice_capture_install_hint()})", import_failed=True)
|
||||
f"Audio libraries not installed ({_voice_capture_install_hint(exc)})", import_failed=True)
|
||||
except OSError:
|
||||
return outcome("Termux:API microphone recording available (PortAudio not required)",
|
||||
_portaudio_missing_message(), import_failed=True)
|
||||
@@ -752,9 +775,7 @@ class AudioRecorder(_RecorderBase):
|
||||
except OSError as e:
|
||||
raise RuntimeError(_portaudio_missing_message()) from e
|
||||
except ImportError as e:
|
||||
raise RuntimeError(
|
||||
"Voice mode requires sounddevice and numpy.\n"
|
||||
f"Install with: {_voice_capture_install_hint()}") from e
|
||||
raise RuntimeError(f"Voice mode requires sounddevice and numpy.\n{_voice_capture_install_hint(e)}") from e
|
||||
with self._lock:
|
||||
if self._recording:
|
||||
return
|
||||
@@ -1481,7 +1502,7 @@ def check_voice_requirements() -> Dict[str, Any]:
|
||||
details = [
|
||||
"Audio capture: OK (Termux:API microphone)" if termux_capture
|
||||
else "Audio capture: OK" if has_audio
|
||||
else f"Audio capture: MISSING ({_voice_capture_install_hint()})",
|
||||
else f"Audio capture: MISSING ({_audio_unavailable_reason()})",
|
||||
"STT provider: DISABLED in config (stt.enabled: false)" if not stt_enabled
|
||||
else f"STT provider: {stt_label}" if stt_label
|
||||
else ("STT provider: MISSING (run `hermes tools` and configure "
|
||||
|
||||
Reference in new issue
Block a user