refactor(plugins): drop dead disk_cleanup.deep + unused constants, compact docstrings/comments, tighten guards in google_meet/chronos/loader
This commit is contained in:
@@ -1,10 +1,6 @@
|
|||||||
"""Context engine plugin discovery.
|
"""Context engine plugin discovery: ``plugins/context_engine/<name>/`` → ``ContextEngine``.
|
||||||
|
Engines ship in the repo, separate from the general plugin system; only one is active
|
||||||
Scans ``plugins/context_engine/<name>/`` for ``ContextEngine`` implementations. Engines live
|
(``context.engine`` in config.yaml; default ``"compressor"``, the built-in ContextCompressor)."""
|
||||||
in the repo (always available, no user install) and are separate from the general plugin
|
|
||||||
system. Only one is active (``context.engine`` in config.yaml; default ``"compressor"``,
|
|
||||||
the built-in ContextCompressor).
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -21,12 +17,9 @@ _CONTEXT_ENGINE_PLUGINS_DIR = Path(__file__).parent
|
|||||||
|
|
||||||
def discover_context_engines() -> List[Tuple[str, str, bool]]:
|
def discover_context_engines() -> List[Tuple[str, str, bool]]:
|
||||||
"""Return ``[(name, description, is_available), ...]`` for every bundled engine."""
|
"""Return ``[(name, description, is_available), ...]`` for every bundled engine."""
|
||||||
return [
|
return [(child.name, _loader.read_plugin_description(child),
|
||||||
(
|
_loader.probe_availability(lambda c=child: _load_engine_from_dir(c)))
|
||||||
child.name,
|
for child in _loader.iter_plugin_dirs(_CONTEXT_ENGINE_PLUGINS_DIR)]
|
||||||
_loader.read_plugin_description(child),
|
|
||||||
_loader.probe_availability(lambda c=child: _load_engine_from_dir(c)))
|
|
||||||
for child in _loader.iter_plugin_dirs(_CONTEXT_ENGINE_PLUGINS_DIR)]
|
|
||||||
|
|
||||||
|
|
||||||
def load_context_engine(name: str) -> Optional["ContextEngine"]: # noqa: F821
|
def load_context_engine(name: str) -> Optional["ContextEngine"]: # noqa: F821
|
||||||
@@ -56,8 +49,8 @@ def _load_engine_from_dir(engine_dir: Path) -> Optional["ContextEngine"]: # noq
|
|||||||
|
|
||||||
|
|
||||||
class _EngineCollector(_loader.NoopPluginContext):
|
class _EngineCollector(_loader.NoopPluginContext):
|
||||||
"""Fake plugin context capturing register_context_engine; forwards register_command
|
"""Captures register_context_engine; forwards register_command to the global plugin command
|
||||||
to the global plugin command registry so engine slash commands behave like plugin ones."""
|
registry so engine slash commands behave like plugin ones."""
|
||||||
|
|
||||||
def __init__(self, engine_name: str = ""):
|
def __init__(self, engine_name: str = ""):
|
||||||
self.engine = None
|
self.engine = None
|
||||||
@@ -70,9 +63,8 @@ class _EngineCollector(_loader.NoopPluginContext):
|
|||||||
def register_command(self, name: str, handler, description: str = "", args_hint: str = "") -> None:
|
def register_command(self, name: str, handler, description: str = "", args_hint: str = "") -> None:
|
||||||
clean = (name or "").lower().strip().lstrip("/").replace(" ", "-")
|
clean = (name or "").lower().strip().lstrip("/").replace(" ", "-")
|
||||||
if not clean:
|
if not clean:
|
||||||
logger.warning(
|
logger.warning("Context engine '%s' tried to register a command with an empty name.",
|
||||||
"Context engine '%s' tried to register a command with an empty name.",
|
self._engine_name)
|
||||||
self._engine_name)
|
|
||||||
return
|
return
|
||||||
|
|
||||||
conflict = "Context engine '%s' tried to register command '/%s' which %s Skipping."
|
conflict = "Context engine '%s' tried to register command '/%s' which %s Skipping."
|
||||||
@@ -91,12 +83,9 @@ class _EngineCollector(_loader.NoopPluginContext):
|
|||||||
logger.warning(conflict, self._engine_name, clean, "is already registered by a plugin.")
|
logger.warning(conflict, self._engine_name, clean, "is already registered by a plugin.")
|
||||||
return
|
return
|
||||||
manager._plugin_commands[clean] = {
|
manager._plugin_commands[clean] = {
|
||||||
"handler": handler,
|
"handler": handler, "description": description or "Context engine command",
|
||||||
"description": description or "Context engine command",
|
"plugin": f"context-engine:{self._engine_name}", "args_hint": (args_hint or "").strip()}
|
||||||
"plugin": f"context-engine:{self._engine_name}",
|
|
||||||
"args_hint": (args_hint or "").strip()}
|
|
||||||
self._registered_commands.append(clean)
|
self._registered_commands.append(clean)
|
||||||
logger.debug("Context engine '%s' registered command: /%s", self._engine_name, clean)
|
logger.debug("Context engine '%s' registered command: /%s", self._engine_name, clean)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.debug(
|
logger.debug("Context engine '%s' could not register /%s: %s", self._engine_name, clean, exc)
|
||||||
"Context engine '%s' could not register /%s: %s", self._engine_name, clean, exc)
|
|
||||||
|
|||||||
@@ -1,11 +1,7 @@
|
|||||||
"""Cron scheduler provider plugin discovery.
|
"""Cron scheduler provider discovery: bundled ``plugins/cron_providers/<name>/`` then user
|
||||||
|
``$HERMES_HOME/plugins/<name>/`` (bundled wins on collision). The built-in InProcessCronScheduler
|
||||||
Scans bundled ``plugins/cron_providers/<name>/`` then user ``$HERMES_HOME/plugins/<name>/``
|
is core, not discovered here, so the fallback can't be removed; one provider is active
|
||||||
for ``CronScheduler`` implementations (bundled wins on name collision). The built-in
|
(``cron.provider`` in config.yaml, empty = built-in)."""
|
||||||
``InProcessCronScheduler`` is core (``cron/scheduler_provider.py``), not discovered here,
|
|
||||||
so the fallback can never be accidentally removed; only one provider is active
|
|
||||||
(``cron.provider`` in config.yaml, empty = built-in).
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -22,7 +18,6 @@ _CRON_PLUGINS_DIR = Path(__file__).parent
|
|||||||
_USER_NAMESPACE = "_hermes_user_cron"
|
_USER_NAMESPACE = "_hermes_user_cron"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def _is_cron_provider_dir(path: Path) -> bool:
|
def _is_cron_provider_dir(path: Path) -> bool:
|
||||||
"""Cheap text heuristic: ``__init__.py`` mentions the cron scheduler contract."""
|
"""Cheap text heuristic: ``__init__.py`` mentions the cron scheduler contract."""
|
||||||
init_file = path / "__init__.py"
|
init_file = path / "__init__.py"
|
||||||
@@ -40,10 +35,8 @@ def _user_provider_dirs() -> List[Path]:
|
|||||||
user_dir = _loader.user_plugins_dir()
|
user_dir = _loader.user_plugins_dir()
|
||||||
if not user_dir:
|
if not user_dir:
|
||||||
return []
|
return []
|
||||||
return [
|
return [child for child in sorted(user_dir.iterdir())
|
||||||
child for child in sorted(user_dir.iterdir())
|
if child.is_dir() and not child.name.startswith(("_", ".")) and _is_cron_provider_dir(child)]
|
||||||
if child.is_dir() and not child.name.startswith(("_", ".")) and _is_cron_provider_dir(child)
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def _iter_provider_dirs() -> List[Tuple[str, Path]]:
|
def _iter_provider_dirs() -> List[Tuple[str, Path]]:
|
||||||
@@ -67,12 +60,9 @@ def find_provider_dir(name: str) -> Optional[Path]:
|
|||||||
|
|
||||||
def discover_cron_schedulers() -> List[Tuple[str, str, bool]]:
|
def discover_cron_schedulers() -> List[Tuple[str, str, bool]]:
|
||||||
"""Return ``[(name, description, is_available), ...]`` for all discovered providers."""
|
"""Return ``[(name, description, is_available), ...]`` for all discovered providers."""
|
||||||
return [
|
return [(name, _loader.read_plugin_description(child),
|
||||||
(
|
_loader.probe_availability(lambda c=child: _load_provider_from_dir(c)))
|
||||||
name,
|
for name, child in _iter_provider_dirs()]
|
||||||
_loader.read_plugin_description(child),
|
|
||||||
_loader.probe_availability(lambda c=child: _load_provider_from_dir(c)))
|
|
||||||
for name, child in _iter_provider_dirs()]
|
|
||||||
|
|
||||||
|
|
||||||
def load_cron_scheduler(name: str) -> Optional["CronScheduler"]: # noqa: F821
|
def load_cron_scheduler(name: str) -> Optional["CronScheduler"]: # noqa: F821
|
||||||
@@ -94,9 +84,7 @@ def _load_provider_from_dir(provider_dir: Path) -> Optional["CronScheduler"]: #
|
|||||||
is_bundled = _CRON_PLUGINS_DIR in provider_dir.parents or provider_dir.parent == _CRON_PLUGINS_DIR
|
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}"
|
module_name = f"plugins.cron_providers.{name}" if is_bundled else f"{_USER_NAMESPACE}.{name}"
|
||||||
mod = _loader.load_plugin_module(
|
mod = _loader.load_plugin_module(
|
||||||
module_name, provider_dir,
|
module_name, provider_dir, parents=("plugins", "plugins.cron_providers"), logger=logger,
|
||||||
parents=("plugins", "plugins.cron_providers"),
|
|
||||||
logger=logger,
|
|
||||||
synthetic_namespace=None if is_bundled else _USER_NAMESPACE)
|
synthetic_namespace=None if is_bundled else _USER_NAMESPACE)
|
||||||
if mod is None:
|
if mod is None:
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
"""Chronos — NAS-mediated managed cron provider (scale-to-zero).
|
"""Chronos — NAS-mediated managed cron provider (scale-to-zero).
|
||||||
|
|
||||||
Instead of a 60s in-process ticker, asks NAS to arm one external one-shot per job at its
|
Instead of a 60s ticker, asks NAS to arm one external one-shot per job at its next-fire time;
|
||||||
next-fire time; NAS calls back ``/api/cron/fire`` and the job re-arms after running.
|
NAS calls back ``/api/cron/fire`` and the job re-arms after running. start() never blocks or
|
||||||
Between fires the machine is truly idle: start() never blocks or spawns a periodic wake,
|
spawns a periodic wake; reconcile runs only on a warm process (start / on_jobs_changed / fire).
|
||||||
and reconcile only runs on a warm process (start / on_jobs_changed / piggybacked on a fire).
|
Holds no scheduler credentials — speaks only to NAS ``agent-cron`` endpoints with the Nous token.
|
||||||
Chronos names no scheduler vendor and holds no scheduler credentials — it speaks only to
|
|
||||||
NAS's ``agent-cron`` endpoints with the agent's existing Nous token.
|
|
||||||
Inert unless ``cron.provider: chronos``. Wire contract: ``docs/chronos-managed-cron-contract.md``.
|
Inert unless ``cron.provider: chronos``. Wire contract: ``docs/chronos-managed-cron-contract.md``.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -33,8 +31,7 @@ class ChronosCronScheduler(CronScheduler):
|
|||||||
"""NAS-mediated external cron provider."""
|
"""NAS-mediated external cron provider."""
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
# Best-effort cache of job_id -> fire_at armed via NAS; reconcile rebuilds
|
# Best-effort job_id -> fire_at cache; a cold process simply re-arms (idempotent).
|
||||||
# desired state from jobs.json, so a cold process simply re-arms (idempotent).
|
|
||||||
self._armed: Dict[str, str] = {}
|
self._armed: Dict[str, str] = {}
|
||||||
self._lock = threading.Lock()
|
self._lock = threading.Lock()
|
||||||
self._client = None # lazily constructed (no network in is_available)
|
self._client = None # lazily constructed (no network in is_available)
|
||||||
@@ -44,12 +41,11 @@ class ChronosCronScheduler(CronScheduler):
|
|||||||
return "chronos"
|
return "chronos"
|
||||||
|
|
||||||
def is_available(self) -> bool:
|
def is_available(self) -> bool:
|
||||||
"""Config presence only — NO network. Needs portal URL, a publicly reachable
|
"""Config presence only — NO network: portal URL, publicly reachable callback URL and a
|
||||||
callback URL (for NAS->agent fires) and a Nous login; otherwise the resolver
|
Nous login; otherwise the resolver falls back to the built-in ticker."""
|
||||||
falls back to the built-in ticker."""
|
|
||||||
if not (_cfg("cron", "chronos", "portal_url") and _cfg("cron", "chronos", "callback_url")):
|
if not (_cfg("cron", "chronos", "portal_url") and _cfg("cron", "chronos", "callback_url")):
|
||||||
return False
|
return False
|
||||||
# Stored-token presence only (no refresh); the refresh-aware token is resolved at provision time.
|
# Stored-token presence only (no refresh); refresh-aware token resolved at provision time.
|
||||||
try:
|
try:
|
||||||
from hermes_cli.auth import get_provider_auth_state
|
from hermes_cli.auth import get_provider_auth_state
|
||||||
return bool((get_provider_auth_state("nous") or {}).get("access_token"))
|
return bool((get_provider_auth_state("nous") or {}).get("access_token"))
|
||||||
@@ -62,12 +58,9 @@ class ChronosCronScheduler(CronScheduler):
|
|||||||
self._client = NasCronClient(_cfg("cron", "chronos", "portal_url"))
|
self._client = NasCronClient(_cfg("cron", "chronos", "portal_url"))
|
||||||
return self._client
|
return self._client
|
||||||
|
|
||||||
# -- lifecycle --------------------------------------------------------
|
|
||||||
|
|
||||||
def start(self, stop_event, *, adapters=None, loop=None, interval=60):
|
def start(self, stop_event, *, adapters=None, loop=None, interval=60):
|
||||||
"""Arm all enabled jobs via NAS, then RETURN — no loop, no periodic wake (scale-to-zero)."""
|
"""Arm all enabled jobs via NAS, then RETURN — no loop, no periodic wake (scale-to-zero)."""
|
||||||
# A new lifecycle cannot prove what an interrupted prior process did:
|
# A new lifecycle can't prove what an interrupted process did: classify unknown, never requeue.
|
||||||
# classify those attempts unknown for audit only, never requeue here.
|
|
||||||
self.recover_interrupted()
|
self.recover_interrupted()
|
||||||
try:
|
try:
|
||||||
self.reconcile()
|
self.reconcile()
|
||||||
@@ -87,20 +80,16 @@ class ChronosCronScheduler(CronScheduler):
|
|||||||
"""Arm the first one-shot for a new job; may raise so creation can report it."""
|
"""Arm the first one-shot for a new job; may raise so creation can report it."""
|
||||||
self._arm_one_shot(job)
|
self._arm_one_shot(job)
|
||||||
|
|
||||||
# -- arming -----------------------------------------------------------
|
|
||||||
|
|
||||||
def _arm_one_shot(self, job: Dict[str, Any]) -> None:
|
def _arm_one_shot(self, job: Dict[str, Any]) -> None:
|
||||||
"""Arm exactly one one-shot at next_run_at. The agent computes the time; NAS is the
|
"""Arm one one-shot at next_run_at (agent computes the time; NAS executes).
|
||||||
dumb executor. dedup_key=(job_id, fire_at) makes re-arming the same fire a no-op."""
|
dedup_key=(job_id, fire_at) makes re-arming the same fire a no-op."""
|
||||||
job_id = job["id"]
|
job_id = job["id"]
|
||||||
fire_at = job.get("next_run_at")
|
fire_at = job.get("next_run_at")
|
||||||
if not fire_at:
|
if not fire_at:
|
||||||
return
|
return
|
||||||
self._get_client().provision(
|
self._get_client().provision(
|
||||||
job_id=job_id,
|
job_id=job_id, fire_at=fire_at, dedup_key=f"{job_id}:{fire_at}",
|
||||||
fire_at=fire_at,
|
agent_callback_url=str(_cfg("cron", "chronos", "callback_url") or ""))
|
||||||
agent_callback_url=str(_cfg("cron", "chronos", "callback_url") or ""),
|
|
||||||
dedup_key=f"{job_id}:{fire_at}")
|
|
||||||
with self._lock:
|
with self._lock:
|
||||||
self._armed[job_id] = fire_at
|
self._armed[job_id] = fire_at
|
||||||
|
|
||||||
@@ -119,16 +108,14 @@ class ChronosCronScheduler(CronScheduler):
|
|||||||
self._armed.pop(job_id, None)
|
self._armed.pop(job_id, None)
|
||||||
|
|
||||||
def _list_armed(self) -> Dict[str, str]:
|
def _list_armed(self) -> Dict[str, str]:
|
||||||
"""Observed armed one-shots (job_id -> fire_at): in-memory map when warm, else ask
|
"""Armed one-shots (job_id -> fire_at): in-memory map when warm, else ask NAS ({} on
|
||||||
NAS. If the NAS list fails return {} — reconcile then re-arms idempotently."""
|
failure — reconcile then re-arms idempotently)."""
|
||||||
with self._lock:
|
with self._lock:
|
||||||
if self._armed:
|
if self._armed:
|
||||||
return dict(self._armed)
|
return dict(self._armed)
|
||||||
try:
|
try:
|
||||||
observed = {
|
observed = {item["job_id"]: item.get("fire_at", "")
|
||||||
item["job_id"]: item.get("fire_at", "")
|
for item in self._get_client().list_armed() if item.get("job_id")}
|
||||||
for item in self._get_client().list_armed()
|
|
||||||
if item.get("job_id")}
|
|
||||||
with self._lock:
|
with self._lock:
|
||||||
self._armed.update(observed)
|
self._armed.update(observed)
|
||||||
return observed
|
return observed
|
||||||
@@ -141,8 +128,7 @@ class ChronosCronScheduler(CronScheduler):
|
|||||||
from cron.jobs import get_job, load_jobs
|
from cron.jobs import get_job, load_jobs
|
||||||
|
|
||||||
desired: Dict[str, str] = {
|
desired: Dict[str, str] = {
|
||||||
j["id"]: j["next_run_at"]
|
j["id"]: j["next_run_at"] for j in load_jobs()
|
||||||
for j in load_jobs()
|
|
||||||
if j.get("enabled") and j.get("next_run_at") and j.get("state") != "paused"}
|
if j.get("enabled") and j.get("next_run_at") and j.get("state") != "paused"}
|
||||||
observed = self._list_armed()
|
observed = self._list_armed()
|
||||||
|
|
||||||
@@ -152,22 +138,19 @@ class ChronosCronScheduler(CronScheduler):
|
|||||||
if job:
|
if job:
|
||||||
self._arm_logged(job, f"arm job {job_id}")
|
self._arm_logged(job, f"arm job {job_id}")
|
||||||
|
|
||||||
for job_id in list(observed.keys()):
|
for job_id in observed.keys() - desired.keys():
|
||||||
if job_id not in desired:
|
try:
|
||||||
try:
|
self._cancel(job_id)
|
||||||
self._cancel(job_id)
|
except Exception as e:
|
||||||
except Exception as e:
|
logger.warning("Chronos failed to cancel orphan %s: %s", job_id, e)
|
||||||
logger.warning("Chronos failed to cancel orphan %s: %s", job_id, e)
|
|
||||||
|
|
||||||
# No ``fire_due`` override on purpose: ``provider_supports_split_fire`` treats ANY
|
# No ``fire_due`` override on purpose: ``provider_supports_split_fire`` treats ANY override as
|
||||||
# override as the legacy single-phase signal, which would opt Chronos out of claim
|
# the legacy single-phase signal, opting Chronos out of claim admission and cancel-aware drain.
|
||||||
# admission, duplicate detection, and cancel-aware drain on the fire webhook.
|
|
||||||
|
|
||||||
def fire_claimed(
|
def fire_claimed(
|
||||||
self, claimed_job: dict, *, adapters: Any = None, loop: Any = None, cancel_event: Any = None
|
self, claimed_job: dict, *, adapters: Any = None, loop: Any = None, cancel_event: Any = None
|
||||||
) -> bool:
|
) -> bool:
|
||||||
ran = super().fire_claimed(
|
ran = super().fire_claimed(claimed_job, adapters=adapters, loop=loop, cancel_event=cancel_event)
|
||||||
claimed_job, adapters=adapters, loop=loop, cancel_event=cancel_event)
|
|
||||||
if ran:
|
if ran:
|
||||||
from cron.jobs import get_job
|
from cron.jobs import get_job
|
||||||
job_id = claimed_job["id"]
|
job_id = claimed_job["id"]
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
"""Thin HTTP client for the agent -> NAS ``agent-cron`` endpoints (Chronos).
|
"""Thin HTTP client for the agent -> NAS ``agent-cron`` endpoints (Chronos): arm one-shot / cancel /
|
||||||
|
list, authenticated with the existing Nous Portal token.
|
||||||
The agent only asks NAS to "arm a one-shot at time T" / "cancel" / "list", authenticated
|
Wire contract: ``docs/chronos-managed-cron-contract.md``."""
|
||||||
with its existing Nous Portal access token (no new secret). NAS owns the external
|
|
||||||
scheduler and its credentials. Wire contract: ``docs/chronos-managed-cron-contract.md``.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -31,23 +28,20 @@ class NasCronClient:
|
|||||||
def _headers(self) -> Dict[str, str]:
|
def _headers(self) -> Dict[str, str]:
|
||||||
"""Bearer auth with the agent's existing Nous Portal access token (refresh-aware)."""
|
"""Bearer auth with the agent's existing Nous Portal access token (refresh-aware)."""
|
||||||
from hermes_cli.auth import resolve_nous_access_token
|
from hermes_cli.auth import resolve_nous_access_token
|
||||||
return {
|
return {"Authorization": f"Bearer {resolve_nous_access_token()}",
|
||||||
"Authorization": f"Bearer {resolve_nous_access_token()}",
|
"Content-Type": "application/json"}
|
||||||
"Content-Type": "application/json"}
|
|
||||||
|
|
||||||
def _request(self, method: str, path: str, **kwargs: Any) -> Dict[str, Any]:
|
def _request(self, method: str, path: str, **kwargs: Any) -> Dict[str, Any]:
|
||||||
"""Issue one request; raise NasCronClientError on transport error or non-2xx."""
|
"""Issue one request; raise NasCronClientError on transport error or non-2xx."""
|
||||||
import requests # lazy: agent already depends on requests
|
import requests # lazy: agent already depends on requests
|
||||||
|
|
||||||
try:
|
try:
|
||||||
resp = requests.request(
|
resp = requests.request(method, f"{self.portal_url}{path}", headers=self._headers(),
|
||||||
method, f"{self.portal_url}{path}",
|
timeout=self.timeout_seconds, **kwargs)
|
||||||
headers=self._headers(), timeout=self.timeout_seconds, **kwargs)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise NasCronClientError(f"{method} {path} failed: {e}") from e
|
raise NasCronClientError(f"{method} {path} failed: {e}") from e
|
||||||
if resp.status_code // 100 != 2:
|
if resp.status_code // 100 != 2:
|
||||||
raise NasCronClientError(
|
raise NasCronClientError(f"{method} {path} returned {resp.status_code}: {resp.text[:200]}")
|
||||||
f"{method} {path} returned {resp.status_code}: {resp.text[:200]}")
|
|
||||||
try:
|
try:
|
||||||
return resp.json() if resp.content else {}
|
return resp.json() if resp.content else {}
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -55,15 +49,10 @@ class NasCronClient:
|
|||||||
|
|
||||||
def provision(self, *, job_id: str, fire_at: str, agent_callback_url: str,
|
def provision(self, *, job_id: str, fire_at: str, agent_callback_url: str,
|
||||||
dedup_key: str) -> Dict[str, Any]:
|
dedup_key: str) -> Dict[str, Any]:
|
||||||
"""Arm a one-shot for ``job_id`` at ``fire_at`` (ISO 8601).
|
"""Arm a one-shot for ``job_id`` at ``fire_at`` (ISO 8601); ``dedup_key`` makes re-arming
|
||||||
|
idempotent NAS-side. Returns the NAS response (e.g. ``{schedule_id}``)."""
|
||||||
``dedup_key`` (``{job_id}:{fire_at}``) makes re-arming the same fire idempotent
|
|
||||||
NAS-side. Returns the NAS response (e.g. ``{schedule_id}``).
|
|
||||||
"""
|
|
||||||
return self._request("POST", _PROVISION_PATH, json={
|
return self._request("POST", _PROVISION_PATH, json={
|
||||||
"job_id": job_id,
|
"job_id": job_id, "fire_at": fire_at, "agent_callback_url": agent_callback_url,
|
||||||
"fire_at": fire_at,
|
|
||||||
"agent_callback_url": agent_callback_url,
|
|
||||||
"dedup_key": dedup_key})
|
"dedup_key": dedup_key})
|
||||||
|
|
||||||
def cancel(self, *, job_id: str) -> Dict[str, Any]:
|
def cancel(self, *, job_id: str) -> Dict[str, Any]:
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
"""Inbound cron-fire token verification for Chronos.
|
"""Inbound cron-fire token verification for Chronos.
|
||||||
|
|
||||||
NAS POSTs ``/api/cron/fire`` with a short-lived NAS-minted JWT; this verifies it (via
|
NAS POSTs ``/api/cron/fire`` with a short-lived NAS-minted JWT, verified via PyJWT (never
|
||||||
PyJWT, never hand-rolled) before any job runs. We verify a NAS-minted token rather than
|
hand-rolled) before any job runs. ``get_fire_verifier`` is the pluggable seam so an alternative
|
||||||
let the external scheduler call the agent directly because the scheduler signs with NAS
|
auth mode (direct per-job cron-key) can swap in without a handler change.
|
||||||
keys the agent doesn't hold. ``get_fire_verifier`` is the pluggable seam so an
|
|
||||||
alternative auth mode (direct per-job cron-key) can swap in without a handler change.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -15,13 +13,11 @@ from typing import Any, Callable, Dict, Optional
|
|||||||
|
|
||||||
logger = logging.getLogger("cron.chronos.verify")
|
logger = logging.getLogger("cron.chronos.verify")
|
||||||
|
|
||||||
# Purpose claim scoping a token to the fire endpoint; a general agent JWT must not replay here.
|
# Purpose claim scoping a token to the fire endpoint (a general agent JWT must not replay here).
|
||||||
_FIRE_PURPOSE = "cron_fire"
|
_FIRE_PURPOSE = "cron_fire"
|
||||||
|
|
||||||
# Process-wide PyJWKClient cache keyed by JWKS URL. PyJWKClient caches keys on the
|
# Process-wide PyJWKClient cache keyed by JWKS URL: PyJWKClient caches keys on the INSTANCE, so a
|
||||||
# INSTANCE, so a fresh client per fire re-fetched the JWKS every time — under bursts
|
# fresh client per fire re-fetched JWKS every time (portal rate-limits 403->401; relay 504s).
|
||||||
# the portal rate-limited (403 -> 401) or the fetch blocked the loop past the relay
|
|
||||||
# timeout (504). One client per URL makes the steady state zero JWKS fetches per fire.
|
|
||||||
_JWK_CLIENTS: Dict[str, Any] = {}
|
_JWK_CLIENTS: Dict[str, Any] = {}
|
||||||
_JWK_CLIENTS_LOCK = threading.Lock()
|
_JWK_CLIENTS_LOCK = threading.Lock()
|
||||||
|
|
||||||
@@ -43,24 +39,15 @@ def _get_jwk_client(jwks_url: str) -> Any:
|
|||||||
return client
|
return client
|
||||||
|
|
||||||
|
|
||||||
def verify_nas_fire_token(
|
def verify_nas_fire_token(*, token: str, expected_audience: str, jwks_or_key: Optional[str] = None,
|
||||||
*,
|
issuer: Optional[str] = None, leeway_seconds: int = 30) -> Optional[Dict[str, Any]]:
|
||||||
token: str,
|
"""Verify a NAS-minted cron-fire JWT; return decoded claims or None (never raises, so the
|
||||||
expected_audience: str,
|
handler answers 401 without leaking which check failed). Checks asymmetric signature (JWKS
|
||||||
jwks_or_key: Optional[str] = None,
|
URL or inline PEM; symmetric rejected), ``aud``, ``exp``/``nbf`` with leeway, ``iss`` when
|
||||||
issuer: Optional[str] = None,
|
configured, and ``purpose == "cron_fire"``."""
|
||||||
leeway_seconds: int = 30) -> Optional[Dict[str, Any]]:
|
|
||||||
"""Verify a NAS-minted cron-fire JWT; return decoded claims or None (never raises).
|
|
||||||
|
|
||||||
Checks asymmetric signature (JWKS URL or inline PEM; symmetric secrets rejected since
|
|
||||||
NAS signs asymmetrically), ``aud``, ``exp``/``nbf`` with leeway, ``iss`` when configured,
|
|
||||||
and ``purpose == "cron_fire"``. Never raises, so the handler answers 401 without
|
|
||||||
leaking which check failed.
|
|
||||||
"""
|
|
||||||
if not token or not expected_audience:
|
if not token or not expected_audience:
|
||||||
return None
|
return None
|
||||||
if not jwks_or_key:
|
if not jwks_or_key: # never fall back to unsigned decode on a security boundary
|
||||||
# No key -> refuse; never fall back to unsigned decode on a security boundary.
|
|
||||||
logger.warning("cron fire: no JWKS/key configured; refusing token")
|
logger.warning("cron fire: no JWKS/key configured; refusing token")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -73,10 +60,8 @@ def verify_nas_fire_token(
|
|||||||
signing_key = jwks_or_key # inline PEM public key (test / pinned-key deployments)
|
signing_key = jwks_or_key # inline PEM public key (test / pinned-key deployments)
|
||||||
|
|
||||||
decode_kwargs: Dict[str, Any] = dict(
|
decode_kwargs: Dict[str, Any] = dict(
|
||||||
algorithms=["RS256", "RS384", "RS512", "ES256", "ES384"],
|
algorithms=["RS256", "RS384", "RS512", "ES256", "ES384"], audience=expected_audience,
|
||||||
audience=expected_audience,
|
leeway=leeway_seconds, options={"require": ["exp", "aud"]})
|
||||||
leeway=leeway_seconds,
|
|
||||||
options={"require": ["exp", "aud"]})
|
|
||||||
if issuer:
|
if issuer:
|
||||||
decode_kwargs["issuer"] = issuer
|
decode_kwargs["issuer"] = issuer
|
||||||
claims = jwt.decode(token, signing_key, **decode_kwargs)
|
claims = jwt.decode(token, signing_key, **decode_kwargs)
|
||||||
|
|||||||
@@ -1,15 +1,8 @@
|
|||||||
"""disk-cleanup plugin — auto-cleanup of ephemeral Hermes session files.
|
"""disk-cleanup plugin — auto-cleanup of ephemeral Hermes session files.
|
||||||
|
|
||||||
Wires three behaviours:
|
``post_tool_call`` silently tracks test/temp paths created by write_file/patch/terminal;
|
||||||
|
``on_session_end`` runs :func:`disk_cleanup.quick` when any test file was tracked this turn;
|
||||||
1. ``post_tool_call`` hook — inspects ``write_file`` / ``patch`` / ``terminal``
|
``/disk-cleanup`` exposes status / dry-run / quick / deep / track / forget.
|
||||||
tool calls for newly-created paths matching test/temp patterns under
|
|
||||||
``HERMES_HOME`` and tracks them silently. Zero agent compliance required.
|
|
||||||
2. ``on_session_end`` hook — when any test files were auto-tracked during the
|
|
||||||
just-finished turn, runs :func:`disk_cleanup.quick` and logs one line to
|
|
||||||
``$HERMES_HOME/disk-cleanup/cleanup.log``.
|
|
||||||
3. ``/disk-cleanup`` slash command — manual ``status``, ``dry-run``, ``quick``,
|
|
||||||
``deep``, ``track``, ``forget``.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -26,27 +19,22 @@ from . import disk_cleanup as dg
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
# Per-task set of test files newly tracked this turn, keyed by task_id (or
|
# Test files newly tracked this turn, keyed by task_id (or session_id) so on_session_end can
|
||||||
# session_id as fallback) so on_session_end can decide whether to run cleanup.
|
# decide whether to run cleanup. Locked: post_tool_call fires concurrently on parallel calls.
|
||||||
# Locked: post_tool_call can fire concurrently on parallel tool calls.
|
|
||||||
_recent_test_tracks: Dict[str, Set[str]] = {}
|
_recent_test_tracks: Dict[str, Set[str]] = {}
|
||||||
_lock = threading.Lock()
|
_lock = threading.Lock()
|
||||||
|
|
||||||
_TERMINAL_PATH_REGEX = re.compile(r"(?:^|\s)(/[^\s'\"`]+|\~/[^\s'\"`]+)")
|
_TERMINAL_PATH_REGEX = re.compile(r"(?:^|\s)(/[^\s'\"`]+|\~/[^\s'\"`]+)")
|
||||||
|
|
||||||
|
|
||||||
# --- Path extraction from tool calls ----------------------------------------
|
|
||||||
|
|
||||||
def _extract_path_arg(args: Dict[str, Any], result: str) -> Set[str]:
|
def _extract_path_arg(args: Dict[str, Any], result: str) -> Set[str]:
|
||||||
"""write_file and patch: only the single-file ``path`` arg. patch mostly edits
|
"""write_file/patch: the single ``path`` arg (re-tracking existing files is a no-op)."""
|
||||||
existing files; re-tracking is a no-op (track() dedups), so this is safe."""
|
|
||||||
path = args.get("path")
|
path = args.get("path")
|
||||||
return {path} if isinstance(path, str) and path else set()
|
return {path} if isinstance(path, str) and path else set()
|
||||||
|
|
||||||
|
|
||||||
def _extract_paths_from_terminal(args: Dict[str, Any], result: str) -> Set[str]:
|
def _extract_paths_from_terminal(args: Dict[str, Any], result: str) -> Set[str]:
|
||||||
"""Best-effort: pull candidate filesystem paths from a terminal command and
|
"""Candidate paths from a terminal command + output; guess_category/is_safe_path filter later."""
|
||||||
its output; ``guess_category`` / ``is_safe_path`` filter them afterwards."""
|
|
||||||
paths: Set[str] = set()
|
paths: Set[str] = set()
|
||||||
cmd = args.get("command") or ""
|
cmd = args.get("command") or ""
|
||||||
if isinstance(cmd, str) and cmd:
|
if isinstance(cmd, str) and cmd:
|
||||||
@@ -66,16 +54,8 @@ _PATH_EXTRACTORS: Dict[str, Callable[[Dict[str, Any], str], Set[str]]] = {
|
|||||||
"terminal": _extract_paths_from_terminal}
|
"terminal": _extract_paths_from_terminal}
|
||||||
|
|
||||||
|
|
||||||
# --- Hooks ------------------------------------------------------------------
|
def _on_post_tool_call(tool_name: str = "", args: Optional[Dict[str, Any]] = None, result: Any = None,
|
||||||
|
task_id: str = "", session_id: str = "", tool_call_id: str = "", **_: Any) -> None:
|
||||||
def _on_post_tool_call(
|
|
||||||
tool_name: str = "",
|
|
||||||
args: Optional[Dict[str, Any]] = None,
|
|
||||||
result: Any = None,
|
|
||||||
task_id: str = "",
|
|
||||||
session_id: str = "",
|
|
||||||
tool_call_id: str = "",
|
|
||||||
**_: Any) -> None:
|
|
||||||
"""Auto-track ephemeral files created by recent tool calls. Best-effort, never raises."""
|
"""Auto-track ephemeral files created by recent tool calls. Best-effort, never raises."""
|
||||||
extractor = _PATH_EXTRACTORS.get(tool_name)
|
extractor = _PATH_EXTRACTORS.get(tool_name)
|
||||||
if not isinstance(args, dict) or extractor is None:
|
if not isinstance(args, dict) or extractor is None:
|
||||||
@@ -96,8 +76,7 @@ def _on_post_tool_call(
|
|||||||
def _on_session_end(
|
def _on_session_end(
|
||||||
session_id: str = "", completed: bool = True, interrupted: bool = False, **_: Any) -> None:
|
session_id: str = "", completed: bool = True, interrupted: bool = False, **_: Any) -> None:
|
||||||
"""Run quick cleanup if any test files were tracked during this turn."""
|
"""Run quick cleanup if any test files were tracked during this turn."""
|
||||||
# Drain the session bucket plus every task-scoped bucket: subagents record
|
# Drain the session bucket plus every task-scoped bucket (subagents record into their own).
|
||||||
# into their own task_id buckets and should be cleaned up at session end too.
|
|
||||||
with _lock:
|
with _lock:
|
||||||
had_tracks = bool(_recent_test_tracks.pop(session_id or "default", None) or _recent_test_tracks)
|
had_tracks = bool(_recent_test_tracks.pop(session_id or "default", None) or _recent_test_tracks)
|
||||||
_recent_test_tracks.clear()
|
_recent_test_tracks.clear()
|
||||||
@@ -111,13 +90,10 @@ def _on_session_end(
|
|||||||
return
|
return
|
||||||
|
|
||||||
if summary["deleted"] or summary["empty_dirs"]:
|
if summary["deleted"] or summary["empty_dirs"]:
|
||||||
dg._log(
|
dg._log(f"AUTO_QUICK (session_end): deleted={summary['deleted']} "
|
||||||
f"AUTO_QUICK (session_end): deleted={summary['deleted']} "
|
f"dirs={summary['empty_dirs']} freed={dg.fmt_size(summary['freed'])}")
|
||||||
f"dirs={summary['empty_dirs']} freed={dg.fmt_size(summary['freed'])}")
|
|
||||||
|
|
||||||
|
|
||||||
# --- Slash command ----------------------------------------------------------
|
|
||||||
|
|
||||||
_HELP_TEXT = """\
|
_HELP_TEXT = """\
|
||||||
/disk-cleanup — ephemeral-file cleanup
|
/disk-cleanup — ephemeral-file cleanup
|
||||||
|
|
||||||
@@ -137,9 +113,8 @@ Test files are auto-tracked on write_file / terminal and auto-cleaned at session
|
|||||||
|
|
||||||
|
|
||||||
def _fmt_summary(summary: Dict[str, Any]) -> str:
|
def _fmt_summary(summary: Dict[str, Any]) -> str:
|
||||||
base = (
|
base = (f"[disk-cleanup] Cleaned {summary['deleted']} files + "
|
||||||
f"[disk-cleanup] Cleaned {summary['deleted']} files + "
|
f"{summary['empty_dirs']} empty dirs, freed {dg.fmt_size(summary['freed'])}.")
|
||||||
f"{summary['empty_dirs']} empty dirs, freed {dg.fmt_size(summary['freed'])}.")
|
|
||||||
if summary.get("errors"):
|
if summary.get("errors"):
|
||||||
base += f"\n {len(summary['errors'])} error(s); see cleanup.log."
|
base += f"\n {len(summary['errors'])} error(s); see cleanup.log."
|
||||||
return base
|
return base
|
||||||
@@ -161,8 +136,7 @@ def _cmd_dry_run(argv: List[str]) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _cmd_deep(argv: List[str]) -> str:
|
def _cmd_deep(argv: List[str]) -> str:
|
||||||
# In-session deep can't prompt interactively — show what quick cleaned plus
|
# In-session deep can't prompt — show what quick cleaned plus items needing confirmation.
|
||||||
# the items that WOULD need confirmation.
|
|
||||||
quick_summary = dg.quick()
|
quick_summary = dg.quick()
|
||||||
_auto, prompt_items = dg.dry_run()
|
_auto, prompt_items = dg.dry_run()
|
||||||
lines = [_fmt_summary(quick_summary)]
|
lines = [_fmt_summary(quick_summary)]
|
||||||
@@ -187,9 +161,8 @@ def _cmd_forget(argv: List[str]) -> str:
|
|||||||
if len(argv) < 2:
|
if len(argv) < 2:
|
||||||
return "Usage: /disk-cleanup forget <path>"
|
return "Usage: /disk-cleanup forget <path>"
|
||||||
n = dg.forget(argv[1])
|
n = dg.forget(argv[1])
|
||||||
return (
|
return (f"Removed {n} tracking entr{'y' if n == 1 else 'ies'} for {argv[1]}." if n
|
||||||
f"Removed {n} tracking entr{'y' if n == 1 else 'ies'} for {argv[1]}."
|
else f"Not found in tracking: {argv[1]}")
|
||||||
if n else f"Not found in tracking: {argv[1]}")
|
|
||||||
|
|
||||||
|
|
||||||
_SUBCOMMANDS: Dict[str, Callable[[List[str]], str]] = {
|
_SUBCOMMANDS: Dict[str, Callable[[List[str]], str]] = {
|
||||||
@@ -214,7 +187,5 @@ def _handle_slash(raw_args: str) -> Optional[str]:
|
|||||||
def register(ctx) -> None:
|
def register(ctx) -> None:
|
||||||
ctx.register_hook("post_tool_call", _on_post_tool_call)
|
ctx.register_hook("post_tool_call", _on_post_tool_call)
|
||||||
ctx.register_hook("on_session_end", _on_session_end)
|
ctx.register_hook("on_session_end", _on_session_end)
|
||||||
ctx.register_command(
|
ctx.register_command("disk-cleanup", handler=_handle_slash,
|
||||||
"disk-cleanup",
|
description="Track and clean up ephemeral Hermes session files.")
|
||||||
handler=_handle_slash,
|
|
||||||
description="Track and clean up ephemeral Hermes session files.")
|
|
||||||
|
|||||||
@@ -1,15 +1,9 @@
|
|||||||
"""disk_cleanup — ephemeral file cleanup for Hermes Agent.
|
"""disk_cleanup — ephemeral file cleanup library behind the disk-cleanup plugin.
|
||||||
|
|
||||||
Library module behind the disk-cleanup plugin; ``__init__.py`` wires these
|
|
||||||
functions into ``post_tool_call`` / ``on_session_end`` hooks so tracking and
|
|
||||||
cleanup happen without the agent calling a tool or remembering a skill.
|
|
||||||
|
|
||||||
Rules: test files delete at task end (age >= 0); temp after 7 days; cron-output
|
Rules: test files delete at task end (age >= 0); temp after 7 days; cron-output
|
||||||
after 14 days; empty dirs under HERMES_HOME always. Deep-only prompts: research
|
after 14 days; empty dirs under HERMES_HOME always. Prompt-only: research
|
||||||
(keep 10 newest, > 30 days), chrome-profile > 14 days, any file > 500 MB.
|
(keep 10 newest, > 30 days), chrome-profile > 14 days, any file > 500 MB.
|
||||||
|
Scope: strictly HERMES_HOME and /tmp/hermes-*; never ~/.hermes/logs/ or system dirs.
|
||||||
Scope: strictly HERMES_HOME and /tmp/hermes-*. Never touches ~/.hermes/logs/ or
|
|
||||||
any system directory.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -19,7 +13,7 @@ import logging
|
|||||||
import shutil
|
import shutil
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Callable, Dict, Iterator, List, Optional, Tuple
|
from typing import Any, Dict, Iterator, List, Optional, Tuple
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from hermes_constants import get_hermes_home
|
from hermes_constants import get_hermes_home
|
||||||
@@ -36,19 +30,13 @@ logger = logging.getLogger(__name__)
|
|||||||
_LARGE_FILE_BYTES = 500 * 1024 * 1024
|
_LARGE_FILE_BYTES = 500 * 1024 * 1024
|
||||||
|
|
||||||
|
|
||||||
# --- Paths / safety ---------------------------------------------------------
|
|
||||||
|
|
||||||
def _state_file(name: str) -> Path:
|
def _state_file(name: str) -> Path:
|
||||||
"""``$HERMES_HOME/disk-cleanup/<name>`` — state and audit log deliberately
|
"""``$HERMES_HOME/disk-cleanup/<name>`` — deliberately outside ``$HERMES_HOME/logs/``."""
|
||||||
live outside ``$HERMES_HOME/logs/``."""
|
|
||||||
return get_hermes_home() / "disk-cleanup" / name
|
return get_hermes_home() / "disk-cleanup" / name
|
||||||
|
|
||||||
|
|
||||||
def is_safe_path(path: Path) -> bool:
|
def is_safe_path(path: Path) -> bool:
|
||||||
"""Accept only paths under HERMES_HOME or ``/tmp/hermes-*``.
|
"""Accept only paths under HERMES_HOME or ``/tmp/hermes-*`` (rejects /mnt/c etc.)."""
|
||||||
|
|
||||||
Rejects Windows mounts (``/mnt/c`` etc.) and any system directory.
|
|
||||||
"""
|
|
||||||
try:
|
try:
|
||||||
path.resolve().relative_to(get_hermes_home())
|
path.resolve().relative_to(get_hermes_home())
|
||||||
return True
|
return True
|
||||||
@@ -70,8 +58,6 @@ def _log(message: str) -> None:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
# --- tracked.json — atomic read/write, backup scoped to tracked.json only ----
|
|
||||||
|
|
||||||
def load_tracked() -> List[Dict[str, Any]]:
|
def load_tracked() -> List[Dict[str, Any]]:
|
||||||
"""Load tracked.json. Restores from ``.bak`` on corruption."""
|
"""Load tracked.json. Restores from ``.bak`` on corruption."""
|
||||||
tf = _state_file("tracked.json")
|
tf = _state_file("tracked.json")
|
||||||
@@ -81,16 +67,17 @@ def load_tracked() -> List[Dict[str, Any]]:
|
|||||||
try:
|
try:
|
||||||
return json.loads(tf.read_text(encoding="utf-8"))
|
return json.loads(tf.read_text(encoding="utf-8"))
|
||||||
except (json.JSONDecodeError, ValueError):
|
except (json.JSONDecodeError, ValueError):
|
||||||
bak = tf.with_suffix(".json.bak")
|
pass
|
||||||
if bak.exists():
|
bak = tf.with_suffix(".json.bak")
|
||||||
try:
|
if bak.exists():
|
||||||
data = json.loads(bak.read_text(encoding="utf-8"))
|
try:
|
||||||
_log("WARN: tracked.json corrupted — restored from .bak")
|
data = json.loads(bak.read_text(encoding="utf-8"))
|
||||||
return data
|
_log("WARN: tracked.json corrupted — restored from .bak")
|
||||||
except Exception:
|
return data
|
||||||
pass
|
except Exception:
|
||||||
_log("WARN: tracked.json corrupted, no backup — starting fresh")
|
pass
|
||||||
return []
|
_log("WARN: tracked.json corrupted, no backup — starting fresh")
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
def save_tracked(tracked: List[Dict[str, Any]]) -> None:
|
def save_tracked(tracked: List[Dict[str, Any]]) -> None:
|
||||||
@@ -104,13 +91,10 @@ def save_tracked(tracked: List[Dict[str, Any]]) -> None:
|
|||||||
tmp.replace(tf)
|
tmp.replace(tf)
|
||||||
|
|
||||||
|
|
||||||
# --- Categories / protected trees -------------------------------------------
|
|
||||||
|
|
||||||
ALLOWED_CATEGORIES = {
|
ALLOWED_CATEGORIES = {
|
||||||
"temp", "test", "research", "download", "chrome-profile", "cron-output", "other"}
|
"temp", "test", "research", "download", "chrome-profile", "cron-output", "other"}
|
||||||
|
|
||||||
# Top-level HERMES_HOME dirs whose empty subdirs are never swept. The last row
|
# Top-level HERMES_HOME dirs whose empty subdirs are never swept (last row: user project trees).
|
||||||
# is user-authored project trees (patches/, projects/, ...) — never sweep inside.
|
|
||||||
_EMPTY_DIR_PROTECTED_TOP_LEVEL = frozenset({
|
_EMPTY_DIR_PROTECTED_TOP_LEVEL = frozenset({
|
||||||
"logs", "memories", "sessions", "cron", "cronjobs",
|
"logs", "memories", "sessions", "cron", "cronjobs",
|
||||||
"cache", "skills", "plugins", "disk-cleanup", "optional-skills",
|
"cache", "skills", "plugins", "disk-cleanup", "optional-skills",
|
||||||
@@ -120,9 +104,8 @@ _EMPTY_DIR_PROTECTED_TOP_LEVEL = frozenset({
|
|||||||
_EMPTY_DIR_SWEEP_PRUNE_DIRS = frozenset({
|
_EMPTY_DIR_SWEEP_PRUNE_DIRS = frozenset({
|
||||||
".git", "node_modules", "venv", ".venv", "site-packages", "__pycache__"})
|
".git", "node_modules", "venv", ".venv", "site-packages", "__pycache__"})
|
||||||
|
|
||||||
# Top-level entries under HERMES_HOME that guess_category() never auto-tracks:
|
# Top-level HERMES_HOME entries guess_category() never auto-tracks: state, logs, memory,
|
||||||
# state dir, logs, memory, sessions, config/secrets, and user-authored project
|
# sessions, config/secrets, and user project trees (test_* inside projects/ is not disposable).
|
||||||
# trees (a file named test_*/tmp_* inside patches/ or projects/ is not disposable).
|
|
||||||
_NEVER_TRACK_TOP_LEVEL = frozenset({
|
_NEVER_TRACK_TOP_LEVEL = frozenset({
|
||||||
"disk-cleanup", "logs", "memories", "sessions", "config.yaml",
|
"disk-cleanup", "logs", "memories", "sessions", "config.yaml",
|
||||||
"skills", "plugins", ".env", "USER.md", "MEMORY.md", "SOUL.md",
|
"skills", "plugins", ".env", "USER.md", "MEMORY.md", "SOUL.md",
|
||||||
@@ -130,20 +113,15 @@ _NEVER_TRACK_TOP_LEVEL = frozenset({
|
|||||||
"patches", "projects", "skins", "themes", "contributors",
|
"patches", "projects", "skins", "themes", "contributors",
|
||||||
"profiles", "backups", "optional-skills"})
|
"profiles", "backups", "optional-skills"})
|
||||||
|
|
||||||
# Defense-in-depth for quick(): exact cron control-plane paths never deleted,
|
# Defense-in-depth for quick(): exact cron control-plane paths never deleted regardless of
|
||||||
# regardless of stored category (guards stale tracked.json entries).
|
# stored category (guards stale tracked.json entries).
|
||||||
_PROTECTED_CRON_PATHS: set[str] = set()
|
_PROTECTED_CRON_PATHS: set[str] = set()
|
||||||
|
|
||||||
|
|
||||||
def _is_protected_cron_path(p: Path) -> bool:
|
def _is_protected_cron_path(p: Path) -> bool:
|
||||||
"""True if *p* is cron control-plane state that must never be deleted.
|
"""True if *p* is cron control-plane state (EXACT match: ``cron/``, ``jobs.json``,
|
||||||
|
``.tick.lock``, the ``output/`` root). Never widen to everything under ``cron/output/``:
|
||||||
Matches by EXACT path only: the ``cron/`` dir itself, ``jobs.json``,
|
run artifacts there are disposable; only wholesale deletion of ``output/`` is fatal."""
|
||||||
``.tick.lock``, and the ``output/`` root. It must NOT be widened to
|
|
||||||
everything under ``cron/output/`` — run artifacts there are disposable and
|
|
||||||
cleaned by retention; only the ``output/`` root is protected because
|
|
||||||
deleting it wholesale erases every job's retained run history.
|
|
||||||
"""
|
|
||||||
if not _PROTECTED_CRON_PATHS: # built lazily so HERMES_HOME resolves once
|
if not _PROTECTED_CRON_PATHS: # built lazily so HERMES_HOME resolves once
|
||||||
hermes_home = get_hermes_home()
|
hermes_home = get_hermes_home()
|
||||||
for parent in ("cron", "cronjobs"):
|
for parent in ("cron", "cronjobs"):
|
||||||
@@ -161,8 +139,6 @@ def fmt_size(n: float) -> str:
|
|||||||
return f"{n:.1f} PB"
|
return f"{n:.1f} PB"
|
||||||
|
|
||||||
|
|
||||||
# --- Track / forget ---------------------------------------------------------
|
|
||||||
|
|
||||||
def track(path_str: str, category: str, silent: bool = False) -> bool:
|
def track(path_str: str, category: str, silent: bool = False) -> bool:
|
||||||
"""Register a file for tracking. Returns True if newly tracked."""
|
"""Register a file for tracking. Returns True if newly tracked."""
|
||||||
if category not in ALLOWED_CATEGORIES:
|
if category not in ALLOWED_CATEGORIES:
|
||||||
@@ -182,11 +158,8 @@ def track(path_str: str, category: str, silent: bool = False) -> bool:
|
|||||||
if any(item["path"] == str(path) for item in tracked):
|
if any(item["path"] == str(path) for item in tracked):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
tracked.append({
|
tracked.append({"path": str(path), "timestamp": datetime.now(timezone.utc).isoformat(),
|
||||||
"path": str(path),
|
"category": category, "size": size})
|
||||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
||||||
"category": category,
|
|
||||||
"size": size})
|
|
||||||
save_tracked(tracked)
|
save_tracked(tracked)
|
||||||
_log(f"TRACKED: {path} ({category}, {fmt_size(size)})")
|
_log(f"TRACKED: {path} ({category}, {fmt_size(size)})")
|
||||||
if not silent:
|
if not silent:
|
||||||
@@ -198,26 +171,22 @@ def forget(path_str: str) -> int:
|
|||||||
"""Remove a path from tracking without deleting the file."""
|
"""Remove a path from tracking without deleting the file."""
|
||||||
p = Path(path_str).resolve()
|
p = Path(path_str).resolve()
|
||||||
tracked = load_tracked()
|
tracked = load_tracked()
|
||||||
before = len(tracked)
|
kept = [i for i in tracked if Path(i["path"]).resolve() != p]
|
||||||
tracked = [i for i in tracked if Path(i["path"]).resolve() != p]
|
removed = len(tracked) - len(kept)
|
||||||
removed = before - len(tracked)
|
|
||||||
if removed:
|
if removed:
|
||||||
save_tracked(tracked)
|
save_tracked(kept)
|
||||||
_log(f"FORGOT: {p} ({removed} entries)")
|
_log(f"FORGOT: {p} ({removed} entries)")
|
||||||
return removed
|
return removed
|
||||||
|
|
||||||
|
|
||||||
# --- Rules shared by dry_run / quick / deep ---------------------------------
|
|
||||||
|
|
||||||
def _live_items(tracked: List[Dict], now: datetime, *, log_stale: bool = False) -> Iterator[Tuple[Dict, Path, int]]:
|
def _live_items(tracked: List[Dict], now: datetime, *, log_stale: bool = False) -> Iterator[Tuple[Dict, Path, int]]:
|
||||||
"""Yield ``(item, path, age_days)`` for entries whose path still exists."""
|
"""Yield ``(item, path, age_days)`` for entries whose path still exists."""
|
||||||
for item in tracked:
|
for item in tracked:
|
||||||
p = Path(item["path"])
|
p = Path(item["path"])
|
||||||
if not p.exists():
|
if p.exists():
|
||||||
if log_stale:
|
yield item, p, (now - datetime.fromisoformat(item["timestamp"])).days
|
||||||
_log(f"STALE: {p} (removed from tracking)")
|
elif log_stale:
|
||||||
continue
|
_log(f"STALE: {p} (removed from tracking)")
|
||||||
yield item, p, (now - datetime.fromisoformat(item["timestamp"])).days
|
|
||||||
|
|
||||||
|
|
||||||
def _is_auto_delete(cat: str, age: int) -> bool:
|
def _is_auto_delete(cat: str, age: int) -> bool:
|
||||||
@@ -225,15 +194,13 @@ def _is_auto_delete(cat: str, age: int) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
def _prompt_group(item: Dict, age: int) -> Optional[str]:
|
def _prompt_group(item: Dict, age: int) -> Optional[str]:
|
||||||
"""Deep-only bucket: ``research`` / ``chrome`` / ``large`` or None."""
|
"""Prompt-only bucket: ``research`` / ``chrome`` / ``large`` or None."""
|
||||||
cat = item["category"]
|
cat = item["category"]
|
||||||
if cat == "research" and age > 30:
|
if cat == "research" and age > 30:
|
||||||
return "research"
|
return "research"
|
||||||
if cat == "chrome-profile" and age > 14:
|
if cat == "chrome-profile" and age > 14:
|
||||||
return "chrome"
|
return "chrome"
|
||||||
if item["size"] > _LARGE_FILE_BYTES:
|
return "large" if item["size"] > _LARGE_FILE_BYTES else None
|
||||||
return "large"
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _delete_item(item: Dict) -> Optional[str]:
|
def _delete_item(item: Dict) -> Optional[str]:
|
||||||
@@ -251,15 +218,11 @@ def _delete_item(item: Dict) -> Optional[str]:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
# Stored categories that are re-validated against guess_category() before use.
|
# Stored categories re-validated against guess_category() before use: old tracked.json entries
|
||||||
# Old tracked.json entries can carry "cron-output" for control-plane files
|
# may carry "cron-output" for control-plane files or "test" for files under protected trees.
|
||||||
# (cron/jobs.json) or "test" for files now under protected project trees;
|
|
||||||
# guess_category() was tightened later but existing entries were never re-checked.
|
|
||||||
_STALE_SKIP_NOTE = {"cron-output": "", "test": " — under protected tree"}
|
_STALE_SKIP_NOTE = {"cron-output": "", "test": " — under protected tree"}
|
||||||
|
|
||||||
|
|
||||||
# --- Dry run / quick / deep -------------------------------------------------
|
|
||||||
|
|
||||||
def dry_run() -> Tuple[List[Dict], List[Dict]]:
|
def dry_run() -> Tuple[List[Dict], List[Dict]]:
|
||||||
"""Return (auto_delete_list, needs_prompt_list) without touching files."""
|
"""Return (auto_delete_list, needs_prompt_list) without touching files."""
|
||||||
auto: List[Dict] = []
|
auto: List[Dict] = []
|
||||||
@@ -277,10 +240,7 @@ def dry_run() -> Tuple[List[Dict], List[Dict]]:
|
|||||||
|
|
||||||
|
|
||||||
def quick() -> Dict[str, Any]:
|
def quick() -> Dict[str, Any]:
|
||||||
"""Safe deterministic cleanup — no prompts.
|
"""Safe deterministic cleanup — no prompts. Returns ``{deleted, empty_dirs, freed, errors}``."""
|
||||||
|
|
||||||
Returns: ``{"deleted": N, "empty_dirs": N, "freed": bytes, "errors": [str, ...]}``.
|
|
||||||
"""
|
|
||||||
deleted = freed = 0
|
deleted = freed = 0
|
||||||
new_tracked: List[Dict] = []
|
new_tracked: List[Dict] = []
|
||||||
errors: List[str] = []
|
errors: List[str] = []
|
||||||
@@ -320,9 +280,8 @@ def _subdirs(dirpath: Path, exclude: frozenset) -> List[Path]:
|
|||||||
|
|
||||||
|
|
||||||
def _sweep_empty_dirs(hermes_home: Path) -> int:
|
def _sweep_empty_dirs(hermes_home: Path) -> int:
|
||||||
"""Remove empty dirs under HERMES_HOME, never recursing into durable state
|
"""Remove empty dirs under HERMES_HOME without recursing into durable/heavy trees (a full
|
||||||
trees. Some installs keep the Hermes checkout, venv, and desktop build under
|
rglob over a checkout+venv under HERMES_HOME can stall the gateway loop for minutes).
|
||||||
HERMES_HOME; a full rglob there can stall the gateway event loop for minutes.
|
|
||||||
Iterative post-order so parents emptied by child removal are caught."""
|
Iterative post-order so parents emptied by child removal are caught."""
|
||||||
removed = 0
|
removed = 0
|
||||||
stack: List[Tuple[Path, bool]] = [
|
stack: List[Tuple[Path, bool]] = [
|
||||||
@@ -344,36 +303,6 @@ def _sweep_empty_dirs(hermes_home: Path) -> int:
|
|||||||
return removed
|
return removed
|
||||||
|
|
||||||
|
|
||||||
def deep(confirm: Optional[Callable[[Dict], bool]] = None) -> Dict[str, Any]:
|
|
||||||
"""Deep cleanup: :func:`quick`, then ask *confirm(item)* for each risky item
|
|
||||||
(research > 30d beyond the 10 newest, chrome-profile > 14d, any file > 500 MB).
|
|
||||||
|
|
||||||
Returns: ``{"quick": {...}, "deep_deleted": N, "deep_freed": bytes}``.
|
|
||||||
"""
|
|
||||||
quick_result = quick()
|
|
||||||
if confirm is None: # no interactive confirmer — stop after the quick pass
|
|
||||||
return {"quick": quick_result, "deep_deleted": 0, "deep_freed": 0}
|
|
||||||
|
|
||||||
tracked = load_tracked()
|
|
||||||
groups: Dict[str, List[Dict]] = {"research": [], "chrome": [], "large": []}
|
|
||||||
for item, _p, age in _live_items(tracked, datetime.now(timezone.utc)):
|
|
||||||
group = _prompt_group(item, age)
|
|
||||||
if group:
|
|
||||||
groups[group].append(item)
|
|
||||||
|
|
||||||
groups["research"].sort(key=lambda x: x["timestamp"], reverse=True)
|
|
||||||
del groups["research"][:10] # keep the 10 newest research items
|
|
||||||
|
|
||||||
removed = [item for group in groups.values() for item in group if confirm(item) and _delete_item(item) is None]
|
|
||||||
if removed:
|
|
||||||
remove_paths = {i["path"] for i in removed}
|
|
||||||
save_tracked([i for i in tracked if i["path"] not in remove_paths])
|
|
||||||
|
|
||||||
return {"quick": quick_result, "deep_deleted": len(removed), "deep_freed": sum(i["size"] for i in removed)}
|
|
||||||
|
|
||||||
|
|
||||||
# --- Status -----------------------------------------------------------------
|
|
||||||
|
|
||||||
def status() -> Dict[str, Any]:
|
def status() -> Dict[str, Any]:
|
||||||
"""Return per-category breakdown and top 10 largest tracked files."""
|
"""Return per-category breakdown and top 10 largest tracked files."""
|
||||||
tracked = load_tracked()
|
tracked = load_tracked()
|
||||||
@@ -383,8 +312,8 @@ def status() -> Dict[str, Any]:
|
|||||||
c["count"] += 1
|
c["count"] += 1
|
||||||
c["size"] += item["size"]
|
c["size"] += item["size"]
|
||||||
|
|
||||||
existing = [(i["path"], i["size"], i["category"]) for i in tracked if Path(i["path"]).exists()]
|
existing = sorted(((i["path"], i["size"], i["category"]) for i in tracked
|
||||||
existing.sort(key=lambda x: x[1], reverse=True)
|
if Path(i["path"]).exists()), key=lambda x: x[1], reverse=True)
|
||||||
return {"categories": cats, "top10": existing[:10], "total_tracked": len(tracked)}
|
return {"categories": cats, "top10": existing[:10], "total_tracked": len(tracked)}
|
||||||
|
|
||||||
|
|
||||||
@@ -405,17 +334,12 @@ def format_status(s: Dict[str, Any]) -> str:
|
|||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
# --- Auto-categorisation from tool-call inspection --------------------------
|
|
||||||
|
|
||||||
_TEST_PATTERNS = ("test_", "tmp_")
|
_TEST_PATTERNS = ("test_", "tmp_")
|
||||||
_TEST_SUFFIXES = (".test.py", ".test.js", ".test.ts", ".test.md")
|
_TEST_SUFFIXES = (".test.py", ".test.js", ".test.ts", ".test.md")
|
||||||
|
|
||||||
|
|
||||||
def guess_category(path: Path) -> Optional[str]:
|
def guess_category(path: Path) -> Optional[str]:
|
||||||
"""Return a category label for *path*, or None if we shouldn't track it.
|
"""Category label for *path*, or None if we shouldn't track it (``post_tool_call`` hook)."""
|
||||||
|
|
||||||
Used by the ``post_tool_call`` hook to auto-track ephemeral files.
|
|
||||||
"""
|
|
||||||
if not is_safe_path(path):
|
if not is_safe_path(path):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -425,18 +349,13 @@ def guess_category(path: Path) -> Optional[str]:
|
|||||||
if top in _NEVER_TRACK_TOP_LEVEL:
|
if top in _NEVER_TRACK_TOP_LEVEL:
|
||||||
return None
|
return None
|
||||||
if top in ("cron", "cronjobs"):
|
if top in ("cron", "cronjobs"):
|
||||||
# Only the disposable ``output/`` subtree is a candidate. Top-level
|
# Only the disposable ``output/`` subtree; control-plane state (jobs.json,
|
||||||
# control-plane state (jobs.json, .tick.lock) must never be tracked —
|
# .tick.lock) must never be tracked — deleting it wipes the scheduler registry.
|
||||||
# deleting it wipes the live scheduler registry.
|
return "cron-output" if len(rel.parts) >= 3 and rel.parts[1] == "output" else None
|
||||||
if len(rel.parts) >= 3 and rel.parts[1] == "output":
|
|
||||||
return "cron-output"
|
|
||||||
return None
|
|
||||||
if top == "cache":
|
if top == "cache":
|
||||||
return "temp"
|
return "temp"
|
||||||
except ValueError:
|
except ValueError:
|
||||||
pass # not under HERMES_HOME (e.g. /tmp/hermes-*) — fall through to name rules
|
pass # not under HERMES_HOME (e.g. /tmp/hermes-*) — fall through to name rules
|
||||||
|
|
||||||
name = path.name
|
name = path.name
|
||||||
if name.startswith(_TEST_PATTERNS) or name.endswith(_TEST_SUFFIXES):
|
return "test" if name.startswith(_TEST_PATTERNS) or name.endswith(_TEST_SUFFIXES) else None
|
||||||
return "test"
|
|
||||||
return None
|
|
||||||
|
|||||||
@@ -1,12 +1,9 @@
|
|||||||
"""google_meet plugin — let the agent join a Meet call, transcribe it, follow up.
|
"""google_meet plugin — let the agent join a Meet call, transcribe it, follow up.
|
||||||
|
|
||||||
Spawns a headless Chromium via Playwright, joins the Meet URL, enables live
|
Headless Chromium (Playwright) joins the URL, enables live captions and scrapes them into a
|
||||||
captions and scrapes them into a transcript file in the agent's workspace.
|
transcript. Realtime mode adds agent speech (OpenAI Realtime + virtual audio device); remote
|
||||||
Realtime mode additionally lets the agent speak (OpenAI Realtime + a virtual
|
nodes run the bot on another machine. Explicit-by-design: only joins ``https://meet.google.com/``
|
||||||
audio device); remote nodes let the bot run on another machine.
|
URLs passed in — no calendar scanning, auto-dial or consent announcement.
|
||||||
|
|
||||||
Explicit-by-design: only joins ``https://meet.google.com/`` URLs explicitly
|
|
||||||
passed in. No calendar scanning, no auto-dial, no consent announcement.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -34,10 +31,7 @@ _TOOLS = (
|
|||||||
|
|
||||||
|
|
||||||
def _on_session_end(**kwargs) -> None:
|
def _on_session_end(**kwargs) -> None:
|
||||||
"""Leave a still-running call so we don't orphan a headless Chromium.
|
"""Leave a still-running call so we don't orphan a headless Chromium (never raises)."""
|
||||||
|
|
||||||
Swallows all exceptions — session end must never fail on bot cleanup.
|
|
||||||
"""
|
|
||||||
try:
|
try:
|
||||||
status = pm.status()
|
status = pm.status()
|
||||||
if status.get("ok") and status.get("alive"):
|
if status.get("ok") and status.get("alive"):
|
||||||
@@ -48,8 +42,7 @@ def _on_session_end(**kwargs) -> None:
|
|||||||
|
|
||||||
def register(ctx) -> None:
|
def register(ctx) -> None:
|
||||||
"""Register tools, CLI, and lifecycle hooks (called once by the plugin loader)."""
|
"""Register tools, CLI, and lifecycle hooks (called once by the plugin loader)."""
|
||||||
# Windows: no tested audio-routing path and flakier guest-join Chromium —
|
# Windows: no tested audio-routing path and flaky guest-join Chromium — refuse rather than half-work.
|
||||||
# refuse to register rather than half-work.
|
|
||||||
system = platform.system().lower()
|
system = platform.system().lower()
|
||||||
if system not in {"linux", "darwin"}:
|
if system not in {"linux", "darwin"}:
|
||||||
logger.info("google_meet plugin: platform=%s not supported (linux/macos only)", system)
|
logger.info("google_meet plugin: platform=%s not supported (linux/macos only)", system)
|
||||||
@@ -60,12 +53,9 @@ def register(ctx) -> None:
|
|||||||
check_fn=check_meet_requirements, emoji=emoji)
|
check_fn=check_meet_requirements, emoji=emoji)
|
||||||
|
|
||||||
ctx.register_cli_command(
|
ctx.register_cli_command(
|
||||||
name="meet",
|
name="meet", help="Google Meet bot (join, transcribe, follow up)",
|
||||||
help="Google Meet bot (join, transcribe, follow up)",
|
setup_fn=_register_meet_cli, handler_fn=_meet_command,
|
||||||
setup_fn=_register_meet_cli,
|
description=("Let the hermes agent join a Google Meet call and scrape live "
|
||||||
handler_fn=_meet_command,
|
"captions into a transcript. See: hermes meet setup"))
|
||||||
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)
|
ctx.register_hook("on_session_end", _on_session_end)
|
||||||
|
|||||||
@@ -18,11 +18,8 @@ def read_json(path: Path) -> Optional[Any]:
|
|||||||
|
|
||||||
|
|
||||||
def write_json_atomic(path: Path, data: Any, mode: Optional[int] = None) -> None:
|
def write_json_atomic(path: Path, data: Any, mode: Optional[int] = None) -> None:
|
||||||
"""Write ``json.dumps(data, indent=2)`` via a ``.json.tmp`` sibling + rename.
|
"""Write ``json.dumps(data, indent=2)`` via a ``.json.tmp`` sibling + rename; *mode* (e.g.
|
||||||
|
``0o600``) is applied to the temp file so the final file never exists with looser perms."""
|
||||||
*mode* (e.g. ``0o600``) is applied to the temp file before the rename so
|
|
||||||
the final file never exists with looser permissions.
|
|
||||||
"""
|
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
tmp = path.with_suffix(".json.tmp")
|
tmp = path.with_suffix(".json.tmp")
|
||||||
tmp.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
tmp.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
||||||
|
|||||||
@@ -1,14 +1,8 @@
|
|||||||
"""Virtual audio bridge for feeding generated speech into Chrome's mic.
|
"""Virtual audio bridge for feeding generated speech into Chrome's mic.
|
||||||
|
|
||||||
Provisions a platform-specific virtual audio device the Meet bot's Chromium
|
Linux: pactl creates a null-sink plus a virtual source on the sink's monitor; callers set
|
||||||
can use as its input; the OpenAI Realtime client writes PCM into it.
|
``PULSE_SOURCE=<source_name>`` in Chrome's env. macOS: only verifies BlackHole 2ch is installed
|
||||||
|
(the default-input switch is left to the user). Windows: unsupported.
|
||||||
Linux: pactl creates a null-sink plus a virtual source whose master is the
|
|
||||||
sink's monitor. Callers set ``PULSE_SOURCE=<source_name>`` in Chrome's env.
|
|
||||||
|
|
||||||
macOS: only verifies BlackHole 2ch is installed and returns its name; the
|
|
||||||
default-input switch is left to the user so we never surprise their system
|
|
||||||
audio state. Windows: unsupported.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -22,20 +16,12 @@ _BLACKHOLE_DEVICE = "BlackHole 2ch"
|
|||||||
|
|
||||||
|
|
||||||
def _pactl(*args: str, check: bool) -> subprocess.CompletedProcess:
|
def _pactl(*args: str, check: bool) -> subprocess.CompletedProcess:
|
||||||
return subprocess.run(
|
return subprocess.run(["pactl", *args], check=check, capture_output=True, text=True,
|
||||||
["pactl", *args],
|
encoding='utf-8', errors='replace', stdin=subprocess.DEVNULL)
|
||||||
check=check,
|
|
||||||
capture_output=True,
|
|
||||||
text=True, encoding='utf-8', errors='replace',
|
|
||||||
stdin=subprocess.DEVNULL)
|
|
||||||
|
|
||||||
|
|
||||||
class AudioBridge:
|
class AudioBridge:
|
||||||
"""Manages a virtual audio device for Chrome fake-mic input.
|
"""Virtual audio device for Chrome fake-mic input: ``setup()`` before launch, ``teardown()`` after."""
|
||||||
|
|
||||||
Call ``setup()`` once before launching the Meet bot and ``teardown()``
|
|
||||||
when the session ends (idempotent).
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, name_prefix: str = "hermes_meet") -> None:
|
def __init__(self, name_prefix: str = "hermes_meet") -> None:
|
||||||
self._name_prefix = name_prefix
|
self._name_prefix = name_prefix
|
||||||
@@ -69,8 +55,7 @@ class AudioBridge:
|
|||||||
if self._torn_down:
|
if self._torn_down:
|
||||||
return
|
return
|
||||||
if self._platform == "linux":
|
if self._platform == "linux":
|
||||||
# Unload in reverse order (virtual-source before null-sink).
|
for mod_id in reversed(self._module_ids): # virtual-source before null-sink
|
||||||
for mod_id in reversed(self._module_ids):
|
|
||||||
try:
|
try:
|
||||||
_pactl("unload-module", str(mod_id), check=False)
|
_pactl("unload-module", str(mod_id), check=False)
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -114,19 +99,16 @@ class AudioBridge:
|
|||||||
|
|
||||||
def _setup_darwin(self) -> dict:
|
def _setup_darwin(self) -> dict:
|
||||||
try:
|
try:
|
||||||
out = subprocess.check_output(
|
out = subprocess.check_output(["system_profiler", "SPAudioDataType"], text=True,
|
||||||
["system_profiler", "SPAudioDataType"],
|
encoding='utf-8', errors='replace', stderr=subprocess.STDOUT)
|
||||||
text=True, encoding='utf-8', errors='replace',
|
|
||||||
stderr=subprocess.STDOUT)
|
|
||||||
except FileNotFoundError as exc:
|
except FileNotFoundError as exc:
|
||||||
raise RuntimeError("system_profiler not found (macOS-only command)") from exc
|
raise RuntimeError("system_profiler not found (macOS-only command)") from exc
|
||||||
except subprocess.CalledProcessError as exc:
|
except subprocess.CalledProcessError as exc:
|
||||||
raise RuntimeError(f"system_profiler failed: {exc.output}") from exc
|
raise RuntimeError(f"system_profiler failed: {exc.output}") from exc
|
||||||
|
|
||||||
if "BlackHole" not in out:
|
if "BlackHole" not in out:
|
||||||
raise RuntimeError(
|
raise RuntimeError("BlackHole virtual audio device not installed. "
|
||||||
"BlackHole virtual audio device not installed. "
|
"Install via: brew install blackhole-2ch")
|
||||||
"Install via: brew install blackhole-2ch")
|
|
||||||
return self._finish("darwin", _BLACKHOLE_DEVICE, _BLACKHOLE_DEVICE, [])
|
return self._finish("darwin", _BLACKHOLE_DEVICE, _BLACKHOLE_DEVICE, [])
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|||||||
+33
-65
@@ -22,6 +22,7 @@ from hermes_constants import get_hermes_home
|
|||||||
|
|
||||||
from plugins.google_meet import process_manager as pm
|
from plugins.google_meet import process_manager as pm
|
||||||
from plugins.google_meet.meet_bot import _is_safe_meet_url
|
from plugins.google_meet.meet_bot import _is_safe_meet_url
|
||||||
|
from plugins.google_meet.node.cli import register_cli as _register_node_cli
|
||||||
from plugins.google_meet.tools import resolve_node
|
from plugins.google_meet.tools import resolve_node
|
||||||
|
|
||||||
|
|
||||||
@@ -37,14 +38,10 @@ def register_cli(subparser: argparse.ArgumentParser) -> None:
|
|||||||
|
|
||||||
inst_p = subs.add_parser(
|
inst_p = subs.add_parser(
|
||||||
"install", help="Install prerequisites (pip deps, Chromium, platform audio tools)")
|
"install", help="Install prerequisites (pip deps, Chromium, platform audio tools)")
|
||||||
inst_p.add_argument(
|
inst_p.add_argument("--realtime", action="store_true",
|
||||||
"--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.")
|
||||||
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).")
|
||||||
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")
|
subs.add_parser("auth", help="Sign in to Google and save session state")
|
||||||
|
|
||||||
@@ -53,11 +50,10 @@ def register_cli(subparser: argparse.ArgumentParser) -> None:
|
|||||||
join_p.add_argument("--guest-name", default="Hermes Agent")
|
join_p.add_argument("--guest-name", default="Hermes Agent")
|
||||||
join_p.add_argument("--duration", default=None, help="e.g. 30m, 2h, 90s")
|
join_p.add_argument("--duration", default=None, help="e.g. 30m, 2h, 90s")
|
||||||
join_p.add_argument("--headed", action="store_true", help="show browser")
|
join_p.add_argument("--headed", action="store_true", help="show browser")
|
||||||
join_p.add_argument(
|
join_p.add_argument("--mode", choices=("transcribe", "realtime"), default="transcribe",
|
||||||
"--mode", choices=("transcribe", "realtime"), default="transcribe",
|
help="transcribe (default, listen-only) or realtime (speak via OpenAI Realtime)")
|
||||||
help="transcribe (default, listen-only) or realtime (speak via OpenAI Realtime)")
|
join_p.add_argument("--node", default=None,
|
||||||
join_p.add_argument(
|
help="remote node name, or 'auto' to use the sole registered node")
|
||||||
"--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")
|
subs.add_parser("status", help="Print current Meet bot state")
|
||||||
|
|
||||||
@@ -70,20 +66,8 @@ def register_cli(subparser: argparse.ArgumentParser) -> None:
|
|||||||
|
|
||||||
subs.add_parser("stop", help="Leave the current meeting")
|
subs.add_parser("stop", help="Leave the current meeting")
|
||||||
|
|
||||||
node_p = subs.add_parser(
|
_register_node_cli(subs.add_parser(
|
||||||
"node", help="Manage remote meet node hosts (run/list/approve/remove/status/ping)")
|
"node", help="Manage remote meet node hosts (run/list/approve/remove/status/ping)"))
|
||||||
try:
|
|
||||||
from plugins.google_meet.node.cli import register_cli as _register_node_cli
|
|
||||||
_register_node_cli(node_p)
|
|
||||||
except Exception as e: # pragma: no cover — defensive
|
|
||||||
# Keep the subparser present so argparse dispatch surfaces a clear error.
|
|
||||||
err = e
|
|
||||||
|
|
||||||
def _node_unavailable(args):
|
|
||||||
print(f"hermes meet node: module unavailable ({err})")
|
|
||||||
return 1
|
|
||||||
node_p.set_defaults(func=_node_unavailable)
|
|
||||||
|
|
||||||
subparser.set_defaults(func=meet_command)
|
subparser.set_defaults(func=meet_command)
|
||||||
|
|
||||||
|
|
||||||
@@ -97,12 +81,11 @@ def _cmd_node(args: argparse.Namespace) -> int:
|
|||||||
|
|
||||||
_DISPATCH = {
|
_DISPATCH = {
|
||||||
"setup": lambda a: _cmd_setup(),
|
"setup": lambda a: _cmd_setup(),
|
||||||
"install": lambda a: _cmd_install(
|
"install": lambda a: _cmd_install(realtime=bool(getattr(a, "realtime", False)),
|
||||||
realtime=bool(getattr(a, "realtime", False)), assume_yes=bool(getattr(a, "yes", False))),
|
assume_yes=bool(getattr(a, "yes", False))),
|
||||||
"auth": lambda a: _cmd_auth(),
|
"auth": lambda a: _cmd_auth(),
|
||||||
"join": lambda a: _cmd_join(
|
"join": lambda a: _cmd_join(url=a.url, guest_name=a.guest_name, duration=a.duration, headed=a.headed,
|
||||||
url=a.url, guest_name=a.guest_name, duration=a.duration, headed=a.headed,
|
mode=getattr(a, "mode", "transcribe"), node=getattr(a, "node", None)),
|
||||||
mode=getattr(a, "mode", "transcribe"), node=getattr(a, "node", None)),
|
|
||||||
"status": lambda a: _print_result(pm.status()),
|
"status": lambda a: _print_result(pm.status()),
|
||||||
"transcript": lambda a: _cmd_transcript(last=a.last),
|
"transcript": lambda a: _cmd_transcript(last=a.last),
|
||||||
"say": lambda a: _cmd_say(text=a.text, node=getattr(a, "node", None)),
|
"say": lambda a: _cmd_say(text=a.text, node=getattr(a, "node", None)),
|
||||||
@@ -155,24 +138,19 @@ def _cmd_setup() -> int:
|
|||||||
print(f" chromium : {chromium_msg}")
|
print(f" chromium : {chromium_msg}")
|
||||||
|
|
||||||
auth_path = _auth_state_path()
|
auth_path = _auth_state_path()
|
||||||
print(
|
print(" google auth : "
|
||||||
" google auth : "
|
+ (f"ok ({auth_path})" if auth_path.is_file() else "not saved — run: hermes meet auth"))
|
||||||
+ (f"ok ({auth_path})" if auth_path.is_file() else "not saved — run: hermes meet auth"))
|
|
||||||
|
|
||||||
print()
|
print()
|
||||||
all_ok = system_ok and pw_ok and chromium_ok
|
all_ok = system_ok and pw_ok and chromium_ok
|
||||||
if all_ok:
|
print("ready. Join a meeting: hermes meet join https://meet.google.com/abc-defg-hij" if all_ok
|
||||||
print("ready. Join a meeting: hermes meet join https://meet.google.com/abc-defg-hij")
|
else "not ready yet — fix the items above.")
|
||||||
else:
|
|
||||||
print("not ready yet — fix the items above.")
|
|
||||||
return 0 if all_ok else 1
|
return 0 if all_ok else 1
|
||||||
|
|
||||||
|
|
||||||
def _cmd_install(*, realtime: bool, assume_yes: bool) -> int:
|
def _cmd_install(*, realtime: bool, assume_yes: bool) -> int:
|
||||||
"""pip deps + Chromium; with ``--realtime`` also the platform audio bridge deps.
|
"""pip deps + Chromium; ``--realtime`` adds the platform audio bridge deps.
|
||||||
|
Prompts before every package-manager invocation unless ``--yes``. Linux/macOS only."""
|
||||||
Prompts before every package-manager invocation unless ``--yes``. Linux/macOS only.
|
|
||||||
"""
|
|
||||||
system = platform.system()
|
system = platform.system()
|
||||||
if system not in {"Linux", "Darwin"}:
|
if system not in {"Linux", "Darwin"}:
|
||||||
print(f"google_meet install: {system} is not supported (linux/macos only)")
|
print(f"google_meet install: {system} is not supported (linux/macos only)")
|
||||||
@@ -242,17 +220,15 @@ def _cmd_install(*, realtime: bool, assume_yes: bool) -> int:
|
|||||||
if not needs:
|
if not needs:
|
||||||
print(" BlackHole and ffmpeg already installed.")
|
print(" BlackHole and ffmpeg already installed.")
|
||||||
elif not shutil.which("brew"):
|
elif not shutil.which("brew"):
|
||||||
print(
|
print(" missing: " + ", ".join(needs) + "\n"
|
||||||
" missing: " + ", ".join(needs) + "\n"
|
" install Homebrew first (https://brew.sh) or install the packages manually.")
|
||||||
" install Homebrew first (https://brew.sh) or install the packages manually.")
|
|
||||||
else:
|
else:
|
||||||
_install_pkgs(f" install via brew: {' '.join(needs)}?", ["brew", "install", *needs],
|
_install_pkgs(f" install via brew: {' '.join(needs)}?", ["brew", "install", *needs],
|
||||||
" brew install failed — install them manually")
|
" brew install failed — install them manually")
|
||||||
print(
|
print("\n NOTE: macOS does not auto-route audio. Open\n"
|
||||||
"\n NOTE: macOS does not auto-route audio. Open\n"
|
" System Settings → Sound → Input\n"
|
||||||
" System Settings → Sound → Input\n"
|
" and select 'BlackHole 2ch' before starting a realtime meeting.\n"
|
||||||
" and select 'BlackHole 2ch' before starting a realtime meeting.\n"
|
" hermes will not switch your default input for you.")
|
||||||
" hermes will not switch your default input for you.")
|
|
||||||
|
|
||||||
print("\ndone. verify with: hermes meet setup")
|
print("\ndone. verify with: hermes meet setup")
|
||||||
return 0
|
return 0
|
||||||
@@ -263,9 +239,8 @@ def _cmd_auth() -> int:
|
|||||||
try:
|
try:
|
||||||
from playwright.sync_api import sync_playwright
|
from playwright.sync_api import sync_playwright
|
||||||
except ImportError:
|
except ImportError:
|
||||||
print(
|
print("playwright is not installed. run:\n"
|
||||||
"playwright is not installed. run:\n"
|
" pip install playwright && python -m playwright install chromium")
|
||||||
" pip install playwright && python -m playwright install chromium")
|
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
path = _auth_state_path()
|
path = _auth_state_path()
|
||||||
@@ -315,14 +290,8 @@ def _remote(node: str, op: str, call) -> int:
|
|||||||
return _print_result({"node": name, **res})
|
return _print_result({"node": name, **res})
|
||||||
|
|
||||||
|
|
||||||
def _cmd_join(
|
def _cmd_join(url: str, *, guest_name: str, duration: Optional[str], headed: bool,
|
||||||
url: str,
|
mode: str = "transcribe", node: Optional[str] = None) -> int:
|
||||||
*,
|
|
||||||
guest_name: str,
|
|
||||||
duration: Optional[str],
|
|
||||||
headed: bool,
|
|
||||||
mode: str = "transcribe",
|
|
||||||
node: Optional[str] = None) -> int:
|
|
||||||
if not _is_safe_meet_url(url):
|
if not _is_safe_meet_url(url):
|
||||||
print(f"refusing: not a meet.google.com URL: {url}")
|
print(f"refusing: not a meet.google.com URL: {url}")
|
||||||
return 2
|
return 2
|
||||||
@@ -330,9 +299,8 @@ def _cmd_join(
|
|||||||
return _remote(node, "start_bot", lambda c: c.start_bot(
|
return _remote(node, "start_bot", lambda c: c.start_bot(
|
||||||
url=url, guest_name=guest_name, duration=duration, headed=headed, mode=mode))
|
url=url, guest_name=guest_name, duration=duration, headed=headed, mode=mode))
|
||||||
auth = _auth_state_path()
|
auth = _auth_state_path()
|
||||||
return _print_result(pm.start(
|
return _print_result(pm.start(url=url, headed=headed, guest_name=guest_name, duration=duration,
|
||||||
url=url, headed=headed, guest_name=guest_name, duration=duration,
|
auth_state=str(auth) if auth.is_file() else None, mode=mode))
|
||||||
auth_state=str(auth) if auth.is_file() else None, mode=mode))
|
|
||||||
|
|
||||||
|
|
||||||
def _cmd_say(text: str, node: Optional[str] = None) -> int:
|
def _cmd_say(text: str, node: Optional[str] = None) -> int:
|
||||||
|
|||||||
+59
-104
@@ -1,16 +1,11 @@
|
|||||||
"""Headless Google Meet bot — Playwright + live-caption scraping.
|
"""Headless Google Meet bot — Playwright + live-caption scraping.
|
||||||
|
|
||||||
Standalone subprocess spawned by ``process_manager.py``. Config comes from env
|
Standalone subprocess spawned by ``process_manager.py``; configured via ``HERMES_MEET_*`` env,
|
||||||
vars; status + transcript are written under ``$HERMES_MEET_OUT_DIR`` and read
|
status + transcript written under ``$HERMES_MEET_OUT_DIR`` (filesystem is the only IPC).
|
||||||
by the ``meet_*`` tools — no IPC beyond the filesystem.
|
No WebRTC audio parsing: Meet's live captions are watched via a MutationObserver — lossy and
|
||||||
|
English-biased, but deterministic (no STT billing) and stable thanks to the ARIA role.
|
||||||
We don't parse WebRTC audio: we enable Meet's built-in live captions and watch
|
Debug: ``HERMES_MEET_URL=... HERMES_MEET_OUT_DIR=/tmp/x HERMES_MEET_HEADED=1 \\
|
||||||
the caption container via a MutationObserver. Lossy and English-biased, but
|
python -m plugins.google_meet.meet_bot``
|
||||||
deterministic (no STT billing) and stable across Meet UI rewrites thanks to
|
|
||||||
the container's ARIA role. Only ``https://meet.google.com/`` URLs are accepted.
|
|
||||||
|
|
||||||
Debug run: ``HERMES_MEET_URL=... HERMES_MEET_OUT_DIR=/tmp/meet-debug
|
|
||||||
HERMES_MEET_HEADED=1 python -m plugins.google_meet.meet_bot``
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -31,15 +26,8 @@ from plugins.google_meet._jsonfile import write_json_atomic
|
|||||||
|
|
||||||
# Short three-segment code, a lookup URL, or /new. Anything else is rejected.
|
# Short three-segment code, a lookup URL, or /new. Anything else is rejected.
|
||||||
MEET_URL_RE = re.compile(
|
MEET_URL_RE = re.compile(
|
||||||
r"^https://meet\.google\.com/("
|
r"^https://meet\.google\.com/([a-z0-9]{3,}-[a-z0-9]{3,}-[a-z0-9]{3,}|lookup/[^/?#]+|new)"
|
||||||
r"[a-z0-9]{3,}-[a-z0-9]{3,}-[a-z0-9]{3,}"
|
r"(?:[/?#].*)?$")
|
||||||
r"|lookup/[^/?#]+"
|
|
||||||
r"|new"
|
|
||||||
r")(?:[/?#].*)?$")
|
|
||||||
|
|
||||||
# Filenames the bot reads/writes in ``HERMES_MEET_OUT_DIR``.
|
|
||||||
SAY_QUEUE_FILENAME = "say_queue.jsonl"
|
|
||||||
SAY_PCM_FILENAME = "speaker.pcm"
|
|
||||||
|
|
||||||
_FFMPEG_MISSING = "ffmpeg not found — install via `brew install ffmpeg` for realtime on macOS"
|
_FFMPEG_MISSING = "ffmpeg not found — install via `brew install ffmpeg` for realtime on macOS"
|
||||||
|
|
||||||
@@ -65,15 +53,13 @@ def _quiet(fn, *args, **kwargs):
|
|||||||
|
|
||||||
# status.json keys in file order → _BotState attribute + initial value.
|
# status.json keys in file order → _BotState attribute + initial value.
|
||||||
_STATUS_FIELDS = (
|
_STATUS_FIELDS = (
|
||||||
("meetingId", "meeting_id", None), ("url", "url", None),
|
("meetingId", "meeting_id", None), ("url", "url", None), ("inCall", "in_call", False),
|
||||||
("inCall", "in_call", False), ("captioning", "captioning", False),
|
("captioning", "captioning", False), ("captionsEnabledAttempted", "captions_enabled_attempted", False),
|
||||||
("captionsEnabledAttempted", "captions_enabled_attempted", False),
|
("lobbyWaiting", "lobby_waiting", False), ("joinAttemptedAt", "join_attempted_at", None),
|
||||||
("lobbyWaiting", "lobby_waiting", False),
|
("joinedAt", "joined_at", None), ("lastCaptionAt", "last_caption_at", None),
|
||||||
("joinAttemptedAt", "join_attempted_at", None), ("joinedAt", "joined_at", None),
|
("transcriptLines", "transcript_lines", 0), ("transcriptPath", "transcript_path", None),
|
||||||
("lastCaptionAt", "last_caption_at", None), ("transcriptLines", "transcript_lines", 0),
|
|
||||||
("transcriptPath", "transcript_path", None),
|
|
||||||
("error", "error", None), ("exited", "exited", False), ("pid", None, None),
|
("error", "error", None), ("exited", "exited", False), ("pid", None, None),
|
||||||
# v2 realtime telemetry.
|
# realtime telemetry
|
||||||
("realtime", "realtime", False), ("realtimeReady", "realtime_ready", False),
|
("realtime", "realtime", False), ("realtimeReady", "realtime_ready", False),
|
||||||
("realtimeDevice", "realtime_device", None), ("audioBytesOut", "audio_bytes_out", 0),
|
("realtimeDevice", "realtime_device", None), ("audioBytesOut", "audio_bytes_out", 0),
|
||||||
("lastAudioOutAt", "last_audio_out_at", None), ("lastBargeInAt", "last_barge_in_at", None),
|
("lastAudioOutAt", "last_audio_out_at", None), ("lastBargeInAt", "last_barge_in_at", None),
|
||||||
@@ -113,8 +99,7 @@ class _BotState:
|
|||||||
|
|
||||||
def _flush(self) -> None:
|
def _flush(self) -> None:
|
||||||
data = {key: getattr(self, attr) if attr else None for key, attr, _ in _STATUS_FIELDS}
|
data = {key: getattr(self, attr) if attr else None for key, attr, _ in _STATUS_FIELDS}
|
||||||
data["transcriptPath"] = str(self.transcript_path)
|
data.update(transcriptPath=str(self.transcript_path), pid=os.getpid()) # keeps table key order
|
||||||
data["pid"] = os.getpid() # overrides keep the table's key order
|
|
||||||
write_json_atomic(self.status_path, data)
|
write_json_atomic(self.status_path, data)
|
||||||
|
|
||||||
def set(self, **kwargs) -> None:
|
def set(self, **kwargs) -> None:
|
||||||
@@ -182,8 +167,7 @@ _CAPTION_OBSERVER_JS = r"""
|
|||||||
})();
|
})();
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Best-effort caption toggle: Meet binds it to the ``c`` key; click targeting
|
# Best-effort caption toggle: Meet binds it to the ``c`` key; click targeting is too brittle.
|
||||||
# is too brittle to rely on.
|
|
||||||
_ENABLE_CAPTIONS_JS = (
|
_ENABLE_CAPTIONS_JS = (
|
||||||
"(() => { document.body.dispatchEvent(new KeyboardEvent('keydown', "
|
"(() => { document.body.dispatchEvent(new KeyboardEvent('keydown', "
|
||||||
"{ key: 'c', code: 'KeyC', keyCode: 67, which: 67, bubbles: true })); return true; })();")
|
"{ key: 'c', code: 'KeyC', keyCode: 67, which: 67, bubbles: true })); return true; })();")
|
||||||
@@ -192,8 +176,8 @@ _LEAVE_CALL_JS = (
|
|||||||
"() => { const b = document.querySelector('button[aria-label*=\"eave call\"]');"
|
"() => { const b = document.querySelector('button[aria-label*=\"eave call\"]');"
|
||||||
" if (b) b.click(); }")
|
" if (b) b.click(); }")
|
||||||
|
|
||||||
# True once we're clearly past the lobby: leave button, caption region
|
# True once past the lobby: leave button, caption region (once our observer is installed)
|
||||||
# (only once our observer is installed) or participant list visible.
|
# or participant list visible.
|
||||||
_ADMISSION_PROBE_JS = r"""
|
_ADMISSION_PROBE_JS = r"""
|
||||||
(() => {
|
(() => {
|
||||||
if (document.querySelector('button[aria-label*="eave call" i]')) return true;
|
if (document.querySelector('button[aria-label*="eave call" i]')) return true;
|
||||||
@@ -230,7 +214,7 @@ def _visible(locator):
|
|||||||
|
|
||||||
|
|
||||||
def _start_pcm_pump(rt: dict, bridge_info: dict, pcm_path: Path, state: "_BotState") -> None:
|
def _start_pcm_pump(rt: dict, bridge_info: dict, pcm_path: Path, state: "_BotState") -> None:
|
||||||
"""Stream the growing ``speaker.pcm`` (24kHz s16le mono) into the OS device Chrome's fake mic reads."""
|
"""Stream the growing ``speaker.pcm`` (24kHz s16le mono) into the device Chrome's fake mic reads."""
|
||||||
bridge_info = bridge_info or {}
|
bridge_info = bridge_info or {}
|
||||||
platform_tag = bridge_info.get("platform")
|
platform_tag = bridge_info.get("platform")
|
||||||
if platform_tag == "linux":
|
if platform_tag == "linux":
|
||||||
@@ -239,8 +223,7 @@ def _start_pcm_pump(rt: dict, bridge_info: dict, pcm_path: Path, state: "_BotSta
|
|||||||
f"--device={sink}", str(pcm_path)]
|
f"--device={sink}", str(pcm_path)]
|
||||||
missing = "paplay not found — install pulseaudio-utils for realtime on Linux"
|
missing = "paplay not found — install pulseaudio-utils for realtime on Linux"
|
||||||
elif platform_tag == "darwin":
|
elif platform_tag == "darwin":
|
||||||
# The user must have BlackHole selected as default input for Chrome
|
# User must have BlackHole as default input; ffmpeg targets it by audiotoolbox index.
|
||||||
# to pick it up; ffmpeg targets the device by audiotoolbox index.
|
|
||||||
if not shutil.which("ffmpeg"):
|
if not shutil.which("ffmpeg"):
|
||||||
state.set(error=_FFMPEG_MISSING)
|
state.set(error=_FFMPEG_MISSING)
|
||||||
return
|
return
|
||||||
@@ -270,9 +253,9 @@ def _start_realtime_speaker(rt: dict, cfg: "_BotConfig", stop_flag: dict, state:
|
|||||||
state.set(error=f"realtime import failed: {e}")
|
state.set(error=f"realtime import failed: {e}")
|
||||||
return
|
return
|
||||||
|
|
||||||
pcm_path = cfg.out_dir / SAY_PCM_FILENAME
|
pcm_path = cfg.out_dir / "speaker.pcm"
|
||||||
queue_path = cfg.out_dir / SAY_QUEUE_FILENAME
|
queue_path = cfg.out_dir / "say_queue.jsonl"
|
||||||
pcm_path.write_bytes(b"") # start each session with a clean sink file
|
pcm_path.write_bytes(b"") # clean sink file per session
|
||||||
queue_path.touch() # so the speaker poller doesn't error on first iteration
|
queue_path.touch() # so the speaker poller doesn't error on first iteration
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -301,10 +284,8 @@ def _start_realtime_speaker(rt: dict, cfg: "_BotConfig", stop_flag: dict, state:
|
|||||||
|
|
||||||
|
|
||||||
def _mac_audio_device_index(device_name: str) -> str:
|
def _mac_audio_device_index(device_name: str) -> str:
|
||||||
"""ffmpeg ``-audio_device_index`` for *device_name* (case-insensitive), ``"0"`` if not found.
|
"""ffmpeg ``-audio_device_index`` for *device_name* (case-insensitive; ``"0"`` if not found).
|
||||||
|
ffmpeg prints the avfoundation device table on stderr as ``[N] Name``."""
|
||||||
ffmpeg prints the avfoundation device table on stderr as ``[N] Name``.
|
|
||||||
"""
|
|
||||||
try:
|
try:
|
||||||
out = subprocess.run(
|
out = subprocess.run(
|
||||||
["ffmpeg", "-f", "avfoundation", "-list_devices", "true", "-i", ""],
|
["ffmpeg", "-f", "avfoundation", "-list_devices", "true", "-i", ""],
|
||||||
@@ -342,10 +323,9 @@ def _teardown_realtime(rt: dict) -> None:
|
|||||||
_quiet(rt["pcm_pump"].wait, timeout=3)
|
_quiet(rt["pcm_pump"].wait, timeout=3)
|
||||||
if rt["speaker_thread"] is not None:
|
if rt["speaker_thread"] is not None:
|
||||||
_quiet(rt["speaker_thread"].join, timeout=5.0)
|
_quiet(rt["speaker_thread"].join, timeout=5.0)
|
||||||
if rt["session"]:
|
for key, method in (("session", "close"), ("bridge", "teardown")):
|
||||||
_quiet(rt["session"].close)
|
if rt[key]:
|
||||||
if rt["bridge"]:
|
_quiet(getattr(rt[key], method))
|
||||||
_quiet(rt["bridge"].teardown)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -377,9 +357,8 @@ def _config_from_env() -> _BotConfig:
|
|||||||
guest_name=env("HERMES_MEET_GUEST_NAME", "Hermes Agent"),
|
guest_name=env("HERMES_MEET_GUEST_NAME", "Hermes Agent"),
|
||||||
duration_s=_parse_duration(env("HERMES_MEET_DURATION", "")),
|
duration_s=_parse_duration(env("HERMES_MEET_DURATION", "")),
|
||||||
realtime=env("HERMES_MEET_MODE", "transcribe").strip().lower() == "realtime",
|
realtime=env("HERMES_MEET_MODE", "transcribe").strip().lower() == "realtime",
|
||||||
# HERMES_MEET_REALTIME_KEY is resolved by process_manager.start() through the
|
# HERMES_MEET_REALTIME_KEY is resolved by process_manager.start() via the parent's
|
||||||
# parent's profile secret scope; the OPENAI_API_KEY fallback only serves
|
# profile secret scope; OPENAI_API_KEY only serves standalone `python -m` runs.
|
||||||
# standalone `python -m plugins.google_meet.meet_bot` runs.
|
|
||||||
realtime_api_key=env("HERMES_MEET_REALTIME_KEY") or env("OPENAI_API_KEY", ""),
|
realtime_api_key=env("HERMES_MEET_REALTIME_KEY") or env("OPENAI_API_KEY", ""),
|
||||||
realtime_model=env("HERMES_MEET_REALTIME_MODEL", "gpt-realtime"),
|
realtime_model=env("HERMES_MEET_REALTIME_MODEL", "gpt-realtime"),
|
||||||
realtime_voice=env("HERMES_MEET_REALTIME_VOICE", "alloy"),
|
realtime_voice=env("HERMES_MEET_REALTIME_VOICE", "alloy"),
|
||||||
@@ -388,10 +367,7 @@ def _config_from_env() -> _BotConfig:
|
|||||||
|
|
||||||
|
|
||||||
def _join(page, cfg: _BotConfig, state: _BotState) -> None:
|
def _join(page, cfg: _BotConfig, state: _BotState) -> None:
|
||||||
"""Fill the guest-name field (guest mode) and click 'Join now' / 'Ask to join'.
|
"""Fill the guest-name field and click 'Join now' / 'Ask to join' (the latter → lobby_waiting)."""
|
||||||
|
|
||||||
'Ask to join' means we're in the lobby → ``lobby_waiting``.
|
|
||||||
"""
|
|
||||||
name_box = _visible(page.locator('input[aria-label*="name" i]'))
|
name_box = _visible(page.locator('input[aria-label*="name" i]'))
|
||||||
if name_box is not None:
|
if name_box is not None:
|
||||||
_quiet(name_box.fill, cfg.guest_name, timeout=2_000)
|
_quiet(name_box.fill, cfg.guest_name, timeout=2_000)
|
||||||
@@ -409,11 +385,8 @@ def _join(page, cfg: _BotConfig, state: _BotState) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def _drain_loop(page, cfg: _BotConfig, state: _BotState, rt: dict, stop_flag: dict) -> None:
|
def _drain_loop(page, cfg: _BotConfig, state: _BotState, rt: dict, stop_flag: dict) -> None:
|
||||||
"""Admission + caption drain loop; runs until SIGTERM, duration expiry, lobby timeout/denial or page loss.
|
"""Admission + caption drain loop until SIGTERM, duration expiry, lobby timeout/denial or page loss.
|
||||||
|
Sets ``leave_reason`` for every exit but SIGTERM; triggers barge-in; mirrors realtime counters."""
|
||||||
Sets ``state.leave_reason`` for every exit but SIGTERM. Also triggers
|
|
||||||
barge-in and mirrors realtime counters into status.json.
|
|
||||||
"""
|
|
||||||
deadline = (time.time() + cfg.duration_s) if cfg.duration_s else None
|
deadline = (time.time() + cfg.duration_s) if cfg.duration_s else None
|
||||||
lobby_deadline = time.time() + cfg.lobby_timeout
|
lobby_deadline = time.time() + cfg.lobby_timeout
|
||||||
last_admission_check = 0.0
|
last_admission_check = 0.0
|
||||||
@@ -429,9 +402,8 @@ def _drain_loop(page, cfg: _BotConfig, state: _BotState, rt: dict, stop_flag: di
|
|||||||
state.set(in_call=True, lobby_waiting=False, joined_at=now)
|
state.set(in_call=True, lobby_waiting=False, joined_at=now)
|
||||||
elif now > lobby_deadline:
|
elif now > lobby_deadline:
|
||||||
waited = int(lobby_deadline - state.join_attempted_at) if state.join_attempted_at else 0
|
waited = int(lobby_deadline - state.join_attempted_at) if state.join_attempted_at else 0
|
||||||
state.set(
|
state.set(error=f"lobby timeout — host never admitted the bot within {waited}s",
|
||||||
error=f"lobby timeout — host never admitted the bot within {waited}s",
|
leave_reason="lobby_timeout")
|
||||||
leave_reason="lobby_timeout")
|
|
||||||
return
|
return
|
||||||
elif _probe(page, _DENIED_PROBE_JS):
|
elif _probe(page, _DENIED_PROBE_JS):
|
||||||
state.set(error="host denied admission", leave_reason="denied")
|
state.set(error="host denied admission", leave_reason="denied")
|
||||||
@@ -444,22 +416,18 @@ def _drain_loop(page, cfg: _BotConfig, state: _BotState, rt: dict, stop_flag: di
|
|||||||
continue
|
continue
|
||||||
speaker = str(entry.get("speaker", ""))
|
speaker = str(entry.get("speaker", ""))
|
||||||
state.record_caption(speaker=speaker, text=str(entry.get("text", "")))
|
state.record_caption(speaker=speaker, text=str(entry.get("text", "")))
|
||||||
# Barge-in: a real human spoke while we may be generating
|
# Barge-in: a real human spoke while we may be generating audio.
|
||||||
# audio — cancel the in-flight response.
|
if (rt["session"] is not None and _looks_like_human_speaker(speaker, cfg.guest_name)
|
||||||
if (rt["session"] is not None
|
|
||||||
and _looks_like_human_speaker(speaker, cfg.guest_name)
|
|
||||||
and _quiet(rt["session"].cancel_response)):
|
and _quiet(rt["session"].cancel_response)):
|
||||||
state.set(last_barge_in_at=now)
|
state.set(last_barge_in_at=now)
|
||||||
except Exception:
|
except Exception:
|
||||||
# Meet reloaded or we got booted — exit rather than spin.
|
if page.is_closed(): # Meet reloaded or we got booted — exit rather than spin
|
||||||
if page.is_closed():
|
|
||||||
state.set(leave_reason="page_closed")
|
state.set(leave_reason="page_closed")
|
||||||
return
|
return
|
||||||
|
|
||||||
if rt["session"] is not None:
|
if rt["session"] is not None:
|
||||||
state.set(
|
state.set(audio_bytes_out=rt["session"].audio_bytes_out,
|
||||||
audio_bytes_out=rt["session"].audio_bytes_out,
|
last_audio_out_at=rt["session"].last_audio_out_at)
|
||||||
last_audio_out_at=rt["session"].last_audio_out_at)
|
|
||||||
|
|
||||||
time.sleep(1.0)
|
time.sleep(1.0)
|
||||||
|
|
||||||
@@ -467,9 +435,8 @@ def _drain_loop(page, cfg: _BotConfig, state: _BotState, rt: dict, stop_flag: di
|
|||||||
def run_bot() -> int:
|
def run_bot() -> int:
|
||||||
cfg = _config_from_env()
|
cfg = _config_from_env()
|
||||||
if not _is_safe_meet_url(cfg.url):
|
if not _is_safe_meet_url(cfg.url):
|
||||||
sys.stderr.write(
|
sys.stderr.write("google_meet bot: refusing to launch — HERMES_MEET_URL must be a "
|
||||||
"google_meet bot: refusing to launch — HERMES_MEET_URL must be a "
|
"meet.google.com URL. got: %r\n" % cfg.url)
|
||||||
"meet.google.com URL. got: %r\n" % cfg.url)
|
|
||||||
return 2
|
return 2
|
||||||
if cfg.out_dir is None:
|
if cfg.out_dir is None:
|
||||||
sys.stderr.write("google_meet bot: HERMES_MEET_OUT_DIR is required\n")
|
sys.stderr.write("google_meet bot: HERMES_MEET_OUT_DIR is required\n")
|
||||||
@@ -477,19 +444,15 @@ def run_bot() -> int:
|
|||||||
|
|
||||||
state = _BotState(out_dir=cfg.out_dir, meeting_id=_meeting_id_from_url(cfg.url), url=cfg.url)
|
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
|
# SIGTERM sets a flag (not an exception) so the Playwright teardown below still runs
|
||||||
# still runs and ``meet_leave`` gets a finalized transcript.
|
# and ``meet_leave`` gets a finalized transcript.
|
||||||
stop_flag = {"stop": False}
|
stop_flag = {"stop": False}
|
||||||
|
for sig in (signal.SIGTERM, signal.SIGINT):
|
||||||
|
signal.signal(sig, lambda _sig, _frame: stop_flag.__setitem__("stop", True))
|
||||||
|
|
||||||
def _on_signal(_sig, _frame):
|
# Realtime resources in one dict so teardown works however we exit.
|
||||||
stop_flag["stop"] = True
|
rt = {"enabled": cfg.realtime, "bridge": None, "bridge_info": None, "session": None,
|
||||||
|
"speaker_thread": None}
|
||||||
signal.signal(signal.SIGTERM, _on_signal)
|
|
||||||
signal.signal(signal.SIGINT, _on_signal)
|
|
||||||
|
|
||||||
# Realtime resources tracked 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"]:
|
if rt["enabled"]:
|
||||||
_setup_realtime(rt, cfg.realtime_api_key, state)
|
_setup_realtime(rt, cfg.realtime_api_key, state)
|
||||||
|
|
||||||
@@ -497,20 +460,17 @@ def run_bot() -> int:
|
|||||||
from playwright.sync_api import sync_playwright
|
from playwright.sync_api import sync_playwright
|
||||||
except ImportError as e:
|
except ImportError as e:
|
||||||
state.set(error=f"playwright not installed: {e}", exited=True)
|
state.set(error=f"playwright not installed: {e}", exited=True)
|
||||||
sys.stderr.write(
|
sys.stderr.write("google_meet bot: playwright is not installed. Run "
|
||||||
"google_meet bot: playwright is not installed. Run "
|
"`pip install playwright && python -m playwright install chromium`\n")
|
||||||
"`pip install playwright && python -m playwright install chromium`\n")
|
|
||||||
if rt["bridge"]:
|
if rt["bridge"]:
|
||||||
rt["bridge"].teardown()
|
rt["bridge"].teardown()
|
||||||
return 3
|
return 3
|
||||||
|
|
||||||
chrome_args = ["--use-fake-ui-for-media-stream", "--disable-blink-features=AutomationControlled"]
|
chrome_args = ["--use-fake-ui-for-media-stream", "--disable-blink-features=AutomationControlled"]
|
||||||
if not rt["enabled"]:
|
if not rt["enabled"]:
|
||||||
# Silent fake device — mic content is irrelevant when we're not speaking.
|
chrome_args.insert(1, "--use-fake-device-for-media-stream") # silent fake mic
|
||||||
chrome_args.insert(1, "--use-fake-device-for-media-stream")
|
|
||||||
elif rt["bridge_info"] and rt["bridge_info"].get("platform") == "linux":
|
elif rt["bridge_info"] and rt["bridge_info"].get("platform") == "linux":
|
||||||
# Playwright's launch() takes no env: set PULSE_SOURCE on our own
|
# Playwright's launch() takes no env: set PULSE_SOURCE on ourselves so Chrome inherits it.
|
||||||
# process so the child Chrome inherits the virtual source.
|
|
||||||
os.environ["PULSE_SOURCE"] = rt["bridge_info"].get("device_name", "")
|
os.environ["PULSE_SOURCE"] = rt["bridge_info"].get("device_name", "")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -518,9 +478,8 @@ def run_bot() -> int:
|
|||||||
browser = pw.chromium.launch(headless=not cfg.headed, args=chrome_args)
|
browser = pw.chromium.launch(headless=not cfg.headed, args=chrome_args)
|
||||||
context_args = {
|
context_args = {
|
||||||
"viewport": {"width": 1280, "height": 800},
|
"viewport": {"width": 1280, "height": 800},
|
||||||
"user_agent": (
|
"user_agent": ("Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
|
||||||
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
|
"(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"),
|
||||||
"(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"),
|
|
||||||
"permissions": ["microphone", "camera"]}
|
"permissions": ["microphone", "camera"]}
|
||||||
if cfg.auth_state and Path(cfg.auth_state).is_file():
|
if cfg.auth_state and Path(cfg.auth_state).is_file():
|
||||||
context_args["storage_state"] = cfg.auth_state
|
context_args["storage_state"] = cfg.auth_state
|
||||||
@@ -561,14 +520,10 @@ def run_bot() -> int:
|
|||||||
|
|
||||||
|
|
||||||
def _looks_like_human_speaker(speaker: str, bot_guest_name: str) -> bool:
|
def _looks_like_human_speaker(speaker: str, bot_guest_name: str) -> bool:
|
||||||
"""Whether a caption's speaker is probably a human rather than our own echo.
|
"""Whether a caption's speaker is probably a human rather than our own echo (Meet attributes
|
||||||
|
our fake-mic audio to the bot's name; blank/unknown speakers are ambiguous — no barge-in)."""
|
||||||
Meet attributes our fake-mic audio to the bot's own name; blank/unknown
|
return bool(speaker and speaker.strip()) and (
|
||||||
speakers (raw-text fallback) are ambiguous, so neither triggers barge-in.
|
speaker.strip().lower() not in {"unknown", "you", bot_guest_name.strip().lower()})
|
||||||
"""
|
|
||||||
if not speaker or not speaker.strip():
|
|
||||||
return False
|
|
||||||
return speaker.strip().lower() not in {"unknown", "you", bot_guest_name.strip().lower()}
|
|
||||||
|
|
||||||
|
|
||||||
_DURATION_UNITS = {"h": 3600.0, "m": 60.0, "s": 1.0}
|
_DURATION_UNITS = {"h": 3600.0, "m": 60.0, "s": 1.0}
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
"""Remote 'node host' primitive: run the Meet bot on a different machine than the gateway.
|
"""Remote 'node host': run the Meet bot on a different machine than the gateway.
|
||||||
|
|
||||||
gateway (Linux) ── ws://mac.local:18789 ──▶ node server (Mac) → process_manager → meet_bot
|
gateway (Linux) ── ws://mac.local:18789 ──▶ node server (Mac) → process_manager → meet_bot
|
||||||
|
|
||||||
Why: Google sign-in + Chrome profile live on the user's laptop; running the
|
Why: Google sign-in + Chrome profile live on the user's laptop; running the bot there reuses
|
||||||
bot there reuses that profile without shipping credentials to the server.
|
that profile without shipping credentials. NodeClient (gateway RPC), NodeServer (hosts the
|
||||||
|
bot), NodeRegistry (approved nodes: name → url+token), protocol (envelope helpers).
|
||||||
NodeClient (gateway-side RPC), NodeServer (hosts the bot), NodeRegistry
|
|
||||||
(approved nodes: name → url+token), protocol (envelope helpers).
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|||||||
@@ -41,8 +41,7 @@ def _cmd_run(args: argparse.Namespace, reg: NodeRegistry) -> int:
|
|||||||
token = server.ensure_token()
|
token = server.ensure_token()
|
||||||
print(f"[meet-node] display_name={server.display_name}\n"
|
print(f"[meet-node] display_name={server.display_name}\n"
|
||||||
f"[meet-node] listening on ws://{args.host}:{args.port}\n"
|
f"[meet-node] listening on ws://{args.host}:{args.port}\n"
|
||||||
f"[meet-node] token (copy to gateway): {token}\n"
|
f"[meet-node] token (copy to gateway): {token}\n[meet-node] approve with:\n"
|
||||||
"[meet-node] approve with:\n"
|
|
||||||
f" hermes meet node approve <name> ws://<host>:{args.port} {token}")
|
f" hermes meet node approve <name> ws://<host>:{args.port} {token}")
|
||||||
try:
|
try:
|
||||||
asyncio.run(server.serve())
|
asyncio.run(server.serve())
|
||||||
|
|||||||
@@ -1,9 +1,5 @@
|
|||||||
"""Gateway-side RPC client for a remote meet node.
|
"""Gateway-side RPC client for a remote meet node: one short-lived sync WebSocket per call, so
|
||||||
|
non-async tool handlers need no persistent connection. ``websockets`` is imported lazily."""
|
||||||
One short-lived sync WebSocket per call (send one request, read one
|
|
||||||
response, close) so non-async tool handlers need no persistent connection
|
|
||||||
state across agent turns. ``websockets`` is optional and imported lazily.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -29,10 +25,8 @@ class NodeClient:
|
|||||||
try:
|
try:
|
||||||
from websockets.sync.client import connect # type: ignore
|
from websockets.sync.client import connect # type: ignore
|
||||||
except ImportError as exc:
|
except ImportError as exc:
|
||||||
raise RuntimeError(
|
raise RuntimeError("NodeClient requires the 'websockets' package. "
|
||||||
"NodeClient requires the 'websockets' package. "
|
"Install it with: pip install websockets") from exc
|
||||||
"Install it with: pip install websockets"
|
|
||||||
) from exc
|
|
||||||
|
|
||||||
req = _proto.make_request(type, self.token, payload)
|
req = _proto.make_request(type, self.token, payload)
|
||||||
with connect(self.url, open_timeout=self.timeout, close_timeout=self.timeout) as ws:
|
with connect(self.url, open_timeout=self.timeout, close_timeout=self.timeout) as ws:
|
||||||
@@ -48,13 +42,8 @@ class NodeClient:
|
|||||||
raise RuntimeError("response missing payload dict")
|
raise RuntimeError("response missing payload dict")
|
||||||
return payload_out
|
return payload_out
|
||||||
|
|
||||||
def start_bot(
|
def start_bot(self, url: str, guest_name: str = "Hermes Agent", duration: Optional[str] = None,
|
||||||
self,
|
headed: bool = False, mode: str = "transcribe") -> Dict[str, Any]:
|
||||||
url: str,
|
|
||||||
guest_name: str = "Hermes Agent",
|
|
||||||
duration: Optional[str] = None,
|
|
||||||
headed: bool = False,
|
|
||||||
mode: str = "transcribe") -> Dict[str, Any]:
|
|
||||||
payload: Dict[str, Any] = {"url": url, "guest_name": guest_name, "headed": bool(headed), "mode": mode}
|
payload: Dict[str, Any] = {"url": url, "guest_name": guest_name, "headed": bool(headed), "mode": mode}
|
||||||
if duration is not None:
|
if duration is not None:
|
||||||
payload["duration"] = duration
|
payload["duration"] = duration
|
||||||
|
|||||||
@@ -1,14 +1,11 @@
|
|||||||
"""Wire protocol for gateway ↔ node RPC.
|
"""Wire protocol for gateway ↔ node RPC (JSON envelopes).
|
||||||
|
|
||||||
Everything is a JSON object with the same envelope shape:
|
|
||||||
|
|
||||||
Request: {"type": <str>, "id": <str>, "token": <str>, "payload": <dict>}
|
Request: {"type": <str>, "id": <str>, "token": <str>, "payload": <dict>}
|
||||||
Response: {"type": "response", "id": <req-id>, "payload": <dict>}
|
Response: {"type": "response", "id": <req-id>, "payload": <dict>}
|
||||||
Error: {"type": "error", "id": <req-id>, "error": <str>}
|
Error: {"type": "error", "id": <req-id>, "error": <str>}
|
||||||
|
|
||||||
Requests must carry the shared bearer token (set up via ``hermes meet node
|
Requests carry the shared bearer token (``hermes meet node approve`` on the gateway, read off
|
||||||
approve`` on the gateway and read off disk on the server). Mismatched tokens
|
disk on the server); mismatched tokens are rejected before dispatch.
|
||||||
are rejected before dispatch.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -55,11 +52,8 @@ def encode(msg: Dict[str, Any]) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def decode(raw) -> Dict[str, Any]:
|
def decode(raw) -> Dict[str, Any]:
|
||||||
"""Parse a JSON envelope (object with string ``type`` + ``id``), raising ValueError otherwise.
|
"""Parse a JSON envelope (object with string ``type`` + ``id``) from str/bytes; ValueError otherwise.
|
||||||
|
Token match and payload shape are checked server-side in :func:`validate_request`."""
|
||||||
Accepts ``str`` or UTF-8 ``bytes``. Token match and payload shape are
|
|
||||||
checked server-side in :func:`validate_request`.
|
|
||||||
"""
|
|
||||||
if isinstance(raw, (bytes, bytearray)):
|
if isinstance(raw, (bytes, bytearray)):
|
||||||
raw = raw.decode("utf-8")
|
raw = raw.decode("utf-8")
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -26,8 +26,6 @@ class NodeRegistry:
|
|||||||
def __init__(self, path: Optional[Path] = None) -> None:
|
def __init__(self, path: Optional[Path] = None) -> None:
|
||||||
self.path = Path(path) if path is not None else _default_path()
|
self.path = Path(path) if path is not None else _default_path()
|
||||||
|
|
||||||
# ----- storage ------------------------------------------------------
|
|
||||||
|
|
||||||
def _load(self) -> Dict[str, Dict[str, Any]]:
|
def _load(self) -> Dict[str, Dict[str, Any]]:
|
||||||
"""The ``nodes`` map (name → entry); empty when the file is missing or malformed."""
|
"""The ``nodes`` map (name → entry); empty when the file is missing or malformed."""
|
||||||
data = read_json(self.path)
|
data = read_json(self.path)
|
||||||
@@ -37,8 +35,6 @@ class NodeRegistry:
|
|||||||
def _save(self, nodes: Dict[str, Dict[str, Any]]) -> None:
|
def _save(self, nodes: Dict[str, Dict[str, Any]]) -> None:
|
||||||
write_json_atomic(self.path, {"nodes": nodes})
|
write_json_atomic(self.path, {"nodes": nodes})
|
||||||
|
|
||||||
# ----- public API ---------------------------------------------------
|
|
||||||
|
|
||||||
def get(self, name: str) -> Optional[Dict[str, Any]]:
|
def get(self, name: str) -> Optional[Dict[str, Any]]:
|
||||||
entry = self._load().get(name)
|
entry = self._load().get(name)
|
||||||
return None if entry is None else {"name": name, **entry}
|
return None if entry is None else {"name": name, **entry}
|
||||||
@@ -63,10 +59,8 @@ class NodeRegistry:
|
|||||||
return [{"name": name, **entry} for name, entry in sorted(self._load().items())]
|
return [{"name": name, **entry} for name, entry in sorted(self._load().items())]
|
||||||
|
|
||||||
def resolve(self, chrome_node: Optional[str]) -> Optional[Dict[str, Any]]:
|
def resolve(self, chrome_node: Optional[str]) -> Optional[Dict[str, Any]]:
|
||||||
"""Named node's entry, or — when ``chrome_node`` is falsy — the sole registered node.
|
"""Named node's entry, or (``chrome_node`` falsy) the sole registered node; None if unknown
|
||||||
|
or when zero / several nodes are registered (ambiguous)."""
|
||||||
None when the name is unknown or when zero / several nodes are registered (ambiguous).
|
|
||||||
"""
|
|
||||||
if chrome_node:
|
if chrome_node:
|
||||||
return self.get(chrome_node)
|
return self.get(chrome_node)
|
||||||
nodes = self.list_all()
|
nodes = self.list_all()
|
||||||
|
|||||||
@@ -1,15 +1,9 @@
|
|||||||
"""Remote node server — hosts the Meet bot on another machine (e.g. the user's Mac).
|
"""Remote node server — hosts the Meet bot on another machine (``hermes meet node run``).
|
||||||
|
|
||||||
Exposes a WebSocket endpoint that accepts token-signed RPC requests and
|
WebSocket endpoint accepting token-signed RPC requests dispatched to ``process_manager``.
|
||||||
dispatches them to ``plugins.google_meet.process_manager``. Launched by
|
Token: 32 hex chars minted on first boot, persisted at ``$HERMES_HOME/workspace/meetings/
|
||||||
``hermes meet node run``.
|
node_token.json`` so approved gateways survive restarts; the operator copies it to the gateway
|
||||||
|
via ``hermes meet node approve <name> <url> <token>``. ``websockets`` is imported lazily.
|
||||||
Token: 32 hex chars minted on first boot and persisted at
|
|
||||||
``$HERMES_HOME/workspace/meetings/node_token.json`` so previously-approved
|
|
||||||
gateways survive restarts. The operator copies it to the gateway via
|
|
||||||
``hermes meet node approve <name> <url> <token>``.
|
|
||||||
|
|
||||||
``websockets`` is optional and imported lazily inside :meth:`serve`.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -41,9 +35,7 @@ def _rpc_start_bot(payload: Dict[str, Any], pm) -> Dict[str, Any]:
|
|||||||
|
|
||||||
|
|
||||||
def _rpc_say(payload: Dict[str, Any], pm) -> Dict[str, Any]:
|
def _rpc_say(payload: Dict[str, Any], pm) -> Dict[str, Any]:
|
||||||
# Appends to say_queue.jsonl inside the active meeting's out_dir; the
|
# The bot-side consumer only exists in realtime mode: ok=True means "enqueued", not "spoken".
|
||||||
# bot-side consumer only exists in realtime mode, so ok=True here means
|
|
||||||
# "enqueued", not "spoken".
|
|
||||||
text = payload.get("text", "")
|
text = payload.get("text", "")
|
||||||
active = pm._read_active()
|
active = pm._read_active()
|
||||||
enqueued = False
|
enqueued = False
|
||||||
@@ -71,12 +63,8 @@ _RPC = {
|
|||||||
class NodeServer:
|
class NodeServer:
|
||||||
"""WebSocket server that executes meet bot RPCs locally."""
|
"""WebSocket server that executes meet bot RPCs locally."""
|
||||||
|
|
||||||
def __init__(
|
def __init__(self, host: str = "127.0.0.1", port: int = 18789, token_path: Optional[Path] = None,
|
||||||
self,
|
display_name: str = "hermes-meet-node") -> None:
|
||||||
host: str = "127.0.0.1",
|
|
||||||
port: int = 18789,
|
|
||||||
token_path: Optional[Path] = None,
|
|
||||||
display_name: str = "hermes-meet-node") -> None:
|
|
||||||
self.host = host
|
self.host = host
|
||||||
self.port = port
|
self.port = port
|
||||||
self.display_name = display_name
|
self.display_name = display_name
|
||||||
@@ -99,18 +87,16 @@ class NodeServer:
|
|||||||
|
|
||||||
async def _handle_request(self, msg: Dict[str, Any]) -> Dict[str, Any]:
|
async def _handle_request(self, msg: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
"""Validate + dispatch one decoded request; always returns an envelope, never raises.
|
"""Validate + dispatch one decoded request; always returns an envelope, never raises.
|
||||||
|
Envelope ``error`` is for auth/protocol failures and pm crashes; pm's own ``ok``/``error``
|
||||||
The envelope-level ``error`` channel is reserved for auth/protocol
|
results travel inside a normal response payload."""
|
||||||
failures and pm crashes; pm's own ``ok``/``error`` results travel
|
|
||||||
inside a normal response payload.
|
|
||||||
"""
|
|
||||||
ok, reason = _proto.validate_request(msg, self.ensure_token())
|
ok, reason = _proto.validate_request(msg, self.ensure_token())
|
||||||
if not ok:
|
if not ok:
|
||||||
return _proto.make_error(str(msg.get("id") or ""), reason)
|
return _proto.make_error(str(msg.get("id") or ""), reason)
|
||||||
|
|
||||||
req_id, t = msg["id"], msg["type"]
|
req_id, t = msg["id"], msg["type"]
|
||||||
if t == "ping":
|
if t == "ping":
|
||||||
return {"type": "pong", "id": req_id, "payload": {"display_name": self.display_name, "ts": time.time()}}
|
return {"type": "pong", "id": req_id,
|
||||||
|
"payload": {"display_name": self.display_name, "ts": time.time()}}
|
||||||
handler = _RPC.get(t)
|
handler = _RPC.get(t)
|
||||||
if handler is None:
|
if handler is None:
|
||||||
return _proto.make_error(req_id, f"unhandled type: {t!r}")
|
return _proto.make_error(req_id, f"unhandled type: {t!r}")
|
||||||
@@ -130,10 +116,8 @@ class NodeServer:
|
|||||||
try:
|
try:
|
||||||
import websockets # type: ignore
|
import websockets # type: ignore
|
||||||
except ImportError as exc:
|
except ImportError as exc:
|
||||||
raise RuntimeError(
|
raise RuntimeError("NodeServer.serve requires the 'websockets' package. "
|
||||||
"NodeServer.serve requires the 'websockets' package. "
|
"Install it with: pip install websockets") from exc
|
||||||
"Install it with: pip install websockets"
|
|
||||||
) from exc
|
|
||||||
|
|
||||||
self.ensure_token()
|
self.ensure_token()
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +1,9 @@
|
|||||||
"""Subprocess lifecycle manager for the google_meet bot.
|
"""Subprocess lifecycle manager for the google_meet bot.
|
||||||
|
|
||||||
Single active meeting at a time, recorded in
|
One active meeting at a time, recorded in ``$HERMES_HOME/workspace/meetings/.active.json``
|
||||||
``$HERMES_HOME/workspace/meetings/.active.json`` so tool calls across turns
|
(``pid, meeting_id, out_dir, url, started_at, session_id, log_path, mode``) so tool calls
|
||||||
(and ``on_session_end``) can find the bot. The bot is a detached subprocess:
|
across turns can find the bot. The bot is a detached subprocess reached via files only
|
||||||
we hold no fds on it and communicate via files only, so the agent loop can't
|
(``<meeting-id>/status.json``, ``<meeting-id>/transcript.txt``), so the agent loop can't block.
|
||||||
block on it.
|
|
||||||
|
|
||||||
Layout under ``workspace/meetings/``::
|
|
||||||
|
|
||||||
.active.json {"pid", "meeting_id", "out_dir", "url", "started_at",
|
|
||||||
"session_id", "log_path", "mode"}
|
|
||||||
<meeting-id>/status.json live bot state (written by the bot)
|
|
||||||
<meeting-id>/transcript.txt scraped captions
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -43,8 +35,7 @@ def _write_active(data: Dict[str, Any]) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def _pid_alive(pid: int) -> bool:
|
def _pid_alive(pid: int) -> bool:
|
||||||
# Not ``os.kill(pid, 0)``: on Windows that routes through
|
# Not ``os.kill(pid, 0)``: on Windows that can kill the target (bpo-14484).
|
||||||
# GenerateConsoleCtrlEvent and can kill the target (bpo-14484).
|
|
||||||
from gateway.status import _pid_exists
|
from gateway.status import _pid_exists
|
||||||
return bool(pid) and _pid_exists(pid)
|
return bool(pid) and _pid_exists(pid)
|
||||||
|
|
||||||
@@ -64,22 +55,12 @@ def _kill(pid: int, sig) -> None:
|
|||||||
_NO_ACTIVE = {"ok": False, "reason": "no active meeting"}
|
_NO_ACTIVE = {"ok": False, "reason": "no active meeting"}
|
||||||
|
|
||||||
|
|
||||||
def start(
|
def start(url: str, *, out_dir: Optional[Path] = None, headed: bool = False,
|
||||||
url: str,
|
auth_state: Optional[str] = None, guest_name: str = "Hermes Agent", duration: Optional[str] = None,
|
||||||
*,
|
session_id: Optional[str] = None, mode: str = "transcribe", realtime_model: Optional[str] = None,
|
||||||
out_dir: Optional[Path] = None,
|
realtime_voice: Optional[str] = None, realtime_instructions: Optional[str] = None,
|
||||||
headed: bool = False,
|
realtime_api_key: Optional[str] = None) -> Dict[str, Any]:
|
||||||
auth_state: Optional[str] = None,
|
"""Spawn the meet_bot subprocess for *url*, stopping any running bot first (one active meeting)."""
|
||||||
guest_name: str = "Hermes Agent",
|
|
||||||
duration: Optional[str] = None,
|
|
||||||
session_id: Optional[str] = None,
|
|
||||||
mode: str = "transcribe",
|
|
||||||
realtime_model: Optional[str] = None,
|
|
||||||
realtime_voice: Optional[str] = None,
|
|
||||||
realtime_instructions: Optional[str] = None,
|
|
||||||
realtime_api_key: Optional[str] = None) -> Dict[str, Any]:
|
|
||||||
"""Spawn the meet_bot subprocess for *url*, stopping any running bot first
|
|
||||||
(single-active-meeting semantics). Returns a dict summarizing the bot."""
|
|
||||||
from plugins.google_meet.meet_bot import _is_safe_meet_url, _meeting_id_from_url
|
from plugins.google_meet.meet_bot import _is_safe_meet_url, _meeting_id_from_url
|
||||||
|
|
||||||
if not _is_safe_meet_url(url):
|
if not _is_safe_meet_url(url):
|
||||||
@@ -92,7 +73,7 @@ def start(
|
|||||||
out = out_dir or (_root() / meeting_id)
|
out = out_dir or (_root() / meeting_id)
|
||||||
out.mkdir(parents=True, exist_ok=True)
|
out.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
# Wipe stale files from a previous run of this meeting id so polling isn't confused.
|
# Wipe stale files from a previous run of this meeting id.
|
||||||
for name in ("transcript.txt", "status.json"):
|
for name in ("transcript.txt", "status.json"):
|
||||||
try:
|
try:
|
||||||
(out / name).unlink()
|
(out / name).unlink()
|
||||||
@@ -111,10 +92,8 @@ def start(
|
|||||||
(realtime_instructions, "HERMES_MEET_REALTIME_INSTRUCTIONS")):
|
(realtime_instructions, "HERMES_MEET_REALTIME_INSTRUCTIONS")):
|
||||||
if value:
|
if value:
|
||||||
env[var] = value
|
env[var] = value
|
||||||
# Resolve the realtime key at SPAWN time, in the parent, where the profile
|
# Resolve the realtime key at SPAWN time in the parent, where the profile secret scope
|
||||||
# secret scope (a contextvar) is installed. The detached child inherits the
|
# (a contextvar) is installed; the detached child inherits env, not scope.
|
||||||
# environment, not the scope — under a multiplexed gateway an in-child
|
|
||||||
# os.environ read could see another profile's key (or nothing).
|
|
||||||
if not realtime_api_key:
|
if not realtime_api_key:
|
||||||
from agent.secret_scope import get_secret
|
from agent.secret_scope import get_secret
|
||||||
|
|
||||||
@@ -123,8 +102,7 @@ def start(
|
|||||||
env["HERMES_MEET_REALTIME_KEY"] = realtime_api_key
|
env["HERMES_MEET_REALTIME_KEY"] = realtime_api_key
|
||||||
|
|
||||||
log_path = out / "bot.log"
|
log_path = out / "bot.log"
|
||||||
# Detach: stdin=devnull, stdout/stderr → log file, new session so parent
|
# Detach: stdout/stderr → log file, new session so parent signals don't propagate.
|
||||||
# signals don't propagate. The child owns the log fd after Popen.
|
|
||||||
with open(log_path, "ab", buffering=0) as log_fh:
|
with open(log_path, "ab", buffering=0) as log_fh:
|
||||||
proc = subprocess.Popen(
|
proc = subprocess.Popen(
|
||||||
[sys.executable, "-m", "plugins.google_meet.meet_bot"],
|
[sys.executable, "-m", "plugins.google_meet.meet_bot"],
|
||||||
@@ -165,19 +143,13 @@ def transcript(last: Optional[int] = None) -> Dict[str, Any]:
|
|||||||
tp = Path(active.get("out_dir", "")) / "transcript.txt"
|
tp = Path(active.get("out_dir", "")) / "transcript.txt"
|
||||||
text = tp.read_text(encoding="utf-8", errors="replace") if tp.is_file() else ""
|
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()]
|
all_lines = [ln for ln in text.splitlines() if ln.strip()]
|
||||||
return {
|
return {"ok": True, "meetingId": active.get("meeting_id"),
|
||||||
"ok": True,
|
"lines": all_lines[-last:] if last else all_lines, "total": len(all_lines), "path": str(tp)}
|
||||||
"meetingId": active.get("meeting_id"),
|
|
||||||
"lines": all_lines[-last:] if last else all_lines,
|
|
||||||
"total": len(all_lines),
|
|
||||||
"path": str(tp)}
|
|
||||||
|
|
||||||
|
|
||||||
def enqueue_say(text: str) -> Dict[str, Any]:
|
def enqueue_say(text: str) -> Dict[str, Any]:
|
||||||
"""Append a ``say`` request to ``<out_dir>/say_queue.jsonl`` for the bot's speaker thread.
|
"""Append a ``say`` request to ``<out_dir>/say_queue.jsonl``.
|
||||||
|
Refused when no meeting is active or the active bot is transcribe-only."""
|
||||||
Refused when no meeting is active or the active bot is in transcribe-only mode.
|
|
||||||
"""
|
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
text = (text or "").strip()
|
text = (text or "").strip()
|
||||||
@@ -225,8 +197,5 @@ def stop(*, reason: str = "requested") -> Dict[str, Any]:
|
|||||||
(_root() / ".active.json").unlink()
|
(_root() / ".active.json").unlink()
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
pass
|
pass
|
||||||
return {
|
return {"ok": True, "reason": reason, "meetingId": active.get("meeting_id"),
|
||||||
"ok": True,
|
"transcriptPath": str(Path(out_dir) / "transcript.txt") if out_dir else None}
|
||||||
"reason": reason,
|
|
||||||
"meetingId": active.get("meeting_id"),
|
|
||||||
"transcriptPath": str(Path(out_dir) / "transcript.txt") if out_dir else None}
|
|
||||||
|
|||||||
@@ -1,9 +1,4 @@
|
|||||||
"""Realtime speech subpackage for the google_meet plugin (v2).
|
"""Realtime speech: thin OpenAI Realtime client + file-queue speaker for the Meet bot."""
|
||||||
|
|
||||||
Provides a thin OpenAI Realtime API client and a file-queue speaker
|
|
||||||
wrapper so the Meet bot can play synthesized speech through the
|
|
||||||
virtual audio bridge.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from .openai_client import RealtimeSession, RealtimeSpeaker # noqa: F401
|
from .openai_client import RealtimeSession, RealtimeSpeaker # noqa: F401
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
"""OpenAI Realtime API WebSocket client + file-queue speaker.
|
"""OpenAI Realtime API WebSocket client + file-queue speaker.
|
||||||
|
|
||||||
Output side of the v2 voice bridge: text → OpenAI Realtime → audio deltas
|
text → OpenAI Realtime → audio deltas appended as PCM to a file the audio bridge streams into
|
||||||
appended as PCM to a file that the audio bridge streams into Chrome's fake
|
Chrome's fake mic. One sync WebSocket per session; ``websockets`` is imported lazily.
|
||||||
mic. One synchronous WebSocket per speaker/session; ``websockets`` is
|
|
||||||
imported lazily so importing this module never fails without the optional dep.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -30,20 +28,11 @@ def _decode_audio(b64: str) -> bytes:
|
|||||||
|
|
||||||
|
|
||||||
class RealtimeSession:
|
class RealtimeSession:
|
||||||
"""Minimal sync client for the OpenAI Realtime WebSocket API.
|
"""Minimal sync client for the OpenAI Realtime WebSocket API; ``speak`` and ``cancel_response``
|
||||||
|
may run on different threads — a lock serializes WebSocket writes."""
|
||||||
|
|
||||||
``speak`` and ``cancel_response`` may be called from different threads;
|
def __init__(self, api_key: str, model: str = "gpt-realtime", voice: str = "alloy",
|
||||||
a lock serializes WebSocket writes.
|
instructions: str = "", audio_sink_path: Optional[Path] = None, sample_rate: int = 24000) -> None:
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
api_key: str,
|
|
||||||
model: str = "gpt-realtime",
|
|
||||||
voice: str = "alloy",
|
|
||||||
instructions: str = "",
|
|
||||||
audio_sink_path: Optional[Path] = None,
|
|
||||||
sample_rate: int = 24000) -> None:
|
|
||||||
self.api_key = api_key
|
self.api_key = api_key
|
||||||
self.model = model
|
self.model = model
|
||||||
self.voice = voice
|
self.voice = voice
|
||||||
@@ -52,8 +41,7 @@ class RealtimeSession:
|
|||||||
self.sample_rate = sample_rate
|
self.sample_rate = sample_rate
|
||||||
self._ws: Any = None
|
self._ws: Any = None
|
||||||
self._send_lock = threading.Lock()
|
self._send_lock = threading.Lock()
|
||||||
# Public counters for status reporting.
|
self.audio_bytes_out: int = 0 # public counters for status reporting
|
||||||
self.audio_bytes_out: int = 0
|
|
||||||
self.last_audio_out_at: Optional[float] = None
|
self.last_audio_out_at: Optional[float] = None
|
||||||
|
|
||||||
def connect(self) -> None:
|
def connect(self) -> None:
|
||||||
@@ -61,10 +49,8 @@ class RealtimeSession:
|
|||||||
try:
|
try:
|
||||||
from websockets.sync.client import connect # type: ignore
|
from websockets.sync.client import connect # type: ignore
|
||||||
except ImportError as exc: # pragma: no cover - exercised via test
|
except ImportError as exc: # pragma: no cover - exercised via test
|
||||||
raise RuntimeError(
|
raise RuntimeError("websockets package is required for OpenAI Realtime; "
|
||||||
"websockets package is required for OpenAI Realtime; "
|
"install with: pip install websockets") from exc
|
||||||
"install with: pip install websockets"
|
|
||||||
) from exc
|
|
||||||
url = f"{REALTIME_URL}?model={self.model}"
|
url = f"{REALTIME_URL}?model={self.model}"
|
||||||
headers = [("Authorization", f"Bearer {self.api_key}"), ("OpenAI-Beta", "realtime=v1")]
|
headers = [("Authorization", f"Bearer {self.api_key}"), ("OpenAI-Beta", "realtime=v1")]
|
||||||
# Newer websockets takes additional_headers=, older extra_headers=.
|
# Newer websockets takes additional_headers=, older extra_headers=.
|
||||||
@@ -91,12 +77,8 @@ class RealtimeSession:
|
|||||||
self._ws = None
|
self._ws = None
|
||||||
|
|
||||||
def speak(self, text: str, timeout: float = 30.0) -> dict:
|
def speak(self, text: str, timeout: float = 30.0) -> dict:
|
||||||
"""Send ``text`` and append the audio response to ``audio_sink_path``.
|
"""Send ``text`` and append the audio response to ``audio_sink_path`` (opened 'ab' per call
|
||||||
|
so a streaming reader can consume it). Frames other than audio deltas/terminal/error are ignored."""
|
||||||
The sink is opened 'ab' and closed per call so a streaming reader can
|
|
||||||
consume whatever is there. Frames other than audio deltas, terminal
|
|
||||||
response events and errors are ignored.
|
|
||||||
"""
|
|
||||||
if self._ws is None:
|
if self._ws is None:
|
||||||
raise RuntimeError("RealtimeSession.connect() must be called first")
|
raise RuntimeError("RealtimeSession.connect() must be called first")
|
||||||
|
|
||||||
@@ -153,9 +135,7 @@ class RealtimeSession:
|
|||||||
|
|
||||||
def _recv_frame(self, deadline: float, timeout: float) -> Optional[dict]:
|
def _recv_frame(self, deadline: float, timeout: float) -> Optional[dict]:
|
||||||
"""Next dict frame before *deadline* (monotonic), ``None`` once the peer closes.
|
"""Next dict frame before *deadline* (monotonic), ``None`` once the peer closes.
|
||||||
|
Non-dict / unparseable frames are skipped; TimeoutError past the deadline."""
|
||||||
Non-dict / unparseable frames are skipped; TimeoutError past the deadline.
|
|
||||||
"""
|
|
||||||
assert self._ws is not None
|
assert self._ws is not None
|
||||||
while True:
|
while True:
|
||||||
remaining = deadline - time.monotonic()
|
remaining = deadline - time.monotonic()
|
||||||
@@ -176,15 +156,10 @@ class RealtimeSession:
|
|||||||
|
|
||||||
|
|
||||||
class RealtimeSpeaker:
|
class RealtimeSpeaker:
|
||||||
"""File-based JSONL queue wrapper around :class:`RealtimeSession`.
|
"""JSONL queue (``{"id", "text"}`` per line) wrapper around :class:`RealtimeSession`; processed
|
||||||
|
lines are appended to ``processed_path`` (if set) and removed from the queue."""
|
||||||
|
|
||||||
Each queue line is ``{"id": "<uuid>", "text": "..."}``. Processed lines
|
def __init__(self, session: RealtimeSession, queue_path: Path, processed_path: Optional[Path] = None) -> None:
|
||||||
are appended to ``processed_path`` (if set) and removed from the queue.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self, session: RealtimeSession, queue_path: Path, processed_path: Optional[Path] = None
|
|
||||||
) -> None:
|
|
||||||
self.session = session
|
self.session = session
|
||||||
self.queue_path = Path(queue_path)
|
self.queue_path = Path(queue_path)
|
||||||
self.processed_path = Path(processed_path) if processed_path else None
|
self.processed_path = Path(processed_path) if processed_path else None
|
||||||
@@ -205,8 +180,7 @@ class RealtimeSpeaker:
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
def _rewrite_queue(self, remaining: list[dict]) -> None:
|
def _rewrite_queue(self, remaining: list[dict]) -> None:
|
||||||
# Always keep the file (empty when drained): consumers may watch its
|
# Always keep the file (empty when drained): consumers may watch its mtime.
|
||||||
# mtime, and delete-then-recreate is a race.
|
|
||||||
body = "".join(json.dumps(e) + "\n" for e in remaining)
|
body = "".join(json.dumps(e) + "\n" for e in remaining)
|
||||||
self.queue_path.write_text(body, encoding="utf-8")
|
self.queue_path.write_text(body, encoding="utf-8")
|
||||||
|
|
||||||
@@ -236,8 +210,7 @@ class RealtimeSpeaker:
|
|||||||
result = {"ok": True, "bytes_written": 0, "duration_ms": 0.0}
|
result = {"ok": True, "bytes_written": 0, "duration_ms": 0.0}
|
||||||
self._append_processed(head, result)
|
self._append_processed(head, result)
|
||||||
|
|
||||||
# Re-read from disk (new entries may have arrived), then drop the
|
# Re-read (new entries may have arrived), then drop the head by position or id.
|
||||||
# head — by position when it's still first, else by id.
|
|
||||||
latest = self._read_queue()
|
latest = self._read_queue()
|
||||||
if latest and latest[0].get("id") == head.get("id"):
|
if latest and latest[0].get("id") == head.get("id"):
|
||||||
self._rewrite_queue(latest[1:])
|
self._rewrite_queue(latest[1:])
|
||||||
|
|||||||
@@ -17,18 +17,11 @@ from plugins.google_meet import process_manager as pm
|
|||||||
|
|
||||||
def check_meet_requirements() -> bool:
|
def check_meet_requirements() -> bool:
|
||||||
"""True when the plugin can run LOCALLY: Linux/macOS + importable ``playwright``.
|
"""True when the plugin can run LOCALLY: Linux/macOS + importable ``playwright``.
|
||||||
|
Remote-node operation only needs ``websockets``; handlers relax this gate when a node is addressed."""
|
||||||
Remote-node operation only needs ``websockets`` on the gateway side; the
|
import importlib.util
|
||||||
handlers relax this gate themselves when a node is addressed.
|
|
||||||
"""
|
|
||||||
import platform as _p
|
import platform as _p
|
||||||
if _p.system().lower() not in {"linux", "darwin"}:
|
return (_p.system().lower() in {"linux", "darwin"}
|
||||||
return False
|
and importlib.util.find_spec("playwright") is not None)
|
||||||
try:
|
|
||||||
import playwright # noqa: F401
|
|
||||||
except ImportError:
|
|
||||||
return False
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def resolve_node(node: str):
|
def resolve_node(node: str):
|
||||||
@@ -42,10 +35,6 @@ def resolve_node(node: str):
|
|||||||
return NodeClient(url=entry["url"], token=entry["token"]), entry.get("name")
|
return NodeClient(url=entry["url"], token=entry["token"]), entry.get("name")
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Schemas
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
_NODE_PROP = {"type": "string"}
|
_NODE_PROP = {"type": "string"}
|
||||||
|
|
||||||
MEET_JOIN_SCHEMA: Dict[str, Any] = {
|
MEET_JOIN_SCHEMA: Dict[str, Any] = {
|
||||||
@@ -168,10 +157,6 @@ MEET_SAY_SCHEMA: Dict[str, Any] = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Handlers
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def _json(obj: Any) -> str:
|
def _json(obj: Any) -> str:
|
||||||
return json.dumps(obj, ensure_ascii=False)
|
return json.dumps(obj, ensure_ascii=False)
|
||||||
|
|
||||||
@@ -187,10 +172,8 @@ def _dispatch(node: Optional[str], op: str, remote, local) -> str:
|
|||||||
return _json({"success": bool(res.get("ok")), **res})
|
return _json({"success": bool(res.get("ok")), **res})
|
||||||
client, node_name = resolve_node(node)
|
client, node_name = resolve_node(node)
|
||||||
if client is None:
|
if client is None:
|
||||||
return _err(
|
return _err(f"no registered meet node matches {node!r} — "
|
||||||
f"no registered meet node matches {node!r} — "
|
"run `hermes meet node approve <name> <url> <token>` first")
|
||||||
"run `hermes meet node approve <name> <url> <token>` first"
|
|
||||||
)
|
|
||||||
try:
|
try:
|
||||||
res = remote(client)
|
res = remote(client)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -207,23 +190,16 @@ def handle_meet_join(args: Dict[str, Any], **_kw) -> str:
|
|||||||
return _err(f"mode must be 'transcribe' or 'realtime' (got {mode!r})")
|
return _err(f"mode must be 'transcribe' or 'realtime' (got {mode!r})")
|
||||||
|
|
||||||
common: Dict[str, Any] = dict(
|
common: Dict[str, Any] = dict(
|
||||||
url=url,
|
url=url, guest_name=str(args.get("guest_name") or "Hermes Agent"),
|
||||||
guest_name=str(args.get("guest_name") or "Hermes Agent"),
|
|
||||||
duration=str(args.get("duration")) if args.get("duration") else None,
|
duration=str(args.get("duration")) if args.get("duration") else None,
|
||||||
headed=bool(args.get("headed", False)),
|
headed=bool(args.get("headed", False)), mode=mode)
|
||||||
mode=mode,
|
|
||||||
)
|
|
||||||
|
|
||||||
def _local():
|
def _local():
|
||||||
if not check_meet_requirements():
|
if not check_meet_requirements():
|
||||||
return {
|
return {"ok": False, "error": (
|
||||||
"ok": False,
|
"google_meet plugin prerequisites missing — install with "
|
||||||
"error": (
|
"`pip install playwright && python -m playwright install "
|
||||||
"google_meet plugin prerequisites missing — install with "
|
"chromium`. Plugin is supported on Linux and macOS only.")}
|
||||||
"`pip install playwright && python -m playwright install "
|
|
||||||
"chromium`. Plugin is supported on Linux and macOS only."
|
|
||||||
),
|
|
||||||
}
|
|
||||||
return pm.start(**common)
|
return pm.start(**common)
|
||||||
|
|
||||||
return _dispatch(args.get("node"), "start_bot", lambda c: c.start_bot(**common), _local)
|
return _dispatch(args.get("node"), "start_bot", lambda c: c.start_bot(**common), _local)
|
||||||
@@ -240,17 +216,13 @@ def handle_meet_transcript(args: Dict[str, Any], **_kw) -> str:
|
|||||||
last = None
|
last = None
|
||||||
if last is not None and last < 1:
|
if last is not None and last < 1:
|
||||||
last = None
|
last = None
|
||||||
return _dispatch(
|
return _dispatch(args.get("node"), "transcript", lambda c: c.transcript(last=last),
|
||||||
args.get("node"), "transcript",
|
lambda: pm.transcript(last=last))
|
||||||
lambda c: c.transcript(last=last), lambda: pm.transcript(last=last),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def handle_meet_leave(args: Dict[str, Any], **_kw) -> str:
|
def handle_meet_leave(args: Dict[str, Any], **_kw) -> str:
|
||||||
return _dispatch(
|
return _dispatch(args.get("node"), "stop", lambda c: c.stop(),
|
||||||
args.get("node"), "stop",
|
lambda: pm.stop(reason="agent called meet_leave"))
|
||||||
lambda c: c.stop(), lambda: pm.stop(reason="agent called meet_leave"),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def handle_meet_say(args: Dict[str, Any], **_kw) -> str:
|
def handle_meet_say(args: Dict[str, Any], **_kw) -> str:
|
||||||
|
|||||||
+18
-45
@@ -1,10 +1,7 @@
|
|||||||
"""Shared directory-plugin loader for the ``plugins/<kind>/<name>/`` discovery packages.
|
"""Shared directory-plugin loader for ``plugins/<kind>/<name>/`` discovery packages
|
||||||
|
(cron_providers, context_engine, memory): import ``__init__.py`` by path with siblings
|
||||||
Used by ``plugins.cron_providers`` and ``plugins.context_engine``: import a plugin
|
pre-registered so relative imports work, then extract the provider via ``register(ctx)``
|
||||||
``__init__.py`` by path (with its sibling ``*.py`` pre-registered so relative
|
or an ABC-subclass fallback."""
|
||||||
imports work), then extract the provider instance via ``register(ctx)`` or an
|
|
||||||
ABC-subclass fallback.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -41,11 +38,8 @@ def iter_plugin_dirs(root: Path) -> List[Path]:
|
|||||||
"""Sorted child dirs of *root* that have an ``__init__.py`` (skips ``_``/``.`` names)."""
|
"""Sorted child dirs of *root* that have an ``__init__.py`` (skips ``_``/``.`` names)."""
|
||||||
if not root.is_dir():
|
if not root.is_dir():
|
||||||
return []
|
return []
|
||||||
return [
|
return [child for child in sorted(root.iterdir())
|
||||||
child for child in sorted(root.iterdir())
|
if child.is_dir() and not child.name.startswith(("_", ".")) and (child / "__init__.py").exists()]
|
||||||
if child.is_dir()
|
|
||||||
and not child.name.startswith(("_", "."))
|
|
||||||
and (child / "__init__.py").exists()]
|
|
||||||
|
|
||||||
|
|
||||||
def read_plugin_description(plugin_dir: Path) -> str:
|
def read_plugin_description(plugin_dir: Path) -> str:
|
||||||
@@ -75,10 +69,7 @@ def _new_module(name: str, file: Path, search_locations: Optional[List[str]] = N
|
|||||||
|
|
||||||
def _exec(mod: Any, logger: Optional[logging.Logger] = None) -> bool:
|
def _exec(mod: Any, logger: Optional[logging.Logger] = None) -> bool:
|
||||||
"""Execute a module made by ``_new_module`` (None -> False); False (and debug-log) if it raised.
|
"""Execute a module made by ``_new_module`` (None -> False); False (and debug-log) if it raised.
|
||||||
|
The sys.modules entry stays on failure; callers needing a clean retry pop it themselves."""
|
||||||
The sys.modules entry is left in place on failure; callers that need a clean
|
|
||||||
retry (the main plugin module) pop it themselves.
|
|
||||||
"""
|
|
||||||
if mod is None:
|
if mod is None:
|
||||||
return False
|
return False
|
||||||
try:
|
try:
|
||||||
@@ -90,19 +81,13 @@ def _exec(mod: Any, logger: Optional[logging.Logger] = None) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def load_plugin_module(
|
def load_plugin_module(module_name: str, plugin_dir: Path, *, parents: Tuple[str, ...],
|
||||||
module_name: str,
|
logger: logging.Logger, synthetic_namespace: Optional[str] = None) -> Optional[Any]:
|
||||||
plugin_dir: Path,
|
|
||||||
*,
|
|
||||||
parents: Tuple[str, ...],
|
|
||||||
logger: logging.Logger,
|
|
||||||
synthetic_namespace: Optional[str] = None) -> Optional[Any]:
|
|
||||||
"""Import ``plugin_dir/__init__.py`` as *module_name* (reusing sys.modules when loaded).
|
"""Import ``plugin_dir/__init__.py`` as *module_name* (reusing sys.modules when loaded).
|
||||||
|
|
||||||
Order matters: parent packages first (relative imports need them), then sibling
|
Order matters: parents first (relative imports need them), then siblings as ``module_name.<stem>``
|
||||||
``*.py`` as ``module_name.<stem>`` (so ``from ._x import Y`` resolves), then the
|
(so ``from ._x import Y`` resolves), then the module. Finally child is bound onto parent and
|
||||||
module itself. Finally the child is bound onto its parent and the siblings onto the
|
siblings onto module — the shape normal imports produce, which monkeypatch relies on.
|
||||||
module — the shape normal imports produce, which dotted imports and monkeypatch rely on.
|
|
||||||
"""
|
"""
|
||||||
init_file = plugin_dir / "__init__.py"
|
init_file = plugin_dir / "__init__.py"
|
||||||
if not init_file.exists():
|
if not init_file.exists():
|
||||||
@@ -148,8 +133,8 @@ def load_plugin_module(
|
|||||||
|
|
||||||
|
|
||||||
class NoopPluginContext:
|
class NoopPluginContext:
|
||||||
"""Base for the fake ``register(ctx)`` contexts: every registration is a no-op except
|
"""Base for fake ``register(ctx)`` contexts: every registration is a no-op except the one the
|
||||||
the one the subclass overrides to capture its provider."""
|
subclass overrides to capture its provider."""
|
||||||
|
|
||||||
def _noop(self, *args, **kwargs):
|
def _noop(self, *args, **kwargs):
|
||||||
pass
|
pass
|
||||||
@@ -157,14 +142,8 @@ class NoopPluginContext:
|
|||||||
register_tool = register_hook = register_cli_command = register_memory_provider = _noop
|
register_tool = register_hook = register_cli_command = register_memory_provider = _noop
|
||||||
|
|
||||||
|
|
||||||
def instance_from_module(
|
def instance_from_module(mod: Any, *, collector: Any, collected_attr: str, base_cls: type, name: str,
|
||||||
mod: Any,
|
logger: logging.Logger) -> Optional[Any]:
|
||||||
*,
|
|
||||||
collector: Any,
|
|
||||||
collected_attr: str,
|
|
||||||
base_cls: type,
|
|
||||||
name: str,
|
|
||||||
logger: logging.Logger) -> Optional[Any]:
|
|
||||||
"""Extract the provider instance: ``register(ctx)`` first, then any ``base_cls`` subclass."""
|
"""Extract the provider instance: ``register(ctx)`` first, then any ``base_cls`` subclass."""
|
||||||
if hasattr(mod, "register"):
|
if hasattr(mod, "register"):
|
||||||
try:
|
try:
|
||||||
@@ -185,14 +164,8 @@ def instance_from_module(
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def load_named(
|
def load_named(name: str, plugin_dir: Path, load_from_dir: Callable[[Path], Optional[Any]], *, kind: str,
|
||||||
name: str,
|
noun: str, logger: logging.Logger) -> Optional[Any]:
|
||||||
plugin_dir: Path,
|
|
||||||
load_from_dir: Callable[[Path], Optional[Any]],
|
|
||||||
*,
|
|
||||||
kind: str,
|
|
||||||
noun: str,
|
|
||||||
logger: logging.Logger) -> Optional[Any]:
|
|
||||||
"""Shared body of ``load_<kind>(name)``: load from *plugin_dir*, warn + None on failure."""
|
"""Shared body of ``load_<kind>(name)``: load from *plugin_dir*, warn + None on failure."""
|
||||||
try:
|
try:
|
||||||
instance = load_from_dir(plugin_dir)
|
instance = load_from_dir(plugin_dir)
|
||||||
|
|||||||
+10
-25
@@ -1,17 +1,9 @@
|
|||||||
"""Per-plugin persistent storage convention.
|
"""Per-plugin persistent storage: ``<hermes home>/plugin-data/<name>/``.
|
||||||
|
|
||||||
Plugins must NOT park state inside ``<hermes home>/plugins/<name>/`` — that is the
|
Plugins must NOT park state in ``<hermes home>/plugins/<name>/`` (the install dir, deleted by
|
||||||
install dir, which ``hermes plugins remove`` deletes and ``update`` git-pulls into. The
|
``remove`` and git-pulled by ``update``). Secrets are deliberately NOT part of this convention —
|
||||||
sanctioned root is ``<hermes home>/plugin-data/<name>/``: user-owned, untouched by
|
credential reads go through ``agent.secret_scope`` / ``.env``.
|
||||||
install/update/remove, inspectable in one place. Secrets are deliberately NOT part of
|
Usage: ``plugin_data_dir("my-plugin") / "state.json"``; ``plugin_db("my-plugin")`` → ``data.db``.
|
||||||
this convention — credential reads go through ``agent.secret_scope`` / ``.env``.
|
|
||||||
|
|
||||||
Usage::
|
|
||||||
|
|
||||||
from plugins.plugin_storage import plugin_data_dir, plugin_db
|
|
||||||
|
|
||||||
state_file = plugin_data_dir("my-plugin") / "state.json"
|
|
||||||
conn = plugin_db("my-plugin") # <data dir>/data.db
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -22,8 +14,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
__all__ = ["plugin_data_dir", "plugin_db"]
|
__all__ = ["plugin_data_dir", "plugin_db"]
|
||||||
|
|
||||||
# Mirrors the plugin-name shape `hermes plugins install` accepts; anything else could
|
# Mirrors the plugin-name shape `hermes plugins install` accepts (no separators/traversal).
|
||||||
# escape the data root via separators or traversal.
|
|
||||||
_NAME_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$")
|
_NAME_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$")
|
||||||
|
|
||||||
|
|
||||||
@@ -34,11 +25,8 @@ def _validate_name(name: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def plugin_data_dir(name: str) -> Path:
|
def plugin_data_dir(name: str) -> Path:
|
||||||
"""Return (and create) ``<hermes home>/plugin-data/<name>/``.
|
"""Return (and create) ``<hermes home>/plugin-data/<name>/``; resolves ``get_hermes_home()`` on
|
||||||
|
every call so it follows the active profile — don't cache across profile switches."""
|
||||||
Resolves ``get_hermes_home()`` on every call so it follows the active profile —
|
|
||||||
don't cache the result across profile switches.
|
|
||||||
"""
|
|
||||||
from hermes_constants import get_hermes_home
|
from hermes_constants import get_hermes_home
|
||||||
|
|
||||||
root = get_hermes_home() / "plugin-data" / _validate_name(name)
|
root = get_hermes_home() / "plugin-data" / _validate_name(name)
|
||||||
@@ -47,11 +35,8 @@ def plugin_data_dir(name: str) -> Path:
|
|||||||
|
|
||||||
|
|
||||||
def plugin_db(name: str, filename: str = "data.db") -> sqlite3.Connection:
|
def plugin_db(name: str, filename: str = "data.db") -> sqlite3.Connection:
|
||||||
"""Open ``<data dir>/<filename>`` (created on first use).
|
"""Open ``<data dir>/<filename>``. WAL so a dashboard reader and a tool writer coexist;
|
||||||
|
``check_same_thread=False`` for the threaded FastAPI/tool env — caller owns transactions."""
|
||||||
WAL so a dashboard reader and an agent-tool writer coexist; ``check_same_thread=False``
|
|
||||||
matches the multi-threaded FastAPI/tool environment — the caller owns transaction discipline.
|
|
||||||
"""
|
|
||||||
if Path(filename).name != filename or not filename:
|
if Path(filename).name != filename or not filename:
|
||||||
raise ValueError(f"invalid plugin db filename: {filename!r}")
|
raise ValueError(f"invalid plugin db filename: {filename!r}")
|
||||||
|
|
||||||
|
|||||||
+9
-32
@@ -1,15 +1,8 @@
|
|||||||
"""Shared concurrency helpers for plugin authors.
|
"""Thread-safe lazy singletons for plugin authors (stdlib-only).
|
||||||
|
|
||||||
The common plugin footgun is the lazy process-wide singleton (``if _client is None:
|
The ``if _client is None: _client = Expensive()`` footgun: two threads pass the guard, both
|
||||||
_client = Expensive()``): two threads both pass the guard, both build, and the second
|
build, the second leaks the first's connections. :func:`lazy_singleton` decorates a zero-arg
|
||||||
write leaks the first's connections/threads. Multi-threaded agent sessions (delegated
|
accessor; :class:`SingletonSlot` is the manual slot when the instance depends on an argument.
|
||||||
tool calls, background workers) make this reachable, so use these instead of hand-rolling
|
|
||||||
double-checked locking:
|
|
||||||
|
|
||||||
* :func:`lazy_singleton` — decorator for the zero-arg accessor case.
|
|
||||||
* :class:`SingletonSlot` — manual slot when the instance depends on a config/key argument.
|
|
||||||
|
|
||||||
Both are stdlib-only (``threading``) so any plugin can import them cheaply.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -24,18 +17,9 @@ T = TypeVar("T")
|
|||||||
|
|
||||||
|
|
||||||
class SingletonSlot(Generic[T]):
|
class SingletonSlot(Generic[T]):
|
||||||
"""Thread-safe lazy slot for accessors that take a build argument.
|
"""Thread-safe lazy slot: caches the first successfully-built instance ("first config wins").
|
||||||
|
The factory runs at most once under concurrent first calls; if it raises, nothing is cached
|
||||||
Caches the first successfully-built instance and ignores the argument afterwards
|
and the next call retries. ``_slot.get(lambda: Honcho(**resolve(config)))``."""
|
||||||
("first config wins", the semantics most plugins rely on). The factory runs at most
|
|
||||||
once under concurrent first calls; if it raises, nothing is cached and the next call
|
|
||||||
retries. Example::
|
|
||||||
|
|
||||||
_slot: SingletonSlot[Honcho] = SingletonSlot()
|
|
||||||
|
|
||||||
def get_honcho_client(config=None):
|
|
||||||
return _slot.get(lambda: Honcho(**resolve(config)))
|
|
||||||
"""
|
|
||||||
|
|
||||||
__slots__ = ("_lock", "_value", "_set")
|
__slots__ = ("_lock", "_value", "_set")
|
||||||
|
|
||||||
@@ -68,15 +52,8 @@ class SingletonSlot(Generic[T]):
|
|||||||
|
|
||||||
|
|
||||||
def lazy_singleton(factory: Callable[[], T]) -> Callable[[], T]:
|
def lazy_singleton(factory: Callable[[], T]) -> Callable[[], T]:
|
||||||
"""Wrap a zero-argument factory into a thread-safe lazy singleton accessor.
|
"""Wrap a zero-argument factory into a thread-safe lazy singleton accessor (factory runs once
|
||||||
|
even under concurrent first calls; on raise the next call retries). ``.reset()`` drops it."""
|
||||||
The factory runs exactly once even under concurrent first calls (if it raises, the
|
|
||||||
next call retries). A ``.reset()`` attribute drops the instance for tests/teardown::
|
|
||||||
|
|
||||||
@lazy_singleton
|
|
||||||
def get_client():
|
|
||||||
return ExpensiveClient(load_config())
|
|
||||||
"""
|
|
||||||
slot: SingletonSlot[T] = SingletonSlot()
|
slot: SingletonSlot[T] = SingletonSlot()
|
||||||
|
|
||||||
@functools.wraps(factory)
|
@functools.wraps(factory)
|
||||||
|
|||||||
Reference in New Issue
Block a user