diff --git a/plugins/context_engine/__init__.py b/plugins/context_engine/__init__.py index cd7b257194..56c84d5acb 100644 --- a/plugins/context_engine/__init__.py +++ b/plugins/context_engine/__init__.py @@ -36,7 +36,6 @@ def load_context_engine(name: str) -> Optional["ContextEngine"]: # noqa: F821 def _load_engine_from_dir(engine_dir: Path) -> Optional["ContextEngine"]: # noqa: F821 """Import an engine module and extract its ContextEngine (register(ctx) or subclass).""" from agent.context_engine import ContextEngine - name = engine_dir.name mod = _loader.load_plugin_module( f"plugins.context_engine.{name}", engine_dir, @@ -66,7 +65,6 @@ class _EngineCollector(_loader.NoopPluginContext): logger.warning("Context engine '%s' tried to register a command with an empty name.", self._engine_name) return - conflict = "Context engine '%s' tried to register command '/%s' which %s Skipping." try: from hermes_cli.commands import resolve_command diff --git a/plugins/cron_providers/__init__.py b/plugins/cron_providers/__init__.py index ecd7eebf52..4031c8c83b 100644 --- a/plugins/cron_providers/__init__.py +++ b/plugins/cron_providers/__init__.py @@ -79,7 +79,6 @@ def load_cron_scheduler(name: str) -> Optional["CronScheduler"]: # noqa: F821 def _load_provider_from_dir(provider_dir: Path) -> Optional["CronScheduler"]: # noqa: F821 """Import a provider module and extract its CronScheduler (register(ctx) or subclass).""" from cron.scheduler_provider import CronScheduler - name = provider_dir.name is_bundled = _CRON_PLUGINS_DIR in provider_dir.parents or provider_dir.parent == _CRON_PLUGINS_DIR module_name = f"plugins.cron_providers.{name}" if is_bundled else f"{_USER_NAMESPACE}.{name}" diff --git a/plugins/cron_providers/chronos/__init__.py b/plugins/cron_providers/chronos/__init__.py index ea8c291c9c..b02144cd48 100644 --- a/plugins/cron_providers/chronos/__init__.py +++ b/plugins/cron_providers/chronos/__init__.py @@ -126,18 +126,15 @@ class ChronosCronScheduler(CronScheduler): def reconcile(self) -> None: """Converge NAS one-shots toward jobs.json: arm missing/changed, cancel orphans.""" from cron.jobs import get_job, load_jobs - desired: Dict[str, str] = { j["id"]: j["next_run_at"] for j in load_jobs() if j.get("enabled") and j.get("next_run_at") and j.get("state") != "paused"} observed = self._list_armed() - for job_id, fire_at in desired.items(): if observed.get(job_id) != fire_at: job = get_job(job_id) if job: self._arm_logged(job, f"arm job {job_id}") - for job_id in observed.keys() - desired.keys(): try: self._cancel(job_id) diff --git a/plugins/cron_providers/chronos/_nas_client.py b/plugins/cron_providers/chronos/_nas_client.py index 8048065e87..25e65834f3 100644 --- a/plugins/cron_providers/chronos/_nas_client.py +++ b/plugins/cron_providers/chronos/_nas_client.py @@ -34,7 +34,6 @@ class NasCronClient: def _request(self, method: str, path: str, **kwargs: Any) -> Dict[str, Any]: """Issue one request; raise NasCronClientError on transport error or non-2xx.""" import requests # lazy: agent already depends on requests - try: resp = requests.request(method, f"{self.portal_url}{path}", headers=self._headers(), timeout=self.timeout_seconds, **kwargs) diff --git a/plugins/cron_providers/chronos/verify.py b/plugins/cron_providers/chronos/verify.py index 1b4feef1b2..21c51ed45c 100644 --- a/plugins/cron_providers/chronos/verify.py +++ b/plugins/cron_providers/chronos/verify.py @@ -31,7 +31,6 @@ def _get_jwk_client(jwks_url: str) -> Any: client = _JWK_CLIENTS.get(jwks_url) if client is None: from jwt import PyJWKClient - # Explicit Accept + User-Agent: the portal WAF 403s the default urllib fingerprint. client = PyJWKClient( jwks_url, headers={"Accept": "application/json", "User-Agent": "HermesAgent/1.0"}) @@ -50,15 +49,12 @@ def verify_nas_fire_token(*, token: str, expected_audience: str, jwks_or_key: Op if not jwks_or_key: # never fall back to unsigned decode on a security boundary logger.warning("cron fire: no JWKS/key configured; refusing token") return None - try: import jwt - if jwks_or_key.startswith(("http://", "https://")): signing_key = _get_jwk_client(jwks_or_key).get_signing_key_from_jwt(token).key else: signing_key = jwks_or_key # inline PEM public key (test / pinned-key deployments) - decode_kwargs: Dict[str, Any] = dict( algorithms=["RS256", "RS384", "RS512", "ES256", "ES384"], audience=expected_audience, leeway=leeway_seconds, options={"require": ["exp", "aud"]}) @@ -68,7 +64,6 @@ def verify_nas_fire_token(*, token: str, expected_audience: str, jwks_or_key: Op except Exception as e: logger.warning("cron fire: token verification failed: %s", e) return None - if claims.get("purpose") != _FIRE_PURPOSE: logger.warning("cron fire: token missing/!=%s purpose claim", _FIRE_PURPOSE) return None diff --git a/plugins/disk-cleanup/__init__.py b/plugins/disk-cleanup/__init__.py index bdf7c8ba96..6ab289c709 100644 --- a/plugins/disk-cleanup/__init__.py +++ b/plugins/disk-cleanup/__init__.py @@ -82,13 +82,11 @@ def _on_session_end( _recent_test_tracks.clear() if not had_tracks: return - try: summary = dg.quick() except Exception as exc: logger.debug("disk-cleanup quick cleanup failed: %s", exc) return - if summary["deleted"] or summary["empty_dirs"]: dg._log(f"AUTO_QUICK (session_end): deleted={summary['deleted']} " f"dirs={summary['empty_dirs']} freed={dg.fmt_size(summary['freed'])}") diff --git a/plugins/disk-cleanup/disk_cleanup.py b/plugins/disk-cleanup/disk_cleanup.py index 05e95f6db1..16f53509b6 100755 --- a/plugins/disk-cleanup/disk_cleanup.py +++ b/plugins/disk-cleanup/disk_cleanup.py @@ -144,7 +144,6 @@ def track(path_str: str, category: str, silent: bool = False) -> bool: if category not in ALLOWED_CATEGORIES: _log(f"WARN: unknown category '{category}', using 'other'") category = "other" - path = Path(path_str).resolve() if not path.exists(): _log(f"SKIP: {path} (does not exist)") @@ -152,7 +151,6 @@ def track(path_str: str, category: str, silent: bool = False) -> bool: if not is_safe_path(path): _log(f"REJECT: {path} (outside HERMES_HOME)") return False - size = path.stat().st_size if path.is_file() else 0 tracked = load_tracked() if any(item["path"] == str(path) for item in tracked): @@ -243,7 +241,6 @@ def quick() -> Dict[str, Any]: deleted = freed = 0 new_tracked: List[Dict] = [] errors: List[str] = [] - for item, p, age in _live_items(load_tracked(), datetime.now(timezone.utc), log_stale=True): cat = item["category"] if cat in _STALE_SKIP_NOTE and (re_cat := guess_category(p)) != cat: @@ -264,7 +261,6 @@ def quick() -> Dict[str, Any]: else: errors.append(err) new_tracked.append(item) - empty_removed = _sweep_empty_dirs(get_hermes_home()) save_tracked(new_tracked) _log(f"QUICK_SUMMARY: {deleted} files, {empty_removed} dirs, {fmt_size(freed)}") @@ -322,7 +318,6 @@ def format_status(s: Dict[str, Any]) -> str: lines.append(f"{cat:<20} {d['count']:>6} {fmt_size(d['size']):>10}") if not cats: lines.append("(nothing tracked yet)") - lines += ["", "Top 10 largest tracked files:"] if not s["top10"]: lines.append(" (none)") @@ -339,7 +334,6 @@ def guess_category(path: Path) -> Optional[str]: """Category label for *path*, or None if we shouldn't track it (``post_tool_call`` hook).""" if not is_safe_path(path): return None - try: rel = path.resolve().relative_to(get_hermes_home()) top = rel.parts[0] if rel.parts else "" @@ -353,6 +347,5 @@ def guess_category(path: Path) -> Optional[str]: return "temp" except ValueError: pass # not under HERMES_HOME (e.g. /tmp/hermes-*) — fall through to name rules - name = path.name return "test" if name.startswith(_TEST_PATTERNS) or name.endswith(_TEST_SUFFIXES) else None diff --git a/plugins/google_meet/__init__.py b/plugins/google_meet/__init__.py index 4489702594..750d7afcd6 100644 --- a/plugins/google_meet/__init__.py +++ b/plugins/google_meet/__init__.py @@ -46,15 +46,12 @@ def register(ctx) -> None: if system not in {"linux", "darwin"}: logger.info("google_meet plugin: platform=%s not supported (linux/macos only)", system) return - for name, schema, handler, emoji in _TOOLS: ctx.register_tool(name=name, toolset="google_meet", schema=schema, handler=handler, check_fn=check_meet_requirements, emoji=emoji) - ctx.register_cli_command( name="meet", help="Google Meet bot (join, transcribe, follow up)", setup_fn=_register_meet_cli, handler_fn=_meet_command, description=("Let the hermes agent join a Google Meet call and scrape live " "captions into a transcript. See: hermes meet setup")) - ctx.register_hook("on_session_end", _on_session_end) diff --git a/plugins/google_meet/audio_bridge.py b/plugins/google_meet/audio_bridge.py index 1ad6a2ce03..2c7d350092 100644 --- a/plugins/google_meet/audio_bridge.py +++ b/plugins/google_meet/audio_bridge.py @@ -75,7 +75,6 @@ class AudioBridge: def _setup_linux(self) -> dict: sink_name = f"{self._name_prefix}_sink" src_name = f"{self._name_prefix}_src" - try: sink_out = _pactl( "load-module", "module-null-sink", f"sink_name={sink_name}", @@ -85,7 +84,6 @@ class AudioBridge: except subprocess.CalledProcessError as exc: raise RuntimeError(f"pactl load-module null-sink failed: {exc.stderr or exc}") from exc sink_mod_id = self._parse_module_id(sink_out.stdout) - try: src_out = _pactl( "load-module", "module-virtual-source", f"source_name={src_name}", @@ -94,7 +92,6 @@ class AudioBridge: # Roll back the null-sink we just created so we don't leak it. _pactl("unload-module", str(sink_mod_id), check=False) raise RuntimeError(f"pactl load-module virtual-source failed: {exc.stderr or exc}") from exc - return self._finish("linux", src_name, sink_name, [sink_mod_id, self._parse_module_id(src_out.stdout)]) def _setup_darwin(self) -> dict: @@ -105,7 +102,6 @@ class AudioBridge: raise RuntimeError("system_profiler not found (macOS-only command)") from exc except subprocess.CalledProcessError as exc: raise RuntimeError(f"system_profiler failed: {exc.output}") from exc - if "BlackHole" not in out: raise RuntimeError("BlackHole virtual audio device not installed. " "Install via: brew install blackhole-2ch") diff --git a/plugins/google_meet/cli.py b/plugins/google_meet/cli.py index dcf511dc0c..12a4eefc43 100644 --- a/plugins/google_meet/cli.py +++ b/plugins/google_meet/cli.py @@ -34,18 +34,14 @@ def _auth_state_path() -> Path: def register_cli(subparser: argparse.ArgumentParser) -> None: """Build the ``hermes meet`` argparse tree (called at plugin load time).""" subs = subparser.add_subparsers(dest="meet_command") - subs.add_parser("setup", help="Preflight: playwright, chromium, auth") - inst_p = subs.add_parser( "install", help="Install prerequisites (pip deps, Chromium, platform audio tools)") inst_p.add_argument("--realtime", action="store_true", help="Also install realtime audio tools (pulseaudio-utils on Linux, BlackHole+ffmpeg on macOS). Uses sudo/brew, prompts before invoking either.") inst_p.add_argument("--yes", "-y", action="store_true", help="Answer yes to all prompts (use with care; will run sudo apt-get or brew without asking).") - subs.add_parser("auth", help="Sign in to Google and save session state") - join_p = subs.add_parser("join", help="Join a Meet URL") join_p.add_argument("url", help="https://meet.google.com/...") join_p.add_argument("--guest-name", default="Hermes Agent") @@ -55,18 +51,13 @@ def register_cli(subparser: argparse.ArgumentParser) -> None: help="transcribe (default, listen-only) or realtime (speak via OpenAI Realtime)") join_p.add_argument("--node", default=None, help="remote node name, or 'auto' to use the sole registered node") - subs.add_parser("status", help="Print current Meet bot state") - tr_p = subs.add_parser("transcript", help="Print the scraped transcript") tr_p.add_argument("--last", type=int, default=None) - say_p = subs.add_parser("say", help="Speak text in an active realtime meeting") say_p.add_argument("text", help="what to say") say_p.add_argument("--node", default=None) - subs.add_parser("stop", help="Leave the current meeting") - _register_node_cli(subs.add_parser( "node", help="Manage remote meet node hosts (run/list/approve/remove/status/ping)")) subparser.set_defaults(func=meet_command) @@ -100,15 +91,12 @@ def meet_command(args: argparse.Namespace) -> int: def _cmd_setup() -> int: print("google_meet preflight\n---------------------") - system = platform.system() system_ok = system in {"Linux", "Darwin"} print(f" platform : {system} [{'ok' if system_ok else 'unsupported'}]") - pw_ok = importlib.util.find_spec("playwright") is not None print(" playwright : installed" if pw_ok else " playwright : NOT installed — run: pip install playwright") - chromium_ok = False chromium_msg = "unknown" if pw_ok: @@ -124,11 +112,9 @@ def _cmd_setup() -> int: except Exception as e: chromium_msg = f"probe failed: {e}" print(f" chromium : {chromium_msg}") - auth_path = _auth_state_path() print(" google auth : " + (f"ok ({auth_path})" if auth_path.is_file() else "not saved — run: hermes meet auth")) - print() all_ok = system_ok and pw_ok and chromium_ok print("ready. Join a meeting: hermes meet join https://meet.google.com/abc-defg-hij" if all_ok @@ -159,19 +145,16 @@ def _cmd_install(*, realtime: bool, assume_yes: bool) -> int: print(fail_msg) print("google_meet install\n-------------------") - pip_pkgs = ["playwright", "websockets"] print(f"\n[1/3] pip install: {' '.join(pip_pkgs)}") try: from hermes_cli.tools_config import _pip_install - if _pip_install(["--upgrade", *pip_pkgs], capture_output=False).returncode != 0: print(" pip install failed") return 1 except Exception as e: print(f" pip install failed: {e}") return 1 - print("\n[2/3] python -m playwright install chromium") try: res = subprocess.run([sys.executable, "-m", "playwright", "install", "chromium"], check=False, @@ -181,7 +164,6 @@ def _cmd_install(*, realtime: bool, assume_yes: bool) -> int: except Exception as e: print(f" playwright install failed: {e}") return 1 - if not realtime: print("\n[3/3] skipped (pass --realtime to install audio tooling too)") else: @@ -213,7 +195,6 @@ def _cmd_install(*, realtime: bool, assume_yes: bool) -> int: " System Settings → Sound → Input\n" " and select 'BlackHole 2ch' before starting a realtime meeting.\n" " hermes will not switch your default input for you.") - print("\ndone. verify with: hermes meet setup") return 0 @@ -226,7 +207,6 @@ def _cmd_auth() -> int: print("playwright is not installed. run:\n" " pip install playwright && python -m playwright install chromium") return 1 - path = _auth_state_path() path.parent.mkdir(parents=True, exist_ok=True) print(f"opening Chromium — sign in to Google, then return here and press Enter.\nsaving storage state to: {path}") diff --git a/plugins/google_meet/meet_bot.py b/plugins/google_meet/meet_bot.py index d763e7e9da..a9be4c364a 100644 --- a/plugins/google_meet/meet_bot.py +++ b/plugins/google_meet/meet_bot.py @@ -251,12 +251,10 @@ def _start_realtime_speaker(rt: dict, cfg: "_BotConfig", stop_flag: dict, state: except Exception as e: state.set(error=f"realtime import failed: {e}") return - pcm_path = cfg.out_dir / "speaker.pcm" queue_path = cfg.out_dir / "say_queue.jsonl" pcm_path.write_bytes(b"") # clean sink file per session queue_path.touch() # so the speaker poller doesn't error on first iteration - try: session = RealtimeSession( api_key=cfg.realtime_api_key, model=cfg.realtime_model, voice=cfg.realtime_voice, @@ -266,7 +264,6 @@ def _start_realtime_speaker(rt: dict, cfg: "_BotConfig", stop_flag: dict, state: state.set(error=f"realtime connect failed: {e}") return rt["session"] = session - speaker = RealtimeSpeaker( session=session, queue_path=queue_path, processed_path=cfg.out_dir / "say_processed.jsonl") @@ -394,7 +391,6 @@ def _drain_loop(page, cfg: _BotConfig, state: _BotState, rt: dict, stop_flag: di if deadline and now > deadline: state.set(leave_reason="duration_expired") return - if not state.in_call and (now - last_admission_check) > 3.0: last_admission_check = now if _probe(page, _ADMISSION_PROBE_JS): @@ -407,7 +403,6 @@ def _drain_loop(page, cfg: _BotConfig, state: _BotState, rt: dict, stop_flag: di elif _probe(page, _DENIED_PROBE_JS): state.set(error="host denied admission", leave_reason="denied") return - try: queued = page.evaluate("window.__hermesMeetDrain && window.__hermesMeetDrain()") for entry in queued if isinstance(queued, list) else (): @@ -423,11 +418,9 @@ def _drain_loop(page, cfg: _BotConfig, state: _BotState, rt: dict, stop_flag: di if page.is_closed(): # Meet reloaded or we got booted — exit rather than spin state.set(leave_reason="page_closed") return - if rt["session"] is not None: state.set(audio_bytes_out=rt["session"].audio_bytes_out, last_audio_out_at=rt["session"].last_audio_out_at) - time.sleep(1.0) @@ -440,21 +433,17 @@ def run_bot() -> int: if cfg.out_dir is None: sys.stderr.write("google_meet bot: HERMES_MEET_OUT_DIR is required\n") return 2 - state = _BotState(out_dir=cfg.out_dir, meeting_id=_meeting_id_from_url(cfg.url), url=cfg.url) - # SIGTERM sets a flag (not an exception) so the Playwright teardown below still runs # and ``meet_leave`` gets a finalized transcript. stop_flag = {"stop": False} for sig in (signal.SIGTERM, signal.SIGINT): signal.signal(sig, lambda _sig, _frame: stop_flag.__setitem__("stop", True)) - # Realtime resources in one dict so teardown works however we exit. rt = {"enabled": cfg.realtime, "bridge": None, "bridge_info": None, "session": None, "speaker_thread": None} if rt["enabled"]: _setup_realtime(rt, cfg.realtime_api_key, state) - try: from playwright.sync_api import sync_playwright except ImportError as e: @@ -464,14 +453,12 @@ def run_bot() -> int: if rt["bridge"]: rt["bridge"].teardown() return 3 - chrome_args = ["--use-fake-ui-for-media-stream", "--disable-blink-features=AutomationControlled"] if not rt["enabled"]: chrome_args.insert(1, "--use-fake-device-for-media-stream") # silent fake mic elif rt["bridge_info"] and rt["bridge_info"].get("platform") == "linux": # Playwright's launch() takes no env: set PULSE_SOURCE on ourselves so Chrome inherits it. os.environ["PULSE_SOURCE"] = rt["bridge_info"].get("device_name", "") - try: with sync_playwright() as pw: browser = pw.chromium.launch(headless=not cfg.headed, args=chrome_args) @@ -484,13 +471,11 @@ def run_bot() -> int: context_args["storage_state"] = cfg.auth_state context = browser.new_context(**context_args) page = context.new_page() - try: page.goto(cfg.url, wait_until="domcontentloaded", timeout=30_000) except Exception as e: state.set(error=f"navigate failed: {e}", exited=True) return 4 - _join(page, cfg, state) if _quiet(page.evaluate, _ENABLE_CAPTIONS_JS): state.set(captions_enabled_attempted=True) @@ -498,21 +483,17 @@ def run_bot() -> int: page.evaluate(_CAPTION_OBSERVER_JS) except Exception as e: state.set(error=f"caption observer install failed: {e}") - # in_call stays False until admission is confirmed by the drain loop. state.set(captioning=True, join_attempted_at=time.time()) if rt["enabled"]: _start_realtime_speaker(rt, cfg, stop_flag, state) - _drain_loop(page, cfg, state, rt, stop_flag) - _quiet(page.evaluate, _LEAVE_CALL_JS) context.close() browser.close() _teardown_realtime(rt) state.set(in_call=False, captioning=False, exited=True) return 0 - except Exception as e: state.set(error=f"unhandled: {e}", exited=True) return 1 diff --git a/plugins/google_meet/node/cli.py b/plugins/google_meet/node/cli.py index 950f7f6505..3281af5190 100644 --- a/plugins/google_meet/node/cli.py +++ b/plugins/google_meet/node/cli.py @@ -15,23 +15,18 @@ from plugins.google_meet.node.server import NodeServer def register_cli(subparser: argparse.ArgumentParser) -> None: """Add ``run / list / approve / remove / status / ping`` subparsers to the ``node`` parser.""" sp = subparser.add_subparsers(dest="node_cmd", required=True) - run = sp.add_parser("run", help="Start a node server on this machine.") run.add_argument("--host", default="0.0.0.0") run.add_argument("--port", type=int, default=18789) run.add_argument("--display-name", default="hermes-meet-node") - sp.add_parser("list", help="List approved remote nodes.") - app = sp.add_parser("approve", help="Register a remote node on the gateway.") for arg in ("name", "url", "token"): app.add_argument(arg) - for name, help_ in (("remove", "Forget a registered node."), ("status", "Ping a registered node."), ("ping", "Alias for status.")): sp.add_parser(name, help=help_).add_argument("name") - for p in sp.choices.values(): p.set_defaults(func=node_command) diff --git a/plugins/google_meet/node/client.py b/plugins/google_meet/node/client.py index 5cace01f95..46e29ce12f 100644 --- a/plugins/google_meet/node/client.py +++ b/plugins/google_meet/node/client.py @@ -27,12 +27,10 @@ class NodeClient: except ImportError as exc: raise RuntimeError("NodeClient requires the 'websockets' package. " "Install it with: pip install websockets") from exc - req = _proto.make_request(type, self.token, payload) with connect(self.url, open_timeout=self.timeout, close_timeout=self.timeout) as ws: ws.send(_proto.encode(req)) resp = _proto.decode(ws.recv(timeout=self.timeout)) - if resp.get("type") == "error": raise RuntimeError(f"node error: {resp.get('error', '')}") if resp.get("id") != req["id"]: diff --git a/plugins/google_meet/node/server.py b/plugins/google_meet/node/server.py index 7211bc3938..18ff9ce7ea 100644 --- a/plugins/google_meet/node/server.py +++ b/plugins/google_meet/node/server.py @@ -92,7 +92,6 @@ class NodeServer: ok, reason = _proto.validate_request(msg, self.ensure_token()) if not ok: return _proto.make_error(str(msg.get("id") or ""), reason) - req_id, t = msg["id"], msg["type"] if t == "ping": return {"type": "pong", "id": req_id, @@ -100,10 +99,8 @@ class NodeServer: handler = _RPC.get(t) if handler is None: return _proto.make_error(req_id, f"unhandled type: {t!r}") - # Import lazily so test mocks can monkeypatch freely. from plugins.google_meet import process_manager as pm - try: return _proto.make_response(req_id, handler(msg["payload"], pm)) except _RpcError as exc: @@ -118,7 +115,6 @@ class NodeServer: except ImportError as exc: raise RuntimeError("NodeServer.serve requires the 'websockets' package. " "Install it with: pip install websockets") from exc - self.ensure_token() async def _handler(ws): diff --git a/plugins/google_meet/process_manager.py b/plugins/google_meet/process_manager.py index 977b6cce78..afd4ce40b8 100644 --- a/plugins/google_meet/process_manager.py +++ b/plugins/google_meet/process_manager.py @@ -62,24 +62,19 @@ def start(url: str, *, out_dir: Optional[Path] = None, headed: bool = False, realtime_api_key: Optional[str] = None) -> Dict[str, Any]: """Spawn the meet_bot subprocess for *url*, stopping any running bot first (one active meeting).""" from plugins.google_meet.meet_bot import _is_safe_meet_url, _meeting_id_from_url - if not _is_safe_meet_url(url): return {"ok": False, "error": "refusing: only https://meet.google.com/ URLs are allowed. got: " + repr(url)} - if _pid_alive(_active_pid()): stop(reason="replaced by new meet_join") - meeting_id = _meeting_id_from_url(url) out = out_dir or (_root() / meeting_id) out.mkdir(parents=True, exist_ok=True) - # Wipe stale files from a previous run of this meeting id. for name in ("transcript.txt", "status.json"): try: (out / name).unlink() except OSError: pass - env = {**os.environ, "HERMES_MEET_URL": url, "HERMES_MEET_OUT_DIR": str(out), "HERMES_MEET_GUEST_NAME": guest_name} for value, var in ( @@ -96,11 +91,9 @@ def start(url: str, *, out_dir: Optional[Path] = None, headed: bool = False, # (a contextvar) is installed; the detached child inherits env, not scope. if not realtime_api_key: from agent.secret_scope import get_secret - realtime_api_key = get_secret("HERMES_MEET_REALTIME_KEY") or get_secret("OPENAI_API_KEY") if realtime_api_key: env["HERMES_MEET_REALTIME_KEY"] = realtime_api_key - log_path = out / "bot.log" # Detach: stdout/stderr → log file, new session so parent signals don't propagate. with open(log_path, "ab", buffering=0) as log_fh: @@ -129,7 +122,6 @@ def transcript(last: Optional[int] = None) -> Dict[str, Any]: active = _read_active() if not active: return dict(_NO_ACTIVE) - tp = Path(active.get("out_dir", "")) / "transcript.txt" text = tp.read_text(encoding="utf-8", errors="replace") if tp.is_file() else "" all_lines = [ln for ln in text.splitlines() if ln.strip()] @@ -141,22 +133,18 @@ def enqueue_say(text: str) -> Dict[str, Any]: """Append a ``say`` request to ``/say_queue.jsonl``. Refused when no meeting is active or the active bot is transcribe-only.""" import uuid - text = (text or "").strip() if not text: return {"ok": False, "reason": "text is required"} - active = _read_active() if not active: return dict(_NO_ACTIVE) if active.get("mode") != "realtime": return {"ok": False, "reason": ("active meeting is in transcribe mode — pass mode='realtime' " "to meet_join to enable agent speech")} - out_dir = Path(active.get("out_dir", "")) if not out_dir.is_dir(): return {"ok": False, "reason": f"out_dir missing: {out_dir}"} - queue_path = out_dir / "say_queue.jsonl" entry = {"id": uuid.uuid4().hex[:12], "text": text} with queue_path.open("a", encoding="utf-8") as f: @@ -169,7 +157,6 @@ def stop(*, reason: str = "requested") -> Dict[str, Any]: active = _read_active() if not active: return dict(_NO_ACTIVE) - pid = int(active.get("pid", 0)) out_dir = active.get("out_dir") if _pid_alive(pid): @@ -180,7 +167,6 @@ def stop(*, reason: str = "requested") -> Dict[str, Any]: time.sleep(0.5) if _pid_alive(pid): _kill(pid, signal.SIGKILL) # windows-footgun: ok — POSIX-only plugin (google_meet registers no-op on Windows; see __init__.py) - try: (_root() / ".active.json").unlink() except FileNotFoundError: diff --git a/plugins/google_meet/realtime/openai_client.py b/plugins/google_meet/realtime/openai_client.py index bd9cc5c5e3..1f14f3933b 100644 --- a/plugins/google_meet/realtime/openai_client.py +++ b/plugins/google_meet/realtime/openai_client.py @@ -58,7 +58,6 @@ class RealtimeSession: self._ws = connect(url, additional_headers=headers) except TypeError: self._ws = connect(url, extra_headers=headers) - self._send_json({ "type": "session.update", "session": { @@ -81,14 +80,12 @@ class RealtimeSession: so a streaming reader can consume it). Frames other than audio deltas/terminal/error are ignored.""" if self._ws is None: raise RuntimeError("RealtimeSession.connect() must be called first") - start = time.monotonic() self._send_json({ "type": "conversation.item.create", "item": {"type": "message", "role": "user", "content": [{"type": "input_text", "text": text}]}, }) self._send_json({"type": "response.create", "response": {"modalities": ["audio"]}}) - bytes_written = 0 sink_fp = None if self.audio_sink_path is not None: @@ -115,7 +112,6 @@ class RealtimeSession: finally: if sink_fp is not None: sink_fp.close() - return {"ok": True, "bytes_written": bytes_written, "duration_ms": (time.monotonic() - start) * 1000.0} def cancel_response(self) -> bool: @@ -208,7 +204,6 @@ class RealtimeSpeaker: except Exception as exc: result = {"ok": False, "error": str(exc)} self._append_processed(head, result) - # Re-read (new entries may have arrived), then drop the head by position or id. latest = self._read_queue() if latest and latest[0].get("id") == head.get("id"): diff --git a/plugins/google_meet/tools.py b/plugins/google_meet/tools.py index cf42207e24..9162b36266 100644 --- a/plugins/google_meet/tools.py +++ b/plugins/google_meet/tools.py @@ -28,7 +28,6 @@ def resolve_node(node: str): """``(NodeClient, node_name)`` for *node* (``'auto'`` = the sole registered node), or ``(None, None)``.""" from plugins.google_meet.node.registry import NodeRegistry from plugins.google_meet.node.client import NodeClient - entry = NodeRegistry().resolve(node if node != "auto" else None) if entry is None: return None, None @@ -186,7 +185,6 @@ def handle_meet_join(args: Dict[str, Any], **_kw) -> str: mode = (args.get("mode") or "transcribe").strip().lower() if mode not in {"transcribe", "realtime"}: return _err(f"mode must be 'transcribe' or 'realtime' (got {mode!r})") - common: Dict[str, Any] = dict( url=url, guest_name=str(args.get("guest_name") or "Hermes Agent"), duration=str(args.get("duration")) if args.get("duration") else None, diff --git a/plugins/plugin_loader.py b/plugins/plugin_loader.py index 6ef572bf63..49b719f75b 100644 --- a/plugins/plugin_loader.py +++ b/plugins/plugin_loader.py @@ -92,24 +92,20 @@ def load_plugin_module(module_name: str, plugin_dir: Path, *, parents: Tuple[str init_file = plugin_dir / "__init__.py" if not init_file.exists(): return None - # A synthetic package shell has no __file__; only reuse modules loaded from disk. cached = sys.modules.get(module_name) if cached is not None and getattr(cached, "__file__", None): return cached - for parent in parents: parent_path = _PLUGINS_ROOT.joinpath(*parent.split(".")[1:]) if parent not in sys.modules and (parent_path / "__init__.py").exists(): _exec(_new_module(parent, parent_path / "__init__.py", [str(parent_path)])) if synthetic_namespace: register_synthetic_package(synthetic_namespace, []) - # Reserve the name before siblings exec so their relative imports resolve. mod = _new_module(module_name, init_file, [str(plugin_dir)]) if mod is None: return None - loaded_submodules = [] for sub_file in plugin_dir.glob("*.py"): full_sub_name = f"{module_name}.{sub_file.stem}" @@ -118,11 +114,9 @@ def load_plugin_module(module_name: str, plugin_dir: Path, *, parents: Tuple[str sub_mod = _new_module(full_sub_name, sub_file) if _exec(sub_mod, logger): loaded_submodules.append((sub_file.stem, sub_mod)) - if not _exec(mod, logger): sys.modules.pop(module_name, None) return None - parent_name, child_name = module_name.rsplit(".", 1) parent_mod = sys.modules.get(parent_name) if parent_mod is not None: @@ -153,7 +147,6 @@ def instance_from_module(mod: Any, *, collector: Any, collected_attr: str, base_ return instance except Exception as e: logger.debug("register() failed for %s: %s", name, e) - for attr_name in dir(mod): attr = getattr(mod, attr_name, None) if isinstance(attr, type) and issubclass(attr, base_cls) and attr is not base_cls: diff --git a/plugins/plugin_storage.py b/plugins/plugin_storage.py index 9629038500..6be5a8e279 100644 --- a/plugins/plugin_storage.py +++ b/plugins/plugin_storage.py @@ -28,7 +28,6 @@ def plugin_data_dir(name: str) -> Path: """Return (and create) ``/plugin-data//``; resolves ``get_hermes_home()`` on every call so it follows the active profile — don't cache across profile switches.""" from hermes_constants import get_hermes_home - root = get_hermes_home() / "plugin-data" / _validate_name(name) root.mkdir(parents=True, exist_ok=True) return root @@ -39,7 +38,6 @@ def plugin_db(name: str, filename: str = "data.db") -> sqlite3.Connection: ``check_same_thread=False`` for the threaded FastAPI/tool env — caller owns transactions.""" if Path(filename).name != filename or not filename: raise ValueError(f"invalid plugin db filename: {filename!r}") - conn = sqlite3.connect(plugin_data_dir(name) / filename, check_same_thread=False) conn.execute("PRAGMA journal_mode=WAL") conn.execute("PRAGMA foreign_keys=ON")