hermes-mobile-plugin 0.0.15__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,215 @@
1
+ """Hermes Mobile QR Plugin - Clean architecture implementation.
2
+
3
+ Generates a QR code for the Hermes Mobile App to connect directly to the
4
+ Hermes Agent Desktop gateway (port 8642), and exposes voice STT/TTS routes
5
+ on the gateway so the mobile app can speak and listen without an extra
6
+ service.
7
+
8
+ Both behaviors are opt-in via ``plugins.hermes-mobile-qr`` in
9
+ ``~/.hermes/config.yaml``:
10
+
11
+ plugins:
12
+ hermes-mobile-qr:
13
+ auto_generate: true # QR code on startup
14
+ audio: true # voice routes on api_server
15
+
16
+ Audio routes are mounted via the standard plugin handler mechanism
17
+ (``ctx.register_platform_handler("api_server", _wire)``), which gives
18
+ the plugin a handle on the api_server's aiohttp ``web.Application``.
19
+ """
20
+
21
+ import asyncio
22
+ import logging
23
+ import threading
24
+ from pathlib import Path
25
+ from typing import Any, Optional
26
+
27
+ from .constants import PLUGIN_VERSION
28
+
29
+ logger = logging.getLogger(__name__)
30
+
31
+
32
+ class HermesMobileQRPlugin:
33
+ """Plugin that generates QR codes for mobile app connection to 8642 gateway.
34
+
35
+ Used by the legacy entry-point style loader. The standard
36
+ ``register(ctx)`` function below is the path the modern plugin loader
37
+ uses; both entry points are kept for backward compatibility.
38
+ """
39
+
40
+ def __init__(self, config: dict, plugin_dir: Path):
41
+ self.config = config
42
+ self.plugin_dir = plugin_dir
43
+ self._qr_generated = False
44
+
45
+ async def startup(self) -> None:
46
+ """Called when Hermes Agent starts up (legacy entry-point style)."""
47
+ try:
48
+ from .config import get_plugin_config
49
+ plugin_config = get_plugin_config(self.config)
50
+ except ImportError:
51
+ plugin_config = self.config.get("plugins", {}).get("hermes-mobile-qr", {})
52
+
53
+ if plugin_config.get("auto_generate", True):
54
+ logger.info("[Hermes Mobile QR v%s] Plugin starting...", PLUGIN_VERSION)
55
+ await self._generate_qr_and_start_supervisor()
56
+ else:
57
+ logger.info("[Hermes Mobile QR v%s] Auto-generate disabled", PLUGIN_VERSION)
58
+
59
+ async def shutdown(self) -> None:
60
+ logger.info("[Hermes Mobile QR] Plugin shutting down...")
61
+
62
+ async def _generate_qr_and_start_supervisor(self) -> None:
63
+ if self._qr_generated:
64
+ return
65
+ try:
66
+ from .qr_generator import generate_qr
67
+ await generate_qr(self.config, self.plugin_dir)
68
+ self._qr_generated = True
69
+ logger.info("[Hermes Mobile QR] QR code generated successfully")
70
+
71
+ from .supervisor import ensure_running
72
+ ensure_running()
73
+ logger.info("[Hermes Mobile QR] Gateway watchdog verified/spawned")
74
+
75
+ except Exception as e:
76
+ logger.warning("[Hermes Mobile QR] Failed to initialize: %s", e)
77
+
78
+ # ---------------------------------------------------------------------------
79
+ # Standard plugin entry point (register(ctx))
80
+ # ---------------------------------------------------------------------------
81
+
82
+ # Registration is keyed on the aiohttp app INSTANCE, not a global bool: a
83
+ # second api_server connect onto a DIFFERENT app must still get its routes,
84
+ # while a double-wire of the SAME app stays a no-op. (WeakSet so a dead app
85
+ # never pins memory and tests get a clean slate per instance.)
86
+ import weakref
87
+
88
+ _audio_wired_apps = weakref.WeakSet()
89
+ _audio_lock = threading.Lock()
90
+
91
+
92
+ def _wire_audio_routes(native: Any, adapter: Any) -> None:
93
+ """Factory passed to ``ctx.register_platform_handler("api_server", ...)``.
94
+
95
+ Receives the api_server's aiohttp ``web.Application`` as ``native``
96
+ and registers the plugin's voice routes on it. Guarded per-app so a
97
+ second connect of the SAME app doesn't double-register, while a fresh
98
+ app (hot-reload, tests) still gets mounted.
99
+ """
100
+ with _audio_lock:
101
+ if native is not None and native in _audio_wired_apps:
102
+ logger.debug("[hermes-mobile-qr] Audio routes already registered on this app; skipping")
103
+ return
104
+ try:
105
+ from .audio_routes import register as _register_routes
106
+ from .system_routes import register as _register_system_routes
107
+ from .commands_routes import register as _register_command_routes
108
+ from .update_routes import register as _register_update_routes
109
+ from .providers_routes import register as _register_providers_routes
110
+ except Exception as exc:
111
+ logger.warning(
112
+ "[hermes-mobile-qr] Could not import route modules: %s", exc
113
+ )
114
+ return
115
+ if native is None:
116
+ logger.debug(
117
+ "[hermes-mobile-qr] api_server native is None; "
118
+ "audio routes not mounted on this run"
119
+ )
120
+ return
121
+ try:
122
+ _register_routes(native)
123
+ _register_system_routes(native)
124
+ _register_command_routes(native)
125
+ _register_update_routes(native)
126
+ _register_providers_routes(native)
127
+ except Exception as exc:
128
+ logger.exception(
129
+ "[hermes-mobile-qr] Failed to register audio routes: %s", exc
130
+ )
131
+ return
132
+ if native is not None:
133
+ _audio_wired_apps.add(native)
134
+
135
+
136
+ async def _maybe_generate_qr(ctx: Any) -> None:
137
+ """Best-effort QR generation on startup, mirroring the legacy flow.
138
+
139
+ ``PluginContext`` exposes no ``.config``/``.plugin_dir`` attributes — the
140
+ QR payload needs the REAL platforms.api_server config, so load it from
141
+ config.yaml directly, and write the HTML next to the plugin package
142
+ (never the gateway's CWD).
143
+ """
144
+ try:
145
+ from .config import load_hermes_config, get_plugin_config
146
+ hermes_config = load_hermes_config()
147
+ plugin_config = get_plugin_config(hermes_config)
148
+ except ImportError:
149
+ hermes_config, plugin_config = {}, {}
150
+
151
+ if not plugin_config.get("auto_generate", True):
152
+ return
153
+
154
+ try:
155
+ from .constants import PLUGIN_DIR
156
+ from .qr_generator import generate_qr
157
+ out_dir = Path(ctx.plugin_dir) if getattr(ctx, "plugin_dir", None) else PLUGIN_DIR
158
+ await generate_qr(hermes_config, out_dir)
159
+ logger.info("[Hermes Mobile QR] QR code generated via register(ctx)")
160
+ except Exception as exc:
161
+ logger.warning("[Hermes Mobile QR] QR generation skipped: %s", exc)
162
+
163
+
164
+ def register(ctx: Any) -> None:
165
+ """Standard plugin entry point. Called by Hermes plugin loader.
166
+
167
+ Registers the plugin's voice STT/TTS routes on the api_server
168
+ platform, and (if enabled) generates a QR code on startup.
169
+ """
170
+ logger.info(
171
+ "[Hermes Mobile QR v%s] register(ctx) called", PLUGIN_VERSION
172
+ )
173
+
174
+ # Register the api_server handler — always safe (idempotent guard).
175
+ try:
176
+ ctx.register_platform_handler("api_server", _wire_audio_routes)
177
+ except Exception as exc:
178
+ logger.warning(
179
+ "[Hermes Mobile QR] Could not register api_server handler: %s", exc
180
+ )
181
+
182
+ # Keep-alive: spawn the detached gateway watchdog. It lives OUTSIDE the
183
+ # gateway process, so if the gateway dies (crash, OOM, reboot script) it
184
+ # restarts it and the app reconnects. Idempotent via PID file.
185
+ try:
186
+ from .supervisor import ensure_running
187
+ ensure_running()
188
+ except Exception as exc:
189
+ logger.warning("[Hermes Mobile QR] Watchdog spawn failed: %s", exc)
190
+
191
+ # Fire-and-forget QR generation so we don't block loader. Scheduling
192
+ # only ever happens on an ALREADY-running loop: a run_until_complete
193
+ # fallback here could block the plugin loader (or start a stray loop
194
+ # beside the gateway's). No loop at register-time -> QR skipped until
195
+ # the next startup; the CLI covers the manual path.
196
+ try:
197
+ asyncio.get_running_loop().create_task(_maybe_generate_qr(ctx))
198
+ except RuntimeError:
199
+ logger.debug("[hermes-mobile-qr] no running loop at register(); QR gen deferred")
200
+
201
+
202
+ # ---------------------------------------------------------------------------
203
+ # Legacy entry point (kept for backward compatibility)
204
+ # ---------------------------------------------------------------------------
205
+
206
+
207
+ def create_plugin(config: dict, plugin_dir: Path) -> HermesMobileQRPlugin:
208
+ """Factory function called by the legacy Hermes plugin loader."""
209
+ return HermesMobileQRPlugin(config, plugin_dir)
210
+
211
+
212
+ async def startup_hook(config: dict, plugin_dir: Path) -> None:
213
+ """Standalone startup hook if Hermes loader expects a function."""
214
+ plugin = create_plugin(config, plugin_dir)
215
+ await plugin.startup()
@@ -0,0 +1,253 @@
1
+ """Voice backend: STT (Whisper) + TTS (Edge/OpenAI/ElevenLabs) via hermes-agent.
2
+
3
+ Wraps the existing ``tools.voice_mode.transcribe_recording`` and
4
+ ``tools.tts_tool.text_to_speech_tool`` so the mobile app can use the same
5
+ provider chain the desktop voice mode uses, without re-implementing any
6
+ provider logic.
7
+
8
+ Provider chain is configured in ``~/.hermes/config.yaml`` under ``tts.`` and
9
+ ``voice.`` — this module just delegates.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import base64
15
+ import binascii
16
+ import logging
17
+ import os
18
+ import tempfile
19
+ from contextlib import contextmanager, nullcontext
20
+ from typing import Any, Iterator, Optional
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+ # Mirrors hermes-agent's gateway MAX_REQUEST_BYTES (25 MB, api_server.py); a
25
+ # 25 MB recording base64-encodes to ~33 MB and dies at the gateway first.
26
+ _MAX_TRANSCRIPTION_UPLOAD_BYTES = 25 * 1024 * 1024 # 25 MB
27
+ _AUDIO_MIME_EXTENSIONS = {
28
+ "audio/webm": ".webm",
29
+ "audio/ogg": ".ogg",
30
+ "audio/opus": ".opus",
31
+ "audio/mpeg": ".mp3",
32
+ "audio/mp3": ".mp3",
33
+ "audio/mp4": ".mp4",
34
+ "audio/wav": ".wav",
35
+ "audio/x-wav": ".wav",
36
+ "audio/flac": ".flac",
37
+ "audio/aac": ".aac",
38
+ "audio/x-m4a": ".m4a",
39
+ "video/webm": ".webm",
40
+ }
41
+ _AUDIO_EXT_TO_MIME = {
42
+ ".mp3": "audio/mpeg",
43
+ ".ogg": "audio/ogg",
44
+ ".opus": "audio/ogg",
45
+ ".wav": "audio/wav",
46
+ ".flac": "audio/flac",
47
+ }
48
+
49
+
50
+ def _audio_extension_for_mime(mime_type: str) -> str:
51
+ normalized = (mime_type or "").split(";", 1)[0].strip().lower()
52
+ return _AUDIO_MIME_EXTENSIONS.get(normalized, ".webm")
53
+
54
+
55
+ def _is_supported_audio_mime(mime_type: str) -> bool:
56
+ if not mime_type:
57
+ return False
58
+ normalized = mime_type.split(";", 1)[0].strip().lower()
59
+ return normalized.startswith("audio/") or normalized == "video/webm"
60
+
61
+
62
+ class AudioBackendError(Exception):
63
+ """Raised when STT or TTS fails. Carries an HTTP status for the route handler."""
64
+
65
+ def __init__(self, message: str, status: int = 500):
66
+ super().__init__(message)
67
+ self.status = status
68
+
69
+
70
+ def _resolve_profile_scope(profile: Optional[str]) -> Any:
71
+ """Return a context manager that scopes config resolution to *profile*.
72
+
73
+ None / "" / "current" => no scope (use default home).
74
+ Mirrors ``_config_profile_scope`` in hermes_cli/web_server.py.
75
+ """
76
+ requested = (profile or "").strip()
77
+ if not requested or requested.lower() == "current":
78
+ return nullcontext()
79
+
80
+ # Imported lazily — these live inside hermes-agent and are not on the
81
+ # plugin's static-analysis path.
82
+ from hermes_constants import (
83
+ set_hermes_home_override,
84
+ reset_hermes_home_override,
85
+ )
86
+ try:
87
+ from hermes_cli.web_server import _resolve_profile_dir
88
+ except ImportError:
89
+ # Sep-2026 decomposition moved it to the sibling module; hosts that
90
+ # updated hermes-agent broke every profile-scoped speak/transcribe
91
+ # call until the plugin followed.
92
+ from hermes_cli.web_server_profiles import _resolve_profile_dir
93
+
94
+ profile_dir = _resolve_profile_dir(requested)
95
+
96
+ @contextmanager
97
+ def _scoped() -> Iterator[Any]:
98
+ token = set_hermes_home_override(str(profile_dir))
99
+ try:
100
+ yield profile_dir
101
+ finally:
102
+ reset_hermes_home_override(token)
103
+
104
+ return _scoped()
105
+
106
+
107
+ def transcribe_audio(
108
+ audio_b64: str,
109
+ mime_type: str = "audio/webm",
110
+ profile: Optional[str] = None,
111
+ model: Optional[str] = None,
112
+ ) -> str:
113
+ """Decode base64 audio and return its transcript.
114
+
115
+ Args:
116
+ audio_b64: Base64-encoded audio bytes.
117
+ mime_type: MIME type (e.g. ``audio/webm``).
118
+ profile: Optional Hermes profile name.
119
+
120
+ Returns:
121
+ Transcript text. Empty string on silence (per
122
+ ``transcribe_recording`` contract).
123
+
124
+ Raises:
125
+ AudioBackendError: 400 for bad input, 413 for too-large, 500 for
126
+ provider failures, 503 if no transcription provider is available.
127
+ """
128
+ if not audio_b64 or not audio_b64.strip():
129
+ raise AudioBackendError("audio_b64 is required", status=400)
130
+ if not _is_supported_audio_mime(mime_type):
131
+ raise AudioBackendError("mime_type must be audio/*", status=400)
132
+
133
+ try:
134
+ audio_bytes = base64.b64decode(audio_b64, validate=True)
135
+ except (binascii.Error, ValueError) as exc:
136
+ raise AudioBackendError(f"audio_b64 is not valid base64: {exc}", status=400)
137
+
138
+ if not audio_bytes:
139
+ raise AudioBackendError("Audio recording is empty", status=400)
140
+ if len(audio_bytes) > _MAX_TRANSCRIPTION_UPLOAD_BYTES:
141
+ raise AudioBackendError("Audio recording is too large", status=413)
142
+
143
+ suffix = _audio_extension_for_mime(mime_type)
144
+ temp_path = ""
145
+ try:
146
+ with tempfile.NamedTemporaryFile(
147
+ prefix="hermes-mobile-voice-", suffix=suffix, delete=False
148
+ ) as tmp:
149
+ tmp.write(audio_bytes)
150
+ temp_path = tmp.name
151
+
152
+ try:
153
+ from tools.voice_mode import transcribe_recording
154
+ except ImportError as exc:
155
+ raise AudioBackendError(
156
+ f"Transcription provider unavailable: {exc}", status=503
157
+ )
158
+
159
+ scope = _resolve_profile_scope(profile)
160
+ with scope:
161
+ try:
162
+ result = transcribe_recording(temp_path, model=model)
163
+ except Exception as exc:
164
+ logger.exception("Mobile voice transcription failed")
165
+ raise AudioBackendError(
166
+ f"Transcription failed: {exc}", status=500
167
+ )
168
+
169
+ if not isinstance(result, dict):
170
+ # Defensive: if a provider returns a raw string, accept it.
171
+ return (str(result) if result else "").strip()
172
+
173
+ if not result.get("success"):
174
+ raise AudioBackendError(
175
+ result.get("error") or "Transcription failed",
176
+ status=400,
177
+ )
178
+
179
+ return (result.get("transcript") or "").strip()
180
+ finally:
181
+ if temp_path:
182
+ try:
183
+ os.unlink(temp_path)
184
+ except OSError:
185
+ pass
186
+
187
+
188
+ def synthesize_speech(
189
+ text: str,
190
+ profile: Optional[str] = None,
191
+ ) -> tuple[bytes, str, Optional[str]]:
192
+ """Synthesize text to audio bytes.
193
+
194
+ Args:
195
+ text: Text to speak.
196
+ profile: Optional Hermes profile name.
197
+
198
+ Returns:
199
+ Tuple of ``(audio_bytes, mime_type, provider_name)``.
200
+
201
+ Raises:
202
+ AudioBackendError: 400 for empty text, 500 for provider failures,
203
+ 503 if no TTS provider is available.
204
+ """
205
+ text = (text or "").strip()
206
+ if not text:
207
+ raise AudioBackendError("text is required", status=400)
208
+
209
+ try:
210
+ from tools.tts_tool import text_to_speech_tool
211
+ except ImportError as exc:
212
+ raise AudioBackendError(
213
+ f"TTS provider unavailable: {exc}", status=503
214
+ )
215
+
216
+ import json as _json
217
+
218
+ scope = _resolve_profile_scope(profile)
219
+ with scope:
220
+ try:
221
+ result_json = text_to_speech_tool(text)
222
+ result = _json.loads(result_json) if isinstance(result_json, str) else result_json
223
+ except Exception as exc:
224
+ logger.exception("Mobile voice TTS failed")
225
+ raise AudioBackendError(
226
+ f"Speech synthesis failed: {exc}", status=500
227
+ )
228
+
229
+ if not result.get("success"):
230
+ raise AudioBackendError(
231
+ result.get("error") or "Speech synthesis failed",
232
+ status=400,
233
+ )
234
+
235
+ file_path = result.get("file_path")
236
+ if not file_path or not os.path.isfile(file_path):
237
+ raise AudioBackendError("Audio file missing", status=500)
238
+
239
+ try:
240
+ with open(file_path, "rb") as fh:
241
+ audio_bytes = fh.read()
242
+ except OSError as exc:
243
+ raise AudioBackendError(f"Could not read audio: {exc}", status=500)
244
+ finally:
245
+ try:
246
+ os.unlink(file_path)
247
+ except OSError:
248
+ pass
249
+
250
+ ext = os.path.splitext(file_path)[1].lower()
251
+ mime_type = _AUDIO_EXT_TO_MIME.get(ext, "audio/mpeg")
252
+ provider = result.get("provider")
253
+ return audio_bytes, mime_type, provider