Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license
This commit is contained in:
@@ -0,0 +1 @@
|
||||
# Hermes plugins package
|
||||
@@ -0,0 +1,14 @@
|
||||
"""Browser Use cloud browser plugin — bundled, auto-loaded.
|
||||
|
||||
Mirrors the ``plugins/web/<vendor>/`` layout: ``provider.py`` holds the
|
||||
provider class; ``__init__.py::register`` instantiates and registers it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from plugins.browser.browser_use.provider import BrowserUseBrowserProvider
|
||||
|
||||
|
||||
def register(ctx) -> None:
|
||||
"""Register the Browser Use provider with the plugin context."""
|
||||
ctx.register_browser_provider(BrowserUseBrowserProvider())
|
||||
@@ -0,0 +1,7 @@
|
||||
name: browser-browser-use
|
||||
version: 1.0.0
|
||||
description: "Browser Use (https://browser-use.com) cloud browser backend. Supports both direct BROWSER_USE_API_KEY and the managed Nous tool gateway. Also powers the 'Nous Subscription' UX flow that bills usage to a Nous subscription."
|
||||
author: NousResearch
|
||||
kind: backend
|
||||
provides_browser_providers:
|
||||
- browser-use
|
||||
@@ -0,0 +1,351 @@
|
||||
"""Browser Use cloud browser provider — plugin form.
|
||||
|
||||
Subclasses :class:`agent.browser_provider.BrowserProvider` (the plugin-facing
|
||||
ABC introduced in PR #25214). The legacy in-tree module
|
||||
``tools.browser_providers.browser_use`` was removed in the same PR; this file
|
||||
is now the canonical implementation.
|
||||
|
||||
Browser Use is the only browser backend with dual auth: a direct
|
||||
``BROWSER_USE_API_KEY`` for self-billed users, or the managed Nous tool
|
||||
gateway (which Hermes uses to bill Browser Use sessions to a Nous
|
||||
subscription). The dispatch order — direct API key first, managed gateway
|
||||
second — preserves the pre-migration behaviour in
|
||||
``tools.browser_providers.browser_use.BrowserUseProvider._get_config_or_none``.
|
||||
|
||||
Config keys this provider responds to::
|
||||
|
||||
browser:
|
||||
cloud_provider: "browser-use" # explicit selection
|
||||
tool_gateway:
|
||||
browser: "gateway" # optional: prefer managed gateway
|
||||
# even when BROWSER_USE_API_KEY is set
|
||||
|
||||
Auth env vars (one of)::
|
||||
|
||||
BROWSER_USE_API_KEY=... # https://browser-use.com
|
||||
# OR a managed Nous gateway entry (configured via 'hermes setup')
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
import uuid
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import requests
|
||||
|
||||
from agent.browser_provider import BrowserProvider
|
||||
from agent.secret_scope import get_secret
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Idempotency tracking for managed-mode session creation. The managed Nous
|
||||
# gateway returns 409 "already in progress" on retried POSTs; we forward the
|
||||
# original idempotency key so the gateway can deduplicate. Cleared on
|
||||
# success or terminal failure.
|
||||
_pending_create_keys: Dict[str, str] = {}
|
||||
_pending_create_keys_lock = threading.Lock()
|
||||
|
||||
_BASE_URL = "https://api.browser-use.com/api/v3"
|
||||
_DEFAULT_MANAGED_TIMEOUT_MINUTES = 5
|
||||
_DEFAULT_MANAGED_PROXY_COUNTRY_CODE = "us"
|
||||
|
||||
|
||||
def _get_or_create_pending_create_key(task_id: str) -> str:
|
||||
with _pending_create_keys_lock:
|
||||
existing = _pending_create_keys.get(task_id)
|
||||
if existing:
|
||||
return existing
|
||||
|
||||
created = f"browser-use-session-create:{uuid.uuid4().hex}"
|
||||
_pending_create_keys[task_id] = created
|
||||
return created
|
||||
|
||||
|
||||
def _clear_pending_create_key(task_id: str) -> None:
|
||||
with _pending_create_keys_lock:
|
||||
_pending_create_keys.pop(task_id, None)
|
||||
|
||||
|
||||
def _should_preserve_pending_create_key(response: requests.Response) -> bool:
|
||||
"""Decide whether to keep the idempotency key after a failed create.
|
||||
|
||||
Preserve the key when the failure looks retryable (5xx) OR when the
|
||||
gateway reports the original request is still in flight (409 "already
|
||||
in progress") — in either case, retrying with the same key lets the
|
||||
gateway deduplicate.
|
||||
|
||||
Drop the key on any other 4xx (auth failure, bad request, etc.) — those
|
||||
won't succeed by being retried.
|
||||
"""
|
||||
if response.status_code >= 500:
|
||||
return True
|
||||
|
||||
if response.status_code != 409:
|
||||
return False
|
||||
|
||||
try:
|
||||
payload = response.json()
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
if not isinstance(payload, dict):
|
||||
return False
|
||||
|
||||
error = payload.get("error")
|
||||
if not isinstance(error, dict):
|
||||
return False
|
||||
|
||||
message = str(error.get("message") or "").lower()
|
||||
return "already in progress" in message
|
||||
|
||||
|
||||
class BrowserUseBrowserProvider(BrowserProvider):
|
||||
"""Browser Use (https://browser-use.com) cloud browser backend.
|
||||
|
||||
Dual auth: prefers a direct BROWSER_USE_API_KEY when set, falling back
|
||||
to the managed Nous tool gateway when ``tool_gateway.browser`` config
|
||||
routes through it. Setting ``tool_gateway.browser: gateway`` flips the
|
||||
order so managed billing wins even when BROWSER_USE_API_KEY is present.
|
||||
"""
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "browser-use"
|
||||
|
||||
@property
|
||||
def display_name(self) -> str:
|
||||
return "Browser Use"
|
||||
|
||||
def is_available(self) -> bool:
|
||||
return self._get_config_or_none(refresh_token=False) is not None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Config resolution (direct API key OR managed Nous gateway)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _get_config_or_none(self, *, refresh_token: bool = True) -> Optional[Dict[str, Any]]:
|
||||
# Import here to avoid a hard dependency at module-import time —
|
||||
# managed_tool_gateway pulls in the Nous auth stack which can be
|
||||
# heavy and is not needed for direct-API-key users.
|
||||
from tools.managed_tool_gateway import (
|
||||
peek_nous_access_token,
|
||||
resolve_managed_tool_gateway,
|
||||
)
|
||||
from tools.tool_backend_helpers import (
|
||||
NOUS_MANAGED_PROVIDER,
|
||||
read_selection,
|
||||
)
|
||||
|
||||
def _managed_config() -> Optional[Dict[str, Any]]:
|
||||
# Keep availability scans off the synchronous OAuth refresh path.
|
||||
managed = resolve_managed_tool_gateway(
|
||||
"browser-use",
|
||||
token_reader=None if refresh_token else peek_nous_access_token,
|
||||
)
|
||||
if managed is None:
|
||||
return None
|
||||
return {
|
||||
"api_key": managed.nous_user_token,
|
||||
"base_url": managed.gateway_origin.rstrip("/"),
|
||||
"managed_mode": True,
|
||||
}
|
||||
|
||||
api_key = get_secret("BROWSER_USE_API_KEY")
|
||||
selected = read_selection("browser")
|
||||
|
||||
# Strict selection: "nous" (or legacy use_gateway: true) → managed
|
||||
# gateway ONLY; any other stored browser selection → direct API key
|
||||
# ONLY (no silent managed fallback); never-configured → legacy
|
||||
# behavior (direct key when present, else managed gateway).
|
||||
if selected == NOUS_MANAGED_PROVIDER:
|
||||
return _managed_config()
|
||||
if selected is not None:
|
||||
if api_key:
|
||||
return {
|
||||
"api_key": api_key,
|
||||
"base_url": _BASE_URL,
|
||||
"managed_mode": False,
|
||||
}
|
||||
return None
|
||||
if api_key:
|
||||
return {
|
||||
"api_key": api_key,
|
||||
"base_url": _BASE_URL,
|
||||
"managed_mode": False,
|
||||
}
|
||||
return _managed_config()
|
||||
|
||||
def _get_config(self) -> Dict[str, Any]:
|
||||
from tools.tool_backend_helpers import (
|
||||
NOUS_MANAGED_PROVIDER,
|
||||
managed_nous_tools_enabled,
|
||||
read_selection,
|
||||
selection_error,
|
||||
)
|
||||
|
||||
config = self._get_config_or_none()
|
||||
if config is None:
|
||||
selected = read_selection("browser")
|
||||
if selected == NOUS_MANAGED_PROVIDER:
|
||||
raise ValueError(selection_error(
|
||||
"browser",
|
||||
NOUS_MANAGED_PROVIDER,
|
||||
"the Nous Tool Gateway is not available (not entitled or "
|
||||
"unreachable)",
|
||||
))
|
||||
if selected is not None:
|
||||
raise ValueError(selection_error(
|
||||
"browser",
|
||||
selected,
|
||||
"BROWSER_USE_API_KEY is not set",
|
||||
))
|
||||
message = (
|
||||
"Browser Use requires a direct BROWSER_USE_API_KEY credential."
|
||||
)
|
||||
if managed_nous_tools_enabled():
|
||||
message = (
|
||||
"Browser Use requires either a direct BROWSER_USE_API_KEY "
|
||||
"credential or a managed Browser Use gateway configuration."
|
||||
)
|
||||
raise ValueError(message)
|
||||
return config
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Session lifecycle
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _headers(self, config: Dict[str, Any]) -> Dict[str, str]:
|
||||
return {
|
||||
"Content-Type": "application/json",
|
||||
"X-Browser-Use-API-Key": config["api_key"],
|
||||
}
|
||||
|
||||
def create_session(self, task_id: str) -> Dict[str, object]:
|
||||
config = self._get_config()
|
||||
managed_mode = bool(config.get("managed_mode"))
|
||||
|
||||
headers = self._headers(config)
|
||||
if managed_mode:
|
||||
headers["X-Idempotency-Key"] = _get_or_create_pending_create_key(task_id)
|
||||
|
||||
# Keep gateway-backed sessions short so billing authorization does not
|
||||
# default to a long Browser-Use timeout when Hermes only needs a task-
|
||||
# scoped ephemeral browser.
|
||||
payload = (
|
||||
{
|
||||
"timeout": _DEFAULT_MANAGED_TIMEOUT_MINUTES,
|
||||
"proxyCountryCode": _DEFAULT_MANAGED_PROXY_COUNTRY_CODE,
|
||||
}
|
||||
if managed_mode
|
||||
else {}
|
||||
)
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
f"{config['base_url']}/browsers",
|
||||
headers=headers,
|
||||
json=payload,
|
||||
timeout=30,
|
||||
)
|
||||
except requests.RequestException as exc:
|
||||
# Managed mode: propagate raw so callers can retry with the
|
||||
# preserved idempotency key. Direct mode: wrap network failures
|
||||
# into a clean RuntimeError for end users.
|
||||
if managed_mode:
|
||||
raise
|
||||
raise RuntimeError(
|
||||
f"Browser Use API connection failed: {exc}"
|
||||
) from exc
|
||||
|
||||
if not response.ok:
|
||||
if managed_mode and not _should_preserve_pending_create_key(response):
|
||||
_clear_pending_create_key(task_id)
|
||||
raise RuntimeError(
|
||||
f"Failed to create Browser Use session: "
|
||||
f"{response.status_code} {response.text}"
|
||||
)
|
||||
|
||||
session_data = response.json()
|
||||
if managed_mode:
|
||||
_clear_pending_create_key(task_id)
|
||||
session_name = f"hermes_{task_id}_{uuid.uuid4().hex[:8]}"
|
||||
external_call_id = (
|
||||
response.headers.get("x-external-call-id") if managed_mode else None
|
||||
)
|
||||
|
||||
logger.info("Created Browser Use session %s", session_name)
|
||||
|
||||
cdp_url = session_data.get("cdpUrl") or session_data.get("connectUrl") or ""
|
||||
|
||||
return {
|
||||
"session_name": session_name,
|
||||
"bb_session_id": session_data["id"],
|
||||
"cdp_url": cdp_url,
|
||||
# Browser Use sessions have a fixed server-side lifetime. Preserve
|
||||
# the authority returned by the API so the dispatcher can retire an
|
||||
# expired CDP endpoint instead of reconnecting to it indefinitely.
|
||||
"expires_at": session_data.get("timeoutAt"),
|
||||
"features": {"browser_use": True},
|
||||
"external_call_id": external_call_id,
|
||||
}
|
||||
|
||||
def close_session(self, session_id: str) -> bool:
|
||||
try:
|
||||
config = self._get_config()
|
||||
except ValueError:
|
||||
logger.warning(
|
||||
"Cannot close Browser Use session %s — missing credentials", session_id
|
||||
)
|
||||
return False
|
||||
|
||||
try:
|
||||
response = requests.patch(
|
||||
f"{config['base_url']}/browsers/{session_id}",
|
||||
headers=self._headers(config),
|
||||
json={"action": "stop"},
|
||||
timeout=10,
|
||||
)
|
||||
if response.status_code in {200, 201, 204}:
|
||||
logger.debug("Successfully closed Browser Use session %s", session_id)
|
||||
return True
|
||||
else:
|
||||
logger.warning(
|
||||
"Failed to close Browser Use session %s: HTTP %s - %s",
|
||||
session_id,
|
||||
response.status_code,
|
||||
response.text[:200],
|
||||
)
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error("Exception closing Browser Use session %s: %s", session_id, e)
|
||||
return False
|
||||
|
||||
def emergency_cleanup(self, session_id: str) -> None:
|
||||
config = self._get_config_or_none()
|
||||
if config is None:
|
||||
logger.warning(
|
||||
"Cannot emergency-cleanup Browser Use session %s — missing credentials",
|
||||
session_id,
|
||||
)
|
||||
return
|
||||
try:
|
||||
requests.patch(
|
||||
f"{config['base_url']}/browsers/{session_id}",
|
||||
headers=self._headers(config),
|
||||
json={"action": "stop"},
|
||||
timeout=5,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(
|
||||
"Emergency cleanup failed for Browser Use session %s: %s", session_id, e
|
||||
)
|
||||
|
||||
def get_setup_schema(self) -> Optional[Dict[str, Any]]:
|
||||
# Hidden from the hermes tools picker: the "Browser Use" row now
|
||||
# activates the CLI-based backend (tools/browser_use_cli.py). This
|
||||
# provider stays registered for the Nous gateway path and un-migrated
|
||||
# legacy cloud_provider configs.
|
||||
return None
|
||||
@@ -0,0 +1,15 @@
|
||||
"""Browserbase cloud browser plugin — bundled, auto-loaded.
|
||||
|
||||
Mirrors the ``plugins/web/<vendor>/`` and ``plugins/image_gen/openai/``
|
||||
layout: ``provider.py`` holds the provider class; ``__init__.py::register``
|
||||
instantiates and registers it via the plugin context.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from plugins.browser.browserbase.provider import BrowserbaseBrowserProvider
|
||||
|
||||
|
||||
def register(ctx) -> None:
|
||||
"""Register the Browserbase provider with the plugin context."""
|
||||
ctx.register_browser_provider(BrowserbaseBrowserProvider())
|
||||
@@ -0,0 +1,7 @@
|
||||
name: browser-browserbase
|
||||
version: 1.0.0
|
||||
description: "Browserbase (https://browserbase.com) cloud browser backend. Requires BROWSERBASE_API_KEY + BROWSERBASE_PROJECT_ID. Supports stealth, proxies, and keep-alive sessions; auto-falls-back when paid features are unavailable."
|
||||
author: NousResearch
|
||||
kind: backend
|
||||
provides_browser_providers:
|
||||
- browserbase
|
||||
@@ -0,0 +1,300 @@
|
||||
"""Browserbase cloud browser provider — plugin form.
|
||||
|
||||
Subclasses :class:`agent.browser_provider.BrowserProvider` (the plugin-facing
|
||||
ABC introduced in PR #25214). The legacy in-tree module
|
||||
``tools.browser_providers.browserbase`` was removed in the same PR; this file
|
||||
is now the canonical implementation.
|
||||
|
||||
Browserbase requires direct ``BROWSERBASE_API_KEY`` and ``BROWSERBASE_PROJECT_ID``
|
||||
credentials. Managed Nous gateway support has been removed — the Nous
|
||||
subscription now routes through Browser Use instead (see
|
||||
``plugins/browser/browser_use/``).
|
||||
|
||||
Config keys this provider responds to::
|
||||
|
||||
browser:
|
||||
cloud_provider: "browserbase"
|
||||
|
||||
Auth env vars::
|
||||
|
||||
BROWSERBASE_API_KEY=... # https://browserbase.com
|
||||
BROWSERBASE_PROJECT_ID=...
|
||||
|
||||
Optional feature knobs::
|
||||
|
||||
BROWSERBASE_BASE_URL=... # default https://api.browserbase.com
|
||||
BROWSERBASE_PROXIES=true # default true
|
||||
BROWSERBASE_ADVANCED_STEALTH=false
|
||||
BROWSERBASE_KEEP_ALIVE=true # default true
|
||||
BROWSERBASE_SESSION_TIMEOUT=... (seconds, integer, max 21600 = 6h)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import uuid
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import requests
|
||||
|
||||
from agent.browser_provider import BrowserProvider
|
||||
from agent.secret_scope import get_secret
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BrowserbaseBrowserProvider(BrowserProvider):
|
||||
"""Browserbase (https://browserbase.com) cloud browser backend.
|
||||
|
||||
Direct credentials only — managed-Nous-gateway support lives on the
|
||||
Browser Use provider now.
|
||||
"""
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "browserbase"
|
||||
|
||||
@property
|
||||
def display_name(self) -> str:
|
||||
return "Browserbase"
|
||||
|
||||
def is_available(self) -> bool:
|
||||
return self._get_config_or_none() is not None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Config resolution
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _get_config_or_none(self) -> Optional[Dict[str, Any]]:
|
||||
api_key = get_secret("BROWSERBASE_API_KEY")
|
||||
project_id = get_secret("BROWSERBASE_PROJECT_ID")
|
||||
if api_key and project_id:
|
||||
return {
|
||||
"api_key": api_key,
|
||||
"project_id": project_id,
|
||||
"base_url": os.environ.get(
|
||||
"BROWSERBASE_BASE_URL", "https://api.browserbase.com"
|
||||
).rstrip("/"),
|
||||
}
|
||||
return None
|
||||
|
||||
def _get_config(self) -> Dict[str, Any]:
|
||||
config = self._get_config_or_none()
|
||||
if config is None:
|
||||
raise ValueError(
|
||||
"Browserbase requires BROWSERBASE_API_KEY and BROWSERBASE_PROJECT_ID "
|
||||
"environment variables."
|
||||
)
|
||||
return config
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Session lifecycle
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def create_session(self, task_id: str) -> Dict[str, object]:
|
||||
config = self._get_config()
|
||||
|
||||
# Optional env-var knobs
|
||||
enable_proxies = os.environ.get("BROWSERBASE_PROXIES", "true").lower() != "false"
|
||||
enable_advanced_stealth = (
|
||||
os.environ.get("BROWSERBASE_ADVANCED_STEALTH", "false").lower() == "true"
|
||||
)
|
||||
enable_keep_alive = (
|
||||
os.environ.get("BROWSERBASE_KEEP_ALIVE", "true").lower() != "false"
|
||||
)
|
||||
custom_timeout_ms = os.environ.get("BROWSERBASE_SESSION_TIMEOUT")
|
||||
|
||||
features_enabled = {
|
||||
"basic_stealth": True,
|
||||
"proxies": False,
|
||||
"advanced_stealth": False,
|
||||
"keep_alive": False,
|
||||
"custom_timeout": False,
|
||||
}
|
||||
|
||||
session_config: Dict[str, object] = {"projectId": config["project_id"]}
|
||||
|
||||
if enable_keep_alive:
|
||||
session_config["keepAlive"] = True
|
||||
|
||||
if custom_timeout_ms:
|
||||
try:
|
||||
timeout_val = int(custom_timeout_ms)
|
||||
if timeout_val > 0:
|
||||
session_config["timeout"] = timeout_val
|
||||
except ValueError:
|
||||
logger.warning(
|
||||
"Invalid BROWSERBASE_SESSION_TIMEOUT value: %s", custom_timeout_ms
|
||||
)
|
||||
|
||||
if enable_proxies:
|
||||
session_config["proxies"] = True
|
||||
|
||||
if enable_advanced_stealth:
|
||||
session_config["browserSettings"] = {"advancedStealth": True}
|
||||
|
||||
# --- Create session via API ---
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"X-BB-API-Key": config["api_key"],
|
||||
}
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
f"{config['base_url']}/v1/sessions",
|
||||
headers=headers,
|
||||
json=session_config,
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
proxies_fallback = False
|
||||
keepalive_fallback = False
|
||||
|
||||
# Handle 402 — paid features unavailable
|
||||
if response.status_code == 402:
|
||||
if enable_keep_alive:
|
||||
keepalive_fallback = True
|
||||
logger.warning(
|
||||
"keepAlive may require paid plan (402), retrying without it. "
|
||||
"Sessions may timeout during long operations."
|
||||
)
|
||||
session_config.pop("keepAlive", None)
|
||||
response = requests.post(
|
||||
f"{config['base_url']}/v1/sessions",
|
||||
headers=headers,
|
||||
json=session_config,
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
if response.status_code == 402 and enable_proxies:
|
||||
proxies_fallback = True
|
||||
logger.warning(
|
||||
"Proxies unavailable (402), retrying without proxies. "
|
||||
"Bot detection may be less effective."
|
||||
)
|
||||
session_config.pop("proxies", None)
|
||||
response = requests.post(
|
||||
f"{config['base_url']}/v1/sessions",
|
||||
headers=headers,
|
||||
json=session_config,
|
||||
timeout=30,
|
||||
)
|
||||
except requests.RequestException as exc:
|
||||
raise RuntimeError(
|
||||
f"Browserbase API connection failed: {exc}"
|
||||
) from exc
|
||||
|
||||
if not response.ok:
|
||||
raise RuntimeError(
|
||||
f"Failed to create Browserbase session: "
|
||||
f"{response.status_code} {response.text}"
|
||||
)
|
||||
|
||||
session_data = response.json()
|
||||
session_name = f"hermes_{task_id}_{uuid.uuid4().hex[:8]}"
|
||||
|
||||
if enable_proxies and not proxies_fallback:
|
||||
features_enabled["proxies"] = True
|
||||
if enable_advanced_stealth:
|
||||
features_enabled["advanced_stealth"] = True
|
||||
if enable_keep_alive and not keepalive_fallback:
|
||||
features_enabled["keep_alive"] = True
|
||||
if custom_timeout_ms and "timeout" in session_config:
|
||||
features_enabled["custom_timeout"] = True
|
||||
|
||||
feature_str = ", ".join(k for k, v in features_enabled.items() if v)
|
||||
logger.info(
|
||||
"Created Browserbase session %s with features: %s", session_name, feature_str
|
||||
)
|
||||
|
||||
return {
|
||||
"session_name": session_name,
|
||||
"bb_session_id": session_data["id"],
|
||||
"cdp_url": session_data["connectUrl"],
|
||||
"features": features_enabled,
|
||||
}
|
||||
|
||||
def close_session(self, session_id: str) -> bool:
|
||||
try:
|
||||
config = self._get_config()
|
||||
except ValueError:
|
||||
logger.warning(
|
||||
"Cannot close Browserbase session %s — missing credentials", session_id
|
||||
)
|
||||
return False
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
f"{config['base_url']}/v1/sessions/{session_id}",
|
||||
headers={
|
||||
"X-BB-API-Key": config["api_key"],
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
json={
|
||||
"projectId": config["project_id"],
|
||||
"status": "REQUEST_RELEASE",
|
||||
},
|
||||
timeout=10,
|
||||
)
|
||||
if response.status_code in {200, 201, 204}:
|
||||
logger.debug("Successfully closed Browserbase session %s", session_id)
|
||||
return True
|
||||
else:
|
||||
logger.warning(
|
||||
"Failed to close session %s: HTTP %s - %s",
|
||||
session_id,
|
||||
response.status_code,
|
||||
response.text[:200],
|
||||
)
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error("Exception closing Browserbase session %s: %s", session_id, e)
|
||||
return False
|
||||
|
||||
def emergency_cleanup(self, session_id: str) -> None:
|
||||
config = self._get_config_or_none()
|
||||
if config is None:
|
||||
logger.warning(
|
||||
"Cannot emergency-cleanup Browserbase session %s — missing credentials",
|
||||
session_id,
|
||||
)
|
||||
return
|
||||
try:
|
||||
requests.post(
|
||||
f"{config['base_url']}/v1/sessions/{session_id}",
|
||||
headers={
|
||||
"X-BB-API-Key": config["api_key"],
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
json={
|
||||
"projectId": config["project_id"],
|
||||
"status": "REQUEST_RELEASE",
|
||||
},
|
||||
timeout=5,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(
|
||||
"Emergency cleanup failed for Browserbase session %s: %s", session_id, e
|
||||
)
|
||||
|
||||
def get_setup_schema(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"name": "Browserbase",
|
||||
"badge": "paid",
|
||||
"tag": "Cloud browser with stealth and proxies",
|
||||
"env_vars": [
|
||||
{
|
||||
"key": "BROWSERBASE_API_KEY",
|
||||
"prompt": "Browserbase API key",
|
||||
"url": "https://browserbase.com",
|
||||
},
|
||||
{
|
||||
"key": "BROWSERBASE_PROJECT_ID",
|
||||
"prompt": "Browserbase project ID",
|
||||
},
|
||||
],
|
||||
# Cloud-scoped hook: installs the agent-browser CLI only (no
|
||||
# local Chromium — Browserbase hosts the browser).
|
||||
"post_setup": "browserbase",
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
"""Firecrawl cloud browser plugin — bundled, auto-loaded.
|
||||
|
||||
Distinct from ``plugins/web/firecrawl/`` (the web search/extract/crawl
|
||||
plugin); both share the FIRECRAWL_API_KEY but speak to different endpoints
|
||||
(``/v2/browser`` here vs ``/v2/search`` / ``/v2/scrape`` / ``/v2/crawl``
|
||||
over there).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from plugins.browser.firecrawl.provider import FirecrawlBrowserProvider
|
||||
|
||||
|
||||
def register(ctx) -> None:
|
||||
"""Register the Firecrawl cloud-browser provider with the plugin context."""
|
||||
ctx.register_browser_provider(FirecrawlBrowserProvider())
|
||||
@@ -0,0 +1,7 @@
|
||||
name: browser-firecrawl
|
||||
version: 1.0.0
|
||||
description: "Firecrawl (https://firecrawl.dev) cloud browser backend. Requires FIRECRAWL_API_KEY. Distinct from the firecrawl WEB search/extract plugin — the two share an API key but operate on different endpoints."
|
||||
author: NousResearch
|
||||
kind: backend
|
||||
provides_browser_providers:
|
||||
- firecrawl
|
||||
@@ -0,0 +1,174 @@
|
||||
"""Firecrawl cloud browser provider — plugin form.
|
||||
|
||||
Subclasses :class:`agent.browser_provider.BrowserProvider` (the plugin-facing
|
||||
ABC introduced in PR #25214). The legacy in-tree module
|
||||
``tools.browser_providers.firecrawl`` was removed in the same PR; this file
|
||||
is now the canonical implementation.
|
||||
|
||||
This is the cloud-browser path — distinct from the firecrawl WEB plugin at
|
||||
``plugins/web/firecrawl/`` which handles search/extract/crawl on
|
||||
``/v2/search`` / ``/v2/scrape`` / ``/v2/crawl``. The two plugins share the
|
||||
``FIRECRAWL_API_KEY`` env var but talk to different endpoints (this one
|
||||
hits ``/v2/browser``).
|
||||
|
||||
Config keys this provider responds to::
|
||||
|
||||
browser:
|
||||
cloud_provider: "firecrawl" # explicit selection only — not in the
|
||||
# legacy auto-detect walk
|
||||
|
||||
Auth env vars::
|
||||
|
||||
FIRECRAWL_API_KEY=... # https://firecrawl.dev
|
||||
FIRECRAWL_API_URL=... # optional override (default https://api.firecrawl.dev)
|
||||
FIRECRAWL_BROWSER_TTL=... # optional, default 300 seconds
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import uuid
|
||||
from typing import Any, Dict
|
||||
|
||||
import requests
|
||||
|
||||
from agent.browser_provider import BrowserProvider
|
||||
from agent.secret_scope import get_secret
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_BASE_URL = "https://api.firecrawl.dev"
|
||||
|
||||
|
||||
class FirecrawlBrowserProvider(BrowserProvider):
|
||||
"""Firecrawl (https://firecrawl.dev) cloud browser backend.
|
||||
|
||||
Cloud-browser path only — search/extract/crawl live in the separate
|
||||
``plugins/web/firecrawl/`` plugin.
|
||||
"""
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "firecrawl"
|
||||
|
||||
@property
|
||||
def display_name(self) -> str:
|
||||
return "Firecrawl"
|
||||
|
||||
def is_available(self) -> bool:
|
||||
return bool(get_secret("FIRECRAWL_API_KEY"))
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Session lifecycle
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _api_url(self) -> str:
|
||||
return os.environ.get("FIRECRAWL_API_URL", _BASE_URL)
|
||||
|
||||
def _headers(self) -> Dict[str, str]:
|
||||
api_key = get_secret("FIRECRAWL_API_KEY")
|
||||
if not api_key:
|
||||
raise ValueError(
|
||||
"FIRECRAWL_API_KEY environment variable is required. "
|
||||
"Get your key at https://firecrawl.dev"
|
||||
)
|
||||
return {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
}
|
||||
|
||||
def create_session(self, task_id: str) -> Dict[str, object]:
|
||||
try:
|
||||
ttl = int(os.environ.get("FIRECRAWL_BROWSER_TTL", "300"))
|
||||
except (ValueError, TypeError):
|
||||
ttl = 300
|
||||
|
||||
body: Dict[str, object] = {"ttl": ttl}
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
f"{self._api_url()}/v2/browser",
|
||||
headers=self._headers(),
|
||||
json=body,
|
||||
timeout=30,
|
||||
)
|
||||
except requests.RequestException as exc:
|
||||
raise RuntimeError(
|
||||
f"Firecrawl API connection failed: {exc}"
|
||||
) from exc
|
||||
|
||||
if not response.ok:
|
||||
raise RuntimeError(
|
||||
f"Failed to create Firecrawl browser session: "
|
||||
f"{response.status_code} {response.text}"
|
||||
)
|
||||
|
||||
data = response.json()
|
||||
session_name = f"hermes_{task_id}_{uuid.uuid4().hex[:8]}"
|
||||
|
||||
logger.info("Created Firecrawl browser session %s", session_name)
|
||||
|
||||
return {
|
||||
"session_name": session_name,
|
||||
"bb_session_id": data["id"],
|
||||
"cdp_url": data["cdpUrl"],
|
||||
"features": {"firecrawl": True},
|
||||
}
|
||||
|
||||
def close_session(self, session_id: str) -> bool:
|
||||
try:
|
||||
response = requests.delete(
|
||||
f"{self._api_url()}/v2/browser/{session_id}",
|
||||
headers=self._headers(),
|
||||
timeout=10,
|
||||
)
|
||||
if response.status_code in {200, 201, 204}:
|
||||
logger.debug("Successfully closed Firecrawl session %s", session_id)
|
||||
return True
|
||||
else:
|
||||
logger.warning(
|
||||
"Failed to close Firecrawl session %s: HTTP %s - %s",
|
||||
session_id,
|
||||
response.status_code,
|
||||
response.text[:200],
|
||||
)
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error("Exception closing Firecrawl session %s: %s", session_id, e)
|
||||
return False
|
||||
|
||||
def emergency_cleanup(self, session_id: str) -> None:
|
||||
if not self.is_available():
|
||||
logger.warning(
|
||||
"Cannot emergency-cleanup Firecrawl session %s — missing credentials",
|
||||
session_id,
|
||||
)
|
||||
return
|
||||
try:
|
||||
requests.delete(
|
||||
f"{self._api_url()}/v2/browser/{session_id}",
|
||||
headers=self._headers(),
|
||||
timeout=5,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(
|
||||
"Emergency cleanup failed for Firecrawl session %s: %s", session_id, e
|
||||
)
|
||||
|
||||
def get_setup_schema(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"name": "Firecrawl",
|
||||
"badge": "paid",
|
||||
"tag": "Cloud browser with remote execution",
|
||||
"env_vars": [
|
||||
{
|
||||
"key": "FIRECRAWL_API_KEY",
|
||||
"prompt": "Firecrawl API key",
|
||||
"url": "https://firecrawl.dev",
|
||||
},
|
||||
],
|
||||
# Cloud-scoped hook: installs the agent-browser CLI only (no
|
||||
# local Chromium — Firecrawl hosts the browser).
|
||||
"post_setup": "browserbase",
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
"""Context engine plugin discovery.
|
||||
|
||||
Scans ``plugins/context_engine/<name>/`` directories for context engine
|
||||
plugins. Each subdirectory must contain ``__init__.py`` with a class
|
||||
implementing the ContextEngine ABC.
|
||||
|
||||
Context engines are separate from the general plugin system — they live
|
||||
in the repo and are always available without user installation. Only ONE
|
||||
can be active at a time, selected via ``context.engine`` in config.yaml.
|
||||
The default engine is ``"compressor"`` (the built-in ContextCompressor).
|
||||
|
||||
Usage:
|
||||
from plugins.context_engine import discover_context_engines, load_context_engine
|
||||
|
||||
available = discover_context_engines() # [(name, desc, available), ...]
|
||||
engine = load_context_engine("lcm") # ContextEngine instance
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import importlib.util
|
||||
import logging
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_CONTEXT_ENGINE_PLUGINS_DIR = Path(__file__).parent
|
||||
|
||||
|
||||
def discover_context_engines() -> List[Tuple[str, str, bool]]:
|
||||
"""Scan plugins/context_engine/ for available engines.
|
||||
|
||||
Returns list of (name, description, is_available) tuples.
|
||||
Does NOT import the engines — just reads plugin.yaml for metadata
|
||||
and does a lightweight availability check.
|
||||
"""
|
||||
results = []
|
||||
if not _CONTEXT_ENGINE_PLUGINS_DIR.is_dir():
|
||||
return results
|
||||
|
||||
for child in sorted(_CONTEXT_ENGINE_PLUGINS_DIR.iterdir()):
|
||||
if not child.is_dir() or child.name.startswith(("_", ".")):
|
||||
continue
|
||||
init_file = child / "__init__.py"
|
||||
if not init_file.exists():
|
||||
continue
|
||||
|
||||
# Read description from plugin.yaml if available
|
||||
desc = ""
|
||||
yaml_file = child / "plugin.yaml"
|
||||
if yaml_file.exists():
|
||||
try:
|
||||
import yaml
|
||||
with open(yaml_file, encoding="utf-8-sig") as f:
|
||||
meta = yaml.safe_load(f) or {}
|
||||
desc = meta.get("description", "")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Quick availability check — try loading and calling is_available()
|
||||
available = True
|
||||
try:
|
||||
engine = _load_engine_from_dir(child)
|
||||
if engine is None:
|
||||
available = False
|
||||
elif hasattr(engine, "is_available"):
|
||||
available = engine.is_available()
|
||||
except Exception:
|
||||
available = False
|
||||
|
||||
results.append((child.name, desc, available))
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def load_context_engine(name: str) -> Optional["ContextEngine"]:
|
||||
"""Load and return a ContextEngine instance by name.
|
||||
|
||||
Returns None if the engine is not found or fails to load.
|
||||
"""
|
||||
engine_dir = _CONTEXT_ENGINE_PLUGINS_DIR / name
|
||||
if not engine_dir.is_dir():
|
||||
logger.debug("Context engine '%s' not found in %s", name, _CONTEXT_ENGINE_PLUGINS_DIR)
|
||||
return None
|
||||
|
||||
try:
|
||||
engine = _load_engine_from_dir(engine_dir)
|
||||
if engine:
|
||||
return engine
|
||||
logger.warning("Context engine '%s' loaded but no engine instance found", name)
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.warning("Failed to load context engine '%s': %s", name, e)
|
||||
return None
|
||||
|
||||
|
||||
def _load_engine_from_dir(engine_dir: Path) -> Optional["ContextEngine"]:
|
||||
"""Import an engine module and extract the ContextEngine instance.
|
||||
|
||||
The module must have either:
|
||||
- A register(ctx) function (plugin-style) — we simulate a ctx
|
||||
- A top-level class that extends ContextEngine — we instantiate it
|
||||
"""
|
||||
name = engine_dir.name
|
||||
module_name = f"plugins.context_engine.{name}"
|
||||
init_file = engine_dir / "__init__.py"
|
||||
|
||||
if not init_file.exists():
|
||||
return None
|
||||
|
||||
# Check if already loaded
|
||||
if module_name in sys.modules:
|
||||
mod = sys.modules[module_name]
|
||||
else:
|
||||
# Handle relative imports within the plugin
|
||||
# First ensure the parent packages are registered
|
||||
for parent in ("plugins", "plugins.context_engine"):
|
||||
if parent not in sys.modules:
|
||||
parent_path = Path(__file__).parent
|
||||
if parent == "plugins":
|
||||
parent_path = parent_path.parent
|
||||
parent_init = parent_path / "__init__.py"
|
||||
if parent_init.exists():
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
parent, str(parent_init),
|
||||
submodule_search_locations=[str(parent_path)]
|
||||
)
|
||||
if spec:
|
||||
parent_mod = importlib.util.module_from_spec(spec)
|
||||
sys.modules[parent] = parent_mod
|
||||
try:
|
||||
spec.loader.exec_module(parent_mod)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Now load the engine module
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
module_name, str(init_file),
|
||||
submodule_search_locations=[str(engine_dir)]
|
||||
)
|
||||
if not spec:
|
||||
return None
|
||||
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
sys.modules[module_name] = mod
|
||||
|
||||
# Register submodules so relative imports work
|
||||
for sub_file in engine_dir.glob("*.py"):
|
||||
if sub_file.name == "__init__.py":
|
||||
continue
|
||||
sub_name = sub_file.stem
|
||||
full_sub_name = f"{module_name}.{sub_name}"
|
||||
if full_sub_name not in sys.modules:
|
||||
sub_spec = importlib.util.spec_from_file_location(
|
||||
full_sub_name, str(sub_file)
|
||||
)
|
||||
if sub_spec:
|
||||
sub_mod = importlib.util.module_from_spec(sub_spec)
|
||||
sys.modules[full_sub_name] = sub_mod
|
||||
try:
|
||||
sub_spec.loader.exec_module(sub_mod)
|
||||
except Exception as e:
|
||||
logger.debug("Failed to load submodule %s: %s", full_sub_name, e)
|
||||
|
||||
try:
|
||||
spec.loader.exec_module(mod)
|
||||
except Exception as e:
|
||||
logger.debug("Failed to exec_module %s: %s", module_name, e)
|
||||
sys.modules.pop(module_name, None)
|
||||
return None
|
||||
|
||||
# Try register(ctx) pattern first (how plugins are written)
|
||||
if hasattr(mod, "register"):
|
||||
collector = _EngineCollector(engine_name=name)
|
||||
try:
|
||||
mod.register(collector)
|
||||
if collector.engine:
|
||||
return collector.engine
|
||||
except Exception as e:
|
||||
logger.debug("register() failed for %s: %s", name, e)
|
||||
|
||||
# Fallback: find a ContextEngine subclass and instantiate it
|
||||
from agent.context_engine import ContextEngine
|
||||
for attr_name in dir(mod):
|
||||
attr = getattr(mod, attr_name, None)
|
||||
if (isinstance(attr, type) and issubclass(attr, ContextEngine)
|
||||
and attr is not ContextEngine):
|
||||
try:
|
||||
return attr()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
|
||||
class _EngineCollector:
|
||||
"""Fake plugin context that captures register_context_engine calls.
|
||||
|
||||
Plugin context engines using the standard ``register(ctx)`` pattern may
|
||||
also call ``ctx.register_command(...)`` to expose slash commands (e.g.
|
||||
``/lcm``). Forward those to the global plugin command registry so they
|
||||
behave identically to commands registered by normal plugins.
|
||||
"""
|
||||
|
||||
def __init__(self, engine_name: str = ""):
|
||||
self.engine = None
|
||||
self._engine_name = engine_name or "context_engine"
|
||||
self._registered_commands: list[str] = []
|
||||
|
||||
def register_context_engine(self, engine):
|
||||
self.engine = engine
|
||||
|
||||
def register_command(
|
||||
self,
|
||||
name: str,
|
||||
handler,
|
||||
description: str = "",
|
||||
args_hint: str = "",
|
||||
) -> None:
|
||||
"""Forward to the global plugin command registry."""
|
||||
clean = (name or "").lower().strip().lstrip("/").replace(" ", "-")
|
||||
if not clean:
|
||||
logger.warning(
|
||||
"Context engine '%s' tried to register a command with an empty name.",
|
||||
self._engine_name,
|
||||
)
|
||||
return
|
||||
|
||||
# Reject conflicts with built-in commands.
|
||||
try:
|
||||
from hermes_cli.commands import resolve_command
|
||||
if resolve_command(clean) is not None:
|
||||
logger.warning(
|
||||
"Context engine '%s' tried to register command '/%s' which conflicts "
|
||||
"with a built-in command. Skipping.",
|
||||
self._engine_name, clean,
|
||||
)
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
from hermes_cli.plugins import get_plugin_manager
|
||||
manager = get_plugin_manager()
|
||||
if clean in manager._plugin_commands:
|
||||
# Don't clobber a regular plugin's command — same conflict
|
||||
# policy the plugin system uses for plugin-vs-plugin collisions.
|
||||
logger.warning(
|
||||
"Context engine '%s' tried to register command '/%s' which "
|
||||
"is already registered by a plugin. Skipping.",
|
||||
self._engine_name, clean,
|
||||
)
|
||||
return
|
||||
manager._plugin_commands[clean] = {
|
||||
"handler": handler,
|
||||
"description": description or "Context engine command",
|
||||
"plugin": f"context-engine:{self._engine_name}",
|
||||
"args_hint": (args_hint or "").strip(),
|
||||
}
|
||||
self._registered_commands.append(clean)
|
||||
logger.debug(
|
||||
"Context engine '%s' registered command: /%s",
|
||||
self._engine_name, clean,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.debug(
|
||||
"Context engine '%s' could not register /%s: %s",
|
||||
self._engine_name, clean, exc,
|
||||
)
|
||||
|
||||
# No-op for other registration methods
|
||||
def register_tool(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
def register_hook(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
def register_cli_command(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
def register_memory_provider(self, *args, **kwargs):
|
||||
pass
|
||||
@@ -0,0 +1,356 @@
|
||||
"""Cron scheduler provider plugin discovery.
|
||||
|
||||
Scans two directories for cron scheduler provider plugins:
|
||||
|
||||
1. Bundled providers: ``plugins/cron_providers/<name>/`` (shipped with hermes-agent)
|
||||
2. User-installed providers: ``$HERMES_HOME/plugins/<name>/``
|
||||
|
||||
Each subdirectory must contain ``__init__.py`` with a class implementing the
|
||||
``CronScheduler`` ABC (``cron/scheduler_provider.py``). On name collisions,
|
||||
bundled providers take precedence.
|
||||
|
||||
This is a near-verbatim clone of ``plugins/memory/__init__.py`` — the same
|
||||
discovery/loader machinery, retargeted at ``CronScheduler``. The built-in
|
||||
``InProcessCronScheduler`` is NOT discovered here: it is core (lives in
|
||||
``cron/scheduler_provider.py``) so the fallback can never be accidentally
|
||||
removed. Only NON-default providers (e.g. "chronos") live under this directory.
|
||||
|
||||
Only ONE provider can be active at a time, selected via ``cron.provider`` in
|
||||
config.yaml (empty = built-in). See ``cron.scheduler_provider.resolve_cron_scheduler``.
|
||||
|
||||
Usage:
|
||||
from plugins.cron_providers import discover_cron_schedulers, load_cron_scheduler
|
||||
|
||||
available = discover_cron_schedulers() # [(name, desc, available), ...]
|
||||
provider = load_cron_scheduler("chronos") # CronScheduler instance
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import importlib.machinery
|
||||
import importlib.util
|
||||
import logging
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_CRON_PLUGINS_DIR = Path(__file__).parent
|
||||
|
||||
# Synthetic parent package for user-installed providers, so they don't
|
||||
# collide with bundled providers in sys.modules.
|
||||
_USER_NAMESPACE = "_hermes_user_cron"
|
||||
|
||||
|
||||
def _register_synthetic_package(name: str, search_locations: List[str]) -> None:
|
||||
"""Register an empty package shell in sys.modules.
|
||||
|
||||
User-installed providers import as ``_hermes_user_cron.<name>``, a dotted
|
||||
name whose parents exist nowhere on disk. Unless those parents are present
|
||||
in ``sys.modules``, any relative import inside the plugin
|
||||
(``from . import config``) fails with
|
||||
``ModuleNotFoundError: No module named '_hermes_user_cron'`` — the same
|
||||
reason the loader already registers ``plugins`` and ``plugins.cron_providers`` for
|
||||
bundled providers.
|
||||
"""
|
||||
if name in sys.modules:
|
||||
return
|
||||
spec = importlib.machinery.ModuleSpec(name, None, is_package=True)
|
||||
spec.submodule_search_locations = search_locations
|
||||
sys.modules[name] = importlib.util.module_from_spec(spec)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Directory helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _get_user_plugins_dir() -> Optional[Path]:
|
||||
"""Return ``$HERMES_HOME/plugins/`` or None if unavailable."""
|
||||
try:
|
||||
from hermes_constants import get_hermes_home
|
||||
d = get_hermes_home() / "plugins"
|
||||
return d if d.is_dir() else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _is_cron_provider_dir(path: Path) -> bool:
|
||||
"""Heuristic: does *path* look like a cron scheduler provider plugin?
|
||||
|
||||
Checks for ``register_cron_scheduler`` or ``CronScheduler`` in the
|
||||
``__init__.py`` source. Cheap text scan — no import needed.
|
||||
"""
|
||||
init_file = path / "__init__.py"
|
||||
if not init_file.exists():
|
||||
return False
|
||||
try:
|
||||
source = init_file.read_text(errors="replace", encoding="utf-8")[:8192]
|
||||
return "register_cron_scheduler" in source or "CronScheduler" in source
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _iter_provider_dirs() -> List[Tuple[str, Path]]:
|
||||
"""Yield ``(name, path)`` for all discovered provider directories.
|
||||
|
||||
Scans bundled first, then user-installed. Bundled takes precedence on
|
||||
name collisions (first-seen wins via ``seen`` set).
|
||||
"""
|
||||
seen: set = set()
|
||||
dirs: List[Tuple[str, Path]] = []
|
||||
|
||||
# 1. Bundled providers (plugins/cron_providers/<name>/)
|
||||
if _CRON_PLUGINS_DIR.is_dir():
|
||||
for child in sorted(_CRON_PLUGINS_DIR.iterdir()):
|
||||
if not child.is_dir() or child.name.startswith(("_", ".")):
|
||||
continue
|
||||
if not (child / "__init__.py").exists():
|
||||
continue
|
||||
seen.add(child.name)
|
||||
dirs.append((child.name, child))
|
||||
|
||||
# 2. User-installed providers ($HERMES_HOME/plugins/<name>/)
|
||||
user_dir = _get_user_plugins_dir()
|
||||
if user_dir:
|
||||
for child in sorted(user_dir.iterdir()):
|
||||
if not child.is_dir() or child.name.startswith(("_", ".")):
|
||||
continue
|
||||
if child.name in seen:
|
||||
continue # bundled takes precedence
|
||||
if not _is_cron_provider_dir(child):
|
||||
continue # skip non-cron plugins
|
||||
dirs.append((child.name, child))
|
||||
|
||||
return dirs
|
||||
|
||||
|
||||
def find_provider_dir(name: str) -> Optional[Path]:
|
||||
"""Resolve a provider name to its directory.
|
||||
|
||||
Checks bundled first, then user-installed.
|
||||
"""
|
||||
# Bundled
|
||||
bundled = _CRON_PLUGINS_DIR / name
|
||||
if bundled.is_dir() and (bundled / "__init__.py").exists():
|
||||
return bundled
|
||||
# User-installed
|
||||
user_dir = _get_user_plugins_dir()
|
||||
if user_dir:
|
||||
user = user_dir / name
|
||||
if user.is_dir() and _is_cron_provider_dir(user):
|
||||
return user
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def discover_cron_schedulers() -> List[Tuple[str, str, bool]]:
|
||||
"""Scan bundled and user-installed directories for available providers.
|
||||
|
||||
Returns list of (name, description, is_available) tuples. May be empty —
|
||||
the built-in is core, not discovered here, so a fresh checkout with no
|
||||
bundled non-default provider returns []. Bundled providers take precedence
|
||||
on name collisions.
|
||||
"""
|
||||
results = []
|
||||
|
||||
for name, child in _iter_provider_dirs():
|
||||
# Read description from plugin.yaml if available
|
||||
desc = ""
|
||||
yaml_file = child / "plugin.yaml"
|
||||
if yaml_file.exists():
|
||||
try:
|
||||
import yaml
|
||||
with open(yaml_file, encoding="utf-8-sig") as f:
|
||||
meta = yaml.safe_load(f) or {}
|
||||
desc = meta.get("description", "")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Quick availability check — try loading and calling is_available()
|
||||
available = True
|
||||
try:
|
||||
provider = _load_provider_from_dir(child)
|
||||
if provider:
|
||||
available = provider.is_available()
|
||||
else:
|
||||
available = False
|
||||
except Exception:
|
||||
available = False
|
||||
|
||||
results.append((name, desc, available))
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def load_cron_scheduler(name: str) -> Optional["CronScheduler"]: # noqa: F821
|
||||
"""Load and return a CronScheduler instance by name.
|
||||
|
||||
Checks both bundled (``plugins/cron_providers/<name>/``) and user-installed
|
||||
(``$HERMES_HOME/plugins/<name>/``) directories. Bundled takes precedence
|
||||
on name collisions.
|
||||
|
||||
Returns None if the provider is not found or fails to load.
|
||||
"""
|
||||
provider_dir = find_provider_dir(name)
|
||||
if not provider_dir:
|
||||
logger.debug("Cron provider '%s' not found in bundled or user plugins", name)
|
||||
return None
|
||||
|
||||
try:
|
||||
provider = _load_provider_from_dir(provider_dir)
|
||||
if provider:
|
||||
return provider
|
||||
logger.warning("Cron provider '%s' loaded but no provider instance found", name)
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.warning("Failed to load cron provider '%s': %s", name, e)
|
||||
return None
|
||||
|
||||
|
||||
def _load_provider_from_dir(provider_dir: Path) -> Optional["CronScheduler"]: # noqa: F821
|
||||
"""Import a provider module and extract the CronScheduler instance.
|
||||
|
||||
The module must have either:
|
||||
- A register(ctx) function (plugin-style) — we simulate a ctx
|
||||
- A top-level class that extends CronScheduler — we instantiate it
|
||||
"""
|
||||
name = provider_dir.name
|
||||
# Use a separate namespace for user-installed plugins so they don't
|
||||
# collide with bundled providers in sys.modules.
|
||||
_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}"
|
||||
init_file = provider_dir / "__init__.py"
|
||||
|
||||
if not init_file.exists():
|
||||
return None
|
||||
|
||||
# Check if already loaded. A synthetic package shell has no __file__;
|
||||
# only reuse modules that were actually loaded from disk.
|
||||
cached = sys.modules.get(module_name)
|
||||
if cached is not None and getattr(cached, "__file__", None):
|
||||
mod = cached
|
||||
else:
|
||||
# Ensure the parent packages are registered (for relative imports)
|
||||
for parent in ("plugins", "plugins.cron_providers"):
|
||||
if parent not in sys.modules:
|
||||
parent_path = Path(__file__).parent
|
||||
if parent == "plugins":
|
||||
parent_path = parent_path.parent
|
||||
parent_init = parent_path / "__init__.py"
|
||||
if parent_init.exists():
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
parent, str(parent_init),
|
||||
submodule_search_locations=[str(parent_path)]
|
||||
)
|
||||
if spec:
|
||||
parent_mod = importlib.util.module_from_spec(spec)
|
||||
sys.modules[parent] = parent_mod
|
||||
try:
|
||||
spec.loader.exec_module(parent_mod)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# User-installed plugins need their synthetic parent registered the
|
||||
# same way, or relative imports inside the plugin cannot resolve.
|
||||
if not _is_bundled:
|
||||
_register_synthetic_package(_USER_NAMESPACE, [])
|
||||
|
||||
# Now load the provider module
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
module_name, str(init_file),
|
||||
submodule_search_locations=[str(provider_dir)]
|
||||
)
|
||||
if not spec:
|
||||
return None
|
||||
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
sys.modules[module_name] = mod
|
||||
loaded_submodules = []
|
||||
|
||||
# Register submodules so relative imports work
|
||||
# e.g., "from ._nas_client import NasCronClient" in the chronos plugin
|
||||
for sub_file in provider_dir.glob("*.py"):
|
||||
if sub_file.name == "__init__.py":
|
||||
continue
|
||||
sub_name = sub_file.stem
|
||||
full_sub_name = f"{module_name}.{sub_name}"
|
||||
if full_sub_name not in sys.modules:
|
||||
sub_spec = importlib.util.spec_from_file_location(
|
||||
full_sub_name, str(sub_file)
|
||||
)
|
||||
if sub_spec:
|
||||
sub_mod = importlib.util.module_from_spec(sub_spec)
|
||||
sys.modules[full_sub_name] = sub_mod
|
||||
try:
|
||||
sub_spec.loader.exec_module(sub_mod)
|
||||
loaded_submodules.append((sub_name, sub_mod))
|
||||
except Exception as e:
|
||||
logger.debug("Failed to load submodule %s: %s", full_sub_name, e)
|
||||
|
||||
try:
|
||||
spec.loader.exec_module(mod)
|
||||
except Exception as e:
|
||||
logger.debug("Failed to exec_module %s: %s", module_name, e)
|
||||
sys.modules.pop(module_name, None)
|
||||
return None
|
||||
|
||||
# Manual importlib loading bypasses the normal import machinery that
|
||||
# binds child modules onto their parent packages. Restore that shape so
|
||||
# later dotted imports and pytest monkeypatch paths resolve normally.
|
||||
parent_name, child_name = module_name.rsplit(".", 1)
|
||||
parent_mod = sys.modules.get(parent_name)
|
||||
if parent_mod is not None:
|
||||
setattr(parent_mod, child_name, mod)
|
||||
for sub_name, sub_mod in loaded_submodules:
|
||||
setattr(mod, sub_name, sub_mod)
|
||||
|
||||
# Try register(ctx) pattern first (how our plugins are written)
|
||||
if hasattr(mod, "register"):
|
||||
collector = _ProviderCollector()
|
||||
try:
|
||||
mod.register(collector)
|
||||
if collector.provider:
|
||||
return collector.provider
|
||||
except Exception as e:
|
||||
logger.debug("register() failed for %s: %s", name, e)
|
||||
|
||||
# Fallback: find a CronScheduler subclass and instantiate it
|
||||
from cron.scheduler_provider import CronScheduler
|
||||
for attr_name in dir(mod):
|
||||
attr = getattr(mod, attr_name, None)
|
||||
if (isinstance(attr, type) and issubclass(attr, CronScheduler)
|
||||
and attr is not CronScheduler):
|
||||
try:
|
||||
return attr()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
|
||||
class _ProviderCollector:
|
||||
"""Fake plugin context that captures register_cron_scheduler calls."""
|
||||
|
||||
def __init__(self):
|
||||
self.provider = None
|
||||
|
||||
def register_cron_scheduler(self, provider):
|
||||
self.provider = provider
|
||||
|
||||
# No-op for other registration methods
|
||||
def register_tool(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
def register_hook(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
def register_memory_provider(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
def register_cli_command(self, *args, **kwargs):
|
||||
pass
|
||||
@@ -0,0 +1,267 @@
|
||||
"""Chronos — NAS-mediated managed cron provider (scale-to-zero).
|
||||
|
||||
Chronos (the Greek god of time, alongside Hermes) is the first non-default
|
||||
``CronScheduler``. It lets a hosted gateway scale to zero while idle and still
|
||||
fire cron jobs: instead of a 60s in-process ticker, it asks NAS to arm exactly
|
||||
one external one-shot per job at that job's real next-fire time. NAS calls the
|
||||
agent back at fire time over an authenticated webhook (``/api/cron/fire``); the
|
||||
agent runs the job via the shared ``run_one_job`` body and re-arms the next
|
||||
one-shot.
|
||||
|
||||
The external scheduler NAS uses is an internal NAS implementation detail —
|
||||
Chronos names no vendor, holds no scheduler credentials, and speaks only to
|
||||
NAS's ``agent-cron`` endpoints with the agent's existing Nous token.
|
||||
|
||||
Design constraints (see the plan's DQ-1):
|
||||
- start() arms all enabled jobs and RETURNS; it never blocks and never spawns
|
||||
a periodic wake. Between fires the machine is truly at zero.
|
||||
- reconcile runs only on a warm process (start / on_jobs_changed / piggybacked
|
||||
on a fire), never as a periodic wake of a sleeping machine.
|
||||
|
||||
Inert unless ``cron.provider: chronos``. ``resolve_cron_scheduler`` falls back
|
||||
to the built-in if Chronos is unavailable, so cron never loses its trigger.
|
||||
|
||||
Wire contract: ``docs/chronos-managed-cron-contract.md``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from cron.scheduler_provider import CronScheduler
|
||||
|
||||
logger = logging.getLogger("cron.chronos")
|
||||
|
||||
|
||||
def _cfg(*keys: str, default: Any = "") -> Any:
|
||||
"""Read a cron.chronos.* config value (no network)."""
|
||||
try:
|
||||
from hermes_cli.config import cfg_get, load_config
|
||||
return cfg_get(load_config(), *keys, default=default)
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
|
||||
class ChronosCronScheduler(CronScheduler):
|
||||
"""NAS-mediated external cron provider."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
# In-memory map of job_id → fire_at we've asked NAS to arm. Best-effort
|
||||
# cache; reconcile rebuilds desired state from jobs.json, so a cold
|
||||
# process simply re-arms (idempotent via dedup_key).
|
||||
self._armed: Dict[str, str] = {}
|
||||
self._lock = threading.Lock()
|
||||
self._client = None # lazily constructed (no network in is_available)
|
||||
|
||||
# -- identity / availability -----------------------------------------
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "chronos"
|
||||
|
||||
def is_available(self) -> bool:
|
||||
"""Config presence only — NO network.
|
||||
|
||||
Chronos needs a portal base URL, the agent's own publicly-reachable
|
||||
callback URL (for NAS→agent fires), and a usable Nous token (the agent
|
||||
is logged into the portal). If any is missing, resolve_cron_scheduler
|
||||
falls back to the built-in ticker.
|
||||
"""
|
||||
if not (_cfg("cron", "chronos", "portal_url") and _cfg("cron", "chronos", "callback_url")):
|
||||
return False
|
||||
return self._have_nous_token()
|
||||
|
||||
def _have_nous_token(self) -> bool:
|
||||
"""True if the agent has a Nous Portal login (no network call).
|
||||
|
||||
Checks the stored auth state for a Nous access token — does NOT refresh
|
||||
or hit the network (is_available must stay offline). The actual
|
||||
refresh-aware token is resolved lazily at provision time.
|
||||
"""
|
||||
try:
|
||||
from hermes_cli.auth import get_provider_auth_state
|
||||
state = get_provider_auth_state("nous") or {}
|
||||
return bool(state.get("access_token"))
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
# -- client -----------------------------------------------------------
|
||||
|
||||
def _get_client(self):
|
||||
if self._client is None:
|
||||
from ._nas_client import NasCronClient
|
||||
self._client = NasCronClient(_cfg("cron", "chronos", "portal_url"))
|
||||
return self._client
|
||||
|
||||
def _callback_url(self) -> str:
|
||||
return str(_cfg("cron", "chronos", "callback_url") or "")
|
||||
|
||||
# -- lifecycle --------------------------------------------------------
|
||||
|
||||
def start(self, stop_event, *, adapters=None, loop=None, interval=60):
|
||||
"""Arm all enabled jobs via NAS, then RETURN immediately.
|
||||
|
||||
Does NOT block and does NOT spawn a 60s wake (DQ-1) — that is the whole
|
||||
point of scale-to-zero. The machine wakes only on a NAS→agent fire.
|
||||
"""
|
||||
# A new provider lifecycle cannot prove what an interrupted prior
|
||||
# process did. Classify those attempts unknown for audit only; do not
|
||||
# requeue them here.
|
||||
self.recover_interrupted()
|
||||
try:
|
||||
self.reconcile()
|
||||
except Exception as e:
|
||||
logger.warning("Chronos start() reconcile failed: %s", e)
|
||||
# Intentionally return — no loop, no periodic wake.
|
||||
|
||||
def stop(self) -> None:
|
||||
return None
|
||||
|
||||
def on_jobs_changed(self) -> None:
|
||||
"""A job was created/updated/removed/paused/resumed — reconcile the NAS
|
||||
registry so the affected one-shot is (re-)armed or cancelled."""
|
||||
try:
|
||||
self.reconcile()
|
||||
except Exception as e:
|
||||
logger.debug("Chronos on_jobs_changed reconcile failed: %s", e)
|
||||
|
||||
def register_job(self, job: Dict[str, Any]) -> None:
|
||||
"""Arm the first one-shot for a newly persisted job.
|
||||
|
||||
Unlike full reconciliation, this operation is allowed to raise so the
|
||||
creation surface can report that the local job exists but its external
|
||||
trigger was not registered.
|
||||
"""
|
||||
self._arm_one_shot(job)
|
||||
|
||||
# -- arming -----------------------------------------------------------
|
||||
|
||||
def _arm_one_shot(self, job: Dict[str, Any]) -> None:
|
||||
"""Ask NAS to arm exactly one one-shot at the job's next_run_at.
|
||||
|
||||
The agent computes the time; NAS+its scheduler are the dumb executor.
|
||||
Idempotent per (job_id, fire_at) via dedup_key, so re-arming the same
|
||||
fire is a no-op NAS-side.
|
||||
"""
|
||||
job_id = job["id"]
|
||||
fire_at = job.get("next_run_at")
|
||||
if not fire_at:
|
||||
return
|
||||
dedup_key = f"{job_id}:{fire_at}"
|
||||
self._get_client().provision(
|
||||
job_id=job_id,
|
||||
fire_at=fire_at,
|
||||
agent_callback_url=self._callback_url(),
|
||||
dedup_key=dedup_key,
|
||||
)
|
||||
with self._lock:
|
||||
self._armed[job_id] = fire_at
|
||||
|
||||
def _cancel(self, job_id: str) -> None:
|
||||
try:
|
||||
self._get_client().cancel(job_id=job_id)
|
||||
finally:
|
||||
with self._lock:
|
||||
self._armed.pop(job_id, None)
|
||||
|
||||
def _list_armed(self) -> Dict[str, str]:
|
||||
"""Observed armed one-shots: job_id → fire_at.
|
||||
|
||||
Prefer the in-memory map (warm process); on a cold/empty map, ask NAS
|
||||
(best-effort). If NAS list fails, return what we have — reconcile then
|
||||
re-arms desired jobs idempotently.
|
||||
"""
|
||||
with self._lock:
|
||||
if self._armed:
|
||||
return dict(self._armed)
|
||||
try:
|
||||
observed = {
|
||||
item["job_id"]: item.get("fire_at", "")
|
||||
for item in self._get_client().list_armed()
|
||||
if item.get("job_id")
|
||||
}
|
||||
with self._lock:
|
||||
self._armed.update(observed)
|
||||
return observed
|
||||
except Exception as e:
|
||||
logger.debug("Chronos _list_armed failed (will re-arm idempotently): %s", e)
|
||||
return {}
|
||||
|
||||
# -- reconcile --------------------------------------------------------
|
||||
|
||||
def reconcile(self) -> None:
|
||||
"""Converge the NAS-armed one-shots toward jobs.json (desired state):
|
||||
arm missing / re-arm changed-time, cancel orphaned."""
|
||||
from cron.jobs import load_jobs
|
||||
|
||||
desired: Dict[str, str] = {
|
||||
j["id"]: j["next_run_at"]
|
||||
for j in load_jobs()
|
||||
if j.get("enabled") and j.get("next_run_at") and j.get("state") != "paused"
|
||||
}
|
||||
observed = self._list_armed()
|
||||
|
||||
# Arm missing or changed-time.
|
||||
for job_id, fire_at in desired.items():
|
||||
if observed.get(job_id) != fire_at:
|
||||
# Re-fetch the full job dict to arm (need the whole record).
|
||||
from cron.jobs import get_job
|
||||
job = get_job(job_id)
|
||||
if job:
|
||||
try:
|
||||
self._arm_one_shot(job)
|
||||
except Exception as e:
|
||||
logger.warning("Chronos failed to arm job %s: %s", job_id, e)
|
||||
|
||||
# Cancel orphans (armed but no longer desired).
|
||||
for job_id in list(observed.keys()):
|
||||
if job_id not in desired:
|
||||
try:
|
||||
self._cancel(job_id)
|
||||
except Exception as e:
|
||||
logger.warning("Chronos failed to cancel orphan %s: %s", job_id, e)
|
||||
|
||||
# -- fire -------------------------------------------------------------
|
||||
|
||||
# NOTE: no ``fire_due`` override on purpose. The base implementation
|
||||
# virtually dispatches through ``self.claim_fire``/``self.fire_claimed``,
|
||||
# and ``provider_supports_split_fire`` treats ANY ``fire_due`` override
|
||||
# (even a pure ``super()`` delegate) as the legacy single-phase signal —
|
||||
# overriding it here would silently opt Chronos out of claim admission,
|
||||
# duplicate detection, and the cancel-aware drain on the fire webhook.
|
||||
|
||||
def fire_claimed(
|
||||
self,
|
||||
claimed_job: dict,
|
||||
*,
|
||||
adapters: Any = None,
|
||||
loop: Any = None,
|
||||
cancel_event: Any = None,
|
||||
) -> bool:
|
||||
job_id = claimed_job["id"]
|
||||
ran = super().fire_claimed(
|
||||
claimed_job,
|
||||
adapters=adapters,
|
||||
loop=loop,
|
||||
cancel_event=cancel_event,
|
||||
)
|
||||
if ran:
|
||||
from cron.jobs import get_job
|
||||
job = get_job(job_id)
|
||||
if job and job.get("enabled") and job.get("next_run_at"):
|
||||
try:
|
||||
self._arm_one_shot(job)
|
||||
except Exception as e:
|
||||
logger.warning("Chronos failed to re-arm job %s after fire: %s", job_id, e)
|
||||
return ran
|
||||
|
||||
|
||||
def register(ctx) -> None:
|
||||
"""Plugin entrypoint — register the Chronos provider with the loader.
|
||||
|
||||
Mirrors the memory-plugin shape; plugins/cron_providers discovery calls this and
|
||||
collects the provider via register_cron_scheduler.
|
||||
"""
|
||||
ctx.register_cron_scheduler(ChronosCronScheduler())
|
||||
@@ -0,0 +1,123 @@
|
||||
"""Thin HTTP client for the agent → NAS ``agent-cron`` endpoints (Chronos).
|
||||
|
||||
The Chronos provider speaks ONLY to NAS — it names no scheduler vendor and
|
||||
holds no scheduler credentials. NAS owns the external scheduler (an internal
|
||||
implementation detail) and that scheduler's account; the agent just asks NAS to
|
||||
"arm a one-shot at time T" / "cancel" / "list", authenticated with the agent's
|
||||
existing Nous Portal access token (the same token it already uses to call the
|
||||
portal — no new secret).
|
||||
|
||||
Wire contract: ``docs/chronos-managed-cron-contract.md``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
logger = logging.getLogger("cron.chronos")
|
||||
|
||||
# Endpoint paths under the portal base URL.
|
||||
_PROVISION_PATH = "/api/agent-cron/provision"
|
||||
_CANCEL_PATH = "/api/agent-cron/cancel"
|
||||
_LIST_PATH = "/api/agent-cron/list"
|
||||
|
||||
|
||||
class NasCronClientError(RuntimeError):
|
||||
"""Raised when a NAS agent-cron call fails (non-2xx or transport error)."""
|
||||
|
||||
|
||||
class NasCronClient:
|
||||
"""Minimal client for the agent→NAS provision/cancel/list endpoints.
|
||||
|
||||
Uses the agent's refresh-aware Nous access token for auth. No scheduler
|
||||
vendor, no scheduler creds — NAS hides all of that behind these three calls.
|
||||
"""
|
||||
|
||||
def __init__(self, portal_url: str, *, timeout_seconds: float = 15.0) -> None:
|
||||
self.portal_url = portal_url.rstrip("/")
|
||||
self.timeout_seconds = timeout_seconds
|
||||
|
||||
# -- auth -------------------------------------------------------------
|
||||
|
||||
def _access_token(self) -> str:
|
||||
"""The agent's existing Nous Portal access token (refresh-aware)."""
|
||||
from hermes_cli.auth import resolve_nous_access_token
|
||||
return resolve_nous_access_token()
|
||||
|
||||
def _headers(self) -> Dict[str, str]:
|
||||
return {
|
||||
"Authorization": f"Bearer {self._access_token()}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
# -- HTTP -------------------------------------------------------------
|
||||
|
||||
def _post(self, path: str, body: Dict[str, Any]) -> Dict[str, Any]:
|
||||
import requests # lazy: agent already depends on requests
|
||||
|
||||
url = f"{self.portal_url}{path}"
|
||||
try:
|
||||
resp = requests.post(
|
||||
url, json=body, headers=self._headers(), timeout=self.timeout_seconds
|
||||
)
|
||||
except Exception as e:
|
||||
raise NasCronClientError(f"POST {path} failed: {e}") from e
|
||||
if resp.status_code // 100 != 2:
|
||||
raise NasCronClientError(
|
||||
f"POST {path} returned {resp.status_code}: {resp.text[:200]}"
|
||||
)
|
||||
try:
|
||||
return resp.json() if resp.content else {}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
def _get(self, path: str, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
import requests
|
||||
|
||||
url = f"{self.portal_url}{path}"
|
||||
try:
|
||||
resp = requests.get(
|
||||
url, params=params, headers=self._headers(), timeout=self.timeout_seconds
|
||||
)
|
||||
except Exception as e:
|
||||
raise NasCronClientError(f"GET {path} failed: {e}") from e
|
||||
if resp.status_code // 100 != 2:
|
||||
raise NasCronClientError(
|
||||
f"GET {path} returned {resp.status_code}: {resp.text[:200]}"
|
||||
)
|
||||
try:
|
||||
return resp.json() if resp.content else {}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
# -- endpoints --------------------------------------------------------
|
||||
|
||||
def provision(self, *, job_id: str, fire_at: str, agent_callback_url: str,
|
||||
dedup_key: str) -> Dict[str, Any]:
|
||||
"""Ask NAS to arm a one-shot for ``job_id`` at ``fire_at`` (ISO 8601).
|
||||
|
||||
``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._post(_PROVISION_PATH, {
|
||||
"job_id": job_id,
|
||||
"fire_at": fire_at,
|
||||
"agent_callback_url": agent_callback_url,
|
||||
"dedup_key": dedup_key,
|
||||
})
|
||||
|
||||
def cancel(self, *, job_id: str) -> Dict[str, Any]:
|
||||
"""Ask NAS to cancel any armed one-shot for ``job_id``."""
|
||||
return self._post(_CANCEL_PATH, {"job_id": job_id})
|
||||
|
||||
def list_armed(self) -> List[Dict[str, Any]]:
|
||||
"""List the one-shots NAS currently has armed for this agent.
|
||||
|
||||
Returns a list of ``{job_id, fire_at, schedule_id}``. Best-effort: used
|
||||
by reconcile to find orphaned arms on a cold process; on error the
|
||||
caller falls back to idempotent re-arm of all desired jobs.
|
||||
"""
|
||||
data = self._get(_LIST_PATH, {})
|
||||
items = data.get("armed") if isinstance(data, dict) else None
|
||||
return items if isinstance(items, list) else []
|
||||
@@ -0,0 +1,9 @@
|
||||
name: chronos
|
||||
description: >-
|
||||
Chronos — NAS-mediated managed cron provider for scale-to-zero hosted agents.
|
||||
Delegates the "wake me at time T" trigger to Nous infrastructure so an idle
|
||||
gateway can scale to zero and still fire cron jobs. The agent computes each
|
||||
job's next-fire time and asks NAS to arm a one-shot; NAS calls the agent back
|
||||
at fire time over an authenticated webhook. Inert unless cron.provider=chronos.
|
||||
version: 1.0.0
|
||||
author: Nous Research
|
||||
@@ -0,0 +1,154 @@
|
||||
"""Inbound cron-fire token verification for Chronos (Phase 4E.1).
|
||||
|
||||
When NAS relays an external scheduler fire to the agent, it POSTs
|
||||
``/api/cron/fire`` with a short-lived NAS-minted JWT. This module verifies that
|
||||
JWT before any job runs — the security boundary for remotely-triggered job
|
||||
execution.
|
||||
|
||||
We verify a NAS-minted JWT (the trust path the agent already has) rather than
|
||||
let an external scheduler call the agent directly: the scheduler signs with
|
||||
NAS's keys, which the agent doesn't (and shouldn't) hold. See the plan's DQ-4.
|
||||
|
||||
The verifier is pluggable (``get_fire_verifier``) so the escape-hatch mode
|
||||
(direct per-job cron-key) can swap in later with no handler change.
|
||||
|
||||
Crypto is delegated to PyJWT (already a declared dependency) — we do NOT
|
||||
hand-roll JWT verification.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
from typing import Any, Callable, Dict, Optional
|
||||
|
||||
logger = logging.getLogger("cron.chronos.verify")
|
||||
|
||||
# The purpose claim that scopes a token to the fire endpoint. A general agent
|
||||
# JWT (without this claim) must NOT be replayable against /api/cron/fire.
|
||||
_FIRE_PURPOSE = "cron_fire"
|
||||
|
||||
# Process-wide cache of PyJWKClient instances, keyed by JWKS URL.
|
||||
#
|
||||
# WHY THIS EXISTS: a PyJWKClient caches the fetched JWKS (signing keys) on the
|
||||
# INSTANCE. Constructing a fresh client per fire therefore threw that cache
|
||||
# away and forced a synchronous JWKS HTTP GET to the portal on EVERY fire. Under
|
||||
# a burst of concurrent fires (an instance with several cron jobs firing in the
|
||||
# same window) that fanned out into N simultaneous JWKS fetches, which the
|
||||
# portal rate-limited (HTTP 403) — verification then failed and the agent
|
||||
# answered 401. When a fetch was merely slow rather than rate-limited, it blocked
|
||||
# the event loop long enough that the fire webhook could not return its 202
|
||||
# before the relay's 30s timeout (observed in prod as relay 504s concentrated on
|
||||
# high-job-count instances). Reusing one client per URL keeps the signing keys
|
||||
# cached (NAS keys rotate rarely), so the steady state is zero JWKS fetches per
|
||||
# fire. See docs/chronos-managed-cron-contract.md and the betterstack triage.
|
||||
_JWK_CLIENTS: Dict[str, Any] = {}
|
||||
_JWK_CLIENTS_LOCK = threading.Lock()
|
||||
|
||||
|
||||
def _get_jwk_client(jwks_url: str) -> Any:
|
||||
"""Return a process-cached PyJWKClient for ``jwks_url`` (one per URL).
|
||||
|
||||
PyJWKClient does its own key caching internally (``cache_keys``/``lifespan``);
|
||||
the whole point here is to reuse the SAME instance across fires so that cache
|
||||
is actually hit instead of discarded. Double-checked-locked so concurrent
|
||||
fires resolve to a single shared client without racing.
|
||||
"""
|
||||
client = _JWK_CLIENTS.get(jwks_url)
|
||||
if client is not None:
|
||||
return client
|
||||
with _JWK_CLIENTS_LOCK:
|
||||
client = _JWK_CLIENTS.get(jwks_url)
|
||||
if client is None:
|
||||
from jwt import PyJWKClient
|
||||
|
||||
# Explicit Accept + User-Agent so the JWKS fetch isn't blocked by the
|
||||
# NAS portal's WAF, which 403s the default Python-urllib fingerprint
|
||||
# (same fix as the dashboard-auth nous/self_hosted providers).
|
||||
client = PyJWKClient(
|
||||
jwks_url,
|
||||
headers={
|
||||
"Accept": "application/json",
|
||||
"User-Agent": "HermesAgent/1.0",
|
||||
},
|
||||
)
|
||||
_JWK_CLIENTS[jwks_url] = client
|
||||
return client
|
||||
|
||||
|
||||
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]]:
|
||||
"""Verify a NAS-minted cron-fire JWT. Return decoded claims, or None.
|
||||
|
||||
Checks (all must pass):
|
||||
- signature against the NAS JWKS (``jwks_or_key`` is a JWKS URL) — RS256
|
||||
family; symmetric secrets are rejected (NAS signs asymmetrically).
|
||||
- ``aud`` == ``expected_audience`` (this agent: ``agent:{instance_id}``).
|
||||
- ``exp`` / ``nbf`` within ``leeway_seconds``.
|
||||
- ``iss`` == ``issuer`` when an issuer is configured.
|
||||
- ``purpose`` == ``"cron_fire"`` — so a general agent JWT can't be
|
||||
replayed against the fire endpoint.
|
||||
|
||||
Returns None (never raises) on any failure, so the handler can answer 401
|
||||
without leaking which check failed.
|
||||
"""
|
||||
if not token or not expected_audience:
|
||||
return None
|
||||
if not jwks_or_key:
|
||||
# No verification key configured → cannot verify → refuse. We never
|
||||
# fall back to unsigned decode for a security boundary.
|
||||
logger.warning("cron fire: no JWKS/key configured; refusing token")
|
||||
return None
|
||||
|
||||
try:
|
||||
import jwt
|
||||
|
||||
# Resolve the signing key from the JWKS endpoint by the token's kid.
|
||||
signing_key = None
|
||||
if jwks_or_key.startswith("http://") or jwks_or_key.startswith("https://"):
|
||||
# Reuse a process-cached client so the JWKS fetch is amortised across
|
||||
# fires (a fresh client per fire re-fetched the JWKS every time and,
|
||||
# under concurrent fires, tripped the portal's rate limit → 403 →
|
||||
# 401, or blocked the event loop past the relay's 30s timeout → 504).
|
||||
jwk_client = _get_jwk_client(jwks_or_key)
|
||||
signing_key = jwk_client.get_signing_key_from_jwt(token).key
|
||||
else:
|
||||
# A PEM public key passed inline (test / pinned-key deployments).
|
||||
signing_key = jwks_or_key
|
||||
|
||||
options = {"require": ["exp", "aud"]}
|
||||
decode_kwargs: Dict[str, Any] = dict(
|
||||
algorithms=["RS256", "RS384", "RS512", "ES256", "ES384"],
|
||||
audience=expected_audience,
|
||||
leeway=leeway_seconds,
|
||||
options=options,
|
||||
)
|
||||
if issuer:
|
||||
decode_kwargs["issuer"] = issuer
|
||||
|
||||
claims = jwt.decode(token, signing_key, **decode_kwargs)
|
||||
except Exception as e:
|
||||
logger.warning("cron fire: token verification failed: %s", e)
|
||||
return None
|
||||
|
||||
if claims.get("purpose") != _FIRE_PURPOSE:
|
||||
logger.warning("cron fire: token missing/!=%s purpose claim", _FIRE_PURPOSE)
|
||||
return None
|
||||
|
||||
return claims
|
||||
|
||||
|
||||
def get_fire_verifier() -> Callable[..., Optional[Dict[str, Any]]]:
|
||||
"""Return the active inbound-fire verifier.
|
||||
|
||||
Default = the NAS-JWT verifier. The DQ-4 escape hatch (direct per-job
|
||||
cron-key) would return a cron-key verifier here instead, selected by config
|
||||
— so the webhook handler never changes when the auth mode is swapped.
|
||||
"""
|
||||
return verify_nas_fire_token
|
||||
@@ -0,0 +1,491 @@
|
||||
"""BasicAuthProvider — username/password dashboard auth (no OAuth IDP).
|
||||
|
||||
A self-hosted "just put a password on my dashboard" provider. It plugs
|
||||
into the same ``DashboardAuthProvider`` framework as the Nous OAuth
|
||||
provider, but authenticates with a username + password instead of an
|
||||
OAuth redirect: it sets ``supports_password = True`` and implements
|
||||
``complete_password_login``. The login page renders a credential form for
|
||||
it; everything downstream of login (session cookies, verify, refresh,
|
||||
ws-tickets, logout) is identical to the OAuth path because a password
|
||||
session is just a :class:`Session` with provider-minted opaque tokens.
|
||||
|
||||
This provider has **no external IDP and no database**. Credentials are
|
||||
configured up front; sessions are stateless HMAC-signed tokens this
|
||||
provider mints and verifies itself. That keeps it zero-infrastructure —
|
||||
appropriate for a single-box self-hosted dashboard.
|
||||
|
||||
Configuration surfaces (env wins over config.yaml when set non-empty),
|
||||
mirroring the Nous provider's precedence convention:
|
||||
|
||||
``config.yaml`` — canonical surface::
|
||||
|
||||
dashboard:
|
||||
basic_auth:
|
||||
username: admin # required
|
||||
# Provide EITHER a precomputed scrypt hash (preferred — no
|
||||
# plaintext at rest) ...
|
||||
password_hash: "scrypt$..." # see hash_password()
|
||||
# ... OR a plaintext password (hashed in-memory at load).
|
||||
password: "s3cret"
|
||||
secret: "<32+ random bytes, base64 or hex>" # optional; token-signing key
|
||||
session_ttl_seconds: 43200 # optional; access-token lifetime (default 12h)
|
||||
|
||||
Environment overrides::
|
||||
|
||||
HERMES_DASHBOARD_BASIC_AUTH_USERNAME
|
||||
HERMES_DASHBOARD_BASIC_AUTH_PASSWORD_HASH # preferred
|
||||
HERMES_DASHBOARD_BASIC_AUTH_PASSWORD # plaintext fallback
|
||||
HERMES_DASHBOARD_BASIC_AUTH_SECRET
|
||||
HERMES_DASHBOARD_BASIC_AUTH_TTL_SECONDS
|
||||
|
||||
If ``secret`` is not configured, a random per-process secret is generated
|
||||
at startup. That's fine for a single-process dashboard, but means all
|
||||
sessions are invalidated on restart and sessions don't survive across
|
||||
multiple worker processes — set an explicit ``secret`` for stable
|
||||
multi-worker / restart-surviving sessions.
|
||||
|
||||
Password hashing uses stdlib :func:`hashlib.scrypt` (memory-hard, no
|
||||
third-party dependency). ``complete_password_login`` runs a constant-time
|
||||
comparison and always performs a hash even for an unknown username, so
|
||||
the endpoint is not a username-enumeration timing oracle.
|
||||
|
||||
Skip reasons:
|
||||
Like the Nous provider, this exposes a module-level ``LAST_SKIP_REASON``
|
||||
the gate's fail-closed branch can surface when the plugin loads but
|
||||
declines to register (no username/password configured).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
import time
|
||||
from typing import Any, Optional
|
||||
|
||||
from hermes_cli.dashboard_auth import (
|
||||
DashboardAuthProvider,
|
||||
InvalidCredentialsError,
|
||||
LoginStart,
|
||||
RefreshExpiredError,
|
||||
Session,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Defaults
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Access-token lifetime. The middleware transparently refreshes via the
|
||||
# refresh token (30-day) when the access token lapses, so this controls
|
||||
# how often a refresh round trip happens, not how long the user stays
|
||||
# logged in.
|
||||
_DEFAULT_TTL_SECONDS = 12 * 60 * 60 # 12h
|
||||
_REFRESH_TTL_SECONDS = 30 * 24 * 60 * 60 # 30d
|
||||
|
||||
# scrypt parameters (RFC 7914 / stdlib hashlib.scrypt). n must be a power
|
||||
# of two; these are the widely-recommended interactive-login parameters
|
||||
# (~16 MiB, a few ms on commodity hardware).
|
||||
_SCRYPT_N = 2**14
|
||||
_SCRYPT_R = 8
|
||||
_SCRYPT_P = 1
|
||||
_SCRYPT_DKLEN = 32
|
||||
_SCRYPT_SALT_BYTES = 16
|
||||
|
||||
# Length of the HMAC-SHA256 digest appended as a fixed-length suffix to
|
||||
# signed tokens (no separator — binary HMAC bytes can't be confused with
|
||||
# a delimiter).
|
||||
_SIG_LEN = hashlib.sha256().digest_size
|
||||
|
||||
|
||||
LAST_SKIP_REASON: str = ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Password hashing (stdlib scrypt)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
"""Return a ``scrypt$n$r$p$<salt_b64>$<dk_b64>`` hash string.
|
||||
|
||||
Use this to precompute ``password_hash`` for config.yaml so plaintext
|
||||
never sits at rest. Exposed as a module function so operators can run
|
||||
``python -c "from plugins.dashboard_auth.basic import hash_password;
|
||||
print(hash_password('pw'))"``.
|
||||
"""
|
||||
salt = secrets.token_bytes(_SCRYPT_SALT_BYTES)
|
||||
dk = hashlib.scrypt(
|
||||
password.encode("utf-8"),
|
||||
salt=salt,
|
||||
n=_SCRYPT_N,
|
||||
r=_SCRYPT_R,
|
||||
p=_SCRYPT_P,
|
||||
dklen=_SCRYPT_DKLEN,
|
||||
maxmem=0,
|
||||
)
|
||||
return (
|
||||
f"scrypt${_SCRYPT_N}${_SCRYPT_R}${_SCRYPT_P}$"
|
||||
f"{base64.b64encode(salt).decode()}${base64.b64encode(dk).decode()}"
|
||||
)
|
||||
|
||||
|
||||
def _verify_password(password: str, encoded: str) -> bool:
|
||||
"""Constant-time scrypt verify. False on any malformed hash string."""
|
||||
try:
|
||||
scheme, n_s, r_s, p_s, salt_b64, dk_b64 = encoded.split("$")
|
||||
if scheme != "scrypt":
|
||||
return False
|
||||
n, r, p = int(n_s), int(r_s), int(p_s)
|
||||
salt = base64.b64decode(salt_b64)
|
||||
expected = base64.b64decode(dk_b64)
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
try:
|
||||
actual = hashlib.scrypt(
|
||||
password.encode("utf-8"),
|
||||
salt=salt,
|
||||
n=n,
|
||||
r=r,
|
||||
p=p,
|
||||
dklen=len(expected),
|
||||
maxmem=0,
|
||||
)
|
||||
except (ValueError, MemoryError):
|
||||
return False
|
||||
return hmac.compare_digest(actual, expected)
|
||||
|
||||
|
||||
# A fixed dummy hash used to spend ~equal time when the username is
|
||||
# unknown, so an attacker can't distinguish "no such user" (fast) from
|
||||
# "wrong password" (slow scrypt) by timing. Computed once at import.
|
||||
_DUMMY_HASH = hash_password("dummy-password-for-constant-time-verify")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Token signing (stateless HMAC-signed blobs)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _sign(payload: dict, secret: bytes) -> str:
|
||||
raw = json.dumps(payload, separators=(",", ":")).encode()
|
||||
sig = hmac.new(secret, raw, hashlib.sha256).digest()
|
||||
return base64.urlsafe_b64encode(raw + sig).decode()
|
||||
|
||||
|
||||
def _unsign(token: str, secret: bytes) -> Optional[dict]:
|
||||
try:
|
||||
blob = base64.urlsafe_b64decode(token.encode())
|
||||
if len(blob) <= _SIG_LEN:
|
||||
return None
|
||||
raw, sig = blob[:-_SIG_LEN], blob[-_SIG_LEN:]
|
||||
expected = hmac.new(secret, raw, hashlib.sha256).digest()
|
||||
if not hmac.compare_digest(sig, expected):
|
||||
return None
|
||||
return json.loads(raw)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Provider
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class BasicAuthProvider(DashboardAuthProvider):
|
||||
"""Username/password provider with stateless HMAC-signed sessions."""
|
||||
|
||||
name = "basic"
|
||||
display_name = "Username & Password"
|
||||
supports_password = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
username: str,
|
||||
password_hash: str,
|
||||
secret: bytes,
|
||||
ttl_seconds: int = _DEFAULT_TTL_SECONDS,
|
||||
) -> None:
|
||||
if not username:
|
||||
raise ValueError("username must be non-empty")
|
||||
if not password_hash:
|
||||
raise ValueError("password_hash must be non-empty")
|
||||
if len(secret) < 16:
|
||||
raise ValueError("secret must be at least 16 bytes")
|
||||
self._username = username
|
||||
self._password_hash = password_hash
|
||||
self._secret = secret
|
||||
self._ttl = max(60, int(ttl_seconds))
|
||||
|
||||
# ---- OAuth methods: not used (pure-password provider) ------------------
|
||||
|
||||
def start_login(self, *, redirect_uri: str) -> LoginStart:
|
||||
raise NotImplementedError(
|
||||
"BasicAuthProvider is password-only; there is no OAuth redirect "
|
||||
"flow. The login page POSTs to /auth/password-login instead."
|
||||
)
|
||||
|
||||
def complete_login(
|
||||
self, *, code: str, state: str, code_verifier: str, redirect_uri: str
|
||||
) -> Session:
|
||||
raise NotImplementedError(
|
||||
"BasicAuthProvider is password-only; use complete_password_login."
|
||||
)
|
||||
|
||||
# ---- password login ----------------------------------------------------
|
||||
|
||||
def complete_password_login(
|
||||
self, *, username: str, password: str
|
||||
) -> Session:
|
||||
# Constant-time-ish: always run a scrypt verify (against the real
|
||||
# hash if the username matches, else a dummy hash) so an unknown
|
||||
# username and a wrong password take comparable time. Compare the
|
||||
# username with compare_digest too, to avoid a length/byte timing
|
||||
# leak on the username itself.
|
||||
username_ok = hmac.compare_digest(
|
||||
username.encode("utf-8"), self._username.encode("utf-8")
|
||||
)
|
||||
target_hash = self._password_hash if username_ok else _DUMMY_HASH
|
||||
password_ok = _verify_password(password, target_hash)
|
||||
if not (username_ok and password_ok):
|
||||
raise InvalidCredentialsError("invalid username or password")
|
||||
return self._mint_session(self._username)
|
||||
|
||||
# ---- session lifecycle -------------------------------------------------
|
||||
|
||||
def verify_session(self, *, access_token: str) -> Optional[Session]:
|
||||
payload = _unsign(access_token, self._secret)
|
||||
if (
|
||||
payload is None
|
||||
or payload.get("kind") != "access"
|
||||
or payload.get("exp", 0) <= int(time.time())
|
||||
):
|
||||
return None
|
||||
return self._session_from_payload(access_token, "", payload)
|
||||
|
||||
def refresh_session(self, *, refresh_token: str) -> Session:
|
||||
if not refresh_token:
|
||||
raise RefreshExpiredError("no refresh token present in session")
|
||||
payload = _unsign(refresh_token, self._secret)
|
||||
if (
|
||||
payload is None
|
||||
or payload.get("kind") != "refresh"
|
||||
or payload.get("exp", 0) <= int(time.time())
|
||||
):
|
||||
raise RefreshExpiredError("refresh token expired or invalid")
|
||||
return self._mint_session(str(payload.get("sub", self._username)))
|
||||
|
||||
def revoke_session(self, *, refresh_token: str) -> None:
|
||||
# Stateless tokens — nothing to revoke server-side. The session
|
||||
# expires within its TTL. Best-effort no-op, must not raise.
|
||||
_ = refresh_token
|
||||
return None
|
||||
|
||||
# ---- internals ---------------------------------------------------------
|
||||
|
||||
def _mint_session(self, user_id: str) -> Session:
|
||||
now = int(time.time())
|
||||
exp = now + self._ttl
|
||||
access_token = _sign(
|
||||
{"sub": user_id, "kind": "access", "exp": exp}, self._secret
|
||||
)
|
||||
refresh_token = _sign(
|
||||
{"sub": user_id, "kind": "refresh", "exp": now + _REFRESH_TTL_SECONDS},
|
||||
self._secret,
|
||||
)
|
||||
return Session(
|
||||
user_id=user_id,
|
||||
email="",
|
||||
display_name=user_id,
|
||||
org_id="",
|
||||
provider=self.name,
|
||||
expires_at=exp,
|
||||
access_token=access_token,
|
||||
refresh_token=refresh_token,
|
||||
)
|
||||
|
||||
def _session_from_payload(
|
||||
self, access_token: str, refresh_token: str, payload: dict
|
||||
) -> Session:
|
||||
user_id = str(payload.get("sub", ""))
|
||||
return Session(
|
||||
user_id=user_id,
|
||||
email="",
|
||||
display_name=user_id,
|
||||
org_id="",
|
||||
provider=self.name,
|
||||
expires_at=int(payload["exp"]),
|
||||
access_token=access_token,
|
||||
refresh_token=refresh_token,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Plugin entry point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _load_config_basic_auth_section() -> dict:
|
||||
"""Return ``dashboard.basic_auth`` from config.yaml, or ``{}``.
|
||||
|
||||
Robust to load_config() raising, the keys being absent, or the value
|
||||
not being a dict — every shape falls through to ``{}``.
|
||||
"""
|
||||
try:
|
||||
from hermes_cli.config import cfg_get, load_config
|
||||
|
||||
cfg = load_config()
|
||||
except Exception as exc: # noqa: BLE001 — broad catch is intentional
|
||||
logger.debug(
|
||||
"dashboard-auth-basic: load_config() raised %s; "
|
||||
"falling back to env-only configuration",
|
||||
exc,
|
||||
)
|
||||
return {}
|
||||
section = cfg_get(cfg, "dashboard", "basic_auth", default=None)
|
||||
return section if isinstance(section, dict) else {}
|
||||
|
||||
|
||||
def _resolve(env_name: str, cfg_section: dict, cfg_key: str) -> str:
|
||||
"""Env-wins-over-config resolution; empty env treated as unset."""
|
||||
env = os.environ.get(env_name, "").strip()
|
||||
if env:
|
||||
return env
|
||||
return str(cfg_section.get(cfg_key, "") or "").strip()
|
||||
|
||||
|
||||
def _resolve_secret(cfg_section: dict) -> bytes:
|
||||
"""Resolve the token-signing secret.
|
||||
|
||||
Accepts base64 or hex or raw text from config/env. When unset,
|
||||
generates a random per-process secret (sessions then don't survive a
|
||||
restart or span multiple workers — logged at INFO).
|
||||
"""
|
||||
raw = _resolve(
|
||||
"HERMES_DASHBOARD_BASIC_AUTH_SECRET", cfg_section, "secret"
|
||||
)
|
||||
if not raw:
|
||||
logger.info(
|
||||
"dashboard-auth-basic: no 'secret' configured; generating a "
|
||||
"random per-process signing key. Sessions will not survive a "
|
||||
"restart or span multiple workers. Set dashboard.basic_auth."
|
||||
"secret (or HERMES_DASHBOARD_BASIC_AUTH_SECRET) for stable "
|
||||
"sessions."
|
||||
)
|
||||
return secrets.token_bytes(32)
|
||||
# Try base64, then hex, then fall back to the raw UTF-8 bytes.
|
||||
for decoder in (base64.b64decode, bytes.fromhex):
|
||||
try:
|
||||
decoded = decoder(raw)
|
||||
if len(decoded) >= 16:
|
||||
return decoded
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
return raw.encode("utf-8")
|
||||
|
||||
|
||||
def register(ctx) -> None:
|
||||
"""Plugin entry — registers BasicAuthProvider when credentials exist.
|
||||
|
||||
Loopback / ``--insecure`` operators and anyone using the OAuth
|
||||
provider leave ``dashboard.basic_auth`` unset, so this plugin is a
|
||||
no-op for them. When username + (password or password_hash) are
|
||||
configured, it registers a password provider that the login page
|
||||
renders as a credential form.
|
||||
"""
|
||||
global LAST_SKIP_REASON
|
||||
LAST_SKIP_REASON = ""
|
||||
|
||||
section = _load_config_basic_auth_section()
|
||||
username = _resolve(
|
||||
"HERMES_DASHBOARD_BASIC_AUTH_USERNAME", section, "username"
|
||||
)
|
||||
password_hash = _resolve(
|
||||
"HERMES_DASHBOARD_BASIC_AUTH_PASSWORD_HASH", section, "password_hash"
|
||||
)
|
||||
plaintext = _resolve(
|
||||
"HERMES_DASHBOARD_BASIC_AUTH_PASSWORD", section, "password"
|
||||
)
|
||||
ttl_raw = _resolve(
|
||||
"HERMES_DASHBOARD_BASIC_AUTH_TTL_SECONDS", section, "session_ttl_seconds"
|
||||
)
|
||||
|
||||
if not username:
|
||||
LAST_SKIP_REASON = (
|
||||
"dashboard.basic_auth.username is not set (and "
|
||||
"HERMES_DASHBOARD_BASIC_AUTH_USERNAME is empty). Set a username "
|
||||
"and a password (or password_hash) under dashboard.basic_auth in "
|
||||
"config.yaml to enable username/password dashboard login, or use "
|
||||
"the OAuth provider, or pass --insecure to skip the auth gate."
|
||||
)
|
||||
logger.debug("dashboard-auth-basic: %s", LAST_SKIP_REASON)
|
||||
return
|
||||
|
||||
if not password_hash and not plaintext:
|
||||
LAST_SKIP_REASON = (
|
||||
"dashboard.basic_auth.username is set but neither password_hash "
|
||||
"nor password is configured. Provide one of them (password_hash "
|
||||
"is preferred — compute it with "
|
||||
"plugins.dashboard_auth.basic.hash_password)."
|
||||
)
|
||||
logger.warning("dashboard-auth-basic: %s", LAST_SKIP_REASON)
|
||||
return
|
||||
|
||||
# Precedence (env-wins convention): a password supplied via the
|
||||
# HERMES_DASHBOARD_BASIC_AUTH_PASSWORD env var overrides a config.yaml
|
||||
# password_hash, so an operator can rotate the password by setting an
|
||||
# env var without editing config. A password_hash (precomputed) wins
|
||||
# over a config-only plaintext password at the same tier — it's the
|
||||
# preferred at-rest form. Concretely:
|
||||
# * env password set → hash it (overrides any config hash)
|
||||
# * else config password_hash set → use it
|
||||
# * else config plaintext password → hash it in-memory
|
||||
plaintext_from_env = os.environ.get(
|
||||
"HERMES_DASHBOARD_BASIC_AUTH_PASSWORD", ""
|
||||
).strip()
|
||||
if plaintext_from_env:
|
||||
password_hash = hash_password(plaintext_from_env)
|
||||
logger.info(
|
||||
"dashboard-auth-basic: hashed env-supplied password in-memory "
|
||||
"(overrides any config password_hash)."
|
||||
)
|
||||
elif not password_hash:
|
||||
# config-only plaintext password.
|
||||
password_hash = hash_password(plaintext)
|
||||
logger.info(
|
||||
"dashboard-auth-basic: hashed plaintext password in-memory. "
|
||||
"For production, precompute dashboard.basic_auth.password_hash "
|
||||
"and remove the plaintext password from config."
|
||||
)
|
||||
|
||||
secret = _resolve_secret(section)
|
||||
|
||||
try:
|
||||
ttl = int(ttl_raw) if ttl_raw else _DEFAULT_TTL_SECONDS
|
||||
except ValueError:
|
||||
ttl = _DEFAULT_TTL_SECONDS
|
||||
|
||||
try:
|
||||
provider = BasicAuthProvider(
|
||||
username=username,
|
||||
password_hash=password_hash,
|
||||
secret=secret,
|
||||
ttl_seconds=ttl,
|
||||
)
|
||||
except ValueError as exc:
|
||||
LAST_SKIP_REASON = f"BasicAuthProvider construction failed: {exc}"
|
||||
logger.warning("dashboard-auth-basic: %s", LAST_SKIP_REASON)
|
||||
return
|
||||
|
||||
ctx.register_dashboard_auth_provider(provider)
|
||||
logger.info(
|
||||
"dashboard-auth-basic: registered password provider (username=%s)",
|
||||
username,
|
||||
)
|
||||
@@ -0,0 +1,7 @@
|
||||
name: basic
|
||||
version: 1.0.0
|
||||
description: "Dashboard auth provider — username/password (no OAuth IDP). A self-hosted 'just put a password on my dashboard' provider. Activates when dashboard.basic_auth.username plus a password (or password_hash) are configured via config.yaml (canonical surface) or the HERMES_DASHBOARD_BASIC_AUTH_* env vars. Sessions are stateless HMAC-signed tokens minted by the provider; password hashing uses stdlib scrypt (no third-party dependency). Set dashboard.basic_auth.secret for restart-surviving / multi-worker sessions."
|
||||
author: NousResearch
|
||||
kind: backend
|
||||
requires_env:
|
||||
- HERMES_DASHBOARD_BASIC_AUTH_USERNAME
|
||||
@@ -0,0 +1,291 @@
|
||||
"""DrainSecretProvider — shared-bearer-secret auth for the drain-control endpoint.
|
||||
|
||||
Task 2.0b of the safe-shutdown plan, and the FIRST consumer of the generic
|
||||
non-interactive token-auth capability added in Task 2.0a
|
||||
(``supports_token`` / ``verify_token`` on the ``DashboardAuthProvider`` ABC +
|
||||
the route-agnostic ``token_auth`` middleware seam).
|
||||
|
||||
What it is
|
||||
----------
|
||||
A service-to-service auth provider. ``nous-account-service`` (NAS) provisions a
|
||||
**per-agent unique** shared secret into each deployed agent's environment; this
|
||||
provider verifies an inbound ``Authorization`` bearer token against that secret
|
||||
with a constant-time compare and, on a match, vouches for the caller as the
|
||||
``drain-control`` principal. It is NOT an interactive identity provider — there
|
||||
is no login, cookie, session, or refresh. It implements ONLY the token
|
||||
capability (``supports_token = True`` + ``verify_token``); the five interactive
|
||||
ABC methods raise ``NotImplementedError``.
|
||||
|
||||
Why a plugin (not an ad-hoc header check on the drain route)
|
||||
------------------------------------------------------------
|
||||
Decisions.md Q-A: the drain credential MUST be a real auth plugin in the
|
||||
dashboard auth framework, not a bolt-on. Q-C: the framework widening that
|
||||
hosts it is generic (Task 2.0a) and this plugin is merely its first consumer.
|
||||
|
||||
Security properties (decisions.md Q-A)
|
||||
--------------------------------------
|
||||
* **Per-agent unique secret** — each agent gets a distinct secret; a leak's
|
||||
blast radius is one agent.
|
||||
* **Entropy gate at registration** — a weak/short/low-entropy secret fails
|
||||
CLOSED at load (the plugin declines to register and records a skip reason);
|
||||
it is never silently accepted. Bar: >= 256 bits of entropy / >= 43
|
||||
url-safe-base64 chars, and the value must not be obviously structured
|
||||
(all-one-character, too few distinct characters).
|
||||
* **Constant-time compare** — ``hmac.compare_digest`` on the request path, so
|
||||
the endpoint is not a timing oracle.
|
||||
|
||||
Configuration
|
||||
-------------
|
||||
The secret is a CREDENTIAL, so it is carried via an env var (the ``.env``-is-
|
||||
for-secrets-only rule), provisioned by NAS at deploy time (Phase 3):
|
||||
|
||||
HERMES_DASHBOARD_DRAIN_SECRET # the per-agent shared secret (>=43 url-safe-b64 chars)
|
||||
|
||||
Behavioural knobs live in config.yaml (canonical surface):
|
||||
|
||||
dashboard:
|
||||
drain_auth:
|
||||
scope: drain # capability label attached to the principal
|
||||
min_secret_chars: 43 # entropy bar (optional; default 43 ~= 256 bits)
|
||||
|
||||
When ``HERMES_DASHBOARD_DRAIN_SECRET`` is unset, the plugin is a no-op (records
|
||||
a skip reason) — agents that don't want NAS-driven drain just don't set it.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hmac
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
from collections import Counter
|
||||
from typing import Optional
|
||||
|
||||
from hermes_cli.dashboard_auth import (
|
||||
DashboardAuthProvider,
|
||||
LoginStart,
|
||||
Session,
|
||||
TokenPrincipal,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Default entropy bar: 43 url-safe-base64 chars ~= 256 bits. token_urlsafe(32)
|
||||
# produces 43 chars, so a correctly-provisioned secret clears this exactly.
|
||||
_DEFAULT_MIN_SECRET_CHARS = 43
|
||||
# A secret must contain at least this many DISTINCT characters — rejects
|
||||
# degenerate values like "aaaa..." that are long but trivially low-entropy.
|
||||
_MIN_DISTINCT_CHARS = 16
|
||||
# Shannon entropy floor (bits) over the secret's characters — a second,
|
||||
# distribution-aware guard on top of the length + distinct-count checks.
|
||||
_MIN_SHANNON_BITS = 128.0
|
||||
|
||||
# The path the begin/cancel-drain endpoint lives on. Registered as a
|
||||
# token-authable route by ``register()`` so the generic seam guards it. Kept
|
||||
# here (not imported from web_server) to avoid a heavy import at plugin load.
|
||||
DRAIN_ROUTE_PATH = "/api/gateway/drain"
|
||||
|
||||
LAST_SKIP_REASON: str = ""
|
||||
|
||||
|
||||
def _shannon_bits(value: str) -> float:
|
||||
"""Total Shannon entropy (bits) of ``value`` over its character distribution.
|
||||
|
||||
H = len * sum(-p_i * log2(p_i)). A long string drawn from a wide alphabet
|
||||
scores high; a long run of one character scores ~0.
|
||||
"""
|
||||
if not value:
|
||||
return 0.0
|
||||
counts = Counter(value)
|
||||
n = len(value)
|
||||
per_char = -sum((c / n) * math.log2(c / n) for c in counts.values())
|
||||
return per_char * n
|
||||
|
||||
|
||||
def assess_secret_strength(
|
||||
secret: str, *, min_chars: int = _DEFAULT_MIN_SECRET_CHARS
|
||||
) -> Optional[str]:
|
||||
"""Return a rejection reason if ``secret`` is too weak, else ``None``.
|
||||
|
||||
Fail-closed entropy gate (decisions.md Q-A). Checks, in order:
|
||||
* length >= ``min_chars`` (default 43 url-safe-b64 chars ~= 256 bits),
|
||||
* at least ``_MIN_DISTINCT_CHARS`` distinct characters,
|
||||
* Shannon entropy >= ``_MIN_SHANNON_BITS`` bits.
|
||||
|
||||
A ``None`` return means the secret passes. Any string return is a
|
||||
human-readable reason the caller logs + records as the skip reason.
|
||||
"""
|
||||
if not secret:
|
||||
return "secret is empty"
|
||||
if len(secret) < min_chars:
|
||||
return (
|
||||
f"secret too short: {len(secret)} chars (need >= {min_chars}; "
|
||||
"use a >=256-bit value, e.g. `python -c \"import secrets; "
|
||||
"print(secrets.token_urlsafe(32))\"`)"
|
||||
)
|
||||
distinct = len(set(secret))
|
||||
if distinct < _MIN_DISTINCT_CHARS:
|
||||
return (
|
||||
f"secret has only {distinct} distinct characters (need >= "
|
||||
f"{_MIN_DISTINCT_CHARS}); looks structured/low-entropy"
|
||||
)
|
||||
bits = _shannon_bits(secret)
|
||||
if bits < _MIN_SHANNON_BITS:
|
||||
return (
|
||||
f"secret entropy too low: {bits:.0f} bits (need >= "
|
||||
f"{_MIN_SHANNON_BITS:.0f}); looks structured/repeated"
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
class DrainSecretProvider(DashboardAuthProvider):
|
||||
"""Non-interactive shared-bearer-secret provider for drain control."""
|
||||
|
||||
name = "drain-secret"
|
||||
display_name = "Drain Control (service credential)"
|
||||
supports_token = True
|
||||
supports_session = False
|
||||
|
||||
def __init__(self, *, secret: str, scope: str = "drain") -> None:
|
||||
# Defence in depth: construction also enforces the entropy bar, so a
|
||||
# caller that bypasses register()'s check still can't build a weak
|
||||
# provider. register() does the friendly skip-reason path; this raises.
|
||||
reason = assess_secret_strength(secret)
|
||||
if reason is not None:
|
||||
raise ValueError(f"drain secret rejected: {reason}")
|
||||
self._secret = secret
|
||||
self._scope = scope or "drain"
|
||||
|
||||
# ---- token capability (the only thing this provider implements) --------
|
||||
|
||||
def verify_token(self, *, token: str) -> Optional[TokenPrincipal]:
|
||||
"""Constant-time compare against the per-agent shared secret.
|
||||
|
||||
Returns a ``drain-control`` principal on an exact match, else ``None``
|
||||
(the generic seam falls through / fails closed). Uses
|
||||
``hmac.compare_digest`` so a wrong token can't be recovered by timing.
|
||||
"""
|
||||
if not token:
|
||||
return None
|
||||
if hmac.compare_digest(token.encode("utf-8"), self._secret.encode("utf-8")):
|
||||
return TokenPrincipal(
|
||||
principal="drain-control",
|
||||
provider=self.name,
|
||||
scopes=(self._scope,),
|
||||
)
|
||||
return None
|
||||
|
||||
# ---- interactive methods: unsupported (service credential only) --------
|
||||
|
||||
def start_login(self, *, redirect_uri: str) -> LoginStart:
|
||||
raise NotImplementedError(
|
||||
"DrainSecretProvider is a non-interactive service credential; "
|
||||
"there is no login flow."
|
||||
)
|
||||
|
||||
def complete_login(
|
||||
self, *, code: str, state: str, code_verifier: str, redirect_uri: str
|
||||
) -> Session:
|
||||
raise NotImplementedError(
|
||||
"DrainSecretProvider is a non-interactive service credential."
|
||||
)
|
||||
|
||||
def verify_session(self, *, access_token: str) -> Optional[Session]:
|
||||
# Not a cookie-session provider — it never mints a Session, so it can
|
||||
# never recognise a session cookie. Return None (don't raise) so it
|
||||
# stacks harmlessly in the cookie-verify loop.
|
||||
return None
|
||||
|
||||
def refresh_session(self, *, refresh_token: str) -> Session:
|
||||
raise NotImplementedError(
|
||||
"DrainSecretProvider is a non-interactive service credential."
|
||||
)
|
||||
|
||||
def revoke_session(self, *, refresh_token: str) -> None:
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Plugin entry point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _load_config_drain_auth_section() -> dict:
|
||||
"""Return ``dashboard.drain_auth`` from config.yaml, or ``{}``."""
|
||||
try:
|
||||
from hermes_cli.config import cfg_get, load_config
|
||||
|
||||
cfg = load_config()
|
||||
except Exception as exc: # noqa: BLE001 — broad catch is intentional
|
||||
logger.debug(
|
||||
"dashboard-auth-drain: load_config() raised %s; "
|
||||
"falling back to env-only configuration",
|
||||
exc,
|
||||
)
|
||||
return {}
|
||||
section = cfg_get(cfg, "dashboard", "drain_auth", default=None)
|
||||
return section if isinstance(section, dict) else {}
|
||||
|
||||
|
||||
def register(ctx) -> None:
|
||||
"""Plugin entry — registers DrainSecretProvider when a strong secret is set.
|
||||
|
||||
No-op (records a skip reason) when ``HERMES_DASHBOARD_DRAIN_SECRET`` is
|
||||
unset or fails the entropy gate. On success, also registers the
|
||||
begin/cancel-drain route as token-authable via the generic seam.
|
||||
"""
|
||||
global LAST_SKIP_REASON
|
||||
LAST_SKIP_REASON = ""
|
||||
|
||||
secret = os.environ.get("HERMES_DASHBOARD_DRAIN_SECRET", "").strip()
|
||||
if not secret:
|
||||
LAST_SKIP_REASON = (
|
||||
"HERMES_DASHBOARD_DRAIN_SECRET is not set. Set a per-agent "
|
||||
">=256-bit secret (e.g. `python -c \"import secrets; "
|
||||
"print(secrets.token_urlsafe(32))\"`) to enable NAS-driven drain "
|
||||
"coordination; leave it unset to disable the drain endpoint."
|
||||
)
|
||||
logger.debug("dashboard-auth-drain: %s", LAST_SKIP_REASON)
|
||||
return
|
||||
|
||||
section = _load_config_drain_auth_section()
|
||||
scope = str(section.get("scope", "drain") or "drain").strip() or "drain"
|
||||
try:
|
||||
min_chars = int(section.get("min_secret_chars", _DEFAULT_MIN_SECRET_CHARS))
|
||||
except (TypeError, ValueError):
|
||||
min_chars = _DEFAULT_MIN_SECRET_CHARS
|
||||
|
||||
reason = assess_secret_strength(secret, min_chars=min_chars)
|
||||
if reason is not None:
|
||||
LAST_SKIP_REASON = (
|
||||
f"HERMES_DASHBOARD_DRAIN_SECRET rejected — {reason}. "
|
||||
"The drain endpoint stays disabled (fail-closed)."
|
||||
)
|
||||
logger.warning("dashboard-auth-drain: %s", LAST_SKIP_REASON)
|
||||
return
|
||||
|
||||
try:
|
||||
provider = DrainSecretProvider(secret=secret, scope=scope)
|
||||
except ValueError as exc:
|
||||
LAST_SKIP_REASON = f"DrainSecretProvider construction failed: {exc}"
|
||||
logger.warning("dashboard-auth-drain: %s", LAST_SKIP_REASON)
|
||||
return
|
||||
|
||||
ctx.register_dashboard_auth_provider(provider)
|
||||
|
||||
# Opt the begin/cancel-drain endpoint into the generic token-auth seam so
|
||||
# the dashboard's interactive cookie gate doesn't bounce NAS's bearer call.
|
||||
try:
|
||||
from hermes_cli.dashboard_auth.token_auth import register_token_route
|
||||
|
||||
register_token_route(DRAIN_ROUTE_PATH)
|
||||
except Exception as exc: # noqa: BLE001 — seam import must not crash plugin load
|
||||
logger.warning(
|
||||
"dashboard-auth-drain: could not register token route %s: %s",
|
||||
DRAIN_ROUTE_PATH, exc,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"dashboard-auth-drain: registered drain service-credential provider "
|
||||
"(scope=%s, route=%s)",
|
||||
scope, DRAIN_ROUTE_PATH,
|
||||
)
|
||||
@@ -0,0 +1,7 @@
|
||||
name: drain
|
||||
version: 1.0.0
|
||||
description: "Dashboard auth provider — non-interactive shared-bearer-secret for the gateway drain-control endpoint. The first consumer of the generic token-auth capability (supports_token/verify_token). nous-account-service provisions a per-agent unique secret via HERMES_DASHBOARD_DRAIN_SECRET; this provider verifies an inbound Authorization bearer token against it with a constant-time compare and registers /api/gateway/drain as token-authable. Fails CLOSED: a weak/short/low-entropy secret (< 256 bits) is rejected at registration and the endpoint stays disabled. No-op when the env var is unset. Behavioural knobs (scope, min_secret_chars) live under dashboard.drain_auth in config.yaml."
|
||||
author: NousResearch
|
||||
kind: backend
|
||||
requires_env:
|
||||
- HERMES_DASHBOARD_DRAIN_SECRET
|
||||
@@ -0,0 +1,673 @@
|
||||
"""NousDashboardAuthProvider — Nous Portal OAuth (authorization-code + PKCE).
|
||||
|
||||
Implements ``nous-account-service/docs/agent-dashboard-oauth-contract.md``
|
||||
(PR #180). The plugin auto-loads (bundled, kind=backend) but only registers
|
||||
its provider when a client_id is configured — either via ``config.yaml`` or
|
||||
via the Portal-injected env var — so loopback / ``--insecure`` operators
|
||||
are unaffected.
|
||||
|
||||
Configuration surfaces (env wins over config.yaml when set non-empty):
|
||||
|
||||
``config.yaml`` — canonical surface::
|
||||
|
||||
dashboard:
|
||||
oauth:
|
||||
client_id: agent:{agent_instance_id} # required
|
||||
portal_url: https://portal.example # optional
|
||||
|
||||
Environment overrides — used by Fly.io's platform-secret injection so
|
||||
per-deploy values don't need to bake into ``config.yaml``:
|
||||
|
||||
HERMES_DASHBOARD_OAUTH_CLIENT_ID — shape ``agent:{agent_instance_id}``
|
||||
HERMES_DASHBOARD_PORTAL_URL — defaults to
|
||||
``https://portal.nousresearch.com``
|
||||
(production Portal). Override only
|
||||
for staging (``portal.rewbs.uk``)
|
||||
or a custom deployment.
|
||||
|
||||
Empty env var values are treated as unset so a provisioned-but-not-populated
|
||||
Fly secret can't shadow a valid config.yaml entry.
|
||||
|
||||
Key contract points encoded here:
|
||||
|
||||
- client_id is per-instance (``agent:{instance_id}``); the suffix is also
|
||||
cross-checked against the token's ``agent_instance_id`` claim as
|
||||
defense-in-depth.
|
||||
- scope is ``agent_dashboard:access`` only (no OIDC scopes).
|
||||
- tokens are RS256 JWTs verified against ``/.well-known/jwks.json``;
|
||||
JWKS is cached for 5 minutes.
|
||||
- the dashboard auth-code grant issues a 24h rotating refresh token
|
||||
(Portal NAS PR #293). ``refresh_session`` posts ``grant_type=refresh_token``
|
||||
to rotate the access token; ``complete_login`` and ``refresh_session``
|
||||
both populate ``Session.refresh_token`` with the (rotating) value the
|
||||
middleware persists back to the HttpOnly cookie. On a dead/expired/
|
||||
reuse-detected refresh token Portal returns 400 → ``RefreshExpiredError``
|
||||
→ middleware redirects to ``/auth/login``.
|
||||
- audience claim is the bare ``client_id`` (no ``hermes-cli:`` prefix).
|
||||
- tolerant ``oauth_contract_version`` check: missing → warn + proceed;
|
||||
present and ``!= 1`` → refuse.
|
||||
|
||||
The cookie payload returned by ``start_login`` stashes the PKCE
|
||||
``code_verifier`` and the OAuth ``state`` parameter for the
|
||||
``/auth/callback`` handler to retrieve. The auth-route layer is the owner
|
||||
of cookie names; this provider just hands back ``{"code_verifier": …,
|
||||
"state": …}`` and the route serializes those into the ``hermes_session_pkce``
|
||||
cookie.
|
||||
|
||||
Refresh-token rotation: Portal rotates the refresh token on every
|
||||
successful refresh and runs reuse-detection (replaying a rotated token
|
||||
outside Portal's 60s grace revokes the whole session). The host
|
||||
middleware therefore MUST persist the rotated ``Session.refresh_token``
|
||||
back to the cookie on every refresh.
|
||||
|
||||
Skip reasons:
|
||||
The plugin exposes a module-level ``LAST_SKIP_REASON`` that the gate's
|
||||
fail-closed branch reads to surface a useful operator error message
|
||||
("Set HERMES_DASHBOARD_OAUTH_CLIENT_ID …") instead of the bare "no
|
||||
providers registered" the gate would otherwise emit.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
import urllib.parse
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import httpx
|
||||
|
||||
from hermes_cli.dashboard_auth import (
|
||||
DashboardAuthProvider,
|
||||
InvalidCodeError,
|
||||
LoginStart,
|
||||
ProviderError,
|
||||
RefreshExpiredError,
|
||||
classify_jwks_lookup_error,
|
||||
Session,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Defaults
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Production Portal URL. Override via HERMES_DASHBOARD_PORTAL_URL for
|
||||
# staging (portal.rewbs.uk) or a custom deployment. Contract docs name
|
||||
# this as the production issuer.
|
||||
_DEFAULT_PORTAL_URL = "https://portal.nousresearch.com"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Skip-reason channel for operator-friendly error messages
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# When the plugin loads but refuses to register (missing / malformed
|
||||
# env vars), the auth gate downstream just sees "zero providers" and
|
||||
# emits a generic "install a provider" error. That's misleading for the
|
||||
# common case where the provider IS installed but mis-configured. The
|
||||
# plugin writes the *specific* reason to this module-level slot; the
|
||||
# gate reads it back when building its fail-closed SystemExit message.
|
||||
#
|
||||
# Cleared on every register() call so repeated dashboard starts in the
|
||||
# same process (tests, hot-reload) don't leak stale reasons.
|
||||
|
||||
LAST_SKIP_REASON: str = ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Contract constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Contract C3: scope name for the dashboard flow.
|
||||
_SCOPE = "agent_dashboard:access"
|
||||
|
||||
# Contract C11: emitted claim should equal 1; tolerant (warn) if missing.
|
||||
_EXPECTED_CONTRACT_VERSION = 1
|
||||
|
||||
# Contract C7: JWKS Cache-Control max-age=300.
|
||||
_JWKS_CACHE_SECONDS = 300
|
||||
|
||||
# httpx timeout for the token endpoint POST.
|
||||
_TOKEN_ENDPOINT_TIMEOUT_SEC = 10.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _b64url_no_pad(raw: bytes) -> str:
|
||||
"""Base64url-encode without ``=`` padding (RFC 7636 §4)."""
|
||||
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Provider
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class NousDashboardAuthProvider(DashboardAuthProvider):
|
||||
"""Nous Portal OAuth via authorization-code + PKCE (S256)."""
|
||||
|
||||
name = "nous"
|
||||
display_name = "Nous Research"
|
||||
|
||||
def __init__(self, *, client_id: str, portal_url: str) -> None:
|
||||
if not client_id.startswith("agent:"):
|
||||
# Defense-in-depth. The plugin entry point already filters, but
|
||||
# the provider should never be constructible with a malformed id.
|
||||
raise ValueError(
|
||||
"client_id must match contract shape 'agent:{instance_id}', "
|
||||
f"got {client_id!r}"
|
||||
)
|
||||
self._client_id = client_id
|
||||
self._agent_instance_id = client_id[len("agent:") :]
|
||||
self._portal_url = portal_url.rstrip("/")
|
||||
self._jwks_url = f"{self._portal_url}/.well-known/jwks.json"
|
||||
self._authorize_url = f"{self._portal_url}/oauth/authorize"
|
||||
self._token_url = f"{self._portal_url}/api/oauth/token"
|
||||
# PyJWKClient is lazily imported so plugin discovery doesn't pay the
|
||||
# crypto-import cost when the provider isn't activated.
|
||||
self._jwks_client: Any = None
|
||||
|
||||
# ---- public API (DashboardAuthProvider) -------------------------------
|
||||
|
||||
def start_login(self, *, redirect_uri: str) -> LoginStart:
|
||||
self._validate_redirect_uri(redirect_uri)
|
||||
|
||||
code_verifier = _b64url_no_pad(secrets.token_bytes(64)) # ~86 chars
|
||||
code_challenge = _b64url_no_pad(
|
||||
hashlib.sha256(code_verifier.encode("ascii")).digest()
|
||||
)
|
||||
state = _b64url_no_pad(secrets.token_bytes(32))
|
||||
|
||||
params = {
|
||||
"response_type": "code",
|
||||
"client_id": self._client_id,
|
||||
"redirect_uri": redirect_uri,
|
||||
"scope": _SCOPE,
|
||||
"state": state,
|
||||
"code_challenge": code_challenge,
|
||||
"code_challenge_method": "S256",
|
||||
}
|
||||
redirect_url = f"{self._authorize_url}?{urllib.parse.urlencode(params)}"
|
||||
# The auth-route layer expects ``cookie_payload[\"hermes_session_pkce\"]``
|
||||
# as a single semicolon-delimited string of ``key=value`` segments,
|
||||
# matching the stub provider's shape. The route handler prepends
|
||||
# ``provider=`` so the callback knows which plugin to dispatch to.
|
||||
cookie_payload = {
|
||||
"hermes_session_pkce": f"state={state};verifier={code_verifier}",
|
||||
}
|
||||
return LoginStart(redirect_url=redirect_url, cookie_payload=cookie_payload)
|
||||
|
||||
def complete_login(
|
||||
self,
|
||||
*,
|
||||
code: str,
|
||||
state: str,
|
||||
code_verifier: str,
|
||||
redirect_uri: str,
|
||||
) -> Session:
|
||||
# ``state`` is verified by the auth-route layer before this call
|
||||
# (it checks the cookie-stashed state matches the query-param state);
|
||||
# we just receive it for symmetry with the protocol. Nous Portal
|
||||
# doesn't re-check state at the token endpoint, so we ignore it here.
|
||||
_ = state
|
||||
|
||||
try:
|
||||
response = httpx.post(
|
||||
self._token_url,
|
||||
data={
|
||||
"grant_type": "authorization_code",
|
||||
"code": code,
|
||||
"redirect_uri": redirect_uri,
|
||||
"client_id": self._client_id,
|
||||
"code_verifier": code_verifier,
|
||||
},
|
||||
headers={"Accept": "application/json"},
|
||||
timeout=_TOKEN_ENDPOINT_TIMEOUT_SEC,
|
||||
)
|
||||
except httpx.RequestError as exc:
|
||||
raise ProviderError(f"Portal token endpoint unreachable: {exc}") from exc
|
||||
|
||||
# The dashboard auth-code grant now issues a rotating refresh token
|
||||
# (24h session, reuse-detected) — Portal NAS PR #293. A 400 here means
|
||||
# the code/PKCE/redirect_uri failed, surfaced as InvalidCodeError.
|
||||
return self._token_response_to_session(
|
||||
response, bad_request_exc=InvalidCodeError
|
||||
)
|
||||
|
||||
def refresh_session(self, *, refresh_token: str) -> Session:
|
||||
"""Rotate the access token using the refresh token.
|
||||
|
||||
Posts ``grant_type=refresh_token`` to Portal's token endpoint. The
|
||||
refresh token is sent in the ``X-Refresh-Token`` header (not the body)
|
||||
so it never lands in Portal's request-body access logs — mirroring the
|
||||
device-flow CLI convention; Portal reconciles header vs. body and
|
||||
rejects conflicts.
|
||||
|
||||
Portal rotates the refresh token on every successful refresh, so the
|
||||
returned ``Session.refresh_token`` is a NEW value the caller MUST
|
||||
persist (replacing the old cookie). Failing to persist it means the
|
||||
next refresh replays a rotated token and — outside Portal's 60s grace
|
||||
— trips reuse-detection and revokes the whole session.
|
||||
|
||||
Raises ``RefreshExpiredError`` on a 400 (expired / revoked / reuse-
|
||||
detected), so the middleware clears cookies and forces re-login.
|
||||
Raises ``ProviderError`` if Portal is unreachable.
|
||||
"""
|
||||
if not refresh_token:
|
||||
# No RT to present — treat as a dead session so middleware
|
||||
# forces a clean re-login rather than emitting a malformed POST.
|
||||
raise RefreshExpiredError("no refresh token present in session")
|
||||
|
||||
try:
|
||||
response = httpx.post(
|
||||
self._token_url,
|
||||
# The refresh token goes in BOTH the body and the
|
||||
# ``x-nous-refresh-token`` header. Portal's token endpoint
|
||||
# requires ``refresh_token`` in the body (its request schema
|
||||
# rejects a header-only request as ``invalid_request``), and
|
||||
# additionally reconciles the header against the body — sending
|
||||
# both lets Portal keep the value out of body-access-logs while
|
||||
# still satisfying the schema. The header name must match
|
||||
# Portal's ``REFRESH_TOKEN_HEADER`` exactly (``x-nous-refresh-
|
||||
# token``); any other name is silently ignored. (Verified
|
||||
# against the NAS #293 preview deploy: header-only → 400
|
||||
# invalid_request; body → accepted.)
|
||||
data={
|
||||
"grant_type": "refresh_token",
|
||||
"client_id": self._client_id,
|
||||
"refresh_token": refresh_token,
|
||||
},
|
||||
headers={
|
||||
"Accept": "application/json",
|
||||
"x-nous-refresh-token": refresh_token,
|
||||
},
|
||||
timeout=_TOKEN_ENDPOINT_TIMEOUT_SEC,
|
||||
)
|
||||
except httpx.RequestError as exc:
|
||||
raise ProviderError(
|
||||
f"Portal token endpoint unreachable: {exc}"
|
||||
) from exc
|
||||
|
||||
# A 400 on refresh means the RT is expired / revoked / reuse-detected;
|
||||
# surface as RefreshExpiredError so middleware forces re-login.
|
||||
return self._token_response_to_session(
|
||||
response, bad_request_exc=RefreshExpiredError
|
||||
)
|
||||
|
||||
def _token_response_to_session(
|
||||
self,
|
||||
response: httpx.Response,
|
||||
*,
|
||||
bad_request_exc: type[Exception],
|
||||
) -> Session:
|
||||
"""Translate a Portal ``/api/oauth/token`` response into a Session.
|
||||
|
||||
Shared by ``complete_login`` (auth-code grant) and ``refresh_session``
|
||||
(refresh grant). ``bad_request_exc`` is the exception type raised on a
|
||||
400 — ``InvalidCodeError`` for the auth-code path, ``RefreshExpiredError``
|
||||
for the refresh path — so the middleware's distinct handling
|
||||
(400-on-callback vs. force-relogin) is preserved.
|
||||
"""
|
||||
if response.status_code == 400:
|
||||
# Contract: invalid_code / invalid_grant / redirect_uri_mismatch
|
||||
# (auth-code) and expired / revoked / reuse-detected (refresh) all
|
||||
# surface as 400 with an OAuth-shaped JSON error envelope.
|
||||
body = self._parse_json_body(response)
|
||||
error_code = body.get("error", "invalid_request")
|
||||
raise bad_request_exc(f"Portal rejected token request: {error_code}")
|
||||
if response.status_code != 200:
|
||||
raise ProviderError(
|
||||
f"Portal token endpoint returned {response.status_code}: "
|
||||
f"{response.text[:200]!r}"
|
||||
)
|
||||
|
||||
payload = self._parse_json_body(response)
|
||||
access_token = payload.get("access_token")
|
||||
if not access_token or not isinstance(access_token, str):
|
||||
raise ProviderError("Portal token response missing access_token")
|
||||
|
||||
token_type = str(payload.get("token_type", "")).lower()
|
||||
if token_type and token_type != "bearer":
|
||||
raise ProviderError(f"unexpected token_type={token_type!r}")
|
||||
|
||||
claims = self._verify_jwt(access_token)
|
||||
# The dashboard grant issues a rotating refresh token; capture it so
|
||||
# the caller can persist it. Empty string if Portal omitted it (the
|
||||
# session then behaves as access-token-only until expiry).
|
||||
refresh_token = payload.get("refresh_token") or ""
|
||||
if not isinstance(refresh_token, str):
|
||||
refresh_token = ""
|
||||
return self._session_from_claims(access_token, refresh_token, claims)
|
||||
|
||||
|
||||
def verify_session(self, *, access_token: str) -> Optional[Session]:
|
||||
# Contract: returns None on expiry/invalidity (the middleware then
|
||||
# tries refresh_session with the RT cookie, falling back to
|
||||
# redirect-to-login if that also fails); raises ProviderError if the
|
||||
# IDP is unreachable.
|
||||
try:
|
||||
claims = self._verify_jwt(access_token)
|
||||
except InvalidCodeError:
|
||||
# Expired/invalid token — middleware contract is None, not raise.
|
||||
return None
|
||||
except ProviderError:
|
||||
# JWKS unreachable, etc. Bubble up so middleware emits 503.
|
||||
raise
|
||||
# verify_session validates the AT in isolation and has no access to the
|
||||
# refresh token (it lives in a separate cookie the middleware reads);
|
||||
# pass "" here — the RT-driven rotation path is middleware's job.
|
||||
return self._session_from_claims(access_token, "", claims)
|
||||
|
||||
def revoke_session(self, *, refresh_token: str) -> None:
|
||||
# Portal exposes no public refresh-token revocation grant on its token
|
||||
# endpoint (revocation is driven from the authenticated /sessions UI,
|
||||
# keyed by sessionId + userId, not by the RT value). So logout is
|
||||
# client-side cookie clearing; the server-side refresh session simply
|
||||
# expires within its 24h TTL. Best-effort no-op, must not raise.
|
||||
#
|
||||
# If Portal later adds a token-endpoint revoke grant (e.g.
|
||||
# grant_type=... + X-Refresh-Token), implement it here so logout
|
||||
# invalidates the RT server-side immediately rather than waiting out
|
||||
# the TTL.
|
||||
_ = refresh_token
|
||||
return None
|
||||
|
||||
# ---- internals --------------------------------------------------------
|
||||
|
||||
def _validate_redirect_uri(self, redirect_uri: str) -> None:
|
||||
"""Surface obviously-broken redirect_uris before bouncing to Portal.
|
||||
|
||||
The Portal-side check (``agent-redirect-uri.ts``) is authoritative;
|
||||
this is a fast-fail for the common operator-error case. We allow any
|
||||
``http://`` host (not just localhost) so self-hosted dashboards reached
|
||||
over plain HTTP — LAN IPs, internal hostnames, reverse proxies that
|
||||
terminate TLS upstream — are not rejected here; Portal makes the final
|
||||
call on which redirect_uris are permitted.
|
||||
"""
|
||||
parsed = urllib.parse.urlparse(redirect_uri)
|
||||
if parsed.scheme not in ("https", "http"):
|
||||
raise ProviderError(
|
||||
f"redirect_uri must be http(s), got {redirect_uri!r}"
|
||||
)
|
||||
if not parsed.path or not parsed.path.endswith("/auth/callback"):
|
||||
raise ProviderError(
|
||||
"redirect_uri path must end with '/auth/callback', "
|
||||
f"got {redirect_uri!r}"
|
||||
)
|
||||
|
||||
def _parse_json_body(self, response: httpx.Response) -> Dict[str, Any]:
|
||||
ctype = response.headers.get("content-type", "")
|
||||
if not ctype.startswith("application/json"):
|
||||
return {}
|
||||
try:
|
||||
body = response.json()
|
||||
except ValueError:
|
||||
return {}
|
||||
return body if isinstance(body, dict) else {}
|
||||
|
||||
def _get_jwks_client(self) -> Any:
|
||||
if self._jwks_client is None:
|
||||
from jwt import PyJWKClient # lazy import
|
||||
|
||||
self._jwks_client = PyJWKClient(
|
||||
self._jwks_url,
|
||||
cache_keys=True,
|
||||
lifespan=_JWKS_CACHE_SECONDS,
|
||||
headers={
|
||||
"Accept": "application/json",
|
||||
"User-Agent": "HermesAgent/1.0",
|
||||
},
|
||||
)
|
||||
return self._jwks_client
|
||||
|
||||
def _verify_jwt(self, access_token: str) -> Dict[str, Any]:
|
||||
# Lazy import — keeps startup fast for operators who never trigger
|
||||
# the gated path.
|
||||
import jwt
|
||||
|
||||
try:
|
||||
signing_key = self._get_jwks_client().get_signing_key_from_jwt(
|
||||
access_token
|
||||
)
|
||||
except Exception as exc:
|
||||
# Unreachable JWKS -> ProviderError (503); a bearer that is not
|
||||
# one of our JWTs (opaque peer key, foreign kid) -> InvalidCodeError
|
||||
# (None / next provider). Folding both into 503 produced #94558.
|
||||
raise classify_jwks_lookup_error(exc) from exc
|
||||
|
||||
try:
|
||||
claims = jwt.decode(
|
||||
access_token,
|
||||
signing_key.key,
|
||||
algorithms=["RS256"],
|
||||
# Contract C2: aud is the bare client_id.
|
||||
audience=self._client_id,
|
||||
# Contract: issuer is the Portal base URL.
|
||||
issuer=self._portal_url,
|
||||
options={"require": ["exp", "iat", "aud", "iss", "sub"]},
|
||||
)
|
||||
except jwt.ExpiredSignatureError as exc:
|
||||
# verify_session() catches this and returns None per protocol.
|
||||
raise InvalidCodeError(f"access token expired: {exc}") from exc
|
||||
except jwt.InvalidTokenError as exc:
|
||||
# Surface the actual claim values that failed verification so
|
||||
# operators don't have to dig into the JWT to debug config drift
|
||||
# between HERMES_DASHBOARD_PORTAL_URL / HERMES_DASHBOARD_OAUTH_CLIENT_ID
|
||||
# and what Portal is actually emitting. Decoding without verification
|
||||
# is safe here: we've already failed to verify, and we never trust
|
||||
# these values — they're surfaced for diagnostics only.
|
||||
details = ""
|
||||
try:
|
||||
unverified = jwt.decode(
|
||||
access_token,
|
||||
options={"verify_signature": False, "verify_exp": False},
|
||||
)
|
||||
details = (
|
||||
f" [token iss={unverified.get('iss')!r} "
|
||||
f"aud={unverified.get('aud')!r}; "
|
||||
f"expected iss={self._portal_url!r} "
|
||||
f"aud={self._client_id!r}]"
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
raise ProviderError(
|
||||
f"access token verification failed: {exc}{details}"
|
||||
) from exc
|
||||
|
||||
self._check_agent_instance_id(claims)
|
||||
self._check_contract_version(claims)
|
||||
return claims
|
||||
|
||||
def _check_agent_instance_id(self, claims: Dict[str, Any]) -> None:
|
||||
"""Contract C9: cross-check agent_instance_id against our config."""
|
||||
token_instance_id = claims.get("agent_instance_id")
|
||||
if token_instance_id is None:
|
||||
# Tolerated — the claim is documented as "should" not "must".
|
||||
# Our audience check on the bare client_id already binds the
|
||||
# token to this instance; agent_instance_id is defense-in-depth.
|
||||
return
|
||||
if token_instance_id != self._agent_instance_id:
|
||||
raise ProviderError(
|
||||
f"agent_instance_id mismatch: token={token_instance_id!r} "
|
||||
f"vs configured={self._agent_instance_id!r}"
|
||||
)
|
||||
|
||||
def _check_contract_version(self, claims: Dict[str, Any]) -> None:
|
||||
"""Contract C11 — tolerant treatment per OQ-C2."""
|
||||
contract_version = claims.get("oauth_contract_version")
|
||||
if contract_version is None:
|
||||
logger.warning(
|
||||
"Nous Portal token missing oauth_contract_version claim "
|
||||
"(contract says it should be %d); proceeding anyway.",
|
||||
_EXPECTED_CONTRACT_VERSION,
|
||||
)
|
||||
return
|
||||
if contract_version != _EXPECTED_CONTRACT_VERSION:
|
||||
raise ProviderError(
|
||||
f"unsupported oauth_contract_version={contract_version!r}, "
|
||||
f"expected {_EXPECTED_CONTRACT_VERSION}"
|
||||
)
|
||||
|
||||
def _session_from_claims(
|
||||
self,
|
||||
access_token: str,
|
||||
refresh_token: str,
|
||||
claims: Dict[str, Any],
|
||||
) -> Session:
|
||||
# Contract C4: no email / display_name in tokens. AuthWidget will
|
||||
# show user_id (truncated). Session fields kept for forward-compat.
|
||||
user_id = str(claims.get("sub", ""))
|
||||
if not user_id:
|
||||
raise ProviderError("token missing 'sub' (user_id) claim")
|
||||
return Session(
|
||||
user_id=user_id,
|
||||
email="",
|
||||
display_name="",
|
||||
org_id=str(claims.get("org_id") or ""),
|
||||
provider=self.name,
|
||||
expires_at=int(claims["exp"]),
|
||||
access_token=access_token,
|
||||
refresh_token=refresh_token,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Plugin entry point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _load_config_oauth_section() -> dict:
|
||||
"""Return the ``dashboard.oauth`` block from ``config.yaml`` if it
|
||||
exists and is a dict; otherwise an empty dict.
|
||||
|
||||
Robust to (a) load_config() raising (malformed YAML, IO error,
|
||||
config.yaml absent — common in fresh installs), (b) the
|
||||
``dashboard`` key being absent or non-dict, and (c) the ``oauth``
|
||||
sub-key being present but not a dict (user typo). Each shape falls
|
||||
through to ``{}`` so register() can rely on `.get(...)` access.
|
||||
"""
|
||||
try:
|
||||
from hermes_cli.config import cfg_get, load_config
|
||||
|
||||
cfg = load_config()
|
||||
except Exception as exc: # noqa: BLE001 — broad catch is intentional
|
||||
logger.debug(
|
||||
"dashboard-auth-nous: load_config() raised %s; "
|
||||
"falling back to env-only configuration",
|
||||
exc,
|
||||
)
|
||||
return {}
|
||||
section = cfg_get(cfg, "dashboard", "oauth", default=None)
|
||||
return section if isinstance(section, dict) else {}
|
||||
|
||||
|
||||
def _resolve_client_id() -> str:
|
||||
"""Resolve the OAuth client_id with env-overrides-config precedence.
|
||||
|
||||
Order:
|
||||
1. ``HERMES_DASHBOARD_OAUTH_CLIENT_ID`` env var (when non-empty
|
||||
after strip — empty values are treated as unset so a
|
||||
provisioned-but-not-populated Fly secret can't shadow a valid
|
||||
config.yaml entry).
|
||||
2. ``dashboard.oauth.client_id`` in ``config.yaml``.
|
||||
3. Empty string — signals "no client_id configured" to the caller.
|
||||
"""
|
||||
env = os.environ.get("HERMES_DASHBOARD_OAUTH_CLIENT_ID", "").strip()
|
||||
if env:
|
||||
return env
|
||||
cfg_value = _load_config_oauth_section().get("client_id", "")
|
||||
return str(cfg_value).strip()
|
||||
|
||||
|
||||
def _resolve_portal_url() -> str:
|
||||
"""Resolve the Portal URL with env-overrides-config precedence.
|
||||
|
||||
Order:
|
||||
1. ``HERMES_DASHBOARD_PORTAL_URL`` env var (non-empty after strip).
|
||||
2. ``dashboard.oauth.portal_url`` in ``config.yaml``.
|
||||
3. :data:`_DEFAULT_PORTAL_URL` (production Portal).
|
||||
"""
|
||||
env = os.environ.get("HERMES_DASHBOARD_PORTAL_URL", "").strip()
|
||||
if env:
|
||||
return env
|
||||
cfg_value = str(
|
||||
_load_config_oauth_section().get("portal_url", "")
|
||||
).strip()
|
||||
return cfg_value or _DEFAULT_PORTAL_URL
|
||||
|
||||
|
||||
def register(ctx) -> None:
|
||||
"""Plugin entry — called by the plugin loader at startup.
|
||||
|
||||
Registers ``NousDashboardAuthProvider`` only when a client_id is
|
||||
configured (either via ``HERMES_DASHBOARD_OAUTH_CLIENT_ID`` env var
|
||||
or via ``dashboard.oauth.client_id`` in ``config.yaml``). The env
|
||||
var wins when set non-empty — Fly.io's platform-secret injection
|
||||
pushes the per-deploy value through this path.
|
||||
|
||||
When skipping, writes a short human-readable reason to the module-
|
||||
level :data:`LAST_SKIP_REASON` so the dashboard's fail-closed branch
|
||||
can surface "Set HERMES_DASHBOARD_OAUTH_CLIENT_ID …" instead of the
|
||||
bare "no providers registered" the gate would otherwise emit. The
|
||||
reason mentions BOTH configuration surfaces so operators don't
|
||||
guess wrong about which one to populate.
|
||||
|
||||
Operator-owned dashboards (loopback / ``--insecure``) leave both
|
||||
surfaces unset, so this plugin is a no-op for them. The gate-
|
||||
engagement layer (``hermes_cli.web_server.should_require_auth`` +
|
||||
the fail-closed check in ``start_server``) handles the "public bind
|
||||
with zero providers" case independently.
|
||||
"""
|
||||
global LAST_SKIP_REASON
|
||||
LAST_SKIP_REASON = ""
|
||||
|
||||
client_id = _resolve_client_id()
|
||||
portal_url = _resolve_portal_url()
|
||||
|
||||
if not client_id:
|
||||
LAST_SKIP_REASON = (
|
||||
"HERMES_DASHBOARD_OAUTH_CLIENT_ID is not set (and "
|
||||
"dashboard.oauth.client_id in config.yaml is empty). The "
|
||||
"Nous Portal provisions this env var (shape "
|
||||
"'agent:{instance_id}') when it deploys a Hermes Agent "
|
||||
"instance — set it to your provisioned client id (either "
|
||||
"as an env var or under dashboard.oauth.client_id in "
|
||||
"config.yaml), or pass --insecure to skip the OAuth gate "
|
||||
"entirely."
|
||||
)
|
||||
logger.debug("dashboard-auth-nous: %s", LAST_SKIP_REASON)
|
||||
return
|
||||
|
||||
if not client_id.startswith("agent:"):
|
||||
LAST_SKIP_REASON = (
|
||||
f"HERMES_DASHBOARD_OAUTH_CLIENT_ID={client_id!r} doesn't match "
|
||||
f"the contract shape 'agent:{{instance_id}}'. The Nous Portal "
|
||||
f"provisions this value at deploy time; check your Fly app's "
|
||||
f"secrets or override with the value from the Portal admin UI."
|
||||
)
|
||||
logger.warning("dashboard-auth-nous: %s", LAST_SKIP_REASON)
|
||||
return
|
||||
|
||||
try:
|
||||
provider = NousDashboardAuthProvider(
|
||||
client_id=client_id, portal_url=portal_url
|
||||
)
|
||||
except ValueError as exc:
|
||||
LAST_SKIP_REASON = f"NousDashboardAuthProvider construction failed: {exc}"
|
||||
logger.warning("dashboard-auth-nous: %s", LAST_SKIP_REASON)
|
||||
return
|
||||
|
||||
ctx.register_dashboard_auth_provider(provider)
|
||||
logger.info(
|
||||
"dashboard-auth-nous: registered provider (client_id=%s, portal=%s)",
|
||||
client_id,
|
||||
portal_url,
|
||||
)
|
||||
@@ -0,0 +1,7 @@
|
||||
name: nous
|
||||
version: 1.0.0
|
||||
description: "Dashboard auth provider — OAuth 2.0 (authorization-code + PKCE) against Nous Portal. Auto-activates when a client_id is configured via either dashboard.oauth.client_id in config.yaml (canonical surface) or HERMES_DASHBOARD_OAUTH_CLIENT_ID env var (operator override; Portal injects this at Fly.io provisioning). dashboard.oauth.portal_url / HERMES_DASHBOARD_PORTAL_URL are optional and default to https://portal.nousresearch.com."
|
||||
author: NousResearch
|
||||
kind: backend
|
||||
requires_env:
|
||||
- HERMES_DASHBOARD_OAUTH_CLIENT_ID
|
||||
@@ -0,0 +1,864 @@
|
||||
"""SelfHostedOIDCProvider — generic self-hosted OpenID Connect dashboard auth.
|
||||
|
||||
A standards-compliant OpenID Connect Relying Party for the ``hermes dashboard``
|
||||
OAuth gate. Unlike the bundled ``nous`` provider (which encodes Nous Portal's
|
||||
bespoke contract — ``agent:{instance_id}`` client ids, a custom access-token
|
||||
JWT, the ``x-nous-refresh-token`` header, an ``oauth_contract_version`` claim),
|
||||
this provider speaks **plain OIDC** so it works against any conformant
|
||||
self-hosted identity provider:
|
||||
|
||||
Authentik · Keycloak · Zitadel · Authelia · Auth0 · Okta · Google · …
|
||||
|
||||
It is a pure drop-in plugin: it implements the five
|
||||
:class:`~hermes_cli.dashboard_auth.DashboardAuthProvider` methods and touches
|
||||
nothing in core auth/runtime/login. The HTTP round trip, cookies, CSRF
|
||||
``state`` check and ``redirect_uri`` reconstruction are all owned by
|
||||
``hermes_cli/dashboard_auth/routes.py``; this provider only:
|
||||
|
||||
1. discovers the IDP's endpoints from ``{issuer}/.well-known/openid-configuration``,
|
||||
2. builds the ``/authorize`` URL with PKCE (S256),
|
||||
3. exchanges the authorization code for tokens at the discovered
|
||||
``token_endpoint``,
|
||||
4. verifies the **ID token** (RS256/ES256) against the discovered
|
||||
``jwks_uri`` with ``iss`` / ``aud`` pinned to the configured issuer /
|
||||
client id, and maps standard OIDC claims (``sub``, ``email``, ``name``)
|
||||
onto a :class:`~hermes_cli.dashboard_auth.Session`.
|
||||
|
||||
Why the ID token (not the access token)? OIDC guarantees the ID token is a
|
||||
signed JWT carrying identity claims — that is its entire purpose. The access
|
||||
token's format is opaque to the client per the spec; many IDPs issue random
|
||||
opaque strings the client cannot verify locally. Verifying the ID token is the
|
||||
only choice that is universally correct across self-hosted IDPs. (The ``nous``
|
||||
provider verifies its *access* token because Nous Portal mints a custom JWT
|
||||
access token with the dashboard claims baked in — a non-OIDC shortcut.)
|
||||
|
||||
Both **public** (PKCE-only) and **confidential** (PKCE + ``client_secret``)
|
||||
clients are supported. A self-hoster who registers a public client configures
|
||||
no secret and the token-endpoint calls authenticate with PKCE alone (the
|
||||
default). A self-hoster whose IDP defaults the client to *confidential*
|
||||
(Authentik and Keycloak commonly do) sets ``client_secret`` and the provider
|
||||
additionally authenticates the client at the token endpoint, choosing
|
||||
``client_secret_basic`` (HTTP Basic header) or ``client_secret_post`` (secret
|
||||
in the form body) from the IDP's advertised
|
||||
``token_endpoint_auth_methods_supported``. PKCE is sent in **both** modes —
|
||||
the secret is client authentication layered on top, never a replacement for
|
||||
PKCE (OAuth 2.1 / RFC 9700 keep PKCE mandatory regardless).
|
||||
|
||||
Configuration surfaces (env wins over config.yaml when set non-empty, so a
|
||||
provisioned-but-not-populated secret can't shadow a valid config.yaml entry —
|
||||
same precedence convention as the ``nous`` plugin)::
|
||||
|
||||
# config.yaml — canonical surface
|
||||
dashboard:
|
||||
oauth:
|
||||
provider: self-hosted
|
||||
self_hosted:
|
||||
issuer: https://auth.example.com/application/o/hermes/ # required
|
||||
client_id: hermes-dashboard # required
|
||||
scopes: "openid profile email" # optional
|
||||
# client_secret: set ONLY for a confidential client. It is a
|
||||
# credential — prefer the env var / ~/.hermes/.env over config.yaml.
|
||||
|
||||
# Environment overrides (Docker/Fly secret injection)
|
||||
HERMES_DASHBOARD_OIDC_ISSUER
|
||||
HERMES_DASHBOARD_OIDC_CLIENT_ID
|
||||
HERMES_DASHBOARD_OIDC_SCOPES # optional; defaults to "openid profile email"
|
||||
HERMES_DASHBOARD_OIDC_CLIENT_SECRET # optional; set for a confidential client
|
||||
# (the .env file is the canonical home —
|
||||
# it's a secret, not a behavioural setting)
|
||||
|
||||
Skip reasons: when the plugin loads but can't register (missing issuer /
|
||||
client_id), it writes a human-readable reason to the module-level
|
||||
:data:`LAST_SKIP_REASON` so the gate's fail-closed branch can surface a useful
|
||||
operator error instead of the bare "no providers registered".
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
import threading
|
||||
import time
|
||||
import urllib.parse
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import httpx
|
||||
|
||||
from hermes_cli.dashboard_auth import (
|
||||
DashboardAuthProvider,
|
||||
InvalidCodeError,
|
||||
LoginStart,
|
||||
ProviderError,
|
||||
RefreshExpiredError,
|
||||
classify_jwks_lookup_error,
|
||||
Session,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Defaults / constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# OIDC core scopes. ``openid`` is mandatory (without it the IDP won't issue an
|
||||
# ID token); ``profile``/``email`` populate the Session's display_name/email.
|
||||
_DEFAULT_SCOPES = "openid profile email"
|
||||
|
||||
# Signing algorithms we accept on the ID token. RS256 is the OIDC default;
|
||||
# ES256 is common on modern self-hosted IDPs (Zitadel, newer Keycloak realms).
|
||||
# HS256 is deliberately excluded — it implies a shared secret we don't have in
|
||||
# the public-client model and is a well-known JWT confusion footgun.
|
||||
_ALLOWED_ID_TOKEN_ALGS = ("RS256", "ES256", "RS384", "RS512", "ES384", "ES512")
|
||||
|
||||
# httpx timeouts.
|
||||
_DISCOVERY_TIMEOUT_SEC = 10.0
|
||||
_TOKEN_ENDPOINT_TIMEOUT_SEC = 10.0
|
||||
|
||||
# OIDC discovery is low-frequency and the document is effectively static;
|
||||
# cache it for the process lifetime with a soft TTL so a long-running
|
||||
# dashboard picks up an IDP endpoint migration within the hour.
|
||||
_DISCOVERY_CACHE_TTL_SEC = 3600
|
||||
|
||||
# JWKS cache (PyJWKClient handles its own caching; this mirrors the nous
|
||||
# provider's 5-minute lifespan so key rotation is picked up promptly).
|
||||
_JWKS_CACHE_SECONDS = 300
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Skip-reason channel (mirrors the nous plugin)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
LAST_SKIP_REASON: str = ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _b64url_no_pad(raw: bytes) -> str:
|
||||
"""Base64url-encode without ``=`` padding (RFC 7636 §4)."""
|
||||
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode()
|
||||
|
||||
|
||||
def _require_https_or_loopback(url: str, *, field: str) -> str:
|
||||
"""Reject an endpoint URL that isn't HTTPS (loopback http is allowed).
|
||||
|
||||
OAuth credentials (codes, tokens) flow over these URLs. We require HTTPS
|
||||
for everything except an explicit loopback host so a misconfigured issuer
|
||||
can't ship the authorization code / refresh token in cleartext. Returns
|
||||
the URL unchanged on success; raises :class:`ProviderError` otherwise.
|
||||
"""
|
||||
parsed = urllib.parse.urlparse(url)
|
||||
if parsed.scheme == "https":
|
||||
return url
|
||||
if parsed.scheme == "http" and (parsed.hostname or "") in (
|
||||
"localhost",
|
||||
"127.0.0.1",
|
||||
"::1",
|
||||
):
|
||||
return url
|
||||
raise ProviderError(
|
||||
f"OIDC {field} must be https:// (or http on localhost), got {url!r}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Provider
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class SelfHostedOIDCProvider(DashboardAuthProvider):
|
||||
"""Generic self-hosted OpenID Connect provider (authorization-code + PKCE)."""
|
||||
|
||||
name = "self-hosted"
|
||||
display_name = "Self-Hosted OIDC"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
issuer: str,
|
||||
client_id: str,
|
||||
scopes: str = _DEFAULT_SCOPES,
|
||||
client_secret: str = "",
|
||||
) -> None:
|
||||
if not issuer:
|
||||
raise ValueError("issuer is required")
|
||||
if not client_id:
|
||||
raise ValueError("client_id is required")
|
||||
# ``issuer`` is the OIDC issuer identifier. Normalise the trailing
|
||||
# slash for stable string compares (the ``iss`` claim must match the
|
||||
# issuer the IDP advertises in discovery — we pin against the
|
||||
# discovered value, not this normalised one, to be tolerant of a
|
||||
# trailing-slash mismatch between config and the IDP).
|
||||
self._issuer = issuer.rstrip("/")
|
||||
_require_https_or_loopback(self._issuer, field="issuer")
|
||||
self._client_id = client_id
|
||||
self._scopes = scopes.strip() or _DEFAULT_SCOPES
|
||||
# An empty/whitespace secret means "public client" — strip so a
|
||||
# provisioned-but-blank secret can't flip us into a broken confidential
|
||||
# mode that sends an empty client_secret. Non-empty ⇒ confidential.
|
||||
self._client_secret = (client_secret or "").strip()
|
||||
|
||||
# Discovery + JWKS are lazily resolved on first use so plugin
|
||||
# registration never makes a network call (the IDP may be down at
|
||||
# boot; the gate should still come up and fail per-request).
|
||||
self._discovery: Dict[str, Any] | None = None
|
||||
self._discovery_fetched_at: float = 0.0
|
||||
self._discovery_lock = threading.Lock()
|
||||
self._jwks_client: Any = None
|
||||
|
||||
# ---- public API (DashboardAuthProvider) -------------------------------
|
||||
|
||||
def start_login(self, *, redirect_uri: str) -> LoginStart:
|
||||
self._validate_redirect_uri(redirect_uri)
|
||||
disco = self._get_discovery()
|
||||
|
||||
code_verifier = _b64url_no_pad(secrets.token_bytes(64)) # ~86 chars
|
||||
code_challenge = _b64url_no_pad(
|
||||
hashlib.sha256(code_verifier.encode("ascii")).digest()
|
||||
)
|
||||
state = _b64url_no_pad(secrets.token_bytes(32))
|
||||
|
||||
params = {
|
||||
"response_type": "code",
|
||||
"client_id": self._client_id,
|
||||
"redirect_uri": redirect_uri,
|
||||
"scope": self._scopes,
|
||||
"state": state,
|
||||
"code_challenge": code_challenge,
|
||||
"code_challenge_method": "S256",
|
||||
}
|
||||
redirect_url = (
|
||||
f"{disco['authorization_endpoint']}?{urllib.parse.urlencode(params)}"
|
||||
)
|
||||
# Same flat ``state=…;verifier=…`` cookie shape every provider uses;
|
||||
# the auth-route layer prepends ``provider=`` and parses it back out.
|
||||
cookie_payload = {
|
||||
"hermes_session_pkce": f"state={state};verifier={code_verifier}",
|
||||
}
|
||||
return LoginStart(redirect_url=redirect_url, cookie_payload=cookie_payload)
|
||||
|
||||
def complete_login(
|
||||
self,
|
||||
*,
|
||||
code: str,
|
||||
state: str,
|
||||
code_verifier: str,
|
||||
redirect_uri: str,
|
||||
) -> Session:
|
||||
# ``state`` is verified by the auth-route layer before this call.
|
||||
_ = state
|
||||
disco = self._get_discovery()
|
||||
|
||||
data = {
|
||||
"grant_type": "authorization_code",
|
||||
"code": code,
|
||||
"redirect_uri": redirect_uri,
|
||||
"client_id": self._client_id,
|
||||
"code_verifier": code_verifier,
|
||||
}
|
||||
# Confidential clients additionally authenticate the client here (basic
|
||||
# header or post body, per the IDP's advertised methods); public
|
||||
# clients get ({}, {}) and authenticate with PKCE alone.
|
||||
extra_data, extra_headers = self._token_endpoint_auth(disco)
|
||||
data.update(extra_data)
|
||||
return self._exchange(
|
||||
disco["token_endpoint"],
|
||||
data,
|
||||
bad_request_exc=InvalidCodeError,
|
||||
extra_headers=extra_headers,
|
||||
)
|
||||
|
||||
def refresh_session(self, *, refresh_token: str) -> Session:
|
||||
if not refresh_token:
|
||||
raise RefreshExpiredError("no refresh token present in session")
|
||||
disco = self._get_discovery()
|
||||
|
||||
data = {
|
||||
"grant_type": "refresh_token",
|
||||
"client_id": self._client_id,
|
||||
"refresh_token": refresh_token,
|
||||
# Re-request the same scopes so the rotated ID token keeps the
|
||||
# identity claims (some IDPs narrow scope on refresh otherwise).
|
||||
"scope": self._scopes,
|
||||
}
|
||||
# Same client-authentication treatment as complete_login: confidential
|
||||
# clients must authenticate on the refresh grant too, or the IDP
|
||||
# rejects the rotation with invalid_client.
|
||||
extra_data, extra_headers = self._token_endpoint_auth(disco)
|
||||
data.update(extra_data)
|
||||
return self._exchange(
|
||||
disco["token_endpoint"],
|
||||
data,
|
||||
bad_request_exc=RefreshExpiredError,
|
||||
previous_refresh_token=refresh_token,
|
||||
extra_headers=extra_headers,
|
||||
)
|
||||
|
||||
def verify_session(self, *, access_token: str) -> Optional[Session]:
|
||||
# The session cookie stores the ID token in the access-token slot (see
|
||||
# ``_session_from_tokens``) precisely so this per-request check can
|
||||
# verify a real JWT. Returns None on expiry/invalidity (middleware
|
||||
# then refreshes or logs out); raises ProviderError if the IDP/JWKS is
|
||||
# unreachable.
|
||||
try:
|
||||
claims = self._verify_id_token(access_token)
|
||||
except InvalidCodeError:
|
||||
# Expired / invalid token — protocol says return None, not raise.
|
||||
return None
|
||||
except ProviderError:
|
||||
raise
|
||||
# No refresh token available on this path; "" is fine — the middleware
|
||||
# re-reads the refresh-token cookie separately for refresh_session.
|
||||
return self._session_from_tokens(
|
||||
id_token=access_token, refresh_token="", claims=claims
|
||||
)
|
||||
|
||||
def revoke_session(self, *, refresh_token: str) -> None:
|
||||
# Best-effort RFC 7009 revocation if the IDP advertised an endpoint.
|
||||
# Must never raise — logout is client-side cookie clearing regardless.
|
||||
if not refresh_token:
|
||||
return None
|
||||
try:
|
||||
disco = self._get_discovery()
|
||||
except ProviderError:
|
||||
return None
|
||||
endpoint = str(disco.get("revocation_endpoint") or "").strip()
|
||||
if not endpoint:
|
||||
return None
|
||||
data = {
|
||||
"token": refresh_token,
|
||||
"token_type_hint": "refresh_token",
|
||||
"client_id": self._client_id,
|
||||
}
|
||||
headers = {"Accept": "application/json"}
|
||||
# A confidential client must authenticate on revocation too (RFC 7009
|
||||
# §2.1), or the IDP rejects it with invalid_client. Reuse the same
|
||||
# method selection as the token endpoint; public clients add nothing.
|
||||
extra_data, extra_headers = self._token_endpoint_auth(disco)
|
||||
data.update(extra_data)
|
||||
headers.update(extra_headers)
|
||||
try:
|
||||
httpx.post(
|
||||
endpoint,
|
||||
data=data,
|
||||
headers=headers,
|
||||
timeout=_TOKEN_ENDPOINT_TIMEOUT_SEC,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 — best-effort
|
||||
logger.debug("self-hosted OIDC: revoke failed (ignored): %s", exc)
|
||||
return None
|
||||
|
||||
# ---- internals: token exchange ----------------------------------------
|
||||
|
||||
def _token_endpoint_auth(
|
||||
self, disco: Dict[str, Any]
|
||||
) -> tuple[Dict[str, str], Dict[str, str]]:
|
||||
"""Return ``(extra_data, extra_headers)`` for token-endpoint client auth.
|
||||
|
||||
Public client (no ``client_secret`` configured): returns ``({}, {})`` —
|
||||
the exchange authenticates with PKCE alone, exactly as before this
|
||||
method existed.
|
||||
|
||||
Confidential client (``client_secret`` set): authenticates the client
|
||||
per RFC 6749 §2.3.1, choosing the method from the IDP's advertised
|
||||
``token_endpoint_auth_methods_supported``:
|
||||
|
||||
* ``client_secret_post`` advertised (and ``client_secret_basic`` not)
|
||||
→ secret in the form body.
|
||||
* otherwise → HTTP Basic ``Authorization`` header (the OIDC default;
|
||||
also the fallback when the IDP advertises nothing).
|
||||
|
||||
PKCE's ``code_verifier`` is sent regardless by the callers — the secret
|
||||
is layered on top, never a replacement.
|
||||
"""
|
||||
if not self._client_secret:
|
||||
return {}, {}
|
||||
|
||||
methods = disco.get("token_endpoint_auth_methods_supported") or []
|
||||
prefer_post = (
|
||||
"client_secret_post" in methods
|
||||
and "client_secret_basic" not in methods
|
||||
)
|
||||
if prefer_post:
|
||||
# Secret travels in the application/x-www-form-urlencoded body.
|
||||
return {"client_secret": self._client_secret}, {}
|
||||
|
||||
# HTTP Basic: base64(urlencode(client_id) ":" urlencode(secret)).
|
||||
# Both halves must be form-url-encoded *before* base64 per RFC 6749
|
||||
# §2.3.1, or a secret containing ':' / reserved chars corrupts the
|
||||
# header.
|
||||
userpass = (
|
||||
f"{urllib.parse.quote(self._client_id, safe='')}:"
|
||||
f"{urllib.parse.quote(self._client_secret, safe='')}"
|
||||
)
|
||||
encoded = base64.b64encode(userpass.encode("utf-8")).decode("ascii")
|
||||
return {}, {"Authorization": f"Basic {encoded}"}
|
||||
|
||||
def _exchange(
|
||||
self,
|
||||
token_endpoint: str,
|
||||
data: Dict[str, str],
|
||||
*,
|
||||
bad_request_exc: type[Exception],
|
||||
previous_refresh_token: str = "",
|
||||
extra_headers: Optional[Dict[str, str]] = None,
|
||||
) -> Session:
|
||||
"""POST the token endpoint and turn the response into a Session.
|
||||
|
||||
Shared by ``complete_login`` (auth-code grant) and ``refresh_session``
|
||||
(refresh grant). ``bad_request_exc`` is raised on a 400 —
|
||||
``InvalidCodeError`` for the auth-code path, ``RefreshExpiredError``
|
||||
for the refresh path — preserving the middleware's distinct handling.
|
||||
|
||||
``extra_headers`` carries the confidential-client ``Authorization``
|
||||
header when one applies (see ``_token_endpoint_auth``); it is empty for
|
||||
a public client, so the request is byte-identical to the pre-
|
||||
confidential-client behaviour in that case.
|
||||
"""
|
||||
headers = {"Accept": "application/json"}
|
||||
if extra_headers:
|
||||
headers.update(extra_headers)
|
||||
try:
|
||||
response = httpx.post(
|
||||
token_endpoint,
|
||||
data=data,
|
||||
headers=headers,
|
||||
timeout=_TOKEN_ENDPOINT_TIMEOUT_SEC,
|
||||
)
|
||||
except httpx.RequestError as exc:
|
||||
raise ProviderError(
|
||||
f"OIDC token endpoint unreachable: {exc}"
|
||||
) from exc
|
||||
|
||||
if response.status_code == 400:
|
||||
body = self._parse_json_body(response)
|
||||
error_code = body.get("error", "invalid_request")
|
||||
raise bad_request_exc(
|
||||
f"IDP rejected token request: {error_code}"
|
||||
)
|
||||
if response.status_code != 200:
|
||||
raise ProviderError(
|
||||
f"OIDC token endpoint returned {response.status_code}: "
|
||||
f"{response.text[:200]!r}"
|
||||
)
|
||||
|
||||
payload = self._parse_json_body(response)
|
||||
|
||||
id_token = payload.get("id_token")
|
||||
if not id_token or not isinstance(id_token, str):
|
||||
raise ProviderError(
|
||||
"OIDC token response missing id_token — ensure the 'openid' "
|
||||
"scope is configured and the client is allowed to receive an "
|
||||
"ID token."
|
||||
)
|
||||
|
||||
token_type = str(payload.get("token_type", "")).lower()
|
||||
if token_type and token_type != "bearer":
|
||||
raise ProviderError(f"unexpected token_type={token_type!r}")
|
||||
|
||||
claims = self._verify_id_token(id_token)
|
||||
|
||||
# Refresh-token rotation: prefer a freshly-issued one, else keep the
|
||||
# previous (some IDPs don't rotate). Empty string if neither — the
|
||||
# session then behaves as ID-token-only until expiry.
|
||||
refresh_token = payload.get("refresh_token")
|
||||
if not isinstance(refresh_token, str) or not refresh_token:
|
||||
refresh_token = previous_refresh_token or ""
|
||||
|
||||
return self._session_from_tokens(
|
||||
id_token=id_token, refresh_token=refresh_token, claims=claims
|
||||
)
|
||||
|
||||
# ---- internals: discovery ---------------------------------------------
|
||||
|
||||
def _get_discovery(self) -> Dict[str, Any]:
|
||||
"""Return the cached OIDC discovery document, fetching if stale."""
|
||||
now = time.time()
|
||||
if (
|
||||
self._discovery is not None
|
||||
and (now - self._discovery_fetched_at) < _DISCOVERY_CACHE_TTL_SEC
|
||||
):
|
||||
return self._discovery
|
||||
with self._discovery_lock:
|
||||
now = time.time()
|
||||
if (
|
||||
self._discovery is not None
|
||||
and (now - self._discovery_fetched_at) < _DISCOVERY_CACHE_TTL_SEC
|
||||
):
|
||||
return self._discovery
|
||||
disco = self._fetch_discovery()
|
||||
self._discovery = disco
|
||||
self._discovery_fetched_at = now
|
||||
# New issuer/keys → drop the JWKS client so it re-binds to the
|
||||
# freshly-discovered jwks_uri.
|
||||
self._jwks_client = None
|
||||
return disco
|
||||
|
||||
def _discovery_url(self) -> str:
|
||||
# RFC 8414 / OIDC Discovery: ``{issuer}/.well-known/openid-configuration``.
|
||||
return f"{self._issuer}/.well-known/openid-configuration"
|
||||
|
||||
def _fetch_discovery(self) -> Dict[str, Any]:
|
||||
url = self._discovery_url()
|
||||
try:
|
||||
# follow_redirects=True: many IDPs answer the discovery GET with a
|
||||
# 3xx rather than a direct 200 — Authentik canonicalises the
|
||||
# ``.well-known`` path, and any IDP behind a reverse proxy doing an
|
||||
# http→https upgrade redirects too. httpx (unlike curl -L or the
|
||||
# requests library) defaults to follow_redirects=False, so without
|
||||
# this the redirect comes back as a bare 3xx with an empty body and
|
||||
# the ``status != 200`` check below raises "discovery returned 302"
|
||||
# → provider_unreachable → 503. Following the redirect is safe: the
|
||||
# issuer-pin check and _require_https_or_loopback below still
|
||||
# validate the *resolved* document and every endpoint in it, so a
|
||||
# redirect to a hostile location can't smuggle in a bad issuer or a
|
||||
# cleartext endpoint. (The token/revocation POSTs deliberately do
|
||||
# NOT follow redirects — see _exchange — because they carry an auth
|
||||
# code / refresh token and the endpoint is already the canonical
|
||||
# absolute URL resolved here.)
|
||||
response = httpx.get(
|
||||
url,
|
||||
headers={"Accept": "application/json"},
|
||||
timeout=_DISCOVERY_TIMEOUT_SEC,
|
||||
follow_redirects=True,
|
||||
)
|
||||
except httpx.RequestError as exc:
|
||||
raise ProviderError(f"OIDC discovery unreachable: {exc}") from exc
|
||||
if response.status_code != 200:
|
||||
raise ProviderError(
|
||||
f"OIDC discovery returned {response.status_code} for {url!r}"
|
||||
)
|
||||
payload = self._parse_json_body(response)
|
||||
if not payload:
|
||||
raise ProviderError("OIDC discovery returned a non-JSON body")
|
||||
|
||||
authorization_endpoint = str(
|
||||
payload.get("authorization_endpoint", "") or ""
|
||||
).strip()
|
||||
token_endpoint = str(payload.get("token_endpoint", "") or "").strip()
|
||||
jwks_uri = str(payload.get("jwks_uri", "") or "").strip()
|
||||
if not authorization_endpoint or not token_endpoint or not jwks_uri:
|
||||
raise ProviderError(
|
||||
"OIDC discovery missing one of authorization_endpoint / "
|
||||
"token_endpoint / jwks_uri"
|
||||
)
|
||||
|
||||
# Pin the discovered issuer: a mismatch between the configured issuer
|
||||
# and the ``issuer`` the IDP advertises means the discovery document
|
||||
# was served from the wrong place (proxy/MITM/misconfig). We tolerate
|
||||
# only a trailing-slash difference.
|
||||
advertised_issuer = str(payload.get("issuer", "") or "").strip()
|
||||
if advertised_issuer and advertised_issuer.rstrip("/") != self._issuer:
|
||||
raise ProviderError(
|
||||
f"OIDC discovery issuer mismatch: document advertises "
|
||||
f"{advertised_issuer!r} but configured issuer is "
|
||||
f"{self._issuer!r}"
|
||||
)
|
||||
|
||||
_require_https_or_loopback(
|
||||
authorization_endpoint, field="authorization_endpoint"
|
||||
)
|
||||
_require_https_or_loopback(token_endpoint, field="token_endpoint")
|
||||
_require_https_or_loopback(jwks_uri, field="jwks_uri")
|
||||
|
||||
revocation_endpoint = str(
|
||||
payload.get("revocation_endpoint", "") or ""
|
||||
).strip()
|
||||
|
||||
# Client-authentication methods the IDP advertises for the token
|
||||
# endpoint. Used to pick client_secret_basic vs client_secret_post for
|
||||
# a confidential client (see ``_token_endpoint_auth``). Absent/garbage
|
||||
# → empty list → we fall back to the OIDC default (basic).
|
||||
auth_methods_raw = payload.get("token_endpoint_auth_methods_supported")
|
||||
token_endpoint_auth_methods = (
|
||||
[str(m) for m in auth_methods_raw]
|
||||
if isinstance(auth_methods_raw, list)
|
||||
else []
|
||||
)
|
||||
|
||||
return {
|
||||
"issuer": advertised_issuer or self._issuer,
|
||||
"authorization_endpoint": authorization_endpoint,
|
||||
"token_endpoint": token_endpoint,
|
||||
"jwks_uri": jwks_uri,
|
||||
"revocation_endpoint": revocation_endpoint,
|
||||
"token_endpoint_auth_methods_supported": token_endpoint_auth_methods,
|
||||
}
|
||||
|
||||
# ---- internals: JWT verification --------------------------------------
|
||||
|
||||
def _get_jwks_client(self) -> Any:
|
||||
if self._jwks_client is None:
|
||||
from jwt import PyJWKClient # lazy import
|
||||
|
||||
disco = self._get_discovery()
|
||||
self._jwks_client = PyJWKClient(
|
||||
disco["jwks_uri"],
|
||||
cache_keys=True,
|
||||
lifespan=_JWKS_CACHE_SECONDS,
|
||||
headers={
|
||||
"Accept": "application/json",
|
||||
"User-Agent": "HermesAgent/1.0",
|
||||
},
|
||||
)
|
||||
return self._jwks_client
|
||||
|
||||
def _verify_id_token(self, id_token: str) -> Dict[str, Any]:
|
||||
import jwt # lazy import — keeps startup fast for the ungated path
|
||||
|
||||
disco = self._get_discovery()
|
||||
|
||||
try:
|
||||
signing_key = self._get_jwks_client().get_signing_key_from_jwt(
|
||||
id_token
|
||||
)
|
||||
except Exception as exc:
|
||||
# Unreachable JWKS -> ProviderError (503); a bearer that is not
|
||||
# one of our JWTs (opaque peer key, foreign kid) -> InvalidCodeError
|
||||
# (None / next provider). Folding both into 503 produced #94558.
|
||||
raise classify_jwks_lookup_error(exc) from exc
|
||||
|
||||
try:
|
||||
claims = jwt.decode(
|
||||
id_token,
|
||||
signing_key.key,
|
||||
algorithms=list(_ALLOWED_ID_TOKEN_ALGS),
|
||||
audience=self._client_id,
|
||||
issuer=disco["issuer"],
|
||||
options={"require": ["exp", "iat", "aud", "iss", "sub"]},
|
||||
)
|
||||
except jwt.ExpiredSignatureError as exc:
|
||||
# verify_session() catches this and returns None per protocol.
|
||||
raise InvalidCodeError(f"ID token expired: {exc}") from exc
|
||||
except jwt.InvalidTokenError as exc:
|
||||
# Surface the actual iss/aud the token carried so operators can
|
||||
# debug config drift between the configured issuer/client_id and
|
||||
# what the IDP emits. Decoding-without-verification is safe here:
|
||||
# we already failed verification and never trust these values.
|
||||
details = ""
|
||||
try:
|
||||
unverified = jwt.decode(
|
||||
id_token,
|
||||
options={"verify_signature": False, "verify_exp": False},
|
||||
)
|
||||
details = (
|
||||
f" [token iss={unverified.get('iss')!r} "
|
||||
f"aud={unverified.get('aud')!r}; "
|
||||
f"expected iss={disco['issuer']!r} "
|
||||
f"aud={self._client_id!r}]"
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
raise ProviderError(
|
||||
f"ID token verification failed: {exc}{details}"
|
||||
) from exc
|
||||
|
||||
return claims
|
||||
|
||||
# ---- internals: mapping + misc ----------------------------------------
|
||||
|
||||
def _session_from_tokens(
|
||||
self,
|
||||
*,
|
||||
id_token: str,
|
||||
refresh_token: str,
|
||||
claims: Dict[str, Any],
|
||||
) -> Session:
|
||||
"""Map verified OIDC claims onto a Session.
|
||||
|
||||
The verified ID token is stored in ``Session.access_token`` so the
|
||||
per-request ``verify_session`` re-verifies a real JWT. The opaque
|
||||
OAuth access token is intentionally NOT stored — Hermes does not call
|
||||
any resource API with it; the dashboard only needs identity.
|
||||
"""
|
||||
user_id = str(claims.get("sub", ""))
|
||||
if not user_id:
|
||||
raise ProviderError("ID token missing 'sub' (user_id) claim")
|
||||
|
||||
email = str(claims.get("email", "") or "")
|
||||
# Standard OIDC display claims, in preference order.
|
||||
display_name = str(
|
||||
claims.get("name")
|
||||
or claims.get("preferred_username")
|
||||
or claims.get("nickname")
|
||||
or email
|
||||
or ""
|
||||
)
|
||||
# Org/tenant is non-standard; accept the common spellings. Groups, if
|
||||
# present as a list, are joined so multi-tenant IDPs surface *something*
|
||||
# rather than dropping the info — org_id is a free-form string.
|
||||
org_id = claims.get("org_id") or claims.get("organization") or ""
|
||||
if not org_id:
|
||||
groups = claims.get("groups")
|
||||
if isinstance(groups, list) and groups:
|
||||
org_id = ",".join(str(g) for g in groups)
|
||||
org_id = str(org_id or "")
|
||||
|
||||
return Session(
|
||||
user_id=user_id,
|
||||
email=email,
|
||||
display_name=display_name,
|
||||
org_id=org_id,
|
||||
provider=self.name,
|
||||
expires_at=int(claims["exp"]),
|
||||
access_token=id_token,
|
||||
refresh_token=refresh_token,
|
||||
)
|
||||
|
||||
def _validate_redirect_uri(self, redirect_uri: str) -> None:
|
||||
"""Fast-fail obviously-broken redirect_uris before bouncing to the IDP.
|
||||
|
||||
The IDP's own allowlist is authoritative; this just catches the common
|
||||
operator-error case with a clear message. We allow any ``http://`` host
|
||||
(not just localhost) so self-hosted dashboards reached over plain HTTP —
|
||||
LAN IPs, internal hostnames, reverse proxies that terminate TLS upstream
|
||||
— are not rejected here; the IDP makes the final call on which
|
||||
redirect_uris are permitted. Mirrors the nous provider.
|
||||
"""
|
||||
parsed = urllib.parse.urlparse(redirect_uri)
|
||||
if parsed.scheme not in ("https", "http"):
|
||||
raise ProviderError(
|
||||
f"redirect_uri must be http(s), got {redirect_uri!r}"
|
||||
)
|
||||
if not parsed.path or not parsed.path.endswith("/auth/callback"):
|
||||
raise ProviderError(
|
||||
"redirect_uri path must end with '/auth/callback', "
|
||||
f"got {redirect_uri!r}"
|
||||
)
|
||||
|
||||
def _parse_json_body(self, response: httpx.Response) -> Dict[str, Any]:
|
||||
ctype = response.headers.get("content-type", "")
|
||||
if not ctype.startswith("application/json"):
|
||||
return {}
|
||||
try:
|
||||
body = response.json()
|
||||
except ValueError:
|
||||
return {}
|
||||
return body if isinstance(body, dict) else {}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Plugin entry point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _load_config_oauth_section() -> dict:
|
||||
"""Return the ``dashboard.oauth`` block from config.yaml, or ``{}``.
|
||||
|
||||
Robust to load_config() raising, the ``dashboard`` key being absent or
|
||||
non-dict, and ``oauth`` being present but not a dict — each falls through
|
||||
to ``{}`` so callers can rely on ``.get(...)``.
|
||||
"""
|
||||
try:
|
||||
from hermes_cli.config import cfg_get, load_config
|
||||
|
||||
cfg = load_config()
|
||||
except Exception as exc: # noqa: BLE001 — broad catch is intentional
|
||||
logger.debug(
|
||||
"dashboard-auth-self-hosted: load_config() raised %s; "
|
||||
"falling back to env-only configuration",
|
||||
exc,
|
||||
)
|
||||
return {}
|
||||
section = cfg_get(cfg, "dashboard", "oauth", default=None)
|
||||
return section if isinstance(section, dict) else {}
|
||||
|
||||
|
||||
def _oidc_subsection(oauth_section: dict) -> dict:
|
||||
"""Return the ``dashboard.oauth.self_hosted`` sub-block, or ``{}``."""
|
||||
sub = oauth_section.get("self_hosted")
|
||||
return sub if isinstance(sub, dict) else {}
|
||||
|
||||
|
||||
def _resolve_setting(env_var: str, cfg_value: Any) -> str:
|
||||
"""env-wins-config with empty-is-unset precedence.
|
||||
|
||||
1. ``env_var`` when non-empty after strip (an empty provisioned secret
|
||||
must not shadow a valid config.yaml entry).
|
||||
2. ``cfg_value`` from config.yaml.
|
||||
3. Empty string.
|
||||
"""
|
||||
env = os.environ.get(env_var, "").strip()
|
||||
if env:
|
||||
return env
|
||||
return str(cfg_value or "").strip()
|
||||
|
||||
|
||||
def register(ctx) -> None:
|
||||
"""Plugin entry — called by the plugin loader at startup.
|
||||
|
||||
Registers :class:`SelfHostedOIDCProvider` only when both an issuer and a
|
||||
client_id are configured (via ``HERMES_DASHBOARD_OIDC_*`` env vars or the
|
||||
``dashboard.oauth.self_hosted`` block in config.yaml). Operator-owned
|
||||
loopback / ``--insecure`` dashboards leave these unset, so the plugin is a
|
||||
no-op for them.
|
||||
|
||||
On skip, writes a reason to :data:`LAST_SKIP_REASON` that names BOTH
|
||||
configuration surfaces so operators don't guess wrong about which to set.
|
||||
"""
|
||||
global LAST_SKIP_REASON
|
||||
LAST_SKIP_REASON = ""
|
||||
|
||||
oauth_section = _load_config_oauth_section()
|
||||
oidc_cfg = _oidc_subsection(oauth_section)
|
||||
|
||||
issuer = _resolve_setting(
|
||||
"HERMES_DASHBOARD_OIDC_ISSUER", oidc_cfg.get("issuer")
|
||||
)
|
||||
client_id = _resolve_setting(
|
||||
"HERMES_DASHBOARD_OIDC_CLIENT_ID", oidc_cfg.get("client_id")
|
||||
)
|
||||
scopes = (
|
||||
_resolve_setting("HERMES_DASHBOARD_OIDC_SCOPES", oidc_cfg.get("scopes"))
|
||||
or _DEFAULT_SCOPES
|
||||
)
|
||||
# Optional — set only for a confidential client. A credential, so the
|
||||
# canonical home is the env var / ~/.hermes/.env; config.yaml is supported
|
||||
# for precedence symmetry. Empty ⇒ public client (unchanged behaviour).
|
||||
client_secret = _resolve_setting(
|
||||
"HERMES_DASHBOARD_OIDC_CLIENT_SECRET", oidc_cfg.get("client_secret")
|
||||
)
|
||||
|
||||
if not issuer or not client_id:
|
||||
LAST_SKIP_REASON = (
|
||||
"Self-hosted OIDC dashboard auth is not configured. Set both an "
|
||||
"issuer and a client_id — either as env vars "
|
||||
"(HERMES_DASHBOARD_OIDC_ISSUER + HERMES_DASHBOARD_OIDC_CLIENT_ID) "
|
||||
"or under dashboard.oauth.self_hosted.{issuer,client_id} in "
|
||||
"config.yaml — or pass --insecure to skip the OAuth gate "
|
||||
"entirely. (issuer set: %s; client_id set: %s)"
|
||||
% (bool(issuer), bool(client_id))
|
||||
)
|
||||
logger.debug("dashboard-auth-self-hosted: %s", LAST_SKIP_REASON)
|
||||
return
|
||||
|
||||
try:
|
||||
provider = SelfHostedOIDCProvider(
|
||||
issuer=issuer,
|
||||
client_id=client_id,
|
||||
scopes=scopes,
|
||||
client_secret=client_secret,
|
||||
)
|
||||
except (ValueError, ProviderError) as exc:
|
||||
LAST_SKIP_REASON = (
|
||||
f"SelfHostedOIDCProvider construction failed: {exc}"
|
||||
)
|
||||
logger.warning("dashboard-auth-self-hosted: %s", LAST_SKIP_REASON)
|
||||
return
|
||||
|
||||
ctx.register_dashboard_auth_provider(provider)
|
||||
logger.info(
|
||||
"dashboard-auth-self-hosted: registered provider "
|
||||
"(issuer=%s, client_id=%s, scopes=%r, confidential=%s)",
|
||||
issuer,
|
||||
client_id,
|
||||
scopes,
|
||||
# Log only whether a secret is present, never the secret itself.
|
||||
bool(client_secret),
|
||||
)
|
||||
@@ -0,0 +1,8 @@
|
||||
name: self-hosted
|
||||
version: 1.0.0
|
||||
description: "Dashboard auth provider — generic self-hosted OpenID Connect (authorization-code + PKCE, public client). Works against any conformant OIDC identity provider (Authentik, Keycloak, Zitadel, Authelia, Auth0, Okta, Google, …) via OIDC discovery. Auto-activates when an issuer + client_id are configured, either under dashboard.oauth.self_hosted.{issuer,client_id} in config.yaml (canonical surface) or via the HERMES_DASHBOARD_OIDC_ISSUER + HERMES_DASHBOARD_OIDC_CLIENT_ID env vars (operator override / secret injection). Scopes default to 'openid profile email'. Verifies the OIDC ID token (RS256/ES256) against the discovered jwks_uri."
|
||||
author: NousResearch
|
||||
kind: backend
|
||||
requires_env:
|
||||
- HERMES_DASHBOARD_OIDC_ISSUER
|
||||
- HERMES_DASHBOARD_OIDC_CLIENT_ID
|
||||
@@ -0,0 +1,51 @@
|
||||
# disk-cleanup
|
||||
|
||||
Auto-tracks and cleans up ephemeral files created during Hermes Agent
|
||||
sessions — test scripts, temp outputs, cron logs, stale chrome profiles.
|
||||
Scoped strictly to `$HERMES_HOME` and `/tmp/hermes-*`.
|
||||
|
||||
Originally contributed by [@LVT382009](https://github.com/LVT382009) as a
|
||||
skill in PR #12212. Ported to the plugin system so the behaviour runs
|
||||
automatically via `post_tool_call` and `on_session_end` hooks — the agent
|
||||
never needs to remember to call a tool.
|
||||
|
||||
## How it works
|
||||
|
||||
| Hook | Behaviour |
|
||||
|---|---|
|
||||
| `post_tool_call` | When `write_file` / `terminal` / `patch` creates a file matching `test_*`, `tmp_*`, or `*.test.*` inside `HERMES_HOME`, track it silently as `test` / `temp` / `cron-output`. |
|
||||
| `on_session_end` | If any test files were auto-tracked during this turn, run `quick` cleanup (no prompts). |
|
||||
|
||||
Deletion rules (same as the original PR):
|
||||
|
||||
| Category | Threshold | Confirmation |
|
||||
|---|---|---|
|
||||
| `test` | every session end | Never |
|
||||
| `temp` | >7 days since tracked | Never |
|
||||
| `cron-output` | >14 days since tracked | Never |
|
||||
| empty dirs under HERMES_HOME | always | Never |
|
||||
| `research` | >30 days, beyond 10 newest | Always (deep only) |
|
||||
| `chrome-profile` | >14 days since tracked | Always (deep only) |
|
||||
| files >500 MB | never auto | Always (deep only) |
|
||||
|
||||
## Slash command
|
||||
|
||||
```
|
||||
/disk-cleanup status # breakdown + top-10 largest
|
||||
/disk-cleanup dry-run # preview without deleting
|
||||
/disk-cleanup quick # run safe cleanup now
|
||||
/disk-cleanup deep # quick + list items needing prompt
|
||||
/disk-cleanup track <path> <category> # manual tracking
|
||||
/disk-cleanup forget <path> # stop tracking
|
||||
```
|
||||
|
||||
## Safety
|
||||
|
||||
- `is_safe_path()` rejects anything outside `HERMES_HOME` or `/tmp/hermes-*`
|
||||
- Windows mounts (`/mnt/c` etc.) are rejected
|
||||
- The state directory `$HERMES_HOME/disk-cleanup/` is itself excluded
|
||||
- `$HERMES_HOME/logs/`, `memories/`, `sessions/`, `skills/`, `plugins/`,
|
||||
and config files are never tracked
|
||||
- Backup/restore is scoped to `tracked.json` — the plugin never touches
|
||||
agent logs
|
||||
- Atomic writes: `.tmp` → backup → rename
|
||||
@@ -0,0 +1,316 @@
|
||||
"""disk-cleanup plugin — auto-cleanup of ephemeral Hermes session files.
|
||||
|
||||
Wires three behaviours:
|
||||
|
||||
1. ``post_tool_call`` hook — inspects ``write_file`` and ``terminal``
|
||||
tool results 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 a single line to ``$HERMES_HOME/disk-cleanup/cleanup.log``.
|
||||
|
||||
3. ``/disk-cleanup`` slash command — manual ``status``, ``dry-run``,
|
||||
``quick``, ``deep``, ``track``, ``forget``.
|
||||
|
||||
Replaces PR #12212's skill-plus-script design: the agent no longer
|
||||
needs to remember to run commands.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
import shlex
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional, Set
|
||||
|
||||
from . import disk_cleanup as dg
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Per-task set of "test files newly tracked this turn". Keyed by task_id
|
||||
# (or session_id as fallback) so on_session_end can decide whether to run
|
||||
# cleanup. Guarded by a lock — post_tool_call can fire concurrently on
|
||||
# parallel tool calls.
|
||||
_recent_test_tracks: Dict[str, Set[str]] = {}
|
||||
_lock = threading.Lock()
|
||||
|
||||
|
||||
# Tool-call result shapes we can parse
|
||||
_WRITE_FILE_PATH_KEY = "path"
|
||||
_TERMINAL_PATH_REGEX = re.compile(r"(?:^|\s)(/[^\s'\"`]+|\~/[^\s'\"`]+)")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _tracker_key(task_id: str, session_id: str) -> str:
|
||||
return task_id or session_id or "default"
|
||||
|
||||
|
||||
def _record_track(task_id: str, session_id: str, path: Path, category: str) -> None:
|
||||
"""Record that we tracked *path* as *category* during this turn."""
|
||||
if category != "test":
|
||||
return
|
||||
key = _tracker_key(task_id, session_id)
|
||||
with _lock:
|
||||
_recent_test_tracks.setdefault(key, set()).add(str(path))
|
||||
|
||||
|
||||
def _drain(task_id: str, session_id: str) -> Set[str]:
|
||||
"""Pop the set of test paths tracked during this turn."""
|
||||
key = _tracker_key(task_id, session_id)
|
||||
with _lock:
|
||||
return _recent_test_tracks.pop(key, set())
|
||||
|
||||
|
||||
def _attempt_track(path_str: str, task_id: str, session_id: str) -> None:
|
||||
"""Best-effort auto-track. Never raises."""
|
||||
try:
|
||||
p = Path(path_str).expanduser()
|
||||
except Exception:
|
||||
return
|
||||
if not p.exists():
|
||||
return
|
||||
category = dg.guess_category(p)
|
||||
if category is None:
|
||||
return
|
||||
newly = dg.track(str(p), category, silent=True)
|
||||
if newly:
|
||||
_record_track(task_id, session_id, p, category)
|
||||
|
||||
|
||||
def _extract_paths_from_write_file(args: Dict[str, Any]) -> Set[str]:
|
||||
path = args.get(_WRITE_FILE_PATH_KEY)
|
||||
return {path} if isinstance(path, str) and path else set()
|
||||
|
||||
|
||||
def _extract_paths_from_patch(args: Dict[str, Any]) -> Set[str]:
|
||||
# The patch tool creates new files via the `mode="patch"` path too, but
|
||||
# most of its use is editing existing files — we only care about new
|
||||
# ephemeral creations, so treat patch conservatively and only pick up
|
||||
# the single-file `path` arg. Track-then-cleanup is idempotent, so
|
||||
# re-tracking an already-tracked file is a no-op (dedup in track()).
|
||||
path = args.get("path")
|
||||
return {path} if isinstance(path, str) and path else set()
|
||||
|
||||
|
||||
def _extract_paths_from_terminal(args: Dict[str, Any], result: str) -> Set[str]:
|
||||
"""Best-effort: pull candidate filesystem paths from a terminal command
|
||||
and its output, then let ``guess_category`` / ``is_safe_path`` filter.
|
||||
"""
|
||||
paths: Set[str] = set()
|
||||
cmd = args.get("command") or ""
|
||||
if isinstance(cmd, str) and cmd:
|
||||
# Tokenise the command — catches `touch /tmp/hermes-x/test_foo.py`
|
||||
try:
|
||||
for tok in shlex.split(cmd, posix=True):
|
||||
if tok.startswith(("/", "~")):
|
||||
paths.add(tok)
|
||||
except ValueError:
|
||||
pass
|
||||
# Only scan the result text if it's a reasonable size (avoid 50KB dumps).
|
||||
if isinstance(result, str) and len(result) < 4096:
|
||||
for match in _TERMINAL_PATH_REGEX.findall(result):
|
||||
paths.add(match)
|
||||
return paths
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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:
|
||||
"""Auto-track ephemeral files created by recent tool calls."""
|
||||
if not isinstance(args, dict):
|
||||
return
|
||||
|
||||
candidates: Set[str] = set()
|
||||
if tool_name == "write_file":
|
||||
candidates = _extract_paths_from_write_file(args)
|
||||
elif tool_name == "patch":
|
||||
candidates = _extract_paths_from_patch(args)
|
||||
elif tool_name == "terminal":
|
||||
candidates = _extract_paths_from_terminal(args, result if isinstance(result, str) else "")
|
||||
else:
|
||||
return
|
||||
|
||||
for path_str in candidates:
|
||||
_attempt_track(path_str, task_id, session_id)
|
||||
|
||||
|
||||
def _on_session_end(
|
||||
session_id: str = "",
|
||||
completed: bool = True,
|
||||
interrupted: bool = False,
|
||||
**_: Any,
|
||||
) -> None:
|
||||
"""Run quick cleanup if any test files were tracked during this turn."""
|
||||
# Drain both task-level and session-level buckets. In practice only one
|
||||
# is populated per turn; the other is empty.
|
||||
drained_session = _drain("", session_id)
|
||||
# Also drain any task-scoped buckets that happen to exist. This is a
|
||||
# cheap sweep: if an agent spawned subagents (each with their own
|
||||
# task_id) they'll have recorded into separate buckets; we want to
|
||||
# cleanup them all at session end.
|
||||
with _lock:
|
||||
task_buckets = list(_recent_test_tracks.keys())
|
||||
for key in task_buckets:
|
||||
if key and key != session_id:
|
||||
_recent_test_tracks.pop(key, None)
|
||||
|
||||
if not drained_session and not task_buckets:
|
||||
return
|
||||
|
||||
try:
|
||||
summary = dg.quick()
|
||||
except Exception as exc:
|
||||
logger.debug("disk-cleanup quick cleanup failed: %s", exc)
|
||||
return
|
||||
|
||||
if summary["deleted"] or summary["empty_dirs"]:
|
||||
dg._log(
|
||||
f"AUTO_QUICK (session_end): deleted={summary['deleted']} "
|
||||
f"dirs={summary['empty_dirs']} freed={dg.fmt_size(summary['freed'])}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Slash command
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_HELP_TEXT = """\
|
||||
/disk-cleanup — ephemeral-file cleanup
|
||||
|
||||
Subcommands:
|
||||
status Per-category breakdown + top-10 largest
|
||||
dry-run Preview what quick/deep would delete
|
||||
quick Run safe cleanup now (no prompts)
|
||||
deep Run quick, then list items that need prompts
|
||||
track <path> <category> Manually add a path to tracking
|
||||
forget <path> Stop tracking a path (does not delete)
|
||||
|
||||
Categories: temp | test | research | download | chrome-profile | cron-output | other
|
||||
|
||||
All operations are scoped to HERMES_HOME and /tmp/hermes-*.
|
||||
Test files are auto-tracked on write_file / terminal and auto-cleaned at session end.
|
||||
"""
|
||||
|
||||
|
||||
def _fmt_summary(summary: Dict[str, Any]) -> str:
|
||||
base = (
|
||||
f"[disk-cleanup] Cleaned {summary['deleted']} files + "
|
||||
f"{summary['empty_dirs']} empty dirs, freed {dg.fmt_size(summary['freed'])}."
|
||||
)
|
||||
if summary.get("errors"):
|
||||
base += f"\n {len(summary['errors'])} error(s); see cleanup.log."
|
||||
return base
|
||||
|
||||
|
||||
def _handle_slash(raw_args: str) -> Optional[str]:
|
||||
argv = raw_args.strip().split()
|
||||
if not argv or argv[0] in {"help", "-h", "--help"}:
|
||||
return _HELP_TEXT
|
||||
|
||||
sub = argv[0]
|
||||
|
||||
if sub == "status":
|
||||
return dg.format_status(dg.status())
|
||||
|
||||
if sub == "dry-run":
|
||||
auto, prompt = dg.dry_run()
|
||||
auto_size = sum(i["size"] for i in auto)
|
||||
prompt_size = sum(i["size"] for i in prompt)
|
||||
lines = [
|
||||
"Dry-run preview (nothing deleted):",
|
||||
f" Auto-delete : {len(auto)} files ({dg.fmt_size(auto_size)})",
|
||||
]
|
||||
for item in auto:
|
||||
lines.append(f" [{item['category']}] {item['path']}")
|
||||
lines.append(
|
||||
f" Needs prompt: {len(prompt)} files ({dg.fmt_size(prompt_size)})"
|
||||
)
|
||||
for item in prompt:
|
||||
lines.append(f" [{item['category']}] {item['path']}")
|
||||
lines.append(
|
||||
f"\n Total potential: {dg.fmt_size(auto_size + prompt_size)}"
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
if sub == "quick":
|
||||
return _fmt_summary(dg.quick())
|
||||
|
||||
if sub == "deep":
|
||||
# In-session deep can't prompt the user interactively — show what
|
||||
# quick cleaned plus the items that WOULD need confirmation.
|
||||
quick_summary = dg.quick()
|
||||
_auto, prompt_items = dg.dry_run()
|
||||
lines = [_fmt_summary(quick_summary)]
|
||||
if prompt_items:
|
||||
size = sum(i["size"] for i in prompt_items)
|
||||
lines.append(
|
||||
f"\n{len(prompt_items)} item(s) need confirmation "
|
||||
f"({dg.fmt_size(size)}):"
|
||||
)
|
||||
for item in prompt_items:
|
||||
lines.append(f" [{item['category']}] {item['path']}")
|
||||
lines.append(
|
||||
"\nRun `/disk-cleanup forget <path>` to skip, or delete "
|
||||
"manually via terminal."
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
if sub == "track":
|
||||
if len(argv) < 3:
|
||||
return "Usage: /disk-cleanup track <path> <category>"
|
||||
path_arg = argv[1]
|
||||
category = argv[2]
|
||||
if category not in dg.ALLOWED_CATEGORIES:
|
||||
return (
|
||||
f"Unknown category '{category}'. "
|
||||
f"Allowed: {sorted(dg.ALLOWED_CATEGORIES)}"
|
||||
)
|
||||
if dg.track(path_arg, category, silent=True):
|
||||
return f"Tracked {path_arg} as '{category}'."
|
||||
return (
|
||||
f"Not tracked (already present, missing, or outside HERMES_HOME): "
|
||||
f"{path_arg}"
|
||||
)
|
||||
|
||||
if sub == "forget":
|
||||
if len(argv) < 2:
|
||||
return "Usage: /disk-cleanup forget <path>"
|
||||
n = dg.forget(argv[1])
|
||||
return (
|
||||
f"Removed {n} tracking entr{'y' if n == 1 else 'ies'} for {argv[1]}."
|
||||
if n else f"Not found in tracking: {argv[1]}"
|
||||
)
|
||||
|
||||
return f"Unknown subcommand: {sub}\n\n{_HELP_TEXT}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Plugin registration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def register(ctx) -> None:
|
||||
ctx.register_hook("post_tool_call", _on_post_tool_call)
|
||||
ctx.register_hook("on_session_end", _on_session_end)
|
||||
ctx.register_command(
|
||||
"disk-cleanup",
|
||||
handler=_handle_slash,
|
||||
description="Track and clean up ephemeral Hermes session files.",
|
||||
)
|
||||
Executable
+611
@@ -0,0 +1,611 @@
|
||||
"""disk_cleanup — ephemeral file cleanup for Hermes Agent.
|
||||
|
||||
Library module wrapping the deterministic cleanup rules written by
|
||||
@LVT382009 in PR #12212. The plugin ``__init__.py`` wires these
|
||||
functions into ``post_tool_call`` and ``on_session_end`` hooks so
|
||||
tracking and cleanup happen automatically — the agent never needs to
|
||||
call a tool or remember a skill.
|
||||
|
||||
Rules:
|
||||
- test files → delete immediately at task end (age >= 0)
|
||||
- temp files → delete after 7 days
|
||||
- cron-output → delete after 14 days
|
||||
- empty dirs → always delete (under HERMES_HOME)
|
||||
- research → keep 10 newest, prompt for older (deep only)
|
||||
- chrome-profile→ prompt after 14 days (deep only)
|
||||
- >500 MB files → prompt always (deep only)
|
||||
|
||||
Scope: strictly HERMES_HOME and /tmp/hermes-*
|
||||
Never touches: ~/.hermes/logs/ or any system directory.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import shutil
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
try:
|
||||
from hermes_constants import get_hermes_home
|
||||
except Exception: # pragma: no cover — plugin may load before constants resolves
|
||||
import os
|
||||
|
||||
def get_hermes_home() -> Path: # type: ignore[no-redef]
|
||||
val = (os.environ.get("HERMES_HOME") or "").strip()
|
||||
return Path(val).resolve() if val else (Path.home() / ".hermes").resolve()
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Paths
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def get_state_dir() -> Path:
|
||||
"""State dir — separate from ``$HERMES_HOME/logs/``."""
|
||||
return get_hermes_home() / "disk-cleanup"
|
||||
|
||||
|
||||
def get_tracked_file() -> Path:
|
||||
return get_state_dir() / "tracked.json"
|
||||
|
||||
|
||||
def get_log_file() -> Path:
|
||||
"""Audit log — intentionally NOT under ``$HERMES_HOME/logs/``."""
|
||||
return get_state_dir() / "cleanup.log"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Path safety
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def is_safe_path(path: Path) -> bool:
|
||||
"""Accept only paths under HERMES_HOME or ``/tmp/hermes-*``.
|
||||
|
||||
Rejects Windows mounts (``/mnt/c`` etc.) and any system directory.
|
||||
"""
|
||||
hermes_home = get_hermes_home()
|
||||
try:
|
||||
path.resolve().relative_to(hermes_home)
|
||||
return True
|
||||
except (ValueError, OSError):
|
||||
pass
|
||||
# Allow /tmp/hermes-* explicitly
|
||||
parts = path.parts
|
||||
if len(parts) >= 3 and parts[1] == "tmp" and parts[2].startswith("hermes-"):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Audit log
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _log(message: str) -> None:
|
||||
try:
|
||||
log_file = get_log_file()
|
||||
log_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
ts = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
|
||||
with open(log_file, "a", encoding="utf-8") as f:
|
||||
f.write(f"[{ts}] {message}\n")
|
||||
except OSError:
|
||||
# Never let the audit log break the agent loop.
|
||||
pass
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# tracked.json — atomic read/write, backup scoped to tracked.json only
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def load_tracked() -> List[Dict[str, Any]]:
|
||||
"""Load tracked.json. Restores from ``.bak`` on corruption."""
|
||||
tf = get_tracked_file()
|
||||
tf.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if not tf.exists():
|
||||
return []
|
||||
|
||||
try:
|
||||
return json.loads(tf.read_text(encoding="utf-8"))
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
bak = tf.with_suffix(".json.bak")
|
||||
if bak.exists():
|
||||
try:
|
||||
data = json.loads(bak.read_text(encoding="utf-8"))
|
||||
_log("WARN: tracked.json corrupted — restored from .bak")
|
||||
return data
|
||||
except Exception:
|
||||
pass
|
||||
_log("WARN: tracked.json corrupted, no backup — starting fresh")
|
||||
return []
|
||||
|
||||
|
||||
def save_tracked(tracked: List[Dict[str, Any]]) -> None:
|
||||
"""Atomic write: ``.tmp`` → backup old → rename."""
|
||||
tf = get_tracked_file()
|
||||
tf.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = tf.with_suffix(".json.tmp")
|
||||
tmp.write_text(json.dumps(tracked, indent=2), encoding="utf-8")
|
||||
if tf.exists():
|
||||
shutil.copy2(tf, tf.with_suffix(".json.bak"))
|
||||
tmp.replace(tf)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Categories
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
ALLOWED_CATEGORIES = {
|
||||
"temp", "test", "research", "download",
|
||||
"chrome-profile", "cron-output", "other",
|
||||
}
|
||||
|
||||
_EMPTY_DIR_PROTECTED_TOP_LEVEL = frozenset({
|
||||
"logs", "memories", "sessions", "cron", "cronjobs",
|
||||
"cache", "skills", "plugins", "disk-cleanup", "optional-skills",
|
||||
"hermes-agent", "backups", "profiles", ".worktrees",
|
||||
# User-authored project trees — never sweep empty directories
|
||||
# inside these (#75403).
|
||||
"patches", "projects", "skins", "themes", "contributors",
|
||||
})
|
||||
|
||||
_EMPTY_DIR_SWEEP_PRUNE_DIRS = frozenset({
|
||||
".git", "node_modules", "venv", ".venv",
|
||||
"site-packages", "__pycache__",
|
||||
})
|
||||
|
||||
|
||||
# Paths under $HERMES_HOME that must NEVER be deleted by quick(),
|
||||
# regardless of what the stored category says. This is a defense-in-depth
|
||||
# guard against stale tracked.json entries from before #34840.
|
||||
_PROTECTED_CRON_PATHS: set[str] = set()
|
||||
|
||||
|
||||
def _is_protected_cron_path(p: Path) -> bool:
|
||||
"""Return True if *p* is a cron control-plane file/directory that must
|
||||
never be deleted.
|
||||
|
||||
This matches, by EXACT path only, the ``cron/`` directory itself, known
|
||||
control-plane files (``jobs.json``, ``.tick.lock``), and the ``output/``
|
||||
root directory. It does NOT (and must not be "simplified" to) blanket-match
|
||||
everything under ``cron/output/`` — those run artifacts are disposable and
|
||||
are cleaned by retention policy; only the ``output/`` root itself is
|
||||
protected, because deleting it wholesale erases every job's retained run
|
||||
history at once.
|
||||
"""
|
||||
# Lazily build the set once per process so HERMES_HOME is resolved
|
||||
# exactly once.
|
||||
if not _PROTECTED_CRON_PATHS:
|
||||
hermes_home = get_hermes_home()
|
||||
for parent in ("cron", "cronjobs"):
|
||||
base = hermes_home / parent
|
||||
_PROTECTED_CRON_PATHS.add(str(base))
|
||||
_PROTECTED_CRON_PATHS.add(str(base / "output"))
|
||||
_PROTECTED_CRON_PATHS.add(str(base / "jobs.json"))
|
||||
_PROTECTED_CRON_PATHS.add(str(base / ".tick.lock"))
|
||||
resolved = str(p.resolve())
|
||||
return resolved in _PROTECTED_CRON_PATHS
|
||||
|
||||
|
||||
def fmt_size(n: float) -> str:
|
||||
for unit in ("B", "KB", "MB", "GB", "TB"):
|
||||
if n < 1024:
|
||||
return f"{n:.1f} {unit}"
|
||||
n /= 1024
|
||||
return f"{n:.1f} PB"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Track / forget
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def track(path_str: str, category: str, silent: bool = False) -> bool:
|
||||
"""Register a file for tracking. Returns True if newly tracked."""
|
||||
if category not in ALLOWED_CATEGORIES:
|
||||
_log(f"WARN: unknown category '{category}', using 'other'")
|
||||
category = "other"
|
||||
|
||||
path = Path(path_str).resolve()
|
||||
|
||||
if not path.exists():
|
||||
_log(f"SKIP: {path} (does not exist)")
|
||||
return False
|
||||
|
||||
if not is_safe_path(path):
|
||||
_log(f"REJECT: {path} (outside HERMES_HOME)")
|
||||
return False
|
||||
|
||||
size = path.stat().st_size if path.is_file() else 0
|
||||
tracked = load_tracked()
|
||||
|
||||
# Deduplicate
|
||||
if any(item["path"] == str(path) for item in tracked):
|
||||
return False
|
||||
|
||||
tracked.append({
|
||||
"path": str(path),
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"category": category,
|
||||
"size": size,
|
||||
})
|
||||
save_tracked(tracked)
|
||||
_log(f"TRACKED: {path} ({category}, {fmt_size(size)})")
|
||||
if not silent:
|
||||
print(f"Tracked: {path} ({category}, {fmt_size(size)})")
|
||||
return True
|
||||
|
||||
|
||||
def forget(path_str: str) -> int:
|
||||
"""Remove a path from tracking without deleting the file."""
|
||||
p = Path(path_str).resolve()
|
||||
tracked = load_tracked()
|
||||
before = len(tracked)
|
||||
tracked = [i for i in tracked if Path(i["path"]).resolve() != p]
|
||||
removed = before - len(tracked)
|
||||
if removed:
|
||||
save_tracked(tracked)
|
||||
_log(f"FORGOT: {p} ({removed} entries)")
|
||||
return removed
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dry run
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def dry_run() -> Tuple[List[Dict], List[Dict]]:
|
||||
"""Return (auto_delete_list, needs_prompt_list) without touching files."""
|
||||
tracked = load_tracked()
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
auto: List[Dict] = []
|
||||
prompt: List[Dict] = []
|
||||
|
||||
for item in tracked:
|
||||
p = Path(item["path"])
|
||||
if not p.exists():
|
||||
continue
|
||||
age = (now - datetime.fromisoformat(item["timestamp"])).days
|
||||
cat = item["category"]
|
||||
size = item["size"]
|
||||
|
||||
# Re-validate stale "cron-output" entries (fixes #37721).
|
||||
if cat == "cron-output":
|
||||
re_cat = guess_category(p)
|
||||
if re_cat != "cron-output":
|
||||
# Stale entry — would be skipped by quick(); omit from
|
||||
# dry-run output too.
|
||||
continue
|
||||
|
||||
if cat == "test":
|
||||
auto.append(item)
|
||||
elif cat == "temp" and age > 7:
|
||||
auto.append(item)
|
||||
elif cat == "cron-output" and age > 14:
|
||||
auto.append(item)
|
||||
elif cat == "research" and age > 30:
|
||||
prompt.append(item)
|
||||
elif cat == "chrome-profile" and age > 14:
|
||||
prompt.append(item)
|
||||
elif size > 500 * 1024 * 1024:
|
||||
prompt.append(item)
|
||||
|
||||
return auto, prompt
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Quick cleanup
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def quick() -> Dict[str, Any]:
|
||||
"""Safe deterministic cleanup — no prompts.
|
||||
|
||||
Returns: ``{"deleted": N, "empty_dirs": N, "freed": bytes,
|
||||
"errors": [str, ...]}``.
|
||||
"""
|
||||
tracked = load_tracked()
|
||||
now = datetime.now(timezone.utc)
|
||||
deleted = 0
|
||||
freed = 0
|
||||
new_tracked: List[Dict] = []
|
||||
errors: List[str] = []
|
||||
|
||||
for item in tracked:
|
||||
p = Path(item["path"])
|
||||
cat = item["category"]
|
||||
|
||||
if not p.exists():
|
||||
_log(f"STALE: {p} (removed from tracking)")
|
||||
continue
|
||||
|
||||
age = (now - datetime.fromisoformat(item["timestamp"])).days
|
||||
|
||||
# ---- stale-state migration (fixes #37721) ----
|
||||
# Old tracked.json entries may carry a "cron-output" category for
|
||||
# paths that are NOT under cron/output/ (e.g. cron/jobs.json).
|
||||
# guess_category() was fixed in #34840, but existing entries are
|
||||
# never re-validated. Re-classify here so stale entries for cron
|
||||
# control-plane state are not deleted.
|
||||
if cat == "cron-output":
|
||||
re_cat = guess_category(p)
|
||||
if re_cat != "cron-output":
|
||||
_log(
|
||||
f"SKIP stale cron-output entry: {p} "
|
||||
f"(re-classified as {re_cat!r})"
|
||||
)
|
||||
# Drop the stale entry — it was misclassified.
|
||||
continue
|
||||
|
||||
# ---- stale-state migration for 'test' category (fixes #75403) ----
|
||||
# Old tracked.json entries may carry a "test" category for paths
|
||||
# that are now under protected project directories (patches/,
|
||||
# projects/, etc.). guess_category() was tightened in the fix for
|
||||
# #75403, but existing entries are never re-validated. Re-classify
|
||||
# here so stale entries for protected paths are not deleted.
|
||||
if cat == "test":
|
||||
re_cat = guess_category(p)
|
||||
if re_cat != "test":
|
||||
_log(
|
||||
f"SKIP stale test entry: {p} "
|
||||
f"(re-classified as {re_cat!r} — under protected tree)"
|
||||
)
|
||||
continue
|
||||
|
||||
# Hard safety net: never delete cron control-plane state even if
|
||||
# the category somehow slipped through re-validation above.
|
||||
if _is_protected_cron_path(p):
|
||||
_log(f"SKIP protected cron path: {p}")
|
||||
continue
|
||||
|
||||
should_delete = (
|
||||
cat == "test"
|
||||
or (cat == "temp" and age > 7)
|
||||
or (cat == "cron-output" and age > 14)
|
||||
)
|
||||
|
||||
if should_delete:
|
||||
try:
|
||||
if p.is_file():
|
||||
p.unlink()
|
||||
elif p.is_dir():
|
||||
shutil.rmtree(p)
|
||||
freed += item["size"]
|
||||
deleted += 1
|
||||
_log(f"DELETED: {p} ({cat}, {fmt_size(item['size'])})")
|
||||
except OSError as e:
|
||||
_log(f"ERROR deleting {p}: {e}")
|
||||
errors.append(f"{p}: {e}")
|
||||
new_tracked.append(item)
|
||||
else:
|
||||
new_tracked.append(item)
|
||||
|
||||
# Remove empty dirs under HERMES_HOME, but never recurse into known
|
||||
# durable state trees. Some installs place the Hermes checkout, venv,
|
||||
# and desktop build under HERMES_HOME; a full rglob over that tree can
|
||||
# stall the gateway event loop for minutes.
|
||||
hermes_home = get_hermes_home()
|
||||
empty_removed = 0
|
||||
sweep_stack: List[Tuple[Path, bool]] = []
|
||||
try:
|
||||
for top in hermes_home.iterdir():
|
||||
if (
|
||||
top.is_dir()
|
||||
and not top.is_symlink()
|
||||
and top.name not in _EMPTY_DIR_PROTECTED_TOP_LEVEL
|
||||
and top.name not in _EMPTY_DIR_SWEEP_PRUNE_DIRS
|
||||
):
|
||||
sweep_stack.append((top, False))
|
||||
except OSError:
|
||||
sweep_stack = []
|
||||
|
||||
while sweep_stack:
|
||||
dirpath, visited = sweep_stack.pop()
|
||||
if visited:
|
||||
try:
|
||||
if not any(dirpath.iterdir()):
|
||||
dirpath.rmdir()
|
||||
empty_removed += 1
|
||||
_log(f"DELETED: {dirpath} (empty dir)")
|
||||
except OSError:
|
||||
pass
|
||||
continue
|
||||
|
||||
sweep_stack.append((dirpath, True))
|
||||
try:
|
||||
for child in dirpath.iterdir():
|
||||
if (
|
||||
child.is_dir()
|
||||
and not child.is_symlink()
|
||||
and child.name not in _EMPTY_DIR_SWEEP_PRUNE_DIRS
|
||||
):
|
||||
sweep_stack.append((child, False))
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
save_tracked(new_tracked)
|
||||
_log(
|
||||
f"QUICK_SUMMARY: {deleted} files, {empty_removed} dirs, "
|
||||
f"{fmt_size(freed)}"
|
||||
)
|
||||
return {
|
||||
"deleted": deleted,
|
||||
"empty_dirs": empty_removed,
|
||||
"freed": freed,
|
||||
"errors": errors,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Deep cleanup (interactive — not called from plugin hooks)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def deep(
|
||||
confirm: Optional[callable] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Deep cleanup.
|
||||
|
||||
Runs :func:`quick` first, then asks the *confirm* callable for each
|
||||
risky item (research > 30d beyond 10 newest, chrome-profile > 14d,
|
||||
any file > 500 MB). *confirm(item)* must return True to delete.
|
||||
|
||||
Returns: ``{"quick": {...}, "deep_deleted": N, "deep_freed": bytes}``.
|
||||
"""
|
||||
quick_result = quick()
|
||||
|
||||
if confirm is None:
|
||||
# No interactive confirmer — deep stops after the quick pass.
|
||||
return {"quick": quick_result, "deep_deleted": 0, "deep_freed": 0}
|
||||
|
||||
tracked = load_tracked()
|
||||
now = datetime.now(timezone.utc)
|
||||
research, chrome, large = [], [], []
|
||||
|
||||
for item in tracked:
|
||||
p = Path(item["path"])
|
||||
if not p.exists():
|
||||
continue
|
||||
age = (now - datetime.fromisoformat(item["timestamp"])).days
|
||||
cat = item["category"]
|
||||
|
||||
if cat == "research" and age > 30:
|
||||
research.append(item)
|
||||
elif cat == "chrome-profile" and age > 14:
|
||||
chrome.append(item)
|
||||
elif item["size"] > 500 * 1024 * 1024:
|
||||
large.append(item)
|
||||
|
||||
research.sort(key=lambda x: x["timestamp"], reverse=True)
|
||||
old_research = research[10:]
|
||||
|
||||
freed, count = 0, 0
|
||||
to_remove: List[Dict] = []
|
||||
|
||||
for group in (old_research, chrome, large):
|
||||
for item in group:
|
||||
if confirm(item):
|
||||
try:
|
||||
p = Path(item["path"])
|
||||
if p.is_file():
|
||||
p.unlink()
|
||||
elif p.is_dir():
|
||||
shutil.rmtree(p)
|
||||
to_remove.append(item)
|
||||
freed += item["size"]
|
||||
count += 1
|
||||
_log(
|
||||
f"DELETED: {p} ({item['category']}, "
|
||||
f"{fmt_size(item['size'])})"
|
||||
)
|
||||
except OSError as e:
|
||||
_log(f"ERROR deleting {item['path']}: {e}")
|
||||
|
||||
if to_remove:
|
||||
remove_paths = {i["path"] for i in to_remove}
|
||||
save_tracked([i for i in tracked if i["path"] not in remove_paths])
|
||||
|
||||
return {"quick": quick_result, "deep_deleted": count, "deep_freed": freed}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Status
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def status() -> Dict[str, Any]:
|
||||
"""Return per-category breakdown and top 10 largest tracked files."""
|
||||
tracked = load_tracked()
|
||||
cats: Dict[str, Dict] = {}
|
||||
for item in tracked:
|
||||
c = item["category"]
|
||||
cats.setdefault(c, {"count": 0, "size": 0})
|
||||
cats[c]["count"] += 1
|
||||
cats[c]["size"] += item["size"]
|
||||
|
||||
existing = [
|
||||
(i["path"], i["size"], i["category"])
|
||||
for i in tracked if Path(i["path"]).exists()
|
||||
]
|
||||
existing.sort(key=lambda x: x[1], reverse=True)
|
||||
|
||||
return {
|
||||
"categories": cats,
|
||||
"top10": existing[:10],
|
||||
"total_tracked": len(tracked),
|
||||
}
|
||||
|
||||
|
||||
def format_status(s: Dict[str, Any]) -> str:
|
||||
"""Human-readable status string (for slash command output)."""
|
||||
lines = [f"{'Category':<20} {'Files':>6} {'Size':>10}", "-" * 40]
|
||||
cats = s["categories"]
|
||||
for cat, d in sorted(cats.items(), key=lambda x: x[1]["size"], reverse=True):
|
||||
lines.append(f"{cat:<20} {d['count']:>6} {fmt_size(d['size']):>10}")
|
||||
|
||||
if not cats:
|
||||
lines.append("(nothing tracked yet)")
|
||||
|
||||
lines.append("")
|
||||
lines.append("Top 10 largest tracked files:")
|
||||
if not s["top10"]:
|
||||
lines.append(" (none)")
|
||||
else:
|
||||
for rank, (path, size, cat) in enumerate(s["top10"], 1):
|
||||
lines.append(f" {rank:>2}. {fmt_size(size):>8} [{cat}] {path}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auto-categorisation from tool-call inspection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_TEST_PATTERNS = ("test_", "tmp_")
|
||||
_TEST_SUFFIXES = (".test.py", ".test.js", ".test.ts", ".test.md")
|
||||
|
||||
|
||||
def guess_category(path: Path) -> Optional[str]:
|
||||
"""Return a category label for *path*, or None if we shouldn't track it.
|
||||
|
||||
Used by the ``post_tool_call`` hook to auto-track ephemeral files.
|
||||
"""
|
||||
if not is_safe_path(path):
|
||||
return None
|
||||
|
||||
# Skip the state dir itself, logs, memory files, sessions, config.
|
||||
hermes_home = get_hermes_home()
|
||||
try:
|
||||
rel = path.resolve().relative_to(hermes_home)
|
||||
top = rel.parts[0] if rel.parts else ""
|
||||
if top in {
|
||||
"disk-cleanup", "logs", "memories", "sessions", "config.yaml",
|
||||
"skills", "plugins", ".env", "USER.md", "MEMORY.md", "SOUL.md",
|
||||
"auth.json", "hermes-agent",
|
||||
# User-authored and project trees — never auto-delete files
|
||||
# inside these just because they happen to be named test_* or
|
||||
# tmp_* (#75403, also #32164, #37721).
|
||||
"patches", "projects", "skins", "themes", "contributors",
|
||||
"profiles", "backups", "optional-skills",
|
||||
}:
|
||||
return None
|
||||
if top == "cron" or top == "cronjobs":
|
||||
# Only files under the disposable ``output/`` subtree are
|
||||
# cleanup candidates. Top-level cron control-plane state
|
||||
# (e.g. ``jobs.json``, ``.tick.lock``) must never be
|
||||
# auto-tracked — deleting it wipes the live scheduler
|
||||
# registry. See issue #32164.
|
||||
if len(rel.parts) >= 3 and rel.parts[1] == "output":
|
||||
return "cron-output"
|
||||
return None
|
||||
if top == "cache":
|
||||
return "temp"
|
||||
except ValueError:
|
||||
# Path isn't under HERMES_HOME (e.g. /tmp/hermes-*) — fall through.
|
||||
pass
|
||||
|
||||
name = path.name
|
||||
if name.startswith(_TEST_PATTERNS):
|
||||
return "test"
|
||||
if any(name.endswith(sfx) for sfx in _TEST_SUFFIXES):
|
||||
return "test"
|
||||
return None
|
||||
@@ -0,0 +1,7 @@
|
||||
name: disk-cleanup
|
||||
version: 2.0.0
|
||||
description: "Auto-track and clean up ephemeral files (test scripts, temp outputs, cron logs) created during Hermes sessions. Runs via plugin hooks — no agent action required."
|
||||
author: "@LVT382009 (original), NousResearch (plugin port)"
|
||||
hooks:
|
||||
- post_tool_call
|
||||
- on_session_end
|
||||
@@ -0,0 +1,131 @@
|
||||
# google_meet plugin
|
||||
|
||||
Let the hermes agent join a Google Meet call, transcribe it, optionally speak
|
||||
in it, and do the followup work afterwards.
|
||||
|
||||
## What ships
|
||||
|
||||
| Version | What | Status |
|
||||
|---|---|---|
|
||||
| v1 | Transcribe-only: Playwright joins Meet, scrapes captions to transcript file | ✓ ships by default |
|
||||
| v2 | Realtime duplex audio: bot speaks in-call via OpenAI Realtime + BlackHole/PulseAudio null-sink | ✓ opt in with `mode='realtime'` |
|
||||
| v3 | Remote node host: run the bot on a different machine than the gateway | ✓ opt in with `node='<name>'` |
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─ gateway (Linux box, where hermes runs) ────────────────────────────┐
|
||||
│ │
|
||||
│ agent → meet_join(url, mode='realtime', node='my-mac') │
|
||||
│ │ │
|
||||
│ └─ NodeClient ─── ws ────┐ │
|
||||
│ │ │
|
||||
└──────────────────────────────────┼───────────────────────────────────┘
|
||||
│ wss (token auth)
|
||||
▼
|
||||
┌─ node host (user's Mac, signed-in Chrome lives here) ───────────────┐
|
||||
│ │
|
||||
│ NodeServer (from `hermes meet node run`) │
|
||||
│ │ │
|
||||
│ ├─ start_bot → process_manager.start() → spawns meet_bot │
|
||||
│ │ │
|
||||
│ └─ meet_bot (Playwright) │
|
||||
│ ├─ Chromium → meet.google.com │
|
||||
│ ├─ caption scraper → transcript.txt │
|
||||
│ └─ (realtime mode only) RealtimeSpeaker thread │
|
||||
│ ↓ │
|
||||
│ OpenAI Realtime WS → speaker.pcm │
|
||||
│ ↓ │
|
||||
│ paplay → null-sink ← Chrome fake mic │
|
||||
│ │
|
||||
└──────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Without v3: the whole right column runs on the gateway machine.
|
||||
Without v2: the "realtime" path is skipped; transcribe runs alone.
|
||||
|
||||
## Files
|
||||
|
||||
| Path | Purpose |
|
||||
|---|---|
|
||||
| `plugin.yaml` | manifest |
|
||||
| `__init__.py` | `register(ctx)` — registers 5 tools + `on_session_end` hook + `hermes meet` CLI |
|
||||
| `meet_bot.py` | Playwright bot subprocess (standalone, `python -m plugins.google_meet.meet_bot`) |
|
||||
| `process_manager.py` | local bot lifecycle + `enqueue_say` |
|
||||
| `tools.py` | agent-facing tools + node-routing helper |
|
||||
| `cli.py` | `hermes meet setup / auth / join / status / transcript / say / stop / node ...` |
|
||||
| `audio_bridge.py` | v2: PulseAudio null-sink (Linux) + BlackHole probe (macOS) |
|
||||
| `realtime/openai_client.py` | v2: `RealtimeSession` + `RealtimeSpeaker` (file-queue → OpenAI Realtime WS → PCM) |
|
||||
| `node/protocol.py` | v3: message envelope + validation |
|
||||
| `node/registry.py` | v3: `$HERMES_HOME/workspace/meetings/nodes.json` |
|
||||
| `node/server.py` | v3: `NodeServer` (runs on host machine) |
|
||||
| `node/client.py` | v3: `NodeClient` (used by tool handlers + CLI on gateway) |
|
||||
| `node/cli.py` | v3: `hermes meet node {run,list,approve,remove,status,ping}` |
|
||||
| `SKILL.md` | agent usage guide |
|
||||
|
||||
## Local quick start
|
||||
|
||||
```bash
|
||||
hermes plugins enable google_meet
|
||||
hermes meet install # pip + Chromium
|
||||
hermes meet setup # preflight
|
||||
hermes meet auth # optional
|
||||
hermes meet join https://meet.google.com/abc-defg-hij # transcribe
|
||||
```
|
||||
|
||||
## Realtime mode
|
||||
|
||||
Linux (preferred, most automated):
|
||||
```bash
|
||||
hermes meet install --realtime # installs pulseaudio-utils
|
||||
echo 'OPENAI_API_KEY=sk-...' >> ~/.hermes/.env
|
||||
hermes meet join https://meet.google.com/abc-defg-hij --mode realtime
|
||||
# then from the agent or CLI:
|
||||
hermes meet say "Good morning everyone, I'm the note-taker bot."
|
||||
```
|
||||
|
||||
macOS:
|
||||
```bash
|
||||
hermes meet install --realtime # runs: brew install blackhole-2ch ffmpeg
|
||||
# then — manually! — open System Settings → Sound → Input → BlackHole 2ch
|
||||
echo 'OPENAI_API_KEY=sk-...' >> ~/.hermes/.env
|
||||
hermes meet join https://meet.google.com/abc-defg-hij --mode realtime
|
||||
```
|
||||
|
||||
On macOS, hermes will **not** switch your system audio input automatically — the
|
||||
user has to do it. This is deliberate: switching default input on a whim would
|
||||
be a surprising side effect.
|
||||
|
||||
## Remote node host
|
||||
|
||||
On the node machine (e.g. user's Mac with a signed-in Chrome):
|
||||
```bash
|
||||
pip install playwright websockets
|
||||
python -m playwright install chromium
|
||||
hermes plugins enable google_meet
|
||||
hermes meet node run --display-name my-mac --host 0.0.0.0 --port 18789
|
||||
# prints the bearer token on first run; copy it
|
||||
```
|
||||
|
||||
On the gateway:
|
||||
```bash
|
||||
hermes meet node approve my-mac ws://<mac-ip>:18789 <token>
|
||||
hermes meet node ping my-mac
|
||||
# now any meet_* tool call accepts node='my-mac' (or 'auto')
|
||||
```
|
||||
|
||||
## Safety
|
||||
|
||||
- URL gate: only `https://meet.google.com/abc-defg-hij`, `/new`, `/lookup/<id>`.
|
||||
- No calendar scanning, no auto-dial, no auto-consent announcement.
|
||||
- Node server uses bearer-token auth; no key exchange, no TLS termination
|
||||
built in — run it on a LAN or behind a reverse proxy you trust.
|
||||
- One active meeting per (gateway, node) pair. A second `meet_join` leaves the first.
|
||||
- `meet_say` refuses unless the active meeting was started with `mode='realtime'`.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- **Calendar scanning** — deliberately not implemented. Join URLs must be explicit.
|
||||
- **Multi-tenant node sharing** — a node serves one gateway at a time.
|
||||
- **Windows** — audio bridging isn't tested; `register()` no-ops on Windows.
|
||||
- **System audio input switching on macOS** — user responsibility, not the bot's.
|
||||
@@ -0,0 +1,148 @@
|
||||
---
|
||||
name: google_meet
|
||||
description: Join a Google Meet call, transcribe live captions, optionally speak in realtime, and do the followup work afterwards. Use when the user asks the agent to sit in on a meeting, take notes, summarize, respond in-call, or action items from it.
|
||||
version: 0.2.0
|
||||
platforms:
|
||||
- linux
|
||||
- macos
|
||||
metadata:
|
||||
hermes:
|
||||
tags: [meetings, google-meet, transcription, realtime-voice]
|
||||
---
|
||||
|
||||
# google_meet
|
||||
|
||||
## When to use
|
||||
|
||||
The user says any of:
|
||||
|
||||
- "join my Meet at <url>"
|
||||
- "take notes on this meeting"
|
||||
- "summarize the meeting and send followups"
|
||||
- "sit in on my standup"
|
||||
- "be a bot in this call and speak up when X"
|
||||
|
||||
## Two modes
|
||||
|
||||
| Mode | What the bot does |
|
||||
|---|---|
|
||||
| `transcribe` (default) | Joins, enables captions, scrapes a transcript. Listen-only. |
|
||||
| `realtime` | Same as transcribe PLUS speaks into the meeting via OpenAI Realtime. The agent calls `meet_say(text)` and the bot's voice comes out of the call. |
|
||||
|
||||
Pick `realtime` only when the user actually wants the agent to speak. It costs real money (OpenAI Realtime is pay-per-audio-minute) and requires a virtual audio device set up on the machine running the bot.
|
||||
|
||||
## Two locations
|
||||
|
||||
| Location | When |
|
||||
|---|---|
|
||||
| Local (default) | Gateway machine runs the Playwright bot directly. |
|
||||
| Remote node (`node="<name>"`) | Bot runs on a different machine that has a signed-in Chrome and (for realtime) a configured audio bridge. Useful when the gateway runs on a headless Linux box but the user's real signed-in Chrome lives on their Mac. |
|
||||
|
||||
## Prerequisites the user must handle once
|
||||
|
||||
Easiest path — run the built-in installer:
|
||||
|
||||
```bash
|
||||
hermes plugins enable google_meet
|
||||
hermes meet install # pip deps + Chromium (transcribe only)
|
||||
hermes meet install --realtime # + pulseaudio-utils / brew blackhole+ffmpeg
|
||||
hermes meet auth # optional; skips guest-lobby wait
|
||||
hermes meet setup # preflight checks
|
||||
```
|
||||
|
||||
`hermes meet install --realtime` prompts before running `sudo apt-get` (Linux)
|
||||
or `brew install` (macOS). Pass `--yes` to skip the prompt. It will NOT touch
|
||||
your macOS default-input setting — you have to select BlackHole 2ch in
|
||||
System Settings yourself before starting a realtime meeting.
|
||||
|
||||
Or do it manually:
|
||||
```bash
|
||||
pip install playwright websockets && python -m playwright install chromium
|
||||
|
||||
# For realtime mode, additionally:
|
||||
# Linux: sudo apt install pulseaudio-utils
|
||||
# macOS: brew install blackhole-2ch ffmpeg
|
||||
# → System Settings → Sound → Input → BlackHole 2ch
|
||||
# Then set OPENAI_API_KEY or HERMES_MEET_REALTIME_KEY in ~/.hermes/.env
|
||||
```
|
||||
|
||||
For a remote node:
|
||||
```bash
|
||||
# on the user's Mac (where Chrome is signed in):
|
||||
pip install playwright websockets && python -m playwright install chromium
|
||||
hermes plugins enable google_meet
|
||||
hermes meet node run --display-name my-mac # persistent server
|
||||
# copy the printed token
|
||||
|
||||
# on the gateway:
|
||||
hermes meet node approve my-mac ws://<mac-ip>:18789 <token>
|
||||
hermes meet node ping my-mac # confirm reachable
|
||||
```
|
||||
|
||||
Run `hermes meet setup` to preflight local prereqs.
|
||||
|
||||
## Flow
|
||||
|
||||
1. **Join** — call `meet_join(url=..., mode=..., node=...)`. Returns immediately.
|
||||
2. **Announce yourself** — no auto-consent. Say (in whatever channel the user is watching): "A Hermes agent bot is in this call taking notes."
|
||||
3. **Poll** — `meet_status()` for liveness, `meet_transcript(last=20)` for recent captions. Don't re-read the whole transcript every turn.
|
||||
4. **Speak (realtime only)** — `meet_say(text="...")` queues text for TTS. The speech lags by ~2s. Don't spam it.
|
||||
5. **Leave** — `meet_leave()` when done, or set `duration="30m"` on `meet_join` for auto-leave.
|
||||
6. **Follow up** — read `meet_transcript()` in full, summarize, and use regular tools to send the recap, file issues, schedule followups.
|
||||
|
||||
## Tool reference
|
||||
|
||||
| Tool | Parameters | Use |
|
||||
|---|---|---|
|
||||
| `meet_join` | `url`, `mode?`, `guest_name?`, `duration?`, `headed?`, `node?` | Start bot |
|
||||
| `meet_status` | `node?` | Liveness + progress |
|
||||
| `meet_transcript` | `last?`, `node?` | Read captions |
|
||||
| `meet_leave` | `node?` | Close bot |
|
||||
| `meet_say` | `text`, `node?` | Speak in realtime meeting |
|
||||
|
||||
`node?` on all tools: pass a registered node name (or `"auto"` for the sole node) to operate a remote bot instead of a local one. Omit for local.
|
||||
|
||||
## Important limits
|
||||
|
||||
- Captions are only as good as Google Meet's live captions. English-biased, lossy on overlapping speakers.
|
||||
- Guest mode sits in the lobby until a host admits. Warn the user; `hermes meet auth` avoids this.
|
||||
- **Lobby timeout**: if the host doesn't admit the bot within 5 minutes (configurable via `HERMES_MEET_LOBBY_TIMEOUT` env), the bot leaves and `meet_status` reports `leaveReason: "lobby_timeout"`.
|
||||
- **One active meeting per install per location.** A second `meet_join` leaves the first.
|
||||
- **Windows not supported.**
|
||||
- Realtime mode needs a virtual audio device. If the audio bridge setup fails, the bot falls back to transcribe mode and flags it in `meet_status().error`.
|
||||
- `meet_say` requires `mode='realtime'` on the originating `meet_join`. Calling it against a transcribe-mode meeting returns a clear error.
|
||||
- **Barge-in is best-effort.** When a caption arrives attributed to a real participant while the bot is generating audio, the bot sends `response.cancel` to OpenAI Realtime. Captions take ~500ms to show up, so the bot will talk over the first second or so of a human interruption.
|
||||
|
||||
## Status dict reference
|
||||
|
||||
`meet_status()` returns (subset shown, there are more):
|
||||
|
||||
| Key | Meaning |
|
||||
|---|---|
|
||||
| `inCall` | Past the lobby. False while waiting for admission. |
|
||||
| `lobbyWaiting` | Clicked "Ask to join", waiting on host. |
|
||||
| `joinAttemptedAt` / `joinedAt` | Timestamps for lobby-click and actual admission. |
|
||||
| `captioning` | Caption observer is installed. |
|
||||
| `transcriptLines` / `lastCaptionAt` | Transcript progress. |
|
||||
| `realtime` / `realtimeReady` | Realtime mode provisioned / WS connected. |
|
||||
| `realtimeDevice` | Audio device name the bot is feeding (e.g. `hermes_meet_src`). |
|
||||
| `audioBytesOut` / `lastAudioOutAt` | How much PCM the OpenAI session has produced. |
|
||||
| `lastBargeInAt` | Timestamp of the most recent `response.cancel` sent. |
|
||||
| `leaveReason` | `duration_expired`, `lobby_timeout`, `denied`, `page_closed`, or null. |
|
||||
| `error` | Last error (soft — bot may still be running). |
|
||||
|
||||
## Transcript location
|
||||
|
||||
Local:
|
||||
```
|
||||
$HERMES_HOME/workspace/meetings/<meeting-id>/transcript.txt
|
||||
```
|
||||
|
||||
Remote node: transcript lives on the node host's disk. Use `meet_transcript(node=...)` to read it over RPC.
|
||||
|
||||
## Safety
|
||||
|
||||
- URL regex: only `https://meet.google.com/...` URLs pass.
|
||||
- No calendar scanning. No auto-dial.
|
||||
- Remote nodes use bearer-token auth; tokens are generated on the node (32 hex chars, persisted in `$HERMES_HOME/workspace/meetings/node_token.json`) and must be copied to the gateway via `hermes meet node approve`.
|
||||
- `meet_say` text is rate-limited by the OpenAI Realtime session; spam-protection is the bot's problem, not yours, but still — don't queue hundreds of lines.
|
||||
@@ -0,0 +1,103 @@
|
||||
"""google_meet plugin — let the agent join a Meet call, transcribe it, follow up.
|
||||
|
||||
v1: transcribe-only. Spawns a headless Chromium via Playwright, joins the Meet
|
||||
URL, enables live captions, scrapes them into a transcript file. The agent then
|
||||
has the transcript in its workspace and can do whatever followup work it needs
|
||||
using its regular tools.
|
||||
|
||||
v2 (not in this PR): realtime duplex audio so the agent can speak in the
|
||||
meeting, via OpenAI Realtime / Gemini Live + BlackHole / PulseAudio null-sink.
|
||||
``meet_say`` exists as a stub today so the tool surface is stable.
|
||||
|
||||
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
|
||||
|
||||
import logging
|
||||
import platform
|
||||
|
||||
from plugins.google_meet import process_manager as pm
|
||||
from plugins.google_meet.cli import register_cli as _register_meet_cli
|
||||
from plugins.google_meet.cli import meet_command as _meet_command
|
||||
from plugins.google_meet.tools import (
|
||||
MEET_JOIN_SCHEMA,
|
||||
MEET_LEAVE_SCHEMA,
|
||||
MEET_SAY_SCHEMA,
|
||||
MEET_STATUS_SCHEMA,
|
||||
MEET_TRANSCRIPT_SCHEMA,
|
||||
check_meet_requirements,
|
||||
handle_meet_join,
|
||||
handle_meet_leave,
|
||||
handle_meet_say,
|
||||
handle_meet_status,
|
||||
handle_meet_transcript,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
_TOOLS = (
|
||||
("meet_join", MEET_JOIN_SCHEMA, handle_meet_join, "📞"),
|
||||
("meet_status", MEET_STATUS_SCHEMA, handle_meet_status, "🟢"),
|
||||
("meet_transcript", MEET_TRANSCRIPT_SCHEMA, handle_meet_transcript, "📝"),
|
||||
("meet_leave", MEET_LEAVE_SCHEMA, handle_meet_leave, "👋"),
|
||||
("meet_say", MEET_SAY_SCHEMA, handle_meet_say, "🗣️"),
|
||||
)
|
||||
|
||||
|
||||
def _on_session_end(**kwargs) -> None:
|
||||
"""Best-effort cleanup — if a meet bot is still running when the session
|
||||
ends, leave the call so we don't orphan a headless Chromium.
|
||||
|
||||
No-ops when nothing is active. Swallows all exceptions — session end must
|
||||
not fail because the bot cleanup hit an edge case.
|
||||
"""
|
||||
try:
|
||||
status = pm.status()
|
||||
if status.get("ok") and status.get("alive"):
|
||||
pm.stop(reason="session ended")
|
||||
except Exception as e: # pragma: no cover — defensive
|
||||
logger.debug("google_meet on_session_end cleanup failed: %s", e)
|
||||
|
||||
|
||||
def register(ctx) -> None:
|
||||
"""Register tools, CLI, and lifecycle hooks.
|
||||
|
||||
Called once by the plugin loader when the plugin is enabled via
|
||||
``plugins.enabled`` in config.yaml.
|
||||
"""
|
||||
# Windows is not supported in v1 — audio routing for v2 doesn't have a
|
||||
# tested path there and guest-join Chromium is flakier. Refuse to register
|
||||
# rather than half-working.
|
||||
system = platform.system().lower()
|
||||
if system not in {"linux", "darwin"}:
|
||||
logger.info(
|
||||
"google_meet plugin: platform=%s not supported (linux/macos only)",
|
||||
system,
|
||||
)
|
||||
return
|
||||
|
||||
for name, schema, handler, emoji in _TOOLS:
|
||||
ctx.register_tool(
|
||||
name=name,
|
||||
toolset="google_meet",
|
||||
schema=schema,
|
||||
handler=handler,
|
||||
check_fn=check_meet_requirements,
|
||||
emoji=emoji,
|
||||
)
|
||||
|
||||
ctx.register_cli_command(
|
||||
name="meet",
|
||||
help="Google Meet bot (join, transcribe, follow up)",
|
||||
setup_fn=_register_meet_cli,
|
||||
handler_fn=_meet_command,
|
||||
description=(
|
||||
"Let the hermes agent join a Google Meet call and scrape live "
|
||||
"captions into a transcript. See: hermes meet setup"
|
||||
),
|
||||
)
|
||||
|
||||
ctx.register_hook("on_session_end", _on_session_end)
|
||||
@@ -0,0 +1,248 @@
|
||||
"""Virtual audio bridge for feeding generated speech into Chrome's mic.
|
||||
|
||||
v2 module. Provisions a platform-specific virtual audio device so the
|
||||
Meet bot's Chromium instance can be pointed at an input source we
|
||||
control. The OpenAI Realtime client writes PCM bytes into this device;
|
||||
Chrome reads them as if they were coming from a microphone.
|
||||
|
||||
Linux (primary): uses pactl (PulseAudio) to create a null-sink plus a
|
||||
virtual source whose master is the null-sink's monitor. Callers set
|
||||
PULSE_SOURCE=<source_name> in Chrome's env and pass the fake-mic flag.
|
||||
|
||||
macOS: requires BlackHole 2ch to be installed. This module only
|
||||
verifies its presence and returns the device name; routing OS default
|
||||
input is left to the user (or a future switchaudio-osx integration) to
|
||||
avoid surprising the user's system audio state.
|
||||
|
||||
Windows: not supported in v2.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import platform
|
||||
import subprocess
|
||||
from typing import Optional
|
||||
|
||||
|
||||
_BLACKHOLE_DEVICE = "BlackHole 2ch"
|
||||
|
||||
|
||||
class AudioBridge:
|
||||
"""Manages a virtual audio device for Chrome fake-mic input.
|
||||
|
||||
Call ``setup()`` once before launching the Meet bot and
|
||||
``teardown()`` when the session ends. ``teardown()`` is idempotent.
|
||||
"""
|
||||
|
||||
def __init__(self, name_prefix: str = "hermes_meet") -> None:
|
||||
self._name_prefix = name_prefix
|
||||
self._platform: Optional[str] = None
|
||||
self._device_name: Optional[str] = None
|
||||
self._write_target: Optional[str] = None
|
||||
self._module_ids: list[int] = []
|
||||
self._torn_down = False
|
||||
|
||||
# ── public properties ─────────────────────────────────────────────────
|
||||
|
||||
@property
|
||||
def device_name(self) -> str:
|
||||
if not self._device_name:
|
||||
raise RuntimeError("AudioBridge not set up yet")
|
||||
return self._device_name
|
||||
|
||||
@property
|
||||
def write_target(self) -> str:
|
||||
if not self._write_target:
|
||||
raise RuntimeError("AudioBridge not set up yet")
|
||||
return self._write_target
|
||||
|
||||
# ── lifecycle ─────────────────────────────────────────────────────────
|
||||
|
||||
def setup(self) -> dict:
|
||||
"""Provision the virtual audio device.
|
||||
|
||||
Returns a dict describing the device. Raises RuntimeError on
|
||||
unsupported platforms or when required system tools are missing.
|
||||
"""
|
||||
system = platform.system()
|
||||
if system == "Linux":
|
||||
return self._setup_linux()
|
||||
if system == "Darwin":
|
||||
return self._setup_darwin()
|
||||
if system == "Windows":
|
||||
raise RuntimeError("windows not supported in v2")
|
||||
raise RuntimeError(f"unsupported platform: {system}")
|
||||
|
||||
def teardown(self) -> None:
|
||||
"""Release the virtual audio device. Idempotent."""
|
||||
if self._torn_down:
|
||||
return
|
||||
# Only Linux needs explicit unloading.
|
||||
if self._platform == "linux" and self._module_ids:
|
||||
# Unload in reverse order (virtual-source before null-sink).
|
||||
for mod_id in reversed(self._module_ids):
|
||||
try:
|
||||
subprocess.run(
|
||||
["pactl", "unload-module", str(mod_id)],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
stdin=subprocess.DEVNULL,
|
||||
)
|
||||
except Exception:
|
||||
# Best-effort teardown — never raise from here.
|
||||
pass
|
||||
self._module_ids = []
|
||||
self._torn_down = True
|
||||
|
||||
# ── platform impls ────────────────────────────────────────────────────
|
||||
|
||||
def _setup_linux(self) -> dict:
|
||||
sink_name = f"{self._name_prefix}_sink"
|
||||
src_name = f"{self._name_prefix}_src"
|
||||
|
||||
try:
|
||||
sink_out = subprocess.run(
|
||||
[
|
||||
"pactl",
|
||||
"load-module",
|
||||
"module-null-sink",
|
||||
f"sink_name={sink_name}",
|
||||
"sink_properties=device.description=HermesMeetSink",
|
||||
],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True, encoding='utf-8', errors='replace',
|
||||
stdin=subprocess.DEVNULL,
|
||||
)
|
||||
except FileNotFoundError as exc:
|
||||
raise RuntimeError(
|
||||
"pactl not found — install PulseAudio/pipewire-pulse"
|
||||
) from exc
|
||||
except subprocess.CalledProcessError as exc:
|
||||
raise RuntimeError(
|
||||
f"pactl load-module null-sink failed: {exc.stderr or exc}"
|
||||
) from exc
|
||||
|
||||
sink_mod_id = self._parse_module_id(sink_out.stdout)
|
||||
|
||||
try:
|
||||
src_out = subprocess.run(
|
||||
[
|
||||
"pactl",
|
||||
"load-module",
|
||||
"module-virtual-source",
|
||||
f"source_name={src_name}",
|
||||
f"master={sink_name}.monitor",
|
||||
],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True, encoding='utf-8', errors='replace',
|
||||
stdin=subprocess.DEVNULL,
|
||||
)
|
||||
except subprocess.CalledProcessError as exc:
|
||||
# Roll back the null-sink we just created so we don't leak it.
|
||||
subprocess.run(
|
||||
["pactl", "unload-module", str(sink_mod_id)],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
stdin=subprocess.DEVNULL,
|
||||
)
|
||||
raise RuntimeError(
|
||||
f"pactl load-module virtual-source failed: {exc.stderr or exc}"
|
||||
) from exc
|
||||
|
||||
src_mod_id = self._parse_module_id(src_out.stdout)
|
||||
|
||||
self._platform = "linux"
|
||||
self._device_name = src_name
|
||||
self._write_target = sink_name
|
||||
self._module_ids = [sink_mod_id, src_mod_id]
|
||||
self._torn_down = False
|
||||
|
||||
return {
|
||||
"platform": "linux",
|
||||
"device_name": src_name,
|
||||
"sample_rate": 48000,
|
||||
"channels": 2,
|
||||
"module_ids": list(self._module_ids),
|
||||
"write_target": sink_name,
|
||||
}
|
||||
|
||||
def _setup_darwin(self) -> dict:
|
||||
try:
|
||||
out = subprocess.check_output(
|
||||
["system_profiler", "SPAudioDataType"],
|
||||
text=True, encoding='utf-8', errors='replace',
|
||||
stderr=subprocess.STDOUT,
|
||||
)
|
||||
except FileNotFoundError as exc:
|
||||
raise RuntimeError(
|
||||
"system_profiler not found (macOS-only command)"
|
||||
) from exc
|
||||
except subprocess.CalledProcessError as exc:
|
||||
raise RuntimeError(
|
||||
f"system_profiler failed: {exc.output}"
|
||||
) from exc
|
||||
|
||||
if "BlackHole" not in out:
|
||||
raise RuntimeError(
|
||||
"BlackHole virtual audio device not installed. "
|
||||
"Install via: brew install blackhole-2ch"
|
||||
)
|
||||
|
||||
self._platform = "darwin"
|
||||
self._device_name = _BLACKHOLE_DEVICE
|
||||
self._write_target = _BLACKHOLE_DEVICE
|
||||
self._module_ids = []
|
||||
self._torn_down = False
|
||||
|
||||
return {
|
||||
"platform": "darwin",
|
||||
"device_name": _BLACKHOLE_DEVICE,
|
||||
"sample_rate": 48000,
|
||||
"channels": 2,
|
||||
"module_ids": [],
|
||||
"write_target": _BLACKHOLE_DEVICE,
|
||||
}
|
||||
|
||||
# ── helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def _parse_module_id(stdout: str) -> int:
|
||||
"""pactl load-module prints the new module ID to stdout."""
|
||||
text = (stdout or "").strip()
|
||||
if not text:
|
||||
raise RuntimeError("pactl load-module returned empty stdout")
|
||||
# Take the last whitespace-separated token on the first non-empty line.
|
||||
first = text.splitlines()[0].strip()
|
||||
token = first.split()[-1]
|
||||
try:
|
||||
return int(token)
|
||||
except ValueError as exc:
|
||||
raise RuntimeError(
|
||||
f"could not parse pactl module id from: {stdout!r}"
|
||||
) from exc
|
||||
|
||||
|
||||
def chrome_fake_audio_flags(bridge_info: dict) -> list[str]:
|
||||
"""Return Chrome flags for using the fake audio input.
|
||||
|
||||
The PulseAudio source is selected via the ``PULSE_SOURCE`` env var,
|
||||
which callers must set in Chrome's environment before launch:
|
||||
|
||||
env["PULSE_SOURCE"] = bridge_info["device_name"]
|
||||
|
||||
On macOS the caller must ensure the system default audio input is
|
||||
set to the returned BlackHole device (we do not flip that switch).
|
||||
"""
|
||||
system = platform.system()
|
||||
if system == "Linux":
|
||||
# Chromium on Linux picks up the PulseAudio source selected via
|
||||
# PULSE_SOURCE env var; the fake-ui flag skips the permission
|
||||
# prompt so the bot can pick "use my mic" without user input.
|
||||
return ["--use-fake-ui-for-media-stream"]
|
||||
if system == "Darwin":
|
||||
return ["--use-fake-ui-for-media-stream"]
|
||||
if system == "Windows":
|
||||
raise RuntimeError("windows not supported in v2")
|
||||
raise RuntimeError(f"unsupported platform: {system}")
|
||||
@@ -0,0 +1,476 @@
|
||||
"""CLI commands for the google_meet plugin.
|
||||
|
||||
Wires ``hermes meet <subcommand>``:
|
||||
setup — preflight playwright, chromium, auth file, print fixes
|
||||
auth — open a browser to sign into Google, save storage state
|
||||
join <url> — join a Meet URL synchronously (also callable from the agent)
|
||||
status — print current bot state
|
||||
transcript — print the transcript
|
||||
stop — leave the current meeting
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from hermes_constants import get_hermes_home
|
||||
|
||||
from plugins.google_meet import process_manager as pm
|
||||
from plugins.google_meet.meet_bot import _is_safe_meet_url
|
||||
|
||||
|
||||
def _auth_state_path() -> Path:
|
||||
return Path(get_hermes_home()) / "workspace" / "meetings" / "auth.json"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# argparse wiring
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def register_cli(subparser: argparse.ArgumentParser) -> None:
|
||||
"""Build the ``hermes meet`` argparse tree.
|
||||
|
||||
Called by :func:`_register_cli_commands` at plugin load time.
|
||||
"""
|
||||
subs = subparser.add_subparsers(dest="meet_command")
|
||||
|
||||
subs.add_parser("setup", help="Preflight: playwright, chromium, auth")
|
||||
|
||||
inst_p = subs.add_parser(
|
||||
"install",
|
||||
help="Install prerequisites (pip deps, Chromium, platform audio tools)",
|
||||
)
|
||||
inst_p.add_argument(
|
||||
"--realtime", action="store_true",
|
||||
help="Also install realtime audio tools (pulseaudio-utils on Linux, BlackHole+ffmpeg on macOS). Uses sudo/brew, prompts before invoking either.",
|
||||
)
|
||||
inst_p.add_argument(
|
||||
"--yes", "-y", action="store_true",
|
||||
help="Answer yes to all prompts (use with care; will run sudo apt-get or brew without asking).",
|
||||
)
|
||||
|
||||
subs.add_parser("auth", help="Sign in to Google and save session state")
|
||||
|
||||
join_p = subs.add_parser("join", help="Join a Meet URL")
|
||||
join_p.add_argument("url", help="https://meet.google.com/...")
|
||||
join_p.add_argument("--guest-name", default="Hermes Agent")
|
||||
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(
|
||||
"--mode", choices=("transcribe", "realtime"), default="transcribe",
|
||||
help="transcribe (default, listen-only) or realtime (speak via OpenAI Realtime)"
|
||||
)
|
||||
join_p.add_argument(
|
||||
"--node", default=None,
|
||||
help="remote node name, or 'auto' to use the sole registered node"
|
||||
)
|
||||
|
||||
subs.add_parser("status", help="Print current Meet bot state")
|
||||
|
||||
tr_p = subs.add_parser("transcript", help="Print the scraped transcript")
|
||||
tr_p.add_argument("--last", type=int, default=None)
|
||||
|
||||
say_p = subs.add_parser("say", help="Speak text in an active realtime meeting")
|
||||
say_p.add_argument("text", help="what to say")
|
||||
say_p.add_argument("--node", default=None)
|
||||
|
||||
subs.add_parser("stop", help="Leave the current meeting")
|
||||
|
||||
# v3: remote node host management.
|
||||
node_p = subs.add_parser(
|
||||
"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
|
||||
# If the node module fails to import for any reason (optional dep
|
||||
# missing at import time etc.), leave the subparser present but
|
||||
# flag it. The argparse dispatch will surface a clear error.
|
||||
def _node_unavailable(args):
|
||||
print(f"hermes meet node: module unavailable ({e})")
|
||||
return 1
|
||||
node_p.set_defaults(func=_node_unavailable)
|
||||
|
||||
subparser.set_defaults(func=meet_command)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dispatch
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def meet_command(args: argparse.Namespace) -> int:
|
||||
sub = getattr(args, "meet_command", None)
|
||||
if not sub:
|
||||
print("usage: hermes meet {setup,auth,join,status,transcript,say,stop,node}")
|
||||
return 2
|
||||
if sub == "setup":
|
||||
return _cmd_setup()
|
||||
if sub == "install":
|
||||
return _cmd_install(
|
||||
realtime=bool(getattr(args, "realtime", False)),
|
||||
assume_yes=bool(getattr(args, "yes", False)),
|
||||
)
|
||||
if sub == "auth":
|
||||
return _cmd_auth()
|
||||
if sub == "join":
|
||||
return _cmd_join(
|
||||
url=args.url,
|
||||
guest_name=args.guest_name,
|
||||
duration=args.duration,
|
||||
headed=args.headed,
|
||||
mode=getattr(args, "mode", "transcribe"),
|
||||
node=getattr(args, "node", None),
|
||||
)
|
||||
if sub == "status":
|
||||
return _cmd_status()
|
||||
if sub == "transcript":
|
||||
return _cmd_transcript(last=args.last)
|
||||
if sub == "say":
|
||||
return _cmd_say(text=args.text, node=getattr(args, "node", None))
|
||||
if sub == "stop":
|
||||
return _cmd_stop()
|
||||
if sub == "node":
|
||||
# Dispatch was set by the node cli's register_cli; fall through to
|
||||
# whatever its subparsers wired.
|
||||
fn = getattr(args, "func", None)
|
||||
if fn is None or fn is meet_command:
|
||||
print("usage: hermes meet node {run,list,approve,remove,status,ping}")
|
||||
return 2
|
||||
return fn(args)
|
||||
print(f"unknown subcommand: {sub}")
|
||||
return 2
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Subcommand handlers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _cmd_setup() -> int:
|
||||
import platform as _p
|
||||
|
||||
print("google_meet preflight")
|
||||
print("---------------------")
|
||||
|
||||
system = _p.system()
|
||||
system_ok = system in {"Linux", "Darwin"}
|
||||
print(f" platform : {system} [{'ok' if system_ok else 'unsupported'}]")
|
||||
|
||||
try:
|
||||
import playwright # noqa: F401
|
||||
pw_ok = True
|
||||
pw_msg = "installed"
|
||||
except ImportError:
|
||||
pw_ok = False
|
||||
pw_msg = "NOT installed — run: pip install playwright"
|
||||
print(f" playwright : {pw_msg}")
|
||||
|
||||
chromium_ok = False
|
||||
chromium_msg = "unknown"
|
||||
if pw_ok:
|
||||
try:
|
||||
from playwright.sync_api import sync_playwright
|
||||
with sync_playwright() as p:
|
||||
try:
|
||||
exe = p.chromium.executable_path
|
||||
if exe and Path(exe).exists():
|
||||
chromium_ok = True
|
||||
chromium_msg = f"ok ({exe})"
|
||||
else:
|
||||
chromium_msg = (
|
||||
"not installed — run: "
|
||||
"python -m playwright install chromium"
|
||||
)
|
||||
except Exception as e:
|
||||
chromium_msg = f"probe failed: {e}"
|
||||
except Exception as e:
|
||||
chromium_msg = f"probe failed: {e}"
|
||||
print(f" chromium : {chromium_msg}")
|
||||
|
||||
auth_path = _auth_state_path()
|
||||
auth_ok = auth_path.is_file()
|
||||
print(
|
||||
" google auth : "
|
||||
+ (f"ok ({auth_path})" if auth_ok else "not saved — run: hermes meet auth")
|
||||
)
|
||||
|
||||
print()
|
||||
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"
|
||||
)
|
||||
else:
|
||||
print("not ready yet — fix the items above.")
|
||||
return 0 if all_ok else 1
|
||||
|
||||
|
||||
def _cmd_install(*, realtime: bool, assume_yes: bool) -> int:
|
||||
"""Install the plugin's prerequisites.
|
||||
|
||||
Always: pip install playwright + websockets, then
|
||||
``python -m playwright install chromium``.
|
||||
|
||||
With ``--realtime``: also install the platform audio bridge deps.
|
||||
Linux : ``sudo apt-get install -y pulseaudio-utils``
|
||||
macOS : ``brew install blackhole-2ch ffmpeg`` (+ remind the user
|
||||
to select BlackHole as the default input device manually)
|
||||
|
||||
Prompts before every package-manager invocation unless ``--yes``.
|
||||
Refuses to run on Windows.
|
||||
"""
|
||||
import platform as _p
|
||||
import shutil as _shutil
|
||||
import subprocess as _sp
|
||||
|
||||
system = _p.system()
|
||||
if system not in {"Linux", "Darwin"}:
|
||||
print(f"google_meet install: {system} is not supported (linux/macos only)")
|
||||
return 1
|
||||
|
||||
def _confirm(prompt: str) -> bool:
|
||||
if assume_yes:
|
||||
return True
|
||||
try:
|
||||
ans = input(f"{prompt} [y/N] ").strip().lower()
|
||||
except EOFError:
|
||||
return False
|
||||
return ans in {"y", "yes"}
|
||||
|
||||
print("google_meet install")
|
||||
print("-------------------")
|
||||
|
||||
# 1) pip deps — always safe, venv-scoped.
|
||||
pip_pkgs = ["playwright", "websockets"]
|
||||
print(f"\n[1/3] pip install: {' '.join(pip_pkgs)}")
|
||||
try:
|
||||
from hermes_cli.tools_config import _pip_install
|
||||
|
||||
res = _pip_install(["--upgrade", *pip_pkgs], capture_output=False)
|
||||
if res.returncode != 0:
|
||||
print(" pip install failed")
|
||||
return 1
|
||||
except Exception as e:
|
||||
print(f" pip install failed: {e}")
|
||||
return 1
|
||||
|
||||
# 2) Playwright browsers — pulls chromium (~300MB first run).
|
||||
print("\n[2/3] python -m playwright install chromium")
|
||||
try:
|
||||
res = _sp.run(
|
||||
[sys.executable, "-m", "playwright", "install", "chromium"],
|
||||
check=False,
|
||||
)
|
||||
if res.returncode != 0:
|
||||
print(" playwright install failed (may already be installed)")
|
||||
except Exception as e:
|
||||
print(f" playwright install failed: {e}")
|
||||
return 1
|
||||
|
||||
# 3) Platform audio deps for realtime mode.
|
||||
if realtime:
|
||||
print("\n[3/3] realtime audio deps")
|
||||
if system == "Linux":
|
||||
if _shutil.which("paplay") and _shutil.which("pactl"):
|
||||
print(" pulseaudio-utils already installed.")
|
||||
else:
|
||||
if not _confirm(
|
||||
" install pulseaudio-utils? this runs `sudo apt-get install -y pulseaudio-utils`"
|
||||
):
|
||||
print(" skipped (you can run it manually later)")
|
||||
else:
|
||||
cmd = ["sudo", "apt-get", "install", "-y", "pulseaudio-utils"]
|
||||
print(f" $ {' '.join(cmd)}")
|
||||
res = _sp.run(cmd, check=False)
|
||||
if res.returncode != 0:
|
||||
print(" apt install failed — install pulseaudio-utils manually")
|
||||
elif system == "Darwin":
|
||||
have_bh = False
|
||||
try:
|
||||
out = _sp.check_output(["system_profiler", "SPAudioDataType"], text=True, encoding='utf-8', errors='replace')
|
||||
have_bh = "BlackHole" in out
|
||||
except Exception:
|
||||
pass
|
||||
have_ffmpeg = bool(_shutil.which("ffmpeg"))
|
||||
needs = []
|
||||
if not have_bh:
|
||||
needs.append("blackhole-2ch")
|
||||
if not have_ffmpeg:
|
||||
needs.append("ffmpeg")
|
||||
if not needs:
|
||||
print(" BlackHole and ffmpeg already installed.")
|
||||
elif not _shutil.which("brew"):
|
||||
print(
|
||||
" missing: " + ", ".join(needs) + "\n"
|
||||
" install Homebrew first (https://brew.sh) or install the packages manually."
|
||||
)
|
||||
else:
|
||||
if not _confirm(f" install via brew: {' '.join(needs)}?"):
|
||||
print(" skipped (you can run it manually later)")
|
||||
else:
|
||||
cmd = ["brew", "install", *needs]
|
||||
print(f" $ {' '.join(cmd)}")
|
||||
res = _sp.run(cmd, check=False)
|
||||
if res.returncode != 0:
|
||||
print(" brew install failed — install them manually")
|
||||
print(
|
||||
"\n NOTE: macOS does not auto-route audio. Open\n"
|
||||
" System Settings → Sound → Input\n"
|
||||
" and select 'BlackHole 2ch' before starting a realtime meeting.\n"
|
||||
" hermes will not switch your default input for you."
|
||||
)
|
||||
else:
|
||||
print("\n[3/3] skipped (pass --realtime to install audio tooling too)")
|
||||
|
||||
print("\ndone. verify with: hermes meet setup")
|
||||
return 0
|
||||
|
||||
|
||||
def _cmd_auth() -> int:
|
||||
"""Open a headed Chromium, let the user sign in, save storage_state."""
|
||||
try:
|
||||
from playwright.sync_api import sync_playwright
|
||||
except ImportError:
|
||||
print(
|
||||
"playwright is not installed. run:\n"
|
||||
" pip install playwright && python -m playwright install chromium"
|
||||
)
|
||||
return 1
|
||||
|
||||
path = _auth_state_path()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
print("opening Chromium — sign in to Google, then return here and press Enter.")
|
||||
print(f"saving storage state to: {path}")
|
||||
try:
|
||||
with sync_playwright() as pw:
|
||||
browser = pw.chromium.launch(headless=False)
|
||||
context = browser.new_context()
|
||||
page = context.new_page()
|
||||
page.goto("https://accounts.google.com/", wait_until="domcontentloaded")
|
||||
try:
|
||||
input("press Enter after you've signed in ... ")
|
||||
except EOFError:
|
||||
pass
|
||||
context.storage_state(path=str(path))
|
||||
browser.close()
|
||||
except Exception as e:
|
||||
print(f"auth failed: {e}")
|
||||
return 1
|
||||
print("saved. you can now run: hermes meet join <url>")
|
||||
return 0
|
||||
|
||||
|
||||
def _cmd_join(
|
||||
url: str,
|
||||
*,
|
||||
guest_name: str,
|
||||
duration: Optional[str],
|
||||
headed: bool,
|
||||
mode: str = "transcribe",
|
||||
node: Optional[str] = None,
|
||||
) -> int:
|
||||
if not _is_safe_meet_url(url):
|
||||
print(f"refusing: not a meet.google.com URL: {url}")
|
||||
return 2
|
||||
if node:
|
||||
# Remote: go through NodeClient.
|
||||
try:
|
||||
from plugins.google_meet.node.registry import NodeRegistry
|
||||
from plugins.google_meet.node.client import NodeClient
|
||||
except ImportError as e:
|
||||
print(f"node module unavailable: {e}")
|
||||
return 1
|
||||
reg = NodeRegistry()
|
||||
entry = reg.resolve(node if node != "auto" else None)
|
||||
if entry is None:
|
||||
print(f"no registered node matches {node!r}")
|
||||
return 1
|
||||
client = NodeClient(url=entry["url"], token=entry["token"])
|
||||
try:
|
||||
res = client.start_bot(
|
||||
url=url, guest_name=guest_name, duration=duration,
|
||||
headed=headed, mode=mode,
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"remote start_bot failed: {e}")
|
||||
return 1
|
||||
print(json.dumps({"node": entry.get("name"), **res}, indent=2))
|
||||
return 0 if res.get("ok") else 1
|
||||
|
||||
auth = _auth_state_path()
|
||||
res = pm.start(
|
||||
url=url,
|
||||
headed=headed,
|
||||
guest_name=guest_name,
|
||||
duration=duration,
|
||||
auth_state=str(auth) if auth.is_file() else None,
|
||||
mode=mode,
|
||||
)
|
||||
print(json.dumps(res, indent=2))
|
||||
return 0 if res.get("ok") else 1
|
||||
|
||||
|
||||
def _cmd_say(text: str, node: Optional[str] = None) -> int:
|
||||
if not (text or "").strip():
|
||||
print("refusing: empty text")
|
||||
return 2
|
||||
if node:
|
||||
try:
|
||||
from plugins.google_meet.node.registry import NodeRegistry
|
||||
from plugins.google_meet.node.client import NodeClient
|
||||
except ImportError as e:
|
||||
print(f"node module unavailable: {e}")
|
||||
return 1
|
||||
reg = NodeRegistry()
|
||||
entry = reg.resolve(node if node != "auto" else None)
|
||||
if entry is None:
|
||||
print(f"no registered node matches {node!r}")
|
||||
return 1
|
||||
client = NodeClient(url=entry["url"], token=entry["token"])
|
||||
try:
|
||||
res = client.say(text)
|
||||
except Exception as e:
|
||||
print(f"remote say failed: {e}")
|
||||
return 1
|
||||
print(json.dumps({"node": entry.get("name"), **res}, indent=2))
|
||||
return 0 if res.get("ok") else 1
|
||||
|
||||
res = pm.enqueue_say(text)
|
||||
print(json.dumps(res, indent=2))
|
||||
return 0 if res.get("ok") else 1
|
||||
|
||||
|
||||
def _cmd_status() -> int:
|
||||
res = pm.status()
|
||||
print(json.dumps(res, indent=2))
|
||||
return 0 if res.get("ok") else 1
|
||||
|
||||
|
||||
def _cmd_transcript(last: Optional[int]) -> int:
|
||||
res = pm.transcript(last=last)
|
||||
if not res.get("ok"):
|
||||
print(json.dumps(res, indent=2))
|
||||
return 1
|
||||
for ln in res.get("lines", []):
|
||||
print(ln)
|
||||
return 0
|
||||
|
||||
|
||||
def _cmd_stop() -> int:
|
||||
res = pm.stop(reason="hermes meet stop")
|
||||
print(json.dumps(res, indent=2))
|
||||
return 0 if res.get("ok") else 1
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
parser = argparse.ArgumentParser(prog="hermes meet")
|
||||
register_cli(parser)
|
||||
ns = parser.parse_args()
|
||||
sys.exit(meet_command(ns))
|
||||
@@ -0,0 +1,862 @@
|
||||
"""Headless Google Meet bot — Playwright + live-caption scraping.
|
||||
|
||||
Runs as a standalone subprocess spawned by ``process_manager.py``. Reads config
|
||||
from env vars, writes status + transcript to files under
|
||||
``$HERMES_HOME/workspace/meetings/<meeting-id>/``. The main hermes process
|
||||
reads those files via the ``meet_*`` tools — no IPC beyond filesystem.
|
||||
|
||||
The scraping strategy mirrors OpenUtter (sumansid/openutter): we don't parse
|
||||
WebRTC audio, we enable Google Meet's built-in live captions and observe the
|
||||
captions container in the DOM via a MutationObserver. This is lossy and
|
||||
English-biased but it is:
|
||||
|
||||
* deterministic (no API keys, no STT billing),
|
||||
* works behind Meet's normal login / admission,
|
||||
* survives Meet UI rewrites fairly well because the caption container has a
|
||||
stable ARIA role.
|
||||
|
||||
Run standalone for debugging::
|
||||
|
||||
HERMES_MEET_URL=https://meet.google.com/abc-defg-hij \\
|
||||
HERMES_MEET_OUT_DIR=/tmp/meet-debug \\
|
||||
HERMES_MEET_HEADED=1 \\
|
||||
python -m plugins.google_meet.meet_bot
|
||||
|
||||
No meet.google.com URL → exits non-zero. Any URL that doesn't start with
|
||||
``https://meet.google.com/`` is rejected (explicit-by-design).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import signal
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
# Match ``https://meet.google.com/abc-defg-hij`` or ``.../lookup/...`` — the
|
||||
# short three-segment code or a lookup URL. Anything else is rejected.
|
||||
MEET_URL_RE = re.compile(
|
||||
r"^https://meet\.google\.com/("
|
||||
r"[a-z0-9]{3,}-[a-z0-9]{3,}-[a-z0-9]{3,}"
|
||||
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"
|
||||
|
||||
|
||||
def _is_safe_meet_url(url: str) -> bool:
|
||||
"""Return True if *url* is a Google Meet URL we're willing to navigate to."""
|
||||
if not isinstance(url, str):
|
||||
return False
|
||||
return bool(MEET_URL_RE.match(url.strip()))
|
||||
|
||||
|
||||
def _meeting_id_from_url(url: str) -> str:
|
||||
"""Extract the 3-segment meeting code from a Meet URL.
|
||||
|
||||
For ``https://meet.google.com/abc-defg-hij`` → ``abc-defg-hij``.
|
||||
For ``.../lookup/<id>`` or ``/new`` we fall back to a timestamped id — the
|
||||
bot won't know the real code until after redirect, and callers pass this
|
||||
through to filename anyway.
|
||||
"""
|
||||
m = re.search(
|
||||
r"meet\.google\.com/([a-z0-9]{3,}-[a-z0-9]{3,}-[a-z0-9]{3,})",
|
||||
url or "",
|
||||
)
|
||||
if m:
|
||||
return m.group(1)
|
||||
return f"meet-{int(time.time())}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Status + transcript file writers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class _BotState:
|
||||
"""Single-process mutable state, flushed to ``status.json`` on each change."""
|
||||
|
||||
def __init__(self, out_dir: Path, meeting_id: str, url: str):
|
||||
self.out_dir = out_dir
|
||||
self.meeting_id = meeting_id
|
||||
self.url = url
|
||||
self.in_call = False
|
||||
self.captioning = False
|
||||
self.captions_enabled_attempted = False
|
||||
self.lobby_waiting = False
|
||||
self.join_attempted_at: Optional[float] = None
|
||||
self.joined_at: Optional[float] = None
|
||||
self.last_caption_at: Optional[float] = None
|
||||
self.transcript_lines = 0
|
||||
self.error: Optional[str] = None
|
||||
self.exited = False
|
||||
# v2 realtime fields.
|
||||
self.realtime = False
|
||||
self.realtime_ready = False
|
||||
self.realtime_device: Optional[str] = None
|
||||
self.audio_bytes_out: int = 0
|
||||
self.last_audio_out_at: Optional[float] = None
|
||||
self.last_barge_in_at: Optional[float] = None
|
||||
self.leave_reason: Optional[str] = None
|
||||
# Scraped captions, in order, deduped. Each entry is a dict of
|
||||
# {"ts": <epoch>, "speaker": str, "text": str}.
|
||||
self._seen: set = set()
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.transcript_path = out_dir / "transcript.txt"
|
||||
self.status_path = out_dir / "status.json"
|
||||
self._flush()
|
||||
|
||||
# -------- transcript ------------------------------------------------
|
||||
|
||||
def record_caption(self, speaker: str, text: str) -> None:
|
||||
"""Append a caption line if we haven't seen this exact (speaker, text)."""
|
||||
speaker = (speaker or "").strip() or "Unknown"
|
||||
text = (text or "").strip()
|
||||
if not text:
|
||||
return
|
||||
key = f"{speaker}|{text}"
|
||||
if key in self._seen:
|
||||
return
|
||||
self._seen.add(key)
|
||||
self.transcript_lines += 1
|
||||
self.last_caption_at = time.time()
|
||||
ts = time.strftime("%H:%M:%S", time.localtime(self.last_caption_at))
|
||||
line = f"[{ts}] {speaker}: {text}\n"
|
||||
# Atomic-ish append — good enough for a single-writer.
|
||||
with self.transcript_path.open("a", encoding="utf-8") as f:
|
||||
f.write(line)
|
||||
self._flush()
|
||||
|
||||
# -------- status file ----------------------------------------------
|
||||
|
||||
def _flush(self) -> None:
|
||||
data = {
|
||||
"meetingId": self.meeting_id,
|
||||
"url": self.url,
|
||||
"inCall": self.in_call,
|
||||
"captioning": self.captioning,
|
||||
"captionsEnabledAttempted": self.captions_enabled_attempted,
|
||||
"lobbyWaiting": self.lobby_waiting,
|
||||
"joinAttemptedAt": self.join_attempted_at,
|
||||
"joinedAt": self.joined_at,
|
||||
"lastCaptionAt": self.last_caption_at,
|
||||
"transcriptLines": self.transcript_lines,
|
||||
"transcriptPath": str(self.transcript_path),
|
||||
"error": self.error,
|
||||
"exited": self.exited,
|
||||
"pid": os.getpid(),
|
||||
# v2 realtime telemetry.
|
||||
"realtime": self.realtime,
|
||||
"realtimeReady": self.realtime_ready,
|
||||
"realtimeDevice": self.realtime_device,
|
||||
"audioBytesOut": self.audio_bytes_out,
|
||||
"lastAudioOutAt": self.last_audio_out_at,
|
||||
"lastBargeInAt": self.last_barge_in_at,
|
||||
"leaveReason": self.leave_reason,
|
||||
}
|
||||
tmp = self.status_path.with_suffix(".json.tmp")
|
||||
tmp.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
||||
tmp.replace(self.status_path)
|
||||
|
||||
def set(self, **kwargs) -> None:
|
||||
for k, v in kwargs.items():
|
||||
setattr(self, k, v)
|
||||
self._flush()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Playwright bot entry point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# JavaScript injected into the Meet tab to observe captions. Captures
|
||||
# {speaker, text} tuples via a MutationObserver on the caption container,
|
||||
# and exposes ``window.__hermesMeetDrain()`` to pull new entries. This
|
||||
# mirrors the OpenUtter caption scraping approach.
|
||||
_CAPTION_OBSERVER_JS = r"""
|
||||
(() => {
|
||||
if (window.__hermesMeetInstalled) return;
|
||||
window.__hermesMeetInstalled = true;
|
||||
window.__hermesMeetQueue = [];
|
||||
|
||||
const captionSelector = '[role="region"][aria-label*="aption" i], ' +
|
||||
'div[jsname="YSxPC"], ' + // legacy
|
||||
'div[jsname="tgaKEf"]'; // current (Apr 2026)
|
||||
|
||||
function pushEntry(speaker, text) {
|
||||
if (!text || !text.trim()) return;
|
||||
window.__hermesMeetQueue.push({
|
||||
ts: Date.now(),
|
||||
speaker: (speaker || '').trim(),
|
||||
text: text.trim(),
|
||||
});
|
||||
}
|
||||
|
||||
function scan(root) {
|
||||
// Meet captions render as a list of rows; each row contains a speaker
|
||||
// label and a text block. Selectors vary across Meet rewrites; we try
|
||||
// a few shapes and fall back to raw text.
|
||||
const rows = root.querySelectorAll('div[jsname="dsyhDe"], div.CNusmb, div.TBMuR');
|
||||
if (rows.length) {
|
||||
rows.forEach((row) => {
|
||||
const spkEl = row.querySelector('div.KcIKyf, div.zs7s8d, span[jsname="YSxPC"]');
|
||||
const txtEl = row.querySelector('div.bh44bd, span[jsname="tgaKEf"], div.iTTPOb');
|
||||
const speaker = spkEl ? spkEl.innerText : '';
|
||||
const text = txtEl ? txtEl.innerText : row.innerText;
|
||||
pushEntry(speaker, text);
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Fallback: treat the whole region's innerText as one anonymous line.
|
||||
const text = (root.innerText || '').split('\n').filter(Boolean).pop();
|
||||
pushEntry('', text);
|
||||
}
|
||||
|
||||
function attach() {
|
||||
const el = document.querySelector(captionSelector);
|
||||
if (!el) return false;
|
||||
const obs = new MutationObserver(() => scan(el));
|
||||
obs.observe(el, { childList: true, subtree: true, characterData: true });
|
||||
scan(el);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Try now and retry on interval — the caption region only appears after
|
||||
// captions are enabled and someone speaks.
|
||||
if (!attach()) {
|
||||
const iv = setInterval(() => { if (attach()) clearInterval(iv); }, 1500);
|
||||
}
|
||||
|
||||
window.__hermesMeetDrain = () => {
|
||||
const out = window.__hermesMeetQueue.slice();
|
||||
window.__hermesMeetQueue = [];
|
||||
return out;
|
||||
};
|
||||
})();
|
||||
"""
|
||||
|
||||
|
||||
def _enable_captions_js() -> str:
|
||||
"""Return a small JS snippet that tries to click the 'Turn on captions' button.
|
||||
|
||||
Best-effort — Meet's caption toggle is keyboard-accessible via ``c``. We
|
||||
dispatch that keystroke as a cheap fallback. Real click targeting is too
|
||||
brittle to rely on.
|
||||
"""
|
||||
return r"""
|
||||
(() => {
|
||||
const ev = new KeyboardEvent('keydown', {
|
||||
key: 'c', code: 'KeyC', keyCode: 67, which: 67, bubbles: true,
|
||||
});
|
||||
document.body.dispatchEvent(ev);
|
||||
return true;
|
||||
})();
|
||||
"""
|
||||
|
||||
|
||||
def _start_realtime_speaker(
|
||||
*,
|
||||
rt: dict,
|
||||
out_dir: Path,
|
||||
bridge_info: dict,
|
||||
api_key: str,
|
||||
model: str,
|
||||
voice: str,
|
||||
instructions: str,
|
||||
stop_flag: dict,
|
||||
state: "_BotState",
|
||||
) -> None:
|
||||
"""Wire up the OpenAI Realtime session + speaker thread + PCM pump.
|
||||
|
||||
The speaker thread reads text lines from ``say_queue.jsonl``, sends each
|
||||
to OpenAI Realtime, and writes PCM audio into ``speaker.pcm``. A
|
||||
separate *pump* thread forwards that PCM into the OS audio sink so
|
||||
Chrome's fake mic picks it up. On Linux we pipe to ``paplay`` against
|
||||
the null-sink; on macOS the caller is expected to have the BlackHole
|
||||
device selected as default input.
|
||||
"""
|
||||
try:
|
||||
from plugins.google_meet.realtime.openai_client import (
|
||||
RealtimeSession,
|
||||
RealtimeSpeaker,
|
||||
)
|
||||
except Exception as e:
|
||||
state.set(error=f"realtime import failed: {e}")
|
||||
return
|
||||
|
||||
pcm_path = out_dir / SAY_PCM_FILENAME
|
||||
queue_path = out_dir / SAY_QUEUE_FILENAME
|
||||
processed_path = out_dir / "say_processed.jsonl"
|
||||
# Reset the sink file so we start clean each session.
|
||||
pcm_path.write_bytes(b"")
|
||||
# Make sure the queue exists so the speaker poller doesn't error on
|
||||
# first iteration.
|
||||
queue_path.touch()
|
||||
|
||||
try:
|
||||
session = RealtimeSession(
|
||||
api_key=api_key,
|
||||
model=model,
|
||||
voice=voice,
|
||||
instructions=instructions,
|
||||
audio_sink_path=pcm_path,
|
||||
sample_rate=24000,
|
||||
)
|
||||
session.connect()
|
||||
except Exception as e:
|
||||
state.set(error=f"realtime connect failed: {e}")
|
||||
return
|
||||
|
||||
rt["session"] = session
|
||||
|
||||
def _stop_fn():
|
||||
return stop_flag.get("stop", False)
|
||||
|
||||
rt["speaker_stop"] = lambda: stop_flag.__setitem__("stop", stop_flag.get("stop", False))
|
||||
|
||||
speaker = RealtimeSpeaker(
|
||||
session=session,
|
||||
queue_path=queue_path,
|
||||
processed_path=processed_path,
|
||||
)
|
||||
|
||||
def _speaker_loop():
|
||||
try:
|
||||
speaker.run_until_stopped(_stop_fn)
|
||||
except Exception as e:
|
||||
state.set(error=f"realtime speaker crashed: {e}")
|
||||
|
||||
t_speaker = threading.Thread(target=_speaker_loop, name="meet-speaker", daemon=True)
|
||||
t_speaker.start()
|
||||
rt["speaker_thread"] = t_speaker
|
||||
|
||||
# PCM pump: feeds speaker.pcm (24kHz s16le mono) into the OS audio
|
||||
# device that Chrome's fake mic reads from. Different tools per
|
||||
# platform, but the contract is the same — block-read the growing
|
||||
# PCM file and stream it to the device in near-real-time.
|
||||
platform_tag = (bridge_info or {}).get("platform")
|
||||
if platform_tag == "linux":
|
||||
import subprocess as _sp
|
||||
|
||||
sink = (bridge_info or {}).get("write_target") or "hermes_meet_sink"
|
||||
try:
|
||||
proc = _sp.Popen(
|
||||
[
|
||||
"paplay",
|
||||
"--raw",
|
||||
"--rate=24000",
|
||||
"--format=s16le",
|
||||
"--channels=1",
|
||||
f"--device={sink}",
|
||||
str(pcm_path),
|
||||
],
|
||||
stdin=_sp.DEVNULL,
|
||||
stdout=_sp.DEVNULL,
|
||||
stderr=_sp.DEVNULL,
|
||||
)
|
||||
rt["pcm_pump"] = proc
|
||||
except FileNotFoundError:
|
||||
state.set(error="paplay not found — install pulseaudio-utils for realtime on Linux")
|
||||
elif platform_tag == "darwin":
|
||||
# macOS: use ffmpeg to tail-read speaker.pcm and write it to the
|
||||
# BlackHole output device. The user must have BlackHole selected
|
||||
# as the default input in System Settings → Sound for Chrome to
|
||||
# pick it up. We prefer ffmpeg because it's scriptable and can
|
||||
# target AVFoundation devices by name; fall back to afplay-ing
|
||||
# the file in a tight loop if ffmpeg is absent.
|
||||
import shutil as _shutil
|
||||
import subprocess as _sp
|
||||
|
||||
device_name = (bridge_info or {}).get("write_target") or "BlackHole 2ch"
|
||||
if _shutil.which("ffmpeg"):
|
||||
try:
|
||||
# -re: read input at native frame rate.
|
||||
# -f avfoundation -i: speaker path as raw PCM.
|
||||
# -f s16le -ar 24000 -ac 1 -i <pcm>: interpret the file.
|
||||
# -f audiotoolbox -audio_device_index: write to BlackHole.
|
||||
# Simpler: output as raw via coreaudio using "-f audiotoolbox".
|
||||
# ffmpeg's audiotoolbox output picks the current default
|
||||
# output device, which isn't what we want. Instead we use
|
||||
# -f avfoundation with the named device as OUTPUT via
|
||||
# -vn and the device name.
|
||||
proc = _sp.Popen(
|
||||
[
|
||||
"ffmpeg",
|
||||
"-nostdin", "-hide_banner", "-loglevel", "error",
|
||||
"-re",
|
||||
"-f", "s16le", "-ar", "24000", "-ac", "1",
|
||||
"-i", str(pcm_path),
|
||||
"-f", "audiotoolbox",
|
||||
"-audio_device_index", _mac_audio_device_index(device_name),
|
||||
"-",
|
||||
],
|
||||
stdin=_sp.DEVNULL,
|
||||
stdout=_sp.DEVNULL,
|
||||
stderr=_sp.DEVNULL,
|
||||
)
|
||||
rt["pcm_pump"] = proc
|
||||
except FileNotFoundError:
|
||||
state.set(error="ffmpeg not found — install via `brew install ffmpeg` for realtime on macOS")
|
||||
except Exception as e:
|
||||
state.set(error=f"macOS pcm pump failed to start: {e}")
|
||||
else:
|
||||
state.set(error="ffmpeg not found — install via `brew install ffmpeg` for realtime on macOS")
|
||||
|
||||
|
||||
def _mac_audio_device_index(device_name: str) -> str:
|
||||
"""Return the ffmpeg ``-audio_device_index`` for *device_name*, as a string.
|
||||
|
||||
Probes ``ffmpeg -f avfoundation -list_devices true -i ''`` (which prints
|
||||
the device table on stderr) and matches *device_name* case-insensitively.
|
||||
Defaults to ``"0"`` if the device can't be found — caller will get a
|
||||
misrouted stream but not a crash, and the error will be obvious.
|
||||
"""
|
||||
import subprocess as _sp
|
||||
|
||||
try:
|
||||
out = _sp.run(
|
||||
["ffmpeg", "-f", "avfoundation", "-list_devices", "true", "-i", ""],
|
||||
capture_output=True,
|
||||
text=True, encoding='utf-8', errors='replace',
|
||||
timeout=10,
|
||||
)
|
||||
except Exception:
|
||||
return "0"
|
||||
# ffmpeg prints the table on stderr. Lines look like:
|
||||
# [AVFoundation indev @ 0x...] [0] BlackHole 2ch
|
||||
import re as _re
|
||||
|
||||
needle = device_name.strip().lower()
|
||||
for line in (out.stderr or "").splitlines():
|
||||
m = _re.search(r"\[(\d+)\]\s+(.+)$", line)
|
||||
if not m:
|
||||
continue
|
||||
if m.group(2).strip().lower() == needle:
|
||||
return m.group(1)
|
||||
return "0"
|
||||
|
||||
|
||||
def run_bot() -> int: # noqa: C901 — orchestration, explicit branches
|
||||
url = os.environ.get("HERMES_MEET_URL", "").strip()
|
||||
out_dir_env = os.environ.get("HERMES_MEET_OUT_DIR", "").strip()
|
||||
headed = os.environ.get("HERMES_MEET_HEADED", "").lower() in {"1", "true", "yes"}
|
||||
auth_state = os.environ.get("HERMES_MEET_AUTH_STATE", "").strip()
|
||||
guest_name = os.environ.get("HERMES_MEET_GUEST_NAME", "Hermes Agent")
|
||||
duration_s = _parse_duration(os.environ.get("HERMES_MEET_DURATION", ""))
|
||||
# v2: optional realtime mode. Enabled when HERMES_MEET_MODE=realtime.
|
||||
mode = os.environ.get("HERMES_MEET_MODE", "transcribe").strip().lower()
|
||||
realtime_model = os.environ.get("HERMES_MEET_REALTIME_MODEL", "gpt-realtime")
|
||||
realtime_voice = os.environ.get("HERMES_MEET_REALTIME_VOICE", "alloy")
|
||||
realtime_instructions = os.environ.get("HERMES_MEET_REALTIME_INSTRUCTIONS", "")
|
||||
# HERMES_MEET_REALTIME_KEY is set explicitly by process_manager.start(),
|
||||
# which resolves it through the parent's profile secret scope at spawn
|
||||
# time. The bare OPENAI_API_KEY fallback only serves standalone
|
||||
# `python -m plugins.google_meet.meet_bot` runs outside the gateway.
|
||||
realtime_api_key = os.environ.get("HERMES_MEET_REALTIME_KEY") or os.environ.get("OPENAI_API_KEY", "")
|
||||
|
||||
if not url or not _is_safe_meet_url(url):
|
||||
sys.stderr.write(
|
||||
"google_meet bot: refusing to launch — HERMES_MEET_URL must be a "
|
||||
"meet.google.com URL. got: %r\n" % url
|
||||
)
|
||||
return 2
|
||||
if not out_dir_env:
|
||||
sys.stderr.write("google_meet bot: HERMES_MEET_OUT_DIR is required\n")
|
||||
return 2
|
||||
|
||||
out_dir = Path(out_dir_env)
|
||||
meeting_id = _meeting_id_from_url(url)
|
||||
state = _BotState(out_dir=out_dir, meeting_id=meeting_id, url=url)
|
||||
|
||||
# SIGTERM → exit cleanly so the parent ``meet_leave`` gets a finalized
|
||||
# transcript. We set a flag instead of raising so the Playwright context
|
||||
# teardown runs in the finally block below.
|
||||
stop_flag = {"stop": False}
|
||||
|
||||
def _on_signal(_sig, _frame):
|
||||
stop_flag["stop"] = True
|
||||
|
||||
signal.signal(signal.SIGTERM, _on_signal)
|
||||
signal.signal(signal.SIGINT, _on_signal)
|
||||
|
||||
# v2 realtime: provision virtual audio device + start speaker thread.
|
||||
# We track these in a dict so the finally block can tear them down
|
||||
# regardless of how we exit. If anything in the realtime setup fails we
|
||||
# fall back to transcribe mode with a status flag.
|
||||
rt = {
|
||||
"enabled": mode == "realtime",
|
||||
"bridge": None, # AudioBridge | None
|
||||
"bridge_info": None, # dict | None
|
||||
"session": None, # RealtimeSession | None
|
||||
"speaker_thread": None, # threading.Thread | None
|
||||
"speaker_stop": None, # callable | None
|
||||
}
|
||||
if rt["enabled"]:
|
||||
if not realtime_api_key:
|
||||
state.set(error="realtime mode requested but no API key in HERMES_MEET_REALTIME_KEY/OPENAI_API_KEY — falling back to transcribe")
|
||||
rt["enabled"] = False
|
||||
else:
|
||||
try:
|
||||
from plugins.google_meet.audio_bridge import AudioBridge
|
||||
bridge = AudioBridge()
|
||||
rt["bridge_info"] = bridge.setup()
|
||||
rt["bridge"] = bridge
|
||||
state.set(realtime=True, realtime_device=rt["bridge_info"].get("device_name"))
|
||||
except Exception as e:
|
||||
state.set(error=f"audio bridge setup failed: {e} — falling back to transcribe")
|
||||
rt["enabled"] = False
|
||||
|
||||
try:
|
||||
from playwright.sync_api import sync_playwright
|
||||
except ImportError as e:
|
||||
state.set(error=f"playwright not installed: {e}", exited=True)
|
||||
sys.stderr.write(
|
||||
"google_meet bot: playwright is not installed. Run "
|
||||
"`pip install playwright && python -m playwright install chromium`\n"
|
||||
)
|
||||
if rt["bridge"]:
|
||||
rt["bridge"].teardown()
|
||||
return 3
|
||||
|
||||
# Chrome env: if realtime is live on Linux, point PULSE_SOURCE at the
|
||||
# virtual source so Chrome's fake mic reads the audio we generate.
|
||||
chrome_env = os.environ.copy()
|
||||
chrome_args = [
|
||||
"--use-fake-ui-for-media-stream",
|
||||
"--disable-blink-features=AutomationControlled",
|
||||
]
|
||||
if not rt["enabled"]:
|
||||
# v1-style fake device (silence) — we don't care about mic content
|
||||
# when we're not speaking.
|
||||
chrome_args.insert(1, "--use-fake-device-for-media-stream")
|
||||
elif rt["bridge_info"] and rt["bridge_info"].get("platform") == "linux":
|
||||
chrome_env["PULSE_SOURCE"] = rt["bridge_info"].get("device_name", "")
|
||||
|
||||
try:
|
||||
with sync_playwright() as pw:
|
||||
# Playwright's launch() doesn't take env; we set PULSE_SOURCE
|
||||
# via the process env before launch so the child Chrome inherits it.
|
||||
for k, v in chrome_env.items():
|
||||
os.environ[k] = v
|
||||
browser = pw.chromium.launch(
|
||||
headless=not headed,
|
||||
args=chrome_args,
|
||||
)
|
||||
context_args = {
|
||||
"viewport": {"width": 1280, "height": 800},
|
||||
"user_agent": (
|
||||
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
|
||||
"(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"
|
||||
),
|
||||
"permissions": ["microphone", "camera"],
|
||||
}
|
||||
if auth_state and Path(auth_state).is_file():
|
||||
context_args["storage_state"] = auth_state
|
||||
context = browser.new_context(**context_args)
|
||||
page = context.new_page()
|
||||
|
||||
try:
|
||||
page.goto(url, wait_until="domcontentloaded", timeout=30_000)
|
||||
except Exception as e:
|
||||
state.set(error=f"navigate failed: {e}", exited=True)
|
||||
return 4
|
||||
|
||||
# Guest-mode: Meet shows a name field before "Ask to join". When
|
||||
# we're authed, we instead see "Join now".
|
||||
_try_guest_name(page, guest_name)
|
||||
_click_join(page, state)
|
||||
|
||||
# Install caption observer and attempt to enable captions.
|
||||
try:
|
||||
page.evaluate(_enable_captions_js())
|
||||
state.set(captions_enabled_attempted=True)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
page.evaluate(_CAPTION_OBSERVER_JS)
|
||||
except Exception as e:
|
||||
state.set(error=f"caption observer install failed: {e}")
|
||||
|
||||
# Note: in_call=False until admission is confirmed (we detect
|
||||
# either the Leave button or the caption region, signalling we
|
||||
# made it past the lobby).
|
||||
state.set(captioning=True, join_attempted_at=time.time())
|
||||
|
||||
# v2 realtime: start the speaker thread reading from the
|
||||
# plugin-side say queue. The thread reads JSONL lines written by
|
||||
# meet_say, calls OpenAI Realtime, and streams the audio PCM to
|
||||
# the virtual sink that Chrome's fake-mic is pointed at.
|
||||
if rt["enabled"]:
|
||||
_start_realtime_speaker(
|
||||
rt=rt,
|
||||
out_dir=out_dir,
|
||||
bridge_info=rt["bridge_info"],
|
||||
api_key=realtime_api_key,
|
||||
model=realtime_model,
|
||||
voice=realtime_voice,
|
||||
instructions=realtime_instructions,
|
||||
stop_flag=stop_flag,
|
||||
state=state,
|
||||
)
|
||||
if rt["session"] is not None:
|
||||
state.set(realtime_ready=True)
|
||||
|
||||
# Admission + drain loop. Runs until SIGTERM, duration expiry,
|
||||
# or the page detects "You were removed / you left the
|
||||
# meeting". Responsible for:
|
||||
# * detecting admission (Leave button visible → in_call=True)
|
||||
# * timing out stuck-in-lobby (default 5 minutes)
|
||||
# * draining scraped captions into the transcript
|
||||
# * triggering realtime barge-in when a human speaks while
|
||||
# the bot is generating audio
|
||||
# * periodically flushing realtime counters into status.json
|
||||
deadline = (time.time() + duration_s) if duration_s else None
|
||||
lobby_deadline = time.time() + float(
|
||||
os.environ.get("HERMES_MEET_LOBBY_TIMEOUT", "300")
|
||||
)
|
||||
last_admission_check = 0.0
|
||||
while not stop_flag["stop"]:
|
||||
now = time.time()
|
||||
if deadline and now > deadline:
|
||||
state.set(leave_reason="duration_expired")
|
||||
break
|
||||
|
||||
# Admission detection every ~3s until admitted.
|
||||
if not state.in_call and (now - last_admission_check) > 3.0:
|
||||
last_admission_check = now
|
||||
admitted = _detect_admission(page)
|
||||
if admitted:
|
||||
state.set(
|
||||
in_call=True,
|
||||
lobby_waiting=False,
|
||||
joined_at=now,
|
||||
)
|
||||
elif now > lobby_deadline:
|
||||
state.set(
|
||||
error=(
|
||||
"lobby timeout — host never admitted the bot "
|
||||
f"within {int(lobby_deadline - state.join_attempted_at) if state.join_attempted_at else 0}s"
|
||||
),
|
||||
leave_reason="lobby_timeout",
|
||||
)
|
||||
break
|
||||
elif _detect_denied(page):
|
||||
state.set(
|
||||
error="host denied admission",
|
||||
leave_reason="denied",
|
||||
)
|
||||
break
|
||||
|
||||
try:
|
||||
queued = page.evaluate("window.__hermesMeetDrain && window.__hermesMeetDrain()")
|
||||
if isinstance(queued, list):
|
||||
for entry in queued:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
speaker = str(entry.get("speaker", ""))
|
||||
text = str(entry.get("text", ""))
|
||||
state.record_caption(speaker=speaker, text=text)
|
||||
# Barge-in: if the bot is currently generating
|
||||
# audio AND a real human just spoke, cancel the
|
||||
# in-flight response so we don't talk over them.
|
||||
if rt["enabled"] and rt["session"] is not None:
|
||||
if _looks_like_human_speaker(speaker, guest_name):
|
||||
try:
|
||||
cancelled = rt["session"].cancel_response()
|
||||
if cancelled:
|
||||
state.set(last_barge_in_at=now)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
# Meet reloaded or we got booted — try to detect and
|
||||
# exit gracefully rather than spinning.
|
||||
if page.is_closed():
|
||||
state.set(leave_reason="page_closed")
|
||||
break
|
||||
|
||||
# Fold the realtime session's byte/timestamp counters into
|
||||
# the status file so meet_status can surface them.
|
||||
if rt["session"] is not None:
|
||||
state.set(
|
||||
audio_bytes_out=getattr(rt["session"], "audio_bytes_out", 0),
|
||||
last_audio_out_at=getattr(rt["session"], "last_audio_out_at", None),
|
||||
)
|
||||
|
||||
time.sleep(1.0)
|
||||
|
||||
# Try to leave cleanly — click "Leave call" button if present.
|
||||
try:
|
||||
page.evaluate(
|
||||
"() => { const b = document.querySelector('button[aria-label*=\"eave call\"]');"
|
||||
" if (b) b.click(); }"
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
context.close()
|
||||
browser.close()
|
||||
# v2: teardown PCM pump, speaker thread, and audio bridge.
|
||||
if rt.get("pcm_pump"):
|
||||
try:
|
||||
rt["pcm_pump"].terminate()
|
||||
rt["pcm_pump"].wait(timeout=3)
|
||||
except Exception:
|
||||
pass
|
||||
if rt["speaker_stop"]:
|
||||
try:
|
||||
rt["speaker_stop"]()
|
||||
except Exception:
|
||||
pass
|
||||
if rt["speaker_thread"] is not None:
|
||||
try:
|
||||
rt["speaker_thread"].join(timeout=5.0)
|
||||
except Exception:
|
||||
pass
|
||||
if rt["session"]:
|
||||
try:
|
||||
rt["session"].close()
|
||||
except Exception:
|
||||
pass
|
||||
if rt["bridge"]:
|
||||
try:
|
||||
rt["bridge"].teardown()
|
||||
except Exception:
|
||||
pass
|
||||
state.set(in_call=False, captioning=False, exited=True)
|
||||
return 0
|
||||
|
||||
except Exception as e:
|
||||
state.set(error=f"unhandled: {e}", exited=True)
|
||||
return 1
|
||||
|
||||
|
||||
def _try_guest_name(page, guest_name: str) -> None:
|
||||
"""If Meet is showing a guest-name input, type *guest_name* into it."""
|
||||
try:
|
||||
# Meet's guest name input has placeholder "Your name".
|
||||
locator = page.locator('input[aria-label*="name" i]').first
|
||||
if locator.count() and locator.is_visible():
|
||||
locator.fill(guest_name, timeout=2_000)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _detect_admission(page) -> bool:
|
||||
"""True if we're clearly past the lobby and in the call itself.
|
||||
|
||||
Uses a JS-side probe because Meet's DOM structure varies by client
|
||||
version. We check several high-signal indicators and declare admission
|
||||
on the first hit:
|
||||
|
||||
1. Leave-call button is present (``aria-label`` contains "eave call").
|
||||
2. Caption region has appeared (we installed the observer and it attached).
|
||||
3. The participant list container is visible.
|
||||
|
||||
Conservative by default — returns False on any error.
|
||||
"""
|
||||
probe = r"""
|
||||
(() => {
|
||||
const leave = document.querySelector('button[aria-label*="eave call" i]');
|
||||
if (leave) return true;
|
||||
if (window.__hermesMeetInstalled) {
|
||||
const caps = document.querySelector(
|
||||
'[role="region"][aria-label*="aption" i], ' +
|
||||
'div[jsname="YSxPC"], div[jsname="tgaKEf"]'
|
||||
);
|
||||
if (caps) return true;
|
||||
}
|
||||
const parts = document.querySelector('[aria-label*="articipants" i]');
|
||||
if (parts) return true;
|
||||
return false;
|
||||
})();
|
||||
"""
|
||||
try:
|
||||
return bool(page.evaluate(probe))
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _detect_denied(page) -> bool:
|
||||
"""True when Meet is showing a 'you were denied' / 'no one admitted' page."""
|
||||
probe = r"""
|
||||
(() => {
|
||||
const text = document.body ? document.body.innerText || '' : '';
|
||||
// English only — matches what shows up when the host denies or
|
||||
// removes a guest.
|
||||
if (/You can't join this video call/i.test(text)) return true;
|
||||
if (/You were removed from the meeting/i.test(text)) return true;
|
||||
if (/No one responded to your request to join/i.test(text)) return true;
|
||||
return false;
|
||||
})();
|
||||
"""
|
||||
try:
|
||||
return bool(page.evaluate(probe))
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _looks_like_human_speaker(speaker: str, bot_guest_name: str) -> bool:
|
||||
"""Whether a caption line's speaker is probably a human, not our bot echo.
|
||||
|
||||
Meet attributes captions to the speaker's display name. When Chrome is
|
||||
reading our fake mic, Meet still attributes captions to *our* bot name
|
||||
(because the bot is the one "speaking"). We don't want those to trigger
|
||||
barge-in. Anything else — real participant names — does.
|
||||
|
||||
Conservative: unknown / blank speakers (common when caption scraping
|
||||
falls back to raw text) do NOT trigger barge-in, because we can't tell
|
||||
whether it was a human or us.
|
||||
"""
|
||||
if not speaker or not speaker.strip():
|
||||
return False
|
||||
spk = speaker.strip().lower()
|
||||
if spk in {"unknown", "you", bot_guest_name.strip().lower()}:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _click_join(page, state: _BotState) -> None:
|
||||
"""Click 'Join now' or 'Ask to join' if either button is visible.
|
||||
|
||||
Flags ``lobby_waiting`` when we hit the "waiting for host to admit you"
|
||||
state so the agent can surface that in status.
|
||||
"""
|
||||
for label in ("Join now", "Ask to join"):
|
||||
try:
|
||||
btn = page.get_by_role("button", name=label, exact=False).first
|
||||
if btn.count() and btn.is_visible():
|
||||
btn.click(timeout=3_000)
|
||||
if label == "Ask to join":
|
||||
state.set(lobby_waiting=True)
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
|
||||
def _parse_duration(raw: str) -> Optional[float]:
|
||||
"""Parse ``30m`` / ``2h`` / ``90`` (seconds) → float seconds, or None."""
|
||||
if not raw:
|
||||
return None
|
||||
raw = raw.strip().lower()
|
||||
try:
|
||||
if raw.endswith("h"):
|
||||
return float(raw[:-1]) * 3600
|
||||
if raw.endswith("m"):
|
||||
return float(raw[:-1]) * 60
|
||||
if raw.endswith("s"):
|
||||
return float(raw[:-1])
|
||||
return float(raw)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover — subprocess entry point
|
||||
sys.exit(run_bot())
|
||||
@@ -0,0 +1,54 @@
|
||||
"""Remote 'node host' primitive for the google_meet plugin.
|
||||
|
||||
Lets the Meet bot (Playwright + Chrome) run on a different machine than
|
||||
the hermes-agent gateway. The gateway speaks a small JSON-over-WebSocket
|
||||
RPC protocol to the remote node; the node wraps the existing
|
||||
``plugins.google_meet.process_manager`` API.
|
||||
|
||||
Topology
|
||||
--------
|
||||
gateway (Linux) ── ws://mac.local:18789 ──▶ node server (Mac)
|
||||
└─ process_manager
|
||||
└─ meet_bot (Playwright)
|
||||
|
||||
Why: Google sign-in + Chrome profile live on the user's laptop. Running
|
||||
the bot there reuses that profile without shipping credentials to the
|
||||
server.
|
||||
|
||||
Public surface
|
||||
--------------
|
||||
NodeClient — gateway-side RPC client (short-lived sync WS per call)
|
||||
NodeServer — long-running server that hosts the bot
|
||||
NodeRegistry — local JSON registry of approved nodes (name → url+token)
|
||||
protocol — message envelope helpers (make_request, encode, decode, ...)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from plugins.google_meet.node import protocol
|
||||
from plugins.google_meet.node.client import NodeClient
|
||||
from plugins.google_meet.node.protocol import (
|
||||
VALID_REQUEST_TYPES,
|
||||
decode,
|
||||
encode,
|
||||
make_error,
|
||||
make_request,
|
||||
make_response,
|
||||
validate_request,
|
||||
)
|
||||
from plugins.google_meet.node.registry import NodeRegistry
|
||||
from plugins.google_meet.node.server import NodeServer
|
||||
|
||||
__all__ = [
|
||||
"NodeClient",
|
||||
"NodeServer",
|
||||
"NodeRegistry",
|
||||
"protocol",
|
||||
"make_request",
|
||||
"make_response",
|
||||
"make_error",
|
||||
"encode",
|
||||
"decode",
|
||||
"validate_request",
|
||||
"VALID_REQUEST_TYPES",
|
||||
]
|
||||
@@ -0,0 +1,125 @@
|
||||
"""`hermes meet node ...` subcommand tree.
|
||||
|
||||
Wired into the existing ``hermes meet`` parser by the plugin's top-level
|
||||
CLI. This module only defines the subparsers and their dispatch — it
|
||||
does not mutate the existing cli.py.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
from plugins.google_meet.node.client import NodeClient
|
||||
from plugins.google_meet.node.registry import NodeRegistry
|
||||
from plugins.google_meet.node.server import NodeServer
|
||||
|
||||
|
||||
def register_cli(subparser: argparse.ArgumentParser) -> None:
|
||||
"""Add ``run / list / approve / remove / status / ping`` subparsers.
|
||||
|
||||
*subparser* is the ``hermes meet node`` argparse object — typically
|
||||
the result of ``meet_parser.add_parser('node', ...)``.
|
||||
"""
|
||||
sp = subparser.add_subparsers(dest="node_cmd", required=True)
|
||||
|
||||
run = sp.add_parser("run", help="Start a node server on this machine.")
|
||||
run.add_argument("--host", default="0.0.0.0")
|
||||
run.add_argument("--port", type=int, default=18789)
|
||||
run.add_argument("--display-name", default="hermes-meet-node")
|
||||
run.set_defaults(func=node_command)
|
||||
|
||||
lst = sp.add_parser("list", help="List approved remote nodes.")
|
||||
lst.set_defaults(func=node_command)
|
||||
|
||||
app = sp.add_parser("approve", help="Register a remote node on the gateway.")
|
||||
app.add_argument("name")
|
||||
app.add_argument("url")
|
||||
app.add_argument("token")
|
||||
app.set_defaults(func=node_command)
|
||||
|
||||
rm = sp.add_parser("remove", help="Forget a registered node.")
|
||||
rm.add_argument("name")
|
||||
rm.set_defaults(func=node_command)
|
||||
|
||||
st = sp.add_parser("status", help="Ping a registered node.")
|
||||
st.add_argument("name")
|
||||
st.set_defaults(func=node_command)
|
||||
|
||||
pg = sp.add_parser("ping", help="Alias for status.")
|
||||
pg.add_argument("name")
|
||||
pg.set_defaults(func=node_command)
|
||||
|
||||
|
||||
def node_command(args: argparse.Namespace) -> int:
|
||||
"""Dispatch for ``hermes meet node ...``.
|
||||
|
||||
Returns a process exit code. Side-effects print to stdout/stderr.
|
||||
"""
|
||||
cmd = getattr(args, "node_cmd", None)
|
||||
|
||||
if cmd == "run":
|
||||
server = NodeServer(
|
||||
host=args.host,
|
||||
port=args.port,
|
||||
display_name=args.display_name,
|
||||
)
|
||||
token = server.ensure_token()
|
||||
print(f"[meet-node] display_name={server.display_name}")
|
||||
print(f"[meet-node] listening on ws://{args.host}:{args.port}")
|
||||
print(f"[meet-node] token (copy to gateway): {token}")
|
||||
print("[meet-node] approve with:")
|
||||
print(f" hermes meet node approve <name> ws://<host>:{args.port} {token}")
|
||||
try:
|
||||
asyncio.run(server.serve())
|
||||
except KeyboardInterrupt:
|
||||
return 0
|
||||
except RuntimeError as exc:
|
||||
print(f"[meet-node] error: {exc}", file=sys.stderr)
|
||||
return 2
|
||||
return 0
|
||||
|
||||
reg = NodeRegistry()
|
||||
|
||||
if cmd == "list":
|
||||
nodes = reg.list_all()
|
||||
if not nodes:
|
||||
print("no nodes registered")
|
||||
return 0
|
||||
for n in nodes:
|
||||
print(f"{n['name']}\t{n['url']}\ttoken={n['token'][:6]}…")
|
||||
return 0
|
||||
|
||||
if cmd == "approve":
|
||||
reg.add(args.name, args.url, args.token)
|
||||
print(f"approved node {args.name!r} at {args.url}")
|
||||
return 0
|
||||
|
||||
if cmd == "remove":
|
||||
ok = reg.remove(args.name)
|
||||
print(f"removed {args.name!r}" if ok else f"no such node: {args.name!r}")
|
||||
return 0 if ok else 1
|
||||
|
||||
if cmd in {"status", "ping"}:
|
||||
entry = reg.get(args.name)
|
||||
if entry is None:
|
||||
print(f"no such node: {args.name!r}", file=sys.stderr)
|
||||
return 1
|
||||
client = NodeClient(entry["url"], entry["token"])
|
||||
try:
|
||||
result = client.ping()
|
||||
except Exception as exc: # noqa: BLE001 — surface any connection error
|
||||
print(json.dumps({"ok": False, "error": str(exc)}))
|
||||
return 1
|
||||
print(json.dumps({"ok": True, "node": args.name, **_coerce_dict(result)}))
|
||||
return 0
|
||||
|
||||
print(f"unknown node command: {cmd!r}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
|
||||
def _coerce_dict(value: Any) -> dict:
|
||||
return value if isinstance(value, dict) else {"result": value}
|
||||
@@ -0,0 +1,107 @@
|
||||
"""Gateway-side RPC client for a remote meet node.
|
||||
|
||||
Each call opens a short-lived synchronous WebSocket to the node, sends
|
||||
exactly one request, reads exactly one response, and closes. This keeps
|
||||
the client trivial to use from non-async tool handlers and avoids
|
||||
maintaining persistent connection state across agent turns.
|
||||
|
||||
The ``websockets`` package is an optional dep — we import it lazily so
|
||||
plugin load doesn't require it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from plugins.google_meet.node import protocol as _proto
|
||||
|
||||
|
||||
class NodeClient:
|
||||
"""Thin synchronous WS client matching the server's request surface."""
|
||||
|
||||
def __init__(self, url: str, token: str, timeout: float = 10.0) -> None:
|
||||
if not isinstance(url, str) or not url:
|
||||
raise ValueError("url must be a non-empty string")
|
||||
if not isinstance(token, str) or not token:
|
||||
raise ValueError("token must be a non-empty string")
|
||||
self.url = url
|
||||
self.token = token
|
||||
self.timeout = float(timeout)
|
||||
|
||||
# ----- core RPC -----------------------------------------------------
|
||||
|
||||
def _rpc(self, type: str, payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Send one request, return the response payload dict.
|
||||
|
||||
Raises RuntimeError when the server sends an ``error`` envelope
|
||||
or the response id doesn't match.
|
||||
"""
|
||||
try:
|
||||
from websockets.sync.client import connect # type: ignore
|
||||
except ImportError as exc:
|
||||
raise RuntimeError(
|
||||
"NodeClient requires the 'websockets' package. "
|
||||
"Install it with: pip install websockets"
|
||||
) from exc
|
||||
|
||||
req = _proto.make_request(type, self.token, payload)
|
||||
raw_out = _proto.encode(req)
|
||||
|
||||
with connect(self.url, open_timeout=self.timeout,
|
||||
close_timeout=self.timeout) as ws:
|
||||
ws.send(raw_out)
|
||||
raw_in = ws.recv(timeout=self.timeout)
|
||||
|
||||
if isinstance(raw_in, (bytes, bytearray)):
|
||||
raw_in = raw_in.decode("utf-8")
|
||||
resp = _proto.decode(raw_in)
|
||||
|
||||
if resp.get("type") == "error":
|
||||
raise RuntimeError(f"node error: {resp.get('error', '<unknown>')}")
|
||||
if resp.get("id") != req["id"]:
|
||||
raise RuntimeError(
|
||||
f"response id mismatch: sent {req['id']}, got {resp.get('id')!r}"
|
||||
)
|
||||
payload_out = resp.get("payload")
|
||||
if not isinstance(payload_out, dict):
|
||||
# Ping returns {"type": "pong", "payload": {...}} — still a dict.
|
||||
raise RuntimeError("response missing payload dict")
|
||||
return payload_out
|
||||
|
||||
# ----- convenience methods -----------------------------------------
|
||||
|
||||
def start_bot(
|
||||
self,
|
||||
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,
|
||||
}
|
||||
if duration is not None:
|
||||
payload["duration"] = duration
|
||||
return self._rpc("start_bot", payload)
|
||||
|
||||
def stop(self) -> Dict[str, Any]:
|
||||
return self._rpc("stop", {})
|
||||
|
||||
def status(self) -> Dict[str, Any]:
|
||||
return self._rpc("status", {})
|
||||
|
||||
def transcript(self, last: Optional[int] = None) -> Dict[str, Any]:
|
||||
payload: Dict[str, Any] = {}
|
||||
if last is not None:
|
||||
payload["last"] = int(last)
|
||||
return self._rpc("transcript", payload)
|
||||
|
||||
def say(self, text: str) -> Dict[str, Any]:
|
||||
return self._rpc("say", {"text": str(text)})
|
||||
|
||||
def ping(self) -> Dict[str, Any]:
|
||||
return self._rpc("ping", {})
|
||||
@@ -0,0 +1,124 @@
|
||||
"""Wire protocol for gateway ↔ node RPC.
|
||||
|
||||
Everything is a JSON object with the same envelope shape:
|
||||
|
||||
Request: {"type": <str>, "id": <str>, "token": <str>, "payload": <dict>}
|
||||
Response: {"type": "<req-type>_res", "id": <req-id>, "payload": <dict>}
|
||||
Error: {"type": "error", "id": <req-id>, "error": <str>}
|
||||
|
||||
Requests must carry the shared bearer token (set up via
|
||||
``hermes meet node approve`` on the gateway and read off disk on the
|
||||
server). Mismatched tokens are rejected before dispatch.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from typing import Any, Dict, Tuple
|
||||
|
||||
|
||||
VALID_REQUEST_TYPES = frozenset({
|
||||
"start_bot",
|
||||
"stop",
|
||||
"status",
|
||||
"transcript",
|
||||
"say",
|
||||
"ping",
|
||||
})
|
||||
|
||||
|
||||
def make_request(
|
||||
type: str,
|
||||
token: str,
|
||||
payload: Dict[str, Any],
|
||||
req_id: str | None = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Construct a request envelope.
|
||||
|
||||
``req_id`` is auto-generated (uuid4 hex) when not supplied so callers
|
||||
can correlate async responses.
|
||||
"""
|
||||
if not isinstance(type, str) or not type:
|
||||
raise ValueError("type must be a non-empty string")
|
||||
if type not in VALID_REQUEST_TYPES:
|
||||
raise ValueError(f"unknown request type: {type!r}")
|
||||
if not isinstance(token, str):
|
||||
raise ValueError("token must be a string")
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError("payload must be a dict")
|
||||
return {
|
||||
"type": type,
|
||||
"id": req_id or uuid.uuid4().hex,
|
||||
"token": token,
|
||||
"payload": payload,
|
||||
}
|
||||
|
||||
|
||||
def make_response(req_id: str, payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Build a success response. The caller supplies the *request* type;
|
||||
we suffix it with ``_res`` so clients can assert they got the right
|
||||
reply.
|
||||
|
||||
For simplicity we don't require the type here — clients usually just
|
||||
key off ``id``. But we still emit a generic ``*_res`` envelope.
|
||||
"""
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError("payload must be a dict")
|
||||
return {"type": "response", "id": req_id, "payload": payload}
|
||||
|
||||
|
||||
def make_error(req_id: str, error: str) -> Dict[str, Any]:
|
||||
return {"type": "error", "id": req_id, "error": str(error)}
|
||||
|
||||
|
||||
def encode(msg: Dict[str, Any]) -> str:
|
||||
"""Serialize a message envelope to a JSON string."""
|
||||
return json.dumps(msg, separators=(",", ":"), ensure_ascii=False)
|
||||
|
||||
|
||||
def decode(raw: str) -> Dict[str, Any]:
|
||||
"""Parse a JSON envelope, raising ValueError on anything malformed.
|
||||
|
||||
Minimal type validation: must be an object, must contain ``type`` and
|
||||
``id``. Heavier validation (token match, payload shape) happens in
|
||||
:func:`validate_request` on the server side.
|
||||
"""
|
||||
try:
|
||||
obj = json.loads(raw)
|
||||
except (TypeError, json.JSONDecodeError) as exc:
|
||||
raise ValueError(f"malformed JSON: {exc}") from exc
|
||||
if not isinstance(obj, dict):
|
||||
raise ValueError("envelope must be a JSON object")
|
||||
if "type" not in obj or not isinstance(obj["type"], str):
|
||||
raise ValueError("envelope missing string 'type'")
|
||||
if "id" not in obj or not isinstance(obj["id"], str):
|
||||
raise ValueError("envelope missing string 'id'")
|
||||
return obj
|
||||
|
||||
|
||||
def validate_request(msg: Dict[str, Any], expected_token: str) -> Tuple[bool, str]:
|
||||
"""Check a decoded request against the server's shared token.
|
||||
|
||||
Returns ``(True, "")`` when the envelope is acceptable or
|
||||
``(False, <reason>)`` otherwise. Reason strings are safe to surface
|
||||
back to the client in an error envelope.
|
||||
"""
|
||||
if not isinstance(msg, dict):
|
||||
return False, "envelope must be a dict"
|
||||
t = msg.get("type")
|
||||
if not isinstance(t, str) or not t:
|
||||
return False, "missing or non-string 'type'"
|
||||
if t not in VALID_REQUEST_TYPES:
|
||||
return False, f"unknown request type: {t!r}"
|
||||
if not isinstance(msg.get("id"), str) or not msg.get("id"):
|
||||
return False, "missing or non-string 'id'"
|
||||
token = msg.get("token")
|
||||
if not isinstance(token, str) or not token:
|
||||
return False, "missing token"
|
||||
if token != expected_token:
|
||||
return False, "token mismatch"
|
||||
payload = msg.get("payload")
|
||||
if not isinstance(payload, dict):
|
||||
return False, "payload must be a dict"
|
||||
return True, ""
|
||||
@@ -0,0 +1,112 @@
|
||||
"""Local JSON registry of approved remote meet nodes.
|
||||
|
||||
Lives at ``$HERMES_HOME/workspace/meetings/nodes.json``. The gateway
|
||||
consults it to resolve a ``chrome_node`` name to a ``(url, token)`` pair
|
||||
before opening a WebSocket to the remote bot host.
|
||||
|
||||
Schema
|
||||
------
|
||||
{
|
||||
"nodes": {
|
||||
"<name>": {
|
||||
"url": "ws://host:port",
|
||||
"token": "...",
|
||||
"added_at": <epoch_float>
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from hermes_constants import get_hermes_home
|
||||
|
||||
|
||||
def _default_path() -> Path:
|
||||
return Path(get_hermes_home()) / "workspace" / "meetings" / "nodes.json"
|
||||
|
||||
|
||||
class NodeRegistry:
|
||||
"""Simple file-backed registry. Not concurrent-safe across processes
|
||||
— single writer assumed (the gateway CLI)."""
|
||||
|
||||
def __init__(self, path: Optional[Path] = None) -> None:
|
||||
self.path = Path(path) if path is not None else _default_path()
|
||||
|
||||
# ----- storage ------------------------------------------------------
|
||||
|
||||
def _load(self) -> Dict[str, Any]:
|
||||
if not self.path.is_file():
|
||||
return {"nodes": {}}
|
||||
try:
|
||||
data = json.loads(self.path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return {"nodes": {}}
|
||||
if not isinstance(data, dict) or not isinstance(data.get("nodes"), dict):
|
||||
return {"nodes": {}}
|
||||
return data
|
||||
|
||||
def _save(self, data: Dict[str, Any]) -> None:
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = self.path.with_suffix(".json.tmp")
|
||||
tmp.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
||||
tmp.replace(self.path)
|
||||
|
||||
# ----- public API ---------------------------------------------------
|
||||
|
||||
def get(self, name: str) -> Optional[Dict[str, Any]]:
|
||||
data = self._load()
|
||||
entry = data["nodes"].get(name)
|
||||
if entry is None:
|
||||
return None
|
||||
return {"name": name, **entry}
|
||||
|
||||
def add(self, name: str, url: str, token: str) -> None:
|
||||
if not isinstance(name, str) or not name:
|
||||
raise ValueError("node name must be a non-empty string")
|
||||
if not isinstance(url, str) or not url:
|
||||
raise ValueError("url must be a non-empty string")
|
||||
if not isinstance(token, str) or not token:
|
||||
raise ValueError("token must be a non-empty string")
|
||||
data = self._load()
|
||||
data["nodes"][name] = {
|
||||
"url": url,
|
||||
"token": token,
|
||||
"added_at": time.time(),
|
||||
}
|
||||
self._save(data)
|
||||
|
||||
def remove(self, name: str) -> bool:
|
||||
data = self._load()
|
||||
if name in data["nodes"]:
|
||||
del data["nodes"][name]
|
||||
self._save(data)
|
||||
return True
|
||||
return False
|
||||
|
||||
def list_all(self) -> List[Dict[str, Any]]:
|
||||
data = self._load()
|
||||
out: List[Dict[str, Any]] = []
|
||||
for name, entry in sorted(data["nodes"].items()):
|
||||
out.append({"name": name, **entry})
|
||||
return out
|
||||
|
||||
def resolve(self, chrome_node: Optional[str]) -> Optional[Dict[str, Any]]:
|
||||
"""Resolve a node name to its entry.
|
||||
|
||||
If ``chrome_node`` is provided, return that named node (or None).
|
||||
If ``chrome_node`` is None, return the sole registered node when
|
||||
exactly one is registered; otherwise return None (ambiguous or
|
||||
empty).
|
||||
"""
|
||||
if chrome_node:
|
||||
return self.get(chrome_node)
|
||||
nodes = self.list_all()
|
||||
if len(nodes) == 1:
|
||||
return nodes[0]
|
||||
return None
|
||||
@@ -0,0 +1,200 @@
|
||||
"""Remote node server.
|
||||
|
||||
Runs on the machine that will host the Meet bot (typically the user's
|
||||
Mac laptop with a signed-in Chrome). Exposes a WebSocket endpoint that
|
||||
accepts signed RPC requests and dispatches them to the existing
|
||||
``plugins.google_meet.process_manager`` module.
|
||||
|
||||
Launched by ``hermes meet node run``.
|
||||
|
||||
Token handling
|
||||
--------------
|
||||
On first boot we mint 32 hex chars of entropy and persist them at
|
||||
``$HERMES_HOME/workspace/meetings/node_token.json``. Subsequent boots
|
||||
reuse the same token so previously-approved gateways don't need to be
|
||||
re-paired. The operator copies this token out-of-band to the gateway
|
||||
via ``hermes meet node approve <name> <url> <token>``.
|
||||
|
||||
Dependencies
|
||||
------------
|
||||
``websockets`` is an optional dep. We import it lazily inside
|
||||
:meth:`serve` so installing the plugin doesn't require it unless you
|
||||
actually host a node.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import secrets
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from hermes_constants import get_hermes_home
|
||||
from plugins.google_meet.node import protocol as _proto
|
||||
|
||||
|
||||
def _default_token_path() -> Path:
|
||||
return Path(get_hermes_home()) / "workspace" / "meetings" / "node_token.json"
|
||||
|
||||
|
||||
class NodeServer:
|
||||
"""WebSocket server that executes meet bot RPCs locally."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
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.port = port
|
||||
self.display_name = display_name
|
||||
self.token_path = Path(token_path) if token_path is not None else _default_token_path()
|
||||
self._token: Optional[str] = None
|
||||
|
||||
# ----- token management --------------------------------------------
|
||||
|
||||
def ensure_token(self) -> str:
|
||||
"""Return the persisted shared secret, generating one on first use."""
|
||||
if self._token:
|
||||
return self._token
|
||||
if self.token_path.is_file():
|
||||
try:
|
||||
data = json.loads(self.token_path.read_text(encoding="utf-8"))
|
||||
tok = data.get("token")
|
||||
if isinstance(tok, str) and tok:
|
||||
self._token = tok
|
||||
return tok
|
||||
except (OSError, json.JSONDecodeError):
|
||||
pass
|
||||
tok = secrets.token_hex(16) # 32 hex chars
|
||||
self.token_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = self.token_path.with_suffix(".json.tmp")
|
||||
tmp.write_text(
|
||||
json.dumps({"token": tok, "generated_at": time.time()}, indent=2),
|
||||
encoding="utf-8",
|
||||
)
|
||||
# Restrict to owner-read-write only — the token grants full RPC
|
||||
# access to the meet bot (start, transcribe, speak in meetings).
|
||||
try:
|
||||
tmp.chmod(0o600)
|
||||
except (OSError, NotImplementedError):
|
||||
# Best-effort on non-POSIX filesystems; mode is set on POSIX.
|
||||
pass
|
||||
tmp.replace(self.token_path)
|
||||
self._token = tok
|
||||
return tok
|
||||
|
||||
def get_token(self) -> str:
|
||||
"""Alias for :meth:`ensure_token`; does not mutate on subsequent calls."""
|
||||
return self.ensure_token()
|
||||
|
||||
# ----- dispatch -----------------------------------------------------
|
||||
|
||||
async def _handle_request(self, msg: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Validate + dispatch a single decoded request envelope.
|
||||
|
||||
Always returns a response envelope (success or error); never
|
||||
raises. Errors from inside the process_manager are wrapped into
|
||||
the response payload's ``ok``/``error`` keys (which pm already
|
||||
does) rather than being re-encoded as error envelopes — the
|
||||
envelope-level error channel is reserved for auth / protocol
|
||||
failures.
|
||||
"""
|
||||
expected = self.ensure_token()
|
||||
ok, reason = _proto.validate_request(msg, expected)
|
||||
if not ok:
|
||||
return _proto.make_error(str(msg.get("id") or ""), reason)
|
||||
|
||||
req_id = msg["id"]
|
||||
t = msg["type"]
|
||||
payload = msg["payload"]
|
||||
|
||||
# Import lazily so test mocks can monkeypatch freely.
|
||||
from plugins.google_meet import process_manager as pm
|
||||
|
||||
try:
|
||||
if t == "ping":
|
||||
return {"type": "pong", "id": req_id,
|
||||
"payload": {"display_name": self.display_name,
|
||||
"ts": time.time()}}
|
||||
if t == "start_bot":
|
||||
# Whitelist kwargs we pass through to pm.start.
|
||||
kwargs = {
|
||||
k: payload[k]
|
||||
for k in ("url", "guest_name", "duration", "headed",
|
||||
"auth_state", "session_id", "out_dir")
|
||||
if k in payload
|
||||
}
|
||||
if "url" not in kwargs:
|
||||
return _proto.make_error(req_id, "missing 'url' in payload")
|
||||
result = pm.start(**kwargs)
|
||||
return _proto.make_response(req_id, result)
|
||||
if t == "stop":
|
||||
reason_arg = payload.get("reason", "requested")
|
||||
result = pm.stop(reason=reason_arg)
|
||||
return _proto.make_response(req_id, result)
|
||||
if t == "status":
|
||||
return _proto.make_response(req_id, pm.status())
|
||||
if t == "transcript":
|
||||
last = payload.get("last")
|
||||
result = pm.transcript(last=last)
|
||||
return _proto.make_response(req_id, result)
|
||||
if t == "say":
|
||||
# v2 wiring: enqueue into say_queue.jsonl inside the
|
||||
# active meeting's out_dir when present. The bot-side
|
||||
# consumer is v3+ (for v1 this is a stub returning ok).
|
||||
text = payload.get("text", "")
|
||||
active = pm._read_active() # type: ignore[attr-defined]
|
||||
enqueued = False
|
||||
if active and active.get("out_dir"):
|
||||
queue = Path(active["out_dir"]) / "say_queue.jsonl"
|
||||
try:
|
||||
queue.parent.mkdir(parents=True, exist_ok=True)
|
||||
with queue.open("a", encoding="utf-8") as fh:
|
||||
fh.write(json.dumps({"text": text, "ts": time.time()}) + "\n")
|
||||
enqueued = True
|
||||
except OSError:
|
||||
enqueued = False
|
||||
return _proto.make_response(
|
||||
req_id,
|
||||
{"ok": True, "enqueued": enqueued, "text": text},
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 — surface any pm crash to client
|
||||
return _proto.make_error(req_id, f"{type(exc).__name__}: {exc}")
|
||||
|
||||
return _proto.make_error(req_id, f"unhandled type: {t!r}")
|
||||
|
||||
# ----- server loop --------------------------------------------------
|
||||
|
||||
async def serve(self) -> None:
|
||||
"""Run the WebSocket server until cancelled.
|
||||
|
||||
Blocks forever. Callers typically wrap this in ``asyncio.run``.
|
||||
"""
|
||||
try:
|
||||
import websockets # type: ignore
|
||||
except ImportError as exc:
|
||||
raise RuntimeError(
|
||||
"NodeServer.serve requires the 'websockets' package. "
|
||||
"Install it with: pip install websockets"
|
||||
) from exc
|
||||
|
||||
self.ensure_token()
|
||||
|
||||
async def _handler(ws):
|
||||
async for raw in ws:
|
||||
try:
|
||||
msg = _proto.decode(raw if isinstance(raw, str) else raw.decode("utf-8"))
|
||||
except ValueError as exc:
|
||||
await ws.send(_proto.encode(_proto.make_error("", f"decode: {exc}")))
|
||||
continue
|
||||
reply = await self._handle_request(msg)
|
||||
await ws.send(_proto.encode(reply))
|
||||
|
||||
async with websockets.serve(_handler, self.host, self.port):
|
||||
# Run until cancelled.
|
||||
import asyncio
|
||||
await asyncio.Future()
|
||||
@@ -0,0 +1,16 @@
|
||||
name: google_meet
|
||||
version: 0.2.0
|
||||
description: "Join a Google Meet call, transcribe live captions, speak in realtime, and follow up afterwards. v1 transcribe-only is the default; v2 realtime duplex audio via OpenAI Realtime + BlackHole/PulseAudio ships with mode='realtime'; v3 remote node host lets the bot run on a different machine than the gateway (gateway on Linux, Chrome+signed-in profile on the user's Mac). Explicit-by-design: only joins meet.google.com URLs passed in \u2014 no calendar scanning, no auto-dial."
|
||||
author: NousResearch
|
||||
kind: standalone
|
||||
platforms:
|
||||
- linux
|
||||
- macos
|
||||
provides_tools:
|
||||
- meet_join
|
||||
- meet_leave
|
||||
- meet_status
|
||||
- meet_transcript
|
||||
- meet_say
|
||||
hooks:
|
||||
- on_session_end
|
||||
@@ -0,0 +1,339 @@
|
||||
"""Subprocess lifecycle manager for the google_meet bot.
|
||||
|
||||
Single active meeting at a time. Stores the running pid + out_dir in a
|
||||
session-scoped state file under ``$HERMES_HOME/workspace/meetings/.active.json``
|
||||
so tool calls across turns can find the bot, and ``on_session_end`` can clean
|
||||
it up.
|
||||
|
||||
The bot runs as a detached subprocess — we don't hold file descriptors open,
|
||||
so the parent agent loop can't block on it. We communicate via files only.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from hermes_constants import get_hermes_home
|
||||
|
||||
# File + directory layout (under $HERMES_HOME):
|
||||
#
|
||||
# workspace/meetings/
|
||||
# .active.json # pointer to current session's bot
|
||||
# <meeting-id>/
|
||||
# status.json # live bot state (written by bot each tick)
|
||||
# transcript.txt # scraped captions
|
||||
#
|
||||
# .active.json holds:
|
||||
# {"pid": 12345, "meeting_id": "abc-defg-hij", "out_dir": "...",
|
||||
# "url": "https://meet.google.com/...", "started_at": 1714159200.0,
|
||||
# "session_id": "optional"}
|
||||
|
||||
|
||||
def _root() -> Path:
|
||||
return Path(get_hermes_home()) / "workspace" / "meetings"
|
||||
|
||||
|
||||
def _active_file() -> Path:
|
||||
return _root() / ".active.json"
|
||||
|
||||
|
||||
def _read_active() -> Optional[Dict[str, Any]]:
|
||||
p = _active_file()
|
||||
if not p.is_file():
|
||||
return None
|
||||
try:
|
||||
return json.loads(p.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _write_active(data: Dict[str, Any]) -> None:
|
||||
p = _active_file()
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = p.with_suffix(".json.tmp")
|
||||
tmp.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
||||
tmp.replace(p)
|
||||
|
||||
|
||||
def _clear_active() -> None:
|
||||
try:
|
||||
_active_file().unlink()
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
|
||||
def _pid_alive(pid: int) -> bool:
|
||||
# ``os.kill(pid, 0)`` is NOT a no-op on Windows (bpo-14484) — it
|
||||
# routes through GenerateConsoleCtrlEvent and can kill the target.
|
||||
# Use the cross-platform existence check.
|
||||
from gateway.status import _pid_exists
|
||||
return _pid_exists(pid)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API — used by tool handlers + CLI
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def start(
|
||||
url: str,
|
||||
*,
|
||||
out_dir: Optional[Path] = None,
|
||||
headed: bool = False,
|
||||
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,
|
||||
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*.
|
||||
|
||||
If a bot is already running for this hermes install, leave it first —
|
||||
we enforce single-active-meeting semantics.
|
||||
|
||||
Returns a dict summarizing the started bot.
|
||||
"""
|
||||
from plugins.google_meet.meet_bot import _is_safe_meet_url, _meeting_id_from_url
|
||||
|
||||
if not _is_safe_meet_url(url):
|
||||
return {
|
||||
"ok": False,
|
||||
"error": (
|
||||
"refusing: only https://meet.google.com/ URLs are allowed. "
|
||||
"got: " + repr(url)
|
||||
),
|
||||
}
|
||||
|
||||
existing = _read_active()
|
||||
if existing and _pid_alive(int(existing.get("pid", 0))):
|
||||
stop(reason="replaced by new meet_join")
|
||||
|
||||
meeting_id = _meeting_id_from_url(url)
|
||||
out = out_dir or (_root() / meeting_id)
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Wipe any stale transcript/status files from a previous run of this
|
||||
# meeting id so polling isn't confused.
|
||||
for name in ("transcript.txt", "status.json"):
|
||||
f = out / name
|
||||
if f.exists():
|
||||
try:
|
||||
f.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
env = os.environ.copy()
|
||||
env["HERMES_MEET_URL"] = url
|
||||
env["HERMES_MEET_OUT_DIR"] = str(out)
|
||||
env["HERMES_MEET_GUEST_NAME"] = guest_name
|
||||
if headed:
|
||||
env["HERMES_MEET_HEADED"] = "1"
|
||||
if auth_state:
|
||||
env["HERMES_MEET_AUTH_STATE"] = auth_state
|
||||
if duration:
|
||||
env["HERMES_MEET_DURATION"] = duration
|
||||
# v2: realtime mode + passthroughs. The bot defaults to transcribe
|
||||
# mode if HERMES_MEET_MODE isn't set, matching v1 behavior.
|
||||
if mode:
|
||||
env["HERMES_MEET_MODE"] = mode
|
||||
if realtime_model:
|
||||
env["HERMES_MEET_REALTIME_MODEL"] = realtime_model
|
||||
if realtime_voice:
|
||||
env["HERMES_MEET_REALTIME_VOICE"] = realtime_voice
|
||||
if realtime_instructions:
|
||||
env["HERMES_MEET_REALTIME_INSTRUCTIONS"] = realtime_instructions
|
||||
# Resolve the realtime key at SPAWN time, in the parent, where the
|
||||
# profile secret scope (a contextvar) is still installed. The detached
|
||||
# child inherits the process environment — NOT the scope — so under a
|
||||
# multiplexed gateway an in-child os.environ read would see another
|
||||
# profile's OPENAI_API_KEY (or nothing). Pass it explicitly instead;
|
||||
# meet_bot checks HERMES_MEET_REALTIME_KEY before OPENAI_API_KEY.
|
||||
if not realtime_api_key:
|
||||
try:
|
||||
from agent.secret_scope import get_secret
|
||||
|
||||
realtime_api_key = (
|
||||
get_secret("HERMES_MEET_REALTIME_KEY")
|
||||
or get_secret("OPENAI_API_KEY")
|
||||
)
|
||||
except ImportError: # pragma: no cover — secret_scope is in-repo
|
||||
pass
|
||||
if realtime_api_key:
|
||||
env["HERMES_MEET_REALTIME_KEY"] = realtime_api_key
|
||||
|
||||
log_path = out / "bot.log"
|
||||
# Detach: stdin=devnull, stdout/stderr → log file, new session so parent
|
||||
# signals don't propagate.
|
||||
log_fh = open(log_path, "ab", buffering=0)
|
||||
try:
|
||||
proc = subprocess.Popen(
|
||||
[sys.executable, "-m", "plugins.google_meet.meet_bot"],
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=log_fh,
|
||||
stderr=subprocess.STDOUT,
|
||||
env=env,
|
||||
start_new_session=True,
|
||||
close_fds=True,
|
||||
)
|
||||
finally:
|
||||
# The subprocess now owns the log fd; we can close ours.
|
||||
log_fh.close()
|
||||
|
||||
record = {
|
||||
"pid": proc.pid,
|
||||
"meeting_id": meeting_id,
|
||||
"out_dir": str(out),
|
||||
"url": url,
|
||||
"started_at": time.time(),
|
||||
"session_id": session_id,
|
||||
"log_path": str(log_path),
|
||||
"mode": mode,
|
||||
}
|
||||
_write_active(record)
|
||||
return {"ok": True, **record}
|
||||
|
||||
|
||||
def status() -> Dict[str, Any]:
|
||||
"""Return the current meeting state, or ``{"ok": False, "reason": ...}``."""
|
||||
active = _read_active()
|
||||
if not active:
|
||||
return {"ok": False, "reason": "no active meeting"}
|
||||
|
||||
pid = int(active.get("pid", 0))
|
||||
alive = _pid_alive(pid) if pid else False
|
||||
|
||||
status_path = Path(active.get("out_dir", "")) / "status.json"
|
||||
bot_status: Dict[str, Any] = {}
|
||||
if status_path.is_file():
|
||||
try:
|
||||
bot_status = json.loads(status_path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"alive": alive,
|
||||
"pid": pid,
|
||||
"meetingId": active.get("meeting_id"),
|
||||
"url": active.get("url"),
|
||||
"startedAt": active.get("started_at"),
|
||||
"outDir": active.get("out_dir"),
|
||||
**bot_status,
|
||||
}
|
||||
|
||||
|
||||
def transcript(last: Optional[int] = None) -> Dict[str, Any]:
|
||||
"""Read the current transcript file. Returns ok=False if none exists."""
|
||||
active = _read_active()
|
||||
if not active:
|
||||
return {"ok": False, "reason": "no active meeting"}
|
||||
|
||||
tp = Path(active.get("out_dir", "")) / "transcript.txt"
|
||||
if not tp.is_file():
|
||||
return {
|
||||
"ok": True,
|
||||
"meetingId": active.get("meeting_id"),
|
||||
"lines": [],
|
||||
"total": 0,
|
||||
"path": str(tp),
|
||||
}
|
||||
text = tp.read_text(encoding="utf-8", errors="replace")
|
||||
all_lines = [ln for ln in text.splitlines() if ln.strip()]
|
||||
lines = all_lines[-last:] if last else all_lines
|
||||
return {
|
||||
"ok": True,
|
||||
"meetingId": active.get("meeting_id"),
|
||||
"lines": lines,
|
||||
"total": len(all_lines),
|
||||
"path": str(tp),
|
||||
}
|
||||
|
||||
|
||||
def enqueue_say(text: str) -> Dict[str, Any]:
|
||||
"""Append a ``say`` request to the active bot's JSONL queue.
|
||||
|
||||
Returns ``{"ok": False, "reason": ...}`` when no meeting is active or
|
||||
the active bot is in transcribe-only mode. Otherwise writes a line to
|
||||
``<out_dir>/say_queue.jsonl`` that the bot's realtime speaker thread
|
||||
will consume.
|
||||
"""
|
||||
import uuid
|
||||
|
||||
text = (text or "").strip()
|
||||
if not text:
|
||||
return {"ok": False, "reason": "text is required"}
|
||||
|
||||
active = _read_active()
|
||||
if not active:
|
||||
return {"ok": False, "reason": "no active meeting"}
|
||||
if active.get("mode") != "realtime":
|
||||
return {
|
||||
"ok": False,
|
||||
"reason": (
|
||||
"active meeting is in transcribe mode — pass mode='realtime' "
|
||||
"to meet_join to enable agent speech"
|
||||
),
|
||||
}
|
||||
|
||||
out_dir = Path(active.get("out_dir", ""))
|
||||
if not out_dir.is_dir():
|
||||
return {"ok": False, "reason": f"out_dir missing: {out_dir}"}
|
||||
|
||||
queue_path = out_dir / "say_queue.jsonl"
|
||||
entry = {"id": uuid.uuid4().hex[:12], "text": text}
|
||||
with queue_path.open("a", encoding="utf-8") as f:
|
||||
f.write(json.dumps(entry) + "\n")
|
||||
return {
|
||||
"ok": True,
|
||||
"meetingId": active.get("meeting_id"),
|
||||
"enqueued_id": entry["id"],
|
||||
"queue_path": str(queue_path),
|
||||
}
|
||||
|
||||
|
||||
def stop(*, reason: str = "requested") -> Dict[str, Any]:
|
||||
"""Signal the active bot to leave cleanly, then clear the active pointer.
|
||||
|
||||
Sends SIGTERM and waits up to 10s for the bot to exit. Falls back to
|
||||
SIGKILL if the bot doesn't respond.
|
||||
"""
|
||||
active = _read_active()
|
||||
if not active:
|
||||
return {"ok": False, "reason": "no active meeting"}
|
||||
|
||||
pid = int(active.get("pid", 0))
|
||||
out_dir = active.get("out_dir")
|
||||
transcript_path = Path(out_dir) / "transcript.txt" if out_dir else None
|
||||
|
||||
if pid and _pid_alive(pid):
|
||||
try:
|
||||
os.kill(pid, signal.SIGTERM)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
for _ in range(20):
|
||||
if not _pid_alive(pid):
|
||||
break
|
||||
time.sleep(0.5)
|
||||
if _pid_alive(pid):
|
||||
try:
|
||||
os.kill(pid, signal.SIGKILL) # windows-footgun: ok — POSIX-only plugin (google_meet registers no-op on Windows; see __init__.py)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
|
||||
_clear_active()
|
||||
return {
|
||||
"ok": True,
|
||||
"reason": reason,
|
||||
"meetingId": active.get("meeting_id"),
|
||||
"transcriptPath": str(transcript_path) if transcript_path else None,
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
"""Realtime speech subpackage for the google_meet plugin (v2).
|
||||
|
||||
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
|
||||
|
||||
__all__ = ["RealtimeSession", "RealtimeSpeaker"]
|
||||
@@ -0,0 +1,332 @@
|
||||
"""OpenAI Realtime API WebSocket client + file-queue speaker.
|
||||
|
||||
This module is the "output" side of the v2 voice bridge: it takes text,
|
||||
sends it to the OpenAI Realtime API, receives audio deltas back, and
|
||||
appends the PCM bytes to a file. A separate consumer (the audio
|
||||
bridge) streams that file into Chrome's fake microphone.
|
||||
|
||||
Designed for simplicity: a single synchronous WebSocket connection per
|
||||
speaker, per session. The ``websockets`` package is imported lazily so
|
||||
that importing this module never fails just because the optional dep
|
||||
is missing.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
|
||||
REALTIME_URL = "wss://api.openai.com/v1/realtime"
|
||||
|
||||
|
||||
def _require_websockets():
|
||||
"""Import ``websockets.sync.client.connect`` or raise with hint."""
|
||||
try:
|
||||
from websockets.sync.client import connect as _connect # type: ignore
|
||||
except ImportError as exc: # pragma: no cover - exercised via test
|
||||
raise RuntimeError(
|
||||
"websockets package is required for OpenAI Realtime; "
|
||||
"install with: pip install websockets"
|
||||
) from exc
|
||||
return _connect
|
||||
|
||||
|
||||
class RealtimeSession:
|
||||
"""Minimal sync client for the OpenAI Realtime WebSocket API.
|
||||
|
||||
Usage:
|
||||
sess = RealtimeSession(api_key=..., audio_sink_path=Path("out.pcm"))
|
||||
sess.connect()
|
||||
sess.speak("Hello team.")
|
||||
sess.close()
|
||||
|
||||
Thread safety: ``speak`` and ``cancel_response`` may be called from
|
||||
different threads; a lock serializes WebSocket writes.
|
||||
"""
|
||||
|
||||
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:
|
||||
import threading as _threading
|
||||
self.api_key = api_key
|
||||
self.model = model
|
||||
self.voice = voice
|
||||
self.instructions = instructions
|
||||
self.audio_sink_path = Path(audio_sink_path) if audio_sink_path else None
|
||||
self.sample_rate = sample_rate
|
||||
self._ws: Any = None
|
||||
self._send_lock = _threading.Lock()
|
||||
self._last_response_id: Optional[str] = None
|
||||
# Public counters for status reporting.
|
||||
self.audio_bytes_out: int = 0
|
||||
self.last_audio_out_at: Optional[float] = None
|
||||
|
||||
# ── lifecycle ─────────────────────────────────────────────────────────
|
||||
|
||||
def connect(self) -> None:
|
||||
"""Open WS and send session.update with voice+instructions."""
|
||||
connect = _require_websockets()
|
||||
url = f"{REALTIME_URL}?model={self.model}"
|
||||
headers = [
|
||||
("Authorization", f"Bearer {self.api_key}"),
|
||||
("OpenAI-Beta", "realtime=v1"),
|
||||
]
|
||||
# websockets.sync.client.connect accepts either additional_headers=
|
||||
# (newer) or extra_headers= depending on version; try the newer
|
||||
# name first and fall back.
|
||||
try:
|
||||
self._ws = connect(url, additional_headers=headers)
|
||||
except TypeError:
|
||||
self._ws = connect(url, extra_headers=headers)
|
||||
|
||||
self._send_json(
|
||||
{
|
||||
"type": "session.update",
|
||||
"session": {
|
||||
"voice": self.voice,
|
||||
"instructions": self.instructions,
|
||||
"modalities": ["audio", "text"],
|
||||
"output_audio_format": "pcm16",
|
||||
"input_audio_format": "pcm16",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
def close(self) -> None:
|
||||
if self._ws is not None:
|
||||
try:
|
||||
self._ws.close()
|
||||
except Exception:
|
||||
pass
|
||||
self._ws = None
|
||||
|
||||
# ── speaking ──────────────────────────────────────────────────────────
|
||||
|
||||
def speak(self, text: str, timeout: float = 30.0) -> dict:
|
||||
"""Send ``text`` and accumulate the audio response.
|
||||
|
||||
Audio deltas are base64-decoded and appended to
|
||||
``audio_sink_path`` (opened 'ab' and closed per call, so a
|
||||
separate streaming reader can consume whatever is there).
|
||||
"""
|
||||
if self._ws is None:
|
||||
raise RuntimeError("RealtimeSession.connect() must be called first")
|
||||
|
||||
start = time.monotonic()
|
||||
|
||||
self._send_json(
|
||||
{
|
||||
"type": "conversation.item.create",
|
||||
"item": {
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [{"type": "input_text", "text": text}],
|
||||
},
|
||||
}
|
||||
)
|
||||
self._send_json(
|
||||
{
|
||||
"type": "response.create",
|
||||
"response": {"modalities": ["audio"]},
|
||||
}
|
||||
)
|
||||
|
||||
bytes_written = 0
|
||||
sink_fp = None
|
||||
if self.audio_sink_path is not None:
|
||||
self.audio_sink_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
sink_fp = open(self.audio_sink_path, "ab")
|
||||
|
||||
try:
|
||||
while True:
|
||||
remaining = timeout - (time.monotonic() - start)
|
||||
if remaining <= 0:
|
||||
raise TimeoutError(
|
||||
f"realtime response did not complete within {timeout}s"
|
||||
)
|
||||
raw = self._recv(timeout=remaining)
|
||||
if raw is None:
|
||||
# Connection closed by peer.
|
||||
break
|
||||
try:
|
||||
frame = json.loads(raw) if isinstance(raw, (str, bytes, bytearray)) else raw
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if not isinstance(frame, dict):
|
||||
continue
|
||||
ftype = frame.get("type")
|
||||
if ftype == "response.audio.delta":
|
||||
b64 = frame.get("delta") or frame.get("audio") or ""
|
||||
if b64 and sink_fp is not None:
|
||||
try:
|
||||
chunk = base64.b64decode(b64)
|
||||
except (ValueError, TypeError):
|
||||
chunk = b""
|
||||
if chunk:
|
||||
sink_fp.write(chunk)
|
||||
sink_fp.flush()
|
||||
bytes_written += len(chunk)
|
||||
self.audio_bytes_out += len(chunk)
|
||||
self.last_audio_out_at = time.time()
|
||||
elif ftype == "response.created":
|
||||
rid = (frame.get("response") or {}).get("id")
|
||||
if rid:
|
||||
self._last_response_id = rid
|
||||
elif ftype in {"response.done", "response.completed", "response.cancelled"}:
|
||||
break
|
||||
elif ftype == "error":
|
||||
err = frame.get("error") or frame
|
||||
raise RuntimeError(f"realtime error: {err}")
|
||||
# All other frames (response.created, response.output_item.*,
|
||||
# response.audio_transcript.delta, rate_limits.updated, ...)
|
||||
# are ignored for v2.
|
||||
finally:
|
||||
if sink_fp is not None:
|
||||
sink_fp.close()
|
||||
|
||||
duration_ms = (time.monotonic() - start) * 1000.0
|
||||
return {
|
||||
"ok": True,
|
||||
"bytes_written": bytes_written,
|
||||
"duration_ms": duration_ms,
|
||||
}
|
||||
|
||||
# ── ws plumbing ───────────────────────────────────────────────────────
|
||||
|
||||
def cancel_response(self) -> bool:
|
||||
"""Interrupt the in-flight response (barge-in).
|
||||
|
||||
Sends ``response.cancel`` on the current WebSocket so the model
|
||||
stops generating audio immediately. Safe to call at any time;
|
||||
returns True if a cancel was actually sent, False when there's
|
||||
nothing to cancel or the socket isn't open.
|
||||
"""
|
||||
if self._ws is None:
|
||||
return False
|
||||
try:
|
||||
self._send_json({"type": "response.cancel"})
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def _send_json(self, payload: dict) -> None:
|
||||
assert self._ws is not None
|
||||
with self._send_lock:
|
||||
self._ws.send(json.dumps(payload))
|
||||
|
||||
def _recv(self, timeout: Optional[float] = None):
|
||||
assert self._ws is not None
|
||||
try:
|
||||
if timeout is None:
|
||||
return self._ws.recv()
|
||||
return self._ws.recv(timeout=timeout)
|
||||
except TypeError:
|
||||
# Older websockets may not accept timeout kwarg.
|
||||
return self._ws.recv()
|
||||
|
||||
|
||||
class RealtimeSpeaker:
|
||||
"""File-based JSONL queue wrapper around :class:`RealtimeSession`.
|
||||
|
||||
Each line in ``queue_path`` is a JSON object of the form
|
||||
``{"id": "<uuid>", "text": "..."}``. Processed lines are appended
|
||||
to ``processed_path`` (if set) and then removed from the queue;
|
||||
if ``processed_path`` is ``None``, processed lines are simply
|
||||
dropped.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
session: RealtimeSession,
|
||||
queue_path: Path,
|
||||
processed_path: Optional[Path] = None,
|
||||
) -> None:
|
||||
self.session = session
|
||||
self.queue_path = Path(queue_path)
|
||||
self.processed_path = Path(processed_path) if processed_path else None
|
||||
|
||||
# ── helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
def _read_queue(self) -> list[dict]:
|
||||
if not self.queue_path.exists():
|
||||
return []
|
||||
out: list[dict] = []
|
||||
for line in self.queue_path.read_text(encoding="utf-8").splitlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
entry = json.loads(line)
|
||||
except ValueError:
|
||||
continue
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
if "id" not in entry:
|
||||
entry["id"] = str(uuid.uuid4())
|
||||
out.append(entry)
|
||||
return out
|
||||
|
||||
def _rewrite_queue(self, remaining: list[dict]) -> None:
|
||||
if not remaining:
|
||||
# Keep the file but empty — consumers may be watching for
|
||||
# new writes via mtime, and delete-then-recreate is a race.
|
||||
self.queue_path.write_text("", encoding="utf-8")
|
||||
return
|
||||
self.queue_path.write_text(
|
||||
"\n".join(json.dumps(e) for e in remaining) + "\n", encoding="utf-8"
|
||||
)
|
||||
|
||||
def _append_processed(self, entry: dict, result: dict) -> None:
|
||||
if self.processed_path is None:
|
||||
return
|
||||
self.processed_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
record = {"id": entry.get("id"), "text": entry.get("text", ""), "result": result}
|
||||
with open(self.processed_path, "a", encoding="utf-8") as fp:
|
||||
fp.write(json.dumps(record) + "\n")
|
||||
|
||||
# ── main loop ────────────────────────────────────────────────────────
|
||||
|
||||
def run_until_stopped(
|
||||
self,
|
||||
stop_fn: Callable[[], bool],
|
||||
poll_interval: float = 0.5,
|
||||
) -> None:
|
||||
while not stop_fn():
|
||||
entries = self._read_queue()
|
||||
if not entries:
|
||||
time.sleep(poll_interval)
|
||||
continue
|
||||
# Process one at a time; re-check the queue file after each
|
||||
# speak() call because new entries may have arrived.
|
||||
head = entries[0]
|
||||
text = (head.get("text") or "").strip()
|
||||
if text:
|
||||
try:
|
||||
result = self.session.speak(text)
|
||||
except Exception as exc:
|
||||
result = {"ok": False, "error": str(exc)}
|
||||
else:
|
||||
result = {"ok": True, "bytes_written": 0, "duration_ms": 0.0}
|
||||
self._append_processed(head, result)
|
||||
|
||||
# Re-read the queue from disk in case it was appended to
|
||||
# while we were speaking, then drop the head.
|
||||
latest = self._read_queue()
|
||||
if latest and latest[0].get("id") == head.get("id"):
|
||||
self._rewrite_queue(latest[1:])
|
||||
else:
|
||||
# Fallback: drop-by-id anywhere in the queue.
|
||||
self._rewrite_queue(
|
||||
[e for e in latest if e.get("id") != head.get("id")]
|
||||
)
|
||||
@@ -0,0 +1,348 @@
|
||||
"""Agent-facing tools for the google_meet plugin.
|
||||
|
||||
Tools:
|
||||
meet_join — join a Google Meet URL (spawns Playwright bot locally
|
||||
OR on a remote node host via node=<name>)
|
||||
meet_status — report bot liveness + transcript progress
|
||||
meet_transcript — read the current transcript (optional last-N)
|
||||
meet_leave — signal the bot to leave cleanly
|
||||
meet_say — (v2) speak text through the realtime audio bridge.
|
||||
Requires the active meeting to have been joined with
|
||||
mode='realtime'.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from plugins.google_meet import process_manager as pm
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Runtime gate
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def check_meet_requirements() -> bool:
|
||||
"""Return True when the plugin can actually run LOCALLY.
|
||||
|
||||
Gates on:
|
||||
* Python ``playwright`` package importable
|
||||
* the plugin being on a supported platform (Linux or macOS)
|
||||
|
||||
Note: remote-node operation (``node=<name>``) only needs the
|
||||
``websockets`` dep on the gateway side — Chromium lives on the node.
|
||||
But the plugin-level gate keeps the v1 semantics; individual tool
|
||||
handlers relax the requirement when a node is addressed.
|
||||
"""
|
||||
import platform as _p
|
||||
if _p.system().lower() not in {"linux", "darwin"}:
|
||||
return False
|
||||
try:
|
||||
import playwright # noqa: F401
|
||||
except ImportError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Node client helper
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _resolve_node_client(node: Optional[str]):
|
||||
"""Return (NodeClient, node_name) for *node*, or (None, None) to run local.
|
||||
|
||||
Raises RuntimeError with a readable message if the node is named but
|
||||
unresolvable, so the handler can surface a clear error to the agent.
|
||||
"""
|
||||
if node is None or node == "":
|
||||
return None, None
|
||||
from plugins.google_meet.node.registry import NodeRegistry
|
||||
from plugins.google_meet.node.client import NodeClient
|
||||
|
||||
reg = NodeRegistry()
|
||||
entry = reg.resolve(node if node != "auto" else None)
|
||||
if entry is None:
|
||||
raise RuntimeError(
|
||||
f"no registered meet node matches {node!r} — "
|
||||
"run `hermes meet node approve <name> <url> <token>` first"
|
||||
)
|
||||
client = NodeClient(url=entry["url"], token=entry["token"])
|
||||
return client, entry.get("name")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
MEET_JOIN_SCHEMA: Dict[str, Any] = {
|
||||
"name": "meet_join",
|
||||
"description": (
|
||||
"Join a Google Meet call and start scraping live captions into a "
|
||||
"transcript file. Only meet.google.com URLs are accepted; no calendar "
|
||||
"scanning, no auto-dial. Spawns a headless Chromium subprocess that "
|
||||
"runs in parallel with the agent loop — returns immediately. Poll "
|
||||
"with meet_status and read captions with meet_transcript. Reminder "
|
||||
"to the agent: you should announce yourself in the meeting (there is "
|
||||
"no automatic consent announcement)."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"url": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"Full https://meet.google.com/... URL. Required."
|
||||
),
|
||||
},
|
||||
"mode": {
|
||||
"type": "string",
|
||||
"enum": ["transcribe", "realtime"],
|
||||
"description": (
|
||||
"transcribe (default): listen-only, scrape captions. "
|
||||
"realtime: also enable agent speech via meet_say "
|
||||
"(requires OpenAI Realtime key + platform audio bridge)."
|
||||
),
|
||||
},
|
||||
"guest_name": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"Display name to use when joining as guest. Defaults to "
|
||||
"'Hermes Agent'."
|
||||
),
|
||||
},
|
||||
"duration": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"Optional max duration before auto-leave (e.g. '30m', "
|
||||
"'2h', '90s'). Omit to stay until meet_leave is called."
|
||||
),
|
||||
},
|
||||
"headed": {
|
||||
"type": "boolean",
|
||||
"description": (
|
||||
"Run Chromium headed instead of headless (debug only). "
|
||||
"Default false."
|
||||
),
|
||||
},
|
||||
"node": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"Name of a registered remote node to run the bot on "
|
||||
"(useful when the gateway runs on a headless Linux box "
|
||||
"but the user's Chrome with a signed-in Google profile "
|
||||
"lives on their Mac). Pass 'auto' to use the single "
|
||||
"registered node. Default: run locally. Nodes are "
|
||||
"approved via `hermes meet node approve`."
|
||||
),
|
||||
},
|
||||
},
|
||||
"required": ["url"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
}
|
||||
|
||||
MEET_STATUS_SCHEMA: Dict[str, Any] = {
|
||||
"name": "meet_status",
|
||||
"description": (
|
||||
"Report the current Meet session state — whether the bot is alive, "
|
||||
"has joined, is sitting in the lobby, number of transcript lines "
|
||||
"captured, and last-caption timestamp."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"node": {"type": "string"},
|
||||
},
|
||||
"additionalProperties": False,
|
||||
},
|
||||
}
|
||||
|
||||
MEET_TRANSCRIPT_SCHEMA: Dict[str, Any] = {
|
||||
"name": "meet_transcript",
|
||||
"description": (
|
||||
"Read the scraped transcript for the active Meet session. Returns "
|
||||
"full transcript unless 'last' is set, in which case returns the last "
|
||||
"N lines only."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"last": {
|
||||
"type": "integer",
|
||||
"description": (
|
||||
"Optional: return only the last N caption lines. Useful "
|
||||
"for polling during a meeting without re-reading the "
|
||||
"whole transcript."
|
||||
),
|
||||
"minimum": 1,
|
||||
},
|
||||
"node": {"type": "string"},
|
||||
},
|
||||
"additionalProperties": False,
|
||||
},
|
||||
}
|
||||
|
||||
MEET_LEAVE_SCHEMA: Dict[str, Any] = {
|
||||
"name": "meet_leave",
|
||||
"description": (
|
||||
"Leave the active Meet call cleanly, stop caption scraping, and "
|
||||
"finalize the transcript file. Safe to call when no meeting is "
|
||||
"active — returns ok=false with a reason."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"node": {"type": "string"},
|
||||
},
|
||||
"additionalProperties": False,
|
||||
},
|
||||
}
|
||||
|
||||
MEET_SAY_SCHEMA: Dict[str, Any] = {
|
||||
"name": "meet_say",
|
||||
"description": (
|
||||
"Speak text into the active Meet call. Requires the active meeting "
|
||||
"to have been joined with mode='realtime'. The text is queued to "
|
||||
"the bot's OpenAI Realtime session; the generated audio is streamed "
|
||||
"into Chrome's fake microphone via a virtual audio device "
|
||||
"(PulseAudio null-sink on Linux, BlackHole on macOS). Returns "
|
||||
"immediately — the actual speech lags by a couple of seconds."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"text": {"type": "string", "description": "Text to speak."},
|
||||
"node": {"type": "string"},
|
||||
},
|
||||
"required": ["text"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Handlers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _json(obj: Any) -> str:
|
||||
return json.dumps(obj, ensure_ascii=False)
|
||||
|
||||
|
||||
def _err(msg: str, **extra) -> str:
|
||||
return _json({"success": False, "error": msg, **extra})
|
||||
|
||||
|
||||
def handle_meet_join(args: Dict[str, Any], **_kw) -> str:
|
||||
url = (args.get("url") or "").strip()
|
||||
if not url:
|
||||
return _err("url is required")
|
||||
mode = (args.get("mode") or "transcribe").strip().lower()
|
||||
if mode not in {"transcribe", "realtime"}:
|
||||
return _err(f"mode must be 'transcribe' or 'realtime' (got {mode!r})")
|
||||
|
||||
node = args.get("node")
|
||||
try:
|
||||
client, node_name = _resolve_node_client(node)
|
||||
except RuntimeError as e:
|
||||
return _err(str(e))
|
||||
|
||||
if client is not None:
|
||||
# Remote path — delegate to the node host.
|
||||
try:
|
||||
res = client.start_bot(
|
||||
url=url,
|
||||
guest_name=str(args.get("guest_name") or "Hermes Agent"),
|
||||
duration=str(args.get("duration")) if args.get("duration") else None,
|
||||
headed=bool(args.get("headed", False)),
|
||||
mode=mode,
|
||||
)
|
||||
return _json({"success": bool(res.get("ok")), "node": node_name, **res})
|
||||
except Exception as e:
|
||||
return _err(f"remote node start_bot failed: {e}", node=node_name)
|
||||
|
||||
# Local path — same as v1, with v2 params.
|
||||
if not check_meet_requirements():
|
||||
return _err(
|
||||
"google_meet plugin prerequisites missing — install with "
|
||||
"`pip install playwright && python -m playwright install "
|
||||
"chromium`. Plugin is supported on Linux and macOS only."
|
||||
)
|
||||
res = pm.start(
|
||||
url=url,
|
||||
headed=bool(args.get("headed", False)),
|
||||
guest_name=str(args.get("guest_name") or "Hermes Agent"),
|
||||
duration=str(args.get("duration")) if args.get("duration") else None,
|
||||
mode=mode,
|
||||
)
|
||||
return _json({"success": bool(res.get("ok")), **res})
|
||||
|
||||
|
||||
def handle_meet_status(args: Dict[str, Any], **_kw) -> str:
|
||||
try:
|
||||
client, node_name = _resolve_node_client(args.get("node"))
|
||||
except RuntimeError as e:
|
||||
return _err(str(e))
|
||||
if client is not None:
|
||||
try:
|
||||
res = client.status()
|
||||
return _json({"success": bool(res.get("ok")), "node": node_name, **res})
|
||||
except Exception as e:
|
||||
return _err(f"remote node status failed: {e}", node=node_name)
|
||||
res = pm.status()
|
||||
return _json({"success": bool(res.get("ok")), **res})
|
||||
|
||||
|
||||
def handle_meet_transcript(args: Dict[str, Any], **_kw) -> str:
|
||||
last = args.get("last")
|
||||
try:
|
||||
last_i = int(last) if last is not None else None
|
||||
if last_i is not None and last_i < 1:
|
||||
last_i = None
|
||||
except (TypeError, ValueError):
|
||||
last_i = None
|
||||
try:
|
||||
client, node_name = _resolve_node_client(args.get("node"))
|
||||
except RuntimeError as e:
|
||||
return _err(str(e))
|
||||
if client is not None:
|
||||
try:
|
||||
res = client.transcript(last=last_i)
|
||||
return _json({"success": bool(res.get("ok")), "node": node_name, **res})
|
||||
except Exception as e:
|
||||
return _err(f"remote node transcript failed: {e}", node=node_name)
|
||||
res = pm.transcript(last=last_i)
|
||||
return _json({"success": bool(res.get("ok")), **res})
|
||||
|
||||
|
||||
def handle_meet_leave(args: Dict[str, Any], **_kw) -> str:
|
||||
try:
|
||||
client, node_name = _resolve_node_client(args.get("node"))
|
||||
except RuntimeError as e:
|
||||
return _err(str(e))
|
||||
if client is not None:
|
||||
try:
|
||||
res = client.stop()
|
||||
return _json({"success": bool(res.get("ok")), "node": node_name, **res})
|
||||
except Exception as e:
|
||||
return _err(f"remote node stop failed: {e}", node=node_name)
|
||||
res = pm.stop(reason="agent called meet_leave")
|
||||
return _json({"success": bool(res.get("ok")), **res})
|
||||
|
||||
|
||||
def handle_meet_say(args: Dict[str, Any], **_kw) -> str:
|
||||
text = (args.get("text") or "").strip()
|
||||
if not text:
|
||||
return _err("text is required")
|
||||
try:
|
||||
client, node_name = _resolve_node_client(args.get("node"))
|
||||
except RuntimeError as e:
|
||||
return _err(str(e))
|
||||
if client is not None:
|
||||
try:
|
||||
res = client.say(text)
|
||||
return _json({"success": bool(res.get("ok")), "node": node_name, **res})
|
||||
except Exception as e:
|
||||
return _err(f"remote node say failed: {e}", node=node_name)
|
||||
res = pm.enqueue_say(text)
|
||||
return _json({"success": bool(res.get("ok")), **res})
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Hermes Achievements contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,150 @@
|
||||
# Hermes Achievements
|
||||
|
||||
> **Bundled with Hermes Agent.** Originally authored by [@PCinkusz](https://github.com/PCinkusz) at https://github.com/PCinkusz/hermes-achievements — vendored into `plugins/hermes-achievements/` so it ships with the dashboard out-of-the-box and stays in lockstep with Hermes feature changes. Upstream repo remains the staging ground for new badges and UI iteration.
|
||||
>
|
||||
> When Hermes is installed via the install script or cloned from source, this plugin auto-registers as a dashboard tab on first `hermes dashboard` launch. No separate install step. See [Built-in Plugins → hermes-achievements](../../website/docs/user-guide/features/built-in-plugins.md) in the main docs.
|
||||
|
||||
Achievement system for the Hermes Dashboard: collectible, tiered badges generated from real local Hermes session history.
|
||||
|
||||

|
||||
|
||||
The screenshots use temporary demo tier data to show the full visual range. The plugin itself reads real local Hermes session history by default.
|
||||
|
||||
> **Update notice (2026-04-29):** If you installed this plugin before today, update to the latest version. The achievements scan path was refactored for much faster warm loads (snapshot cache + incremental checkpoint scan).
|
||||
>
|
||||
> **Share cards (2026-05-04, vendored in hermes-agent v0.4.0):** Unlocked achievement cards now have a "Share" button that renders a 1200×630 PNG share card (client-side canvas, no backend, no network) with Download + Copy-to-clipboard actions. Fits X/Twitter, Discord, LinkedIn, Bluesky link-preview dimensions.
|
||||
|
||||
## What it does
|
||||
|
||||
Hermes Achievements scans local Hermes sessions and unlocks badges based on real agent behavior:
|
||||
|
||||
- autonomous tool chains
|
||||
- debugging and recovery patterns
|
||||
- vibe-coding file edits
|
||||
- Hermes-native skills, memory, cron, and plugin usage
|
||||
- web research and browser automation
|
||||
- model/provider workflows
|
||||
- lifestyle patterns such as weekend or night sessions
|
||||
|
||||
Achievements have three visible states:
|
||||
|
||||
- **Unlocked** — earned at least one tier
|
||||
- **Discovered** — known achievement, progress visible, not earned yet
|
||||
- **Secret** — hidden until Hermes detects the first related signal
|
||||
|
||||
Most achievements level through:
|
||||
|
||||
```text
|
||||
Copper → Silver → Gold → Diamond → Olympian
|
||||
```
|
||||
|
||||
Each card has a collapsible **What counts** section showing the exact tracked metric or requirement once the user wants details.
|
||||
|
||||
Version `0.2.x` expands the catalog to 60+ achievements, including model/provider badges such as **Five-Model Flight**, **Provider Polyglot**, **Claude Confidant**, **Gemini Cartographer**, and **Open Weights Pilgrim**.
|
||||
|
||||
## Examples
|
||||
|
||||
- Let Him Cook
|
||||
- Toolchain Maxxer
|
||||
- Red Text Connoisseur
|
||||
- Port 3000 Is Taken
|
||||
- This Was Supposed To Be Quick
|
||||
- One More Small Change
|
||||
- Skillsmith
|
||||
- Memory Keeper
|
||||
- Context Dragon
|
||||
- Plugin Goblin
|
||||
- Rabbit Hole Certified
|
||||
|
||||
## Install
|
||||
|
||||
Clone into your Hermes plugins directory:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/PCinkusz/hermes-achievements ~/.hermes/plugins/hermes-achievements
|
||||
```
|
||||
|
||||
For local development, keep the repo elsewhere and symlink it:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/PCinkusz/hermes-achievements ~/hermes-achievements
|
||||
ln -s ~/hermes-achievements ~/.hermes/plugins/hermes-achievements
|
||||
```
|
||||
|
||||
Then rescan dashboard plugins:
|
||||
|
||||
```bash
|
||||
curl http://127.0.0.1:9119/api/dashboard/plugins/rescan
|
||||
```
|
||||
|
||||
If backend API routes 404, restart `hermes dashboard`; plugin APIs are mounted at dashboard startup.
|
||||
|
||||
## Updating
|
||||
|
||||
If you installed with git:
|
||||
|
||||
```bash
|
||||
cd ~/.hermes/plugins/hermes-achievements
|
||||
git pull --ff-only
|
||||
curl http://127.0.0.1:9119/api/dashboard/plugins/rescan
|
||||
```
|
||||
|
||||
If the update changes backend routes or `plugin_api.py`, restart `hermes dashboard` after pulling.
|
||||
|
||||
As of 2026-04-29, updating is strongly recommended because scan performance changed significantly:
|
||||
- removed duplicate `/overview` scan path
|
||||
- added cached `/achievements` snapshot
|
||||
- added incremental checkpoint reuse for unchanged sessions
|
||||
|
||||
Achievement unlock state is stored locally in `state.json` and is not overwritten by git updates. New achievements are evaluated from your existing Hermes session history. Achievement IDs are stable and should not be renamed casually because they are the unlock-state keys.
|
||||
|
||||
Releases are tagged in git, for example:
|
||||
|
||||
```bash
|
||||
git fetch --tags
|
||||
git checkout v0.2.0
|
||||
```
|
||||
|
||||
## Files
|
||||
|
||||
```text
|
||||
dashboard/
|
||||
├── manifest.json
|
||||
├── plugin_api.py
|
||||
└── dist/
|
||||
├── index.js
|
||||
└── style.css
|
||||
```
|
||||
|
||||
## API
|
||||
|
||||
Routes are mounted under:
|
||||
|
||||
```text
|
||||
/api/plugins/hermes-achievements/
|
||||
```
|
||||
|
||||
Endpoints:
|
||||
|
||||
```text
|
||||
GET /achievements
|
||||
GET /scan-status
|
||||
GET /recent-unlocks
|
||||
GET /sessions/{session_id}/badges
|
||||
POST /rescan
|
||||
POST /reset-state
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
Run checks:
|
||||
|
||||
```bash
|
||||
node --check dashboard/dist/index.js
|
||||
python3 -m py_compile dashboard/plugin_api.py
|
||||
python3 -m unittest tests/test_achievement_engine.py -v
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
+726
File diff suppressed because one or more lines are too long
@@ -0,0 +1,146 @@
|
||||
/* hermes-achievements dashboard styles
|
||||
* Originally authored by @PCinkusz — https://github.com/PCinkusz/hermes-achievements (MIT).
|
||||
* Bundled into hermes-agent. The in-progress scan banner rules at the bottom
|
||||
* (.ha-scan-banner*) are a small addition layered on top of the original bundle.
|
||||
*/
|
||||
.ha-page { display: flex; flex-direction: column; gap: 1rem; }
|
||||
.ha-hero { position: relative; overflow: hidden; display: flex; align-items: flex-end; justify-content: space-between; gap: 1rem; border: 1px solid var(--color-border); background: radial-gradient(circle at 12% 0, rgba(103,232,249,.13), transparent 30%), linear-gradient(135deg, color-mix(in srgb, var(--color-card) 88%, transparent), color-mix(in srgb, var(--color-primary) 10%, transparent)); padding: 1.25rem; }
|
||||
.ha-hero:before { content: ""; position: absolute; inset: auto -10% -80% -10%; height: 180%; pointer-events: none; background: radial-gradient(circle, rgba(242,201,76,.12), transparent 55%); }
|
||||
.ha-hero h1 { position: relative; margin: 0; font-size: clamp(2rem, 4vw, 4.2rem); line-height: .9; letter-spacing: -0.06em; }
|
||||
.ha-hero p { position: relative; max-width: 52rem; margin: .65rem 0 0; color: var(--color-muted-foreground); }
|
||||
.ha-kicker { position: relative; color: var(--color-muted-foreground); text-transform: uppercase; letter-spacing: .18em; font-size: .72rem; font-family: var(--font-mono, ui-monospace, monospace); }
|
||||
.ha-refresh { position: relative; white-space: nowrap; }
|
||||
.ha-stats { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: .75rem; }
|
||||
.ha-stat-content { padding: 1rem !important; }
|
||||
.ha-stat-label { color: var(--color-muted-foreground); font-size: .75rem; text-transform: uppercase; letter-spacing: .12em; }
|
||||
.ha-stat-value { margin-top: .35rem; font-size: 1.4rem; font-weight: 750; letter-spacing: -0.035em; }
|
||||
.ha-stat-hint { margin-top: .2rem; color: var(--color-muted-foreground); font-size: .75rem; }
|
||||
.ha-toolbar { display: flex; justify-content: space-between; gap: .75rem; align-items: center; flex-wrap: wrap; }
|
||||
.ha-pills { display: flex; gap: .35rem; flex-wrap: wrap; }
|
||||
.ha-pills button { border: 1px solid var(--color-border); background: color-mix(in srgb, var(--color-card) 72%, transparent); color: var(--color-muted-foreground); padding: .35rem .6rem; font-size: .78rem; cursor: pointer; }
|
||||
.ha-pills button.active, .ha-pills button:hover { color: var(--color-foreground); border-color: var(--ha-tier, var(--color-ring)); background: color-mix(in srgb, var(--color-primary) 16%, var(--color-card)); }
|
||||
.ha-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: .9rem; }
|
||||
.ha-card { --ha-tier: var(--color-border); position: relative; overflow: hidden; min-height: 214px; border: 1px solid color-mix(in srgb, var(--ha-tier) 46%, var(--color-border)); background: radial-gradient(circle at 2.6rem 2.2rem, color-mix(in srgb, var(--ha-tier) 16%, transparent), transparent 34%), linear-gradient(180deg, rgba(255,255,255,.04), transparent), color-mix(in srgb, var(--color-card) 92%, #000); transition: transform .16s ease, border-color .16s ease, opacity .16s ease, box-shadow .16s ease; }
|
||||
.ha-card:hover { border-color: var(--ha-tier); box-shadow: 0 0 0 1px color-mix(in srgb, var(--ha-tier) 16%, transparent); }
|
||||
.ha-card-content { position: relative; z-index: 1; padding: 1rem !important; display: flex; flex-direction: column; gap: .75rem; height: 100%; }
|
||||
.ha-card-head { display: grid; grid-template-columns: 3.1rem minmax(0, 1fr) auto; gap: .85rem; align-items: start; }
|
||||
.ha-icon { display: grid; place-items: center; width: 2.9rem; height: 2.9rem; color: var(--ha-tier); }
|
||||
.ha-lucide { width: 1.78rem; height: 1.78rem; stroke: currentColor; stroke-width: 2.15; filter: drop-shadow(0 0 8px color-mix(in srgb, var(--ha-tier) 24%, transparent)); }
|
||||
.ha-card-title { font-weight: 780; line-height: 1.05; letter-spacing: -0.025em; }
|
||||
.ha-card-category { margin-top: .28rem; color: var(--color-muted-foreground); font-size: .76rem; }
|
||||
.ha-badges { display: flex; flex-direction: column; align-items: flex-end; gap: .25rem; }
|
||||
.ha-tier-badge, .ha-state-badge { border: 1px solid var(--ha-tier); color: var(--ha-tier); background: color-mix(in srgb, var(--ha-tier) 10%, transparent); padding: .16rem .38rem; font-size: .67rem; text-transform: uppercase; letter-spacing: .08em; font-family: var(--font-mono, ui-monospace, monospace); }
|
||||
.ha-description { margin: 0; color: var(--color-muted-foreground); font-size: .86rem; line-height: 1.45; min-height: 2.4em; }
|
||||
.ha-criteria { border: 1px solid color-mix(in srgb, var(--ha-tier) 28%, var(--color-border)); background: color-mix(in srgb, var(--ha-tier) 5%, transparent); }
|
||||
.ha-criteria summary { cursor: pointer; padding: .5rem .65rem; color: var(--ha-tier); text-transform: uppercase; letter-spacing: .1em; font-size: .66rem; font-family: var(--font-mono, ui-monospace, monospace); user-select: none; }
|
||||
.ha-criteria summary:hover { background: color-mix(in srgb, var(--ha-tier) 8%, transparent); }
|
||||
.ha-criteria p { margin: 0; border-top: 1px solid color-mix(in srgb, var(--ha-tier) 18%, var(--color-border)); padding: .55rem .65rem .65rem; color: color-mix(in srgb, var(--color-foreground) 78%, var(--color-muted-foreground)); font-size: .76rem; line-height: 1.38; }
|
||||
.ha-progress-row { display: flex; align-items: center; gap: .55rem; margin-top: 0; }
|
||||
.ha-progress-track { flex: 1; height: .48rem; border: 1px solid color-mix(in srgb, var(--ha-tier) 34%, var(--color-border)); background: rgba(0,0,0,.22); overflow: hidden; }
|
||||
.ha-progress-fill { height: 100%; background: linear-gradient(90deg, var(--ha-tier), color-mix(in srgb, var(--ha-tier) 48%, white)); }
|
||||
.ha-progress-text { min-width: 5.4rem; text-align: right; font-family: var(--font-mono, ui-monospace, monospace); color: var(--color-muted-foreground); font-size: .72rem; }
|
||||
.ha-evidence-slot { min-height: 1.65rem; margin-top: auto; display: flex; align-items: flex-end; }
|
||||
.ha-evidence { width: 100%; display: flex; align-items: center; gap: .4rem; color: var(--color-muted-foreground); font-size: .72rem; min-width: 0; }
|
||||
.ha-evidence-label { text-transform: uppercase; letter-spacing: .09em; font-family: var(--font-mono, ui-monospace, monospace); flex: 0 0 auto; }
|
||||
.ha-evidence-title { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: color-mix(in srgb, var(--color-foreground) 84%, var(--color-muted-foreground)); }
|
||||
.ha-evidence-empty { visibility: hidden; }
|
||||
.ha-latest h2 { margin: 0 0 .5rem; font-size: 1rem; }
|
||||
.ha-latest-row { display: flex; gap: .5rem; flex-wrap: wrap; }
|
||||
.ha-chip { display: inline-flex; align-items: center; gap: .35rem; border: 1px solid var(--ha-tier); color: var(--ha-tier); background: color-mix(in srgb, var(--ha-tier) 10%, transparent); padding: .35rem .55rem; font-size: .8rem; }
|
||||
.ha-chip-icon .ha-lucide { width: .95rem; height: .95rem; }
|
||||
.ha-slot { border-style: dashed; }
|
||||
.ha-slot-content { display: flex; gap: .6rem; align-items: center; padding: .65rem .8rem !important; font-size: .82rem; }
|
||||
.ha-slot-star { color: #67e8f9; }
|
||||
.ha-slot-muted { color: var(--color-muted-foreground); margin-left: auto; }
|
||||
.ha-error { border-color: #ef4444; color: #fecaca; }
|
||||
.ha-loading { color: var(--color-muted-foreground); font-family: var(--font-mono, ui-monospace, monospace); padding: 2rem; border: 1px dashed var(--color-border); }
|
||||
.ha-guide { display: grid; grid-template-columns: minmax(0, 1.15fr) minmax(0, .85fr); gap: .75rem; }
|
||||
.ha-guide > div { border: 1px solid var(--color-border); background: color-mix(in srgb, var(--color-card) 82%, transparent); padding: .85rem 1rem; }
|
||||
.ha-guide strong { display: block; margin-bottom: .45rem; font-size: .78rem; text-transform: uppercase; letter-spacing: .12em; font-family: var(--font-mono, ui-monospace, monospace); }
|
||||
.ha-guide p { margin: 0; color: var(--color-muted-foreground); font-size: .84rem; line-height: 1.45; }
|
||||
.ha-tier-legend { display: flex; align-items: center; gap: .45rem; flex-wrap: wrap; }
|
||||
.ha-tier-step { --ha-tier: var(--color-border); display: inline-flex; align-items: center; gap: .32rem; color: var(--ha-tier); border: 1px solid color-mix(in srgb, var(--ha-tier) 52%, var(--color-border)); background: color-mix(in srgb, var(--ha-tier) 8%, transparent); padding: .28rem .45rem; font-size: .72rem; font-family: var(--font-mono, ui-monospace, monospace); text-transform: uppercase; letter-spacing: .06em; }
|
||||
.ha-tier-step i { width: .55rem; height: .55rem; background: var(--ha-tier); display: inline-block; }
|
||||
.ha-tier-arrow { color: var(--color-muted-foreground); }
|
||||
.ha-state-discovered { opacity: .92; }
|
||||
.ha-state-discovered .ha-card-title { color: color-mix(in srgb, var(--color-foreground) 82%, var(--ha-tier)); }
|
||||
.ha-state-secret { opacity: .5; filter: grayscale(.55); }
|
||||
.ha-state-secret:after { content: ""; position: absolute; inset: 0; pointer-events: none; background: repeating-linear-gradient(-45deg, transparent 0 8px, rgba(255,255,255,.035) 8px 10px); }
|
||||
.ha-tier-pending { --ha-tier: color-mix(in srgb, var(--color-muted-foreground) 64%, transparent); }
|
||||
.ha-tier-copper { --ha-tier: #b87333; }
|
||||
.ha-tier-silver { --ha-tier: #c0c7d2; }
|
||||
.ha-tier-gold { --ha-tier: #f2c94c; box-shadow: 0 0 22px rgba(242,201,76,.08); }
|
||||
.ha-tier-diamond { --ha-tier: #67e8f9; box-shadow: 0 0 24px rgba(103,232,249,.1); }
|
||||
.ha-tier-olympian { --ha-tier: #c084fc; box-shadow: 0 0 34px rgba(192,132,252,.18), 0 0 12px rgba(242,201,76,.1); }
|
||||
@media (max-width: 980px) { .ha-stats { grid-template-columns: repeat(2, minmax(0, 1fr)); } .ha-guide { grid-template-columns: 1fr; } }
|
||||
@media (max-width: 800px) { .ha-stats { grid-template-columns: 1fr; } .ha-hero { flex-direction: column; align-items: stretch; } .ha-card-head { grid-template-columns: 3.1rem 1fr; } .ha-badges { grid-column: 1 / -1; align-items: flex-start; flex-direction: row; } }
|
||||
|
||||
.ha-secret-empty-content { padding: 1rem !important; }
|
||||
.ha-secret-empty strong { display: block; margin-bottom: .35rem; }
|
||||
.ha-secret-empty p { margin: 0; color: var(--color-muted-foreground); font-size: .86rem; line-height: 1.45; }
|
||||
.ha-page-loading { animation: ha-fade-in .18s ease-out; }
|
||||
.ha-loading-hero { align-items: center; }
|
||||
.ha-scan-status { position: relative; z-index: 1; display: flex; align-items: center; gap: .8rem; min-width: 18rem; border: 1px solid color-mix(in srgb, #67e8f9 35%, var(--color-border)); background: color-mix(in srgb, var(--color-card) 78%, transparent); padding: .8rem .95rem; color: var(--color-foreground); }
|
||||
.ha-scan-status strong { display: block; font-size: .82rem; text-transform: uppercase; letter-spacing: .1em; font-family: var(--font-mono, ui-monospace, monospace); }
|
||||
.ha-scan-status p { margin: .25rem 0 0; font-size: .78rem; line-height: 1.35; color: var(--color-muted-foreground); }
|
||||
.ha-scan-pulse { width: .72rem; height: .72rem; flex: 0 0 auto; border-radius: 999px; background: #67e8f9; box-shadow: 0 0 0 0 rgba(103,232,249,.55); animation: ha-pulse 1.35s ease-out infinite; }
|
||||
.ha-skeleton-card { pointer-events: none; }
|
||||
.ha-skeleton { position: relative; overflow: hidden; border-radius: 0; background: color-mix(in srgb, var(--color-muted-foreground) 16%, transparent); }
|
||||
.ha-skeleton:after { content: ""; position: absolute; inset: 0; transform: translateX(-100%); background: linear-gradient(90deg, transparent, rgba(255,255,255,.14), transparent); animation: ha-shimmer 1.35s infinite; }
|
||||
.ha-skeleton-stack { display: flex; flex-direction: column; gap: .45rem; padding-top: .15rem; }
|
||||
.ha-skeleton-icon { width: 2.9rem; height: 2.9rem; }
|
||||
.ha-skeleton-title { width: 72%; height: .95rem; }
|
||||
.ha-skeleton-meta { width: 45%; height: .65rem; }
|
||||
.ha-skeleton-badge { width: 4.4rem; height: 1.05rem; }
|
||||
.ha-skeleton-badge-short { width: 3.6rem; }
|
||||
.ha-skeleton-line { height: .78rem; width: 92%; }
|
||||
.ha-skeleton-line-short { width: 68%; }
|
||||
.ha-skeleton-criteria { height: 2.2rem; width: 100%; border: 1px solid color-mix(in srgb, var(--color-muted-foreground) 18%, var(--color-border)); }
|
||||
.ha-skeleton-evidence { width: 58%; height: .8rem; }
|
||||
.ha-skeleton-progress { flex: 1; height: .48rem; }
|
||||
.ha-skeleton-progress-text { width: 4.6rem; height: .75rem; }
|
||||
.ha-skeleton-stat-value { width: 56%; height: 1.35rem; margin-top: .55rem; }
|
||||
.ha-skeleton-stat-hint { width: 76%; height: .7rem; margin-top: .55rem; }
|
||||
.ha-loading-guide p { color: var(--color-muted-foreground); }
|
||||
@keyframes ha-shimmer { 100% { transform: translateX(100%); } }
|
||||
@keyframes ha-pulse { 0% { box-shadow: 0 0 0 0 rgba(103,232,249,.48); } 70% { box-shadow: 0 0 0 .65rem rgba(103,232,249,0); } 100% { box-shadow: 0 0 0 0 rgba(103,232,249,0); } }
|
||||
@keyframes ha-fade-in { from { opacity: 0; transform: translateY(3px); } to { opacity: 1; transform: translateY(0); } }
|
||||
.ha-loading-hero p, .ha-scan-status p, .ha-loading-guide p { text-transform: none; letter-spacing: normal; }
|
||||
|
||||
/* In-progress scan banner — shown on the main page while the background scan
|
||||
* is still walking through session history, so the user sees continuous
|
||||
* progress (X / Y sessions · Z%) instead of guessing whether anything is
|
||||
* happening. Reuses .ha-scan-pulse + ha-pulse keyframes from the loading page.
|
||||
*/
|
||||
.ha-scan-banner { display: flex; flex-direction: column; gap: .6rem; border: 1px solid color-mix(in srgb, #67e8f9 35%, var(--color-border)); background: color-mix(in srgb, var(--color-card) 78%, transparent); padding: .8rem .95rem; animation: ha-fade-in .18s ease-out; }
|
||||
.ha-scan-banner-head { display: flex; align-items: center; gap: .8rem; }
|
||||
.ha-scan-banner-text strong { display: block; font-size: .82rem; text-transform: uppercase; letter-spacing: .1em; font-family: var(--font-mono, ui-monospace, monospace); color: var(--color-foreground); }
|
||||
.ha-scan-banner-text p { margin: .25rem 0 0; font-size: .78rem; line-height: 1.35; color: var(--color-muted-foreground); text-transform: none; letter-spacing: normal; }
|
||||
.ha-scan-progress-track { height: .4rem; border: 1px solid color-mix(in srgb, #67e8f9 28%, var(--color-border)); background: rgba(0,0,0,.22); overflow: hidden; }
|
||||
.ha-scan-progress-fill { height: 100%; background: linear-gradient(90deg, #67e8f9, color-mix(in srgb, #67e8f9 48%, white)); transition: width .4s ease-out; }
|
||||
|
||||
/* Share achievement — trigger button on unlocked cards + modal dialog.
|
||||
* Added to the vendored bundle (on top of the upstream PCinkusz base).
|
||||
* Canvas rendering is pure client-side, no backend, no network.
|
||||
*/
|
||||
.ha-share-trigger { border: 1px solid color-mix(in srgb, var(--ha-tier) 58%, var(--color-border)); color: var(--ha-tier); background: color-mix(in srgb, var(--ha-tier) 8%, transparent); padding: .18rem .42rem; font-size: .66rem; text-transform: uppercase; letter-spacing: .08em; font-family: var(--font-mono, ui-monospace, monospace); cursor: pointer; margin-top: .05rem; transition: background .12s ease, border-color .12s ease; }
|
||||
.ha-share-trigger:hover { background: color-mix(in srgb, var(--ha-tier) 20%, transparent); border-color: var(--ha-tier); }
|
||||
.ha-share-trigger:focus-visible { outline: 2px solid var(--ha-tier); outline-offset: 2px; }
|
||||
|
||||
.ha-share-backdrop { position: fixed; inset: 0; z-index: 1000; background: rgba(4,6,10,.72); backdrop-filter: blur(6px); display: flex; align-items: center; justify-content: center; padding: 1.5rem; animation: ha-fade-in .14s ease-out; }
|
||||
.ha-share-dialog { width: min(760px, 100%); max-height: calc(100vh - 3rem); overflow: auto; border: 1px solid color-mix(in srgb, var(--color-border) 70%, var(--color-ring)); background: color-mix(in srgb, var(--color-card) 94%, #000); box-shadow: 0 24px 60px rgba(0,0,0,.55); display: flex; flex-direction: column; gap: .9rem; padding: 1rem 1.1rem 1.1rem; }
|
||||
.ha-share-head { display: flex; align-items: center; justify-content: space-between; gap: .75rem; }
|
||||
.ha-share-head strong { font-size: .82rem; text-transform: uppercase; letter-spacing: .1em; font-family: var(--font-mono, ui-monospace, monospace); color: var(--color-foreground); }
|
||||
.ha-share-close { width: 1.9rem; height: 1.9rem; display: grid; place-items: center; border: 1px solid var(--color-border); background: transparent; color: var(--color-muted-foreground); font-size: 1.1rem; cursor: pointer; line-height: 1; }
|
||||
.ha-share-close:hover { color: var(--color-foreground); border-color: var(--color-ring); }
|
||||
.ha-share-preview { position: relative; border: 1px solid var(--color-border); background: #0b0d11; overflow: hidden; aspect-ratio: 1200 / 630; }
|
||||
.ha-share-preview img { display: block; width: 100%; height: 100%; object-fit: contain; }
|
||||
.ha-share-placeholder { position: absolute; inset: 0; display: grid; place-items: center; color: var(--color-muted-foreground); font-family: var(--font-mono, ui-monospace, monospace); font-size: .82rem; text-transform: uppercase; letter-spacing: .1em; animation: ha-pulse 1.4s ease-in-out infinite; border-radius: 0; }
|
||||
.ha-share-error { border: 1px solid #ef4444; color: #fecaca; background: color-mix(in srgb, #ef4444 10%, transparent); padding: .55rem .7rem; font-size: .78rem; font-family: var(--font-mono, ui-monospace, monospace); }
|
||||
.ha-share-actions { display: flex; gap: .55rem; flex-wrap: wrap; }
|
||||
.ha-share-btn { border: 1px solid var(--color-border); background: color-mix(in srgb, var(--color-card) 72%, transparent); color: var(--color-foreground); padding: .5rem .85rem; font-size: .82rem; font-family: var(--font-mono, ui-monospace, monospace); text-transform: uppercase; letter-spacing: .08em; cursor: pointer; transition: border-color .12s ease, background .12s ease; }
|
||||
.ha-share-btn:hover:not(:disabled) { border-color: var(--color-ring); background: color-mix(in srgb, var(--color-primary) 16%, var(--color-card)); }
|
||||
.ha-share-btn:disabled { opacity: .5; cursor: not-allowed; }
|
||||
.ha-share-btn-primary { border-color: #ffffff; color: #ffffff; background: #000000; }
|
||||
.ha-share-btn-primary:hover:not(:disabled) { background: #1a1a1a; border-color: #67e8f9; color: #67e8f9; }
|
||||
.ha-share-hint { margin: 0; color: var(--color-muted-foreground); font-size: .76rem; line-height: 1.45; }
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "hermes-achievements",
|
||||
"label": "Achievements",
|
||||
"description": "Steam-style achievements for vibe coding and agentic Hermes workflows.",
|
||||
"icon": "Star",
|
||||
"version": "0.4.0",
|
||||
"tab": { "path": "/achievements", "position": "after:analytics" },
|
||||
"entry": "dist/index.js",
|
||||
"css": "dist/style.css",
|
||||
"api": "plugin_api.py"
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 1.4 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.3 MiB |
@@ -0,0 +1,171 @@
|
||||
import importlib.util
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
MODULE_PATH = Path(__file__).resolve().parents[1] / "dashboard" / "plugin_api.py"
|
||||
spec = importlib.util.spec_from_file_location("plugin_api", MODULE_PATH)
|
||||
plugin_api = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(plugin_api)
|
||||
|
||||
|
||||
class AchievementEngineTests(unittest.TestCase):
|
||||
def test_tool_call_stats_detect_tool_names_and_errors(self):
|
||||
messages = [
|
||||
{"role": "assistant", "tool_calls": [{"function": {"name": "terminal"}}]},
|
||||
{"role": "tool", "tool_name": "terminal", "content": "Error: port 3000 already in use"},
|
||||
{"role": "assistant", "tool_calls": [{"function": {"name": "web_search"}}]},
|
||||
]
|
||||
|
||||
stats = plugin_api.analyze_messages("s1", "Fix dev server", messages)
|
||||
|
||||
self.assertEqual(stats["tool_call_count"], 2)
|
||||
self.assertEqual(stats["tool_names"], {"terminal", "web_search"})
|
||||
self.assertEqual(stats["error_count"], 1)
|
||||
self.assertIs(stats["port_conflict"], True)
|
||||
|
||||
def test_tiered_achievement_reaches_highest_matching_tier(self):
|
||||
definition = {
|
||||
"id": "let_him_cook",
|
||||
"threshold_metric": "max_tool_calls_in_session",
|
||||
"tiers": [
|
||||
{"name": "Copper", "threshold": 10},
|
||||
{"name": "Silver", "threshold": 25},
|
||||
{"name": "Gold", "threshold": 50},
|
||||
],
|
||||
}
|
||||
aggregate = {"max_tool_calls_in_session": 28}
|
||||
|
||||
result = plugin_api.evaluate_tiered(definition, aggregate)
|
||||
|
||||
self.assertIs(result["unlocked"], True)
|
||||
self.assertEqual(result["tier"], "Silver")
|
||||
self.assertEqual(result["progress"], 28)
|
||||
self.assertEqual(result["next_tier"], "Gold")
|
||||
|
||||
def test_tiered_achievement_can_be_discovered_without_unlocking(self):
|
||||
definition = {
|
||||
"id": "terminal_goblin",
|
||||
"threshold_metric": "total_terminal_calls",
|
||||
"tiers": [{"name": "Copper", "threshold": 50}],
|
||||
}
|
||||
aggregate = {"total_terminal_calls": 12}
|
||||
|
||||
result = plugin_api.evaluate_tiered(definition, aggregate)
|
||||
|
||||
self.assertIs(result["unlocked"], False)
|
||||
self.assertIs(result["discovered"], True)
|
||||
self.assertEqual(result["state"], "discovered")
|
||||
self.assertEqual(result["progress"], 12)
|
||||
self.assertEqual(result["next_threshold"], 50)
|
||||
|
||||
def test_secret_achievement_stays_hidden_without_progress(self):
|
||||
definition = {
|
||||
"id": "permission_denied_any_percent",
|
||||
"name": "Permission Denied Any%",
|
||||
"secret": True,
|
||||
"requirements": [{"metric": "permission_denied_events", "gte": 3}],
|
||||
}
|
||||
aggregate = {"permission_denied_events": 0}
|
||||
|
||||
result = plugin_api.evaluate_requirements(definition, aggregate)
|
||||
display = plugin_api.display_achievement({**definition, **result})
|
||||
|
||||
self.assertEqual(result["state"], "secret")
|
||||
self.assertEqual(display["name"], "???")
|
||||
self.assertNotIn("Permission", display["description"])
|
||||
|
||||
def test_multi_condition_unlock_requires_all_requirements(self):
|
||||
definition = {
|
||||
"id": "full_send",
|
||||
"requirements": [
|
||||
{"metric": "max_terminal_calls_in_session", "gte": 10},
|
||||
{"metric": "max_file_tool_calls_in_session", "gte": 5},
|
||||
{"metric": "max_web_calls_in_session", "gte": 2},
|
||||
],
|
||||
}
|
||||
|
||||
partial = plugin_api.evaluate_requirements(definition, {
|
||||
"max_terminal_calls_in_session": 12,
|
||||
"max_file_tool_calls_in_session": 2,
|
||||
"max_web_calls_in_session": 0,
|
||||
})
|
||||
complete = plugin_api.evaluate_requirements(definition, {
|
||||
"max_terminal_calls_in_session": 12,
|
||||
"max_file_tool_calls_in_session": 6,
|
||||
"max_web_calls_in_session": 2,
|
||||
})
|
||||
|
||||
self.assertEqual(partial["state"], "discovered")
|
||||
self.assertIs(partial["unlocked"], False)
|
||||
self.assertLess(partial["progress_pct"], 100)
|
||||
self.assertEqual(complete["state"], "unlocked")
|
||||
self.assertIs(complete["unlocked"], True)
|
||||
|
||||
def test_catalog_has_60_plus_unique_achievements(self):
|
||||
ids = [achievement["id"] for achievement in plugin_api.ACHIEVEMENTS]
|
||||
self.assertGreaterEqual(len(ids), 60)
|
||||
self.assertEqual(len(ids), len(set(ids)))
|
||||
|
||||
def test_model_provider_metrics_are_aggregated(self):
|
||||
sessions = [
|
||||
{"model_names": {"openai/gpt-5", "anthropic/claude-sonnet-4"}},
|
||||
{"model_names": {"google/gemini-pro", "mistral/large"}},
|
||||
{"model_names": {"qwen/qwen3"}},
|
||||
]
|
||||
|
||||
aggregate = plugin_api.aggregate_stats(sessions)
|
||||
|
||||
self.assertEqual(aggregate["distinct_model_count"], 5)
|
||||
self.assertEqual(aggregate["distinct_provider_count"], 5)
|
||||
result = plugin_api.evaluate_definition(
|
||||
next(a for a in plugin_api.ACHIEVEMENTS if a["id"] == "five_model_flight"),
|
||||
aggregate,
|
||||
)
|
||||
self.assertEqual(result["state"], "unlocked")
|
||||
self.assertEqual(result["tier"], "Copper")
|
||||
|
||||
def test_removed_noisy_achievements_are_not_in_catalog(self):
|
||||
ids = {achievement["id"] for achievement in plugin_api.ACHIEVEMENTS}
|
||||
self.assertNotIn("fallback_pilot", ids)
|
||||
self.assertNotIn("browser_sleuth", ids)
|
||||
self.assertNotIn("release_ritualist", ids)
|
||||
|
||||
def test_open_weights_pilgrim_counts_only_local_model_metadata(self):
|
||||
aggregate_mentions_only = plugin_api.aggregate_stats([
|
||||
{"model_names": {"openai/gpt-5"}, "local_model_events": 999},
|
||||
])
|
||||
aggregate_local_chat = plugin_api.aggregate_stats([
|
||||
{"model_names": {"openai/gpt-5"}},
|
||||
{"model_names": {"ollama/llama3"}},
|
||||
])
|
||||
definition = next(a for a in plugin_api.ACHIEVEMENTS if a["id"] == "open_weights_pilgrim")
|
||||
|
||||
self.assertEqual(aggregate_mentions_only["local_model_chat_sessions"], 0)
|
||||
self.assertEqual(plugin_api.evaluate_definition(definition, aggregate_mentions_only)["state"], "discovered")
|
||||
self.assertEqual(aggregate_local_chat["local_model_chat_sessions"], 1)
|
||||
self.assertEqual(plugin_api.evaluate_definition(definition, aggregate_local_chat)["state"], "unlocked")
|
||||
|
||||
def test_config_surgeon_ignores_generic_config_mentions(self):
|
||||
stats = plugin_api.analyze_messages("s1", "Config talk", [{"content": "config config configuration not configured"}])
|
||||
self.assertEqual(stats["config_events"], 0)
|
||||
stats = plugin_api.analyze_messages("s2", "Real config", [{"content": "edited config.yaml, manifest.json, and .env.local"}])
|
||||
self.assertGreaterEqual(stats["config_events"], 3)
|
||||
|
||||
def test_dashboard_card_hover_does_not_move_click_target(self):
|
||||
style_css = (
|
||||
Path(__file__).resolve().parents[1]
|
||||
/ "dashboard"
|
||||
/ "dist"
|
||||
/ "style.css"
|
||||
).read_text(encoding="utf-8")
|
||||
|
||||
hover_rule = next(
|
||||
line for line in style_css.splitlines() if line.startswith(".ha-card:hover")
|
||||
)
|
||||
self.assertNotIn("transform:", hover_rule)
|
||||
self.assertIn("border-color: var(--ha-tier)", hover_rule)
|
||||
self.assertIn("box-shadow:", hover_rule)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,336 @@
|
||||
"""DeepInfra image generation backend.
|
||||
|
||||
Exposes DeepInfra's image-gen catalog (FLUX, Qwen-Image-Edit, …) through
|
||||
the OpenAI-compatible ``/v1/openai/images/generations`` endpoint as an
|
||||
:class:`ImageGenProvider` implementation.
|
||||
|
||||
**Fully dynamic model discovery.** Unlike the other image-gen plugins in
|
||||
this tree (which ship a hardcoded ``_MODELS`` dict), DeepInfra publishes
|
||||
a single tagged catalog at
|
||||
``https://api.deepinfra.com/v1/openai/models?filter=true&sort_by=hermes``
|
||||
where each entry's ``metadata.tags`` declares its surface (``image-gen``
|
||||
here). ``list_models()`` filters that catalog via
|
||||
:func:`hermes_cli.models._fetch_deepinfra_models_by_tag` so newly added
|
||||
models show up in ``hermes tools`` automatically. No model ids are
|
||||
hardcoded in this file — if a model is retired upstream, it disappears
|
||||
from hermes the next time the catalog is fetched, no patch required.
|
||||
|
||||
Model selection (first hit wins):
|
||||
|
||||
1. ``DEEPINFRA_IMAGE_MODEL`` env var
|
||||
2. ``image_gen.deepinfra.model`` in ``config.yaml``
|
||||
3. First model from the live catalog
|
||||
|
||||
When all three are absent (catalog unreachable, nothing configured),
|
||||
``generate()`` returns an :func:`error_response` rather than guessing.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from agent.secret_scope import get_secret
|
||||
from agent.image_gen_provider import (
|
||||
DEFAULT_ASPECT_RATIO,
|
||||
ImageGenProvider,
|
||||
error_response,
|
||||
resolve_aspect_ratio,
|
||||
save_b64_image,
|
||||
save_url_image,
|
||||
success_response,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# DeepInfra accepts standard OpenAI ``size`` strings. Mirrors the
|
||||
# OpenAI plugin's mapping so aspect_ratio semantics stay consistent
|
||||
# across the agent's image_generate tool surface.
|
||||
_SIZES = {
|
||||
"landscape": "1536x1024",
|
||||
"square": "1024x1024",
|
||||
"portrait": "1024x1536",
|
||||
}
|
||||
|
||||
|
||||
def _load_deepinfra_image_config() -> Dict[str, Any]:
|
||||
"""Read ``image_gen.deepinfra`` from config.yaml."""
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
|
||||
cfg = load_config()
|
||||
section = cfg.get("image_gen") if isinstance(cfg, dict) else None
|
||||
di_section = section.get("deepinfra") if isinstance(section, dict) else None
|
||||
return di_section if isinstance(di_section, dict) else {}
|
||||
except Exception as exc:
|
||||
logger.debug("Could not load image_gen.deepinfra config: %s", exc)
|
||||
return {}
|
||||
|
||||
|
||||
def _live_models() -> Optional[List[Dict[str, Any]]]:
|
||||
"""Fetch ``image-gen``-tagged models from the DeepInfra catalog."""
|
||||
try:
|
||||
from hermes_cli.models import _fetch_deepinfra_models_by_tag
|
||||
except Exception as exc:
|
||||
logger.debug("Cannot import _fetch_deepinfra_models_by_tag: %s", exc)
|
||||
return None
|
||||
return _fetch_deepinfra_models_by_tag("image-gen")
|
||||
|
||||
|
||||
def _format_catalog_row(item: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Format a catalog item into the picker row shape."""
|
||||
mid = item.get("id", "")
|
||||
metadata = item.get("metadata") or {}
|
||||
pricing = metadata.get("pricing") if isinstance(metadata, dict) else None
|
||||
price = ""
|
||||
if isinstance(pricing, dict) and pricing.get("per_image_unit") is not None:
|
||||
try:
|
||||
price = f"${float(pricing['per_image_unit']):.4f}/image"
|
||||
except (TypeError, ValueError):
|
||||
price = ""
|
||||
row: Dict[str, Any] = {
|
||||
"id": mid,
|
||||
"display": mid.split("/", 1)[-1] if "/" in mid else mid,
|
||||
"strengths": metadata.get("description", "") if isinstance(metadata, dict) else "",
|
||||
}
|
||||
if price:
|
||||
row["price"] = price
|
||||
if isinstance(metadata, dict):
|
||||
for key in ("default_width", "default_height", "default_iterations"):
|
||||
if metadata.get(key) is not None:
|
||||
row[key] = metadata[key]
|
||||
return row
|
||||
|
||||
|
||||
def _resolve_model(catalog: List[Dict[str, Any]], cfg: Dict[str, Any]) -> Optional[str]:
|
||||
"""Pick the model id (env > config > first live result, else None).
|
||||
|
||||
Takes the already-loaded ``image_gen.deepinfra`` config so ``generate()``
|
||||
reads config once instead of via a second ``load_config`` deepcopy.
|
||||
"""
|
||||
env_override = os.environ.get("DEEPINFRA_IMAGE_MODEL", "").strip()
|
||||
if env_override:
|
||||
return env_override
|
||||
cfg_model = cfg.get("model") if isinstance(cfg, dict) else None
|
||||
if isinstance(cfg_model, str) and cfg_model.strip():
|
||||
return cfg_model.strip()
|
||||
if catalog:
|
||||
first = catalog[0].get("id")
|
||||
if isinstance(first, str) and first:
|
||||
return first
|
||||
return None
|
||||
|
||||
|
||||
class DeepInfraImageGenProvider(ImageGenProvider):
|
||||
"""DeepInfra ``images.generations`` backend.
|
||||
|
||||
Catalog is discovered live from the DeepInfra ``/models`` endpoint
|
||||
filtered by the ``image-gen`` surface tag.
|
||||
"""
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "deepinfra"
|
||||
|
||||
@property
|
||||
def display_name(self) -> str:
|
||||
return "DeepInfra"
|
||||
|
||||
def is_available(self) -> bool:
|
||||
return bool((get_secret("DEEPINFRA_API_KEY", "") or "").strip())
|
||||
|
||||
def list_models(self) -> List[Dict[str, Any]]:
|
||||
live = _live_models()
|
||||
if not live:
|
||||
return []
|
||||
return [_format_catalog_row(item) for item in live]
|
||||
|
||||
def default_model(self) -> Optional[str]:
|
||||
rows = self.list_models()
|
||||
if rows:
|
||||
return rows[0].get("id")
|
||||
return None
|
||||
|
||||
def capabilities(self) -> Dict[str, Any]:
|
||||
"""DeepInfra's OpenAI-compatible generation surface is text-only."""
|
||||
return {"modalities": ["text"], "max_reference_images": 0}
|
||||
|
||||
def get_setup_schema(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"name": "DeepInfra",
|
||||
"badge": "paid",
|
||||
"tag": "FLUX, Qwen-Image, … — live catalog from api.deepinfra.com",
|
||||
"env_vars": [
|
||||
{
|
||||
"key": "DEEPINFRA_API_KEY",
|
||||
"prompt": "DeepInfra API key",
|
||||
"url": "https://deepinfra.com/dash/api_keys",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
def generate(
|
||||
self,
|
||||
prompt: str,
|
||||
aspect_ratio: str = DEFAULT_ASPECT_RATIO,
|
||||
**kwargs: Any,
|
||||
) -> Dict[str, Any]:
|
||||
prompt = (prompt or "").strip()
|
||||
aspect = resolve_aspect_ratio(aspect_ratio)
|
||||
|
||||
if kwargs.get("image_url") or kwargs.get("reference_image_urls"):
|
||||
return error_response(
|
||||
error=(
|
||||
"DeepInfra image generation is text-to-image only in this "
|
||||
"backend; image_url and reference_image_urls are unsupported."
|
||||
),
|
||||
error_type="modality_unsupported",
|
||||
provider="deepinfra",
|
||||
prompt=prompt,
|
||||
aspect_ratio=aspect,
|
||||
)
|
||||
|
||||
if not prompt:
|
||||
return error_response(
|
||||
error="Prompt is required and must be a non-empty string",
|
||||
error_type="invalid_argument",
|
||||
provider="deepinfra",
|
||||
aspect_ratio=aspect,
|
||||
)
|
||||
|
||||
api_key = (get_secret("DEEPINFRA_API_KEY", "") or "").strip()
|
||||
if not api_key:
|
||||
return error_response(
|
||||
error=(
|
||||
"DEEPINFRA_API_KEY not set. Run `hermes tools` → Image "
|
||||
"Generation → DeepInfra to configure, or `hermes setup` "
|
||||
"to add the key."
|
||||
),
|
||||
error_type="auth_required",
|
||||
provider="deepinfra",
|
||||
aspect_ratio=aspect,
|
||||
)
|
||||
|
||||
di_cfg = _load_deepinfra_image_config()
|
||||
catalog = _live_models() or []
|
||||
model_id = _resolve_model(catalog, di_cfg)
|
||||
if not model_id:
|
||||
return error_response(
|
||||
error=(
|
||||
"No DeepInfra image-gen model available. Pin one in "
|
||||
"config.yaml under image_gen.deepinfra.model, set "
|
||||
"DEEPINFRA_IMAGE_MODEL, or check connectivity to "
|
||||
"api.deepinfra.com so the live catalog can be fetched."
|
||||
),
|
||||
error_type="no_model_available",
|
||||
provider="deepinfra",
|
||||
prompt=prompt,
|
||||
aspect_ratio=aspect,
|
||||
)
|
||||
size = _SIZES.get(aspect, _SIZES["square"])
|
||||
from hermes_cli.models import deepinfra_base_url
|
||||
base_url = deepinfra_base_url(di_cfg)
|
||||
|
||||
# DeepInfra's /images/generations is OpenAI-compatible — use the
|
||||
# openai SDK so we inherit its retry, timeout, and error mapping
|
||||
# (mirrors the existing OpenAI image-gen plugin).
|
||||
try:
|
||||
import openai
|
||||
except ImportError:
|
||||
return error_response(
|
||||
error="openai Python package not installed (pip install openai)",
|
||||
error_type="missing_dependency",
|
||||
provider="deepinfra",
|
||||
aspect_ratio=aspect,
|
||||
)
|
||||
|
||||
client = openai.OpenAI(api_key=api_key, base_url=base_url)
|
||||
try:
|
||||
response = client.images.generate(
|
||||
model=model_id,
|
||||
prompt=prompt,
|
||||
size=size,
|
||||
n=1,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.debug("DeepInfra image generation failed", exc_info=True)
|
||||
return error_response(
|
||||
error=f"DeepInfra image generation failed: {exc}",
|
||||
error_type="api_error",
|
||||
provider="deepinfra",
|
||||
model=model_id,
|
||||
prompt=prompt,
|
||||
aspect_ratio=aspect,
|
||||
)
|
||||
finally:
|
||||
close = getattr(client, "close", None)
|
||||
if callable(close):
|
||||
close()
|
||||
|
||||
data = getattr(response, "data", None) or []
|
||||
if not data:
|
||||
return error_response(
|
||||
error="DeepInfra returned no image data",
|
||||
error_type="empty_response",
|
||||
provider="deepinfra",
|
||||
model=model_id,
|
||||
prompt=prompt,
|
||||
aspect_ratio=aspect,
|
||||
)
|
||||
|
||||
first = data[0]
|
||||
b64 = getattr(first, "b64_json", None)
|
||||
url = getattr(first, "url", None)
|
||||
|
||||
# Drop the ``vendor/`` prefix and any colons so the saved filename
|
||||
# stays a single path component on every OS.
|
||||
short = model_id.split("/", 1)[-1].replace(":", "_")
|
||||
|
||||
if b64:
|
||||
try:
|
||||
saved_path = save_b64_image(b64, prefix=f"deepinfra_{short}")
|
||||
except Exception as exc:
|
||||
return error_response(
|
||||
error=f"Could not save image to cache: {exc}",
|
||||
error_type="io_error",
|
||||
provider="deepinfra",
|
||||
model=model_id,
|
||||
prompt=prompt,
|
||||
aspect_ratio=aspect,
|
||||
)
|
||||
image_ref = str(saved_path)
|
||||
elif url:
|
||||
# Materialise the (often short-lived) delivery URL locally so a
|
||||
# downstream consumer (Telegram send_photo, browser fetch) doesn't
|
||||
# get a dead link — mirrors the openai/xai/krea image plugins.
|
||||
# Best-effort: fall back to the bare URL if the download fails.
|
||||
try:
|
||||
image_ref = str(save_url_image(url, prefix=f"deepinfra_{short}"))
|
||||
except Exception as exc:
|
||||
logger.debug("DeepInfra: caching delivery URL failed (%s); returning URL", exc)
|
||||
image_ref = url
|
||||
else:
|
||||
return error_response(
|
||||
error="DeepInfra response contained neither b64_json nor URL",
|
||||
error_type="empty_response",
|
||||
provider="deepinfra",
|
||||
model=model_id,
|
||||
prompt=prompt,
|
||||
aspect_ratio=aspect,
|
||||
)
|
||||
|
||||
return success_response(
|
||||
image=image_ref,
|
||||
model=model_id,
|
||||
prompt=prompt,
|
||||
aspect_ratio=aspect,
|
||||
provider="deepinfra",
|
||||
extra={"size": size},
|
||||
)
|
||||
|
||||
|
||||
def register(ctx) -> None:
|
||||
"""Plugin entry point — wire ``DeepInfraImageGenProvider`` into the registry."""
|
||||
ctx.register_image_gen_provider(DeepInfraImageGenProvider())
|
||||
@@ -0,0 +1,7 @@
|
||||
name: deepinfra
|
||||
version: 1.0.0
|
||||
description: "DeepInfra image generation backend (FLUX, Qwen-Image, …) via OpenAI-compatible /v1/images/generations. Catalog discovered live from api.deepinfra.com."
|
||||
author: Georgi Atsev
|
||||
kind: backend
|
||||
requires_env:
|
||||
- DEEPINFRA_API_KEY
|
||||
@@ -0,0 +1,218 @@
|
||||
"""FAL.ai image generation backend.
|
||||
|
||||
Wraps the 18-model FAL catalog (FLUX 2, Z-Image, Nano Banana, GPT
|
||||
Image 1.5, Recraft, Imagen 4, Qwen, Ideogram, …) as an
|
||||
:class:`ImageGenProvider` implementation.
|
||||
|
||||
The heavy lifting — model catalog, payload construction, request
|
||||
submission, managed-Nous-gateway selection, Clarity Upscaler chaining
|
||||
— lives in :mod:`tools.image_generation_tool`. This plugin reaches into
|
||||
that module via call-time indirection (``import tools.image_generation_tool as _it``)
|
||||
so:
|
||||
|
||||
* the existing test suite (``tests/tools/test_image_generation.py``,
|
||||
``tests/tools/test_managed_media_gateways.py``) keeps patching
|
||||
``image_tool._submit_fal_request`` / ``image_tool.fal_client`` /
|
||||
``image_tool._managed_fal_client`` without modification, and
|
||||
* there's exactly one canonical FAL code path on disk — the plugin is a
|
||||
registration adapter, not a parallel implementation.
|
||||
|
||||
See issue #26241 for the migration plan and the
|
||||
``plugin-extraction-test-patch-compatibility.md`` rules this follows.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from agent.image_gen_provider import (
|
||||
DEFAULT_ASPECT_RATIO,
|
||||
ImageGenProvider,
|
||||
resolve_aspect_ratio,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FalImageGenProvider(ImageGenProvider):
|
||||
"""FAL.ai image generation backend.
|
||||
|
||||
Delegates to ``tools.image_generation_tool.image_generate_tool`` so
|
||||
the in-tree FAL implementation (model catalog, payload builder,
|
||||
managed-gateway selection, Clarity Upscaler chaining) is the single
|
||||
source of truth. Everything is resolved at call time via the
|
||||
``_it`` indirection so tests can monkey-patch the legacy module.
|
||||
"""
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "fal"
|
||||
|
||||
@property
|
||||
def display_name(self) -> str:
|
||||
return "FAL.ai"
|
||||
|
||||
def is_available(self) -> bool:
|
||||
# Available when direct FAL_KEY is set OR the managed Nous
|
||||
# gateway resolves a fal-queue origin. Both checks come from the
|
||||
# legacy module so this provider tracks whatever logic ships
|
||||
# there.
|
||||
import tools.image_generation_tool as _it
|
||||
try:
|
||||
return bool(_it.check_fal_api_key())
|
||||
except Exception: # noqa: BLE001 — defensive; never break the picker
|
||||
return False
|
||||
|
||||
def list_models(self) -> List[Dict[str, Any]]:
|
||||
import tools.image_generation_tool as _it
|
||||
return [
|
||||
{
|
||||
"id": model_id,
|
||||
"display": meta.get("display", model_id),
|
||||
"speed": meta.get("speed", ""),
|
||||
"strengths": meta.get("strengths", ""),
|
||||
"price": meta.get("price", ""),
|
||||
}
|
||||
for model_id, meta in _it.FAL_MODELS.items()
|
||||
]
|
||||
|
||||
def default_model(self) -> Optional[str]:
|
||||
import tools.image_generation_tool as _it
|
||||
return _it.DEFAULT_MODEL
|
||||
|
||||
def get_setup_schema(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"name": "FAL.ai",
|
||||
"badge": "paid",
|
||||
"tag": "Pick from flux-2-klein, flux-2-pro, gpt-image, nano-banana-2, nano-banana-pro, etc. — text-to-image & image editing",
|
||||
"env_vars": [
|
||||
{
|
||||
"key": "FAL_KEY",
|
||||
"prompt": "FAL API key",
|
||||
"url": "https://fal.ai/dashboard/keys",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
def capabilities(self) -> Dict[str, Any]:
|
||||
# Whether image-to-image is available depends on the currently-
|
||||
# selected FAL model (each model entry declares an edit_endpoint or
|
||||
# not). Report the active model's actual surface so the dynamic tool
|
||||
# schema is accurate.
|
||||
import tools.image_generation_tool as _it
|
||||
|
||||
try:
|
||||
_model_id, meta = _it._resolve_fal_model()
|
||||
except Exception: # noqa: BLE001
|
||||
return {"modalities": ["text"], "max_reference_images": 0}
|
||||
# Clarity Upscaler chains on explicit request for any FAL model.
|
||||
if meta.get("edit_endpoint"):
|
||||
return {
|
||||
"modalities": ["text", "image"],
|
||||
"max_reference_images": int(meta.get("max_reference_images") or 1),
|
||||
"supports_upscale": True,
|
||||
}
|
||||
return {
|
||||
"modalities": ["text"],
|
||||
"max_reference_images": 0,
|
||||
"supports_upscale": True,
|
||||
}
|
||||
|
||||
def generate(
|
||||
self,
|
||||
prompt: str,
|
||||
aspect_ratio: str = DEFAULT_ASPECT_RATIO,
|
||||
*,
|
||||
image_url: Optional[str] = None,
|
||||
reference_image_urls: Optional[List[str]] = None,
|
||||
**kwargs: Any,
|
||||
) -> Dict[str, Any]:
|
||||
"""Generate or edit an image via the legacy FAL pipeline.
|
||||
|
||||
Forwards prompt + aspect_ratio + image_url/reference_image_urls (and
|
||||
any forward-compat extras the schema supports) into
|
||||
:func:`tools.image_generation_tool.image_generate_tool`, then reshapes
|
||||
its JSON-string response into the provider-ABC dict format consumed by
|
||||
``_dispatch_to_plugin_provider``.
|
||||
"""
|
||||
import tools.image_generation_tool as _it
|
||||
|
||||
aspect = resolve_aspect_ratio(aspect_ratio)
|
||||
passthrough = {
|
||||
key: kwargs[key]
|
||||
for key in (
|
||||
"num_inference_steps",
|
||||
"guidance_scale",
|
||||
"num_images",
|
||||
"output_format",
|
||||
"seed",
|
||||
"upscale",
|
||||
)
|
||||
if key in kwargs and kwargs[key] is not None
|
||||
}
|
||||
# Only forward the image-to-image inputs when actually supplied, so a
|
||||
# plain text-to-image call delegates exactly as it did before (no
|
||||
# noisy None kwargs).
|
||||
if image_url is not None:
|
||||
passthrough["image_url"] = image_url
|
||||
if reference_image_urls is not None:
|
||||
passthrough["reference_image_urls"] = reference_image_urls
|
||||
|
||||
try:
|
||||
raw = _it.image_generate_tool(
|
||||
prompt=prompt,
|
||||
aspect_ratio=aspect,
|
||||
**passthrough,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 — never raise out of generate
|
||||
logger.warning("FAL image_generate_tool raised: %s", exc, exc_info=True)
|
||||
return {
|
||||
"success": False,
|
||||
"image": None,
|
||||
"error": f"FAL image generation failed: {exc}",
|
||||
"error_type": type(exc).__name__,
|
||||
"provider": "fal",
|
||||
"prompt": prompt,
|
||||
"aspect_ratio": aspect,
|
||||
}
|
||||
|
||||
try:
|
||||
response = json.loads(raw) if isinstance(raw, str) else raw
|
||||
except Exception: # noqa: BLE001
|
||||
response = {"success": False, "image": None, "error": "Invalid JSON from FAL pipeline"}
|
||||
|
||||
if not isinstance(response, dict):
|
||||
response = {
|
||||
"success": False,
|
||||
"image": None,
|
||||
"error": "FAL pipeline returned a non-dict response",
|
||||
"error_type": "provider_contract",
|
||||
}
|
||||
|
||||
# Stamp provider/prompt/aspect_ratio so downstream consumers see
|
||||
# the uniform shape declared in ``agent.image_gen_provider``.
|
||||
response.setdefault("provider", "fal")
|
||||
response.setdefault("prompt", prompt)
|
||||
response.setdefault("aspect_ratio", aspect)
|
||||
# Annotate model best-effort — the legacy pipeline resolves it
|
||||
# internally, so query it after the fact for the response shape.
|
||||
if "model" not in response:
|
||||
try:
|
||||
model_id, _meta = _it._resolve_fal_model()
|
||||
response["model"] = model_id
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
return response
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Plugin entry point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def register(ctx) -> None:
|
||||
"""Plugin entry point — wire ``FalImageGenProvider`` into the registry."""
|
||||
ctx.register_image_gen_provider(FalImageGenProvider())
|
||||
@@ -0,0 +1,7 @@
|
||||
name: fal
|
||||
version: 1.0.0
|
||||
description: "FAL.ai image generation backend (flux-2-klein, flux-2-pro, nano-banana-2, nano-banana-pro, gpt-image-1.5, recraft-v3, etc.)."
|
||||
author: NousResearch
|
||||
kind: backend
|
||||
requires_env:
|
||||
- FAL_KEY
|
||||
@@ -0,0 +1,921 @@
|
||||
"""Krea image generation backend.
|
||||
|
||||
Exposes Krea's `Krea 2` foundation image model family — Krea 2 Medium and
|
||||
Krea 2 Large — as an :class:`ImageGenProvider` implementation.
|
||||
|
||||
Krea's API is asynchronous: the generate endpoint returns a ``job_id``
|
||||
that you poll at ``GET /jobs/{job_id}``. This provider hides that
|
||||
roundtrip behind the synchronous ``generate()`` contract: submit, poll
|
||||
every 2s with light backoff, materialise the result URL to local cache,
|
||||
return the success/error dict like every other backend.
|
||||
|
||||
Selection precedence (first hit wins):
|
||||
|
||||
1. ``KREA_IMAGE_MODEL`` env var (escape hatch for scripts / tests)
|
||||
2. ``image_gen.krea.model`` in ``config.yaml``
|
||||
3. ``image_gen.model`` in ``config.yaml`` (when it's one of our IDs)
|
||||
4. :data:`DEFAULT_MODEL` — ``krea-2-medium`` (Krea's "start here" recommendation)
|
||||
|
||||
Docs: https://docs.krea.ai/developers/krea-2/overview
|
||||
API: https://docs.krea.ai/api-reference/krea/krea-2-large
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
import uuid
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
import requests
|
||||
|
||||
from agent.secret_scope import get_secret
|
||||
from agent.image_gen_provider import (
|
||||
DEFAULT_ASPECT_RATIO,
|
||||
ImageGenProvider,
|
||||
error_response,
|
||||
normalize_reference_images,
|
||||
resolve_aspect_ratio,
|
||||
save_url_image,
|
||||
success_response,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
BASE_URL = "https://api.krea.ai"
|
||||
|
||||
# Map our short model IDs to Krea's URL path segment.
|
||||
_MODELS: Dict[str, Dict[str, Any]] = {
|
||||
"krea-2-medium": {
|
||||
"display": "Krea 2 Medium",
|
||||
"speed": "~15-25s",
|
||||
"strengths": "Illustration, anime, painting, expressive styles. Faster + cheaper.",
|
||||
"price": "$0.030 (text) / $0.035 (style refs) / $0.040 (moodboards)",
|
||||
"path": "medium",
|
||||
# Upscaling is opt-in everywhere (Aug 2026 policy: default-on
|
||||
# enhance passes degraded output quality).
|
||||
"upscale": False,
|
||||
},
|
||||
"krea-2-large": {
|
||||
"display": "Krea 2 Large",
|
||||
"speed": "~25-60s",
|
||||
"strengths": "Photorealism, raw textured looks (motion blur, grain), expressive styles.",
|
||||
"price": "$0.060 (text) / $0.065 (style refs) / $0.070 (moodboards)",
|
||||
"path": "large",
|
||||
# 2K native — high-res enough out of the box.
|
||||
"upscale": False,
|
||||
},
|
||||
"krea-2-medium-turbo": {
|
||||
"display": "Krea 2 Medium Turbo",
|
||||
"speed": "~8-15s",
|
||||
"strengths": "Fastest Krea 2 — medium quality at lower latency / cost.",
|
||||
"price": "$0.015 (text) / $0.0175 (style refs)",
|
||||
"path": "medium-turbo",
|
||||
# Opt-in only (Aug 2026 policy).
|
||||
"upscale": False,
|
||||
},
|
||||
}
|
||||
|
||||
DEFAULT_MODEL = "krea-2-medium"
|
||||
|
||||
# Hermes uses 3 abstract aspect ratios. Map to Krea's enum (which is wider).
|
||||
# Krea accepts: 1:1, 4:3, 3:2, 16:9, 2.35:1, 4:5, 2:3, 9:16
|
||||
_ASPECT_MAP = {
|
||||
"landscape": "16:9",
|
||||
"square": "1:1",
|
||||
"portrait": "9:16",
|
||||
}
|
||||
|
||||
# Only resolution Krea currently supports.
|
||||
DEFAULT_RESOLUTION = "1K"
|
||||
|
||||
# Krea's image_style_references entries are objects ({"url", "strength"}), not
|
||||
# bare URL strings. When the caller supplies a URL without an explicit strength
|
||||
# we apply Krea's recommended starting value. Range per Krea docs is -2..2.
|
||||
_DEFAULT_STYLE_REFERENCE_STRENGTH = 0.6
|
||||
|
||||
# Valid creativity levels per Krea docs. Default is "medium".
|
||||
_VALID_CREATIVITY = {"raw", "low", "medium", "high"}
|
||||
|
||||
# Polling cadence. Krea recommends 2-5s; we start at 2s and back off to 5s
|
||||
# for long jobs (Large can take ~1min). Total ceiling matches Krea's
|
||||
# hosted-tool timeout of 3 minutes.
|
||||
_POLL_INITIAL_INTERVAL = 2.0
|
||||
_POLL_MAX_INTERVAL = 5.0
|
||||
_POLL_BACKOFF = 1.3
|
||||
_POLL_TIMEOUT_SECONDS = 180.0
|
||||
|
||||
# HTTP statuses worth retrying during the poll loop. Everything else (401,
|
||||
# 402, 403, 404, other 4xx) is a permanent failure — surface it immediately
|
||||
# instead of burning the 180s deadline retrying a request that will never
|
||||
# succeed.
|
||||
_RETRYABLE_POLL_STATUSES = frozenset({408, 409, 425, 429, 500, 502, 503, 504})
|
||||
|
||||
_TERMINAL_STATES = {"completed", "failed", "cancelled"}
|
||||
|
||||
# Krea Enhance — the upscale/enhancer endpoint used for the optional
|
||||
# ``upscale`` pass after generation ("1.5K native, 4K via Enhancer" is
|
||||
# Krea's own pipeline shape). Cheap creative enhancer, max 8K.
|
||||
_ENHANCE_PATH = "/generate/enhance/krea/enhance"
|
||||
_ENHANCE_SCALE_FACTOR = 2
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Config
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _load_krea_config() -> Dict[str, Any]:
|
||||
"""Read ``image_gen.krea`` (with fallthrough to ``image_gen``) from config.yaml."""
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
|
||||
cfg = load_config()
|
||||
section = cfg.get("image_gen") if isinstance(cfg, dict) else None
|
||||
return section if isinstance(section, dict) else {}
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.debug("Could not load image_gen config: %s", exc)
|
||||
return {}
|
||||
|
||||
|
||||
def _resolve_model(explicit: Optional[str] = None) -> Tuple[str, Dict[str, Any]]:
|
||||
"""Decide which model to use and return ``(model_id, meta)``.
|
||||
|
||||
Precedence: explicit caller override (e.g. managed-mode routing or a direct
|
||||
``model`` kwarg) → ``KREA_IMAGE_MODEL`` env → ``image_gen.krea.model`` →
|
||||
``image_gen.model`` → :data:`DEFAULT_MODEL`.
|
||||
"""
|
||||
if isinstance(explicit, str) and explicit.strip() in _MODELS:
|
||||
return explicit.strip(), _MODELS[explicit.strip()]
|
||||
|
||||
env_override = os.environ.get("KREA_IMAGE_MODEL")
|
||||
if env_override and env_override in _MODELS:
|
||||
return env_override, _MODELS[env_override]
|
||||
|
||||
cfg = _load_krea_config()
|
||||
krea_cfg = cfg.get("krea") if isinstance(cfg.get("krea"), dict) else {}
|
||||
candidate: Optional[str] = None
|
||||
if isinstance(krea_cfg, dict):
|
||||
value = krea_cfg.get("model")
|
||||
if isinstance(value, str) and value in _MODELS:
|
||||
candidate = value
|
||||
if candidate is None:
|
||||
top = cfg.get("model")
|
||||
if isinstance(top, str) and top in _MODELS:
|
||||
candidate = top
|
||||
|
||||
if candidate is not None:
|
||||
return candidate, _MODELS[candidate]
|
||||
|
||||
return DEFAULT_MODEL, _MODELS[DEFAULT_MODEL]
|
||||
|
||||
|
||||
def _resolve_managed_krea_gateway():
|
||||
"""Return managed Krea gateway config when the user is on the managed path.
|
||||
|
||||
Strict selection model: the managed Krea gateway is used when the stored
|
||||
``image_gen`` selection is ``nous`` (or legacy ``use_gateway: true``), or
|
||||
on a never-configured install when no direct ``KREA_API_KEY`` exists.
|
||||
An explicit vendor selection (``krea``, ``fal``, ...) pins the direct
|
||||
path. Returns ``None`` (direct/BYO path) otherwise, and never raises —
|
||||
plugin discovery and availability scans must stay robust.
|
||||
"""
|
||||
try:
|
||||
from tools.managed_tool_gateway import resolve_managed_tool_gateway
|
||||
from tools.tool_backend_helpers import (
|
||||
NOUS_MANAGED_PROVIDER,
|
||||
read_selection,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.debug("Managed Krea gateway resolution unavailable: %s", exc)
|
||||
return None
|
||||
|
||||
try:
|
||||
selected = read_selection("image_gen")
|
||||
except Exception: # noqa: BLE001
|
||||
selected = None
|
||||
if selected is not None and selected != NOUS_MANAGED_PROVIDER:
|
||||
# Explicit vendor selection: direct credentials only.
|
||||
return None
|
||||
if selected is None and get_secret("KREA_API_KEY"):
|
||||
return None
|
||||
|
||||
try:
|
||||
return resolve_managed_tool_gateway("krea")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.debug("Managed Krea gateway resolution failed: %s", exc)
|
||||
return None
|
||||
|
||||
|
||||
def _managed_krea_gateway_ready() -> bool:
|
||||
"""Cheap, offline-friendly probe for managed Krea availability."""
|
||||
try:
|
||||
from tools.managed_tool_gateway import is_managed_tool_gateway_ready
|
||||
except Exception: # noqa: BLE001
|
||||
return False
|
||||
try:
|
||||
return bool(is_managed_tool_gateway_ready("krea"))
|
||||
except Exception: # noqa: BLE001
|
||||
return False
|
||||
|
||||
|
||||
def _resolve_creativity(value: Optional[str]) -> str:
|
||||
"""Coerce ``creativity`` kwarg to a valid Krea value (default ``medium``)."""
|
||||
if isinstance(value, str):
|
||||
v = value.strip().lower()
|
||||
if v in _VALID_CREATIVITY:
|
||||
return v
|
||||
cfg = _load_krea_config()
|
||||
krea_cfg = cfg.get("krea") if isinstance(cfg.get("krea"), dict) else {}
|
||||
cfg_value = krea_cfg.get("creativity") if isinstance(krea_cfg, dict) else None
|
||||
if isinstance(cfg_value, str) and cfg_value.strip().lower() in _VALID_CREATIVITY:
|
||||
return cfg_value.strip().lower()
|
||||
return "medium"
|
||||
|
||||
|
||||
def _poll_krea_job(
|
||||
base_url: str,
|
||||
auth_token: str,
|
||||
job_id: str,
|
||||
*,
|
||||
timeout_seconds: float = _POLL_TIMEOUT_SECONDS,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Poll ``/jobs/{job_id}`` until terminal; return the job dict or None.
|
||||
|
||||
Best-effort variant of the main generate() poll loop used for secondary
|
||||
jobs (the Enhance upscale pass): any failure returns ``None`` so the
|
||||
caller can fall back instead of failing the whole generation.
|
||||
"""
|
||||
job_url = f"{base_url}/jobs/{job_id}"
|
||||
headers = {
|
||||
"Authorization": f"Bearer {auth_token}",
|
||||
"User-Agent": "Hermes-Agent/1.0 (krea-image-gen)",
|
||||
}
|
||||
interval = _POLL_INITIAL_INTERVAL
|
||||
deadline = time.monotonic() + timeout_seconds
|
||||
while True:
|
||||
time.sleep(interval)
|
||||
interval = min(interval * _POLL_BACKOFF, _POLL_MAX_INTERVAL)
|
||||
try:
|
||||
resp = requests.get(job_url, headers=headers, timeout=30)
|
||||
resp.raise_for_status()
|
||||
job = resp.json()
|
||||
except requests.HTTPError as exc:
|
||||
status = exc.response.status_code if exc.response is not None else 0
|
||||
if status not in _RETRYABLE_POLL_STATUSES or time.monotonic() >= deadline:
|
||||
logger.warning("Krea enhance poll failed (%d) for job %s", status, job_id)
|
||||
return None
|
||||
continue
|
||||
except Exception as exc: # noqa: BLE001 — timeout/connection/JSON
|
||||
if time.monotonic() >= deadline:
|
||||
logger.warning("Krea enhance poll gave up for job %s: %s", job_id, exc)
|
||||
return None
|
||||
continue
|
||||
|
||||
if isinstance(job, dict):
|
||||
status_str = job.get("status")
|
||||
if status_str in _TERMINAL_STATES or job.get("completed_at"):
|
||||
return job
|
||||
if time.monotonic() >= deadline:
|
||||
logger.warning("Krea enhance job %s did not finish in %ds", job_id, int(timeout_seconds))
|
||||
return None
|
||||
|
||||
|
||||
def _extract_result_url(job: Optional[Dict[str, Any]]) -> Optional[str]:
|
||||
"""Pull the first result URL out of a terminal Krea job dict."""
|
||||
if not isinstance(job, dict):
|
||||
return None
|
||||
result = job.get("result")
|
||||
if not isinstance(result, dict):
|
||||
return None
|
||||
urls = result.get("urls")
|
||||
if isinstance(urls, list):
|
||||
for candidate in urls:
|
||||
if isinstance(candidate, str) and candidate.strip():
|
||||
return candidate.strip()
|
||||
single = result.get("url")
|
||||
if isinstance(single, str) and single.strip():
|
||||
return single.strip()
|
||||
return None
|
||||
|
||||
|
||||
def _enhance_image(
|
||||
base_url: str,
|
||||
auth_token: str,
|
||||
image_url: str,
|
||||
prompt: str,
|
||||
*,
|
||||
managed: bool,
|
||||
) -> Optional[str]:
|
||||
"""Run Krea Enhance on ``image_url``; return the enhanced URL or None.
|
||||
|
||||
Best-effort: any submit/poll/result failure logs and returns ``None`` so
|
||||
the caller falls back to the original (un-upscaled) image — an upscale
|
||||
failure must never destroy an already-successful generation.
|
||||
"""
|
||||
headers = {
|
||||
"Authorization": f"Bearer {auth_token}",
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": "Hermes-Agent/1.0 (krea-image-gen)",
|
||||
}
|
||||
if managed:
|
||||
headers["x-idempotency-key"] = str(uuid.uuid4())
|
||||
payload: Dict[str, Any] = {
|
||||
"image_url": image_url,
|
||||
"image_scaling_factor": _ENHANCE_SCALE_FACTOR,
|
||||
# Keep the enhancer faithful to the generated composition: the
|
||||
# original prompt guides detail, and default ai_strength stays
|
||||
# conservative (Krea default 0.4 adds detail without redrawing).
|
||||
"prompt": prompt,
|
||||
}
|
||||
try:
|
||||
resp = requests.post(
|
||||
f"{base_url}{_ENHANCE_PATH}", headers=headers, json=payload, timeout=30,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
job_id = (resp.json() or {}).get("job_id")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning("Krea Enhance submit failed: %s", exc)
|
||||
return None
|
||||
if not isinstance(job_id, str) or not job_id:
|
||||
logger.warning("Krea Enhance submit response missing job_id")
|
||||
return None
|
||||
|
||||
job = _poll_krea_job(base_url, auth_token, job_id)
|
||||
if not isinstance(job, dict) or job.get("status") in {"failed", "cancelled"}:
|
||||
logger.warning("Krea Enhance job %s did not complete successfully", job_id)
|
||||
return None
|
||||
return _extract_result_url(job)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Provider
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class KreaImageGenProvider(ImageGenProvider):
|
||||
"""Krea ``Krea 2`` foundation image model backend (Medium + Large)."""
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "krea"
|
||||
|
||||
@property
|
||||
def display_name(self) -> str:
|
||||
return "Krea"
|
||||
|
||||
def is_available(self) -> bool:
|
||||
# Available with a direct Krea key OR via the managed Nous gateway
|
||||
# (Nous Subscription), so portal users with no Krea key can still
|
||||
# reach Krea 2 through the gateway.
|
||||
return bool(get_secret("KREA_API_KEY")) or _managed_krea_gateway_ready()
|
||||
|
||||
def list_models(self) -> List[Dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
"id": model_id,
|
||||
"display": meta["display"],
|
||||
"speed": meta["speed"],
|
||||
"strengths": meta["strengths"],
|
||||
"price": meta["price"],
|
||||
}
|
||||
for model_id, meta in _MODELS.items()
|
||||
]
|
||||
|
||||
def default_model(self) -> Optional[str]:
|
||||
return DEFAULT_MODEL
|
||||
|
||||
def get_setup_schema(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"name": "Krea",
|
||||
"badge": "paid",
|
||||
"tag": "Krea 2 foundation model — Medium ($0.03), Large ($0.06), Medium Turbo ($0.015). Style transfer, moodboards, reference-guided generation. Direct key or managed Nous Subscription gateway.",
|
||||
"env_vars": [
|
||||
{
|
||||
"key": "KREA_API_KEY",
|
||||
"prompt": "Krea API key",
|
||||
"url": "https://www.krea.ai/settings/api-tokens",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
def capabilities(self) -> Dict[str, Any]:
|
||||
# Krea supports reference-guided generation (image-to-image style
|
||||
# transfer) via image_style_references — up to 10 refs — and an
|
||||
# opt-in Enhance upscale pass (see generate()'s upscale_requested).
|
||||
return {
|
||||
"modalities": ["text", "image"],
|
||||
"max_reference_images": 10,
|
||||
"supports_upscale": True,
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# generate()
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def generate(
|
||||
self,
|
||||
prompt: str,
|
||||
aspect_ratio: str = DEFAULT_ASPECT_RATIO,
|
||||
*,
|
||||
image_url: Optional[str] = None,
|
||||
reference_image_urls: Optional[List[str]] = None,
|
||||
**kwargs: Any,
|
||||
) -> Dict[str, Any]:
|
||||
prompt = (prompt or "").strip()
|
||||
aspect = resolve_aspect_ratio(aspect_ratio)
|
||||
krea_ar = _ASPECT_MAP.get(aspect, "1:1")
|
||||
|
||||
# Collect reference images for reference-guided generation (image-to-
|
||||
# image style transfer). Sources, in order:
|
||||
# 1. unified image_url (primary source) + reference_image_urls (strings)
|
||||
# 2. legacy image_style_references kwarg — may be plain URL strings OR
|
||||
# Krea's richer ref objects (e.g. {"url": ..., "strength": ...}),
|
||||
# which are passed through verbatim for backward compatibility.
|
||||
style_refs: List[Any] = []
|
||||
if isinstance(image_url, str) and image_url.strip():
|
||||
style_refs.append(image_url.strip())
|
||||
for ref in (normalize_reference_images(reference_image_urls) or []):
|
||||
style_refs.append(ref)
|
||||
legacy_refs = kwargs.get("image_style_references")
|
||||
if isinstance(legacy_refs, list):
|
||||
for ref in legacy_refs:
|
||||
if isinstance(ref, str):
|
||||
if ref.strip():
|
||||
style_refs.append(ref.strip())
|
||||
elif ref:
|
||||
# Non-string ref object (dict, etc.) — pass through as-is.
|
||||
style_refs.append(ref)
|
||||
# Dedupe string entries while preserving order (dict refs aren't
|
||||
# hashable, so they're kept verbatim); Krea caps at 10.
|
||||
seen: set = set()
|
||||
deduped: List[Any] = []
|
||||
for r in style_refs:
|
||||
if isinstance(r, str):
|
||||
if r in seen:
|
||||
continue
|
||||
seen.add(r)
|
||||
deduped.append(r)
|
||||
style_refs = deduped[:10]
|
||||
modality = "image" if style_refs else "text"
|
||||
|
||||
if not prompt:
|
||||
return error_response(
|
||||
error="Prompt is required and must be a non-empty string",
|
||||
error_type="invalid_argument",
|
||||
provider="krea",
|
||||
aspect_ratio=aspect,
|
||||
)
|
||||
|
||||
# Route through the managed Nous gateway (Nous Subscription) when the
|
||||
# user is on the managed path; otherwise use the direct Krea API with a
|
||||
# BYO ``KREA_API_KEY``. The gateway owns the shared Krea credential and
|
||||
# meters/bills per generation, so the caller token is the Nous access
|
||||
# token, not a Krea key.
|
||||
managed = _resolve_managed_krea_gateway()
|
||||
if managed is not None:
|
||||
base_url = managed.gateway_origin.rstrip("/")
|
||||
auth_token = managed.nous_user_token
|
||||
else:
|
||||
base_url = BASE_URL
|
||||
auth_token = get_secret("KREA_API_KEY")
|
||||
if not auth_token:
|
||||
return error_response(
|
||||
error=(
|
||||
"KREA_API_KEY not set. Run `hermes tools` → Image "
|
||||
"Generation → Krea to configure, get a key at "
|
||||
"https://www.krea.ai/settings/api-tokens, or sign in to "
|
||||
"a Nous account with the managed Krea gateway enabled "
|
||||
"(`hermes setup`)."
|
||||
),
|
||||
error_type="auth_required",
|
||||
provider="krea",
|
||||
aspect_ratio=aspect,
|
||||
)
|
||||
|
||||
model_id, meta = _resolve_model(kwargs.get("model"))
|
||||
creativity = _resolve_creativity(kwargs.get("creativity"))
|
||||
|
||||
# The managed gateway only prices base text-to-image and URL
|
||||
# ``image_style_references`` tiers. Trained styles (LoRAs) and
|
||||
# moodboards have no managed price and are rejected at the gateway, so
|
||||
# fail fast here with actionable guidance instead of a raw 400.
|
||||
if managed is not None:
|
||||
if isinstance(kwargs.get("styles"), list) and kwargs.get("styles"):
|
||||
return error_response(
|
||||
error=(
|
||||
"Managed Krea (Nous Subscription) does not support "
|
||||
"trained styles (LoRAs). Set KREA_API_KEY to use Krea "
|
||||
"directly, or omit `styles`."
|
||||
),
|
||||
error_type="unsupported_argument",
|
||||
provider="krea",
|
||||
model=model_id,
|
||||
prompt=prompt,
|
||||
aspect_ratio=aspect,
|
||||
)
|
||||
if isinstance(kwargs.get("moodboards"), list) and kwargs.get("moodboards"):
|
||||
return error_response(
|
||||
error=(
|
||||
"Managed Krea (Nous Subscription) does not support "
|
||||
"moodboards. Set KREA_API_KEY to use Krea directly, or "
|
||||
"omit `moodboards`."
|
||||
),
|
||||
error_type="unsupported_argument",
|
||||
provider="krea",
|
||||
model=model_id,
|
||||
prompt=prompt,
|
||||
aspect_ratio=aspect,
|
||||
)
|
||||
|
||||
payload: Dict[str, Any] = {
|
||||
"prompt": prompt,
|
||||
"aspect_ratio": krea_ar,
|
||||
"resolution": DEFAULT_RESOLUTION,
|
||||
"creativity": creativity,
|
||||
}
|
||||
|
||||
# Optional forward-compat passthroughs — the Krea API accepts these
|
||||
# but they're not required and most agent calls won't supply them.
|
||||
seed = kwargs.get("seed")
|
||||
if isinstance(seed, int):
|
||||
payload["seed"] = seed
|
||||
|
||||
styles = kwargs.get("styles")
|
||||
if isinstance(styles, list) and styles:
|
||||
payload["styles"] = styles
|
||||
|
||||
if style_refs:
|
||||
# Reference-guided generation (image-to-image style transfer).
|
||||
# Krea requires each entry to be an object ({"url", "strength"}),
|
||||
# NOT a bare URL string — a string yields a 422 "Expected object,
|
||||
# received string". Convert URL strings to the object form and pass
|
||||
# already-object refs through verbatim (clamped to 10 above).
|
||||
normalized_refs: List[Any] = []
|
||||
for ref in style_refs:
|
||||
if isinstance(ref, str):
|
||||
normalized_refs.append(
|
||||
{"url": ref, "strength": _DEFAULT_STYLE_REFERENCE_STRENGTH}
|
||||
)
|
||||
else:
|
||||
normalized_refs.append(ref)
|
||||
payload["image_style_references"] = normalized_refs
|
||||
|
||||
moodboards = kwargs.get("moodboards")
|
||||
if isinstance(moodboards, list) and moodboards:
|
||||
# Krea currently caps at 1 moodboard per request.
|
||||
payload["moodboards"] = moodboards[:1]
|
||||
|
||||
headers = {
|
||||
"Authorization": f"Bearer {auth_token}",
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": "Hermes-Agent/1.0 (krea-image-gen)",
|
||||
}
|
||||
if managed is not None:
|
||||
# The gateway derives the per-generation billing idempotency
|
||||
# boundary from this header (else it falls back to a body
|
||||
# fingerprint). A fresh key per submit keeps each generation a
|
||||
# distinct billable execution.
|
||||
headers["x-idempotency-key"] = str(uuid.uuid4())
|
||||
|
||||
# 1. Submit job.
|
||||
submit_url = f"{base_url}/generate/image/krea/krea-2/{meta['path']}"
|
||||
try:
|
||||
response = requests.post(
|
||||
submit_url,
|
||||
headers=headers,
|
||||
json=payload,
|
||||
timeout=30,
|
||||
)
|
||||
response.raise_for_status()
|
||||
except requests.HTTPError as exc:
|
||||
resp = exc.response
|
||||
status = resp.status_code if resp is not None else 0
|
||||
try:
|
||||
body = resp.json() if resp is not None else {}
|
||||
err_msg = (
|
||||
body.get("error", {}).get("message")
|
||||
if isinstance(body.get("error"), dict)
|
||||
else body.get("message") or body.get("detail")
|
||||
) or (resp.text[:300] if resp is not None else str(exc))
|
||||
except Exception: # noqa: BLE001
|
||||
err_msg = resp.text[:300] if resp is not None else str(exc)
|
||||
logger.error("Krea submit failed (%d): %s", status, err_msg)
|
||||
# On a managed 4xx, surface actionable remediation mirroring the
|
||||
# FAL managed gateway path: the model may not be enabled/priced on
|
||||
# the Nous Portal, or the gateway's shared Krea key hit its
|
||||
# concurrency cap (429).
|
||||
if managed is not None and 400 <= status < 500:
|
||||
hint = (
|
||||
"Krea's shared-key concurrency cap was hit — retry shortly."
|
||||
if status == 429
|
||||
else (
|
||||
f"Model '{model_id}' may not be enabled/priced on the "
|
||||
"Nous Portal's Krea gateway. Set KREA_API_KEY to use "
|
||||
"Krea directly, or pick a different model via "
|
||||
"`hermes tools` → Image Generation."
|
||||
)
|
||||
)
|
||||
return error_response(
|
||||
error=(
|
||||
f"Nous Subscription Krea gateway rejected '{model_id}' "
|
||||
f"(HTTP {status}): {err_msg}. {hint}"
|
||||
),
|
||||
error_type="api_error",
|
||||
provider="krea",
|
||||
model=model_id,
|
||||
prompt=prompt,
|
||||
aspect_ratio=aspect,
|
||||
)
|
||||
return error_response(
|
||||
error=f"Krea image generation failed ({status}): {err_msg}",
|
||||
error_type="api_error",
|
||||
provider="krea",
|
||||
model=model_id,
|
||||
prompt=prompt,
|
||||
aspect_ratio=aspect,
|
||||
)
|
||||
except requests.Timeout:
|
||||
return error_response(
|
||||
error="Krea submit timed out (30s)",
|
||||
error_type="timeout",
|
||||
provider="krea",
|
||||
model=model_id,
|
||||
prompt=prompt,
|
||||
aspect_ratio=aspect,
|
||||
)
|
||||
except requests.ConnectionError as exc:
|
||||
return error_response(
|
||||
error=f"Krea connection error: {exc}",
|
||||
error_type="connection_error",
|
||||
provider="krea",
|
||||
model=model_id,
|
||||
prompt=prompt,
|
||||
aspect_ratio=aspect,
|
||||
)
|
||||
|
||||
try:
|
||||
submit_body = response.json()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return error_response(
|
||||
error=f"Krea returned invalid JSON on submit: {exc}",
|
||||
error_type="invalid_response",
|
||||
provider="krea",
|
||||
model=model_id,
|
||||
prompt=prompt,
|
||||
aspect_ratio=aspect,
|
||||
)
|
||||
|
||||
job_id = submit_body.get("job_id")
|
||||
if not isinstance(job_id, str) or not job_id:
|
||||
return error_response(
|
||||
error="Krea submit response missing job_id",
|
||||
error_type="invalid_response",
|
||||
provider="krea",
|
||||
model=model_id,
|
||||
prompt=prompt,
|
||||
aspect_ratio=aspect,
|
||||
)
|
||||
|
||||
# 2. Poll for completion. Status/result polling is bound to the same
|
||||
# principal at the gateway, so the managed path polls the gateway's
|
||||
# ``/jobs/{id}`` with the Nous token (404 on cross-user/unknown jobs).
|
||||
job_url = f"{base_url}/jobs/{job_id}"
|
||||
poll_headers = {
|
||||
"Authorization": f"Bearer {auth_token}",
|
||||
"User-Agent": "Hermes-Agent/1.0 (krea-image-gen)",
|
||||
}
|
||||
interval = _POLL_INITIAL_INTERVAL
|
||||
deadline = time.monotonic() + _POLL_TIMEOUT_SECONDS
|
||||
last_status: Optional[str] = None
|
||||
|
||||
while True:
|
||||
time.sleep(interval)
|
||||
interval = min(interval * _POLL_BACKOFF, _POLL_MAX_INTERVAL)
|
||||
|
||||
try:
|
||||
poll_resp = requests.get(job_url, headers=poll_headers, timeout=30)
|
||||
poll_resp.raise_for_status()
|
||||
except requests.HTTPError as exc:
|
||||
resp = exc.response
|
||||
status = resp.status_code if resp is not None else 0
|
||||
logger.error("Krea poll failed (%d) for job %s", status, job_id)
|
||||
# Fail fast for non-retryable statuses (auth/billing/not-found,
|
||||
# other permanent 4xx) so callers don't wait the full 180s
|
||||
# deadline on a request that will never succeed. Only retry
|
||||
# transient statuses such as 408/409/425/429/5xx.
|
||||
if status not in _RETRYABLE_POLL_STATUSES or time.monotonic() >= deadline:
|
||||
return error_response(
|
||||
error=f"Krea poll failed ({status}) for job {job_id}",
|
||||
error_type="api_error",
|
||||
provider="krea",
|
||||
model=model_id,
|
||||
prompt=prompt,
|
||||
aspect_ratio=aspect,
|
||||
)
|
||||
# Otherwise keep trying — transient 5xx (and a few retryable
|
||||
# 4xx like 408/409/425/429) are common on async jobs.
|
||||
continue
|
||||
except (requests.Timeout, requests.ConnectionError) as exc:
|
||||
logger.warning("Krea poll transient error for job %s: %s", job_id, exc)
|
||||
if time.monotonic() >= deadline:
|
||||
return error_response(
|
||||
error=f"Krea poll timed out for job {job_id}: {exc}",
|
||||
error_type="timeout",
|
||||
provider="krea",
|
||||
model=model_id,
|
||||
prompt=prompt,
|
||||
aspect_ratio=aspect,
|
||||
)
|
||||
continue
|
||||
|
||||
try:
|
||||
job = poll_resp.json()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning("Krea poll returned invalid JSON for job %s: %s", job_id, exc)
|
||||
if time.monotonic() >= deadline:
|
||||
return error_response(
|
||||
error=f"Krea poll returned invalid JSON: {exc}",
|
||||
error_type="invalid_response",
|
||||
provider="krea",
|
||||
model=model_id,
|
||||
prompt=prompt,
|
||||
aspect_ratio=aspect,
|
||||
)
|
||||
continue
|
||||
|
||||
status_str = job.get("status") if isinstance(job, dict) else None
|
||||
if isinstance(status_str, str):
|
||||
last_status = status_str
|
||||
if status_str in _TERMINAL_STATES:
|
||||
break
|
||||
|
||||
# ``completed_at`` is a backstop terminal marker even when the
|
||||
# ``status`` enum is unfamiliar (Krea adds new pending states
|
||||
# over time — backlogged/scheduled/sampling — and we don't
|
||||
# want to mis-handle a future one).
|
||||
if isinstance(job, dict) and job.get("completed_at"):
|
||||
break
|
||||
|
||||
if time.monotonic() >= deadline:
|
||||
return error_response(
|
||||
error=(
|
||||
f"Krea job {job_id} did not complete within "
|
||||
f"{int(_POLL_TIMEOUT_SECONDS)}s (last status: {last_status or 'unknown'})"
|
||||
),
|
||||
error_type="timeout",
|
||||
provider="krea",
|
||||
model=model_id,
|
||||
prompt=prompt,
|
||||
aspect_ratio=aspect,
|
||||
)
|
||||
|
||||
# 3. Terminal — extract result.
|
||||
if not isinstance(job, dict):
|
||||
return error_response(
|
||||
error="Krea returned non-dict job body",
|
||||
error_type="invalid_response",
|
||||
provider="krea",
|
||||
model=model_id,
|
||||
prompt=prompt,
|
||||
aspect_ratio=aspect,
|
||||
)
|
||||
|
||||
if last_status == "failed":
|
||||
err = (job.get("result") or {}).get("error") if isinstance(job.get("result"), dict) else None
|
||||
return error_response(
|
||||
error=f"Krea job {job_id} failed: {err or 'unknown error'}",
|
||||
error_type="api_error",
|
||||
provider="krea",
|
||||
model=model_id,
|
||||
prompt=prompt,
|
||||
aspect_ratio=aspect,
|
||||
)
|
||||
|
||||
if last_status == "cancelled":
|
||||
return error_response(
|
||||
error=f"Krea job {job_id} was cancelled",
|
||||
error_type="cancelled",
|
||||
provider="krea",
|
||||
model=model_id,
|
||||
prompt=prompt,
|
||||
aspect_ratio=aspect,
|
||||
)
|
||||
|
||||
# Successful path — pull URL out of the result.
|
||||
result = job.get("result")
|
||||
if not isinstance(result, dict):
|
||||
return error_response(
|
||||
error="Krea job completed but result was missing",
|
||||
error_type="empty_response",
|
||||
provider="krea",
|
||||
model=model_id,
|
||||
prompt=prompt,
|
||||
aspect_ratio=aspect,
|
||||
)
|
||||
|
||||
# Per Krea's job-lifecycle docs the completed payload exposes
|
||||
# ``result.urls`` (an array). Fall back to a single ``url`` field
|
||||
# for forward/backward compatibility.
|
||||
result_image_url: Optional[str] = None
|
||||
urls = result.get("urls")
|
||||
if isinstance(urls, list) and urls:
|
||||
for candidate in urls:
|
||||
if isinstance(candidate, str) and candidate.strip():
|
||||
result_image_url = candidate.strip()
|
||||
break
|
||||
if result_image_url is None:
|
||||
single = result.get("url")
|
||||
if isinstance(single, str) and single.strip():
|
||||
result_image_url = single.strip()
|
||||
|
||||
if result_image_url is None:
|
||||
return error_response(
|
||||
error="Krea result contained no image URL",
|
||||
error_type="empty_response",
|
||||
provider="krea",
|
||||
model=model_id,
|
||||
prompt=prompt,
|
||||
aspect_ratio=aspect,
|
||||
)
|
||||
|
||||
# High-resolution pass (Krea Enhance). Precedence: explicit kwarg >
|
||||
# ``image_gen.krea.upscale`` config > per-model catalog default
|
||||
# (1.5K-native tiers default on; 2K-native Large stays off). Best-
|
||||
# effort: failure falls back to the original image rather than
|
||||
# failing the generation.
|
||||
upscaled = False
|
||||
upscale_requested = kwargs.get("upscale")
|
||||
if not isinstance(upscale_requested, bool):
|
||||
cfg_krea = _load_krea_config().get("krea")
|
||||
cfg_upscale = cfg_krea.get("upscale") if isinstance(cfg_krea, dict) else None
|
||||
if isinstance(cfg_upscale, bool):
|
||||
upscale_requested = cfg_upscale
|
||||
else:
|
||||
upscale_requested = bool(meta.get("upscale", False))
|
||||
if upscale_requested:
|
||||
enhanced_url = _enhance_image(
|
||||
base_url,
|
||||
auth_token,
|
||||
result_image_url,
|
||||
prompt,
|
||||
managed=managed is not None,
|
||||
)
|
||||
if enhanced_url:
|
||||
result_image_url = enhanced_url
|
||||
upscaled = True
|
||||
else:
|
||||
logger.warning(
|
||||
"Krea Enhance pass failed — returning native-resolution image"
|
||||
)
|
||||
|
||||
# Materialise locally — Krea result URLs may expire, mirroring
|
||||
# what we do for xAI / OpenAI URL responses (#26942).
|
||||
try:
|
||||
saved_path = save_url_image(result_image_url, prefix=f"krea_{model_id}")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning(
|
||||
"Krea image URL %s could not be cached (%s); falling back to bare URL.",
|
||||
result_image_url,
|
||||
exc,
|
||||
)
|
||||
image_ref = result_image_url
|
||||
else:
|
||||
image_ref = str(saved_path)
|
||||
|
||||
extra: Dict[str, Any] = {
|
||||
"krea_aspect_ratio": krea_ar,
|
||||
"resolution": DEFAULT_RESOLUTION,
|
||||
"creativity": creativity,
|
||||
"job_id": job_id,
|
||||
"upscaled": upscaled,
|
||||
}
|
||||
if upscaled:
|
||||
extra["upscale_factor"] = _ENHANCE_SCALE_FACTOR
|
||||
if isinstance(job.get("completed_at"), str):
|
||||
extra["completed_at"] = job["completed_at"]
|
||||
|
||||
return success_response(
|
||||
image=image_ref,
|
||||
model=model_id,
|
||||
prompt=prompt,
|
||||
aspect_ratio=aspect,
|
||||
provider="krea",
|
||||
modality=modality,
|
||||
extra=extra,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Plugin entry point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def register(ctx) -> None:
|
||||
"""Plugin entry point — wire ``KreaImageGenProvider`` into the registry."""
|
||||
ctx.register_image_gen_provider(KreaImageGenProvider())
|
||||
@@ -0,0 +1,7 @@
|
||||
name: krea
|
||||
version: 1.1.0
|
||||
description: "Krea image generation backend (Krea 2 Large + Medium + Medium Turbo foundation models). Direct KREA_API_KEY or managed Nous Subscription gateway."
|
||||
author: NousResearch
|
||||
kind: backend
|
||||
requires_env:
|
||||
- KREA_API_KEY
|
||||
@@ -0,0 +1,297 @@
|
||||
"""Meta Model API image generation backend.
|
||||
|
||||
Exposes Meta's ``muse-image`` model(s) as an :class:`ImageGenProvider`.
|
||||
The Meta Model API (https://api.meta.ai/v1) is OpenAI-compatible, so we reuse
|
||||
the OpenAI Python SDK pointed at Meta's base URL and authenticate with
|
||||
``META_MODEL_API_KEY``.
|
||||
|
||||
Output is base64 JSON (WebP) -> saved under ``$HERMES_HOME/cache/images/``.
|
||||
|
||||
Selection precedence (first hit wins):
|
||||
1. ``model`` kwarg forwarded by the dispatcher (the ``hermes tools`` pick)
|
||||
2. ``META_IMAGE_MODEL`` env var (escape hatch for scripts / tests)
|
||||
3. ``image_gen.meta-ai.model`` in ``config.yaml``
|
||||
4. ``image_gen.model`` in ``config.yaml`` (when it's one of our IDs)
|
||||
5. :data:`DEFAULT_MODEL`
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from agent.secret_scope import get_secret
|
||||
from agent.image_gen_provider import (
|
||||
DEFAULT_ASPECT_RATIO,
|
||||
ImageGenProvider,
|
||||
error_response,
|
||||
normalize_reference_images,
|
||||
resolve_aspect_ratio,
|
||||
save_b64_image,
|
||||
save_url_image,
|
||||
success_response,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_BASE_URL = "https://api.meta.ai/v1"
|
||||
# Auth env vars, in priority order. Mirrors the bundled ``meta-ai`` chat
|
||||
# provider (plugins/model-providers/meta-ai): MODEL_API_KEY is Meta's
|
||||
# documented var; the rest are accepted aliases.
|
||||
API_KEY_ENVS = ("MODEL_API_KEY", "META_API_KEY", "META_MODEL_API_KEY")
|
||||
# Primary key shown in setup prompts / error messages.
|
||||
API_KEY_ENV = "META_MODEL_API_KEY"
|
||||
# Optional base-url override (same var the chat provider honors).
|
||||
BASE_URL_ENV = "META_BASE_URL"
|
||||
|
||||
|
||||
def _resolve_api_key() -> Optional[str]:
|
||||
"""First non-empty auth env var, checked in priority order."""
|
||||
for env in API_KEY_ENVS:
|
||||
val = get_secret(env)
|
||||
if val:
|
||||
return val
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_base_url() -> str:
|
||||
return (os.environ.get(BASE_URL_ENV) or "").strip() or DEFAULT_BASE_URL
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Model catalog
|
||||
# ---------------------------------------------------------------------------
|
||||
# Catalog shown in `hermes tools` and matched against `image_gen.model`.
|
||||
# The model id is sent verbatim to the Meta Model API (`/v1/images/generations`).
|
||||
_MODELS: Dict[str, Dict[str, Any]] = {
|
||||
"muse-image-1.0": {
|
||||
"display": "Muse Image 1.0",
|
||||
"speed": "~10s",
|
||||
"strengths": "Meta Model API image generation",
|
||||
"price": "$0.01/image",
|
||||
},
|
||||
}
|
||||
DEFAULT_MODEL = "muse-image-1.0"
|
||||
|
||||
# aspect_ratio -> OpenAI-style size string
|
||||
_SIZES: Dict[str, str] = {
|
||||
"square": "1024x1024",
|
||||
"landscape": "1536x1024",
|
||||
"portrait": "1024x1536",
|
||||
}
|
||||
|
||||
|
||||
def _resolve_model(caller_model: Optional[str] = None) -> Tuple[str, Dict[str, Any]]:
|
||||
"""Return (model_id, metadata) using the documented precedence chain.
|
||||
|
||||
``caller_model`` is the ``model`` kwarg the dispatcher forwards from the
|
||||
top-level ``image_gen.model`` config key (what ``hermes tools`` writes).
|
||||
It wins when it names one of our models, mirroring the xai/krea/openrouter
|
||||
providers, so a user's picker choice is never silently dropped.
|
||||
"""
|
||||
if caller_model and caller_model in _MODELS:
|
||||
return caller_model, _MODELS[caller_model]
|
||||
|
||||
env_model = os.environ.get("META_IMAGE_MODEL")
|
||||
if env_model and env_model in _MODELS:
|
||||
return env_model, _MODELS[env_model]
|
||||
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
|
||||
cfg = load_config() or {}
|
||||
ig = cfg.get("image_gen") or {}
|
||||
scoped = (ig.get("meta-ai") or {}).get("model")
|
||||
if scoped and scoped in _MODELS:
|
||||
return scoped, _MODELS[scoped]
|
||||
top = ig.get("model")
|
||||
if top and top in _MODELS:
|
||||
return top, _MODELS[top]
|
||||
except Exception:
|
||||
logger.debug("Could not read image_gen model from config", exc_info=True)
|
||||
|
||||
return DEFAULT_MODEL, _MODELS[DEFAULT_MODEL]
|
||||
|
||||
|
||||
class MetaImageGenProvider(ImageGenProvider):
|
||||
"""Meta Model API ``images.generate`` backend (muse-image)."""
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "meta-ai"
|
||||
|
||||
@property
|
||||
def display_name(self) -> str:
|
||||
return "Meta Model API"
|
||||
|
||||
def is_available(self) -> bool:
|
||||
if not _resolve_api_key():
|
||||
return False
|
||||
try:
|
||||
import openai # noqa: F401
|
||||
except ImportError:
|
||||
return False
|
||||
return True
|
||||
|
||||
def list_models(self) -> List[Dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
"id": mid,
|
||||
"display": m["display"],
|
||||
"speed": m["speed"],
|
||||
"strengths": m["strengths"],
|
||||
"price": m["price"],
|
||||
}
|
||||
for mid, m in _MODELS.items()
|
||||
]
|
||||
|
||||
def default_model(self) -> Optional[str]:
|
||||
return DEFAULT_MODEL
|
||||
|
||||
def get_setup_schema(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"name": "Meta Model API",
|
||||
"badge": "paid",
|
||||
"tag": "Muse Image via Meta Model API (api.meta.ai)",
|
||||
"env_vars": [
|
||||
{
|
||||
"key": API_KEY_ENV,
|
||||
"prompt": "Meta Model API key (LLM|... token)",
|
||||
"url": "https://api.meta.ai",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
def capabilities(self) -> Dict[str, Any]:
|
||||
# Text-to-image only for now. Bump this once image-to-image is verified
|
||||
# against the Meta endpoint.
|
||||
return {"modalities": ["text"], "max_reference_images": 0}
|
||||
|
||||
def generate(
|
||||
self,
|
||||
prompt: str,
|
||||
aspect_ratio: str = DEFAULT_ASPECT_RATIO,
|
||||
*,
|
||||
image_url: Optional[str] = None,
|
||||
reference_image_urls: Optional[List[str]] = None,
|
||||
**kwargs: Any,
|
||||
) -> Dict[str, Any]:
|
||||
prompt = (prompt or "").strip()
|
||||
aspect = resolve_aspect_ratio(aspect_ratio)
|
||||
|
||||
if not prompt:
|
||||
return error_response(
|
||||
error="Prompt is required and must be a non-empty string",
|
||||
error_type="invalid_argument",
|
||||
provider="meta-ai",
|
||||
aspect_ratio=aspect,
|
||||
)
|
||||
|
||||
api_key = _resolve_api_key()
|
||||
if not api_key:
|
||||
return error_response(
|
||||
error=(
|
||||
f"{API_KEY_ENV} not set. Run `hermes tools` -> Image "
|
||||
"Generation -> Meta Model API to configure."
|
||||
),
|
||||
error_type="auth_required",
|
||||
provider="meta-ai",
|
||||
aspect_ratio=aspect,
|
||||
)
|
||||
|
||||
try:
|
||||
import openai
|
||||
except ImportError:
|
||||
return error_response(
|
||||
error="openai Python package not installed (pip install openai)",
|
||||
error_type="missing_dependency",
|
||||
provider="meta-ai",
|
||||
aspect_ratio=aspect,
|
||||
)
|
||||
|
||||
model_id, _meta = _resolve_model(kwargs.get("model"))
|
||||
size = _SIZES.get(aspect, _SIZES["square"])
|
||||
|
||||
client = openai.OpenAI(api_key=api_key, base_url=_resolve_base_url())
|
||||
|
||||
payload: Dict[str, Any] = {
|
||||
"model": model_id,
|
||||
"prompt": prompt,
|
||||
"size": size,
|
||||
"n": 1,
|
||||
}
|
||||
|
||||
try:
|
||||
response = client.images.generate(**payload)
|
||||
except Exception as exc:
|
||||
logger.debug("Meta image generation failed", exc_info=True)
|
||||
return error_response(
|
||||
error=f"Meta image generation failed: {exc}",
|
||||
error_type="api_error",
|
||||
provider="meta-ai",
|
||||
model=model_id,
|
||||
prompt=prompt,
|
||||
aspect_ratio=aspect,
|
||||
)
|
||||
|
||||
try:
|
||||
first = response.data[0]
|
||||
except (AttributeError, IndexError, TypeError):
|
||||
return error_response(
|
||||
error="Meta response contained no image data",
|
||||
error_type="empty_response",
|
||||
provider="meta-ai",
|
||||
model=model_id,
|
||||
prompt=prompt,
|
||||
aspect_ratio=aspect,
|
||||
)
|
||||
|
||||
b64 = getattr(first, "b64_json", None)
|
||||
url = getattr(first, "url", None)
|
||||
|
||||
try:
|
||||
if b64:
|
||||
path = save_b64_image(b64, prefix="meta", extension="webp")
|
||||
image_ref = str(path)
|
||||
elif url:
|
||||
path = save_url_image(url, prefix="meta")
|
||||
image_ref = str(path)
|
||||
else:
|
||||
return error_response(
|
||||
error="Meta response contained neither b64_json nor URL",
|
||||
error_type="empty_response",
|
||||
provider="meta-ai",
|
||||
model=model_id,
|
||||
prompt=prompt,
|
||||
aspect_ratio=aspect,
|
||||
)
|
||||
except Exception as exc:
|
||||
return error_response(
|
||||
error=f"Failed to save Meta image: {exc}",
|
||||
error_type="io_error",
|
||||
provider="meta-ai",
|
||||
model=model_id,
|
||||
prompt=prompt,
|
||||
aspect_ratio=aspect,
|
||||
)
|
||||
|
||||
revised_prompt = getattr(first, "revised_prompt", None)
|
||||
extra: Dict[str, Any] = {"size": size}
|
||||
if revised_prompt:
|
||||
extra["revised_prompt"] = revised_prompt
|
||||
|
||||
return success_response(
|
||||
image=image_ref,
|
||||
model=model_id,
|
||||
prompt=prompt,
|
||||
aspect_ratio=aspect,
|
||||
provider="meta-ai",
|
||||
modality="text",
|
||||
extra=extra,
|
||||
)
|
||||
|
||||
|
||||
def register(ctx) -> None:
|
||||
"""Plugin entry point -- wire ``MetaImageGenProvider`` into the registry."""
|
||||
ctx.register_image_gen_provider(MetaImageGenProvider())
|
||||
@@ -0,0 +1,7 @@
|
||||
name: meta-ai-image-gen
|
||||
version: 1.0.0
|
||||
description: "Meta Model API image generation backend (muse-image). OpenAI-compatible /v1/images/generations. Saves images to $HERMES_HOME/cache/images/."
|
||||
author: Meta Platforms, Inc.
|
||||
kind: backend
|
||||
requires_env:
|
||||
- META_MODEL_API_KEY
|
||||
@@ -0,0 +1,770 @@
|
||||
"""OpenAI image generation backend — ChatGPT/Codex OAuth variant.
|
||||
|
||||
Identical model catalog and tier semantics to the ``openai`` image-gen plugin
|
||||
(``gpt-image-2`` at low/medium/high quality), but routes the request through
|
||||
the Codex Responses API ``image_generation`` tool instead of the
|
||||
``images.generate`` REST endpoint. This lets users who are already
|
||||
authenticated with Codex/ChatGPT generate images without configuring a
|
||||
separate ``OPENAI_API_KEY``.
|
||||
|
||||
Selection precedence for the tier (first hit wins):
|
||||
|
||||
1. ``OPENAI_IMAGE_MODEL`` env var (escape hatch for scripts / tests)
|
||||
2. ``image_gen.openai-codex.model`` in ``config.yaml``
|
||||
3. ``image_gen.model`` in ``config.yaml`` (when it's one of our tier IDs)
|
||||
4. :data:`DEFAULT_MODEL` — ``gpt-image-2-medium``
|
||||
|
||||
Output is saved as PNG under ``$HERMES_HOME/cache/images/``. Source images for
|
||||
image-to-image/editing are sent as Responses ``input_image`` content parts.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from agent.image_gen_provider import (
|
||||
DEFAULT_ASPECT_RATIO,
|
||||
ImageGenProvider,
|
||||
error_response,
|
||||
normalize_reference_images,
|
||||
resolve_aspect_ratio,
|
||||
save_b64_image,
|
||||
success_response,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# NOTE: do NOT reintroduce an "account capability" classifier keyed on
|
||||
# ``Tool choice 'image_generation' not found in 'tools' parameter``. That HTTP
|
||||
# 400 is a *request-shape* rejection (the Codex backend resolves tool_choice as
|
||||
# a function-tool name and never recognizes hosted-tool entries) — it is
|
||||
# emitted for every account, including accounts where image generation works.
|
||||
# A previous version of this file translated that 400 into "Image generation is
|
||||
# not enabled for the current Codex account. Switch the image provider to
|
||||
# OpenAI API key, FAL, or xAI.", which reported a universal bug in our own
|
||||
# payload as the user's entitlement problem and sent people away from a
|
||||
# provider that was never actually tried. The request-shape bug is fixed by
|
||||
# omitting tool_choice (see ``_build_responses_payload``); any remaining HTTP
|
||||
# error must surface verbatim so it stays diagnosable. See issues #19505,
|
||||
# #49008 and #31335.
|
||||
|
||||
_MAX_ERROR_BODY_CHARS = 500
|
||||
|
||||
|
||||
def _summarize_error_body(body: str) -> str:
|
||||
"""Return a bounded, information-preserving summary of an error body.
|
||||
|
||||
Prefers the parsed ``error.message`` field, because a blind head-truncation
|
||||
of the raw body can cut the actual message off entirely — Codex error
|
||||
payloads sometimes carry hundreds of bytes of leading metadata, so
|
||||
``body[:500]`` yielded a wall of padding and no diagnosis. Falls back to a
|
||||
truncated raw body for non-JSON responses.
|
||||
"""
|
||||
text = body or ""
|
||||
try:
|
||||
payload = json.loads(text)
|
||||
error = payload.get("error") if isinstance(payload, dict) else None
|
||||
message = error.get("message") if isinstance(error, dict) else None
|
||||
if isinstance(message, str) and message.strip():
|
||||
return message.strip()[:_MAX_ERROR_BODY_CHARS]
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
return text[:_MAX_ERROR_BODY_CHARS]
|
||||
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Model catalog — mirrors the ``openai`` plugin so the picker UX is identical.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
API_MODEL = "gpt-image-2"
|
||||
|
||||
_MODELS: Dict[str, Dict[str, Any]] = {
|
||||
"gpt-image-2-low": {
|
||||
"display": "GPT Image 2 (Low)",
|
||||
"speed": "~15s",
|
||||
"strengths": "Fast iteration, lowest cost",
|
||||
"quality": "low",
|
||||
},
|
||||
"gpt-image-2-medium": {
|
||||
"display": "GPT Image 2 (Medium)",
|
||||
"speed": "~40s",
|
||||
"strengths": "Balanced — default",
|
||||
"quality": "medium",
|
||||
},
|
||||
"gpt-image-2-high": {
|
||||
"display": "GPT Image 2 (High)",
|
||||
"speed": "~2min",
|
||||
"strengths": "Highest fidelity, strongest prompt adherence",
|
||||
"quality": "high",
|
||||
},
|
||||
}
|
||||
|
||||
DEFAULT_MODEL = "gpt-image-2-medium"
|
||||
|
||||
_SIZES = {
|
||||
"landscape": "1536x1024",
|
||||
"square": "1024x1024",
|
||||
"portrait": "1024x1536",
|
||||
}
|
||||
|
||||
# Codex Responses surface used for the request. The chat model itself is only
|
||||
# the host that calls the ``image_generation`` tool; the actual image work is
|
||||
# done by ``API_MODEL``.
|
||||
_CODEX_CHAT_MODEL = "gpt-5.5"
|
||||
_CODEX_BASE_URL = "https://chatgpt.com/backend-api/codex"
|
||||
_CODEX_INSTRUCTIONS = (
|
||||
"You are an assistant that must fulfill image generation and image editing "
|
||||
"requests by using the image_generation tool when provided."
|
||||
)
|
||||
|
||||
_MAX_REFERENCE_IMAGES = 16
|
||||
_MAX_INPUT_IMAGE_BYTES = 25 * 1024 * 1024
|
||||
# gpt-image-2's Responses ``input_image`` accepts raster formats only. The
|
||||
# shared magic-byte sniffer also recognizes SVG/TIFF/ICO, which the API
|
||||
# rejects server-side — gate to this allowlist so unsupported inputs fail
|
||||
# locally with a clear error instead of an opaque HTTP 400.
|
||||
_ACCEPTED_INPUT_MIME = frozenset(
|
||||
{"image/png", "image/jpeg", "image/gif", "image/webp"}
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Config + auth helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _load_image_gen_config() -> Dict[str, Any]:
|
||||
"""Read ``image_gen`` from config.yaml (returns {} on any failure)."""
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
|
||||
cfg = load_config()
|
||||
section = cfg.get("image_gen") if isinstance(cfg, dict) else None
|
||||
return section if isinstance(section, dict) else {}
|
||||
except Exception as exc:
|
||||
logger.debug("Could not load image_gen config: %s", exc)
|
||||
return {}
|
||||
|
||||
|
||||
def _resolve_model() -> Tuple[str, Dict[str, Any]]:
|
||||
"""Decide which tier to use and return ``(model_id, meta)``."""
|
||||
import os
|
||||
|
||||
env_override = os.environ.get("OPENAI_IMAGE_MODEL")
|
||||
if env_override and env_override in _MODELS:
|
||||
return env_override, _MODELS[env_override]
|
||||
|
||||
cfg = _load_image_gen_config()
|
||||
sub = cfg.get("openai-codex") if isinstance(cfg.get("openai-codex"), dict) else {}
|
||||
candidate: Optional[str] = None
|
||||
if isinstance(sub, dict):
|
||||
value = sub.get("model")
|
||||
if isinstance(value, str) and value in _MODELS:
|
||||
candidate = value
|
||||
if candidate is None:
|
||||
top = cfg.get("model")
|
||||
if isinstance(top, str) and top in _MODELS:
|
||||
candidate = top
|
||||
|
||||
if candidate is not None:
|
||||
return candidate, _MODELS[candidate]
|
||||
|
||||
return DEFAULT_MODEL, _MODELS[DEFAULT_MODEL]
|
||||
|
||||
|
||||
def _read_codex_access_token() -> Optional[str]:
|
||||
"""Return a usable Codex OAuth token, or None.
|
||||
|
||||
Delegates to the canonical reader in ``agent.auxiliary_client`` so token
|
||||
expiry, credential pool selection, and JWT decoding stay in one place.
|
||||
"""
|
||||
try:
|
||||
from agent.auxiliary_client import _read_codex_access_token as _reader
|
||||
|
||||
token = _reader()
|
||||
if isinstance(token, str) and token.strip():
|
||||
return token.strip()
|
||||
return None
|
||||
except Exception as exc:
|
||||
logger.debug("Could not resolve Codex access token: %s", exc)
|
||||
return None
|
||||
|
||||
|
||||
def _sniff_image_mime(raw: bytes) -> Optional[str]:
|
||||
"""Return a safe raster image MIME from magic bytes (not filename labels).
|
||||
|
||||
Delegates magic-byte detection to the shared sniffer in
|
||||
``agent.image_routing`` (single source of truth), then gates the result
|
||||
to :data:`_ACCEPTED_INPUT_MIME` — the raster formats gpt-image-2's
|
||||
``input_image`` actually accepts. SVG/TIFF/ICO (which the shared sniffer
|
||||
also recognizes) are rejected here so they fail locally with a clear
|
||||
error instead of an opaque server-side HTTP 400.
|
||||
"""
|
||||
from agent.image_routing import _sniff_mime_from_bytes
|
||||
|
||||
mime = _sniff_mime_from_bytes(raw)
|
||||
if mime in _ACCEPTED_INPUT_MIME:
|
||||
return mime
|
||||
return None
|
||||
|
||||
|
||||
def _data_url_to_input_image_url(value: str) -> str:
|
||||
"""Validate and canonicalize a data:image URL for Responses input_image."""
|
||||
if "," not in value:
|
||||
raise ValueError("Image data URL is missing a comma separator")
|
||||
header, data = value.split(",", 1)
|
||||
header_lc = header.lower()
|
||||
if not header_lc.startswith("data:image/") or ";base64" not in header_lc:
|
||||
raise ValueError("Only base64 data:image URLs are supported as Codex image inputs")
|
||||
raw = base64.b64decode(data, validate=True)
|
||||
if len(raw) > _MAX_INPUT_IMAGE_BYTES:
|
||||
raise ValueError("Image data URL exceeds 25MB cap")
|
||||
mime = _sniff_image_mime(raw)
|
||||
if mime is None:
|
||||
raise ValueError("Image data URL does not contain supported image bytes")
|
||||
encoded = base64.b64encode(raw).decode("ascii")
|
||||
return f"data:{mime};base64,{encoded}"
|
||||
|
||||
|
||||
def _local_image_to_data_url(value: str) -> str:
|
||||
"""Read a local image path and return a validated data:image URL."""
|
||||
try:
|
||||
from agent.file_safety import get_read_block_error
|
||||
|
||||
blocked = get_read_block_error(value)
|
||||
if blocked:
|
||||
raise ValueError(blocked)
|
||||
except ValueError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.debug("Codex image input read guard unavailable: %s", exc)
|
||||
|
||||
path = Path(os.path.expanduser(value)).resolve()
|
||||
if not path.is_file():
|
||||
raise ValueError(f"Image input path does not exist or is not a file: {value}")
|
||||
size = path.stat().st_size
|
||||
if size <= 0:
|
||||
raise ValueError(f"Image input path is empty: {value}")
|
||||
if size > _MAX_INPUT_IMAGE_BYTES:
|
||||
raise ValueError(f"Image input path exceeds 25MB cap: {value}")
|
||||
raw = path.read_bytes()
|
||||
mime = _sniff_image_mime(raw)
|
||||
if mime is None:
|
||||
raise ValueError(f"Image input path is not a supported image: {value}")
|
||||
encoded = base64.b64encode(raw).decode("ascii")
|
||||
return f"data:{mime};base64,{encoded}"
|
||||
|
||||
|
||||
def _to_input_image_part(value: str) -> Dict[str, str]:
|
||||
"""Convert a URL/data URL/local path into a Responses input_image part."""
|
||||
candidate = (value or "").strip()
|
||||
if not candidate:
|
||||
raise ValueError("Blank image input")
|
||||
lowered = candidate.lower()
|
||||
if lowered.startswith("http://") or lowered.startswith("https://"):
|
||||
image_url = candidate
|
||||
elif lowered.startswith("data:"):
|
||||
image_url = _data_url_to_input_image_url(candidate)
|
||||
else:
|
||||
image_url = _local_image_to_data_url(candidate)
|
||||
return {"type": "input_image", "image_url": image_url}
|
||||
|
||||
|
||||
def _normalize_input_images(
|
||||
image_url: Optional[str],
|
||||
reference_image_urls: Optional[List[str]],
|
||||
) -> List[Dict[str, str]]:
|
||||
"""Collect primary + reference images as ordered Responses content parts."""
|
||||
values: List[str] = []
|
||||
if isinstance(image_url, str) and image_url.strip():
|
||||
values.append(image_url.strip())
|
||||
for ref in (normalize_reference_images(reference_image_urls) or []):
|
||||
values.append(ref)
|
||||
values = values[:_MAX_REFERENCE_IMAGES]
|
||||
return [_to_input_image_part(value) for value in values]
|
||||
|
||||
|
||||
# Progressive preview frames (partial_image_b64) are intermediate renders.
|
||||
# Saving them as finals produced the long-running "smear" failure mode on the
|
||||
# Codex Responses path. Defense in depth:
|
||||
# 1) request layer prefers no progressive frames when the backend honors it
|
||||
# 2) extractor never lets a partial overwrite a final result
|
||||
# 3) generate() only delivers source=final; partial-only / empty are not success
|
||||
# Live streams sometimes still emit a partial event even with 0; that is fine as
|
||||
# long as only a final ``result`` can be saved.
|
||||
_PARTIAL_IMAGES_REQUESTED = 0
|
||||
# Content-agnostic retries when the stream does not yield a final result
|
||||
# (empty stream or progressive-only). No prompt-class branching.
|
||||
_NONFINAL_RETRIES = 1
|
||||
|
||||
|
||||
def _build_responses_payload(
|
||||
*,
|
||||
prompt: str,
|
||||
size: str,
|
||||
quality: str,
|
||||
input_images: Optional[List[Dict[str, str]]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Build the Codex Responses request body for an image_generation call."""
|
||||
content: List[Dict[str, Any]] = [{"type": "input_text", "text": prompt}]
|
||||
if input_images:
|
||||
content.extend(input_images)
|
||||
return {
|
||||
"model": _CODEX_CHAT_MODEL,
|
||||
"store": False,
|
||||
"instructions": _CODEX_INSTRUCTIONS,
|
||||
"input": [{
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": content,
|
||||
}],
|
||||
"tools": [{
|
||||
"type": "image_generation",
|
||||
"model": API_MODEL,
|
||||
"size": size,
|
||||
"quality": quality,
|
||||
"output_format": "png",
|
||||
"background": "opaque",
|
||||
# Prefer 0 progressive preview frames. Preview frames can arrive
|
||||
# without a later final ``result`` and look like smeared /
|
||||
# unfinished images if saved as the deliverable. Even when the
|
||||
# backend still emits a partial event, generate() refuses to
|
||||
# deliver anything except source=final.
|
||||
"partial_images": _PARTIAL_IMAGES_REQUESTED,
|
||||
}],
|
||||
# No ``tool_choice`` is sent: the chatgpt.com/backend-api/codex backend
|
||||
# rejects every shape we have for forcing the hosted ``image_generation``
|
||||
# tool. ``{"type": "allowed_tools", "mode": "required", "tools": [{"type":
|
||||
# "image_generation"}]}`` (and the simpler ``{"type": "image_generation"}``
|
||||
# form) both 400 with ``Tool choice 'image_generation' not found in 'tools'
|
||||
# parameter`` — the backend looks up tool_choice as a *function* name and
|
||||
# never recognizes hosted-tool entries. Letting the host model decide is
|
||||
# the only shape Codex currently accepts; the ``instructions`` above are
|
||||
# what nudge it toward the tool. See issue #19505.
|
||||
"stream": True,
|
||||
}
|
||||
|
||||
|
||||
def _extract_image_candidates(value: Any) -> Tuple[Optional[str], Optional[str]]:
|
||||
"""Return ``(final_result_b64, latest_partial_b64)`` from a payload tree.
|
||||
|
||||
Final ``image_generation_call.result`` and progressive ``partial_image_b64``
|
||||
are tracked separately so a partial can never overwrite a genuine final,
|
||||
including when both coexist in the same event payload.
|
||||
"""
|
||||
result_b64: Optional[str] = None
|
||||
partial_b64: Optional[str] = None
|
||||
|
||||
def walk(node: Any) -> None:
|
||||
nonlocal result_b64, partial_b64
|
||||
if isinstance(node, dict):
|
||||
if node.get("type") == "image_generation_call":
|
||||
result = node.get("result")
|
||||
if isinstance(result, str) and result:
|
||||
result_b64 = result
|
||||
partial = node.get("partial_image_b64")
|
||||
if isinstance(partial, str) and partial:
|
||||
partial_b64 = partial
|
||||
for child in node.values():
|
||||
walk(child)
|
||||
elif isinstance(node, list):
|
||||
for child in node:
|
||||
walk(child)
|
||||
|
||||
walk(value)
|
||||
return result_b64, partial_b64
|
||||
|
||||
|
||||
def _extract_image_b64(value: Any) -> Optional[str]:
|
||||
"""Return image b64 from a payload, preferring final result over partial.
|
||||
|
||||
Progressive ``partial_image_b64`` is only used when no final
|
||||
``image_generation_call.result`` is present in the same payload tree.
|
||||
"""
|
||||
result_b64, partial_b64 = _extract_image_candidates(value)
|
||||
return result_b64 or partial_b64
|
||||
|
||||
|
||||
def _png_pixel_size(raw: bytes) -> Optional[str]:
|
||||
"""Return ``\"{w}x{h}\"`` for a PNG payload, or None if not a PNG IHDR."""
|
||||
import struct
|
||||
|
||||
if len(raw) < 24 or raw[:8] != b"\x89PNG\r\n\x1a\n":
|
||||
return None
|
||||
# IHDR: length(4) + type(4) + width(4) + height(4)
|
||||
if raw[12:16] != b"IHDR":
|
||||
return None
|
||||
width, height = struct.unpack(">II", raw[16:24])
|
||||
return f"{width}x{height}"
|
||||
|
||||
|
||||
def _iter_sse_json(response: Any):
|
||||
"""Yield JSON payloads from an SSE response without OpenAI SDK parsing.
|
||||
|
||||
The ChatGPT/Codex backend can emit image-generation events newer than the
|
||||
pinned Python SDK understands. Parsing raw SSE keeps this provider tolerant
|
||||
of those event-shape changes.
|
||||
"""
|
||||
event_name: Optional[str] = None
|
||||
data_lines: List[str] = []
|
||||
|
||||
def flush():
|
||||
nonlocal event_name, data_lines
|
||||
if not data_lines:
|
||||
event_name = None
|
||||
return None
|
||||
raw = "\n".join(data_lines).strip()
|
||||
event = event_name
|
||||
event_name = None
|
||||
data_lines = []
|
||||
if not raw or raw == "[DONE]":
|
||||
return None
|
||||
payload = json.loads(raw)
|
||||
if isinstance(payload, dict) and event and "type" not in payload:
|
||||
payload["type"] = event
|
||||
return payload
|
||||
|
||||
for line in response.iter_lines():
|
||||
if isinstance(line, bytes):
|
||||
line = line.decode("utf-8", errors="replace")
|
||||
line = str(line)
|
||||
if line == "":
|
||||
payload = flush()
|
||||
if payload is not None:
|
||||
yield payload
|
||||
continue
|
||||
if line.startswith(":"):
|
||||
continue
|
||||
if line.startswith("event:"):
|
||||
event_name = line[len("event:"):].strip()
|
||||
elif line.startswith("data:"):
|
||||
data_lines.append(line[len("data:"):].lstrip())
|
||||
|
||||
payload = flush()
|
||||
if payload is not None:
|
||||
yield payload
|
||||
|
||||
|
||||
def _collect_image_b64(
|
||||
token: str,
|
||||
*,
|
||||
prompt: str,
|
||||
size: str,
|
||||
quality: str,
|
||||
input_images: Optional[List[Dict[str, str]]] = None,
|
||||
) -> Optional[Dict[str, str]]:
|
||||
"""Stream a Codex Responses image_generation call.
|
||||
|
||||
Returns ``{\"b64\": ..., \"source\": \"final\"|\"partial\"}`` or ``None``.
|
||||
|
||||
Final ``result`` frames are preferred across the whole stream. A progressive
|
||||
``partial_image_b64`` is retained only when no final result ever arrives;
|
||||
callers must not treat partial-only as an unconditional success.
|
||||
"""
|
||||
import httpx
|
||||
from agent.codex_headers import codex_cloudflare_headers
|
||||
|
||||
headers = codex_cloudflare_headers(token)
|
||||
headers.update({
|
||||
"Accept": "text/event-stream",
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Content-Type": "application/json",
|
||||
})
|
||||
payload = _build_responses_payload(
|
||||
prompt=prompt,
|
||||
size=size,
|
||||
quality=quality,
|
||||
input_images=input_images,
|
||||
)
|
||||
timeout = httpx.Timeout(300.0, connect=30.0, read=300.0, write=30.0, pool=30.0)
|
||||
|
||||
final_b64: Optional[str] = None
|
||||
partial_b64: Optional[str] = None
|
||||
with httpx.Client(timeout=timeout, headers=headers) as http:
|
||||
with http.stream("POST", f"{_CODEX_BASE_URL}/responses", json=payload) as response:
|
||||
try:
|
||||
response.raise_for_status()
|
||||
except httpx.HTTPStatusError as exc:
|
||||
exc.response.read()
|
||||
raise RuntimeError(
|
||||
f"Codex Responses API returned HTTP {exc.response.status_code}: "
|
||||
f"{_summarize_error_body(exc.response.text)}"
|
||||
) from exc
|
||||
for event in _iter_sse_json(response):
|
||||
result_b64, event_partial = _extract_image_candidates(event)
|
||||
if result_b64:
|
||||
final_b64 = result_b64
|
||||
if event_partial:
|
||||
partial_b64 = event_partial
|
||||
|
||||
if final_b64:
|
||||
return {"b64": final_b64, "source": "final"}
|
||||
if partial_b64:
|
||||
return {"b64": partial_b64, "source": "partial"}
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Provider
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class OpenAICodexImageGenProvider(ImageGenProvider):
|
||||
"""gpt-image-2 routed through ChatGPT/Codex OAuth instead of an API key."""
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "openai-codex"
|
||||
|
||||
@property
|
||||
def display_name(self) -> str:
|
||||
return "OpenAI (Codex auth)"
|
||||
|
||||
def is_available(self) -> bool:
|
||||
if not _read_codex_access_token():
|
||||
return False
|
||||
try:
|
||||
import httpx # noqa: F401
|
||||
except ImportError:
|
||||
return False
|
||||
return True
|
||||
|
||||
def list_models(self) -> List[Dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
"id": model_id,
|
||||
"display": meta["display"],
|
||||
"speed": meta["speed"],
|
||||
"strengths": meta["strengths"],
|
||||
"price": "varies",
|
||||
}
|
||||
for model_id, meta in _MODELS.items()
|
||||
]
|
||||
|
||||
def default_model(self) -> Optional[str]:
|
||||
return DEFAULT_MODEL
|
||||
|
||||
def get_setup_schema(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"name": "OpenAI (Codex auth)",
|
||||
"badge": "free",
|
||||
"tag": "gpt-image-2 via ChatGPT/Codex OAuth — no API key required; supports text and image inputs",
|
||||
"env_vars": [],
|
||||
"post_setup_hint": (
|
||||
"Sign in with `hermes auth codex` (or `hermes setup` → Codex) "
|
||||
"if you haven't already. No API key needed."
|
||||
),
|
||||
}
|
||||
|
||||
def capabilities(self) -> Dict[str, Any]:
|
||||
# The Codex Responses image_generation tool accepts source/reference
|
||||
# images as `input_image` message content parts. Keep this capability
|
||||
# honest so the dynamic `image_generate` schema encourages identity-
|
||||
# preserving edits instead of unrelated text-to-image redraws.
|
||||
return {"modalities": ["text", "image"], "max_reference_images": _MAX_REFERENCE_IMAGES}
|
||||
|
||||
def generate(
|
||||
self,
|
||||
prompt: str,
|
||||
aspect_ratio: str = DEFAULT_ASPECT_RATIO,
|
||||
*,
|
||||
image_url: Optional[str] = None,
|
||||
reference_image_urls: Optional[List[str]] = None,
|
||||
**kwargs: Any,
|
||||
) -> Dict[str, Any]:
|
||||
prompt = (prompt or "").strip()
|
||||
aspect = resolve_aspect_ratio(aspect_ratio)
|
||||
|
||||
if not prompt:
|
||||
return error_response(
|
||||
error="Prompt is required and must be a non-empty string",
|
||||
error_type="invalid_argument",
|
||||
provider="openai-codex",
|
||||
aspect_ratio=aspect,
|
||||
)
|
||||
|
||||
if not _read_codex_access_token():
|
||||
return error_response(
|
||||
error=(
|
||||
"No Codex/ChatGPT OAuth credentials available. Run "
|
||||
"`hermes auth codex` (or `hermes setup` → Codex) to sign in."
|
||||
),
|
||||
error_type="auth_required",
|
||||
provider="openai-codex",
|
||||
aspect_ratio=aspect,
|
||||
)
|
||||
|
||||
try:
|
||||
import httpx # noqa: F401
|
||||
except ImportError:
|
||||
return error_response(
|
||||
error="httpx Python package not installed (pip install httpx)",
|
||||
error_type="missing_dependency",
|
||||
provider="openai-codex",
|
||||
aspect_ratio=aspect,
|
||||
)
|
||||
|
||||
tier_id, meta = _resolve_model()
|
||||
size = _SIZES.get(aspect, _SIZES["square"])
|
||||
|
||||
token = _read_codex_access_token()
|
||||
if not token:
|
||||
return error_response(
|
||||
error=(
|
||||
"No Codex/ChatGPT OAuth credentials available. Run "
|
||||
"`hermes auth codex` (or `hermes setup` → Codex) to sign in."
|
||||
),
|
||||
error_type="auth_required",
|
||||
provider="openai-codex",
|
||||
model=tier_id,
|
||||
prompt=prompt,
|
||||
aspect_ratio=aspect,
|
||||
)
|
||||
|
||||
try:
|
||||
input_images = _normalize_input_images(image_url, reference_image_urls)
|
||||
except Exception as exc:
|
||||
return error_response(
|
||||
error=f"Invalid image input for Codex image editing: {exc}",
|
||||
error_type="invalid_image_input",
|
||||
provider="openai-codex",
|
||||
model=tier_id,
|
||||
prompt=prompt,
|
||||
aspect_ratio=aspect,
|
||||
)
|
||||
|
||||
try:
|
||||
collected: Optional[Dict[str, str]] = None
|
||||
for attempt in range(_NONFINAL_RETRIES + 1):
|
||||
collected = _collect_image_b64(
|
||||
token,
|
||||
prompt=prompt,
|
||||
size=size,
|
||||
quality=meta["quality"],
|
||||
input_images=input_images or None,
|
||||
)
|
||||
if collected and collected.get("source") == "final" and collected.get("b64"):
|
||||
break
|
||||
if attempt < _NONFINAL_RETRIES:
|
||||
kind = (
|
||||
"progressive-only partial frame"
|
||||
if collected and collected.get("source") == "partial"
|
||||
else "no image_generation_call result"
|
||||
)
|
||||
logger.warning(
|
||||
"Codex image stream ended with %s (attempt %s/%s); "
|
||||
"retrying once before failing closed.",
|
||||
kind,
|
||||
attempt + 1,
|
||||
_NONFINAL_RETRIES + 1,
|
||||
)
|
||||
continue
|
||||
break
|
||||
except Exception as exc:
|
||||
logger.debug("Codex image generation failed", exc_info=True)
|
||||
return error_response(
|
||||
error=f"OpenAI image generation via Codex auth failed: {exc}",
|
||||
error_type="api_error",
|
||||
provider="openai-codex",
|
||||
model=tier_id,
|
||||
prompt=prompt,
|
||||
aspect_ratio=aspect,
|
||||
)
|
||||
|
||||
if not collected or not collected.get("b64"):
|
||||
return error_response(
|
||||
error=(
|
||||
"Codex response contained no image_generation_call result "
|
||||
f"after {_NONFINAL_RETRIES + 1} attempt(s)"
|
||||
),
|
||||
error_type="empty_response",
|
||||
provider="openai-codex",
|
||||
model=tier_id,
|
||||
prompt=prompt,
|
||||
aspect_ratio=aspect,
|
||||
)
|
||||
|
||||
image_source = collected.get("source") or "unknown"
|
||||
b64 = collected["b64"]
|
||||
|
||||
# Defense in depth: never deliver a progressive-only frame as success.
|
||||
# Partials are intermediate previews and have presented as smeared /
|
||||
# unfinished images when saved as finals.
|
||||
if image_source != "final":
|
||||
pixel_hint = None
|
||||
try:
|
||||
import base64 as _b64mod
|
||||
|
||||
pixel_hint = _png_pixel_size(_b64mod.b64decode(b64, validate=False))
|
||||
except Exception:
|
||||
pixel_hint = None
|
||||
detail = (
|
||||
"Codex returned only a progressive partial image frame after "
|
||||
f"{_NONFINAL_RETRIES + 1} attempt(s); refusing to save it "
|
||||
"as a final deliverable."
|
||||
)
|
||||
if pixel_hint:
|
||||
detail = f"{detail} partial_pixel_size={pixel_hint}."
|
||||
err = error_response(
|
||||
error=detail,
|
||||
error_type="incomplete_image",
|
||||
provider="openai-codex",
|
||||
model=tier_id,
|
||||
prompt=prompt,
|
||||
aspect_ratio=aspect,
|
||||
)
|
||||
err["image_source"] = image_source
|
||||
err["requested_size"] = size
|
||||
err["partial_pixel_size"] = pixel_hint
|
||||
err["nonfinal_retries"] = _NONFINAL_RETRIES
|
||||
return err
|
||||
|
||||
try:
|
||||
import base64 as _b64mod
|
||||
|
||||
raw_bytes = _b64mod.b64decode(b64)
|
||||
pixel_size = _png_pixel_size(raw_bytes)
|
||||
saved_path = save_b64_image(b64, prefix=f"openai_codex_{tier_id}")
|
||||
except Exception as exc:
|
||||
return error_response(
|
||||
error=f"Could not save image to cache: {exc}",
|
||||
error_type="io_error",
|
||||
provider="openai-codex",
|
||||
model=tier_id,
|
||||
prompt=prompt,
|
||||
aspect_ratio=aspect,
|
||||
)
|
||||
|
||||
return success_response(
|
||||
image=str(saved_path),
|
||||
model=tier_id,
|
||||
prompt=prompt,
|
||||
aspect_ratio=aspect,
|
||||
provider="openai-codex",
|
||||
modality="image" if input_images else "text",
|
||||
extra={
|
||||
"size": size,
|
||||
"quality": meta["quality"],
|
||||
"input_image_count": len(input_images),
|
||||
"image_source": image_source,
|
||||
"requested_size": size,
|
||||
"pixel_size": pixel_size,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Plugin entry point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def register(ctx) -> None:
|
||||
"""Plugin entry point — register the Codex-backed image-gen provider."""
|
||||
ctx.register_image_gen_provider(OpenAICodexImageGenProvider())
|
||||
@@ -0,0 +1,5 @@
|
||||
name: openai-codex
|
||||
version: 1.0.0
|
||||
description: "OpenAI image generation backed by ChatGPT/Codex OAuth (gpt-image-2 via the Responses image_generation tool). Saves generated images to $HERMES_HOME/cache/images/."
|
||||
author: NousResearch
|
||||
kind: backend
|
||||
@@ -0,0 +1,419 @@
|
||||
"""OpenAI image generation backend.
|
||||
|
||||
Exposes OpenAI's ``gpt-image-2`` model at three quality tiers as an
|
||||
:class:`ImageGenProvider` implementation. The tiers are implemented as
|
||||
three virtual model IDs so the ``hermes tools`` model picker and the
|
||||
``image_gen.model`` config key behave like any other multi-model backend:
|
||||
|
||||
gpt-image-2-low ~15s fastest, good for iteration
|
||||
gpt-image-2-medium ~40s default — balanced
|
||||
gpt-image-2-high ~2min slowest, highest fidelity
|
||||
|
||||
All three hit the same underlying API model (``gpt-image-2``) with a
|
||||
different ``quality`` parameter. Output is base64 JSON → saved under
|
||||
``$HERMES_HOME/cache/images/``.
|
||||
|
||||
Selection precedence (first hit wins):
|
||||
|
||||
1. ``OPENAI_IMAGE_MODEL`` env var (escape hatch for scripts / tests)
|
||||
2. ``image_gen.openai.model`` in ``config.yaml``
|
||||
3. ``image_gen.model`` in ``config.yaml`` (when it's one of our tier IDs)
|
||||
4. :data:`DEFAULT_MODEL` — ``gpt-image-2-medium``
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from agent.secret_scope import get_secret
|
||||
from agent.image_gen_provider import (
|
||||
DEFAULT_ASPECT_RATIO,
|
||||
ImageGenProvider,
|
||||
error_response,
|
||||
normalize_reference_images,
|
||||
resolve_aspect_ratio,
|
||||
save_b64_image,
|
||||
save_url_image,
|
||||
success_response,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Model catalog
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# All three IDs resolve to the same underlying API model with a different
|
||||
# ``quality`` setting. ``api_model`` is what gets sent to OpenAI;
|
||||
# ``quality`` is the knob that changes generation time and output fidelity.
|
||||
|
||||
API_MODEL = "gpt-image-2"
|
||||
|
||||
_MODELS: Dict[str, Dict[str, Any]] = {
|
||||
"gpt-image-2-low": {
|
||||
"display": "GPT Image 2 (Low)",
|
||||
"speed": "~15s",
|
||||
"strengths": "Fast iteration, lowest cost",
|
||||
"quality": "low",
|
||||
},
|
||||
"gpt-image-2-medium": {
|
||||
"display": "GPT Image 2 (Medium)",
|
||||
"speed": "~40s",
|
||||
"strengths": "Balanced — default",
|
||||
"quality": "medium",
|
||||
},
|
||||
"gpt-image-2-high": {
|
||||
"display": "GPT Image 2 (High)",
|
||||
"speed": "~2min",
|
||||
"strengths": "Highest fidelity, strongest prompt adherence",
|
||||
"quality": "high",
|
||||
},
|
||||
}
|
||||
|
||||
DEFAULT_MODEL = "gpt-image-2-medium"
|
||||
|
||||
_SIZES = {
|
||||
"landscape": "1536x1024",
|
||||
"square": "1024x1024",
|
||||
"portrait": "1024x1536",
|
||||
}
|
||||
|
||||
|
||||
def _load_openai_config() -> Dict[str, Any]:
|
||||
"""Read ``image_gen`` from config.yaml (returns {} on any failure)."""
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
|
||||
cfg = load_config()
|
||||
section = cfg.get("image_gen") if isinstance(cfg, dict) else None
|
||||
return section if isinstance(section, dict) else {}
|
||||
except Exception as exc:
|
||||
logger.debug("Could not load image_gen config: %s", exc)
|
||||
return {}
|
||||
|
||||
|
||||
def _resolve_model() -> Tuple[str, Dict[str, Any]]:
|
||||
"""Decide which tier to use and return ``(model_id, meta)``."""
|
||||
env_override = os.environ.get("OPENAI_IMAGE_MODEL")
|
||||
if env_override and env_override in _MODELS:
|
||||
return env_override, _MODELS[env_override]
|
||||
|
||||
cfg = _load_openai_config()
|
||||
openai_cfg = cfg.get("openai") if isinstance(cfg.get("openai"), dict) else {}
|
||||
candidate: Optional[str] = None
|
||||
if isinstance(openai_cfg, dict):
|
||||
value = openai_cfg.get("model")
|
||||
if isinstance(value, str) and value in _MODELS:
|
||||
candidate = value
|
||||
if candidate is None:
|
||||
top = cfg.get("model")
|
||||
if isinstance(top, str) and top in _MODELS:
|
||||
candidate = top
|
||||
|
||||
if candidate is not None:
|
||||
return candidate, _MODELS[candidate]
|
||||
|
||||
return DEFAULT_MODEL, _MODELS[DEFAULT_MODEL]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Source-image loading (for image-to-image / edit)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _load_image_bytes(ref: str) -> Tuple[bytes, str]:
|
||||
"""Load image bytes from a URL or local file path.
|
||||
|
||||
Returns ``(data, filename)``. Raises on any network / IO error so the
|
||||
caller can surface a clean error_response.
|
||||
"""
|
||||
ref = ref.strip()
|
||||
lower = ref.lower()
|
||||
if lower.startswith(("http://", "https://")):
|
||||
import requests
|
||||
|
||||
resp = requests.get(ref, timeout=60)
|
||||
resp.raise_for_status()
|
||||
name = ref.split("?", 1)[0].rsplit("/", 1)[-1] or "image.png"
|
||||
return resp.content, name
|
||||
if lower.startswith("data:"):
|
||||
import base64
|
||||
|
||||
header, _, b64 = ref.partition(",")
|
||||
ext = "png"
|
||||
if "image/" in header:
|
||||
ext = header.split("image/", 1)[1].split(";", 1)[0] or "png"
|
||||
return base64.b64decode(b64), f"image.{ext}"
|
||||
# Local file path — enforce the shared credential-read guard before reading.
|
||||
from agent.file_safety import raise_if_read_blocked
|
||||
|
||||
raise_if_read_blocked(ref)
|
||||
with open(ref, "rb") as fh:
|
||||
data = fh.read()
|
||||
name = os.path.basename(ref) or "image.png"
|
||||
return data, name
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Provider
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class OpenAIImageGenProvider(ImageGenProvider):
|
||||
"""OpenAI ``images.generate`` / ``images.edit`` backend — gpt-image-2."""
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "openai"
|
||||
|
||||
@property
|
||||
def display_name(self) -> str:
|
||||
return "OpenAI"
|
||||
|
||||
def is_available(self) -> bool:
|
||||
if not get_secret("OPENAI_API_KEY"):
|
||||
return False
|
||||
try:
|
||||
import openai # noqa: F401
|
||||
except ImportError:
|
||||
return False
|
||||
return True
|
||||
|
||||
def list_models(self) -> List[Dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
"id": model_id,
|
||||
"display": meta["display"],
|
||||
"speed": meta["speed"],
|
||||
"strengths": meta["strengths"],
|
||||
"price": "varies",
|
||||
}
|
||||
for model_id, meta in _MODELS.items()
|
||||
]
|
||||
|
||||
def default_model(self) -> Optional[str]:
|
||||
return DEFAULT_MODEL
|
||||
|
||||
def get_setup_schema(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"name": "OpenAI",
|
||||
"badge": "paid",
|
||||
"tag": "gpt-image-2 at low/medium/high quality tiers — text-to-image & image editing",
|
||||
"env_vars": [
|
||||
{
|
||||
"key": "OPENAI_API_KEY",
|
||||
"prompt": "OpenAI API key",
|
||||
"url": "https://platform.openai.com/api-keys",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
def capabilities(self) -> Dict[str, Any]:
|
||||
# gpt-image-2 supports editing via images.edit() with up to 16 source
|
||||
# images.
|
||||
return {"modalities": ["text", "image"], "max_reference_images": 16}
|
||||
|
||||
def generate(
|
||||
self,
|
||||
prompt: str,
|
||||
aspect_ratio: str = DEFAULT_ASPECT_RATIO,
|
||||
*,
|
||||
image_url: Optional[str] = None,
|
||||
reference_image_urls: Optional[List[str]] = None,
|
||||
**kwargs: Any,
|
||||
) -> Dict[str, Any]:
|
||||
prompt = (prompt or "").strip()
|
||||
aspect = resolve_aspect_ratio(aspect_ratio)
|
||||
|
||||
if not prompt:
|
||||
return error_response(
|
||||
error="Prompt is required and must be a non-empty string",
|
||||
error_type="invalid_argument",
|
||||
provider="openai",
|
||||
aspect_ratio=aspect,
|
||||
)
|
||||
|
||||
api_key = get_secret("OPENAI_API_KEY")
|
||||
if not api_key:
|
||||
return error_response(
|
||||
error=(
|
||||
"OPENAI_API_KEY not set. Run `hermes tools` → Image "
|
||||
"Generation → OpenAI to configure, or `hermes setup` "
|
||||
"to add the key."
|
||||
),
|
||||
error_type="auth_required",
|
||||
provider="openai",
|
||||
aspect_ratio=aspect,
|
||||
)
|
||||
|
||||
try:
|
||||
import openai
|
||||
except ImportError:
|
||||
return error_response(
|
||||
error="openai Python package not installed (pip install openai)",
|
||||
error_type="missing_dependency",
|
||||
provider="openai",
|
||||
aspect_ratio=aspect,
|
||||
)
|
||||
|
||||
tier_id, meta = _resolve_model()
|
||||
size = _SIZES.get(aspect, _SIZES["square"])
|
||||
|
||||
# Collect source images (primary + references) for image-to-image.
|
||||
sources: List[str] = []
|
||||
if isinstance(image_url, str) and image_url.strip():
|
||||
sources.append(image_url.strip())
|
||||
for ref in (normalize_reference_images(reference_image_urls) or []):
|
||||
sources.append(ref)
|
||||
sources = sources[:16] # gpt-image-2 edit caps at 16 images
|
||||
is_edit = bool(sources)
|
||||
modality = "image" if is_edit else "text"
|
||||
|
||||
client = openai.OpenAI(api_key=api_key)
|
||||
|
||||
if is_edit:
|
||||
# images.edit() expects file-like objects. Download/read each
|
||||
# source into a named BytesIO so the SDK sends correct multipart.
|
||||
import io
|
||||
|
||||
try:
|
||||
files = []
|
||||
for ref in sources:
|
||||
data, fname = _load_image_bytes(ref)
|
||||
bio = io.BytesIO(data)
|
||||
bio.name = fname
|
||||
files.append(bio)
|
||||
except Exception as exc:
|
||||
return error_response(
|
||||
error=f"Could not load source image for editing: {exc}",
|
||||
error_type="io_error",
|
||||
provider="openai",
|
||||
model=tier_id,
|
||||
prompt=prompt,
|
||||
aspect_ratio=aspect,
|
||||
)
|
||||
|
||||
try:
|
||||
response = client.images.edit(
|
||||
model=API_MODEL,
|
||||
image=files if len(files) > 1 else files[0],
|
||||
prompt=prompt,
|
||||
size=size, # type: ignore[arg-type] # _SIZES values are valid gpt-image sizes
|
||||
quality=meta["quality"],
|
||||
n=1,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.debug("OpenAI image edit failed", exc_info=True)
|
||||
return error_response(
|
||||
error=f"OpenAI image editing failed: {exc}",
|
||||
error_type="api_error",
|
||||
provider="openai",
|
||||
model=tier_id,
|
||||
prompt=prompt,
|
||||
aspect_ratio=aspect,
|
||||
)
|
||||
else:
|
||||
# gpt-image-2 returns b64_json unconditionally and REJECTS
|
||||
# ``response_format`` as an unknown parameter. Don't send it.
|
||||
payload: Dict[str, Any] = {
|
||||
"model": API_MODEL,
|
||||
"prompt": prompt,
|
||||
"size": size,
|
||||
"n": 1,
|
||||
"quality": meta["quality"],
|
||||
}
|
||||
|
||||
try:
|
||||
response = client.images.generate(**payload)
|
||||
except Exception as exc:
|
||||
logger.debug("OpenAI image generation failed", exc_info=True)
|
||||
return error_response(
|
||||
error=f"OpenAI image generation failed: {exc}",
|
||||
error_type="api_error",
|
||||
provider="openai",
|
||||
model=tier_id,
|
||||
prompt=prompt,
|
||||
aspect_ratio=aspect,
|
||||
)
|
||||
|
||||
data = getattr(response, "data", None) or []
|
||||
if not data:
|
||||
return error_response(
|
||||
error="OpenAI returned no image data",
|
||||
error_type="empty_response",
|
||||
provider="openai",
|
||||
model=tier_id,
|
||||
prompt=prompt,
|
||||
aspect_ratio=aspect,
|
||||
)
|
||||
|
||||
first = data[0]
|
||||
b64 = getattr(first, "b64_json", None)
|
||||
url = getattr(first, "url", None)
|
||||
revised_prompt = getattr(first, "revised_prompt", None)
|
||||
|
||||
if b64:
|
||||
try:
|
||||
saved_path = save_b64_image(b64, prefix=f"openai_{tier_id}")
|
||||
except Exception as exc:
|
||||
return error_response(
|
||||
error=f"Could not save image to cache: {exc}",
|
||||
error_type="io_error",
|
||||
provider="openai",
|
||||
model=tier_id,
|
||||
prompt=prompt,
|
||||
aspect_ratio=aspect,
|
||||
)
|
||||
image_ref = str(saved_path)
|
||||
elif url:
|
||||
# Defensive — gpt-image-2 returns b64 today, but OpenAI's API
|
||||
# has previously returned URLs. Cache the bytes locally so the
|
||||
# gateway never tries to fetch an ephemeral / signed URL after
|
||||
# it expires — same rationale as the xAI provider (#26942).
|
||||
try:
|
||||
saved_path = save_url_image(url, prefix=f"openai_{tier_id}")
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"OpenAI image URL %s could not be cached (%s); falling back to bare URL.",
|
||||
url,
|
||||
exc,
|
||||
)
|
||||
image_ref = url
|
||||
else:
|
||||
image_ref = str(saved_path)
|
||||
else:
|
||||
return error_response(
|
||||
error="OpenAI response contained neither b64_json nor URL",
|
||||
error_type="empty_response",
|
||||
provider="openai",
|
||||
model=tier_id,
|
||||
prompt=prompt,
|
||||
aspect_ratio=aspect,
|
||||
)
|
||||
|
||||
extra: Dict[str, Any] = {"size": size, "quality": meta["quality"]}
|
||||
if revised_prompt:
|
||||
extra["revised_prompt"] = revised_prompt
|
||||
|
||||
return success_response(
|
||||
image=image_ref,
|
||||
model=tier_id,
|
||||
prompt=prompt,
|
||||
aspect_ratio=aspect,
|
||||
provider="openai",
|
||||
modality=modality,
|
||||
extra=extra,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Plugin entry point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def register(ctx) -> None:
|
||||
"""Plugin entry point — wire ``OpenAIImageGenProvider`` into the registry."""
|
||||
ctx.register_image_gen_provider(OpenAIImageGenProvider())
|
||||
@@ -0,0 +1,7 @@
|
||||
name: openai
|
||||
version: 1.0.0
|
||||
description: "OpenAI image generation backend (gpt-image-2). Saves generated images to $HERMES_HOME/cache/images/."
|
||||
author: NousResearch
|
||||
kind: backend
|
||||
requires_env:
|
||||
- OPENAI_API_KEY
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,7 @@
|
||||
name: openrouter
|
||||
version: 1.1.0
|
||||
description: "OpenRouter + Nous Portal image generation. Chat-completions image output (reference-grounded) plus OpenRouter's Dedicated Image API (/images/generations) for gpt-image-2, Krea 2, Qwen Image 3 Pro, MAI-Image-2.5 and Grok Imagine — exact per-model aspect ratios, resolution/quality/background/seed/n, up to 16 reference images. Text-to-image and image-to-image."
|
||||
author: Hermes Agent
|
||||
kind: backend
|
||||
requires_env:
|
||||
- OPENROUTER_API_KEY
|
||||
@@ -0,0 +1,625 @@
|
||||
"""xAI image generation backend.
|
||||
|
||||
Exposes xAI's ``grok-imagine-image`` model as an
|
||||
:class:`ImageGenProvider` implementation.
|
||||
|
||||
Features:
|
||||
- Text-to-image generation
|
||||
- Multiple aspect ratios (1:1, 16:9, 9:16, etc.)
|
||||
- Multiple resolutions (1K, 2K)
|
||||
- Base64 output saved to cache
|
||||
|
||||
Selection precedence (first hit wins):
|
||||
1. ``XAI_IMAGE_MODEL`` env var
|
||||
2. ``image_gen.xai.model`` in ``config.yaml``
|
||||
3. :data:`DEFAULT_MODEL`
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
import requests
|
||||
|
||||
from agent.image_gen_provider import (
|
||||
DEFAULT_ASPECT_RATIO,
|
||||
ImageGenProvider,
|
||||
error_response,
|
||||
normalize_reference_images,
|
||||
resolve_aspect_ratio,
|
||||
save_b64_image,
|
||||
save_url_image,
|
||||
success_response,
|
||||
)
|
||||
from tools.xai_http import (
|
||||
build_xai_storage_options,
|
||||
hermes_xai_user_agent,
|
||||
maybe_mark_xai_storage_notice_seen,
|
||||
read_xai_imagine_storage_config,
|
||||
resolve_xai_http_credentials,
|
||||
xai_storage_notice_text,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Model catalog
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_MODELS: Dict[str, Dict[str, Any]] = {
|
||||
"grok-imagine-image": {
|
||||
"display": "Grok Imagine Image",
|
||||
"speed": "~5-10s",
|
||||
"strengths": "Fast, high-quality",
|
||||
},
|
||||
"grok-imagine-image-2.0": {
|
||||
"display": "Grok Imagine Image 2.0",
|
||||
"speed": "~10-20s",
|
||||
"strengths": "Typography/layout-aware; legible small text; strongest quality.",
|
||||
},
|
||||
"grok-imagine-image-quality": {
|
||||
"display": "Grok Imagine Image (Quality)",
|
||||
"speed": "~10-20s",
|
||||
"strengths": "Higher fidelity / detail; slower than the standard model.",
|
||||
},
|
||||
}
|
||||
|
||||
DEFAULT_MODEL = "grok-imagine-image"
|
||||
|
||||
# Live catalog cache: (models_dict, fetched_monotonic). xAI's
|
||||
# ``/image-generation-models`` endpoint is the source of truth so newly
|
||||
# released Imagine models appear in the picker without a code change;
|
||||
# the static ``_MODELS`` table is the offline fallback and supplies curated
|
||||
# speed/strengths text for the models we know about.
|
||||
_LIVE_CACHE: Optional[Tuple[Dict[str, Dict[str, Any]], float]] = None
|
||||
_LIVE_CACHE_TTL = 300.0
|
||||
_LIVE_TIMEOUT = 10.0
|
||||
|
||||
|
||||
def _fetch_live_models() -> Dict[str, Dict[str, Any]]:
|
||||
"""Fetch image models from xAI's ``/image-generation-models`` endpoint.
|
||||
|
||||
Returns ``{model_id: {"input_modalities": [...], "aliases": [...]}}``.
|
||||
Raises on any failure — callers treat that as "use the static table".
|
||||
"""
|
||||
creds = resolve_xai_http_credentials()
|
||||
api_key = str(creds.get("api_key") or "").strip()
|
||||
if not api_key:
|
||||
raise RuntimeError("no xAI credentials")
|
||||
base_url = str(creds.get("base_url") or "https://api.x.ai/v1").strip().rstrip("/")
|
||||
response = requests.get(
|
||||
f"{base_url}/image-generation-models",
|
||||
headers={
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"User-Agent": hermes_xai_user_agent(),
|
||||
},
|
||||
timeout=_LIVE_TIMEOUT,
|
||||
)
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
entries = payload.get("models") or payload.get("data") or []
|
||||
out: Dict[str, Dict[str, Any]] = {}
|
||||
for entry in entries:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
model_id = entry.get("id") or entry.get("name")
|
||||
if not isinstance(model_id, str) or not model_id.strip():
|
||||
continue
|
||||
out[model_id.strip()] = {
|
||||
"input_modalities": entry.get("input_modalities") or [],
|
||||
"aliases": entry.get("aliases") or [],
|
||||
}
|
||||
return out
|
||||
|
||||
|
||||
def _live_models() -> Dict[str, Dict[str, Any]]:
|
||||
"""Cached live catalog (``{}`` when unreachable)."""
|
||||
global _LIVE_CACHE
|
||||
import time
|
||||
|
||||
if _LIVE_CACHE is not None and time.monotonic() - _LIVE_CACHE[1] < _LIVE_CACHE_TTL:
|
||||
return _LIVE_CACHE[0]
|
||||
try:
|
||||
live = _fetch_live_models()
|
||||
except Exception as exc: # noqa: BLE001 - offline/unauth → static fallback
|
||||
logger.debug("xAI live image model catalog unavailable: %s", exc)
|
||||
live = {}
|
||||
_LIVE_CACHE = (live, time.monotonic())
|
||||
return live
|
||||
|
||||
|
||||
def _catalog() -> Dict[str, Dict[str, Any]]:
|
||||
"""Merged model catalog: live endpoint IDs + curated static metadata.
|
||||
|
||||
Known models keep their curated display/speed/strengths; models xAI
|
||||
ships after this file was written still show up (with generic metadata)
|
||||
so users can pick them the day they launch. Static table alone when the
|
||||
API is unreachable.
|
||||
"""
|
||||
live = _live_models()
|
||||
if not live:
|
||||
return dict(_MODELS)
|
||||
merged: Dict[str, Dict[str, Any]] = {}
|
||||
for model_id in live:
|
||||
meta = _MODELS.get(model_id)
|
||||
if meta is None:
|
||||
meta = {
|
||||
"display": model_id,
|
||||
"speed": "",
|
||||
"strengths": "New xAI Imagine model (from live xAI catalog)",
|
||||
}
|
||||
merged[model_id] = dict(meta)
|
||||
merged[model_id]["input_modalities"] = live[model_id].get("input_modalities") or []
|
||||
# Keep curated entries that the live list may momentarily omit.
|
||||
for model_id, meta in _MODELS.items():
|
||||
merged.setdefault(model_id, dict(meta))
|
||||
return merged
|
||||
|
||||
# xAI aspect ratios (more options than FAL/OpenAI)
|
||||
_XAI_ASPECT_RATIOS = {
|
||||
"landscape": "16:9",
|
||||
"square": "1:1",
|
||||
"portrait": "9:16",
|
||||
"4:3": "4:3",
|
||||
"3:4": "3:4",
|
||||
"3:2": "3:2",
|
||||
"2:3": "2:3",
|
||||
}
|
||||
|
||||
# xAI resolutions
|
||||
_XAI_RESOLUTIONS = {"1k", "2k"}
|
||||
|
||||
DEFAULT_RESOLUTION = "1k"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Config
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _load_xai_config() -> Dict[str, Any]:
|
||||
"""Read ``image_gen.xai`` from config.yaml."""
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
|
||||
cfg = load_config()
|
||||
section = cfg.get("image_gen") if isinstance(cfg, dict) else None
|
||||
xai_section = section.get("xai") if isinstance(section, dict) else None
|
||||
return xai_section if isinstance(xai_section, dict) else {}
|
||||
except Exception as exc:
|
||||
logger.debug("Could not load image_gen.xai config: %s", exc)
|
||||
return {}
|
||||
|
||||
|
||||
def _resolve_model(caller_model: Optional[str] = None) -> Tuple[str, Dict[str, Any]]:
|
||||
"""Decide which model to use and return ``(model_id, meta)``.
|
||||
|
||||
Priority:
|
||||
1. Caller-supplied ``caller_model`` — the dispatcher forwards top-level
|
||||
``image_gen.model`` (what ``hermes tools`` writes) as the ``model``
|
||||
kwarg, mirroring the openrouter provider.
|
||||
2. ``XAI_IMAGE_MODEL`` env override.
|
||||
3. Scoped ``image_gen.xai.model`` in config.yaml.
|
||||
4. :data:`DEFAULT_MODEL`.
|
||||
|
||||
Every candidate is validated against the merged live+static catalog,
|
||||
so a newly released xAI model is selectable the day it appears in the
|
||||
live catalog — no code change required.
|
||||
"""
|
||||
catalog = _catalog()
|
||||
if caller_model and caller_model in catalog:
|
||||
return caller_model, catalog[caller_model]
|
||||
|
||||
env_override = os.environ.get("XAI_IMAGE_MODEL")
|
||||
if env_override and env_override in catalog:
|
||||
return env_override, catalog[env_override]
|
||||
|
||||
cfg = _load_xai_config()
|
||||
candidate = cfg.get("model") if isinstance(cfg.get("model"), str) else None
|
||||
if candidate and candidate in catalog:
|
||||
return candidate, catalog[candidate]
|
||||
|
||||
return DEFAULT_MODEL, catalog.get(DEFAULT_MODEL, _MODELS[DEFAULT_MODEL])
|
||||
|
||||
|
||||
def _resolve_edit_model(caller_model: Optional[str] = None) -> str:
|
||||
"""Model for ``/v1/images/edits`` requests.
|
||||
|
||||
An explicitly selected model (caller kwarg, env, or config) that accepts
|
||||
image input is honored for edits; otherwise fall back to the quality
|
||||
model, which xAI documents as the edit-capable baseline.
|
||||
"""
|
||||
catalog = _catalog()
|
||||
explicit = caller_model or os.environ.get("XAI_IMAGE_MODEL") or (
|
||||
_load_xai_config().get("model") if isinstance(_load_xai_config().get("model"), str) else None
|
||||
)
|
||||
if explicit and explicit in catalog:
|
||||
modalities = catalog[explicit].get("input_modalities") or []
|
||||
if "image" in modalities:
|
||||
return explicit
|
||||
return "grok-imagine-image-quality"
|
||||
|
||||
|
||||
def _resolve_resolution() -> str:
|
||||
"""Get configured resolution."""
|
||||
cfg = _load_xai_config()
|
||||
res = cfg.get("resolution") if isinstance(cfg.get("resolution"), str) else None
|
||||
if res and res in _XAI_RESOLUTIONS:
|
||||
return res
|
||||
return DEFAULT_RESOLUTION
|
||||
|
||||
|
||||
def _xai_image_field(source: str) -> Dict[str, str]:
|
||||
"""Build the xAI ``image`` field for an edit request.
|
||||
|
||||
xAI's ``/v1/images/edits`` accepts a public HTTPS URL or a base64 data URI.
|
||||
Local file paths are read and encoded into a ``data:`` URI.
|
||||
"""
|
||||
source = source.strip()
|
||||
lower = source.lower()
|
||||
if lower.startswith(("http://", "https://", "data:")):
|
||||
return {"url": source, "type": "image_url"}
|
||||
# Local file path → base64 data URI.
|
||||
import base64
|
||||
import os as _os
|
||||
|
||||
# Enforce the shared credential-read guard before reading local bytes
|
||||
# (same boundary the OpenAI / OpenRouter / Codex image providers apply).
|
||||
from agent.file_safety import raise_if_read_blocked
|
||||
|
||||
raise_if_read_blocked(source)
|
||||
with open(_os.path.expanduser(source), "rb") as fh: # windows-footgun: ok
|
||||
raw = fh.read()
|
||||
ext = (_os.path.splitext(source)[1].lstrip(".") or "png").lower()
|
||||
if ext == "jpg":
|
||||
ext = "jpeg"
|
||||
b64 = base64.b64encode(raw).decode("utf-8")
|
||||
return {"url": f"data:image/{ext};base64,{b64}", "type": "image_url"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Provider
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class XAIImageGenProvider(ImageGenProvider):
|
||||
"""xAI ``grok-imagine-image`` backend."""
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "xai"
|
||||
|
||||
@property
|
||||
def display_name(self) -> str:
|
||||
return "xAI (Grok)"
|
||||
|
||||
def is_available(self) -> bool:
|
||||
creds = resolve_xai_http_credentials()
|
||||
return bool(creds.get("api_key"))
|
||||
|
||||
def list_models(self) -> List[Dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
"id": model_id,
|
||||
"display": meta.get("display", model_id),
|
||||
"speed": meta.get("speed", ""),
|
||||
"strengths": meta.get("strengths", ""),
|
||||
}
|
||||
for model_id, meta in _catalog().items()
|
||||
]
|
||||
|
||||
def get_setup_schema(self) -> Dict[str, Any]:
|
||||
# Auth resolution is delegated to the shared ``xai_grok`` post_setup
|
||||
# hook (``hermes_cli/tools_config.py``); identical to the TTS / video
|
||||
# gen entries so users see the same OAuth-or-API-key choice for every
|
||||
# xAI service.
|
||||
storage_notice = xai_storage_notice_text("image_gen")
|
||||
tag = (
|
||||
"grok-imagine-image - text-to-image & image editing; uses xAI "
|
||||
"Grok OAuth or XAI_API_KEY"
|
||||
)
|
||||
if storage_notice:
|
||||
tag += f". {storage_notice}"
|
||||
return {
|
||||
"name": "xAI Grok Imagine (image)",
|
||||
"badge": "paid",
|
||||
"tag": tag,
|
||||
"env_vars": [],
|
||||
"post_setup": "xai_grok",
|
||||
}
|
||||
|
||||
def capabilities(self) -> Dict[str, Any]:
|
||||
# xAI's /v1/images/edits supports image editing via grok-imagine-image
|
||||
# -quality, including up to 3 total source images.
|
||||
return {
|
||||
"modalities": ["text", "image"],
|
||||
"max_reference_images": 2,
|
||||
"max_source_images": 3,
|
||||
}
|
||||
|
||||
def generate(
|
||||
self,
|
||||
prompt: str,
|
||||
aspect_ratio: str = DEFAULT_ASPECT_RATIO,
|
||||
*,
|
||||
image_url: Optional[str] = None,
|
||||
reference_image_urls: Optional[List[str]] = None,
|
||||
**kwargs: Any,
|
||||
) -> Dict[str, Any]:
|
||||
"""Generate an image (text-to-image) or edit a source image (image-to-image).
|
||||
|
||||
Routing: when ``image_url`` is provided, POST to ``/v1/images/edits``
|
||||
with the source image; otherwise POST to ``/v1/images/generations``.
|
||||
Per xAI docs, editing uses the ``grok-imagine-image-quality`` model and
|
||||
a JSON body (the OpenAI SDK's multipart ``images.edit()`` is NOT
|
||||
supported by xAI).
|
||||
"""
|
||||
creds = resolve_xai_http_credentials()
|
||||
api_key = str(creds.get("api_key") or "").strip()
|
||||
provider_name = str(creds.get("provider") or "xai").strip() or "xai"
|
||||
if not api_key:
|
||||
return error_response(
|
||||
error="No xAI credentials found. Configure xAI OAuth in `hermes model` or set XAI_API_KEY.",
|
||||
error_type="missing_api_key",
|
||||
provider=provider_name,
|
||||
aspect_ratio=aspect_ratio,
|
||||
)
|
||||
|
||||
model_id, meta = _resolve_model(kwargs.get("model"))
|
||||
aspect = resolve_aspect_ratio(aspect_ratio)
|
||||
xai_ar = _XAI_ASPECT_RATIOS.get(aspect, "1:1")
|
||||
resolution = _resolve_resolution()
|
||||
xai_res = resolution if resolution in _XAI_RESOLUTIONS else DEFAULT_RESOLUTION
|
||||
|
||||
source_images: List[str] = []
|
||||
if isinstance(image_url, str) and image_url.strip():
|
||||
source_images.append(image_url.strip())
|
||||
refs = normalize_reference_images(reference_image_urls)
|
||||
if refs:
|
||||
source_images.extend(refs)
|
||||
if len(source_images) > 3:
|
||||
return error_response(
|
||||
error="xAI image editing supports at most 3 source images",
|
||||
error_type="too_many_references",
|
||||
provider=provider_name,
|
||||
model="grok-imagine-image-quality",
|
||||
prompt=prompt,
|
||||
aspect_ratio=aspect,
|
||||
)
|
||||
for index, source in enumerate(source_images):
|
||||
field = "image_url" if index == 0 and image_url and image_url.strip() == source else "reference_image_urls"
|
||||
lower = source.lower()
|
||||
if not lower.startswith(("http://", "https://", "data:")):
|
||||
path = Path(source).expanduser()
|
||||
if not path.is_file():
|
||||
return error_response(
|
||||
error=(
|
||||
f"{field} must be a public HTTPS URL or data URI "
|
||||
"(e.g. the `image`/`public_url` from a prior Imagine result)"
|
||||
),
|
||||
error_type="invalid_image_url",
|
||||
provider=provider_name,
|
||||
model="grok-imagine-image-quality",
|
||||
prompt=prompt,
|
||||
aspect_ratio=aspect,
|
||||
)
|
||||
is_edit = bool(source_images)
|
||||
modality = "image" if is_edit else "text"
|
||||
|
||||
headers = {
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": hermes_xai_user_agent(),
|
||||
}
|
||||
|
||||
base_url = str(creds.get("base_url") or "https://api.x.ai/v1").strip().rstrip("/")
|
||||
storage_options = build_xai_storage_options(
|
||||
"image_gen",
|
||||
filename_prefix="hermes-xai-image",
|
||||
extension="png",
|
||||
)
|
||||
storage_notice = maybe_mark_xai_storage_notice_seen("image_gen")
|
||||
storage_cfg = read_xai_imagine_storage_config("image_gen")
|
||||
|
||||
if is_edit:
|
||||
# Editing needs an image-input-capable model. An explicit user
|
||||
# selection that accepts image input (e.g. grok-imagine-image-2.0)
|
||||
# is honored; otherwise the documented quality baseline is used.
|
||||
# The source image may be a public URL or a base64 data URI;
|
||||
# local file paths are converted to a data URI here.
|
||||
edit_model = _resolve_edit_model(kwargs.get("model"))
|
||||
try:
|
||||
image_fields = [_xai_image_field(source) for source in source_images]
|
||||
except Exception as exc:
|
||||
return error_response(
|
||||
error=f"Could not load source image for editing: {exc}",
|
||||
error_type="io_error",
|
||||
provider=provider_name,
|
||||
model=edit_model,
|
||||
prompt=prompt,
|
||||
aspect_ratio=aspect,
|
||||
)
|
||||
payload: Dict[str, Any] = {
|
||||
"model": edit_model,
|
||||
"prompt": prompt,
|
||||
}
|
||||
if len(image_fields) == 1:
|
||||
payload["image"] = image_fields[0]
|
||||
else:
|
||||
payload["images"] = image_fields
|
||||
endpoint_url = f"{base_url}/images/edits"
|
||||
model_id = edit_model
|
||||
else:
|
||||
payload = {
|
||||
"model": model_id,
|
||||
"prompt": prompt,
|
||||
"aspect_ratio": xai_ar,
|
||||
"resolution": xai_res,
|
||||
}
|
||||
endpoint_url = f"{base_url}/images/generations"
|
||||
if storage_options is not None:
|
||||
payload["storage_options"] = storage_options
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
endpoint_url,
|
||||
headers=headers,
|
||||
json=payload,
|
||||
timeout=120,
|
||||
)
|
||||
response.raise_for_status()
|
||||
except requests.HTTPError as exc:
|
||||
response = exc.response
|
||||
status = response.status_code if response is not None else 0
|
||||
try:
|
||||
err_msg = response.json().get("error", {}).get("message", response.text[:300])
|
||||
except Exception:
|
||||
err_msg = response.text[:300] if response is not None else str(exc)
|
||||
logger.error("xAI image gen failed (%d): %s", status, err_msg)
|
||||
return error_response(
|
||||
error=f"xAI image generation failed ({status}): {err_msg}",
|
||||
error_type="api_error",
|
||||
provider=provider_name,
|
||||
model=model_id,
|
||||
prompt=prompt,
|
||||
aspect_ratio=aspect,
|
||||
)
|
||||
except requests.Timeout:
|
||||
return error_response(
|
||||
error="xAI image generation timed out (120s)",
|
||||
error_type="timeout",
|
||||
provider=provider_name,
|
||||
model=model_id,
|
||||
prompt=prompt,
|
||||
aspect_ratio=aspect,
|
||||
)
|
||||
except requests.ConnectionError as exc:
|
||||
return error_response(
|
||||
error=f"xAI connection error: {exc}",
|
||||
error_type="connection_error",
|
||||
provider=provider_name,
|
||||
model=model_id,
|
||||
prompt=prompt,
|
||||
aspect_ratio=aspect,
|
||||
)
|
||||
|
||||
try:
|
||||
result = response.json()
|
||||
except Exception as exc:
|
||||
return error_response(
|
||||
error=f"xAI returned invalid JSON: {exc}",
|
||||
error_type="invalid_response",
|
||||
provider=provider_name,
|
||||
model=model_id,
|
||||
prompt=prompt,
|
||||
aspect_ratio=aspect,
|
||||
)
|
||||
|
||||
# Parse response - xAI returns data[0].b64_json, data[0].url, and
|
||||
# optionally data[0].file_output when storage_options were requested.
|
||||
data = result.get("data", [])
|
||||
if not data:
|
||||
return error_response(
|
||||
error="xAI returned no image data",
|
||||
error_type="empty_response",
|
||||
provider=provider_name,
|
||||
model=model_id,
|
||||
prompt=prompt,
|
||||
aspect_ratio=aspect,
|
||||
)
|
||||
|
||||
first = data[0]
|
||||
b64 = first.get("b64_json")
|
||||
url = first.get("url")
|
||||
file_output = first.get("file_output") if isinstance(first, dict) else None
|
||||
file_output = file_output if isinstance(file_output, dict) else {}
|
||||
public_url = file_output.get("public_url") if isinstance(file_output.get("public_url"), str) else None
|
||||
|
||||
if public_url:
|
||||
image_ref = public_url
|
||||
elif b64:
|
||||
try:
|
||||
saved_path = save_b64_image(b64, prefix=f"xai_{model_id}")
|
||||
except Exception as exc:
|
||||
return error_response(
|
||||
error=f"Could not save image to cache: {exc}",
|
||||
error_type="io_error",
|
||||
provider="xai",
|
||||
model=model_id,
|
||||
prompt=prompt,
|
||||
aspect_ratio=aspect,
|
||||
)
|
||||
image_ref = str(saved_path)
|
||||
elif url:
|
||||
# xAI's grok-imagine-image returns ephemeral ``imgen.x.ai/xai-tmp-*``
|
||||
# URLs that 404 within minutes — by the time Telegram's
|
||||
# ``send_photo`` or any downstream consumer fetches them, the
|
||||
# asset is gone (#26942). Materialise the bytes locally at
|
||||
# tool-completion time so the gateway has a stable file path to
|
||||
# upload, mirroring the b64 branch above and the audio_cache
|
||||
# pattern used by text_to_speech.
|
||||
try:
|
||||
saved_path = save_url_image(url, prefix=f"xai_{model_id}")
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"xAI image URL %s could not be cached (%s); falling back to bare URL.",
|
||||
url,
|
||||
exc,
|
||||
)
|
||||
image_ref = url
|
||||
else:
|
||||
image_ref = str(saved_path)
|
||||
else:
|
||||
return error_response(
|
||||
error="xAI response contained neither b64_json nor URL",
|
||||
error_type="empty_response",
|
||||
provider="xai",
|
||||
model=model_id,
|
||||
prompt=prompt,
|
||||
aspect_ratio=aspect,
|
||||
)
|
||||
|
||||
extra: Dict[str, Any] = {
|
||||
"storage_enabled": bool(storage_cfg["enabled"]),
|
||||
}
|
||||
if not is_edit:
|
||||
extra["resolution"] = xai_res
|
||||
if storage_notice:
|
||||
extra["storage_notice"] = storage_notice
|
||||
if public_url:
|
||||
extra["public_url"] = public_url
|
||||
if file_output:
|
||||
for key in (
|
||||
"filename",
|
||||
"expires_at",
|
||||
"public_url_expires_at",
|
||||
"public_url_error",
|
||||
"storage_error",
|
||||
):
|
||||
if key in file_output:
|
||||
extra[key] = file_output[key]
|
||||
if result.get("usage"):
|
||||
extra["usage"] = result["usage"]
|
||||
|
||||
return success_response(
|
||||
image=image_ref,
|
||||
model=model_id,
|
||||
prompt=prompt,
|
||||
aspect_ratio=aspect,
|
||||
provider="xai",
|
||||
modality=modality,
|
||||
extra=extra,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Plugin registration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def register(ctx: Any) -> None:
|
||||
"""Register this provider with the image gen registry."""
|
||||
ctx.register_image_gen_provider(XAIImageGenProvider())
|
||||
@@ -0,0 +1,7 @@
|
||||
name: xai
|
||||
version: 1.0.0
|
||||
description: "xAI image generation backend (grok-imagine-image). Text-to-image."
|
||||
author: Julien Talbot
|
||||
kind: backend
|
||||
requires_env:
|
||||
- XAI_API_KEY
|
||||
+4787
File diff suppressed because it is too large
Load Diff
+1593
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "kanban",
|
||||
"label": "Kanban",
|
||||
"description": "Multi-agent collaboration board — drag-drop cards across columns, read comment threads, see which profile is running what",
|
||||
"icon": "Package",
|
||||
"version": "1.0.0",
|
||||
"tab": {
|
||||
"path": "/kanban",
|
||||
"position": "after:skills"
|
||||
},
|
||||
"entry": "dist/index.js",
|
||||
"css": "dist/style.css",
|
||||
"api": "plugin_api.py"
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,32 @@
|
||||
# DEPRECATED — the kanban dispatcher now runs inside the gateway by
|
||||
# default (config key: kanban.dispatch_in_gateway, default true). To
|
||||
# migrate:
|
||||
#
|
||||
# systemctl --user disable --now hermes-kanban-dispatcher.service
|
||||
# # then make sure a gateway is running; e.g. a systemd user unit
|
||||
# # for `hermes gateway start`. The gateway hosts the dispatcher.
|
||||
#
|
||||
# This unit is kept for users who truly cannot run the gateway (host
|
||||
# policy forbids long-lived services, etc.). It now invokes the
|
||||
# standalone dispatcher via the explicit --force flag, so nobody
|
||||
# accidentally keeps two dispatchers racing against the same
|
||||
# kanban.db. Running this unit AND a gateway with
|
||||
# dispatch_in_gateway=true is NOT supported.
|
||||
|
||||
[Unit]
|
||||
Description=Hermes Kanban dispatcher (DEPRECATED standalone daemon — prefer gateway-embedded dispatch)
|
||||
Documentation=https://hermes-agent.nousresearch.com/docs/user-guide/features/kanban
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=/usr/bin/env hermes kanban daemon --force --interval 60 --pidfile %t/hermes-kanban-dispatcher.pid
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
# Log to the journal via stdout/stderr; the dispatcher also writes per-task
|
||||
# worker output to $HERMES_HOME/kanban/logs/<task>.log.
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
@@ -0,0 +1,778 @@
|
||||
"""Memory provider plugin discovery.
|
||||
|
||||
Scans four sources for memory provider plugins:
|
||||
|
||||
1. Bundled providers: ``plugins/memory/<name>/`` (shipped with hermes-agent)
|
||||
2. User-installed providers: ``$HERMES_HOME/plugins/<name>/``
|
||||
3. Project-local providers: ``./.hermes/plugins/<name>/``, opt-in via
|
||||
``HERMES_ENABLE_PROJECT_PLUGINS``
|
||||
4. Pip-installed providers: ``hermes_agent.memory_providers`` entry points
|
||||
|
||||
Directory providers must contain ``__init__.py`` with a class implementing
|
||||
the MemoryProvider ABC. Pip packages expose a provider or ``register(ctx)``
|
||||
callback through the entry-point group.
|
||||
|
||||
These are the same four sources the general ``PluginManager`` scans, but the
|
||||
precedence is deliberately the reverse of its later-source-wins order: here
|
||||
**bundled wins**, then user, then project, then entry point. A memory provider
|
||||
is activated by name, so letting a directory dropped into the working tree
|
||||
shadow a shipped provider would silently redirect the agent's memory. Changing
|
||||
this order is a breaking change, not a cleanup.
|
||||
|
||||
Only ONE provider can be active at a time, selected via
|
||||
``memory.provider`` in config.yaml.
|
||||
|
||||
Usage:
|
||||
from plugins.memory import discover_memory_providers, load_memory_provider
|
||||
|
||||
available = discover_memory_providers() # [(name, desc, available), ...]
|
||||
provider = load_memory_provider("mnemosyne") # MemoryProvider instance
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import importlib.machinery
|
||||
import importlib.metadata
|
||||
import importlib.util
|
||||
import logging
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import List, Optional, Tuple, TYPE_CHECKING
|
||||
from hermes_cli.config import cfg_get
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from agent.memory_provider import MemoryProvider
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_MEMORY_PLUGINS_DIR = Path(__file__).parent
|
||||
ENTRY_POINTS_GROUP = "hermes_agent.memory_providers"
|
||||
_REGISTERED_MEMORY_PROVIDER_SKILLS: dict[str, Path] = {}
|
||||
|
||||
# Synthetic parent package for user-installed providers, so they don't
|
||||
# collide with bundled providers in sys.modules.
|
||||
_USER_NAMESPACE = "_hermes_user_memory"
|
||||
|
||||
|
||||
def _register_synthetic_package(name: str, search_locations: List[str]) -> None:
|
||||
"""Register an empty package shell in sys.modules.
|
||||
|
||||
User-installed providers import as ``_hermes_user_memory.<name>``, a
|
||||
dotted name whose parents exist nowhere on disk. Unless those parents
|
||||
are present in ``sys.modules``, any relative import inside the plugin
|
||||
(``from . import config``) fails with
|
||||
``ModuleNotFoundError: No module named '_hermes_user_memory'`` — the
|
||||
same reason the loader already registers ``plugins`` and
|
||||
``plugins.memory`` for bundled providers.
|
||||
"""
|
||||
if name in sys.modules:
|
||||
return
|
||||
spec = importlib.machinery.ModuleSpec(name, None, is_package=True)
|
||||
spec.submodule_search_locations = search_locations
|
||||
sys.modules[name] = importlib.util.module_from_spec(spec)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Directory helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _get_user_plugins_dir() -> Optional[Path]:
|
||||
"""Return ``$HERMES_HOME/plugins/`` or None if unavailable."""
|
||||
try:
|
||||
from hermes_constants import get_hermes_home
|
||||
d = get_hermes_home() / "plugins"
|
||||
return d if d.is_dir() else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _get_project_plugins_dir() -> Optional[Path]:
|
||||
"""Return ``./.hermes/plugins/`` or None if unavailable or not opted in.
|
||||
|
||||
Gated on ``HERMES_ENABLE_PROJECT_PLUGINS`` exactly as the general
|
||||
``PluginManager`` gates its own project scan — a repository you merely
|
||||
``cd`` into must not be able to offer the agent a memory backend.
|
||||
"""
|
||||
try:
|
||||
from hermes_cli.plugins import _env_enabled
|
||||
|
||||
if not _env_enabled("HERMES_ENABLE_PROJECT_PLUGINS"):
|
||||
return None
|
||||
d = Path.cwd() / ".hermes" / "plugins"
|
||||
return d if d.is_dir() else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _is_memory_provider_dir(path: Path) -> bool:
|
||||
"""Heuristic: does *path* look like a memory provider plugin?
|
||||
|
||||
Checks for ``register_memory_provider`` or ``MemoryProvider`` in the
|
||||
``__init__.py`` source. Cheap text scan — no import needed.
|
||||
"""
|
||||
init_file = path / "__init__.py"
|
||||
if not init_file.exists():
|
||||
return False
|
||||
try:
|
||||
source = init_file.read_text(errors="replace", encoding="utf-8")[:8192]
|
||||
return "register_memory_provider" in source or "MemoryProvider" in source
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _iter_provider_dirs() -> List[Tuple[str, Path]]:
|
||||
"""Yield ``(name, path)`` for all discovered provider directories.
|
||||
|
||||
Scans bundled, then user-installed, then project-local. Bundled takes
|
||||
precedence on name collisions (first-seen wins via ``seen`` set).
|
||||
"""
|
||||
seen: set = set()
|
||||
dirs: List[Tuple[str, Path]] = []
|
||||
|
||||
# 1. Bundled providers (plugins/memory/<name>/)
|
||||
if _MEMORY_PLUGINS_DIR.is_dir():
|
||||
for child in sorted(_MEMORY_PLUGINS_DIR.iterdir()):
|
||||
if not child.is_dir() or child.name.startswith(("_", ".")):
|
||||
continue
|
||||
if not (child / "__init__.py").exists():
|
||||
continue
|
||||
seen.add(child.name)
|
||||
dirs.append((child.name, child))
|
||||
|
||||
# 2. User-installed providers ($HERMES_HOME/plugins/<name>/)
|
||||
# 3. Project-local providers (./.hermes/plugins/<name>/), opt-in
|
||||
for source_dir in (_get_user_plugins_dir(), _get_project_plugins_dir()):
|
||||
if not source_dir:
|
||||
continue
|
||||
for child in sorted(source_dir.iterdir()):
|
||||
if not child.is_dir() or child.name.startswith(("_", ".")):
|
||||
continue
|
||||
if child.name in seen:
|
||||
continue # earlier source wins
|
||||
if not _is_memory_provider_dir(child):
|
||||
continue # skip non-memory plugins
|
||||
seen.add(child.name)
|
||||
dirs.append((child.name, child))
|
||||
|
||||
return dirs
|
||||
|
||||
|
||||
def _iter_entry_points():
|
||||
"""Yield pip-installed memory provider entry points."""
|
||||
try:
|
||||
eps = importlib.metadata.entry_points()
|
||||
if hasattr(eps, "select"):
|
||||
return list(eps.select(group=ENTRY_POINTS_GROUP))
|
||||
if isinstance(eps, dict):
|
||||
return list(eps.get(ENTRY_POINTS_GROUP, []))
|
||||
return [ep for ep in eps if ep.group == ENTRY_POINTS_GROUP]
|
||||
except Exception as exc:
|
||||
logger.debug("Memory provider entry-point scan failed: %s", exc)
|
||||
return []
|
||||
|
||||
|
||||
def find_provider_dir(name: str) -> Optional[Path]:
|
||||
"""Resolve a provider name to the directory holding its files.
|
||||
|
||||
Checks bundled, then user-installed, then project-local, then the package
|
||||
directory of a pip entry-point provider.
|
||||
|
||||
The entry-point case matters because two of a provider's files are read
|
||||
from disk rather than imported: ``config_schema.py`` (loaded by path so the
|
||||
web server never pulls in the agent runtime — see
|
||||
``plugins/memory/config_schema.py``) and ``cli.py`` (loaded by
|
||||
``discover_plugin_cli_commands`` at argparse time). Without a directory, a
|
||||
pip-installed provider silently loses its dashboard config panel and its
|
||||
``hermes <provider>`` subcommands — working, but a second-class citizen next
|
||||
to a directory install.
|
||||
"""
|
||||
# Bundled
|
||||
bundled = _MEMORY_PLUGINS_DIR / name
|
||||
if bundled.is_dir() and (bundled / "__init__.py").exists():
|
||||
return bundled
|
||||
# User-installed, then project-local
|
||||
for source_dir in (_get_user_plugins_dir(), _get_project_plugins_dir()):
|
||||
if not source_dir:
|
||||
continue
|
||||
candidate = source_dir / name
|
||||
if candidate.is_dir() and _is_memory_provider_dir(candidate):
|
||||
return candidate
|
||||
# Pip entry point
|
||||
return _entry_point_package_dir(find_provider_entry_point(name))
|
||||
|
||||
|
||||
def _entry_point_package_dir(entry_point) -> Optional[Path]:
|
||||
"""The directory of an entry point's module, resolved WITHOUT importing it.
|
||||
|
||||
Discovery must stay free of third-party imports: ``find_provider_dir`` is
|
||||
called from the dashboard and from argparse setup, long before the operator
|
||||
has selected a provider, so importing every installed candidate would run
|
||||
arbitrary code on the strength of a package merely being present.
|
||||
``resolve_module_origin`` walks the module's file layout instead.
|
||||
|
||||
Only package entry points (``pkg/__init__.py``) yield a directory — a
|
||||
provider pointed at a bare ``module.py`` has nowhere to put a sibling
|
||||
``config_schema.py``, so it correctly resolves to None.
|
||||
"""
|
||||
if entry_point is None:
|
||||
return None
|
||||
try:
|
||||
from hermes_cli.plugins import resolve_module_origin
|
||||
|
||||
module_name = (entry_point.value or "").split(":")[0].strip()
|
||||
origin = resolve_module_origin(module_name)
|
||||
if not origin:
|
||||
return None
|
||||
path = Path(origin)
|
||||
return path.parent if path.name == "__init__.py" else None
|
||||
except Exception as exc:
|
||||
logger.debug("Could not resolve directory for entry point '%s': %s",
|
||||
getattr(entry_point, "name", "?"), exc)
|
||||
return None
|
||||
|
||||
|
||||
def find_provider_entry_point(name: str):
|
||||
"""Resolve a provider name to a pip entry point, if installed."""
|
||||
for entry_point in _iter_entry_points():
|
||||
if entry_point.name == name:
|
||||
return entry_point
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def list_memory_provider_names() -> List[str]:
|
||||
"""Cheap name-only listing of discoverable memory providers.
|
||||
|
||||
Unlike :func:`discover_memory_providers`, this does NOT import provider
|
||||
modules or run availability checks — a directory scan plus entry-point
|
||||
*enumeration*, which reads distribution metadata without executing any of
|
||||
it. Safe to call at module-import time (e.g. when building the dashboard
|
||||
config schema, where it fills the ``memory.provider`` dropdown).
|
||||
"""
|
||||
names = {name for name, _ in _iter_provider_dirs()}
|
||||
names.update(ep.name for ep in _iter_entry_points())
|
||||
return sorted(names)
|
||||
|
||||
|
||||
def discover_memory_providers() -> List[Tuple[str, str, bool]]:
|
||||
"""Scan directory and pip entry-point memory providers.
|
||||
|
||||
Returns list of (name, description, is_available) tuples.
|
||||
Bundled providers take precedence on name collisions, followed by
|
||||
user-installed directory providers, then pip entry-point providers.
|
||||
"""
|
||||
results = []
|
||||
seen: set[str] = set()
|
||||
|
||||
for name, child in _iter_provider_dirs():
|
||||
# Read description from plugin.yaml if available
|
||||
desc = ""
|
||||
yaml_file = child / "plugin.yaml"
|
||||
if yaml_file.exists():
|
||||
try:
|
||||
import yaml
|
||||
with open(yaml_file, encoding="utf-8-sig") as f:
|
||||
meta = yaml.safe_load(f) or {}
|
||||
desc = meta.get("description", "")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Quick availability check — try loading and calling is_available()
|
||||
available = True
|
||||
try:
|
||||
provider = _load_provider_from_dir(child, register_skills=False)
|
||||
if provider:
|
||||
available = provider.is_available()
|
||||
else:
|
||||
available = False
|
||||
except Exception:
|
||||
available = False
|
||||
|
||||
results.append((name, desc, available))
|
||||
seen.add(name)
|
||||
|
||||
for entry_point in _iter_entry_points():
|
||||
name = entry_point.name
|
||||
if name in seen:
|
||||
continue
|
||||
desc = ""
|
||||
available = True
|
||||
try:
|
||||
provider = _load_provider_from_entry_point(
|
||||
entry_point,
|
||||
register_skills=False,
|
||||
)
|
||||
if provider:
|
||||
available = provider.is_available()
|
||||
else:
|
||||
available = False
|
||||
except Exception:
|
||||
available = False
|
||||
|
||||
results.append((name, desc, available))
|
||||
seen.add(name)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def load_memory_provider(
|
||||
name: str,
|
||||
*,
|
||||
register_skills: Optional[bool] = None,
|
||||
) -> Optional["MemoryProvider"]:
|
||||
"""Load and return a MemoryProvider instance by name.
|
||||
|
||||
Checks bundled (``plugins/memory/<name>/``), user-installed
|
||||
(``$HERMES_HOME/plugins/<name>/``), and pip entry-point providers.
|
||||
Bundled providers take precedence on name collisions.
|
||||
|
||||
Skills register only when *name* is the configured active provider unless
|
||||
``register_skills`` is passed explicitly. This keeps status and setup
|
||||
inspection of inactive providers free of registry side effects.
|
||||
|
||||
Returns None if the provider is not found or fails to load.
|
||||
"""
|
||||
if register_skills is None:
|
||||
register_skills = name == _get_active_memory_provider()
|
||||
|
||||
provider_dir = find_provider_dir(name)
|
||||
entry_point = None if provider_dir else find_provider_entry_point(name)
|
||||
if not provider_dir and entry_point is None:
|
||||
logger.debug(
|
||||
"Memory provider '%s' not found in bundled, user plugins, or entry points",
|
||||
name,
|
||||
)
|
||||
return None
|
||||
|
||||
try:
|
||||
provider = (
|
||||
_load_provider_from_dir(provider_dir, register_skills=register_skills)
|
||||
if provider_dir
|
||||
else _load_provider_from_entry_point(
|
||||
entry_point,
|
||||
register_skills=register_skills,
|
||||
)
|
||||
)
|
||||
if provider:
|
||||
return provider
|
||||
logger.warning("Memory provider '%s' loaded but no provider instance found", name)
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.warning("Failed to load memory provider '%s': %s", name, e)
|
||||
return None
|
||||
|
||||
|
||||
def _load_provider_from_entry_point(
|
||||
entry_point,
|
||||
*,
|
||||
register_skills: bool = True,
|
||||
) -> Optional["MemoryProvider"]:
|
||||
"""Import a provider entry point and extract the MemoryProvider instance."""
|
||||
from agent.memory_provider import MemoryProvider
|
||||
|
||||
loaded = entry_point.load()
|
||||
|
||||
if isinstance(loaded, MemoryProvider):
|
||||
return loaded
|
||||
|
||||
if isinstance(loaded, type) and issubclass(loaded, MemoryProvider):
|
||||
try:
|
||||
return loaded()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if hasattr(loaded, "register"):
|
||||
collector = _ProviderCollector(entry_point.name, register_skills=register_skills)
|
||||
loaded.register(collector)
|
||||
if collector.provider:
|
||||
return collector.provider
|
||||
|
||||
if callable(loaded):
|
||||
try:
|
||||
provider = loaded()
|
||||
if isinstance(provider, MemoryProvider):
|
||||
return provider
|
||||
except TypeError:
|
||||
pass
|
||||
|
||||
collector = _ProviderCollector(entry_point.name, register_skills=register_skills)
|
||||
loaded(collector)
|
||||
return collector.provider
|
||||
|
||||
for attr_name in dir(loaded):
|
||||
attr = getattr(loaded, attr_name, None)
|
||||
if (isinstance(attr, type) and issubclass(attr, MemoryProvider)
|
||||
and attr is not MemoryProvider):
|
||||
try:
|
||||
return attr()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
logger.debug("Memory provider entry point '%s' loaded no provider", entry_point.name)
|
||||
return None
|
||||
|
||||
|
||||
def _load_provider_from_dir(
|
||||
provider_dir: Path,
|
||||
*,
|
||||
register_skills: bool = True,
|
||||
) -> Optional["MemoryProvider"]:
|
||||
"""Import a provider module and extract the MemoryProvider instance.
|
||||
|
||||
The module must have either:
|
||||
- A register(ctx) function (plugin-style) — we simulate a ctx
|
||||
- A top-level class that extends MemoryProvider — we instantiate it
|
||||
"""
|
||||
name = provider_dir.name
|
||||
# Use a separate namespace for user-installed plugins so they don't
|
||||
# collide with bundled providers in sys.modules.
|
||||
_is_bundled = _MEMORY_PLUGINS_DIR in provider_dir.parents or provider_dir.parent == _MEMORY_PLUGINS_DIR
|
||||
module_name = f"plugins.memory.{name}" if _is_bundled else f"{_USER_NAMESPACE}.{name}"
|
||||
init_file = provider_dir / "__init__.py"
|
||||
|
||||
if not init_file.exists():
|
||||
return None
|
||||
|
||||
# Check if already loaded. A synthetic package shell registered by
|
||||
# discover_plugin_cli_commands() for relative-import support has no
|
||||
# __file__; only reuse modules that were actually loaded from disk.
|
||||
cached = sys.modules.get(module_name)
|
||||
if cached is not None and getattr(cached, "__file__", None):
|
||||
mod = cached
|
||||
else:
|
||||
# Handle relative imports within the plugin
|
||||
# First ensure the parent packages are registered
|
||||
for parent in ("plugins", "plugins.memory"):
|
||||
if parent not in sys.modules:
|
||||
parent_path = Path(__file__).parent
|
||||
if parent == "plugins":
|
||||
parent_path = parent_path.parent
|
||||
parent_init = parent_path / "__init__.py"
|
||||
if parent_init.exists():
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
parent, str(parent_init),
|
||||
submodule_search_locations=[str(parent_path)]
|
||||
)
|
||||
if spec:
|
||||
parent_mod = importlib.util.module_from_spec(spec)
|
||||
sys.modules[parent] = parent_mod
|
||||
try:
|
||||
spec.loader.exec_module(parent_mod)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# User-installed plugins need their synthetic parent registered the
|
||||
# same way, or relative imports inside the plugin cannot resolve.
|
||||
if not _is_bundled:
|
||||
_register_synthetic_package(_USER_NAMESPACE, [])
|
||||
|
||||
# Now load the provider module
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
module_name, str(init_file),
|
||||
submodule_search_locations=[str(provider_dir)]
|
||||
)
|
||||
if not spec:
|
||||
return None
|
||||
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
sys.modules[module_name] = mod
|
||||
|
||||
# Register submodules so relative imports work
|
||||
# e.g., "from .store import MemoryStore" in holographic plugin
|
||||
for sub_file in provider_dir.glob("*.py"):
|
||||
if sub_file.name == "__init__.py":
|
||||
continue
|
||||
sub_name = sub_file.stem
|
||||
full_sub_name = f"{module_name}.{sub_name}"
|
||||
if full_sub_name not in sys.modules:
|
||||
sub_spec = importlib.util.spec_from_file_location(
|
||||
full_sub_name, str(sub_file)
|
||||
)
|
||||
if sub_spec:
|
||||
sub_mod = importlib.util.module_from_spec(sub_spec)
|
||||
sys.modules[full_sub_name] = sub_mod
|
||||
try:
|
||||
sub_spec.loader.exec_module(sub_mod)
|
||||
except Exception as e:
|
||||
logger.debug("Failed to load submodule %s: %s", full_sub_name, e)
|
||||
|
||||
try:
|
||||
spec.loader.exec_module(mod)
|
||||
except Exception as e:
|
||||
logger.debug("Failed to exec_module %s: %s", module_name, e)
|
||||
sys.modules.pop(module_name, None)
|
||||
return None
|
||||
|
||||
# Try register(ctx) pattern first (how our plugins are written)
|
||||
if hasattr(mod, "register"):
|
||||
collector = _ProviderCollector(name, register_skills=register_skills)
|
||||
try:
|
||||
mod.register(collector)
|
||||
except Exception as e:
|
||||
# A raise AFTER register_memory_provider() must not cost us the
|
||||
# provider. Falling through to the subclass scan below would
|
||||
# discard the instance the plugin configured and hand back a bare
|
||||
# second one — a silent downgrade that looks like success.
|
||||
if collector.provider is None:
|
||||
logger.debug("register() failed for %s: %s", name, e)
|
||||
else:
|
||||
logger.warning(
|
||||
"Memory provider '%s' raised after registering (%s) — "
|
||||
"using the registered provider; later registrations were skipped",
|
||||
name, e,
|
||||
)
|
||||
if collector.provider:
|
||||
return collector.provider
|
||||
|
||||
# Fallback: find a MemoryProvider subclass and instantiate it
|
||||
from agent.memory_provider import MemoryProvider
|
||||
for attr_name in dir(mod):
|
||||
attr = getattr(mod, attr_name, None)
|
||||
if (isinstance(attr, type) and issubclass(attr, MemoryProvider)
|
||||
and attr is not MemoryProvider):
|
||||
try:
|
||||
return attr()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
|
||||
class _ProviderCollector:
|
||||
"""Plugin context for memory providers.
|
||||
|
||||
Captures ``register_memory_provider`` directly — that is the one call the
|
||||
exclusive activation path owns — and delegates everything else to a real
|
||||
``PluginContext`` (see ``__getattr__``), so a memory provider has the same
|
||||
registration surface as any other plugin.
|
||||
"""
|
||||
|
||||
def __init__(self, name: str, *, register_skills: bool = True):
|
||||
self.name = name
|
||||
self.provider = None
|
||||
self._register_skills = register_skills
|
||||
self._context = None
|
||||
|
||||
def register_memory_provider(self, provider):
|
||||
self.provider = provider
|
||||
|
||||
def register_skill(self, *args, **kwargs):
|
||||
"""Forward plugin-provided skills to the general plugin registry.
|
||||
|
||||
Handled explicitly rather than through ``__getattr__`` because skills
|
||||
are tracked for pruning: switching the active provider has to retract
|
||||
the skills the previous one registered, which needs the qualified name
|
||||
and resolved path recorded here.
|
||||
|
||||
Gated on ``register_skills`` so merely *inspecting* an inactive
|
||||
provider — ``hermes memory status``, the setup picker — leaves no
|
||||
registry side effects behind.
|
||||
"""
|
||||
if not self._register_skills:
|
||||
return
|
||||
try:
|
||||
manager_context = self._plugin_context()
|
||||
manager_context.register_skill(*args, **kwargs)
|
||||
skill_name = args[0] if args else kwargs.get("name")
|
||||
qualified_name = f"{self.name}:{skill_name}"
|
||||
|
||||
from hermes_cli.plugins import get_plugin_manager
|
||||
|
||||
registered_path = get_plugin_manager().find_plugin_skill(qualified_name)
|
||||
if registered_path is not None:
|
||||
_REGISTERED_MEMORY_PROVIDER_SKILLS[qualified_name] = registered_path
|
||||
except Exception as exc:
|
||||
logger.debug("Memory provider '%s' failed to register skill: %s", self.name, exc)
|
||||
|
||||
def register_cli_command(self, *args, **kwargs):
|
||||
pass # CLI registration happens via discover_plugin_cli_commands()
|
||||
|
||||
def __getattr__(self, attr: str):
|
||||
"""Delegate any other ``register_*`` call to a real ``PluginContext``.
|
||||
|
||||
Memory providers used to get a hand-maintained stub of three no-ops
|
||||
here, which had two failure modes. Calls it *did* know about
|
||||
(``register_tool``, ``register_hook``) were silently dropped, so a
|
||||
provider's tools simply never appeared. Calls it did *not* know about
|
||||
raised ``AttributeError`` — and ``register_auxiliary_task`` is one of
|
||||
them, despite ``PluginContext.register_auxiliary_task`` documenting a
|
||||
memory provider (hindsight's pre-retain dedup) as its worked example.
|
||||
That exception surfaces as "register() failed" and costs the provider.
|
||||
|
||||
Delegating instead of enumerating means this can never drift behind
|
||||
``PluginContext`` again: a capability added there works for memory
|
||||
providers on the same commit, which is what the "widen the generic
|
||||
plugin surface" rule in AGENTS.md asks for.
|
||||
|
||||
Only ``register_*`` is forwarded. Everything else raises normally, so a
|
||||
typo still fails loudly rather than being absorbed.
|
||||
"""
|
||||
if not attr.startswith("register_"):
|
||||
raise AttributeError(attr)
|
||||
|
||||
def _forward(*args, **kwargs):
|
||||
try:
|
||||
return self._plugin_context().__getattribute__(attr)(*args, **kwargs)
|
||||
except Exception as exc:
|
||||
# A secondary registration must not cost the provider itself —
|
||||
# by the time these run, register_memory_provider has usually
|
||||
# already handed us the instance the agent needs.
|
||||
logger.warning(
|
||||
"Memory provider '%s' failed to %s: %s", self.name, attr, exc
|
||||
)
|
||||
return None
|
||||
|
||||
return _forward
|
||||
|
||||
def _plugin_context(self):
|
||||
"""A real ``PluginContext`` for this provider, built once on demand.
|
||||
|
||||
Lazy because the common case — a provider that only calls
|
||||
``register_memory_provider`` — must not pay for importing the general
|
||||
plugin manager, which discovery touches on every hermes startup.
|
||||
"""
|
||||
if self._context is None:
|
||||
from hermes_cli.plugins import PluginContext, PluginManifest, get_plugin_manager
|
||||
|
||||
manifest = PluginManifest(name=self.name, key=self.name)
|
||||
self._context = PluginContext(manifest, get_plugin_manager())
|
||||
return self._context
|
||||
|
||||
|
||||
def _get_active_memory_provider() -> Optional[str]:
|
||||
"""Read the active memory provider name from config.yaml.
|
||||
|
||||
Returns the provider name (e.g. ``"honcho"``) or None if no
|
||||
external provider is configured. Lightweight — only reads config,
|
||||
no plugin loading.
|
||||
"""
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
config = load_config()
|
||||
return cfg_get(config, "memory", "provider") or None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _prune_inactive_memory_provider_skills(
|
||||
active_provider: Optional[str] = None,
|
||||
) -> None:
|
||||
"""Remove tracked skills that no longer belong to the active provider."""
|
||||
if active_provider is None:
|
||||
active_provider = _get_active_memory_provider()
|
||||
|
||||
from hermes_cli.plugins import get_plugin_manager
|
||||
|
||||
manager = get_plugin_manager()
|
||||
for qualified_name, registered_path in list(
|
||||
_REGISTERED_MEMORY_PROVIDER_SKILLS.items()
|
||||
):
|
||||
namespace, _, _ = qualified_name.partition(":")
|
||||
if namespace == active_provider:
|
||||
continue
|
||||
if manager.find_plugin_skill(qualified_name) == registered_path:
|
||||
manager.remove_plugin_skill(qualified_name)
|
||||
_REGISTERED_MEMORY_PROVIDER_SKILLS.pop(qualified_name, None)
|
||||
|
||||
|
||||
def discover_plugin_cli_commands() -> List[dict]:
|
||||
"""Return CLI commands for the **active** memory plugin only.
|
||||
|
||||
Only one memory provider can be active at a time (set via
|
||||
``memory.provider`` in config.yaml). This function reads that
|
||||
value and only loads CLI registration for the matching plugin.
|
||||
If no provider is active, no commands are registered.
|
||||
|
||||
Looks for a ``register_cli(subparser)`` function in the active
|
||||
plugin's ``cli.py``. Returns a list of at most one dict with
|
||||
keys: ``name``, ``help``, ``description``, ``setup_fn``,
|
||||
``handler_fn``.
|
||||
|
||||
This is a lightweight scan — it only imports ``cli.py``, not the
|
||||
full plugin module. Safe to call during argparse setup before
|
||||
any provider is loaded.
|
||||
"""
|
||||
results: List[dict] = []
|
||||
if not _MEMORY_PLUGINS_DIR.is_dir():
|
||||
return results
|
||||
|
||||
active_provider = _get_active_memory_provider()
|
||||
if not active_provider:
|
||||
return results
|
||||
|
||||
# Only look at the active provider's directory
|
||||
plugin_dir = find_provider_dir(active_provider)
|
||||
if not plugin_dir:
|
||||
return results
|
||||
|
||||
cli_file = plugin_dir / "cli.py"
|
||||
if not cli_file.exists():
|
||||
return results
|
||||
|
||||
_is_bundled = _MEMORY_PLUGINS_DIR in plugin_dir.parents or plugin_dir.parent == _MEMORY_PLUGINS_DIR
|
||||
module_name = f"plugins.memory.{active_provider}.cli" if _is_bundled else f"{_USER_NAMESPACE}.{active_provider}.cli"
|
||||
try:
|
||||
# Import the CLI module (lightweight — no SDK needed)
|
||||
if module_name in sys.modules:
|
||||
cli_mod = sys.modules[module_name]
|
||||
else:
|
||||
if not _is_bundled:
|
||||
# cli.py imports as _hermes_user_memory.<name>.cli, usually
|
||||
# before the provider itself is loaded. Register its parent
|
||||
# packages so relative imports inside cli.py
|
||||
# ("from . import config") resolve without executing the
|
||||
# plugin's __init__.py. The package shell has no __file__,
|
||||
# so _load_provider_from_dir() will still load the real
|
||||
# module later instead of reusing the shell.
|
||||
_register_synthetic_package(_USER_NAMESPACE, [])
|
||||
_register_synthetic_package(
|
||||
f"{_USER_NAMESPACE}.{active_provider}", [str(plugin_dir)]
|
||||
)
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
module_name, str(cli_file)
|
||||
)
|
||||
if not spec or not spec.loader:
|
||||
return results
|
||||
cli_mod = importlib.util.module_from_spec(spec)
|
||||
sys.modules[module_name] = cli_mod
|
||||
spec.loader.exec_module(cli_mod)
|
||||
|
||||
register_cli = getattr(cli_mod, "register_cli", None)
|
||||
if not callable(register_cli):
|
||||
return results
|
||||
|
||||
# Read metadata from plugin.yaml if available
|
||||
help_text = f"Manage {active_provider} memory plugin"
|
||||
description = ""
|
||||
yaml_file = plugin_dir / "plugin.yaml"
|
||||
if yaml_file.exists():
|
||||
try:
|
||||
import yaml
|
||||
with open(yaml_file, encoding="utf-8-sig") as f:
|
||||
meta = yaml.safe_load(f) or {}
|
||||
desc = meta.get("description", "")
|
||||
if desc:
|
||||
help_text = desc
|
||||
description = desc
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
handler_fn = getattr(cli_mod, f"{active_provider}_command", None) or \
|
||||
getattr(cli_mod, "honcho_command", None)
|
||||
|
||||
results.append({
|
||||
"name": active_provider,
|
||||
"help": help_text,
|
||||
"description": description,
|
||||
"setup_fn": register_cli,
|
||||
"handler_fn": handler_fn,
|
||||
"plugin": active_provider,
|
||||
})
|
||||
except Exception as e:
|
||||
logger.debug("Failed to scan CLI for memory plugin '%s': %s", active_provider, e)
|
||||
|
||||
return results
|
||||
@@ -0,0 +1,41 @@
|
||||
# ByteRover Memory Provider
|
||||
|
||||
Persistent memory via the `brv` CLI — hierarchical knowledge tree with tiered retrieval (fuzzy text → LLM-driven search).
|
||||
|
||||
## Requirements
|
||||
|
||||
Install the ByteRover CLI:
|
||||
```bash
|
||||
curl -fsSL https://byterover.dev/install.sh | sh
|
||||
# or
|
||||
npm install -g byterover-cli
|
||||
```
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
hermes memory setup # select "byterover"
|
||||
```
|
||||
|
||||
Or manually:
|
||||
```bash
|
||||
hermes config set memory.provider byterover
|
||||
# Optional cloud sync:
|
||||
echo "BRV_API_KEY=your-key" >> ~/.hermes/.env
|
||||
```
|
||||
|
||||
## Config
|
||||
|
||||
| Env Var | Required | Description |
|
||||
|---------|----------|-------------|
|
||||
| `BRV_API_KEY` | No | Cloud sync key (optional, local-first by default) |
|
||||
|
||||
Working directory: `$HERMES_HOME/byterover/` (profile-scoped).
|
||||
|
||||
## Tools
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `brv_query` | Search the knowledge tree |
|
||||
| `brv_curate` | Store facts, decisions, patterns |
|
||||
| `brv_status` | CLI version, tree stats, sync state |
|
||||
@@ -0,0 +1,449 @@
|
||||
"""ByteRover memory plugin — MemoryProvider interface.
|
||||
|
||||
Persistent memory via the ByteRover CLI (``brv``). Organizes knowledge into
|
||||
a hierarchical context tree with tiered retrieval (fuzzy text → LLM-driven
|
||||
search). Local-first with optional cloud sync.
|
||||
|
||||
Original PR #3499 by hieuntg81, adapted to MemoryProvider ABC.
|
||||
|
||||
Requires: ``brv`` CLI installed (npm install -g byterover-cli or
|
||||
curl -fsSL https://byterover.dev/install.sh | sh).
|
||||
|
||||
Config via environment variables (profile-scoped via each profile's .env):
|
||||
BRV_API_KEY — ByteRover API key (for cloud features, optional for local)
|
||||
|
||||
Config via config.yaml:
|
||||
memory:
|
||||
byterover:
|
||||
auto_extract: false # disable automatic brv curate hooks
|
||||
|
||||
Working directory: $HERMES_HOME/byterover/ (profile-scoped context tree)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from agent.memory_provider import MemoryProvider
|
||||
from tools.registry import tool_error
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Timeouts
|
||||
_QUERY_TIMEOUT = 10 # brv query — should be fast
|
||||
_CURATE_TIMEOUT = 120 # brv curate — may involve LLM processing
|
||||
|
||||
# Minimum lengths to filter noise
|
||||
_MIN_QUERY_LEN = 10
|
||||
_MIN_OUTPUT_LEN = 20
|
||||
|
||||
|
||||
def _coerce_bool(value: Any, default: bool = False) -> bool:
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if value is None:
|
||||
return default
|
||||
if isinstance(value, (int, float)):
|
||||
return bool(value)
|
||||
if isinstance(value, str):
|
||||
text = value.strip().lower()
|
||||
if text in {"1", "true", "yes", "on"}:
|
||||
return True
|
||||
if text in {"0", "false", "no", "off"}:
|
||||
return False
|
||||
return default
|
||||
|
||||
|
||||
def _load_plugin_config() -> Dict[str, Any]:
|
||||
"""Read ByteRover's profile-scoped memory config.
|
||||
|
||||
New memory-provider setup stores non-secret provider settings under
|
||||
``memory.<provider>``. Some users also set ``memory.provider_config`` from
|
||||
early docs/issues, so accept it as a compatibility fallback.
|
||||
"""
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
|
||||
config = load_config()
|
||||
memory_config = config.get("memory", {})
|
||||
if not isinstance(memory_config, dict):
|
||||
return {}
|
||||
|
||||
provider_config = memory_config.get("byterover", {})
|
||||
if isinstance(provider_config, dict) and provider_config:
|
||||
return dict(provider_config)
|
||||
|
||||
legacy_config = memory_config.get("provider_config", {})
|
||||
if isinstance(legacy_config, dict):
|
||||
return dict(legacy_config)
|
||||
except Exception:
|
||||
pass
|
||||
return {}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# brv binary resolution (cached, thread-safe)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_brv_path_lock = threading.Lock()
|
||||
_cached_brv_path: Optional[str] = None
|
||||
|
||||
|
||||
def _resolve_brv_path() -> Optional[str]:
|
||||
"""Find the brv binary on PATH or well-known install locations."""
|
||||
global _cached_brv_path
|
||||
with _brv_path_lock:
|
||||
if _cached_brv_path is not None:
|
||||
return _cached_brv_path if _cached_brv_path != "" else None
|
||||
|
||||
found = shutil.which("brv")
|
||||
if not found:
|
||||
home = Path.home()
|
||||
candidates = [
|
||||
home / ".brv-cli" / "bin" / "brv",
|
||||
Path("/usr/local/bin/brv"),
|
||||
home / ".npm-global" / "bin" / "brv",
|
||||
]
|
||||
for c in candidates:
|
||||
if c.exists():
|
||||
found = str(c)
|
||||
break
|
||||
|
||||
with _brv_path_lock:
|
||||
if _cached_brv_path is not None:
|
||||
return _cached_brv_path if _cached_brv_path != "" else None
|
||||
_cached_brv_path = found or ""
|
||||
return found
|
||||
|
||||
|
||||
def _run_brv(args: List[str], timeout: int = _QUERY_TIMEOUT,
|
||||
cwd: str = None) -> dict:
|
||||
"""Run a brv CLI command. Returns {success, output, error}."""
|
||||
brv_path = _resolve_brv_path()
|
||||
if not brv_path:
|
||||
return {"success": False, "error": "brv CLI not found. Install: npm install -g byterover-cli"}
|
||||
|
||||
cmd = [brv_path] + args
|
||||
effective_cwd = cwd or str(_get_brv_cwd())
|
||||
Path(effective_cwd).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
env = os.environ.copy()
|
||||
brv_bin_dir = str(Path(brv_path).parent)
|
||||
env["PATH"] = brv_bin_dir + os.pathsep + env.get("PATH", "")
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd, capture_output=True, text=True, encoding='utf-8', errors='replace',
|
||||
timeout=timeout, cwd=effective_cwd, env=env,
|
||||
stdin=subprocess.DEVNULL,
|
||||
)
|
||||
stdout = result.stdout.strip()
|
||||
stderr = result.stderr.strip()
|
||||
|
||||
if result.returncode == 0:
|
||||
return {"success": True, "output": stdout}
|
||||
return {"success": False, "error": stderr or stdout or f"brv exited {result.returncode}"}
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
return {"success": False, "error": f"brv timed out after {timeout}s"}
|
||||
except FileNotFoundError:
|
||||
global _cached_brv_path
|
||||
with _brv_path_lock:
|
||||
_cached_brv_path = None
|
||||
return {"success": False, "error": "brv CLI not found"}
|
||||
except Exception as e:
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
|
||||
def _get_brv_cwd() -> Path:
|
||||
"""Profile-scoped working directory for the brv context tree."""
|
||||
from hermes_constants import get_hermes_home
|
||||
return get_hermes_home() / "byterover"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tool schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
QUERY_SCHEMA = {
|
||||
"name": "brv_query",
|
||||
"description": (
|
||||
"Search ByteRover's persistent knowledge tree for relevant context. "
|
||||
"Returns memories, project knowledge, architectural decisions, and "
|
||||
"patterns from previous sessions. Use for any question where past "
|
||||
"context would help."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {"type": "string", "description": "What to search for."},
|
||||
},
|
||||
"required": ["query"],
|
||||
},
|
||||
}
|
||||
|
||||
CURATE_SCHEMA = {
|
||||
"name": "brv_curate",
|
||||
"description": (
|
||||
"Store important information in ByteRover's persistent knowledge tree. "
|
||||
"Use for architectural decisions, bug fixes, user preferences, project "
|
||||
"patterns — anything worth remembering across sessions. ByteRover's LLM "
|
||||
"automatically categorizes and organizes the memory."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"content": {"type": "string", "description": "The information to remember."},
|
||||
},
|
||||
"required": ["content"],
|
||||
},
|
||||
}
|
||||
|
||||
STATUS_SCHEMA = {
|
||||
"name": "brv_status",
|
||||
"description": "Check ByteRover status — CLI version, context tree stats, cloud sync state.",
|
||||
"parameters": {"type": "object", "properties": {}, "required": []},
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MemoryProvider implementation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class ByteRoverMemoryProvider(MemoryProvider):
|
||||
"""ByteRover persistent memory via the brv CLI."""
|
||||
|
||||
def __init__(self, config: Optional[Dict[str, Any]] = None):
|
||||
self._config = dict(config) if config is not None else _load_plugin_config()
|
||||
self._auto_extract = _coerce_bool(self._config.get("auto_extract"), True)
|
||||
self._cwd = ""
|
||||
self._session_id = ""
|
||||
self._turn_count = 0
|
||||
self._sync_thread: Optional[threading.Thread] = None
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "byterover"
|
||||
|
||||
def is_available(self) -> bool:
|
||||
"""Check if brv CLI is installed. No network calls."""
|
||||
return _resolve_brv_path() is not None
|
||||
|
||||
def get_config_schema(self):
|
||||
return [
|
||||
{
|
||||
"key": "api_key",
|
||||
"description": "ByteRover API key (optional, for cloud sync)",
|
||||
"secret": True,
|
||||
"env_var": "BRV_API_KEY",
|
||||
"url": "https://app.byterover.dev",
|
||||
},
|
||||
{
|
||||
"key": "auto_extract",
|
||||
"description": "Automatically curate completed turns and compression/memory hooks",
|
||||
"default": "true",
|
||||
"choices": ["true", "false"],
|
||||
},
|
||||
]
|
||||
|
||||
def initialize(self, session_id: str, **kwargs) -> None:
|
||||
self._cwd = str(_get_brv_cwd())
|
||||
self._session_id = session_id
|
||||
self._turn_count = 0
|
||||
Path(self._cwd).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def system_prompt_block(self) -> str:
|
||||
if not _resolve_brv_path():
|
||||
return ""
|
||||
return (
|
||||
"# ByteRover Memory\n"
|
||||
"Active. Persistent knowledge tree with hierarchical context.\n"
|
||||
"Use brv_query to search past knowledge, brv_curate to store "
|
||||
"important facts, brv_status to check state."
|
||||
)
|
||||
|
||||
def prefetch(self, query: str, *, session_id: str = "") -> str:
|
||||
"""Run brv query synchronously before the agent's first LLM call.
|
||||
|
||||
Blocks until the query completes (up to _QUERY_TIMEOUT seconds), ensuring
|
||||
the result is available as context before the model is called.
|
||||
"""
|
||||
if not query or len(query.strip()) < _MIN_QUERY_LEN:
|
||||
return ""
|
||||
result = _run_brv(
|
||||
["query", "--", query.strip()[:5000]],
|
||||
timeout=_QUERY_TIMEOUT, cwd=self._cwd,
|
||||
)
|
||||
if result["success"] and result.get("output"):
|
||||
output = result["output"].strip()
|
||||
if len(output) > _MIN_OUTPUT_LEN:
|
||||
return f"## ByteRover Context\n{output}"
|
||||
return ""
|
||||
|
||||
def queue_prefetch(self, query: str, *, session_id: str = "") -> None:
|
||||
"""No-op: prefetch() now runs synchronously at turn start."""
|
||||
pass
|
||||
|
||||
def sync_turn(self, user_content: str, assistant_content: str, *, session_id: str = "") -> None:
|
||||
"""Curate the conversation turn in background (non-blocking)."""
|
||||
self._turn_count += 1
|
||||
if not self._auto_extract:
|
||||
logger.debug("ByteRover sync_turn skipped (auto_extract disabled)")
|
||||
return
|
||||
|
||||
# Only curate substantive turns
|
||||
if len(user_content.strip()) < _MIN_QUERY_LEN:
|
||||
return
|
||||
|
||||
def _sync():
|
||||
try:
|
||||
combined = f"User: {user_content[:2000]}\nAssistant: {assistant_content[:2000]}"
|
||||
_run_brv(
|
||||
["curate", "--", combined],
|
||||
timeout=_CURATE_TIMEOUT, cwd=self._cwd,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug("ByteRover sync failed: %s", e)
|
||||
|
||||
# Wait for previous sync
|
||||
if self._sync_thread and self._sync_thread.is_alive():
|
||||
self._sync_thread.join(timeout=5.0)
|
||||
|
||||
self._sync_thread = threading.Thread(
|
||||
target=_sync, daemon=True, name="brv-sync"
|
||||
)
|
||||
self._sync_thread.start()
|
||||
|
||||
def on_memory_write(self, action: str, target: str, content: str) -> None:
|
||||
"""Mirror built-in memory writes to ByteRover."""
|
||||
if not self._auto_extract:
|
||||
logger.debug("ByteRover memory mirror skipped (auto_extract disabled)")
|
||||
return
|
||||
if action not in {"add", "replace"} or not content:
|
||||
return
|
||||
|
||||
def _write():
|
||||
try:
|
||||
label = "User profile" if target == "user" else "Agent memory"
|
||||
_run_brv(
|
||||
["curate", "--", f"[{label}] {content}"],
|
||||
timeout=_CURATE_TIMEOUT, cwd=self._cwd,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug("ByteRover memory mirror failed: %s", e)
|
||||
|
||||
t = threading.Thread(target=_write, daemon=True, name="brv-memwrite")
|
||||
t.start()
|
||||
|
||||
def on_pre_compress(self, messages: List[Dict[str, Any]]) -> str:
|
||||
"""Extract insights before context compression discards turns."""
|
||||
if not self._auto_extract:
|
||||
logger.debug("ByteRover pre-compression flush skipped (auto_extract disabled)")
|
||||
return ""
|
||||
if not messages:
|
||||
return ""
|
||||
|
||||
# Build a summary of messages about to be compressed
|
||||
parts = []
|
||||
for msg in messages[-10:]: # last 10 messages
|
||||
role = msg.get("role", "")
|
||||
content = msg.get("content", "")
|
||||
if isinstance(content, str) and content.strip() and role in {"user", "assistant"}:
|
||||
parts.append(f"{role}: {content[:500]}")
|
||||
|
||||
if not parts:
|
||||
return ""
|
||||
|
||||
combined = "\n".join(parts)
|
||||
|
||||
def _flush():
|
||||
try:
|
||||
_run_brv(
|
||||
["curate", "--", f"[Pre-compression context]\n{combined}"],
|
||||
timeout=_CURATE_TIMEOUT, cwd=self._cwd,
|
||||
)
|
||||
logger.info("ByteRover pre-compression flush: %d messages", len(parts))
|
||||
except Exception as e:
|
||||
logger.debug("ByteRover pre-compression flush failed: %s", e)
|
||||
|
||||
t = threading.Thread(target=_flush, daemon=True, name="brv-flush")
|
||||
t.start()
|
||||
return ""
|
||||
|
||||
def get_tool_schemas(self) -> List[Dict[str, Any]]:
|
||||
return [QUERY_SCHEMA, CURATE_SCHEMA, STATUS_SCHEMA]
|
||||
|
||||
def handle_tool_call(self, tool_name: str, args: dict, **kwargs) -> str:
|
||||
if tool_name == "brv_query":
|
||||
return self._tool_query(args)
|
||||
elif tool_name == "brv_curate":
|
||||
return self._tool_curate(args)
|
||||
elif tool_name == "brv_status":
|
||||
return self._tool_status()
|
||||
return tool_error(f"Unknown tool: {tool_name}")
|
||||
|
||||
def shutdown(self) -> None:
|
||||
if self._sync_thread and self._sync_thread.is_alive():
|
||||
self._sync_thread.join(timeout=10.0)
|
||||
|
||||
# -- Tool implementations ------------------------------------------------
|
||||
|
||||
def _tool_query(self, args: dict) -> str:
|
||||
query = args.get("query", "")
|
||||
if not query:
|
||||
return tool_error("query is required")
|
||||
|
||||
result = _run_brv(
|
||||
["query", "--", query.strip()[:5000]],
|
||||
timeout=_QUERY_TIMEOUT, cwd=self._cwd,
|
||||
)
|
||||
|
||||
if not result["success"]:
|
||||
return tool_error(result.get("error", "Query failed"))
|
||||
|
||||
output = result.get("output", "").strip()
|
||||
if not output or len(output) < _MIN_OUTPUT_LEN:
|
||||
return json.dumps({"result": "No relevant memories found."})
|
||||
|
||||
# Truncate very long results
|
||||
if len(output) > 8000:
|
||||
output = output[:8000] + "\n\n[... truncated]"
|
||||
|
||||
return json.dumps({"result": output})
|
||||
|
||||
def _tool_curate(self, args: dict) -> str:
|
||||
content = args.get("content", "")
|
||||
if not content:
|
||||
return tool_error("content is required")
|
||||
|
||||
result = _run_brv(
|
||||
["curate", "--", content],
|
||||
timeout=_CURATE_TIMEOUT, cwd=self._cwd,
|
||||
)
|
||||
|
||||
if not result["success"]:
|
||||
return tool_error(result.get("error", "Curate failed"))
|
||||
|
||||
return json.dumps({"result": "Memory curated successfully."})
|
||||
|
||||
def _tool_status(self) -> str:
|
||||
result = _run_brv(["status"], timeout=15, cwd=self._cwd)
|
||||
if not result["success"]:
|
||||
return tool_error(result.get("error", "Status check failed"))
|
||||
return json.dumps({"status": result.get("output", "")})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Plugin entry point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def register(ctx) -> None:
|
||||
"""Register ByteRover as a memory provider plugin."""
|
||||
ctx.register_memory_provider(ByteRoverMemoryProvider())
|
||||
@@ -0,0 +1,9 @@
|
||||
name: byterover
|
||||
version: 1.0.0
|
||||
description: "ByteRover — persistent knowledge tree with tiered retrieval via the brv CLI."
|
||||
external_dependencies:
|
||||
- name: brv
|
||||
install: "curl -fsSL https://byterover.dev/install.sh | sh"
|
||||
check: "brv --version"
|
||||
hooks:
|
||||
- on_pre_compress
|
||||
@@ -0,0 +1,144 @@
|
||||
"""Declarative configuration schema for memory provider plugins.
|
||||
|
||||
Each memory provider plugin *declares* its configurable surface in a
|
||||
``config_schema.py`` next to its ``__init__.py`` — the fields, their types,
|
||||
which values are secrets, and (for selects) the allowed options. A single
|
||||
generic renderer in the desktop UI and a single generic ``GET/PUT
|
||||
/api/memory/providers/{name}/config`` endpoint pair drive the whole
|
||||
experience, so adding a provider config surface is pure declaration with no
|
||||
bespoke UI components.
|
||||
|
||||
Schema files are loaded by path (like the provider plugins themselves), never
|
||||
via package import: plugin ``__init__.py`` files pull in the agent runtime,
|
||||
which must not load into the web server. A ``config_schema.py`` may only
|
||||
import from this module.
|
||||
|
||||
This module is intentionally pure data: it imports nothing from the
|
||||
config/env layer. ``web_server`` owns the generic read/write logic that
|
||||
interprets these declarations, dispatching on ``ProviderConfigSchema.storage``
|
||||
to the matching backend.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import logging
|
||||
from dataclasses import dataclass, field as dataclass_field
|
||||
|
||||
_log = logging.getLogger(__name__)
|
||||
|
||||
# Field kinds understood by the generic renderer.
|
||||
KIND_TEXT = "text"
|
||||
KIND_SELECT = "select"
|
||||
KIND_SECRET = "secret"
|
||||
KIND_BOOL = "bool"
|
||||
KIND_NUMBER = "number"
|
||||
KIND_JSON = "json"
|
||||
|
||||
# Storage backends understood by web_server (see its read/write dispatch).
|
||||
STORAGE_FLAT_JSON = "flat_json"
|
||||
STORAGE_HONCHO_HOST_BLOCK = "honcho_host_block"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProviderFieldOption:
|
||||
"""A single choice for a ``select`` field."""
|
||||
|
||||
value: str
|
||||
label: str
|
||||
description: str = ""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProviderField:
|
||||
"""One configurable field on a memory provider.
|
||||
|
||||
A field is stored in exactly one place, decided by ``kind``:
|
||||
|
||||
* non-secret kinds — persisted to the provider's config via its storage
|
||||
backend under ``key``.
|
||||
* ``secret`` — persisted to the env store under ``env_key`` and never read
|
||||
back out over the API (only an ``is_set`` flag is surfaced).
|
||||
|
||||
``aliases`` and ``env_fallbacks`` let a field read legacy values written by
|
||||
earlier CLI/env setup without re-introducing per-provider code. ``inline``
|
||||
marks the curated subset shown in the compact panel; the rest surface only
|
||||
in the full-config modal. ``group`` buckets fields within that modal.
|
||||
"""
|
||||
|
||||
key: str
|
||||
label: str
|
||||
kind: str = KIND_TEXT
|
||||
default: str = ""
|
||||
description: str = ""
|
||||
placeholder: str = ""
|
||||
options: tuple[ProviderFieldOption, ...] = ()
|
||||
env_key: str | None = None
|
||||
aliases: tuple[str, ...] = ()
|
||||
env_fallbacks: tuple[str, ...] = ()
|
||||
inline: bool = False
|
||||
group: str = ""
|
||||
# Longer help text surfaced as an info tooltip next to the field label.
|
||||
info: str = ""
|
||||
# Host-block placement: "host" (per-profile) or "root"; flat-json ignores it.
|
||||
scope: str = "host"
|
||||
|
||||
@property
|
||||
def is_secret(self) -> bool:
|
||||
return self.kind == KIND_SECRET
|
||||
|
||||
def allowed_values(self) -> set[str]:
|
||||
return {opt.value for opt in self.options}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProviderConfigSchema:
|
||||
"""A provider plugin's declared config surface."""
|
||||
|
||||
name: str
|
||||
label: str
|
||||
storage: str = STORAGE_FLAT_JSON
|
||||
# Optional link to the provider's config docs, shown in the full-config modal.
|
||||
docs_url: str = ""
|
||||
fields: tuple[ProviderField, ...] = dataclass_field(default_factory=tuple)
|
||||
|
||||
def inline_fields(self) -> tuple[ProviderField, ...]:
|
||||
return tuple(f for f in self.fields if f.inline)
|
||||
|
||||
|
||||
_SCHEMA_CACHE: dict[str, ProviderConfigSchema] = {}
|
||||
|
||||
|
||||
def get_provider_config_schema(name: str) -> ProviderConfigSchema | None:
|
||||
"""Return the ``CONFIG_SCHEMA`` declared by the provider plugin ``name``.
|
||||
|
||||
Providers without a ``config_schema.py`` (e.g. ``builtin``) return ``None``
|
||||
and simply render no config panel. The cache keys on the resolved schema
|
||||
file, not the name: user-installed plugins are per-profile, so one
|
||||
profile's lookup must never answer for another's.
|
||||
"""
|
||||
|
||||
from plugins.memory import find_provider_dir
|
||||
|
||||
provider_dir = find_provider_dir(name)
|
||||
path = provider_dir / "config_schema.py" if provider_dir else None
|
||||
if path is None or not path.is_file():
|
||||
return None
|
||||
|
||||
key = str(path)
|
||||
if key in _SCHEMA_CACHE:
|
||||
return _SCHEMA_CACHE[key]
|
||||
|
||||
try:
|
||||
spec = importlib.util.spec_from_file_location(f"_hermes_memory_config_schema.{name}", path)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
schema = getattr(module, "CONFIG_SCHEMA", None)
|
||||
except Exception:
|
||||
# Never cache a failed load: it would pin an empty panel until restart.
|
||||
_log.exception("failed to load config schema for memory provider %r", name)
|
||||
return None
|
||||
|
||||
if schema is not None:
|
||||
_SCHEMA_CACHE[key] = schema
|
||||
return schema
|
||||
@@ -0,0 +1,150 @@
|
||||
# Hindsight Memory Provider
|
||||
|
||||
Long-term memory with knowledge graph, entity resolution, and multi-strategy retrieval. Supports cloud, local embedded, and local external modes.
|
||||
|
||||
## Requirements
|
||||
|
||||
- **Cloud:** API key from [ui.hindsight.vectorize.io](https://ui.hindsight.vectorize.io)
|
||||
- **Local Embedded:** API key for a supported LLM provider (OpenAI, Anthropic, Gemini, Groq, OpenRouter, MiniMax, Ollama, or any OpenAI-compatible endpoint). Embeddings and reranking run locally — no additional API keys needed.
|
||||
- **Local External:** A running Hindsight instance (Docker or self-hosted) reachable over HTTP.
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
hermes memory setup # select "hindsight"
|
||||
```
|
||||
|
||||
The setup wizard installs dependencies automatically via `uv`, walks you through configuration, and offers to seed the bank with a **starter memory template** (a curated set of dispositions/instructions for common agent roles) — you can skip it, and it warns before overwriting an already-configured bank.
|
||||
|
||||
Or manually (cloud mode with defaults):
|
||||
```bash
|
||||
hermes config set memory.provider hindsight
|
||||
echo "HINDSIGHT_API_KEY=your-key" >> ~/.hermes/.env
|
||||
```
|
||||
|
||||
### Cloud
|
||||
|
||||
Connects to the Hindsight Cloud API. Requires an API key from [ui.hindsight.vectorize.io](https://ui.hindsight.vectorize.io).
|
||||
|
||||
### Local Embedded
|
||||
|
||||
Hermes spins up a local Hindsight daemon with built-in PostgreSQL. Requires an LLM API key for memory extraction and synthesis. The daemon starts automatically in the background on first use and stops after 5 minutes of inactivity.
|
||||
|
||||
Supports any OpenAI-compatible LLM endpoint (llama.cpp, vLLM, LM Studio, etc.) — pick `openai_compatible` as the provider and enter the base URL.
|
||||
|
||||
Daemon startup logs: `~/.hermes/logs/hindsight-embed.log`
|
||||
Daemon runtime logs: `~/.hindsight/profiles/<profile>.log`
|
||||
|
||||
To open the Hindsight web UI (local embedded mode only):
|
||||
```bash
|
||||
hindsight-embed -p hermes ui start
|
||||
```
|
||||
|
||||
### Local External
|
||||
|
||||
Points the plugin at an existing Hindsight instance you're already running (Docker, self-hosted, etc.). No daemon management — just a URL and an optional API key.
|
||||
|
||||
## Config
|
||||
|
||||
Config file: `~/.hermes/hindsight/config.json`
|
||||
|
||||
### Connection
|
||||
|
||||
| Key | Default | Description |
|
||||
|-----|---------|-------------|
|
||||
| `mode` | `cloud` | `cloud`, `local_embedded`, or `local_external` |
|
||||
| `api_url` | `https://api.hindsight.vectorize.io` | API URL (cloud and local_external modes) |
|
||||
|
||||
### Memory Bank
|
||||
|
||||
| Key | Default | Description |
|
||||
|-----|---------|-------------|
|
||||
| `bank_id` | `hermes` | Memory bank name (static fallback used when `bank_id_template` is unset or resolves empty) |
|
||||
| `bank_id_template` | — | Optional template to derive the bank name dynamically. Placeholders: `{profile}`, `{workspace}`, `{platform}`, `{user}`, `{session}`. Example: `hermes-{profile}` isolates memory per active Hermes profile. Empty placeholders collapse cleanly (e.g. `hermes-{user}` with no user becomes `hermes`). |
|
||||
| `bank_mission` | — | Reflect mission (identity/framing for reflect reasoning). Applied via Banks API. |
|
||||
| `bank_retain_mission` | — | Retain mission (steers what gets extracted). Applied via Banks API. |
|
||||
|
||||
### Recall
|
||||
|
||||
| Key | Default | Description |
|
||||
|-----|---------|-------------|
|
||||
| `recall_budget` | `mid` | Recall thoroughness: `low` / `mid` / `high` |
|
||||
| `recall_prefetch_method` | `recall` | Auto-recall method: `recall` (raw facts) or `reflect` (LLM synthesis) |
|
||||
| `recall_max_tokens` | `4096` | Maximum tokens for recall results |
|
||||
| `recall_max_input_chars` | `800` | Maximum input query length for auto-recall |
|
||||
| `recall_prompt_preamble` | — | Custom preamble for recalled memories in context |
|
||||
| `recall_tags` | — | Tags to filter when searching memories |
|
||||
| `recall_tags_match` | `any` | Tag matching mode: `any` / `all` / `any_strict` / `all_strict` |
|
||||
| `recall_types` | `observation` | Fact types surfaced by recall (both auto-recall and the `hindsight_recall` tool). Comma-separated string or JSON list. **Default narrowed to `observation` only** (see "Behavior change" below). Set to `observation,world,experience` to also include raw facts. |
|
||||
| `auto_recall` | `true` | Automatically recall memories before each turn |
|
||||
| `recall_sync` | `false` | Recall synchronously against the *current* message each turn (higher relevance, adds recall latency). Default off: recall runs in the background and is injected on the next turn. |
|
||||
| `recall_indicator` | `true` | Show a `👁️ Hindsight — recalled N memories` status line when auto-recall injects memory. Turn off for customer-facing agents. |
|
||||
|
||||
> **Behavior change — `recall_types` defaults to `observation` only.**
|
||||
>
|
||||
> Previously recall returned all three fact types. It now returns only observations.
|
||||
>
|
||||
> Per [Hindsight's docs](https://hindsight.vectorize.io/developer/observations), observations are the **consolidated** knowledge layer Hindsight builds on top of raw facts: deduplicated beliefs grounded in evidence, refined as new facts arrive, with proof counts and freshness signals. Raw `world` / `experience` facts are the individual supporting evidence that feeds them. For per-turn context injection, observations are denser per token and avoid feeding the model multiple raw facts that one observation already summarizes.
|
||||
>
|
||||
> Restore the broad recall with `"recall_types": "observation,world,experience"` (string or JSON list) in `~/.hermes/hindsight/config.json`. This applies to **both** auto-recall and the `hindsight_recall` tool — both read the same `recall_types` setting (the tool schema has no per-call `types` argument), so narrowing the default narrows both paths.
|
||||
|
||||
### Retain
|
||||
|
||||
| Key | Default | Description |
|
||||
|-----|---------|-------------|
|
||||
| `auto_retain` | `true` | Automatically retain conversation turns |
|
||||
| `retain_async` | `true` | Process retain asynchronously on the Hindsight server |
|
||||
| `retain_every_n_turns` | `1` | Retain every N turns (1 = every turn) |
|
||||
| `retain_context` | `conversation between Hermes Agent and the User` | Context label for retained memories |
|
||||
| `retain_tags` | — | Default tags applied to retained memories; merged with per-call tool tags |
|
||||
| `retain_source` | — | Opt-in `metadata.source` attached to retained memories (identifies the storing client, e.g. `hermes`). Empty by default — no attribution tag ships unless you set it. |
|
||||
| `retain_indicator` | `true` | Show a `👁️ Hindsight — saving to memory…` status line when a turn is saved. Turn off for customer-facing agents. |
|
||||
| `retain_user_prefix` | `User` | Label used before user turns in auto-retained transcripts |
|
||||
| `retain_assistant_prefix` | `Assistant` | Label used before assistant turns in auto-retained transcripts |
|
||||
|
||||
### Integration
|
||||
|
||||
| Key | Default | Description |
|
||||
|-----|---------|-------------|
|
||||
| `memory_mode` | `hybrid` | How memories are integrated into the agent |
|
||||
|
||||
**memory_mode:**
|
||||
- `hybrid` — automatic context injection + tools available to the LLM
|
||||
- `context` — automatic injection only, no tools exposed
|
||||
- `tools` — tools only, no automatic injection
|
||||
|
||||
### Local Embedded LLM
|
||||
|
||||
| Key | Default | Description |
|
||||
|-----|---------|-------------|
|
||||
| `llm_provider` | `openai` | `openai`, `anthropic`, `gemini`, `groq`, `openrouter`, `minimax`, `ollama`, `lmstudio`, `openai_compatible` |
|
||||
| `llm_model` | per-provider | Model name (e.g. `gpt-4o-mini`, `qwen/qwen3.5-9b`) |
|
||||
| `llm_base_url` | — | Endpoint URL for `openai_compatible` (e.g. `http://192.168.1.10:8080/v1`) |
|
||||
|
||||
The LLM API key is stored in `~/.hermes/.env` as `HINDSIGHT_LLM_API_KEY`.
|
||||
|
||||
## Tools
|
||||
|
||||
Available in `hybrid` and `tools` memory modes:
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `hindsight_retain` | Store information with auto entity extraction; supports optional per-call `tags` |
|
||||
| `hindsight_recall` | Multi-strategy search (semantic + entity graph) |
|
||||
| `hindsight_reflect` | Cross-memory synthesis (LLM-powered) |
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `HINDSIGHT_API_KEY` | API key for Hindsight Cloud |
|
||||
| `HINDSIGHT_LLM_API_KEY` | LLM API key for local mode |
|
||||
| `HINDSIGHT_API_LLM_BASE_URL` | LLM Base URL for local mode (e.g. OpenRouter) |
|
||||
| `HINDSIGHT_API_URL` | Override API endpoint |
|
||||
| `HINDSIGHT_BANK_ID` | Override bank name |
|
||||
| `HINDSIGHT_BUDGET` | Override recall budget |
|
||||
| `HINDSIGHT_MODE` | Override mode (`cloud`, `local_embedded`, `local_external`) |
|
||||
|
||||
## Client Version
|
||||
|
||||
Requires `hindsight-client >= 0.6.1`. The plugin auto-upgrades on session start if an older version is detected.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,76 @@
|
||||
"""Hindsight's declared config surface — rendered by the generic desktop panel."""
|
||||
|
||||
from plugins.memory.config_schema import (
|
||||
KIND_SECRET,
|
||||
KIND_SELECT,
|
||||
KIND_TEXT,
|
||||
ProviderConfigSchema,
|
||||
ProviderField,
|
||||
ProviderFieldOption,
|
||||
)
|
||||
|
||||
CONFIG_SCHEMA = ProviderConfigSchema(
|
||||
name="hindsight",
|
||||
label="Hindsight",
|
||||
fields=(
|
||||
ProviderField(
|
||||
key="mode",
|
||||
label="Mode",
|
||||
kind=KIND_SELECT,
|
||||
default="cloud",
|
||||
description="How Hermes connects to Hindsight.",
|
||||
options=(
|
||||
ProviderFieldOption(
|
||||
"cloud",
|
||||
"Cloud",
|
||||
"Hindsight Cloud API (lightweight, just needs an API key)",
|
||||
),
|
||||
ProviderFieldOption(
|
||||
"local_external",
|
||||
"Local External",
|
||||
"Connect to an existing Hindsight instance",
|
||||
),
|
||||
),
|
||||
inline=True,
|
||||
),
|
||||
ProviderField(
|
||||
key="api_key",
|
||||
label="API key",
|
||||
kind=KIND_SECRET,
|
||||
env_key="HINDSIGHT_API_KEY",
|
||||
description="Used to authenticate with the Hindsight API.",
|
||||
placeholder="Enter Hindsight API key",
|
||||
inline=True,
|
||||
),
|
||||
ProviderField(
|
||||
key="api_url",
|
||||
label="API URL",
|
||||
kind=KIND_TEXT,
|
||||
default="https://api.hindsight.vectorize.io",
|
||||
aliases=("apiUrl",),
|
||||
env_fallbacks=("HINDSIGHT_API_URL",),
|
||||
inline=True,
|
||||
),
|
||||
ProviderField(
|
||||
key="bank_id",
|
||||
label="Bank ID",
|
||||
kind=KIND_TEXT,
|
||||
default="hermes",
|
||||
aliases=("bankId",),
|
||||
inline=True,
|
||||
),
|
||||
ProviderField(
|
||||
key="recall_budget",
|
||||
label="Recall budget",
|
||||
kind=KIND_SELECT,
|
||||
default="mid",
|
||||
aliases=("budget",),
|
||||
options=(
|
||||
ProviderFieldOption("low", "low"),
|
||||
ProviderFieldOption("mid", "mid"),
|
||||
ProviderFieldOption("high", "high"),
|
||||
),
|
||||
inline=True,
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,8 @@
|
||||
name: hindsight
|
||||
version: 1.0.0
|
||||
description: "Hindsight — long-term memory with knowledge graph, entity resolution, and multi-strategy retrieval."
|
||||
pip_dependencies:
|
||||
- "hindsight-client>=0.6.1"
|
||||
requires_env: []
|
||||
hooks:
|
||||
- on_session_end
|
||||
@@ -0,0 +1,153 @@
|
||||
"""Starter bank templates for the Hindsight memory-provider setup wizard.
|
||||
|
||||
Fetches the Hindsight Bank Templates catalog, filters to templates tagged for
|
||||
the ``hermes`` integration, and applies a chosen manifest to the user's bank
|
||||
via the import API (``POST /v1/default/banks/{bank}/import``, which creates the
|
||||
bank if it doesn't exist).
|
||||
|
||||
Kept out of ``__init__`` so the wizard logic stays small and testable. The
|
||||
catalog source is overridable with ``HINDSIGHT_TEMPLATES_URL`` (e.g. to pin a
|
||||
version or point at a mirror).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import urllib.request
|
||||
from urllib.parse import urljoin
|
||||
|
||||
from hermes_cli.urllib_security import open_credentialed_url
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# The Bank Templates catalog lives in the Hindsight docs repo and is the same
|
||||
# file that powers hindsight.vectorize.io/templates.
|
||||
_DEFAULT_CATALOG_URL = (
|
||||
"https://raw.githubusercontent.com/vectorize-io/hindsight/main/"
|
||||
"hindsight-docs/src/data/templates.json"
|
||||
)
|
||||
_HTTP_TIMEOUT = 15
|
||||
|
||||
# The starter-template step needs the API reachable during setup. A
|
||||
# local_embedded daemon isn't running yet at that point, so it's skipped there.
|
||||
SUPPORTED_MODES = ("cloud", "local_external")
|
||||
|
||||
|
||||
def supported_for_mode(mode: str) -> bool:
|
||||
return mode in SUPPORTED_MODES
|
||||
|
||||
|
||||
def catalog_url() -> str:
|
||||
return os.environ.get("HINDSIGHT_TEMPLATES_URL", _DEFAULT_CATALOG_URL)
|
||||
|
||||
|
||||
def _get_json(url: str) -> dict:
|
||||
req = urllib.request.Request(url, headers={"Accept": "application/json"})
|
||||
with urllib.request.urlopen(req, timeout=_HTTP_TIMEOUT) as resp: # noqa: S310 - fixed https catalog
|
||||
return json.loads(resp.read().decode("utf-8"))
|
||||
|
||||
|
||||
def fetch_hermes_templates(url: str | None = None) -> list[dict]:
|
||||
"""Return catalog entries tagged for the ``hermes`` integration."""
|
||||
catalog = _get_json(url or catalog_url())
|
||||
entries = catalog.get("templates", []) if isinstance(catalog, dict) else []
|
||||
return [e for e in entries if "hermes" in (e.get("integrations") or [])]
|
||||
|
||||
|
||||
def fetch_manifest(entry: dict, url: str | None = None) -> dict:
|
||||
"""Fetch the BankTemplateManifest JSON for a catalog entry."""
|
||||
# manifest_file is relative to the catalog (e.g. "templates/foo.json").
|
||||
manifest_url = urljoin(url or catalog_url(), entry["manifest_file"])
|
||||
return _get_json(manifest_url)
|
||||
|
||||
|
||||
def apply_template(api_url: str, bank_id: str, api_key: str | None, manifest: dict) -> None:
|
||||
"""Apply a manifest to a bank via the import endpoint. Raises on failure."""
|
||||
endpoint = f"{api_url.rstrip('/')}/v1/default/banks/{bank_id}/import"
|
||||
data = json.dumps(manifest).encode("utf-8")
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if api_key:
|
||||
headers["Authorization"] = f"Bearer {api_key}"
|
||||
req = urllib.request.Request(endpoint, data=data, headers=headers, method="POST") # noqa: S310
|
||||
with open_credentialed_url(req, timeout=_HTTP_TIMEOUT) as resp:
|
||||
resp.read() # drain; open_credentialed_url raises HTTPError on non-2xx
|
||||
|
||||
|
||||
def probe_existing_customization(api_url: str, bank_id: str, api_key: str | None) -> bool:
|
||||
"""Best-effort: True if the bank already has template-level config, mental
|
||||
models, or directives — i.e. applying a template would overwrite settings.
|
||||
|
||||
A missing bank, or any error, is treated as "not customized": the step must
|
||||
never block on this probe.
|
||||
"""
|
||||
endpoint = f"{api_url.rstrip('/')}/v1/default/banks/{bank_id}/export"
|
||||
headers = {"Accept": "application/json"}
|
||||
if api_key:
|
||||
headers["Authorization"] = f"Bearer {api_key}"
|
||||
req = urllib.request.Request(endpoint, headers=headers) # noqa: S310
|
||||
try:
|
||||
with open_credentialed_url(req, timeout=_HTTP_TIMEOUT) as resp:
|
||||
data = json.loads(resp.read().decode("utf-8"))
|
||||
except Exception as e: # missing bank / network — treat as not customized
|
||||
logger.debug("Hindsight: bank customization probe skipped: %s", e)
|
||||
return False
|
||||
return bool(data.get("bank") or data.get("mental_models") or data.get("directives"))
|
||||
|
||||
|
||||
def run_template_step(
|
||||
*,
|
||||
api_url: str,
|
||||
bank_id: str,
|
||||
api_key: str | None,
|
||||
select,
|
||||
cancelled,
|
||||
log=print,
|
||||
) -> str | None:
|
||||
"""Drive the wizard's starter-template step.
|
||||
|
||||
``select(title, items, default, cancel_returns)`` is the picker (injected so
|
||||
this is testable without curses). Returns the applied template id, or None
|
||||
if skipped/blank/failed. Never raises — the template is a nice-to-have.
|
||||
"""
|
||||
try:
|
||||
entries = fetch_hermes_templates()
|
||||
except Exception as e: # network/parse — non-fatal
|
||||
logger.debug("Hindsight: could not fetch templates: %s", e)
|
||||
return None
|
||||
if not entries:
|
||||
return None
|
||||
|
||||
items = [(e.get("name", e["id"]), (e.get("description") or "")[:72]) for e in entries]
|
||||
items.append(("Blank", "Start with an empty memory bank"))
|
||||
idx = select(" Starter memory template", items, default=0, cancel_returns=cancelled)
|
||||
if idx == cancelled or idx >= len(entries):
|
||||
return None # blank or cancelled
|
||||
|
||||
entry = entries[idx]
|
||||
|
||||
# If the bank is already configured (re-running setup on an existing bank),
|
||||
# applying a template overwrites its config and upserts its models/directives.
|
||||
# Confirm before clobbering.
|
||||
if probe_existing_customization(api_url, bank_id, api_key):
|
||||
confirm = select(
|
||||
f" Bank '{bank_id}' already has memory settings — apply this template on top?",
|
||||
[("Apply", "Overwrite config; add/update mental models & directives"),
|
||||
("Keep existing", "Leave the bank as-is")],
|
||||
default=1,
|
||||
cancel_returns=cancelled,
|
||||
)
|
||||
if confirm != 0:
|
||||
log(f" Kept existing settings for bank '{bank_id}'.")
|
||||
return None
|
||||
|
||||
try:
|
||||
manifest = fetch_manifest(entry)
|
||||
apply_template(api_url, bank_id, api_key, manifest)
|
||||
log(f" ✓ Applied '{entry.get('name', entry['id'])}' template to bank '{bank_id}'")
|
||||
return entry["id"]
|
||||
except Exception as e:
|
||||
log(f" ⚠ Could not apply template ({e}). You can apply one later from "
|
||||
f"hindsight.vectorize.io/templates.")
|
||||
return None
|
||||
@@ -0,0 +1,36 @@
|
||||
# Holographic Memory Provider
|
||||
|
||||
Local SQLite fact store with FTS5 search, trust scoring, entity resolution, and HRR-based compositional retrieval.
|
||||
|
||||
## Requirements
|
||||
|
||||
None — uses SQLite (always available). NumPy optional for HRR algebra.
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
hermes memory setup # select "holographic"
|
||||
```
|
||||
|
||||
Or manually:
|
||||
```bash
|
||||
hermes config set memory.provider holographic
|
||||
```
|
||||
|
||||
## Config
|
||||
|
||||
Config in `config.yaml` under `plugins.hermes-memory-store`:
|
||||
|
||||
| Key | Default | Description |
|
||||
|-----|---------|-------------|
|
||||
| `db_path` | `$HERMES_HOME/memory_store.db` | SQLite database path |
|
||||
| `auto_extract` | `false` | Auto-extract facts at session end |
|
||||
| `default_trust` | `0.5` | Default trust score for new facts |
|
||||
| `hrr_dim` | `1024` | HRR vector dimensions |
|
||||
|
||||
## Tools
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `fact_store` | 9 actions: add, search, probe, related, reason, contradict, update, remove, list |
|
||||
| `fact_feedback` | Rate facts as helpful/unhelpful (trains trust scores) |
|
||||
@@ -0,0 +1,462 @@
|
||||
"""hermes-memory-store — holographic memory plugin using MemoryProvider interface.
|
||||
|
||||
Registers as a MemoryProvider plugin, giving the agent structured fact storage
|
||||
with entity resolution, trust scoring, and HRR-based compositional retrieval.
|
||||
|
||||
Original plugin by dusterbloom (PR #2351), adapted to the MemoryProvider ABC.
|
||||
|
||||
Config in $HERMES_HOME/config.yaml (profile-scoped):
|
||||
plugins:
|
||||
hermes-memory-store:
|
||||
db_path: $HERMES_HOME/memory_store.db # omit to use the default
|
||||
auto_extract: false
|
||||
default_trust: 0.5
|
||||
min_trust_threshold: 0.3
|
||||
temporal_decay_half_life: 0
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from agent.memory_provider import MemoryProvider
|
||||
from tools.registry import tool_error
|
||||
from utils import is_truthy_value
|
||||
from .store import MemoryStore
|
||||
from .retrieval import FactRetriever
|
||||
from hermes_cli.config import cfg_get
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tool schemas (unchanged from original PR)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
FACT_STORE_SCHEMA = {
|
||||
"name": "fact_store",
|
||||
"description": (
|
||||
"Deep structured memory with algebraic reasoning. "
|
||||
"Use alongside the memory tool — memory for always-on context, "
|
||||
"fact_store for deep recall and compositional queries.\n\n"
|
||||
"ACTIONS (simple → powerful):\n"
|
||||
"• add — Store a fact the user would expect you to remember.\n"
|
||||
"• search — Keyword lookup ('editor config', 'deploy process').\n"
|
||||
"• probe — Entity recall: ALL facts about a person/thing.\n"
|
||||
"• related — What connects to an entity? Structural adjacency.\n"
|
||||
"• reason — Compositional: facts connected to MULTIPLE entities simultaneously.\n"
|
||||
"• contradict — Memory hygiene: find facts making conflicting claims.\n"
|
||||
"• update/remove/list — CRUD operations.\n\n"
|
||||
"IMPORTANT: Before answering questions about the user, ALWAYS probe or reason first."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"action": {
|
||||
"type": "string",
|
||||
"enum": ["add", "search", "probe", "related", "reason", "contradict", "update", "remove", "list"],
|
||||
},
|
||||
"content": {"type": "string", "description": "Fact content (required for 'add')."},
|
||||
"query": {"type": "string", "description": "Search query (required for 'search')."},
|
||||
"entity": {"type": "string", "description": "Entity name for 'probe'/'related'."},
|
||||
"entities": {"type": "array", "items": {"type": "string"}, "description": "Entity names for 'reason'."},
|
||||
"fact_id": {"type": "integer", "description": "Fact ID for 'update'/'remove'."},
|
||||
"category": {"type": "string", "enum": ["user_pref", "project", "tool", "general"]},
|
||||
"tags": {"type": "string", "description": "Comma-separated tags."},
|
||||
"trust_delta": {"type": "number", "description": "Trust adjustment for 'update'."},
|
||||
"min_trust": {"type": "number", "description": "Minimum trust filter (default: 0.3)."},
|
||||
"limit": {"type": "integer", "description": "Max results (default: 10)."},
|
||||
},
|
||||
"required": ["action"],
|
||||
},
|
||||
}
|
||||
|
||||
FACT_FEEDBACK_SCHEMA = {
|
||||
"name": "fact_feedback",
|
||||
"description": (
|
||||
"Rate a fact after using it. Mark 'helpful' if accurate, 'unhelpful' if outdated. "
|
||||
"This trains the memory — good facts rise, bad facts sink."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"action": {"type": "string", "enum": ["helpful", "unhelpful"]},
|
||||
"fact_id": {"type": "integer", "description": "The fact ID to rate."},
|
||||
},
|
||||
"required": ["action", "fact_id"],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Config
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _load_plugin_config() -> dict:
|
||||
try:
|
||||
# Canonical loader: behavioral read now honors the managed-scope
|
||||
# overlay + ${VAR} expansion (e.g. an api key template) too.
|
||||
from hermes_cli.config import load_config_readonly
|
||||
all_config = load_config_readonly()
|
||||
return cfg_get(all_config, "plugins", "hermes-memory-store", default={}) or {}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MemoryProvider implementation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class HolographicMemoryProvider(MemoryProvider):
|
||||
"""Holographic memory with structured facts, entity resolution, and HRR retrieval."""
|
||||
|
||||
def __init__(self, config: dict | None = None):
|
||||
self._config = config or _load_plugin_config()
|
||||
self._store = None
|
||||
self._retriever = None
|
||||
self._min_trust = float(self._config.get("min_trust_threshold", 0.3))
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "holographic"
|
||||
|
||||
def is_available(self) -> bool:
|
||||
return True # SQLite is always available, numpy is optional
|
||||
|
||||
def save_config(self, values, hermes_home):
|
||||
"""Write config to config.yaml under plugins.hermes-memory-store."""
|
||||
from pathlib import Path
|
||||
config_path = Path(hermes_home) / "config.yaml"
|
||||
try:
|
||||
import yaml
|
||||
# Write-back round-trip: raw read is correct (merged defaults
|
||||
# must not be persisted back into the user's file).
|
||||
from hermes_cli.config import read_user_config_raw
|
||||
existing = read_user_config_raw(config_path)
|
||||
existing.setdefault("plugins", {})
|
||||
existing["plugins"]["hermes-memory-store"] = values
|
||||
with open(config_path, "w", encoding="utf-8") as f:
|
||||
yaml.dump(existing, f, default_flow_style=False)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def get_config_schema(self):
|
||||
from hermes_constants import display_hermes_home
|
||||
_default_db = f"{display_hermes_home()}/memory_store.db"
|
||||
return [
|
||||
{"key": "db_path", "description": "SQLite database path", "default": _default_db},
|
||||
{"key": "auto_extract", "description": "Auto-extract facts at session end", "default": "false", "choices": ["true", "false"]},
|
||||
{"key": "default_trust", "description": "Default trust score for new facts", "default": "0.5"},
|
||||
{"key": "hrr_dim", "description": "HRR vector dimensions", "default": "1024"},
|
||||
]
|
||||
|
||||
def initialize(self, session_id: str, **kwargs) -> None:
|
||||
from hermes_constants import get_hermes_home
|
||||
_hermes_home = str(get_hermes_home())
|
||||
_default_db = _hermes_home + "/memory_store.db"
|
||||
db_path = self._config.get("db_path", _default_db)
|
||||
# Expand $HERMES_HOME in user-supplied paths so config values like
|
||||
# "$HERMES_HOME/memory_store.db" or "~/.hermes/memory_store.db" both
|
||||
# resolve to the active profile's directory.
|
||||
if isinstance(db_path, str):
|
||||
db_path = db_path.replace("$HERMES_HOME", _hermes_home)
|
||||
db_path = db_path.replace("${HERMES_HOME}", _hermes_home)
|
||||
default_trust = float(self._config.get("default_trust", 0.5))
|
||||
hrr_dim = int(self._config.get("hrr_dim", 1024))
|
||||
hrr_weight = float(self._config.get("hrr_weight", 0.3))
|
||||
temporal_decay = int(self._config.get("temporal_decay_half_life", 0))
|
||||
|
||||
self._store = MemoryStore(db_path=db_path, default_trust=default_trust, hrr_dim=hrr_dim)
|
||||
self._retriever = FactRetriever(
|
||||
store=self._store,
|
||||
temporal_decay_half_life=temporal_decay,
|
||||
hrr_weight=hrr_weight,
|
||||
hrr_dim=hrr_dim,
|
||||
)
|
||||
self._session_id = session_id
|
||||
|
||||
def system_prompt_block(self) -> str:
|
||||
if not self._store:
|
||||
return ""
|
||||
try:
|
||||
total = self._store._conn.execute(
|
||||
"SELECT COUNT(*) FROM facts"
|
||||
).fetchone()[0]
|
||||
except Exception:
|
||||
total = 0
|
||||
if total == 0:
|
||||
return (
|
||||
"# Holographic Memory\n"
|
||||
"Active. Empty fact store — proactively add facts the user would expect you to remember.\n"
|
||||
"Use fact_store(action='add') to store durable structured facts about people, projects, preferences, decisions.\n"
|
||||
"Use fact_feedback to rate facts after using them (trains trust scores)."
|
||||
)
|
||||
return (
|
||||
f"# Holographic Memory\n"
|
||||
f"Active. {total} facts stored with entity resolution and trust scoring.\n"
|
||||
f"Use fact_store to search, probe entities, reason across entities, or add facts.\n"
|
||||
f"Use fact_feedback to rate facts after using them (trains trust scores)."
|
||||
)
|
||||
|
||||
def prefetch(self, query: str, *, session_id: str = "") -> str:
|
||||
if not self._retriever or not query:
|
||||
return ""
|
||||
try:
|
||||
results = self._retriever.search(query, min_trust=self._min_trust, limit=5)
|
||||
if not results:
|
||||
return ""
|
||||
lines = []
|
||||
for r in results:
|
||||
trust = r.get("trust_score", r.get("trust", 0))
|
||||
lines.append(f"- [{trust:.1f}] {r.get('content', '')}")
|
||||
return "## Holographic Memory\n" + "\n".join(lines)
|
||||
except Exception as e:
|
||||
logger.debug("Holographic prefetch failed: %s", e)
|
||||
return ""
|
||||
|
||||
def sync_turn(self, user_content: str, assistant_content: str, *, session_id: str = "") -> None:
|
||||
# Holographic memory stores explicit facts via tools, not auto-sync.
|
||||
# The on_session_end hook handles auto-extraction if configured.
|
||||
pass
|
||||
|
||||
def get_tool_schemas(self) -> List[Dict[str, Any]]:
|
||||
return [FACT_STORE_SCHEMA, FACT_FEEDBACK_SCHEMA]
|
||||
|
||||
def handle_tool_call(self, tool_name: str, args: Dict[str, Any], **kwargs) -> str:
|
||||
if tool_name == "fact_store":
|
||||
return self._handle_fact_store(args)
|
||||
elif tool_name == "fact_feedback":
|
||||
return self._handle_fact_feedback(args)
|
||||
return tool_error(f"Unknown tool: {tool_name}")
|
||||
|
||||
def on_session_end(self, messages: List[Dict[str, Any]]) -> None:
|
||||
# is_truthy_value: the config schema declares auto_extract as a string
|
||||
# enum ("false"/"true"), and a plain truthiness check treats the string
|
||||
# "false" as enabled (#57682).
|
||||
if not is_truthy_value(self._config.get("auto_extract", False)):
|
||||
return
|
||||
if not self._store or not messages:
|
||||
return
|
||||
self._auto_extract_facts(messages)
|
||||
|
||||
def on_memory_write(self, action: str, target: str, content: str) -> None:
|
||||
"""Mirror built-in memory writes as facts."""
|
||||
if action == "add" and self._store and content:
|
||||
try:
|
||||
category = "user_pref" if target == "user" else "general"
|
||||
self._store.add_fact(content, category=category)
|
||||
except Exception as e:
|
||||
logger.debug("Holographic memory_write mirror failed: %s", e)
|
||||
|
||||
def shutdown(self) -> None:
|
||||
# Release the shared SQLite connection deterministically on the
|
||||
# caller's thread. Dropping the reference alone leaves fd finalization
|
||||
# to GC, which keeps the connection (and its write lock) alive on a
|
||||
# long-running gateway and prolongs the "database is locked" contention
|
||||
# this store's shared-connection refcounting is meant to eliminate.
|
||||
# close() is idempotent and refcount-guarded, so siblings stay safe.
|
||||
if self._store is not None:
|
||||
try:
|
||||
self._store.close()
|
||||
except Exception as e:
|
||||
logger.debug("Holographic shutdown close() failed: %s", e)
|
||||
self._store = None
|
||||
self._retriever = None
|
||||
|
||||
# -- Tool handlers -------------------------------------------------------
|
||||
|
||||
def _handle_fact_store(self, args: dict) -> str:
|
||||
try:
|
||||
action = args["action"]
|
||||
store = self._store
|
||||
retriever = self._retriever
|
||||
|
||||
if action == "add":
|
||||
fact_id = store.add_fact(
|
||||
args["content"],
|
||||
category=args.get("category", "general"),
|
||||
tags=args.get("tags", ""),
|
||||
)
|
||||
return json.dumps({"fact_id": fact_id, "status": "added"})
|
||||
|
||||
elif action == "search":
|
||||
results = retriever.search(
|
||||
args["query"],
|
||||
category=args.get("category"),
|
||||
min_trust=float(args.get("min_trust", self._min_trust)),
|
||||
limit=int(args.get("limit", 10)),
|
||||
)
|
||||
return json.dumps({"results": results, "count": len(results)})
|
||||
|
||||
elif action == "probe":
|
||||
results = retriever.probe(
|
||||
args["entity"],
|
||||
category=args.get("category"),
|
||||
limit=int(args.get("limit", 10)),
|
||||
)
|
||||
return json.dumps({"results": results, "count": len(results)})
|
||||
|
||||
elif action == "related":
|
||||
results = retriever.related(
|
||||
args["entity"],
|
||||
category=args.get("category"),
|
||||
limit=int(args.get("limit", 10)),
|
||||
)
|
||||
return json.dumps({"results": results, "count": len(results)})
|
||||
|
||||
elif action == "reason":
|
||||
entities = args.get("entities", [])
|
||||
if not entities:
|
||||
return tool_error("reason requires 'entities' list")
|
||||
results = retriever.reason(
|
||||
entities,
|
||||
category=args.get("category"),
|
||||
limit=int(args.get("limit", 10)),
|
||||
)
|
||||
return json.dumps({"results": results, "count": len(results)})
|
||||
|
||||
elif action == "contradict":
|
||||
results = retriever.contradict(
|
||||
category=args.get("category"),
|
||||
limit=int(args.get("limit", 10)),
|
||||
)
|
||||
return json.dumps({"results": results, "count": len(results)})
|
||||
|
||||
elif action == "update":
|
||||
updated = store.update_fact(
|
||||
int(args["fact_id"]),
|
||||
content=args.get("content"),
|
||||
trust_delta=float(args["trust_delta"]) if "trust_delta" in args else None,
|
||||
tags=args.get("tags"),
|
||||
category=args.get("category"),
|
||||
)
|
||||
return json.dumps({"updated": updated})
|
||||
|
||||
elif action == "remove":
|
||||
removed = store.remove_fact(int(args["fact_id"]))
|
||||
return json.dumps({"removed": removed})
|
||||
|
||||
elif action == "list":
|
||||
facts = store.list_facts(
|
||||
category=args.get("category"),
|
||||
min_trust=float(args.get("min_trust", 0.0)),
|
||||
limit=int(args.get("limit", 10)),
|
||||
)
|
||||
return json.dumps({"facts": facts, "count": len(facts)})
|
||||
|
||||
else:
|
||||
return tool_error(f"Unknown action: {action}")
|
||||
|
||||
except KeyError as exc:
|
||||
return tool_error(f"Missing required argument: {exc}")
|
||||
except Exception as exc:
|
||||
return tool_error(str(exc))
|
||||
|
||||
def _handle_fact_feedback(self, args: dict) -> str:
|
||||
try:
|
||||
fact_id = int(args["fact_id"])
|
||||
helpful = args["action"] == "helpful"
|
||||
result = self._store.record_feedback(fact_id, helpful=helpful)
|
||||
return json.dumps(result)
|
||||
except KeyError as exc:
|
||||
return tool_error(f"Missing required argument: {exc}")
|
||||
except Exception as exc:
|
||||
return tool_error(str(exc))
|
||||
|
||||
# -- Auto-extraction (on_session_end) ------------------------------------
|
||||
|
||||
def _auto_extract_facts(self, messages: list) -> None:
|
||||
# Local import (pattern used in initialize()): the compressor module is
|
||||
# heavier than this plugin and is only needed when auto_extract is on.
|
||||
from agent.context_compressor import (
|
||||
_MERGED_PRIOR_CONTEXT_HEADER,
|
||||
_MERGED_SUMMARY_DELIMITER,
|
||||
is_compaction_summary_message,
|
||||
)
|
||||
|
||||
def _pre_delimiter_user_segment(msg: dict):
|
||||
"""Return the genuine user text preceding a merged-into-tail
|
||||
compaction summary, or None when the whole message is a summary.
|
||||
|
||||
Merge-into-tail messages (agent/context_compressor.py ~3163-3190)
|
||||
wrap real prior tail content BEFORE ``_MERGED_SUMMARY_DELIMITER``,
|
||||
prefixed with ``_MERGED_PRIOR_CONTEXT_HEADER``, then append the
|
||||
generated handoff summary AFTER the delimiter. Dropping the whole
|
||||
row (as ``is_compaction_summary_message`` alone would suggest)
|
||||
discards that genuine pre-delimiter content too (#57690 review).
|
||||
Only the summary suffix must be excluded from harvesting.
|
||||
"""
|
||||
content = msg.get("content", "")
|
||||
if not isinstance(content, str) or _MERGED_SUMMARY_DELIMITER not in content:
|
||||
return None
|
||||
pre = content.split(_MERGED_SUMMARY_DELIMITER, 1)[0]
|
||||
if pre.startswith(_MERGED_PRIOR_CONTEXT_HEADER):
|
||||
pre = pre[len(_MERGED_PRIOR_CONTEXT_HEADER):]
|
||||
pre = pre.strip()
|
||||
return pre or None
|
||||
|
||||
_PREF_PATTERNS = [
|
||||
re.compile(r'\bI\s+(?:prefer|like|love|use|want|need)\s+(.+)', re.IGNORECASE),
|
||||
re.compile(r'\bmy\s+(?:favorite|preferred|default)\s+\w+\s+is\s+(.+)', re.IGNORECASE),
|
||||
re.compile(r'\bI\s+(?:always|never|usually)\s+(.+)', re.IGNORECASE),
|
||||
]
|
||||
_DECISION_PATTERNS = [
|
||||
re.compile(r'\bwe\s+(?:decided|agreed|chose)\s+(?:to\s+)?(.+)', re.IGNORECASE),
|
||||
re.compile(r'\bthe\s+project\s+(?:uses|needs|requires)\s+(.+)', re.IGNORECASE),
|
||||
]
|
||||
|
||||
extracted = 0
|
||||
for msg in messages:
|
||||
if msg.get("role") != "user":
|
||||
continue
|
||||
# Compaction handoff summaries can be inserted as role="user"
|
||||
# messages; their prose reliably matches the decision patterns, so
|
||||
# without this guard the compactor's own output is stored as a
|
||||
# durable "fact" on every rollover (#57682). A merge-into-tail
|
||||
# summary also carries genuine pre-delimiter user content in the
|
||||
# SAME row; harvest that segment instead of dropping the whole
|
||||
# message (#57690 review).
|
||||
pre_delimiter_segment = _pre_delimiter_user_segment(msg)
|
||||
if pre_delimiter_segment is not None:
|
||||
content = pre_delimiter_segment
|
||||
elif is_compaction_summary_message(msg):
|
||||
continue
|
||||
else:
|
||||
content = msg.get("content", "")
|
||||
if not isinstance(content, str) or len(content) < 10:
|
||||
continue
|
||||
|
||||
for pattern in _PREF_PATTERNS:
|
||||
if pattern.search(content):
|
||||
try:
|
||||
self._store.add_fact(content[:400], category="user_pref")
|
||||
extracted += 1
|
||||
except Exception:
|
||||
pass
|
||||
break
|
||||
|
||||
for pattern in _DECISION_PATTERNS:
|
||||
if pattern.search(content):
|
||||
try:
|
||||
self._store.add_fact(content[:400], category="project")
|
||||
extracted += 1
|
||||
except Exception:
|
||||
pass
|
||||
break
|
||||
|
||||
if extracted:
|
||||
logger.info("Auto-extracted %d facts from conversation", extracted)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Plugin entry point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def register(ctx) -> None:
|
||||
"""Register the holographic memory provider with the plugin system."""
|
||||
config = _load_plugin_config()
|
||||
provider = HolographicMemoryProvider(config=config)
|
||||
ctx.register_memory_provider(provider)
|
||||
@@ -0,0 +1,290 @@
|
||||
"""Holographic Reduced Representations (HRR) with phase encoding.
|
||||
|
||||
HRRs are a vector symbolic architecture for encoding compositional structure
|
||||
into fixed-width distributed representations. This module uses *phase vectors*:
|
||||
each concept is a vector of angles in [0, 2π). The algebraic operations are:
|
||||
|
||||
bind — circular convolution (phase addition) — associates two concepts
|
||||
unbind — circular correlation (phase subtraction) — retrieves a bound value
|
||||
bundle — superposition (circular mean) — merges multiple concepts
|
||||
|
||||
Phase encoding is numerically stable, avoids the magnitude collapse of
|
||||
traditional complex-number HRRs, and maps cleanly to cosine similarity.
|
||||
|
||||
Atoms are generated deterministically from SHA-256 so representations are
|
||||
identical across processes, machines, and language versions.
|
||||
|
||||
References:
|
||||
Plate (1995) — Holographic Reduced Representations
|
||||
Gayler (2004) — Vector Symbolic Architectures answer Jackendoff's challenges
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import struct
|
||||
import math
|
||||
|
||||
try:
|
||||
import numpy as np
|
||||
_HAS_NUMPY = True
|
||||
except ImportError:
|
||||
_HAS_NUMPY = False
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_TWO_PI = 2.0 * math.pi
|
||||
_FLOAT32_BLOB_PREFIX = b"HRR1"
|
||||
|
||||
|
||||
def _require_numpy() -> None:
|
||||
if not _HAS_NUMPY:
|
||||
raise RuntimeError("numpy is required for holographic operations")
|
||||
|
||||
|
||||
def _np():
|
||||
"""Return the numpy module after the runtime availability guard."""
|
||||
_require_numpy()
|
||||
return np # type: ignore[name-defined]
|
||||
|
||||
|
||||
def encode_atom(word: str, dim: int = 1024) -> "np.ndarray":
|
||||
"""Deterministic phase vector via SHA-256 counter blocks.
|
||||
|
||||
Uses hashlib (not numpy RNG) for cross-platform reproducibility.
|
||||
|
||||
Algorithm:
|
||||
- Generate enough SHA-256 blocks by hashing f"{word}:{i}" for i=0,1,2,...
|
||||
- Concatenate digests, interpret as uint16 values via struct.unpack
|
||||
- Scale to [0, 2π): phases = values * (2π / 65536)
|
||||
- Truncate to dim elements
|
||||
- Returns np.float64 array of shape (dim,)
|
||||
"""
|
||||
_require_numpy()
|
||||
|
||||
# Each SHA-256 digest is 32 bytes = 16 uint16 values.
|
||||
values_per_block = 16
|
||||
blocks_needed = math.ceil(dim / values_per_block)
|
||||
|
||||
uint16_values: list[int] = []
|
||||
for i in range(blocks_needed):
|
||||
digest = hashlib.sha256(f"{word}:{i}".encode()).digest()
|
||||
uint16_values.extend(struct.unpack("<16H", digest))
|
||||
|
||||
phases = np.array(uint16_values[:dim], dtype=np.float64) * (_TWO_PI / 65536.0)
|
||||
return phases
|
||||
|
||||
|
||||
def bind(a: "np.ndarray", b: "np.ndarray") -> "np.ndarray":
|
||||
"""Circular convolution = element-wise phase addition.
|
||||
|
||||
Binding associates two concepts into a single composite vector.
|
||||
The result is dissimilar to both inputs (quasi-orthogonal).
|
||||
"""
|
||||
_require_numpy()
|
||||
return (a + b) % _TWO_PI
|
||||
|
||||
|
||||
def unbind(memory: "np.ndarray", key: "np.ndarray") -> "np.ndarray":
|
||||
"""Circular correlation = element-wise phase subtraction.
|
||||
|
||||
Unbinding retrieves the value associated with a key from a memory vector.
|
||||
unbind(bind(a, b), a) ≈ b (up to superposition noise)
|
||||
"""
|
||||
_require_numpy()
|
||||
return (memory - key) % _TWO_PI
|
||||
|
||||
|
||||
def bundle(*vectors: "np.ndarray") -> "np.ndarray":
|
||||
"""Superposition via circular mean of complex exponentials.
|
||||
|
||||
Bundling merges multiple vectors into one that is similar to each input.
|
||||
The result can hold O(sqrt(dim)) items before similarity degrades.
|
||||
"""
|
||||
_require_numpy()
|
||||
complex_sum = np.sum([np.exp(1j * v) for v in vectors], axis=0)
|
||||
return np.angle(complex_sum) % _TWO_PI
|
||||
|
||||
|
||||
def similarity(a: "np.ndarray", b: "np.ndarray") -> float:
|
||||
"""Phase cosine similarity. Range [-1, 1].
|
||||
|
||||
Returns 1.0 for identical vectors, near 0.0 for random (unrelated) vectors,
|
||||
and -1.0 for perfectly anti-correlated vectors.
|
||||
"""
|
||||
_require_numpy()
|
||||
return float(np.mean(np.cos(a - b)))
|
||||
|
||||
|
||||
def encode_text(text: str, dim: int = 1024) -> "np.ndarray":
|
||||
"""Bag-of-words: bundle of atom vectors for each token.
|
||||
|
||||
Tokenizes by lowercasing, splitting on whitespace, and stripping
|
||||
leading/trailing punctuation from each token.
|
||||
|
||||
Returns bundle of all token atom vectors.
|
||||
If text is empty or produces no tokens, returns encode_atom("__hrr_empty__", dim).
|
||||
"""
|
||||
_require_numpy()
|
||||
|
||||
tokens = [
|
||||
token.strip(".,!?;:\"'()[]{}")
|
||||
for token in text.lower().split()
|
||||
]
|
||||
tokens = [t for t in tokens if t]
|
||||
|
||||
if not tokens:
|
||||
return encode_atom("__hrr_empty__", dim)
|
||||
|
||||
atom_vectors = [encode_atom(token, dim) for token in tokens]
|
||||
return bundle(*atom_vectors)
|
||||
|
||||
|
||||
def encode_fact(content: str, entities: list[str], dim: int = 1024) -> "np.ndarray":
|
||||
"""Structured encoding: content bound to ROLE_CONTENT, each entity bound to ROLE_ENTITY, all bundled.
|
||||
|
||||
Role vectors are reserved atoms: "__hrr_role_content__", "__hrr_role_entity__"
|
||||
|
||||
Components:
|
||||
1. bind(encode_text(content, dim), encode_atom("__hrr_role_content__", dim))
|
||||
2. For each entity: bind(encode_atom(entity.lower(), dim), encode_atom("__hrr_role_entity__", dim))
|
||||
3. bundle all components together
|
||||
|
||||
This enables algebraic extraction:
|
||||
unbind(fact, bind(entity, ROLE_ENTITY)) ≈ content_vector
|
||||
"""
|
||||
_require_numpy()
|
||||
|
||||
role_content = encode_atom("__hrr_role_content__", dim)
|
||||
role_entity = encode_atom("__hrr_role_entity__", dim)
|
||||
|
||||
components: list[np.ndarray] = [
|
||||
bind(encode_text(content, dim), role_content)
|
||||
]
|
||||
|
||||
for entity in entities:
|
||||
components.append(bind(encode_atom(entity.lower(), dim), role_entity))
|
||||
|
||||
return bundle(*components)
|
||||
|
||||
|
||||
def phases_to_bytes(phases: "np.ndarray", dim: int | None = None) -> bytes:
|
||||
"""Serialize phase vectors as float32 blobs.
|
||||
|
||||
float32 halves SQLite BLOB storage versus the legacy float64 format
|
||||
(4 KB + a 4-byte format prefix instead of 8 KB at dim=1024) while
|
||||
preserving enough precision for phase-similarity retrieval.
|
||||
``bytes_to_phases`` keeps reading legacy float64 blobs for backward
|
||||
compatibility.
|
||||
|
||||
When ``dim`` is 1 the prefixed float32 blob (8 bytes) collides in size
|
||||
with a raw float64 blob (8 bytes), making the format ambiguous. In
|
||||
that case we fall back to writing raw float64 so that ``bytes_to_phases``
|
||||
can never misinterpret the blob.
|
||||
"""
|
||||
numpy = _np()
|
||||
if dim is None:
|
||||
dim = int(phases.shape[0])
|
||||
float32_blob_bytes = len(_FLOAT32_BLOB_PREFIX) + dim * numpy.dtype(numpy.float32).itemsize
|
||||
float64_bytes = dim * numpy.dtype(numpy.float64).itemsize
|
||||
if float32_blob_bytes == float64_bytes:
|
||||
# dim=1: sizes collide, write legacy float64 to stay unambiguous
|
||||
return numpy.asarray(phases, dtype=numpy.float64).tobytes()
|
||||
payload = numpy.asarray(phases, dtype=numpy.float32).tobytes()
|
||||
return _FLOAT32_BLOB_PREFIX + payload
|
||||
|
||||
|
||||
def bytes_to_phases(data: bytes, dim: int | None = None) -> "np.ndarray":
|
||||
"""Deserialize a phase vector from new float32 or legacy float64 storage.
|
||||
|
||||
New float32 blobs carry a small prefix so callers can round-trip without
|
||||
knowing ``dim``. Legacy float64 blobs are raw NumPy bytes and remain
|
||||
readable for backward compatibility. The returned array is copied and
|
||||
promoted to float64 so downstream HRR math keeps the existing numerical
|
||||
behavior.
|
||||
|
||||
When ``dim`` is 1 the prefixed float32 blob and the raw float64 blob are
|
||||
both 8 bytes, so size alone cannot disambiguate. ``phases_to_bytes``
|
||||
avoids writing prefixed blobs in that case; here we guard the remaining
|
||||
collision window (a legacy float64 blob that happens to start with the
|
||||
``HRR1`` prefix) by preferring the legacy interpretation when sizes
|
||||
match and the caller supplied ``dim``.
|
||||
"""
|
||||
numpy = _np()
|
||||
|
||||
if dim is not None:
|
||||
float32_payload_bytes = dim * numpy.dtype(numpy.float32).itemsize
|
||||
float32_blob_bytes = len(_FLOAT32_BLOB_PREFIX) + float32_payload_bytes
|
||||
float64_bytes = dim * numpy.dtype(numpy.float64).itemsize
|
||||
|
||||
# When sizes collide (dim=1), prefer legacy float64 for a blob that
|
||||
# starts with the prefix, because phases_to_bytes never writes a
|
||||
# prefixed float32 blob at dim=1 — any such blob must be legacy.
|
||||
if float32_blob_bytes == float64_bytes:
|
||||
if len(data) == float64_bytes:
|
||||
return numpy.frombuffer(data, dtype=numpy.float64).copy()
|
||||
if data.startswith(_FLOAT32_BLOB_PREFIX):
|
||||
payload_len = len(data) - len(_FLOAT32_BLOB_PREFIX)
|
||||
raise ValueError(
|
||||
f"HRR vector blob has {len(data)} bytes ({payload_len} payload bytes after "
|
||||
f"the float32 prefix); expected {float64_bytes} (legacy float64) for dim={dim}"
|
||||
)
|
||||
raise ValueError(
|
||||
f"HRR legacy vector blob has {len(data)} bytes; expected "
|
||||
f"{float64_bytes} (float64) for dim={dim}"
|
||||
)
|
||||
|
||||
if data.startswith(_FLOAT32_BLOB_PREFIX) and len(data) == float32_blob_bytes:
|
||||
payload = data[len(_FLOAT32_BLOB_PREFIX):]
|
||||
return numpy.frombuffer(payload, dtype=numpy.float32).astype(numpy.float64)
|
||||
if len(data) == float64_bytes:
|
||||
return numpy.frombuffer(data, dtype=numpy.float64).copy()
|
||||
if data.startswith(_FLOAT32_BLOB_PREFIX):
|
||||
payload_len = len(data) - len(_FLOAT32_BLOB_PREFIX)
|
||||
raise ValueError(
|
||||
f"HRR vector blob has {len(data)} bytes ({payload_len} payload bytes after "
|
||||
f"the float32 prefix); expected {float32_blob_bytes} (prefixed float32) "
|
||||
f"or {float64_bytes} (legacy float64) for dim={dim}"
|
||||
)
|
||||
raise ValueError(
|
||||
f"HRR legacy vector blob has {len(data)} bytes; expected "
|
||||
f"{float64_bytes} (float64) for dim={dim}"
|
||||
)
|
||||
|
||||
if data.startswith(_FLOAT32_BLOB_PREFIX):
|
||||
payload = data[len(_FLOAT32_BLOB_PREFIX):]
|
||||
if len(payload) % numpy.dtype(numpy.float32).itemsize != 0:
|
||||
raise ValueError(
|
||||
f"HRR float32 vector blob has invalid payload byte length: {len(payload)}"
|
||||
)
|
||||
return numpy.frombuffer(payload, dtype=numpy.float32).astype(numpy.float64)
|
||||
|
||||
if len(data) % numpy.dtype(numpy.float64).itemsize != 0:
|
||||
raise ValueError(f"HRR legacy vector blob has invalid byte length: {len(data)}")
|
||||
return numpy.frombuffer(data, dtype=numpy.float64).copy()
|
||||
|
||||
|
||||
def snr_estimate(dim: int, n_items: int) -> float:
|
||||
"""Signal-to-noise ratio estimate for holographic storage.
|
||||
|
||||
SNR = sqrt(dim / n_items) when n_items > 0, else inf.
|
||||
|
||||
The SNR falls below 2.0 when n_items > dim / 4, meaning retrieval
|
||||
errors become likely. Logs a warning when this threshold is crossed.
|
||||
"""
|
||||
_require_numpy()
|
||||
|
||||
if n_items <= 0:
|
||||
return float("inf")
|
||||
|
||||
snr = math.sqrt(dim / n_items)
|
||||
|
||||
if snr < 2.0:
|
||||
logger.warning(
|
||||
"HRR storage near capacity: SNR=%.2f (dim=%d, n_items=%d). "
|
||||
"Retrieval accuracy may degrade. Consider increasing dim or reducing stored items.",
|
||||
snr,
|
||||
dim,
|
||||
n_items,
|
||||
)
|
||||
|
||||
return snr
|
||||
@@ -0,0 +1,5 @@
|
||||
name: holographic
|
||||
version: 0.1.0
|
||||
description: "Holographic memory — local SQLite fact store with FTS5 search, trust scoring, and HRR-based compositional retrieval."
|
||||
hooks:
|
||||
- on_session_end
|
||||
@@ -0,0 +1,668 @@
|
||||
"""Hybrid keyword/BM25 retrieval for the memory store.
|
||||
|
||||
Ported from KIK memory_agent.py — combines FTS5 full-text search with
|
||||
Jaccard similarity reranking and trust-weighted scoring.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from datetime import datetime, timezone
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .store import MemoryStore
|
||||
|
||||
try:
|
||||
from . import holographic as hrr
|
||||
except ImportError:
|
||||
import holographic as hrr # type: ignore[no-redef]
|
||||
|
||||
|
||||
class FactRetriever:
|
||||
"""Multi-strategy fact retrieval with trust-weighted scoring."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
store: MemoryStore,
|
||||
temporal_decay_half_life: int = 0, # days, 0 = disabled
|
||||
fts_weight: float = 0.4,
|
||||
jaccard_weight: float = 0.3,
|
||||
hrr_weight: float = 0.3,
|
||||
hrr_dim: int = 1024,
|
||||
):
|
||||
self.store = store
|
||||
self.half_life = temporal_decay_half_life
|
||||
self.hrr_dim = hrr_dim
|
||||
|
||||
# Auto-redistribute weights if numpy unavailable
|
||||
if hrr_weight > 0 and not hrr._HAS_NUMPY:
|
||||
fts_weight = 0.6
|
||||
jaccard_weight = 0.4
|
||||
hrr_weight = 0.0
|
||||
|
||||
self.fts_weight = fts_weight
|
||||
self.jaccard_weight = jaccard_weight
|
||||
self.hrr_weight = hrr_weight
|
||||
|
||||
def search(
|
||||
self,
|
||||
query: str,
|
||||
category: str | None = None,
|
||||
min_trust: float = 0.3,
|
||||
limit: int = 10,
|
||||
) -> list[dict]:
|
||||
"""Hybrid search: FTS5 candidates → Jaccard rerank → trust weighting.
|
||||
|
||||
Pipeline:
|
||||
1. FTS5 search: Get limit*3 candidates from SQLite full-text search
|
||||
2. Jaccard boost: Token overlap between query and fact content
|
||||
3. Trust weighting: final_score = relevance * trust_score
|
||||
4. Temporal decay (optional): decay = 0.5^(age_days / half_life)
|
||||
|
||||
Returns list of dicts with fact data + 'score' field, sorted by score desc.
|
||||
"""
|
||||
# Stage 1: Get FTS5 candidates (more than limit for reranking headroom)
|
||||
candidates = self._fts_candidates(query, category, min_trust, limit * 3)
|
||||
|
||||
if not candidates:
|
||||
return []
|
||||
|
||||
# Stage 2: Rerank with Jaccard + trust + optional decay
|
||||
query_tokens = self._tokenize(query)
|
||||
# The query vector is loop-invariant — encode it at most once, on
|
||||
# the first candidate that actually carries an HRR vector. Lazy on
|
||||
# purpose: migrated stores can have FTS candidates whose hrr_vector
|
||||
# was never backfilled (MemoryStore._init_db adds the column
|
||||
# without backfilling), and those must not pay for an encode
|
||||
# nothing will use. encode_text is deterministic (SHA-256 counter
|
||||
# blocks), so the hoisted vector is bit-identical to what the
|
||||
# per-candidate calls produced.
|
||||
query_vec = None
|
||||
scored = []
|
||||
|
||||
for fact in candidates:
|
||||
content_tokens = self._tokenize(fact["content"])
|
||||
tag_tokens = self._tokenize(fact.get("tags", ""))
|
||||
all_tokens = content_tokens | tag_tokens
|
||||
|
||||
jaccard = self._jaccard_similarity(query_tokens, all_tokens)
|
||||
fts_score = fact.get("fts_rank", 0.0)
|
||||
|
||||
# HRR similarity
|
||||
if self.hrr_weight > 0 and fact.get("hrr_vector"):
|
||||
fact_vec = hrr.bytes_to_phases(fact["hrr_vector"], dim=self.hrr_dim)
|
||||
if query_vec is None:
|
||||
query_vec = hrr.encode_text(query, self.hrr_dim)
|
||||
hrr_sim = (hrr.similarity(query_vec, fact_vec) + 1.0) / 2.0 # shift to [0,1]
|
||||
else:
|
||||
hrr_sim = 0.5 # neutral
|
||||
|
||||
# Combine FTS5 + Jaccard + HRR
|
||||
relevance = (self.fts_weight * fts_score
|
||||
+ self.jaccard_weight * jaccard
|
||||
+ self.hrr_weight * hrr_sim)
|
||||
|
||||
# Trust weighting
|
||||
score = relevance * fact["trust_score"]
|
||||
|
||||
# Optional temporal decay
|
||||
if self.half_life > 0:
|
||||
score *= self._temporal_decay(fact.get("updated_at") or fact.get("created_at"))
|
||||
|
||||
fact["score"] = score
|
||||
scored.append(fact)
|
||||
|
||||
# Sort by score descending, return top limit
|
||||
scored.sort(key=lambda x: x["score"], reverse=True)
|
||||
results = scored[:limit]
|
||||
# Strip raw HRR bytes — callers expect JSON-serializable dicts
|
||||
for fact in results:
|
||||
fact.pop("hrr_vector", None)
|
||||
return results
|
||||
|
||||
def probe(
|
||||
self,
|
||||
entity: str,
|
||||
category: str | None = None,
|
||||
limit: int = 10,
|
||||
) -> list[dict]:
|
||||
"""Compositional entity query using HRR algebra.
|
||||
|
||||
Unbinds entity from memory bank to extract associated content.
|
||||
This is NOT keyword search — it uses algebraic structure to find facts
|
||||
where the entity plays a structural role.
|
||||
|
||||
Falls back to FTS5 search if numpy unavailable.
|
||||
"""
|
||||
if not hrr._HAS_NUMPY:
|
||||
# Fallback to keyword search on entity name
|
||||
return self.search(entity, category=category, limit=limit)
|
||||
|
||||
conn = self.store._conn
|
||||
|
||||
# Encode entity as role-bound vector
|
||||
role_entity = hrr.encode_atom("__hrr_role_entity__", self.hrr_dim)
|
||||
entity_vec = hrr.encode_atom(entity.lower(), self.hrr_dim)
|
||||
probe_key = hrr.bind(entity_vec, role_entity)
|
||||
|
||||
# Try category-specific bank first, then all facts
|
||||
if category:
|
||||
bank_name = f"cat:{category}"
|
||||
bank_row = conn.execute(
|
||||
"SELECT vector FROM memory_banks WHERE bank_name = ?",
|
||||
(bank_name,),
|
||||
).fetchone()
|
||||
if bank_row:
|
||||
bank_vec = hrr.bytes_to_phases(bank_row["vector"], dim=self.hrr_dim)
|
||||
extracted = hrr.unbind(bank_vec, probe_key)
|
||||
# Use extracted signal to score individual facts
|
||||
return self._score_facts_by_vector(
|
||||
extracted, category=category, limit=limit
|
||||
)
|
||||
|
||||
# Score against individual fact vectors directly
|
||||
where = "WHERE hrr_vector IS NOT NULL"
|
||||
params: list = []
|
||||
if category:
|
||||
where += " AND category = ?"
|
||||
params.append(category)
|
||||
|
||||
rows = conn.execute(
|
||||
f"""
|
||||
SELECT fact_id, content, category, tags, trust_score,
|
||||
retrieval_count, helpful_count, created_at, updated_at,
|
||||
hrr_vector
|
||||
FROM facts
|
||||
{where}
|
||||
""",
|
||||
params,
|
||||
).fetchall()
|
||||
|
||||
if not rows:
|
||||
# Final fallback: keyword search
|
||||
return self.search(entity, category=category, limit=limit)
|
||||
|
||||
# role_content is loop-invariant — encode it once (deterministic
|
||||
# SHA-256-based atom) instead of once per fact row.
|
||||
role_content = hrr.encode_atom("__hrr_role_content__", self.hrr_dim)
|
||||
scored = []
|
||||
for row in rows:
|
||||
fact = dict(row)
|
||||
fact_vec = hrr.bytes_to_phases(fact.pop("hrr_vector"), dim=self.hrr_dim)
|
||||
# Unbind probe key from fact to see if entity is structurally present
|
||||
residual = hrr.unbind(fact_vec, probe_key)
|
||||
# Compare residual against content signal
|
||||
content_vec = hrr.bind(hrr.encode_text(fact["content"], self.hrr_dim), role_content)
|
||||
sim = hrr.similarity(residual, content_vec)
|
||||
fact["score"] = (sim + 1.0) / 2.0 * fact["trust_score"]
|
||||
scored.append(fact)
|
||||
|
||||
scored.sort(key=lambda x: x["score"], reverse=True)
|
||||
return scored[:limit]
|
||||
|
||||
def related(
|
||||
self,
|
||||
entity: str,
|
||||
category: str | None = None,
|
||||
limit: int = 10,
|
||||
) -> list[dict]:
|
||||
"""Discover facts that share structural connections with an entity.
|
||||
|
||||
Unlike probe (which finds facts *about* an entity), related finds
|
||||
facts that are connected through shared context — e.g., other entities
|
||||
mentioned alongside this one, or content that overlaps structurally.
|
||||
|
||||
Falls back to FTS5 search if numpy unavailable.
|
||||
"""
|
||||
if not hrr._HAS_NUMPY:
|
||||
return self.search(entity, category=category, limit=limit)
|
||||
|
||||
conn = self.store._conn
|
||||
|
||||
# Encode entity as a bare atom (not role-bound — we want ANY structural match)
|
||||
entity_vec = hrr.encode_atom(entity.lower(), self.hrr_dim)
|
||||
|
||||
# Get all facts with vectors
|
||||
where = "WHERE hrr_vector IS NOT NULL"
|
||||
params: list = []
|
||||
if category:
|
||||
where += " AND category = ?"
|
||||
params.append(category)
|
||||
|
||||
rows = conn.execute(
|
||||
f"""
|
||||
SELECT fact_id, content, category, tags, trust_score,
|
||||
retrieval_count, helpful_count, created_at, updated_at,
|
||||
hrr_vector
|
||||
FROM facts
|
||||
{where}
|
||||
""",
|
||||
params,
|
||||
).fetchall()
|
||||
|
||||
if not rows:
|
||||
return self.search(entity, category=category, limit=limit)
|
||||
|
||||
# Score each fact by how much the entity's atom appears in its vector
|
||||
# This catches both role-bound entity matches AND content word matches
|
||||
# Both role atoms are loop-invariant — encode them once here
|
||||
# (deterministic SHA-256-based atoms) instead of twice per fact row.
|
||||
role_entity = hrr.encode_atom("__hrr_role_entity__", self.hrr_dim)
|
||||
role_content = hrr.encode_atom("__hrr_role_content__", self.hrr_dim)
|
||||
scored = []
|
||||
for row in rows:
|
||||
fact = dict(row)
|
||||
fact_vec = hrr.bytes_to_phases(fact.pop("hrr_vector"), dim=self.hrr_dim)
|
||||
|
||||
# Check structural similarity: unbind entity from fact
|
||||
residual = hrr.unbind(fact_vec, entity_vec)
|
||||
# A high-similarity residual to ANY known role vector means this entity
|
||||
# plays a structural role in the fact
|
||||
|
||||
entity_role_sim = hrr.similarity(residual, role_entity)
|
||||
content_role_sim = hrr.similarity(residual, role_content)
|
||||
# Take the max — entity could appear in either role
|
||||
best_sim = max(entity_role_sim, content_role_sim)
|
||||
|
||||
fact["score"] = (best_sim + 1.0) / 2.0 * fact["trust_score"]
|
||||
scored.append(fact)
|
||||
|
||||
scored.sort(key=lambda x: x["score"], reverse=True)
|
||||
return scored[:limit]
|
||||
|
||||
def reason(
|
||||
self,
|
||||
entities: list[str],
|
||||
category: str | None = None,
|
||||
limit: int = 10,
|
||||
) -> list[dict]:
|
||||
"""Multi-entity compositional query — vector-space JOIN.
|
||||
|
||||
Given multiple entities, algebraically intersects their structural
|
||||
connections to find facts related to ALL of them simultaneously.
|
||||
This is compositional reasoning that no embedding DB can do.
|
||||
|
||||
Example: reason(["peppi", "backend"]) finds facts where peppi AND
|
||||
backend both play structural roles — without keyword matching.
|
||||
|
||||
Falls back to FTS5 search if numpy unavailable.
|
||||
"""
|
||||
if not hrr._HAS_NUMPY or not entities:
|
||||
# Fallback: search with all entities as keywords
|
||||
query = " ".join(entities)
|
||||
return self.search(query, category=category, limit=limit)
|
||||
|
||||
conn = self.store._conn
|
||||
role_entity = hrr.encode_atom("__hrr_role_entity__", self.hrr_dim)
|
||||
|
||||
# For each entity, compute what the bank "remembers" about it
|
||||
# by unbinding entity+role from each fact vector
|
||||
entity_residuals = []
|
||||
for entity in entities:
|
||||
entity_vec = hrr.encode_atom(entity.lower(), self.hrr_dim)
|
||||
probe_key = hrr.bind(entity_vec, role_entity)
|
||||
entity_residuals.append(probe_key)
|
||||
|
||||
# Get all facts with vectors
|
||||
where = "WHERE hrr_vector IS NOT NULL"
|
||||
params: list = []
|
||||
if category:
|
||||
where += " AND category = ?"
|
||||
params.append(category)
|
||||
|
||||
rows = conn.execute(
|
||||
f"""
|
||||
SELECT fact_id, content, category, tags, trust_score,
|
||||
retrieval_count, helpful_count, created_at, updated_at,
|
||||
hrr_vector
|
||||
FROM facts
|
||||
{where}
|
||||
""",
|
||||
params,
|
||||
).fetchall()
|
||||
|
||||
if not rows:
|
||||
query = " ".join(entities)
|
||||
return self.search(query, category=category, limit=limit)
|
||||
|
||||
# Score each fact by how much EACH entity is structurally present.
|
||||
# A fact scores high only if ALL entities have structural presence
|
||||
# (AND semantics via min, vs OR which would use mean/max).
|
||||
role_content = hrr.encode_atom("__hrr_role_content__", self.hrr_dim)
|
||||
|
||||
scored = []
|
||||
for row in rows:
|
||||
fact = dict(row)
|
||||
fact_vec = hrr.bytes_to_phases(fact.pop("hrr_vector"), dim=self.hrr_dim)
|
||||
|
||||
entity_scores = []
|
||||
for probe_key in entity_residuals:
|
||||
residual = hrr.unbind(fact_vec, probe_key)
|
||||
sim = hrr.similarity(residual, role_content)
|
||||
entity_scores.append(sim)
|
||||
|
||||
min_sim = min(entity_scores)
|
||||
fact["score"] = (min_sim + 1.0) / 2.0 * fact["trust_score"]
|
||||
scored.append(fact)
|
||||
|
||||
scored.sort(key=lambda x: x["score"], reverse=True)
|
||||
return scored[:limit]
|
||||
|
||||
def contradict(
|
||||
self,
|
||||
category: str | None = None,
|
||||
threshold: float = 0.3,
|
||||
limit: int = 10,
|
||||
) -> list[dict]:
|
||||
"""Find potentially contradictory facts via entity overlap + content divergence.
|
||||
|
||||
Two facts contradict when they share entities (same subject) but have
|
||||
low content-vector similarity (different claims). This is automated
|
||||
memory hygiene — no other memory system does this.
|
||||
|
||||
Returns pairs of facts with a contradiction score.
|
||||
Falls back to empty list if numpy unavailable.
|
||||
"""
|
||||
if not hrr._HAS_NUMPY:
|
||||
return []
|
||||
|
||||
conn = self.store._conn
|
||||
|
||||
# Get all facts with vectors and their linked entities
|
||||
where = "WHERE f.hrr_vector IS NOT NULL"
|
||||
params: list = []
|
||||
if category:
|
||||
where += " AND f.category = ?"
|
||||
params.append(category)
|
||||
|
||||
rows = conn.execute(
|
||||
f"""
|
||||
SELECT f.fact_id, f.content, f.category, f.tags, f.trust_score,
|
||||
f.created_at, f.updated_at, f.hrr_vector
|
||||
FROM facts f
|
||||
{where}
|
||||
""",
|
||||
params,
|
||||
).fetchall()
|
||||
|
||||
if len(rows) < 2:
|
||||
return []
|
||||
|
||||
# Guard against O(n²) explosion on large fact stores.
|
||||
# At 500 facts, that's ~125K comparisons — acceptable.
|
||||
# Above that, only check the most recently updated facts.
|
||||
_MAX_CONTRADICT_FACTS = 500
|
||||
if len(rows) > _MAX_CONTRADICT_FACTS:
|
||||
rows = sorted(rows, key=lambda r: r["updated_at"] or r["created_at"], reverse=True)
|
||||
rows = rows[:_MAX_CONTRADICT_FACTS]
|
||||
|
||||
# Build entity sets per fact
|
||||
fact_entities: dict[int, set[str]] = {}
|
||||
for row in rows:
|
||||
fid = row["fact_id"]
|
||||
entity_rows = conn.execute(
|
||||
"""
|
||||
SELECT e.name FROM entities e
|
||||
JOIN fact_entities fe ON fe.entity_id = e.entity_id
|
||||
WHERE fe.fact_id = ?
|
||||
""",
|
||||
(fid,),
|
||||
).fetchall()
|
||||
fact_entities[fid] = {r["name"].lower() for r in entity_rows}
|
||||
|
||||
# Compare all pairs: high entity overlap + low content similarity = contradiction
|
||||
facts = [dict(r) for r in rows]
|
||||
contradictions = []
|
||||
|
||||
for i in range(len(facts)):
|
||||
for j in range(i + 1, len(facts)):
|
||||
f1, f2 = facts[i], facts[j]
|
||||
ents1 = fact_entities.get(f1["fact_id"], set())
|
||||
ents2 = fact_entities.get(f2["fact_id"], set())
|
||||
|
||||
if not ents1 or not ents2:
|
||||
continue
|
||||
|
||||
# Entity overlap (Jaccard)
|
||||
entity_overlap = len(ents1 & ents2) / len(ents1 | ents2) if (ents1 | ents2) else 0.0
|
||||
|
||||
if entity_overlap < 0.3:
|
||||
continue # Not enough entity overlap to be contradictory
|
||||
|
||||
# Content similarity via HRR vectors
|
||||
v1 = hrr.bytes_to_phases(f1["hrr_vector"], dim=self.hrr_dim)
|
||||
v2 = hrr.bytes_to_phases(f2["hrr_vector"], dim=self.hrr_dim)
|
||||
content_sim = hrr.similarity(v1, v2)
|
||||
|
||||
# High entity overlap + low content similarity = potential contradiction
|
||||
# contradiction_score: higher = more contradictory
|
||||
contradiction_score = entity_overlap * (1.0 - (content_sim + 1.0) / 2.0)
|
||||
|
||||
if contradiction_score >= threshold:
|
||||
# Strip hrr_vector from output (not JSON serializable)
|
||||
f1_clean = {k: v for k, v in f1.items() if k != "hrr_vector"}
|
||||
f2_clean = {k: v for k, v in f2.items() if k != "hrr_vector"}
|
||||
contradictions.append({
|
||||
"fact_a": f1_clean,
|
||||
"fact_b": f2_clean,
|
||||
"entity_overlap": round(entity_overlap, 3),
|
||||
"content_similarity": round(content_sim, 3),
|
||||
"contradiction_score": round(contradiction_score, 3),
|
||||
"shared_entities": sorted(ents1 & ents2),
|
||||
})
|
||||
|
||||
contradictions.sort(key=lambda x: x["contradiction_score"], reverse=True)
|
||||
return contradictions[:limit]
|
||||
|
||||
def _score_facts_by_vector(
|
||||
self,
|
||||
target_vec: "np.ndarray",
|
||||
category: str | None = None,
|
||||
limit: int = 10,
|
||||
) -> list[dict]:
|
||||
"""Score facts by similarity to a target vector."""
|
||||
conn = self.store._conn
|
||||
|
||||
where = "WHERE hrr_vector IS NOT NULL"
|
||||
params: list = []
|
||||
if category:
|
||||
where += " AND category = ?"
|
||||
params.append(category)
|
||||
|
||||
rows = conn.execute(
|
||||
f"""
|
||||
SELECT fact_id, content, category, tags, trust_score,
|
||||
retrieval_count, helpful_count, created_at, updated_at,
|
||||
hrr_vector
|
||||
FROM facts
|
||||
{where}
|
||||
""",
|
||||
params,
|
||||
).fetchall()
|
||||
|
||||
scored = []
|
||||
for row in rows:
|
||||
fact = dict(row)
|
||||
fact_vec = hrr.bytes_to_phases(fact.pop("hrr_vector"), dim=self.hrr_dim)
|
||||
sim = hrr.similarity(target_vec, fact_vec)
|
||||
fact["score"] = (sim + 1.0) / 2.0 * fact["trust_score"]
|
||||
scored.append(fact)
|
||||
|
||||
scored.sort(key=lambda x: x["score"], reverse=True)
|
||||
return scored[:limit]
|
||||
|
||||
def _fts_candidates(
|
||||
self,
|
||||
query: str,
|
||||
category: str | None,
|
||||
min_trust: float,
|
||||
limit: int,
|
||||
) -> list[dict]:
|
||||
"""Get raw FTS5 candidates from the store.
|
||||
|
||||
Uses the store's database connection directly for FTS5 MATCH
|
||||
with rank scoring. Normalizes FTS5 rank to [0, 1] range.
|
||||
"""
|
||||
conn = self.store._conn
|
||||
|
||||
# Build query - FTS5 rank is negative (lower = better match)
|
||||
# We need to join facts_fts with facts to get all columns
|
||||
params: list = []
|
||||
where_clauses = ["facts_fts MATCH ?"]
|
||||
# FTS5 defaults to AND-between-tokens, which kills recall on
|
||||
# natural-language queries ("what happened with the deployment
|
||||
# rollback"). Sanitize: drop stopwords, OR-join content tokens, so
|
||||
# any significant term can match.
|
||||
params.append(self._sanitize_fts_query(query))
|
||||
|
||||
if category:
|
||||
where_clauses.append("f.category = ?")
|
||||
params.append(category)
|
||||
|
||||
where_clauses.append("f.trust_score >= ?")
|
||||
params.append(min_trust)
|
||||
|
||||
where_sql = " AND ".join(where_clauses)
|
||||
|
||||
sql = f"""
|
||||
SELECT f.*, facts_fts.rank as fts_rank_raw
|
||||
FROM facts_fts
|
||||
JOIN facts f ON f.fact_id = facts_fts.rowid
|
||||
WHERE {where_sql}
|
||||
ORDER BY facts_fts.rank
|
||||
LIMIT ?
|
||||
"""
|
||||
params.append(limit)
|
||||
|
||||
try:
|
||||
rows = conn.execute(sql, params).fetchall()
|
||||
except Exception:
|
||||
# FTS5 MATCH can fail on malformed queries — fall back to empty
|
||||
return []
|
||||
|
||||
if not rows:
|
||||
return []
|
||||
|
||||
# Normalize FTS5 rank: rank is negative, lower = better
|
||||
# Convert to positive score in [0, 1] range
|
||||
raw_ranks = [abs(row["fts_rank_raw"]) for row in rows]
|
||||
max_rank = max(raw_ranks) if raw_ranks else 1.0
|
||||
max_rank = max(max_rank, 1e-6) # avoid div by zero
|
||||
|
||||
results = []
|
||||
for row, raw_rank in zip(rows, raw_ranks):
|
||||
fact = dict(row)
|
||||
fact.pop("fts_rank_raw", None)
|
||||
fact["fts_rank"] = raw_rank / max_rank # normalize to [0, 1]
|
||||
results.append(fact)
|
||||
|
||||
return results
|
||||
|
||||
@staticmethod
|
||||
def _tokenize(text: str) -> set[str]:
|
||||
"""Simple whitespace tokenization with lowercasing.
|
||||
|
||||
Strips common punctuation. No stemming/lemmatization (Phase 1).
|
||||
"""
|
||||
if not text:
|
||||
return set()
|
||||
# Split on whitespace, lowercase, strip punctuation
|
||||
tokens = set()
|
||||
for word in text.lower().split():
|
||||
cleaned = word.strip(".,;:!?\"'()[]{}#@<>")
|
||||
if cleaned:
|
||||
tokens.add(cleaned)
|
||||
return tokens
|
||||
|
||||
# Stopwords dropped before FTS5 OR-expansion. Short English function
|
||||
# words that carry no retrieval signal and force false-negative AND
|
||||
# matches when left in the query.
|
||||
_FTS_STOPWORDS = frozenset({
|
||||
"a", "about", "above", "after", "again", "all", "am", "an", "and",
|
||||
"any", "are", "as", "at", "be", "because", "been", "before", "being",
|
||||
"between", "both", "but", "by", "can", "could", "did", "do", "does",
|
||||
"doing", "don", "down", "during", "each", "few", "for", "from",
|
||||
"further", "had", "has", "have", "having", "he", "her", "here",
|
||||
"hers", "herself", "him", "himself", "his", "how", "i", "if", "in",
|
||||
"into", "is", "it", "its", "itself", "just", "me", "more", "most",
|
||||
"my", "myself", "no", "nor", "not", "now", "of", "off", "on", "once",
|
||||
"only", "or", "other", "our", "ours", "ourselves", "out", "over",
|
||||
"own", "same", "she", "should", "so", "some", "such", "than", "that",
|
||||
"the", "their", "theirs", "them", "themselves", "then", "there",
|
||||
"these", "they", "this", "those", "through", "to", "too", "under",
|
||||
"until", "up", "very", "was", "we", "were", "what", "when", "where",
|
||||
"which", "while", "who", "whom", "why", "will", "with", "would",
|
||||
"you", "your", "yours", "yourself", "yourselves",
|
||||
})
|
||||
|
||||
@classmethod
|
||||
def _sanitize_fts_query(cls, query: str) -> str:
|
||||
"""Convert a natural-language query to an FTS5-safe OR expression.
|
||||
|
||||
FTS5 treats a multi-word MATCH argument as AND-joined by default,
|
||||
which tanks recall on prose queries. This helper:
|
||||
- tokenizes the query
|
||||
- drops stopwords and short (<2 char) tokens
|
||||
- strips FTS5 special characters from each token
|
||||
- OR-joins the survivors
|
||||
|
||||
If nothing remains (pathological query), falls back to the raw
|
||||
query so the caller sees zero results instead of a SQL error.
|
||||
"""
|
||||
if not query:
|
||||
return ""
|
||||
# Strip FTS5 operator characters from EACH token to avoid
|
||||
# accidentally creating a malformed query.
|
||||
_FTS_SPECIAL = '"()*^:-+'
|
||||
tokens: list[str] = []
|
||||
for raw in query.lower().split():
|
||||
cleaned = raw.strip(".,;:!?\"'()[]{}#@<>") .translate(
|
||||
str.maketrans("", "", _FTS_SPECIAL)
|
||||
)
|
||||
if len(cleaned) < 2:
|
||||
continue
|
||||
if cleaned in cls._FTS_STOPWORDS:
|
||||
continue
|
||||
# FTS5 phrase-literal each token to ensure no special chars
|
||||
# sneak through as operators.
|
||||
tokens.append(f'"{cleaned}"')
|
||||
if not tokens:
|
||||
# Fallback: raw query (likely returns 0, but never crashes)
|
||||
return query
|
||||
return " OR ".join(tokens)
|
||||
|
||||
@staticmethod
|
||||
def _jaccard_similarity(set_a: set, set_b: set) -> float:
|
||||
"""Jaccard similarity coefficient: |A ∩ B| / |A ∪ B|."""
|
||||
if not set_a or not set_b:
|
||||
return 0.0
|
||||
intersection = len(set_a & set_b)
|
||||
union = len(set_a | set_b)
|
||||
return intersection / union if union > 0 else 0.0
|
||||
|
||||
def _temporal_decay(self, timestamp_str: str | None) -> float:
|
||||
"""Exponential decay: 0.5^(age_days / half_life_days).
|
||||
|
||||
Returns 1.0 if decay is disabled or timestamp is missing.
|
||||
"""
|
||||
if not self.half_life or not timestamp_str:
|
||||
return 1.0
|
||||
|
||||
try:
|
||||
if isinstance(timestamp_str, str):
|
||||
# Parse ISO format timestamp from SQLite
|
||||
ts = datetime.fromisoformat(timestamp_str.replace("Z", "+00:00"))
|
||||
else:
|
||||
ts = timestamp_str
|
||||
|
||||
if ts.tzinfo is None:
|
||||
ts = ts.replace(tzinfo=timezone.utc)
|
||||
|
||||
age_days = (datetime.now(timezone.utc) - ts).total_seconds() / 86400
|
||||
if age_days < 0:
|
||||
return 1.0
|
||||
|
||||
return math.pow(0.5, age_days / self.half_life)
|
||||
except (ValueError, TypeError):
|
||||
return 1.0
|
||||
@@ -0,0 +1,688 @@
|
||||
"""
|
||||
SQLite-backed fact store with entity resolution and trust scoring.
|
||||
Single-user Hermes memory store plugin.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import sqlite3
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
from . import holographic as hrr
|
||||
except ImportError:
|
||||
import holographic as hrr # type: ignore[no-redef]
|
||||
|
||||
_SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS facts (
|
||||
fact_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
content TEXT NOT NULL UNIQUE,
|
||||
category TEXT DEFAULT 'general',
|
||||
tags TEXT DEFAULT '',
|
||||
trust_score REAL DEFAULT 0.5,
|
||||
retrieval_count INTEGER DEFAULT 0,
|
||||
helpful_count INTEGER DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
hrr_vector BLOB
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS entities (
|
||||
entity_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
entity_type TEXT DEFAULT 'unknown',
|
||||
aliases TEXT DEFAULT '',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS fact_entities (
|
||||
fact_id INTEGER REFERENCES facts(fact_id),
|
||||
entity_id INTEGER REFERENCES entities(entity_id),
|
||||
PRIMARY KEY (fact_id, entity_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_facts_trust ON facts(trust_score DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_facts_category ON facts(category);
|
||||
CREATE INDEX IF NOT EXISTS idx_entities_name ON entities(name);
|
||||
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS facts_fts
|
||||
USING fts5(content, tags, content=facts, content_rowid=fact_id);
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS facts_ai AFTER INSERT ON facts BEGIN
|
||||
INSERT INTO facts_fts(rowid, content, tags)
|
||||
VALUES (new.fact_id, new.content, new.tags);
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS facts_ad AFTER DELETE ON facts BEGIN
|
||||
INSERT INTO facts_fts(facts_fts, rowid, content, tags)
|
||||
VALUES ('delete', old.fact_id, old.content, old.tags);
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS facts_au AFTER UPDATE ON facts BEGIN
|
||||
INSERT INTO facts_fts(facts_fts, rowid, content, tags)
|
||||
VALUES ('delete', old.fact_id, old.content, old.tags);
|
||||
INSERT INTO facts_fts(rowid, content, tags)
|
||||
VALUES (new.fact_id, new.content, new.tags);
|
||||
END;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS memory_banks (
|
||||
bank_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
bank_name TEXT NOT NULL UNIQUE,
|
||||
vector BLOB NOT NULL,
|
||||
dim INTEGER NOT NULL,
|
||||
fact_count INTEGER DEFAULT 0,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
"""
|
||||
|
||||
# Trust adjustment constants
|
||||
_HELPFUL_DELTA = 0.05
|
||||
_UNHELPFUL_DELTA = -0.10
|
||||
_TRUST_MIN = 0.0
|
||||
_TRUST_MAX = 1.0
|
||||
|
||||
# Entity extraction patterns
|
||||
_RE_CAPITALIZED = re.compile(r'\b([A-Z][a-z]+(?:\s+[A-Z][a-z]+)+)\b')
|
||||
_RE_DOUBLE_QUOTE = re.compile(r'"([^"]+)"')
|
||||
_RE_SINGLE_QUOTE = re.compile(r"'([^']+)'")
|
||||
_RE_AKA = re.compile(
|
||||
r'(\w+(?:\s+\w+)*)\s+(?:aka|also known as)\s+(\w+(?:\s+\w+)*)',
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def _clamp_trust(value: float) -> float:
|
||||
return max(_TRUST_MIN, min(_TRUST_MAX, value))
|
||||
|
||||
|
||||
class MemoryStore:
|
||||
"""SQLite-backed fact store with entity resolution and trust scoring."""
|
||||
|
||||
# --- Process-wide shared connection registry -------------------------
|
||||
# SQLite permits only one writer at a time. Each MemoryStore instance used
|
||||
# to open its own connection guarded by its own RLock, so the several
|
||||
# providers that coexist in one process (the main agent plus every
|
||||
# delegate_task subagent) raced as independent WAL writers. Combined with
|
||||
# writes that were not rolled back on error, one connection could leave an
|
||||
# open write transaction that pinned the write lock and made every other
|
||||
# connection's write fail with "database is locked" for the full busy
|
||||
# timeout. All instances for the same database now share ONE connection and
|
||||
# ONE re-entrant lock, so access is fully serialized and cross-connection
|
||||
# contention is impossible. The shared connection is refcounted, so closing
|
||||
# one instance never tears the connection out from under a live sibling.
|
||||
_shared: dict = {}
|
||||
_shared_guard = threading.Lock()
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
db_path: "str | Path | None" = None,
|
||||
default_trust: float = 0.5,
|
||||
hrr_dim: int = 1024,
|
||||
) -> None:
|
||||
if db_path is None:
|
||||
from hermes_constants import get_hermes_home
|
||||
db_path = str(get_hermes_home() / "memory_store.db")
|
||||
self.db_path = Path(db_path).expanduser()
|
||||
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self.default_trust = _clamp_trust(default_trust)
|
||||
self.hrr_dim = hrr_dim
|
||||
self._hrr_available = hrr._HAS_NUMPY
|
||||
|
||||
# Acquire (or open) the process-wide shared connection for this DB.
|
||||
# resolve() (not just expanduser) so symlinked/relative paths to the
|
||||
# same file share ONE connection instead of silently reintroducing
|
||||
# the multi-writer contention this registry exists to prevent.
|
||||
try:
|
||||
self._key = str(self.db_path.resolve())
|
||||
except OSError:
|
||||
self._key = str(self.db_path)
|
||||
with MemoryStore._shared_guard:
|
||||
entry = MemoryStore._shared.get(self._key)
|
||||
if entry is None:
|
||||
conn = sqlite3.connect(
|
||||
self._key,
|
||||
check_same_thread=False,
|
||||
timeout=10.0,
|
||||
# Autocommit: every statement is its own transaction, so a
|
||||
# write that raises mid-method can never leave a dangling
|
||||
# transaction (and its write lock) open. The explicit
|
||||
# commit() calls below become harmless no-ops.
|
||||
isolation_level=None,
|
||||
)
|
||||
conn.row_factory = sqlite3.Row
|
||||
entry = {"conn": conn, "lock": threading.RLock(), "refs": 0, "ready": False}
|
||||
MemoryStore._shared[self._key] = entry
|
||||
entry["refs"] += 1
|
||||
self._entry = entry
|
||||
self._conn = entry["conn"]
|
||||
self._lock = entry["lock"]
|
||||
|
||||
# Initialise the schema once per shared connection.
|
||||
with self._lock:
|
||||
if not self._entry["ready"]:
|
||||
self._init_db()
|
||||
self._entry["ready"] = True
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Initialisation
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _init_db(self) -> None:
|
||||
"""Create tables, indexes, and triggers if they do not exist. Enable WAL mode."""
|
||||
# Use the shared WAL-fallback helper so memory_store.db degrades
|
||||
# gracefully on NFS/SMB/FUSE-mounted HERMES_HOME (same issue as
|
||||
# state.db / kanban.db — see hermes_state._WAL_INCOMPAT_MARKERS).
|
||||
from hermes_state import apply_wal_with_fallback
|
||||
apply_wal_with_fallback(self._conn, db_label="memory_store.db (holographic)")
|
||||
self._conn.executescript(_SCHEMA)
|
||||
# Migrate: add hrr_vector column if missing (safe for existing databases)
|
||||
columns = {row[1] for row in self._conn.execute("PRAGMA table_info(facts)").fetchall()}
|
||||
if "hrr_vector" not in columns:
|
||||
self._conn.execute("ALTER TABLE facts ADD COLUMN hrr_vector BLOB")
|
||||
self._conn.commit()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Public API
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def add_fact(
|
||||
self,
|
||||
content: str,
|
||||
category: str = "general",
|
||||
tags: str = "",
|
||||
) -> int:
|
||||
"""Insert a fact and return its fact_id.
|
||||
|
||||
Deduplicates by content (UNIQUE constraint). On duplicate, returns
|
||||
the existing fact_id without modifying the row. Extracts entities from
|
||||
the content and links them to the fact.
|
||||
"""
|
||||
with self._lock:
|
||||
content = content.strip()
|
||||
if not content:
|
||||
raise ValueError("content must not be empty")
|
||||
|
||||
try:
|
||||
cur = self._conn.execute(
|
||||
"""
|
||||
INSERT INTO facts (content, category, tags, trust_score)
|
||||
VALUES (?, ?, ?, ?)
|
||||
""",
|
||||
(content, category, tags, self.default_trust),
|
||||
)
|
||||
self._conn.commit()
|
||||
fact_id: int = cur.lastrowid # type: ignore[assignment]
|
||||
except sqlite3.IntegrityError:
|
||||
# Duplicate content — return existing id
|
||||
row = self._conn.execute(
|
||||
"SELECT fact_id FROM facts WHERE content = ?", (content,)
|
||||
).fetchone()
|
||||
return int(row["fact_id"])
|
||||
|
||||
# Entity extraction and linking
|
||||
for name in self._extract_entities(content):
|
||||
entity_id = self._resolve_entity(name)
|
||||
self._link_fact_entity(fact_id, entity_id)
|
||||
|
||||
# Compute HRR vector after entity linking
|
||||
self._compute_hrr_vector(fact_id, content)
|
||||
self._rebuild_bank(category)
|
||||
|
||||
return fact_id
|
||||
|
||||
def search_facts(
|
||||
self,
|
||||
query: str,
|
||||
category: str | None = None,
|
||||
min_trust: float = 0.3,
|
||||
limit: int = 10,
|
||||
) -> list[dict]:
|
||||
"""Full-text search over facts using FTS5.
|
||||
|
||||
Returns a list of fact dicts ordered by FTS5 rank, then trust_score
|
||||
descending. Also increments retrieval_count for matched facts.
|
||||
"""
|
||||
with self._lock:
|
||||
query = query.strip()
|
||||
if not query:
|
||||
return []
|
||||
|
||||
# FTS5 AND-joins tokens by default, which zeroes out recall on
|
||||
# natural-language queries. Reuse the retriever's sanitizer
|
||||
# (stopword drop + OR-join content tokens). Imported lazily to
|
||||
# avoid a store->retrieval import cycle.
|
||||
from plugins.memory.holographic.retrieval import FactRetriever
|
||||
|
||||
match_query = FactRetriever._sanitize_fts_query(query)
|
||||
params: list = [match_query, min_trust]
|
||||
category_clause = ""
|
||||
if category is not None:
|
||||
category_clause = "AND f.category = ?"
|
||||
params.append(category)
|
||||
params.append(limit)
|
||||
|
||||
sql = f"""
|
||||
SELECT f.fact_id, f.content, f.category, f.tags,
|
||||
f.trust_score, f.retrieval_count, f.helpful_count,
|
||||
f.created_at, f.updated_at
|
||||
FROM facts f
|
||||
JOIN facts_fts fts ON fts.rowid = f.fact_id
|
||||
WHERE facts_fts MATCH ?
|
||||
AND f.trust_score >= ?
|
||||
{category_clause}
|
||||
ORDER BY fts.rank, f.trust_score DESC
|
||||
LIMIT ?
|
||||
"""
|
||||
|
||||
rows = self._conn.execute(sql, params).fetchall()
|
||||
results = [self._row_to_dict(r) for r in rows]
|
||||
|
||||
if results:
|
||||
ids = [r["fact_id"] for r in results]
|
||||
placeholders = ",".join("?" * len(ids))
|
||||
self._conn.execute(
|
||||
f"UPDATE facts SET retrieval_count = retrieval_count + 1 WHERE fact_id IN ({placeholders})",
|
||||
ids,
|
||||
)
|
||||
self._conn.commit()
|
||||
|
||||
return results
|
||||
|
||||
def update_fact(
|
||||
self,
|
||||
fact_id: int,
|
||||
content: str | None = None,
|
||||
trust_delta: float | None = None,
|
||||
tags: str | None = None,
|
||||
category: str | None = None,
|
||||
) -> bool:
|
||||
"""Partially update a fact. Trust is clamped to [0, 1].
|
||||
|
||||
Returns True if the row existed, False otherwise.
|
||||
"""
|
||||
with self._lock:
|
||||
row = self._conn.execute(
|
||||
"SELECT fact_id, trust_score FROM facts WHERE fact_id = ?", (fact_id,)
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return False
|
||||
|
||||
assignments: list[str] = ["updated_at = CURRENT_TIMESTAMP"]
|
||||
params: list = []
|
||||
|
||||
if content is not None:
|
||||
assignments.append("content = ?")
|
||||
params.append(content.strip())
|
||||
if tags is not None:
|
||||
assignments.append("tags = ?")
|
||||
params.append(tags)
|
||||
if category is not None:
|
||||
assignments.append("category = ?")
|
||||
params.append(category)
|
||||
if trust_delta is not None:
|
||||
new_trust = _clamp_trust(row["trust_score"] + trust_delta)
|
||||
assignments.append("trust_score = ?")
|
||||
params.append(new_trust)
|
||||
|
||||
params.append(fact_id)
|
||||
self._conn.execute(
|
||||
f"UPDATE facts SET {', '.join(assignments)} WHERE fact_id = ?",
|
||||
params,
|
||||
)
|
||||
self._conn.commit()
|
||||
|
||||
# If content changed, re-extract entities
|
||||
if content is not None:
|
||||
self._conn.execute(
|
||||
"DELETE FROM fact_entities WHERE fact_id = ?", (fact_id,)
|
||||
)
|
||||
for name in self._extract_entities(content):
|
||||
entity_id = self._resolve_entity(name)
|
||||
self._link_fact_entity(fact_id, entity_id)
|
||||
self._conn.commit()
|
||||
|
||||
# Recompute HRR vector if content changed
|
||||
if content is not None:
|
||||
self._compute_hrr_vector(fact_id, content)
|
||||
# Rebuild bank for relevant category
|
||||
cat = category or self._conn.execute(
|
||||
"SELECT category FROM facts WHERE fact_id = ?", (fact_id,)
|
||||
).fetchone()["category"]
|
||||
self._rebuild_bank(cat)
|
||||
|
||||
return True
|
||||
|
||||
def remove_fact(self, fact_id: int) -> bool:
|
||||
"""Delete a fact and its entity links. Returns True if the row existed."""
|
||||
with self._lock:
|
||||
row = self._conn.execute(
|
||||
"SELECT fact_id, category FROM facts WHERE fact_id = ?", (fact_id,)
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return False
|
||||
|
||||
self._conn.execute(
|
||||
"DELETE FROM fact_entities WHERE fact_id = ?", (fact_id,)
|
||||
)
|
||||
self._conn.execute("DELETE FROM facts WHERE fact_id = ?", (fact_id,))
|
||||
self._conn.commit()
|
||||
self._rebuild_bank(row["category"])
|
||||
return True
|
||||
|
||||
def list_facts(
|
||||
self,
|
||||
category: str | None = None,
|
||||
min_trust: float = 0.0,
|
||||
limit: int = 50,
|
||||
) -> list[dict]:
|
||||
"""Browse facts ordered by trust_score descending.
|
||||
|
||||
Optionally filter by category and minimum trust score.
|
||||
"""
|
||||
with self._lock:
|
||||
params: list = [min_trust]
|
||||
category_clause = ""
|
||||
if category is not None:
|
||||
category_clause = "AND category = ?"
|
||||
params.append(category)
|
||||
params.append(limit)
|
||||
|
||||
sql = f"""
|
||||
SELECT fact_id, content, category, tags, trust_score,
|
||||
retrieval_count, helpful_count, created_at, updated_at
|
||||
FROM facts
|
||||
WHERE trust_score >= ?
|
||||
{category_clause}
|
||||
ORDER BY trust_score DESC
|
||||
LIMIT ?
|
||||
"""
|
||||
rows = self._conn.execute(sql, params).fetchall()
|
||||
return [self._row_to_dict(r) for r in rows]
|
||||
|
||||
def record_feedback(self, fact_id: int, helpful: bool) -> dict:
|
||||
"""Record user feedback and adjust trust asymmetrically.
|
||||
|
||||
helpful=True -> trust += 0.05, helpful_count += 1
|
||||
helpful=False -> trust -= 0.10
|
||||
|
||||
Returns a dict with fact_id, old_trust, new_trust, helpful_count.
|
||||
Raises KeyError if fact_id does not exist.
|
||||
"""
|
||||
with self._lock:
|
||||
row = self._conn.execute(
|
||||
"SELECT fact_id, trust_score, helpful_count FROM facts WHERE fact_id = ?",
|
||||
(fact_id,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise KeyError(f"fact_id {fact_id} not found")
|
||||
|
||||
old_trust: float = row["trust_score"]
|
||||
delta = _HELPFUL_DELTA if helpful else _UNHELPFUL_DELTA
|
||||
new_trust = _clamp_trust(old_trust + delta)
|
||||
|
||||
helpful_increment = 1 if helpful else 0
|
||||
self._conn.execute(
|
||||
"""
|
||||
UPDATE facts
|
||||
SET trust_score = ?,
|
||||
helpful_count = helpful_count + ?,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE fact_id = ?
|
||||
""",
|
||||
(new_trust, helpful_increment, fact_id),
|
||||
)
|
||||
self._conn.commit()
|
||||
|
||||
return {
|
||||
"fact_id": fact_id,
|
||||
"old_trust": old_trust,
|
||||
"new_trust": new_trust,
|
||||
"helpful_count": row["helpful_count"] + helpful_increment,
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Entity helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _extract_entities(self, text: str) -> list[str]:
|
||||
"""Extract entity candidates from text using simple regex rules.
|
||||
|
||||
Rules applied (in order):
|
||||
1. Capitalized multi-word phrases e.g. "John Doe"
|
||||
2. Double-quoted terms e.g. "Python"
|
||||
3. Single-quoted terms e.g. 'pytest'
|
||||
4. AKA patterns e.g. "Guido aka BDFL" -> two entities
|
||||
|
||||
Returns a deduplicated list preserving first-seen order.
|
||||
"""
|
||||
seen: set[str] = set()
|
||||
candidates: list[str] = []
|
||||
|
||||
def _add(name: str) -> None:
|
||||
stripped = name.strip()
|
||||
if stripped and stripped.lower() not in seen:
|
||||
seen.add(stripped.lower())
|
||||
candidates.append(stripped)
|
||||
|
||||
for m in _RE_CAPITALIZED.finditer(text):
|
||||
_add(m.group(1))
|
||||
|
||||
for m in _RE_DOUBLE_QUOTE.finditer(text):
|
||||
_add(m.group(1))
|
||||
|
||||
for m in _RE_SINGLE_QUOTE.finditer(text):
|
||||
_add(m.group(1))
|
||||
|
||||
for m in _RE_AKA.finditer(text):
|
||||
_add(m.group(1))
|
||||
_add(m.group(2))
|
||||
|
||||
return candidates
|
||||
|
||||
def _resolve_entity(self, name: str) -> int:
|
||||
"""Find an existing entity by name or alias (case-insensitive) or create one.
|
||||
|
||||
Returns the entity_id.
|
||||
"""
|
||||
# Exact name match
|
||||
row = self._conn.execute(
|
||||
"SELECT entity_id FROM entities WHERE name LIKE ?", (name,)
|
||||
).fetchone()
|
||||
if row is not None:
|
||||
return int(row["entity_id"])
|
||||
|
||||
# Search aliases — aliases stored as comma-separated; use LIKE with % boundaries
|
||||
alias_row = self._conn.execute(
|
||||
"""
|
||||
SELECT entity_id FROM entities
|
||||
WHERE ',' || aliases || ',' LIKE '%,' || ? || ',%'
|
||||
""",
|
||||
(name,),
|
||||
).fetchone()
|
||||
if alias_row is not None:
|
||||
return int(alias_row["entity_id"])
|
||||
|
||||
# Create new entity
|
||||
cur = self._conn.execute(
|
||||
"INSERT INTO entities (name) VALUES (?)", (name,)
|
||||
)
|
||||
self._conn.commit()
|
||||
return int(cur.lastrowid) # type: ignore[return-value]
|
||||
|
||||
def _link_fact_entity(self, fact_id: int, entity_id: int) -> None:
|
||||
"""Insert into fact_entities, silently ignore if the link already exists."""
|
||||
self._conn.execute(
|
||||
"""
|
||||
INSERT OR IGNORE INTO fact_entities (fact_id, entity_id)
|
||||
VALUES (?, ?)
|
||||
""",
|
||||
(fact_id, entity_id),
|
||||
)
|
||||
self._conn.commit()
|
||||
|
||||
def _compute_hrr_vector(self, fact_id: int, content: str) -> None:
|
||||
"""Compute and store HRR vector for a fact. No-op if numpy unavailable."""
|
||||
with self._lock:
|
||||
if not self._hrr_available:
|
||||
return
|
||||
|
||||
# Get entities linked to this fact
|
||||
rows = self._conn.execute(
|
||||
"""
|
||||
SELECT e.name FROM entities e
|
||||
JOIN fact_entities fe ON fe.entity_id = e.entity_id
|
||||
WHERE fe.fact_id = ?
|
||||
""",
|
||||
(fact_id,),
|
||||
).fetchall()
|
||||
entities = [row["name"] for row in rows]
|
||||
|
||||
vector = hrr.encode_fact(content, entities, self.hrr_dim)
|
||||
self._conn.execute(
|
||||
"UPDATE facts SET hrr_vector = ? WHERE fact_id = ?",
|
||||
(hrr.phases_to_bytes(vector), fact_id),
|
||||
)
|
||||
self._conn.commit()
|
||||
|
||||
def _rebuild_bank(self, category: str) -> None:
|
||||
"""Full rebuild of a category's memory bank from all its fact vectors."""
|
||||
with self._lock:
|
||||
if not self._hrr_available:
|
||||
return
|
||||
|
||||
bank_name = f"cat:{category}"
|
||||
rows = self._conn.execute(
|
||||
"SELECT hrr_vector FROM facts WHERE category = ? AND hrr_vector IS NOT NULL",
|
||||
(category,),
|
||||
).fetchall()
|
||||
|
||||
if not rows:
|
||||
self._conn.execute("DELETE FROM memory_banks WHERE bank_name = ?", (bank_name,))
|
||||
self._conn.commit()
|
||||
return
|
||||
|
||||
vectors = [hrr.bytes_to_phases(row["hrr_vector"], dim=self.hrr_dim) for row in rows]
|
||||
bank_vector = hrr.bundle(*vectors)
|
||||
fact_count = len(vectors)
|
||||
|
||||
# Check SNR
|
||||
hrr.snr_estimate(self.hrr_dim, fact_count)
|
||||
|
||||
self._conn.execute(
|
||||
"""
|
||||
INSERT INTO memory_banks (bank_name, vector, dim, fact_count, updated_at)
|
||||
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT(bank_name) DO UPDATE SET
|
||||
vector = excluded.vector,
|
||||
dim = excluded.dim,
|
||||
fact_count = excluded.fact_count,
|
||||
updated_at = excluded.updated_at
|
||||
""",
|
||||
(bank_name, hrr.phases_to_bytes(bank_vector), self.hrr_dim, fact_count),
|
||||
)
|
||||
self._conn.commit()
|
||||
|
||||
def rebuild_all_vectors(self, dim: int | None = None) -> int:
|
||||
"""Recompute all HRR vectors + banks from text. For recovery/migration.
|
||||
|
||||
Returns the number of facts processed.
|
||||
"""
|
||||
with self._lock:
|
||||
if not self._hrr_available:
|
||||
return 0
|
||||
|
||||
if dim is not None:
|
||||
self.hrr_dim = dim
|
||||
|
||||
rows = self._conn.execute(
|
||||
"SELECT fact_id, content, category FROM facts"
|
||||
).fetchall()
|
||||
|
||||
categories: set[str] = set()
|
||||
for row in rows:
|
||||
self._compute_hrr_vector(row["fact_id"], row["content"])
|
||||
categories.add(row["category"])
|
||||
|
||||
for category in categories:
|
||||
self._rebuild_bank(category)
|
||||
|
||||
return len(rows)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Utilities
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _row_to_dict(self, row: sqlite3.Row) -> dict:
|
||||
"""Convert a sqlite3.Row to a plain dict."""
|
||||
return dict(row)
|
||||
|
||||
@classmethod
|
||||
def release_all_under(cls, directory: "str | Path") -> int:
|
||||
"""Force-close every shared connection whose database lives under ``directory``.
|
||||
|
||||
``close()`` is refcount-driven, so a live holder (e.g. an agent's
|
||||
memory provider) keeps a profile's SQLite handle open indefinitely.
|
||||
That is exactly what a profile delete must break on Windows: the
|
||||
desktop's main ``serve`` process opens ``memory_store.db`` for every
|
||||
known profile, and ``rmtree`` of the profile directory fails with
|
||||
``WinError 32`` while any of those handles is open (#88347). This
|
||||
closes the matching connections unconditionally — the directory is
|
||||
going away, so later use by a stale holder is expected to fail — and
|
||||
returns how many were closed. In a process that holds none (e.g. the
|
||||
CLI deleting from outside serve) this is a harmless no-op returning 0.
|
||||
"""
|
||||
root = os.path.normcase(str(Path(directory).expanduser().resolve())) + os.sep
|
||||
with cls._shared_guard:
|
||||
# Snapshot the keys first so the registry stays stable while
|
||||
# connections are closed inside their per-database locks (closing
|
||||
# can run no user code, but this keeps the invariant obvious).
|
||||
doomed = [
|
||||
key
|
||||
for key in cls._shared
|
||||
if os.path.normcase(key).startswith(root)
|
||||
]
|
||||
for key in doomed:
|
||||
entry = cls._shared.pop(key)
|
||||
try:
|
||||
with entry["lock"]:
|
||||
entry["conn"].close()
|
||||
except Exception:
|
||||
# A connection that is already closed or broken must not
|
||||
# abort releasing its siblings.
|
||||
pass
|
||||
return len(doomed)
|
||||
|
||||
def close(self) -> None:
|
||||
"""Release this instance's reference to the shared connection.
|
||||
|
||||
The underlying connection is closed only when the last MemoryStore
|
||||
referencing the same database is closed, so closing one instance can
|
||||
never break sibling instances that still hold it. Idempotent.
|
||||
"""
|
||||
if getattr(self, "_entry", None) is None:
|
||||
return
|
||||
with MemoryStore._shared_guard:
|
||||
entry = self._entry
|
||||
if entry is None:
|
||||
return
|
||||
entry["refs"] -= 1
|
||||
if entry["refs"] <= 0:
|
||||
try:
|
||||
entry["conn"].close()
|
||||
finally:
|
||||
# Pop only OUR entry. After release_all_under() force-
|
||||
# closed this entry (profile delete, #88347) a same-path
|
||||
# store may have re-registered a FRESH entry under the
|
||||
# same key; a stale holder's late close() must not evict
|
||||
# it — that would silently reintroduce the multi-writer
|
||||
# contention this registry exists to prevent.
|
||||
if MemoryStore._shared.get(self._key) is entry:
|
||||
MemoryStore._shared.pop(self._key, None)
|
||||
self._entry = None
|
||||
|
||||
def __enter__(self) -> "MemoryStore":
|
||||
return self
|
||||
|
||||
def __exit__(self, *_: object) -> None:
|
||||
self.close()
|
||||
@@ -0,0 +1,414 @@
|
||||
# Honcho Memory Provider
|
||||
|
||||
AI-native cross-session user modeling with multi-pass dialectic reasoning, session summaries, bidirectional peer tools, and persistent conclusions.
|
||||
|
||||
> **Honcho docs:** <https://docs.honcho.dev/v3/guides/integrations/hermes>
|
||||
|
||||
## Requirements
|
||||
|
||||
- `pip install honcho-ai`
|
||||
- A Honcho Cloud account — connect via OAuth sign-in or an API key from
|
||||
[app.honcho.dev](https://app.honcho.dev) — or a self-hosted instance
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
hermes memory setup honcho # configure Honcho directly (works on a fresh install)
|
||||
hermes memory setup # generic picker, choose Honcho from the list
|
||||
```
|
||||
|
||||
For cloud, the wizard asks **OAuth, device code, or API key**. OAuth opens a
|
||||
browser sign-in and stores the grant itself — nothing to copy; tokens refresh
|
||||
automatically. On SSH/headless machines choose **device**: the CLI prints a
|
||||
short code and a link you open in a browser on any other machine; setup
|
||||
completes once you approve there. The desktop app offers the browser flow as
|
||||
a **Connect** link next to the memory-provider dropdown.
|
||||
|
||||
Or manually:
|
||||
```bash
|
||||
hermes config set memory.provider honcho
|
||||
echo "HONCHO_API_KEY=***" >> ~/.hermes/.env
|
||||
```
|
||||
|
||||
> `hermes honcho setup` also works, but only **after** Honcho is the active
|
||||
> memory provider — the `honcho` subcommand is registered for the active
|
||||
> provider only. On a fresh install, use `hermes memory setup honcho`.
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
### Two-Layer Context Injection
|
||||
|
||||
Context is injected into the **user message** at API-call time (not the system prompt) to preserve prompt caching. Only a static mode header goes in the system prompt. The injected block is wrapped in `<memory-context>` fences with a system note clarifying it's background data, not new user input.
|
||||
|
||||
Two independent layers, each on its own cadence:
|
||||
|
||||
**Layer 1 — Base context** (refreshed every `contextCadence` turns):
|
||||
1. **SESSION SUMMARY** — from `session.context(summary=True)`, placed first
|
||||
2. **User Representation** — Honcho's evolving model of the user
|
||||
3. **User Peer Card** — key facts snapshot
|
||||
4. **AI Self-Representation** — Honcho's model of the AI peer
|
||||
5. **AI Identity Card** — AI peer facts
|
||||
|
||||
**Layer 2 — Dialectic supplement** (fired every `dialecticCadence` turns):
|
||||
Multi-pass `.chat()` reasoning about the user, appended after base context.
|
||||
|
||||
Both layers are joined, then truncated to fit `contextTokens` budget via `_truncate_to_budget` (tokens × 4 chars, word-boundary safe).
|
||||
|
||||
### Latest-Message Query Rewrite (opt-in)
|
||||
|
||||
When `queryRewrite: true`, dialectic pass 0 first uses the shared
|
||||
`memory_query_rewrite` auxiliary task to turn the latest message into one
|
||||
concise memory-retrieval question. The rewritten question is used for the
|
||||
dialectic request; base-context retrieval still uses the raw message as its
|
||||
search query. If rewriting times out or returns an invalid result, the plugin
|
||||
falls back to the existing cold/warm prompt below. With the flag on, the
|
||||
generic dialectic prewarm is skipped so it cannot shadow the first user
|
||||
message.
|
||||
|
||||
**Off by default** — the rewrite adds one auxiliary-model call per dialectic
|
||||
cycle (not per pass). Select a fast, inexpensive model under `hermes model`
|
||||
-> auxiliary models -> **Memory query rewrite**; its request timeout is
|
||||
`auxiliary.memory_query_rewrite.timeout` in config.yaml (default 8s). The
|
||||
task and module (`plugins/memory/query_rewrite.py`) are provider-agnostic —
|
||||
any memory provider can reuse them. `dialecticCadence` still controls how
|
||||
often the cycle runs.
|
||||
|
||||
### Cold Start vs Warm Session Prompts
|
||||
|
||||
When latest-message rewriting is unavailable, dialectic pass 0 automatically
|
||||
selects its fallback prompt based on session state:
|
||||
|
||||
- **Cold** (no base context cached): "Who is this person? What are their preferences, goals, and working style? Focus on facts that would help an AI assistant be immediately useful."
|
||||
- **Warm** (base context exists): "Given what's been discussed in this session so far, what context about this user is most relevant to the current conversation? Prioritize active context over biographical facts."
|
||||
|
||||
Not configurable — determined automatically.
|
||||
|
||||
### Dialectic Depth (Multi-Pass Reasoning)
|
||||
|
||||
`dialecticDepth` (1–3, clamped) controls how many `.chat()` calls fire per dialectic cycle:
|
||||
|
||||
| Depth | Passes | Behavior |
|
||||
|-------|--------|----------|
|
||||
| 1 | single `.chat()` | Base query only (cold or warm prompt) |
|
||||
| 2 | audit + synthesis | Pass 0 result is self-audited; pass 1 does targeted synthesis. Conditional bail-out if pass 0 returns strong signal (>300 chars or structured with bullets/sections >100 chars) |
|
||||
| 3 | audit + synthesis + reconciliation | Pass 2 reconciles contradictions across prior passes into a final synthesis |
|
||||
|
||||
### Proportional Reasoning Levels
|
||||
|
||||
When `dialecticDepthLevels` is not set, each pass uses a proportional level relative to `dialecticReasoningLevel` (the "base"):
|
||||
|
||||
| Depth | Pass levels |
|
||||
|-------|-------------|
|
||||
| 1 | [base] |
|
||||
| 2 | [minimal, base] |
|
||||
| 3 | [minimal, base, low] |
|
||||
|
||||
Override with `dialecticDepthLevels`: an explicit array of reasoning level strings per pass.
|
||||
|
||||
### Query-Adaptive Reasoning Level
|
||||
|
||||
The auto-injected dialectic scales `dialecticReasoningLevel` by query length: +1 level at ≥120 chars, +2 at ≥400, clamped at `reasoningLevelCap` (default `"high"`). Disable with `reasoningHeuristic: false` to pin every auto call to `dialecticReasoningLevel`.
|
||||
|
||||
### Three Orthogonal Dialectic Knobs
|
||||
|
||||
| Knob | Controls | Type |
|
||||
|------|----------|------|
|
||||
| `dialecticCadence` | How often — minimum turns between dialectic firings | int |
|
||||
| `dialecticDepth` | How many — passes per firing (1–3) | int |
|
||||
| `dialecticReasoningLevel` | How hard — reasoning ceiling per `.chat()` call | string |
|
||||
|
||||
### Input Sanitization
|
||||
|
||||
`run_conversation` strips leaked `<memory-context>` blocks from user input before processing. When `saveMessages` persists a turn that included injected context, the block can reappear in subsequent turns via message history. The sanitizer removes `<memory-context>` blocks plus associated system notes.
|
||||
|
||||
## Tools
|
||||
|
||||
Five bidirectional tools. All accept an optional `peer` parameter (`"user"` or `"ai"`, default `"user"`).
|
||||
|
||||
| Tool | LLM call? | Description |
|
||||
|------|-----------|-------------|
|
||||
| `honcho_profile` | No | Peer card — key facts snapshot |
|
||||
| `honcho_search` | No | Cross-session message search (hybrid semantic + keyword, ranked excerpts; 800 tok default, 2000 max) |
|
||||
| `honcho_context` | No | Full session context: summary, representation, card, messages |
|
||||
| `honcho_reasoning` | Yes | LLM-synthesized answer via dialectic `.chat()` |
|
||||
| `honcho_conclude` | No | Write, list/search, or delete persistent conclusions (list surfaces the ids delete needs) |
|
||||
|
||||
Tool visibility depends on `recallMode`: hidden in `context` mode, always present in `tools` and `hybrid`.
|
||||
|
||||
## Config Resolution
|
||||
|
||||
Config is read from the first file that exists:
|
||||
|
||||
| Priority | Path | Scope |
|
||||
|----------|------|-------|
|
||||
| 1 | `$HERMES_HOME/honcho.json` | Profile-local (isolated Hermes instances) |
|
||||
| 2 | `~/.hermes/honcho.json` | Default profile (shared host blocks) |
|
||||
| 3 | `~/.honcho/config.json` | Global (cross-app interop) |
|
||||
|
||||
Host key is derived from the active Hermes profile: `hermes` (default) or `hermes_<profile>`.
|
||||
|
||||
For every key, resolution order is: **host block > root > env var > default**.
|
||||
|
||||
## Full Configuration Reference
|
||||
|
||||
### Identity & Connection
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|-----|------|---------|-------------|
|
||||
| `apiKey` | string | — | API key. Falls back to `HONCHO_API_KEY` env var. When connected via OAuth, holds the auto-refreshing access token instead |
|
||||
| `oauth` | object | — | OAuth grant (refresh token, expiry, client, token endpoint). Written by the Connect/sign-in flows and rotated automatically — not hand-edited. Optional: an API key alone works without it |
|
||||
| `baseUrl` | string | — | Base URL for self-hosted Honcho. Local URLs auto-skip API key auth |
|
||||
| `environment` | string | `"production"` | SDK environment mapping |
|
||||
| `enabled` | bool | auto | Master toggle. Auto-enables when `apiKey` or `baseUrl` present |
|
||||
| `workspace` | string | host key | Honcho workspace ID. Shared environment — all profiles in the same workspace can see the same user identity and related memories |
|
||||
| `peerName` | string | — | User peer identity |
|
||||
| `aiPeer` | string | host key | AI peer identity |
|
||||
|
||||
### Identity Mapping (Gateway Multi-User)
|
||||
|
||||
In gateway deployments (Telegram, Discord, Slack, etc.) each user arrives with a platform-native runtime ID (Telegram UID, Discord snowflake, Slack user). These three keys control how those runtime IDs map to Honcho peers. The resolver is config-driven and deterministic — no automatic merging or runtime inference.
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|-----|------|---------|-------------|
|
||||
| `pinUserPeer` | bool | `false` | When `true`, every gateway runtime user collapses to `peerName`. Single-operator deployments where you want all your platforms (and any other users) to share one peer |
|
||||
| `userPeerAliases` | object | `{}` | Map of runtime IDs to peer IDs (`{"7654321": "alice"}`). Many-to-one is the intended pattern — alias all your runtime IDs to one peer name. One-to-many is not supported; one runtime ID resolves to exactly one peer |
|
||||
| `runtimePeerPrefix` | string | `""` | Prepended to unknown runtime IDs to namespace them (e.g. `"telegram_"` → `telegram_7654321`). Used only when no alias matches. Prevents collisions between platforms whose runtime IDs share the same shape |
|
||||
|
||||
> **Deprecated:** `pinPeerName` is a legacy alias for `pinUserPeer`, still read for back-compat (`pinUserPeer` wins where both are set). `hermes honcho setup` migrates it onto `pinUserPeer` on touch and never writes it.
|
||||
|
||||
**Resolver ladder** (first match wins):
|
||||
|
||||
```
|
||||
1. pinUserPeer / pinPeerName=true → return peerName (ignore runtime ID)
|
||||
2. userPeerAliases[runtime_id] → return aliased peer
|
||||
3. userPeerAliases[runtime_id_alt] → check alt-ID too (Telegram UID + username, etc.)
|
||||
4. runtimePeerPrefix + runtime_id → namespaced peer, with sha256 collision escalation
|
||||
5. raw sanitized runtime_id → fallback peer
|
||||
6. peerName → no runtime ID at all (CLI/TUI)
|
||||
7. session-key fallback → no config either
|
||||
```
|
||||
|
||||
**Why no `pinAiPeer`?** The AI peer is already pinned by construction — `aiPeer` is the only AI-side identity setting and the resolver never overrides it. Only the user-side peer has the runtime-vs-config tension that `pinUserPeer` resolves.
|
||||
|
||||
**Host vs root semantics.** All three keys are accepted at both root and `hosts.<host>` levels. Host-level wins. For maps and prefixes, host-level *replaces* the root value as a whole (not merge), so a host can intentionally own its identity universe or wipe it with `userPeerAliases: {}` / `runtimePeerPrefix: ""`.
|
||||
|
||||
**Setup — gateway identity tree.** `hermes honcho setup` only asks about identity mapping when it detects a connected gateway platform (it inspects the gateway config; off-gateway the step is skipped because these keys do nothing without a runtime user ID). When it runs, it asks *who talks to this gateway?* and derives the keys:
|
||||
|
||||
- **just me** → `pinUserPeer: true`. Every non-agent gateway user collapses to `peerName`; the pin overrides all aliases, so pick this only when no user-side identity needs its own peer. Personal use where you connect Hermes to your own Telegram/Discord/etc. If separate agents reach the gateway and each needs a distinct peer, do **not** pin — leave `pinUserPeer: false` and map them via `userPeerAliases` (the `[e]` editor).
|
||||
- **me + other people, pooled** → `pinUserPeer: false` + `userPeerAliases` mapping your runtime IDs to `peerName`. You stay on the shared history; everyone else gets their own peer.
|
||||
- **me + other people / only other people** → `pinUserPeer: false`, optional `runtimePeerPrefix`. Each runtime user → own peer. For bots serving many humans.
|
||||
|
||||
Pick **[e]** at the prompt to set the three keys directly instead of going through the tree.
|
||||
|
||||
**Un-pinning (single → per-user).** Flipping `pinUserPeer` from `true` to `false` does not migrate data. Memory accumulated under `peerName` while pinned stays there; runtime users now resolve to fresh, empty peers. To preserve your own continuity, choose the **pooled** path — alias your runtime IDs back to `peerName` so your turns keep landing on the pooled history while other users get their own peers. The wizard offers this steer automatically when it detects you're un-pinning a previously pinned profile.
|
||||
|
||||
### Memory & Recall
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|-----|------|---------|-------------|
|
||||
| `recallMode` | string | `"hybrid"` | `"hybrid"` (auto-inject + tools), `"context"` (auto-inject only, tools hidden), `"tools"` (tools only, no injection). Legacy `"auto"` → `"hybrid"` |
|
||||
| `observationMode` | string | `"directional"` | Preset: `"directional"` (all on) or `"unified"` (user observes self, AI observes others). Use `observation` object for granular control |
|
||||
| `observation` | object | — | Per-peer observation config (see Observation section) |
|
||||
|
||||
### Write Behavior
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|-----|------|---------|-------------|
|
||||
| `writeFrequency` | string/int | `"async"` | `"async"` (background), `"turn"` (sync per turn), `"session"` (batch on end), or integer N (every N turns) |
|
||||
| `saveMessages` | bool | `true` | Persist messages to Honcho API. When `false`, all automatic writes are skipped — raw turns (`sync_turn`), conclusion mirroring (`on_memory_write`), and session-end/shutdown flushes — while read and tools paths stay fully functional. |
|
||||
|
||||
### Session Resolution
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|-----|------|---------|-------------|
|
||||
| `sessionStrategy` | string | `"per-directory"` | `"per-directory"`, `"per-session"`, `"per-repo"` (git root), `"global"` |
|
||||
| `sessionPeerPrefix` | bool | `false` | Prepend peer name to session keys |
|
||||
| `sessions` | object | `{}` | Manual directory-to-session-name mappings |
|
||||
|
||||
#### Session Name Resolution
|
||||
|
||||
The Honcho session name determines which conversation bucket memory lands in. Resolution follows a priority chain — first match wins:
|
||||
|
||||
| Priority | Source | Example session name |
|
||||
|----------|--------|---------------------|
|
||||
| 1 | Manual map (`sessions` config) | `"myproject-main"` |
|
||||
| 2 | `/title` command (mid-session rename) | `"refactor-auth"` |
|
||||
| 3 | Gateway session key (Telegram, Discord, etc.) | `"agent-main-telegram-dm-8439114563"` |
|
||||
| 4 | `per-session` strategy | Hermes session ID (`20260415_a3f2b1`) |
|
||||
| 5 | `per-repo` strategy | Git root directory name (`hermes-agent`) |
|
||||
| 6 | `per-directory` strategy | Current directory basename (`src`) |
|
||||
| 7 | `global` strategy | Workspace name (`hermes`) |
|
||||
|
||||
Gateway platforms always resolve via priority 3 (per-chat isolation) regardless of `sessionStrategy`. The strategy setting only affects CLI sessions.
|
||||
|
||||
If `sessionPeerPrefix` is `true`, the peer name is prepended: `alice-hermes-agent`.
|
||||
|
||||
#### What each strategy produces
|
||||
|
||||
- **`per-directory`** — basename of `$PWD`. Opening hermes in `~/code/myapp` and `~/code/other` gives two separate sessions. Same directory = same session across runs.
|
||||
- **`per-repo`** — git root directory name. All subdirectories within a repo share one session. Falls back to `per-directory` if not inside a git repo.
|
||||
- **`per-session`** — Hermes session ID (timestamp + hex). Every `hermes` invocation starts a fresh Honcho session. Falls back to `per-directory` if no session ID is available.
|
||||
- **`global`** — workspace name. One session for everything. Memory accumulates across all directories and runs.
|
||||
|
||||
### Multi-Profile Pattern
|
||||
|
||||
Multiple Hermes profiles can share one workspace while maintaining separate AI identities. Config resolution is **host block > root > env var > default** — host blocks inherit from root, so shared settings only need to be declared once:
|
||||
|
||||
```json
|
||||
{
|
||||
"apiKey": "***",
|
||||
"workspace": "hermes",
|
||||
"peerName": "yourname",
|
||||
"hosts": {
|
||||
"hermes": {
|
||||
"aiPeer": "hermes",
|
||||
"recallMode": "hybrid",
|
||||
"sessionStrategy": "per-directory"
|
||||
},
|
||||
"hermes_coder": {
|
||||
"aiPeer": "coder",
|
||||
"recallMode": "tools",
|
||||
"sessionStrategy": "per-repo"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Both profiles see the same user (`yourname`) in the same shared environment (`hermes`), but each AI peer builds its own observations, conclusions, and behavior patterns. The coder's memory stays code-oriented; the main agent's stays broad.
|
||||
|
||||
Host key is derived from the active Hermes profile: `hermes` (default) or `hermes_<profile>` (e.g. `hermes -p coder` -> host key `hermes_coder`). Older `hermes.<profile>` host blocks are still read for compatibility and are migrated when the CLI writes profile-scoped Honcho config.
|
||||
|
||||
### Dialectic & Reasoning
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|-----|------|---------|-------------|
|
||||
| `dialecticDepth` | int | `1` | Passes per dialectic cycle (1–3, clamped). 1=single query, 2=audit+synthesis, 3=audit+synthesis+reconciliation |
|
||||
| `dialecticDepthLevels` | array | — | Optional array of reasoning level strings per pass. Overrides proportional defaults. Example: `["minimal", "low", "medium"]` |
|
||||
| `dialecticReasoningLevel` | string | `"low"` | Base reasoning level for `.chat()`: `"minimal"`, `"low"`, `"medium"`, `"high"`, `"max"` |
|
||||
| `dialecticDynamic` | bool | `true` | When `true`, model can override reasoning level per-call via `honcho_reasoning` tool. When `false`, always uses `dialecticReasoningLevel` |
|
||||
| `dialecticMaxChars` | int | `600` | Max chars of the auto-injected dialectic supplement. Applies only to auto-injection — explicit `honcho_reasoning` tool results return in full |
|
||||
| `dialecticMaxInputChars` | int | `10000` | Max chars for dialectic query input to `.chat()`. Honcho cloud limit: 10k |
|
||||
| `reasoningHeuristic` | bool | `true` | Query-adaptive: auto-scale the auto-injected dialectic's level up by query length (+1 at ≥120 chars, +2 at ≥400), clamped at `reasoningLevelCap`. `false` pins every auto call to `dialecticReasoningLevel` |
|
||||
| `reasoningLevelCap` | string | `"high"` | Ceiling for `reasoningHeuristic` scaling: `"minimal"`, `"low"`, `"medium"`, `"high"`, `"max"` |
|
||||
|
||||
### Token Budgets
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|-----|------|---------|-------------|
|
||||
| `contextTokens` | int | SDK default | Token budget for `context()` API calls. Also gates prefetch truncation (tokens × 4 chars) |
|
||||
| `messageMaxChars` | int | `25000` | Max chars per message sent via `add_messages()`. Exceeding this triggers chunking with `[continued]` markers. Honcho cloud limit: 25k |
|
||||
|
||||
### Cadence (Cost Control)
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|-----|------|---------|-------------|
|
||||
| `contextCadence` | int | `1` | Minimum turns between base context refreshes (session summary + representation + card) |
|
||||
| `dialecticCadence` | int | `1` | Minimum turns between dialectic `.chat()` firings |
|
||||
| `injectionFrequency` | string | `"every-turn"` | `"every-turn"` or `"first-turn"` (inject base context on the first user message only; the dialectic supplement keeps its own cadence) |
|
||||
| `queryRewrite` | bool | `false` | Rewrite the latest message into a retrieval query before dialectic (one extra auxiliary LLM call per cycle) |
|
||||
| `firstTurnBaseWait` | float | `3.0` | Max seconds turn 1 waits for base context / session init. `0` disables the wait (fully async; context surfaces on later turns). Turns 2+ never wait on a stalled init |
|
||||
| `firstTurnDialecticWait` | float | `2.0` | Max seconds turn 1 waits for a dialectic result. `0` disables |
|
||||
|
||||
### Observation (Granular)
|
||||
|
||||
Maps 1:1 to Honcho's per-peer `SessionPeerConfig`. When present, overrides `observationMode` preset.
|
||||
|
||||
```json
|
||||
"observation": {
|
||||
"user": { "observeMe": true, "observeOthers": true },
|
||||
"ai": { "observeMe": true, "observeOthers": true }
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Default | Description |
|
||||
|-------|---------|-------------|
|
||||
| `user.observeMe` | `true` | User peer self-observation (Honcho builds user representation) |
|
||||
| `user.observeOthers` | `true` | User peer observes AI messages |
|
||||
| `ai.observeMe` | `true` | AI peer self-observation (Honcho builds AI representation) |
|
||||
| `ai.observeOthers` | `true` | AI peer observes user messages (enables cross-peer dialectic) |
|
||||
|
||||
Presets:
|
||||
- `"directional"` (default): all four `true`
|
||||
- `"unified"`: user `observeMe=true`, AI `observeOthers=true`, rest `false`
|
||||
|
||||
### Hardcoded Limits
|
||||
|
||||
| Limit | Value |
|
||||
|-------|-------|
|
||||
| Search tool max tokens | 2000 (hard cap), 800 (default) |
|
||||
| Peer card fetch tokens | 200 |
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Fallback for |
|
||||
|----------|-------------|
|
||||
| `HONCHO_API_KEY` | `apiKey` |
|
||||
| `HONCHO_BASE_URL` | `baseUrl` |
|
||||
| `HONCHO_ENVIRONMENT` | `environment` |
|
||||
| `HERMES_HONCHO_HOST` | Host key override |
|
||||
| `HONCHO_OAUTH_DASHBOARD` | OAuth authorize origin (default: cloud dashboard; local-dev `localhost:3000`) |
|
||||
| `HONCHO_OAUTH_AUTHORIZE_URL` | Full authorize URL (overrides the dashboard origin) |
|
||||
| `HONCHO_OAUTH_TOKEN_URL` | Token endpoint (default: cloud API; local-dev `localhost:8000`) |
|
||||
| `HONCHO_OAUTH_DEVICE_AUTH_URL` | Device-authorization endpoint (default: derived from the token URL) |
|
||||
| `HONCHO_OAUTH_CLIENT_ID` | OAuth client (default `hermes-agent`) |
|
||||
| `HONCHO_OAUTH_SCOPE` | Requested scope (default `write`) |
|
||||
|
||||
## CLI Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `hermes memory setup honcho` | Configure Honcho directly — works on a fresh install |
|
||||
| `hermes honcho setup` | Interactive setup wizard (only registered once Honcho is the active provider; redirects to `hermes memory setup`) |
|
||||
| `hermes honcho status` | Show resolved config for active profile |
|
||||
| `hermes honcho enable` / `disable` | Toggle Honcho for active profile |
|
||||
| `hermes honcho mode <mode>` | Change recall or observation mode |
|
||||
| `hermes honcho peer --user <name>` | Update user peer name |
|
||||
| `hermes honcho peer --ai <name>` | Update AI peer name |
|
||||
| `hermes honcho tokens --context <N>` | Set context token budget |
|
||||
| `hermes honcho tokens --dialectic <N>` | Set dialectic max chars |
|
||||
| `hermes honcho map <name>` | Map current directory to a session name |
|
||||
| `hermes honcho sync` | Create host blocks for all Hermes profiles |
|
||||
|
||||
## Example Config
|
||||
|
||||
```json
|
||||
{
|
||||
"apiKey": "***",
|
||||
"workspace": "hermes",
|
||||
"peerName": "username",
|
||||
"contextCadence": 2,
|
||||
"dialecticCadence": 3,
|
||||
"dialecticDepth": 2,
|
||||
"hosts": {
|
||||
"hermes": {
|
||||
"enabled": true,
|
||||
"aiPeer": "hermes",
|
||||
"recallMode": "hybrid",
|
||||
"observation": {
|
||||
"user": { "observeMe": true, "observeOthers": true },
|
||||
"ai": { "observeMe": true, "observeOthers": true }
|
||||
},
|
||||
"writeFrequency": "async",
|
||||
"sessionStrategy": "per-directory",
|
||||
"dialecticReasoningLevel": "low",
|
||||
"dialecticDepth": 2,
|
||||
"dialecticMaxChars": 600,
|
||||
"saveMessages": true
|
||||
},
|
||||
"hermes_coder": {
|
||||
"enabled": true,
|
||||
"aiPeer": "coder",
|
||||
"sessionStrategy": "per-repo",
|
||||
"dialecticDepth": 1,
|
||||
"dialecticDepthLevels": ["low"],
|
||||
"observation": {
|
||||
"user": { "observeMe": true, "observeOthers": false },
|
||||
"ai": { "observeMe": true, "observeOthers": true }
|
||||
}
|
||||
}
|
||||
},
|
||||
"sessions": {
|
||||
"/home/user/myproject": "myproject-main"
|
||||
}
|
||||
}
|
||||
```
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,324 @@
|
||||
"""Honcho's declared config surface — rendered by the generic desktop panel."""
|
||||
|
||||
from plugins.memory.config_schema import (
|
||||
KIND_BOOL,
|
||||
KIND_JSON,
|
||||
KIND_NUMBER,
|
||||
KIND_SECRET,
|
||||
KIND_SELECT,
|
||||
KIND_TEXT,
|
||||
STORAGE_HONCHO_HOST_BLOCK,
|
||||
ProviderConfigSchema,
|
||||
ProviderField,
|
||||
ProviderFieldOption,
|
||||
)
|
||||
|
||||
|
||||
# Reasoning effort levels shared by dialectic-related selects.
|
||||
_REASONING_LEVELS = (
|
||||
ProviderFieldOption("minimal", "Minimal"),
|
||||
ProviderFieldOption("low", "Low"),
|
||||
ProviderFieldOption("medium", "Medium"),
|
||||
ProviderFieldOption("high", "High"),
|
||||
ProviderFieldOption("max", "Max"),
|
||||
)
|
||||
|
||||
|
||||
CONFIG_SCHEMA = ProviderConfigSchema(
|
||||
name="honcho",
|
||||
label="Honcho",
|
||||
storage=STORAGE_HONCHO_HOST_BLOCK,
|
||||
docs_url="https://docs.honcho.dev/v3/guides/integrations/hermes",
|
||||
fields=(
|
||||
# — Connection —
|
||||
ProviderField(
|
||||
key="apiKey",
|
||||
label="API key",
|
||||
kind=KIND_SECRET,
|
||||
env_key="HONCHO_API_KEY",
|
||||
description="Authenticate with Honcho Cloud. Not needed for a self-hosted base URL.",
|
||||
placeholder="Enter Honcho API key",
|
||||
inline=True,
|
||||
group="Connection",
|
||||
),
|
||||
ProviderField(
|
||||
key="baseUrl",
|
||||
label="Base URL",
|
||||
kind=KIND_TEXT,
|
||||
aliases=("base_url",),
|
||||
env_fallbacks=("HONCHO_BASE_URL",),
|
||||
description="Self-hosted Honcho URL. Overrides the environment when set.",
|
||||
placeholder="https://… (self-hosted)",
|
||||
inline=True,
|
||||
group="Connection",
|
||||
scope="root",
|
||||
),
|
||||
ProviderField(
|
||||
key="environment",
|
||||
label="Environment",
|
||||
kind=KIND_SELECT,
|
||||
default="production",
|
||||
env_fallbacks=("HONCHO_ENVIRONMENT",),
|
||||
description="Honcho environment. Ignored when a base URL is set.",
|
||||
options=(
|
||||
ProviderFieldOption("production", "Cloud"),
|
||||
ProviderFieldOption("local", "Local"),
|
||||
),
|
||||
inline=True,
|
||||
group="Connection",
|
||||
),
|
||||
ProviderField(
|
||||
key="workspace",
|
||||
label="Workspace",
|
||||
kind=KIND_TEXT,
|
||||
description="Honcho workspace ID. Defaults to the profile host.",
|
||||
inline=True,
|
||||
group="Connection",
|
||||
),
|
||||
# — Identity —
|
||||
ProviderField(
|
||||
key="peerName",
|
||||
label="Peer name",
|
||||
kind=KIND_TEXT,
|
||||
description="Your stable user peer. Unifies memory across platforms for single-user setups.",
|
||||
placeholder="e.g. eri",
|
||||
inline=True,
|
||||
group="Identity",
|
||||
),
|
||||
ProviderField(
|
||||
key="aiPeer",
|
||||
label="AI peer",
|
||||
kind=KIND_TEXT,
|
||||
description="The AI-side peer name. Defaults to the profile host.",
|
||||
inline=True,
|
||||
group="Identity",
|
||||
),
|
||||
# — Session —
|
||||
ProviderField(
|
||||
key="sessionStrategy",
|
||||
label="Session strategy",
|
||||
kind=KIND_SELECT,
|
||||
default="per-directory",
|
||||
description="How conversations map to Honcho sessions.",
|
||||
info=(
|
||||
"Per session: every conversation gets its own Honcho session. "
|
||||
"Per directory: conversations from the same working directory share one. "
|
||||
"Per repo: conversations from the same git repo share one. "
|
||||
"Global: everything shares a single session."
|
||||
),
|
||||
options=(
|
||||
ProviderFieldOption("per-session", "Per session"),
|
||||
ProviderFieldOption("per-directory", "Per directory"),
|
||||
ProviderFieldOption("per-repo", "Per repo"),
|
||||
ProviderFieldOption("global", "Global"),
|
||||
),
|
||||
inline=True,
|
||||
group="Session",
|
||||
),
|
||||
# —————— Full-config-only fields below (inline=False) ——————
|
||||
# — Connection —
|
||||
ProviderField(
|
||||
key="timeout",
|
||||
label="Request timeout",
|
||||
kind=KIND_NUMBER,
|
||||
aliases=("requestTimeout",),
|
||||
env_fallbacks=("HONCHO_TIMEOUT",),
|
||||
description="Request timeout in seconds for Honcho HTTP calls. Blank uses the default.",
|
||||
placeholder="30",
|
||||
group="Connection",
|
||||
scope="root",
|
||||
),
|
||||
# — Identity —
|
||||
ProviderField(
|
||||
key="pinUserPeer",
|
||||
label="Pin user peer",
|
||||
kind=KIND_BOOL,
|
||||
default="false",
|
||||
aliases=("pinPeerName",),
|
||||
description="Pin the user peer to the peer name, ignoring gateway runtime identity. Unifies memory for single-user setups.",
|
||||
group="Identity",
|
||||
),
|
||||
ProviderField(
|
||||
key="runtimePeerPrefix",
|
||||
label="Runtime peer prefix",
|
||||
kind=KIND_TEXT,
|
||||
description="Prefix applied to unknown gateway runtime user IDs.",
|
||||
placeholder="e.g. telegram_",
|
||||
group="Identity",
|
||||
),
|
||||
ProviderField(
|
||||
key="userPeerAliases",
|
||||
label="User peer aliases",
|
||||
kind=KIND_JSON,
|
||||
description="Map gateway runtime user IDs to stable Honcho peers.",
|
||||
placeholder='{"telegram_123": "eri"}',
|
||||
group="Identity",
|
||||
),
|
||||
# — Session —
|
||||
ProviderField(
|
||||
key="sessionPeerPrefix",
|
||||
label="Session peer prefix",
|
||||
kind=KIND_BOOL,
|
||||
default="false",
|
||||
description="Prefix session peer names with the host.",
|
||||
group="Session",
|
||||
),
|
||||
ProviderField(
|
||||
key="sessions",
|
||||
label="Session overrides",
|
||||
kind=KIND_JSON,
|
||||
description="Explicit session ID overrides keyed by resolver.",
|
||||
placeholder='{"key": "session-id"}',
|
||||
group="Session",
|
||||
scope="root",
|
||||
),
|
||||
# — Message writing —
|
||||
ProviderField(
|
||||
key="saveMessages",
|
||||
label="Save messages",
|
||||
kind=KIND_BOOL,
|
||||
default="true",
|
||||
description="Persist conversation messages to Honcho.",
|
||||
group="Message writing",
|
||||
),
|
||||
ProviderField(
|
||||
key="writeFrequency",
|
||||
label="Write frequency",
|
||||
kind=KIND_TEXT,
|
||||
default="async",
|
||||
description="When to flush messages: async, turn, session, or every N turns.",
|
||||
info=(
|
||||
"async: write in the background as messages arrive. "
|
||||
"turn: flush after each turn. session: flush when the session ends. "
|
||||
"A number N flushes every N turns."
|
||||
),
|
||||
placeholder="async | turn | session | N",
|
||||
group="Message writing",
|
||||
),
|
||||
# — Dialectic —
|
||||
ProviderField(
|
||||
key="dialecticReasoningLevel",
|
||||
label="Reasoning level",
|
||||
kind=KIND_SELECT,
|
||||
default="low",
|
||||
description="Reasoning effort for dialectic (peer.chat) calls.",
|
||||
options=_REASONING_LEVELS,
|
||||
group="Dialectic",
|
||||
),
|
||||
ProviderField(
|
||||
key="dialecticDynamic",
|
||||
label="Dynamic reasoning",
|
||||
kind=KIND_BOOL,
|
||||
default="true",
|
||||
description="Let the model override the reasoning level per call.",
|
||||
group="Dialectic",
|
||||
),
|
||||
ProviderField(
|
||||
key="dialecticMaxChars",
|
||||
label="Max result chars",
|
||||
kind=KIND_NUMBER,
|
||||
description="Max chars of dialectic result injected into the system prompt.",
|
||||
placeholder="1200",
|
||||
group="Dialectic",
|
||||
),
|
||||
ProviderField(
|
||||
key="dialecticDepth",
|
||||
label="Depth",
|
||||
kind=KIND_NUMBER,
|
||||
description="Dialectic passes per cycle (1–3).",
|
||||
placeholder="1",
|
||||
group="Dialectic",
|
||||
),
|
||||
ProviderField(
|
||||
key="dialecticDepthLevels",
|
||||
label="Per-pass levels",
|
||||
kind=KIND_JSON,
|
||||
description="Reasoning level per pass; array length matches depth.",
|
||||
placeholder='["low", "medium"]',
|
||||
group="Dialectic",
|
||||
),
|
||||
ProviderField(
|
||||
key="dialecticMaxInputChars",
|
||||
label="Max input chars",
|
||||
kind=KIND_NUMBER,
|
||||
description="Max chars of query input sent to peer.chat().",
|
||||
placeholder="10000",
|
||||
group="Dialectic",
|
||||
),
|
||||
# — Reasoning —
|
||||
ProviderField(
|
||||
key="reasoningHeuristic",
|
||||
label="Reasoning heuristic",
|
||||
kind=KIND_BOOL,
|
||||
default="true",
|
||||
description="Scale the reasoning level up on longer queries.",
|
||||
group="Reasoning",
|
||||
),
|
||||
ProviderField(
|
||||
key="reasoningLevelCap",
|
||||
label="Reasoning level cap",
|
||||
kind=KIND_SELECT,
|
||||
default="high",
|
||||
description="Ceiling for the heuristic-selected reasoning level.",
|
||||
options=_REASONING_LEVELS,
|
||||
group="Reasoning",
|
||||
),
|
||||
# — Recall —
|
||||
ProviderField(
|
||||
key="recallMode",
|
||||
label="Recall mode",
|
||||
kind=KIND_SELECT,
|
||||
default="hybrid",
|
||||
description="How memory retrieval works: hybrid, context-only, or tools-only.",
|
||||
info=(
|
||||
"Hybrid: auto-injected context plus on-demand memory tools. "
|
||||
"Context only: injection without tools. "
|
||||
"Tools only: the model queries memory explicitly, nothing is injected."
|
||||
),
|
||||
options=(
|
||||
ProviderFieldOption("hybrid", "Hybrid"),
|
||||
ProviderFieldOption("context", "Context only"),
|
||||
ProviderFieldOption("tools", "Tools only"),
|
||||
),
|
||||
group="Recall",
|
||||
),
|
||||
ProviderField(
|
||||
key="contextTokens",
|
||||
label="Context token cap",
|
||||
kind=KIND_NUMBER,
|
||||
description="Cap on auto-injected context tokens. Blank leaves it uncapped.",
|
||||
placeholder="(uncapped)",
|
||||
group="Recall",
|
||||
),
|
||||
ProviderField(
|
||||
key="initOnSessionStart",
|
||||
label="Eager init",
|
||||
kind=KIND_BOOL,
|
||||
default="false",
|
||||
description="Initialize the session eagerly in tools mode instead of on first tool call.",
|
||||
group="Recall",
|
||||
),
|
||||
# — Limits —
|
||||
ProviderField(
|
||||
key="messageMaxChars",
|
||||
label="Message max chars",
|
||||
kind=KIND_NUMBER,
|
||||
description="Max chars per message sent to Honcho.",
|
||||
placeholder="25000",
|
||||
group="Limits",
|
||||
),
|
||||
# — Observation —
|
||||
ProviderField(
|
||||
key="observationMode",
|
||||
label="Observation mode",
|
||||
kind=KIND_SELECT,
|
||||
default="directional",
|
||||
description="Per-peer observation preset. Directional observes all directions; unified shares one view.",
|
||||
options=(
|
||||
ProviderFieldOption("directional", "Directional"),
|
||||
ProviderFieldOption("unified", "Unified"),
|
||||
),
|
||||
group="Observation",
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,640 @@
|
||||
"""OAuth credential storage and refresh for the Honcho memory provider.
|
||||
|
||||
An access token authenticates exactly like a scoped API key, so it is stored
|
||||
as the host's ``apiKey``; this module exchanges the refresh token before
|
||||
expiry to keep it live.
|
||||
|
||||
Refresh tokens rotate with single-use reuse detection: a replayed stale token
|
||||
revokes the whole grant. So every refresh must persist the rotated token
|
||||
atomically and be serialized. A failed exchange never raises into the agent:
|
||||
transient failures retry once immediately (the server re-rotates a replayed
|
||||
refresh token only within a short grace window, so waiting for the next
|
||||
memory call is too late), and a permanent OAuth error such as invalid_grant
|
||||
marks the grant dead so nothing keeps hitting the token endpoint — callers
|
||||
surface a re-login prompt instead. A server-side 401 on a locally-valid
|
||||
token is recovered via ``force_refresh_token``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
ACCESS_TOKEN_PREFIX = "hch-at-"
|
||||
REFRESH_TOKEN_PREFIX = "hch-rt-"
|
||||
|
||||
# Refresh this many seconds before the access token actually expires, so an
|
||||
# in-flight request never races the expiry boundary.
|
||||
_REFRESH_SKEW_SECONDS = 120
|
||||
|
||||
# Default HTTP timeout for the token exchange. Kept short — the refresh happens
|
||||
# on the path to a memory call, and a stalled auth server must not hang it.
|
||||
_REFRESH_TIMEOUT_SECONDS = 15.0
|
||||
|
||||
# Retry pause, kept short: the server honors a replayed refresh token only briefly after rotating it.
|
||||
_REFRESH_RETRY_DELAY_SECONDS = 2.0
|
||||
|
||||
# Total wall-clock budget for one exchange cycle (first attempt + pause + retry).
|
||||
# The exchange runs while holding the global refresh locks on the path to a
|
||||
# memory call, so a stalled token endpoint must not hold them for two full
|
||||
# HTTP timeouts back to back.
|
||||
_REFRESH_TOTAL_BUDGET_SECONDS = 20.0
|
||||
|
||||
# After a transient exchange failure, fail open without re-exchanging for this
|
||||
# long. Prevents N waiting threads (or turns) from serializing N full exchange
|
||||
# cycles against an endpoint that just failed.
|
||||
_REFRESH_FAILURE_COOLDOWN_SECONDS = 30.0
|
||||
|
||||
# OAuth error codes that a retry can never fix — the grant itself is dead.
|
||||
_PERMANENT_OAUTH_ERRORS = frozenset({"invalid_grant", "invalid_client", "unauthorized_client"})
|
||||
|
||||
# Token values are secret even though their prefixes are not; redact before logging.
|
||||
# Derived from the canonical prefixes above so a prefix change can't silently
|
||||
# break redaction.
|
||||
_TOKEN_VALUE_RE = re.compile(
|
||||
rf"({re.escape(ACCESS_TOKEN_PREFIX)}|{re.escape(REFRESH_TOKEN_PREFIX)})[A-Za-z0-9._~+/=-]+"
|
||||
)
|
||||
|
||||
|
||||
def redact_tokens(text: str) -> str:
|
||||
"""Replace any embedded token values with their prefix plus a placeholder."""
|
||||
return _TOKEN_VALUE_RE.sub(lambda m: f"{m.group(1)}[redacted]", text)
|
||||
|
||||
|
||||
# Backward-compat alias for oauth-internal call sites and older importers.
|
||||
_redact_tokens = redact_tokens
|
||||
|
||||
|
||||
class OAuthRefreshError(Exception):
|
||||
"""Token endpoint rejected the refresh. ``permanent`` means re-login is required."""
|
||||
|
||||
def __init__(self, message: str, *, error: str = "", permanent: bool = False):
|
||||
super().__init__(message)
|
||||
self.error = error
|
||||
self.permanent = permanent
|
||||
|
||||
# Serializes refresh across threads sharing one process's config. Re-checked
|
||||
# under the lock (double-checked) so racing callers don't replay a rotated
|
||||
# refresh token and trip reuse detection.
|
||||
_refresh_lock = threading.Lock()
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _config_refresh_lock(path: Path):
|
||||
"""Machine-wide advisory lock around read-refresh-persist.
|
||||
|
||||
The in-process ``_refresh_lock`` can't stop a second process (a sibling
|
||||
Hermes profile or the desktop app sharing this honcho.json) from replaying
|
||||
the single-use refresh token and tripping reuse-detection — which revokes
|
||||
the whole grant. An OS file lock on ``<config>.lock`` serializes rotation
|
||||
across processes; best-effort, so a platform without flock degrades to
|
||||
in-process serialization only.
|
||||
"""
|
||||
lock_path = Path(f"{path}.lock")
|
||||
fh = None
|
||||
try:
|
||||
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
fh = open(lock_path, "a+b")
|
||||
if os.name == "nt":
|
||||
import msvcrt
|
||||
|
||||
fh.seek(0)
|
||||
msvcrt.locking(fh.fileno(), msvcrt.LK_LOCK, 1)
|
||||
else:
|
||||
import fcntl
|
||||
|
||||
fcntl.flock(fh.fileno(), fcntl.LOCK_EX)
|
||||
except Exception:
|
||||
logger.debug("Honcho OAuth cross-process lock unavailable; in-process only", exc_info=True)
|
||||
if fh is not None:
|
||||
fh.close()
|
||||
fh = None
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
if fh is not None:
|
||||
try:
|
||||
if os.name == "nt":
|
||||
import msvcrt
|
||||
|
||||
fh.seek(0)
|
||||
msvcrt.locking(fh.fileno(), msvcrt.LK_UNLCK, 1)
|
||||
else:
|
||||
import fcntl
|
||||
|
||||
fcntl.flock(fh.fileno(), fcntl.LOCK_UN)
|
||||
except Exception:
|
||||
pass
|
||||
fh.close()
|
||||
|
||||
# In-memory expiry cache keyed by (config path, host) → (expires_at, access).
|
||||
# Lets the hot path (every memory access calls this) skip the honcho.json read
|
||||
# while the token is comfortably live; disk is only touched near expiry, on a
|
||||
# cache miss, or when an explicit ``raw`` is supplied. Single-key dict ops are
|
||||
# atomic under the GIL, so no separate lock is needed. An access token stays
|
||||
# valid until its own expiry regardless of out-of-band rotation, so a stale
|
||||
# cache entry can't break auth — it just defers picking up external changes
|
||||
# until the token nears expiry and disk is read again.
|
||||
_expiry_cache: dict[tuple[str, str], tuple[float, str]] = {}
|
||||
|
||||
# Permanently rejected grants: (config path, host) → sha256 of the dead refresh token; a re-login rotates the token, so the digest check self-clears.
|
||||
_dead_grants: dict[tuple[str, str], str] = {}
|
||||
|
||||
# Last transient exchange failure per grant: key → monotonic timestamp. While
|
||||
# inside the cooldown window callers fail open to the stale token without
|
||||
# re-exchanging, so waiting threads don't serialize repeated full exchange
|
||||
# cycles against an endpoint that just failed.
|
||||
_refresh_failure_at: dict[tuple[str, str], float] = {}
|
||||
|
||||
|
||||
def _in_failure_cooldown(key: tuple[str, str]) -> bool:
|
||||
failed_at = _refresh_failure_at.get(key)
|
||||
return (
|
||||
failed_at is not None
|
||||
and (time.monotonic() - failed_at) < _REFRESH_FAILURE_COOLDOWN_SECONDS
|
||||
)
|
||||
|
||||
|
||||
# Memoized reauth_required verdict per grant: key → (config mtime_ns, result).
|
||||
# The verdict only changes when the config file is rewritten (re-login), so an
|
||||
# unchanged mtime short-circuits the read+parse on the dead-grant hot path.
|
||||
_reauth_check_cache: dict[tuple[str, str], tuple[int, bool]] = {}
|
||||
|
||||
|
||||
def _refresh_token_digest(cred: OAuthCredential) -> str:
|
||||
return hashlib.sha256(cred.refresh_token.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _grant_is_dead(key: tuple[str, str], cred: OAuthCredential) -> bool:
|
||||
return _dead_grants.get(key) == _refresh_token_digest(cred)
|
||||
|
||||
|
||||
def _mark_grant_dead(key: tuple[str, str], cred: OAuthCredential) -> None:
|
||||
_dead_grants[key] = _refresh_token_digest(cred)
|
||||
# The verdict changed without a config rewrite; drop any memoized answer.
|
||||
_reauth_check_cache.pop(key, None)
|
||||
|
||||
|
||||
def reauth_required(path: Path, host: str) -> bool:
|
||||
"""True when ``host``'s OAuth grant is dead and only a new login fixes it."""
|
||||
key = (str(path), host)
|
||||
if key not in _dead_grants:
|
||||
return False
|
||||
# A re-login rewrites the config file, so gate the read+parse on mtime:
|
||||
# while the file is unchanged the answer cannot change.
|
||||
try:
|
||||
mtime = path.stat().st_mtime_ns
|
||||
except OSError:
|
||||
mtime = -1
|
||||
cached = _reauth_check_cache.get(key)
|
||||
if cached is not None and cached[0] == mtime:
|
||||
return cached[1]
|
||||
block = (_read_config(path).get("hosts") or {}).get(host) or {}
|
||||
cred = OAuthCredential.from_host_block(block)
|
||||
result = cred is not None and _grant_is_dead(key, cred)
|
||||
_reauth_check_cache[key] = (mtime, result)
|
||||
return result
|
||||
|
||||
|
||||
def any_dead_grants() -> bool:
|
||||
"""Cheap predicate: has any grant in this process been marked dead?
|
||||
|
||||
Lets hot-path callers skip config-path resolution entirely in the
|
||||
overwhelmingly common healthy state.
|
||||
"""
|
||||
return bool(_dead_grants)
|
||||
|
||||
|
||||
def is_oauth_access_token(value: str | None) -> bool:
|
||||
"""True when ``value`` is an OAuth access token (vs a static API key)."""
|
||||
return bool(value) and value.startswith(ACCESS_TOKEN_PREFIX)
|
||||
|
||||
|
||||
@dataclass
|
||||
class OAuthCredential:
|
||||
"""An OAuth grant as stored in a honcho.json host block.
|
||||
|
||||
``access_token`` mirrors the host's ``apiKey``; the remaining fields live in
|
||||
the host's ``oauth`` sub-block. ``expires_at`` is absolute epoch seconds.
|
||||
"""
|
||||
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
expires_at: float
|
||||
client_id: str
|
||||
token_endpoint: str
|
||||
scope: str = "write"
|
||||
token_type: str = "Bearer"
|
||||
# Transient consent peer name — set only on a fresh grant, never persisted.
|
||||
consent_peer_name: str | None = None
|
||||
|
||||
@classmethod
|
||||
def from_host_block(cls, block: dict[str, Any]) -> "OAuthCredential | None":
|
||||
"""Build a credential from a honcho.json host block, or None if incomplete."""
|
||||
oauth = block.get("oauth")
|
||||
access = block.get("apiKey")
|
||||
if not isinstance(oauth, dict) or not is_oauth_access_token(access):
|
||||
return None
|
||||
refresh = oauth.get("refreshToken")
|
||||
endpoint = oauth.get("tokenEndpoint")
|
||||
client_id = oauth.get("clientId")
|
||||
if not (refresh and endpoint and client_id):
|
||||
return None
|
||||
try:
|
||||
expires_at = float(oauth.get("expiresAt", 0))
|
||||
except (TypeError, ValueError):
|
||||
expires_at = 0.0
|
||||
return cls(
|
||||
access_token=access,
|
||||
refresh_token=str(refresh),
|
||||
expires_at=expires_at,
|
||||
client_id=str(client_id),
|
||||
token_endpoint=str(endpoint),
|
||||
scope=str(oauth.get("scope", "write")),
|
||||
token_type=str(oauth.get("tokenType", "Bearer")),
|
||||
)
|
||||
|
||||
def oauth_block(self) -> dict[str, Any]:
|
||||
"""The ``oauth`` sub-block to persist (the access token lives in apiKey)."""
|
||||
return {
|
||||
"refreshToken": self.refresh_token,
|
||||
"expiresAt": int(self.expires_at),
|
||||
"clientId": self.client_id,
|
||||
"tokenEndpoint": self.token_endpoint,
|
||||
"scope": self.scope,
|
||||
"tokenType": self.token_type,
|
||||
}
|
||||
|
||||
def is_expired(self, *, now: float, skew: float = _REFRESH_SKEW_SECONDS) -> bool:
|
||||
"""True when the access token is within ``skew`` seconds of expiry."""
|
||||
return now >= (self.expires_at - skew)
|
||||
|
||||
|
||||
# Indirection so tests can drive the exchange without a live server.
|
||||
def _http_post_form(url: str, data: dict[str, str], timeout: float) -> dict[str, Any]:
|
||||
"""POST form-encoded ``data`` to ``url`` and return the parsed JSON body."""
|
||||
import httpx
|
||||
|
||||
resp = httpx.post(url, data=data, timeout=timeout)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
def _http_post_form_status(
|
||||
url: str, data: dict[str, str], timeout: float
|
||||
) -> tuple[int, dict[str, Any]]:
|
||||
"""POST form-encoded ``data``; return ``(status, parsed JSON body)``.
|
||||
|
||||
Unlike ``_http_post_form``, 4xx does not raise — RFC 8628 polling reads the
|
||||
OAuth error body off a 400. A non-JSON body parses to ``{}``.
|
||||
"""
|
||||
import httpx
|
||||
|
||||
resp = httpx.post(url, data=data, timeout=timeout)
|
||||
try:
|
||||
body = resp.json()
|
||||
except ValueError:
|
||||
body = {}
|
||||
if not isinstance(body, dict):
|
||||
body = {}
|
||||
return resp.status_code, body
|
||||
|
||||
|
||||
def _http_get_json(url: str, timeout: float) -> dict[str, Any]:
|
||||
"""GET ``url`` and return the parsed JSON body. Raises on non-2xx/non-JSON."""
|
||||
import httpx
|
||||
|
||||
resp = httpx.get(url, timeout=timeout)
|
||||
resp.raise_for_status()
|
||||
body = resp.json()
|
||||
return body if isinstance(body, dict) else {}
|
||||
|
||||
|
||||
def _exchange_refresh_token(
|
||||
cred: OAuthCredential, *, now: float, timeout: float = _REFRESH_TIMEOUT_SECONDS
|
||||
) -> OAuthCredential:
|
||||
"""Run the refresh_token grant and return the rotated credential.
|
||||
|
||||
Raises ``OAuthRefreshError`` (with the endpoint's error body) on an error
|
||||
response, transport errors as-is; callers fail open.
|
||||
"""
|
||||
status, body = _http_post_form_status(
|
||||
cred.token_endpoint,
|
||||
{
|
||||
"grant_type": "refresh_token",
|
||||
"client_id": cred.client_id,
|
||||
"refresh_token": cred.refresh_token,
|
||||
},
|
||||
timeout,
|
||||
)
|
||||
if status >= 400:
|
||||
error = str(body.get("error") or "")
|
||||
description = str(body.get("error_description") or "")
|
||||
detail = " — ".join(p for p in (error, description) if p) or "no error body"
|
||||
raise OAuthRefreshError(
|
||||
_redact_tokens(f"token endpoint returned HTTP {status}: {detail}"),
|
||||
error=error,
|
||||
permanent=error in _PERMANENT_OAUTH_ERRORS,
|
||||
)
|
||||
access = body.get("access_token")
|
||||
refresh = body.get("refresh_token")
|
||||
if not is_oauth_access_token(access) or not refresh:
|
||||
raise ValueError("refresh response missing access_token/refresh_token")
|
||||
try:
|
||||
expires_in = int(body.get("expires_in", 0))
|
||||
except (TypeError, ValueError):
|
||||
expires_in = 0
|
||||
return OAuthCredential(
|
||||
access_token=access,
|
||||
refresh_token=str(refresh),
|
||||
expires_at=now + expires_in,
|
||||
client_id=cred.client_id,
|
||||
token_endpoint=cred.token_endpoint,
|
||||
scope=str(body.get("scope", cred.scope)),
|
||||
token_type=str(body.get("token_type", cred.token_type)),
|
||||
)
|
||||
|
||||
|
||||
def _exchange_with_retry(cred: OAuthCredential, *, now: float) -> OAuthCredential:
|
||||
"""Exchange the refresh token, retrying once on transient failure.
|
||||
|
||||
The server accepts a replayed token only briefly after rotating it, so the
|
||||
retry cannot wait — and the whole cycle is capped by
|
||||
``_REFRESH_TOTAL_BUDGET_SECONDS`` because it runs under the global refresh
|
||||
locks: a fast first failure gets a full-timeout retry, a slow (timed-out)
|
||||
first attempt gets only the remaining budget.
|
||||
"""
|
||||
deadline = time.monotonic() + _REFRESH_TOTAL_BUDGET_SECONDS
|
||||
try:
|
||||
return _exchange_refresh_token(cred, now=now)
|
||||
except OAuthRefreshError as exc:
|
||||
if exc.permanent:
|
||||
raise
|
||||
first: Exception = exc
|
||||
except Exception as exc:
|
||||
first = exc
|
||||
remaining = deadline - time.monotonic() - _REFRESH_RETRY_DELAY_SECONDS
|
||||
if remaining <= 0:
|
||||
raise first
|
||||
logger.warning(
|
||||
"Honcho OAuth token exchange failed, retrying once: %s",
|
||||
_redact_tokens(str(first)),
|
||||
)
|
||||
time.sleep(_REFRESH_RETRY_DELAY_SECONDS)
|
||||
return _exchange_refresh_token(
|
||||
cred, now=now, timeout=min(remaining, _REFRESH_TIMEOUT_SECONDS)
|
||||
)
|
||||
|
||||
|
||||
def _rotate_and_persist(
|
||||
path: Path,
|
||||
host: str,
|
||||
key: tuple[str, str],
|
||||
cred: OAuthCredential,
|
||||
*,
|
||||
now: float,
|
||||
op_label: str = "refresh",
|
||||
) -> OAuthCredential | None:
|
||||
"""Exchange ``cred`` and persist the rotation; ``None`` on failure (logged).
|
||||
|
||||
A permanent OAuth error marks the grant dead so later calls skip the
|
||||
endpoint until a new login rotates the refresh token.
|
||||
"""
|
||||
try:
|
||||
rotated = _exchange_with_retry(cred, now=now)
|
||||
except OAuthRefreshError as exc:
|
||||
if exc.permanent:
|
||||
_mark_grant_dead(key, cred)
|
||||
logger.error(
|
||||
"Honcho OAuth grant for host %s is no longer valid (%s); "
|
||||
"run 'hermes honcho setup' to re-authenticate", host, exc,
|
||||
)
|
||||
else:
|
||||
_refresh_failure_at[key] = time.monotonic()
|
||||
logger.warning("Honcho OAuth %s failed for host %s: %s", op_label, host, exc)
|
||||
return None
|
||||
except Exception as exc:
|
||||
_refresh_failure_at[key] = time.monotonic()
|
||||
logger.warning(
|
||||
"Honcho OAuth %s failed for host %s: %s",
|
||||
op_label, host, _redact_tokens(str(exc)),
|
||||
)
|
||||
return None
|
||||
_persist_credential(path, host, rotated)
|
||||
return rotated
|
||||
|
||||
|
||||
def _read_config(path: Path) -> dict[str, Any]:
|
||||
try:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return {}
|
||||
|
||||
|
||||
def _atomic_write_config(path: Path, raw: dict[str, Any]) -> None:
|
||||
"""Write ``raw`` to ``path`` atomically, preserving 0600 on the new file."""
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = path.with_name(f".{path.name}.tmp")
|
||||
text = json.dumps(raw, indent=2) + "\n"
|
||||
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
||||
fh.write(text)
|
||||
except Exception:
|
||||
tmp.unlink(missing_ok=True)
|
||||
raise
|
||||
os.replace(tmp, path)
|
||||
|
||||
|
||||
def _deep_merge(base: dict[str, Any], overlay: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Recursively merge ``overlay`` into ``base`` (overlay wins on scalars/lists)."""
|
||||
for key, value in overlay.items():
|
||||
if isinstance(value, dict) and isinstance(base.get(key), dict):
|
||||
_deep_merge(base[key], value)
|
||||
else:
|
||||
base[key] = value
|
||||
return base
|
||||
|
||||
|
||||
def _persist_credential(path: Path, host: str, cred: OAuthCredential) -> None:
|
||||
"""Persist ``cred`` into ``host``'s block (apiKey + oauth), leaving all else intact."""
|
||||
raw = _read_config(path)
|
||||
hosts = raw.setdefault("hosts", {})
|
||||
block = hosts.setdefault(host, {})
|
||||
block["apiKey"] = cred.access_token
|
||||
block["oauth"] = cred.oauth_block()
|
||||
_atomic_write_config(path, raw)
|
||||
_expiry_cache[(str(path), host)] = (cred.expires_at, cred.access_token)
|
||||
_dead_grants.pop((str(path), host), None)
|
||||
_refresh_failure_at.pop((str(path), host), None)
|
||||
|
||||
|
||||
def ensure_fresh_token(
|
||||
path: Path,
|
||||
host: str,
|
||||
raw: dict[str, Any] | None = None,
|
||||
*,
|
||||
now: float | None = None,
|
||||
) -> tuple[str | None, bool]:
|
||||
"""Return ``(access_token, refreshed)`` for ``host``, refreshing if near expiry.
|
||||
|
||||
Returns ``(None, False)`` when the host has no OAuth credential (e.g. a plain
|
||||
API key) so callers leave the existing token untouched. Refresh failures are
|
||||
swallowed: the current (possibly stale) token is returned with
|
||||
``refreshed=False``, transient failures retry once immediately, and a
|
||||
permanently rejected grant is marked dead so later calls skip the endpoint.
|
||||
The 401 recovery in session.py escalates dead grants to the user.
|
||||
"""
|
||||
now = time.time() if now is None else now
|
||||
key = (str(path), host)
|
||||
|
||||
# Hot path: trust the cached expiry while the token is well clear of the
|
||||
# skew window — no disk read. Bypassed when an explicit ``raw`` is supplied.
|
||||
if raw is None:
|
||||
cached = _expiry_cache.get(key)
|
||||
if cached is not None and now < cached[0] - _REFRESH_SKEW_SECONDS:
|
||||
return cached[1], False
|
||||
|
||||
source = raw if raw is not None else _read_config(path)
|
||||
block = (source.get("hosts") or {}).get(host) or {}
|
||||
cred = OAuthCredential.from_host_block(block)
|
||||
if cred is None:
|
||||
_expiry_cache.pop(key, None)
|
||||
return None, False
|
||||
|
||||
_expiry_cache[key] = (cred.expires_at, cred.access_token)
|
||||
if not cred.is_expired(now=now):
|
||||
return cred.access_token, False
|
||||
if _in_failure_cooldown(key):
|
||||
# An exchange just failed transiently; don't pile on the endpoint.
|
||||
return cred.access_token, False
|
||||
|
||||
with _refresh_lock, _config_refresh_lock(path):
|
||||
# Re-read under both locks: another thread or process may have just
|
||||
# rotated the token — adopt theirs instead of replaying the old one.
|
||||
fresh_block = (_read_config(path).get("hosts") or {}).get(host) or {}
|
||||
current = OAuthCredential.from_host_block(fresh_block) or cred
|
||||
if not current.is_expired(now=now):
|
||||
return current.access_token, current.access_token != cred.access_token
|
||||
if _grant_is_dead(key, current):
|
||||
return current.access_token, False
|
||||
if _in_failure_cooldown(key):
|
||||
# The lock holder we waited on just failed; fail open too.
|
||||
return current.access_token, False
|
||||
rotated = _rotate_and_persist(path, host, key, current, now=now)
|
||||
if rotated is None:
|
||||
return current.access_token, False
|
||||
logger.info("Honcho OAuth token refreshed for host %s", host)
|
||||
return rotated.access_token, True
|
||||
|
||||
|
||||
def force_refresh_token(path: Path, host: str) -> str | None:
|
||||
"""Rotate ``host``'s access token now, ignoring local expiry.
|
||||
|
||||
Recovers a 401 on a token the local clock still thinks is valid.
|
||||
"""
|
||||
now = time.time()
|
||||
key = (str(path), host)
|
||||
with _refresh_lock, _config_refresh_lock(path):
|
||||
block = (_read_config(path).get("hosts") or {}).get(host) or {}
|
||||
cred = OAuthCredential.from_host_block(block)
|
||||
if cred is None:
|
||||
_expiry_cache.pop(key, None)
|
||||
return None
|
||||
if _grant_is_dead(key, cred):
|
||||
return None
|
||||
if _in_failure_cooldown(key):
|
||||
# An exchange just failed transiently; don't force another full
|
||||
# cycle — callers fail open and retry after the cooldown.
|
||||
return None
|
||||
cached = _expiry_cache.get(key)
|
||||
# Another thread or process already rotated: adopt the newer on-disk token.
|
||||
if cached is not None and cred.access_token != cached[1] and not cred.is_expired(now=now):
|
||||
_expiry_cache[key] = (cred.expires_at, cred.access_token)
|
||||
return cred.access_token
|
||||
rotated = _rotate_and_persist(path, host, key, cred, now=now, op_label="forced refresh")
|
||||
if rotated is None:
|
||||
return None
|
||||
logger.info("Honcho OAuth token force-refreshed for host %s after an auth failure", host)
|
||||
return rotated.access_token
|
||||
|
||||
|
||||
def install_grant(
|
||||
path: Path,
|
||||
host: str,
|
||||
grant: dict[str, Any],
|
||||
*,
|
||||
client_id: str,
|
||||
token_endpoint: str,
|
||||
apply_config: bool = True,
|
||||
now: float | None = None,
|
||||
) -> OAuthCredential:
|
||||
"""Apply a fresh OAuth grant to ``path`` for ``host``.
|
||||
|
||||
Deep-merges the grant's ``config`` (the manifest default_config) into the
|
||||
file root — preserving other hosts and root keys — then writes the host's
|
||||
``apiKey`` and ``oauth`` block. ``grant`` is an OAuthTokenResponse dict
|
||||
(access_token, refresh_token, expires_in, scope, config).
|
||||
``apply_config=False`` skips the config merge and stores tokens only.
|
||||
"""
|
||||
now = time.time() if now is None else now
|
||||
access = grant.get("access_token")
|
||||
refresh = grant.get("refresh_token")
|
||||
if not is_oauth_access_token(access) or not refresh:
|
||||
raise ValueError("grant missing access_token/refresh_token")
|
||||
try:
|
||||
expires_in = int(grant.get("expires_in", 0))
|
||||
except (TypeError, ValueError):
|
||||
expires_in = 0
|
||||
|
||||
cred = OAuthCredential(
|
||||
access_token=access,
|
||||
refresh_token=str(refresh),
|
||||
expires_at=now + expires_in,
|
||||
client_id=client_id,
|
||||
token_endpoint=token_endpoint,
|
||||
scope=str(grant.get("scope", "write")),
|
||||
token_type=str(grant.get("token_type", "Bearer")),
|
||||
)
|
||||
|
||||
raw = _read_config(path)
|
||||
granted_config = grant.get("config")
|
||||
if isinstance(granted_config, dict):
|
||||
cred.consent_peer_name = granted_config.get("peerName")
|
||||
if apply_config:
|
||||
_deep_merge(raw, granted_config)
|
||||
_expiry_cache[(str(path), host)] = (cred.expires_at, cred.access_token)
|
||||
_dead_grants.pop((str(path), host), None)
|
||||
_refresh_failure_at.pop((str(path), host), None)
|
||||
hosts = raw.setdefault("hosts", {})
|
||||
block = hosts.setdefault(host, {})
|
||||
block["apiKey"] = cred.access_token
|
||||
block["oauth"] = cred.oauth_block()
|
||||
_atomic_write_config(path, raw)
|
||||
return cred
|
||||
|
||||
|
||||
def apply_token_to_client(client: Any, token: str) -> bool:
|
||||
"""Rotate the live Honcho client's Bearer in place. Returns success.
|
||||
|
||||
The SDK builds its auth header per request from the HTTP client's
|
||||
``api_key``, so mutating it rotates every holder of the singleton without a
|
||||
rebuild. Guarded: an SDK shape change degrades to False and the caller can
|
||||
fall back to resetting the client.
|
||||
"""
|
||||
http = getattr(client, "_http", None)
|
||||
if http is None or not hasattr(http, "api_key"):
|
||||
return False
|
||||
http.api_key = token
|
||||
return True
|
||||
@@ -0,0 +1,656 @@
|
||||
"""Browser sign-in flow for the Honcho memory provider — no CLI step.
|
||||
|
||||
``begin_authorization`` / ``complete_authorization`` are the transport-agnostic
|
||||
core: the code can arrive via the loopback listener here or a future
|
||||
``hermes://`` handler. Endpoints are env-overridable with local-dev defaults
|
||||
because ``/authorize`` (dashboard) and ``/oauth/token`` (API) live on
|
||||
different origins.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
from urllib.parse import parse_qs, urlencode, urlparse
|
||||
|
||||
from plugins.memory.honcho import oauth
|
||||
from plugins.memory.honcho.client import resolve_active_host, resolve_config_path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# The loopback redirect registered for the Hermes OAuth client. IP-literal so
|
||||
# the browser can't resolve the advertised host to ::1 and miss the IPv4 bind.
|
||||
LOOPBACK_HOST = "127.0.0.1"
|
||||
LOOPBACK_PORT = 8765
|
||||
LOOPBACK_REDIRECT_URI = f"http://{LOOPBACK_HOST}:{LOOPBACK_PORT}/callback"
|
||||
|
||||
# Pending authorizations live only until their callback returns; keyed by the
|
||||
# CSRF ``state`` so a stray/forged callback can't complete a grant.
|
||||
_PENDING_TTL_SECONDS = 600
|
||||
|
||||
|
||||
def _display_config_path(path: object) -> str:
|
||||
"""Home-relative display string for the consent screen.
|
||||
|
||||
The absolute path (username + home layout) never leaves the machine — it's
|
||||
only shown to the user. Collapse ``$HOME`` to ``~``; for a path outside
|
||||
home, send the bare filename rather than leak an arbitrary absolute path.
|
||||
"""
|
||||
from pathlib import Path as _Path
|
||||
|
||||
p = _Path(str(path))
|
||||
try:
|
||||
return "~/" + str(p.relative_to(_Path.home()))
|
||||
except ValueError:
|
||||
return p.name
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OAuthEndpoints:
|
||||
"""Resolved authorization-server URLs and client identity."""
|
||||
|
||||
authorize_url: str # dashboard /authorize
|
||||
token_url: str # API /oauth/token
|
||||
client_id: str
|
||||
scope: str
|
||||
device_authorization_url: str = "" # API /oauth/device_authorization
|
||||
|
||||
|
||||
# Cloud (production) hosts; dashboard serves /authorize, API serves /oauth/token.
|
||||
_CLOUD_DASHBOARD = "https://app.honcho.dev"
|
||||
_CLOUD_TOKEN_URL = "https://api.honcho.dev/oauth/token"
|
||||
_LOCAL_DASHBOARD = "http://localhost:3000"
|
||||
_LOCAL_TOKEN_URL = "http://localhost:8000/oauth/token"
|
||||
|
||||
# One OAuth client for every surface. Consent branding/UI adapt via the
|
||||
# ``source`` query param (not a separate client_id), so there's a single grant
|
||||
# identity to refresh — no clientId-vs-refresh-token desync to revoke the grant.
|
||||
_DEFAULT_CLIENT_ID = "hermes-agent"
|
||||
|
||||
|
||||
def _is_loopback_url(url: str | None) -> bool:
|
||||
return bool(url) and any(h in url for h in ("localhost", "127.0.0.1", "::1"))
|
||||
|
||||
|
||||
def resolve_endpoints(
|
||||
environment: str | None = None, base_url: str | None = None
|
||||
) -> OAuthEndpoints:
|
||||
"""Resolve OAuth endpoints, zero-config by default.
|
||||
|
||||
Keys off the host's honcho ``environment`` (production → cloud, local →
|
||||
localhost); a self-hosted ``base_url`` derives the token endpoint from the
|
||||
API host. Env vars override every field for unusual deployments.
|
||||
"""
|
||||
if environment is None or base_url is None:
|
||||
try:
|
||||
from plugins.memory.honcho.client import HonchoClientConfig
|
||||
|
||||
cfg = HonchoClientConfig.from_global_config()
|
||||
environment = environment or cfg.environment
|
||||
base_url = base_url if base_url is not None else cfg.base_url
|
||||
except Exception:
|
||||
environment = environment or "production"
|
||||
|
||||
is_local = (environment or "").lower() == "local" or _is_loopback_url(base_url)
|
||||
default_dashboard = _LOCAL_DASHBOARD if is_local else _CLOUD_DASHBOARD
|
||||
default_token = _LOCAL_TOKEN_URL if is_local else _CLOUD_TOKEN_URL
|
||||
# Self-hosted API (non-loopback base_url): token rides the same host.
|
||||
if base_url and not is_local:
|
||||
default_token = f"{base_url.rstrip('/')}/oauth/token"
|
||||
|
||||
dashboard = os.environ.get("HONCHO_OAUTH_DASHBOARD", default_dashboard).rstrip("/")
|
||||
token_url = os.environ.get("HONCHO_OAUTH_TOKEN_URL", default_token)
|
||||
# Device authorization rides the token endpoint's origin.
|
||||
default_device = f"{token_url.rsplit('/', 1)[0]}/device_authorization"
|
||||
return OAuthEndpoints(
|
||||
authorize_url=os.environ.get("HONCHO_OAUTH_AUTHORIZE_URL", f"{dashboard}/authorize"),
|
||||
token_url=token_url,
|
||||
client_id=os.environ.get("HONCHO_OAUTH_CLIENT_ID", _DEFAULT_CLIENT_ID),
|
||||
scope=os.environ.get("HONCHO_OAUTH_SCOPE", "write"),
|
||||
device_authorization_url=os.environ.get("HONCHO_OAUTH_DEVICE_AUTH_URL", default_device),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Pending:
|
||||
verifier: str
|
||||
redirect_uri: str
|
||||
created_at: float
|
||||
|
||||
|
||||
_pending: dict[str, _Pending] = {}
|
||||
_pending_lock = threading.Lock()
|
||||
|
||||
|
||||
def _pkce() -> tuple[str, str]:
|
||||
"""Return (verifier, S256 challenge) for an authorization-code request."""
|
||||
verifier = secrets.token_urlsafe(64)
|
||||
challenge = (
|
||||
base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest())
|
||||
.rstrip(b"=")
|
||||
.decode()
|
||||
)
|
||||
return verifier, challenge
|
||||
|
||||
|
||||
def _prune_pending(now: float) -> None:
|
||||
expired = [s for s, p in _pending.items() if now - p.created_at > _PENDING_TTL_SECONDS]
|
||||
for state in expired:
|
||||
_pending.pop(state, None)
|
||||
|
||||
|
||||
def begin_authorization(
|
||||
endpoints: OAuthEndpoints,
|
||||
redirect_uri: str = LOOPBACK_REDIRECT_URI,
|
||||
*,
|
||||
source: str | None = None,
|
||||
config_path: str | None = None,
|
||||
now: float | None = None,
|
||||
) -> tuple[str, str]:
|
||||
"""Start an authorization: return ``(authorize_url, state)`` and stash PKCE.
|
||||
|
||||
``source`` tags the authorize link with the initiating surface
|
||||
(``hermes-desktop`` / ``hermes-cli``) so the consent side can attribute
|
||||
connects and vary behavior per surface. ``config_path`` is a home-relative
|
||||
*display* string for the consent screen (never the absolute path); callers
|
||||
pass the actual write path separately to ``complete_authorization``.
|
||||
"""
|
||||
now = time.time() if now is None else now
|
||||
verifier, challenge = _pkce()
|
||||
state = secrets.token_urlsafe(32)
|
||||
with _pending_lock:
|
||||
_prune_pending(now)
|
||||
_pending[state] = _Pending(verifier=verifier, redirect_uri=redirect_uri, created_at=now)
|
||||
params = {
|
||||
"client_id": endpoints.client_id,
|
||||
"redirect_uri": redirect_uri,
|
||||
"scope": endpoints.scope,
|
||||
"code_challenge": challenge,
|
||||
"code_challenge_method": "S256",
|
||||
"response_type": "code",
|
||||
"state": state,
|
||||
}
|
||||
if source:
|
||||
params["source"] = source
|
||||
if config_path:
|
||||
params["config_path"] = config_path
|
||||
return f"{endpoints.authorize_url}?{urlencode(params)}", state
|
||||
|
||||
|
||||
def complete_authorization(
|
||||
endpoints: OAuthEndpoints,
|
||||
code: str,
|
||||
state: str,
|
||||
*,
|
||||
config_path: Path | None = None,
|
||||
host: str | None = None,
|
||||
apply_config: bool = True,
|
||||
now: float | None = None,
|
||||
) -> oauth.OAuthCredential:
|
||||
"""Exchange ``code`` for a grant and persist it. Raises on bad state/exchange.
|
||||
|
||||
``apply_config=False`` stores the tokens only, skipping the grant's config
|
||||
block — the CLI path, where settings stay wizard-owned.
|
||||
"""
|
||||
with _pending_lock:
|
||||
pending = _pending.pop(state, None)
|
||||
if pending is None:
|
||||
raise ValueError("unknown or expired authorization state")
|
||||
|
||||
grant = oauth._http_post_form(
|
||||
endpoints.token_url,
|
||||
{
|
||||
"grant_type": "authorization_code",
|
||||
"client_id": endpoints.client_id,
|
||||
"code": code,
|
||||
"redirect_uri": pending.redirect_uri,
|
||||
"code_verifier": pending.verifier,
|
||||
},
|
||||
oauth._REFRESH_TIMEOUT_SECONDS,
|
||||
)
|
||||
|
||||
path = config_path or resolve_config_path()
|
||||
target_host = host or resolve_active_host()
|
||||
cred = oauth.install_grant(
|
||||
path,
|
||||
target_host,
|
||||
grant,
|
||||
client_id=endpoints.client_id,
|
||||
token_endpoint=endpoints.token_url,
|
||||
apply_config=apply_config,
|
||||
now=now,
|
||||
)
|
||||
# Drop the singleton so the next acquisition builds with the new token.
|
||||
from plugins.memory.honcho.client import reset_honcho_client
|
||||
|
||||
reset_honcho_client()
|
||||
logger.info("Honcho OAuth grant installed for host %s", target_host)
|
||||
return cred
|
||||
|
||||
|
||||
_CALLBACK_HTML = (
|
||||
b"<!doctype html><meta charset=utf-8>"
|
||||
b"<title>Honcho connected</title>"
|
||||
b"<body style='font:14px ui-monospace,monospace;background:#0b0e14;color:#c9d1d9;"
|
||||
b"display:flex;align-items:center;justify-content:center;height:100vh;margin:0'>"
|
||||
b"<div>Connected to Honcho. You can close this tab and return to Hermes.</div>"
|
||||
)
|
||||
|
||||
_CALLBACK_ERROR_HTML = (
|
||||
"<!doctype html><meta charset=utf-8>"
|
||||
"<title>Honcho sign-in failed</title>"
|
||||
"<body style='font:14px ui-monospace,monospace;background:#0b0e14;color:#c9d1d9;"
|
||||
"display:flex;align-items:center;justify-content:center;height:100vh;margin:0'>"
|
||||
"<div>Sign-in was not completed ({error}). You can close this tab and re-run setup.</div>"
|
||||
)
|
||||
|
||||
|
||||
def _bind_loopback_server() -> tuple[HTTPServer, dict[str, str]]:
|
||||
"""Bind the one-shot callback server, returning it and its capture dict.
|
||||
|
||||
Prefers :8765; if that's taken, falls back to an OS-assigned port. groudon's
|
||||
redirect matcher relaxes the port for loopback hosts, so the fallback still
|
||||
matches the seeded ``127.0.0.1`` redirect URI — the caller advertises the
|
||||
actual bound port.
|
||||
"""
|
||||
captured: dict[str, str] = {}
|
||||
|
||||
class _Handler(BaseHTTPRequestHandler):
|
||||
def do_GET(self): # noqa: N802 - stdlib API name
|
||||
parsed = urlparse(self.path)
|
||||
if parsed.path != "/callback":
|
||||
self.send_response(404)
|
||||
self.end_headers()
|
||||
return
|
||||
params = parse_qs(parsed.query)
|
||||
captured["code"] = (params.get("code") or [""])[0]
|
||||
captured["state"] = (params.get("state") or [""])[0]
|
||||
captured["error"] = (params.get("error") or [""])[0]
|
||||
captured["error_description"] = (params.get("error_description") or [""])[0]
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/html; charset=utf-8")
|
||||
self.end_headers()
|
||||
if captured["error"]:
|
||||
import html as _html
|
||||
|
||||
page = _CALLBACK_ERROR_HTML.format(error=_html.escape(captured["error"]))
|
||||
self.wfile.write(page.encode("utf-8"))
|
||||
else:
|
||||
self.wfile.write(_CALLBACK_HTML)
|
||||
|
||||
def log_message(self, *args): # silence stdlib request logging
|
||||
return
|
||||
|
||||
try:
|
||||
server = HTTPServer((LOOPBACK_HOST, LOOPBACK_PORT), _Handler)
|
||||
except OSError:
|
||||
server = HTTPServer((LOOPBACK_HOST, 0), _Handler) # OS-assigned fallback
|
||||
return server, captured
|
||||
|
||||
|
||||
def capture_loopback_code(
|
||||
server: HTTPServer, captured: dict[str, str], *, timeout: float = 300.0
|
||||
) -> tuple[str, str]:
|
||||
"""Serve a single ``/callback`` GET on ``server`` and return ``(code, state)``.
|
||||
|
||||
Replies with a close-this-tab page, then stops. Raises ``TimeoutError`` if no
|
||||
callback arrives within ``timeout``.
|
||||
"""
|
||||
server.timeout = timeout
|
||||
try:
|
||||
# handle_request honors server.timeout; loop until our callback lands so a
|
||||
# stray probe to another path doesn't end the wait empty-handed.
|
||||
deadline = time.monotonic() + timeout
|
||||
while "code" not in captured and time.monotonic() < deadline:
|
||||
server.handle_request()
|
||||
finally:
|
||||
server.server_close()
|
||||
|
||||
if captured.get("error"):
|
||||
detail = captured.get("error_description")
|
||||
suffix = f" ({detail})" if detail else ""
|
||||
raise ValueError(f"authorization denied: {captured['error']}{suffix}")
|
||||
if "code" not in captured:
|
||||
raise TimeoutError("no OAuth callback received before timeout")
|
||||
return captured["code"], captured.get("state", "")
|
||||
|
||||
|
||||
def authorize_via_loopback(
|
||||
*,
|
||||
config_path: Path | None = None,
|
||||
host: str | None = None,
|
||||
source: str | None = None,
|
||||
apply_config: bool = True,
|
||||
open_url: Callable[[str], None] | None = None,
|
||||
timeout: float = 300.0,
|
||||
) -> oauth.OAuthCredential:
|
||||
"""Drive the full loopback flow: open browser → capture code → exchange → persist.
|
||||
|
||||
``open_url`` defaults to the system browser; tests inject a driver that
|
||||
follows the authorize redirect into the loopback callback. It always
|
||||
receives the authorize URL, so a CLI caller can also print it for
|
||||
browserless environments.
|
||||
"""
|
||||
# Bind first so the advertised redirect_uri carries the actual bound port
|
||||
# (which may differ from :8765 if it was taken).
|
||||
server, captured = _bind_loopback_server()
|
||||
redirect_uri = f"http://{LOOPBACK_HOST}:{server.server_address[1]}/callback"
|
||||
|
||||
endpoints = resolve_endpoints()
|
||||
path = config_path or resolve_config_path()
|
||||
authorize_url, state = begin_authorization(
|
||||
endpoints, redirect_uri, source=source, config_path=_display_config_path(path)
|
||||
)
|
||||
|
||||
if open_url is None:
|
||||
import webbrowser
|
||||
|
||||
open_url = webbrowser.open
|
||||
|
||||
# Browser opens from a short-lived thread; the socket is already bound, so a
|
||||
# fast redirect can't beat it.
|
||||
opener = threading.Thread(target=lambda: open_url(authorize_url), daemon=True)
|
||||
opener.start()
|
||||
|
||||
code, returned_state = capture_loopback_code(server, captured, timeout=timeout)
|
||||
if returned_state != state:
|
||||
raise ValueError("OAuth state mismatch — possible CSRF, aborting")
|
||||
return complete_authorization(
|
||||
endpoints,
|
||||
code,
|
||||
returned_state,
|
||||
config_path=path,
|
||||
host=host,
|
||||
apply_config=apply_config,
|
||||
)
|
||||
|
||||
|
||||
# — Device authorization grant (RFC 8628), for headless / remote-VM clients —
|
||||
# The loopback flow needs the browser on the same machine; here the CLI prints
|
||||
# a short user code, the user approves from any browser (dashboard /device),
|
||||
# and the device polls the token endpoint until the grant lands.
|
||||
|
||||
DEVICE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code"
|
||||
|
||||
# RFC 8628 §3.5: slow_down adds 5s per response; cap matches the server's
|
||||
# DEVICE_POLL_INTERVAL_MAX so a misbehaving clock can't inflate past it.
|
||||
_SLOW_DOWN_STEP = 5
|
||||
_POLL_INTERVAL_CAP = 60
|
||||
|
||||
# RFC 8414 authorization-server metadata; advertising the device grant is what
|
||||
# distinguishes a host that can do device login from one that can't.
|
||||
_AS_METADATA_PATH = "/.well-known/oauth-authorization-server"
|
||||
|
||||
|
||||
class DeviceFlowError(RuntimeError):
|
||||
"""A device-flow request failed. ``error`` is the RFC error code when known."""
|
||||
|
||||
def __init__(self, error: str, description: str | None = None):
|
||||
self.error = error
|
||||
self.description = description
|
||||
super().__init__(f"{error}: {description}" if description else error)
|
||||
|
||||
|
||||
class AccessDenied(DeviceFlowError):
|
||||
"""The user denied the authorization request."""
|
||||
|
||||
|
||||
class DeviceCodeExpired(DeviceFlowError):
|
||||
"""The device code expired before the user approved it."""
|
||||
|
||||
|
||||
class AuthorizationTimeout(DeviceFlowError):
|
||||
"""Polling ran past the device code's lifetime with no decision."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DeviceCode:
|
||||
"""RFC 8628 §3.2 device authorization response."""
|
||||
|
||||
device_code: str
|
||||
user_code: str
|
||||
verification_uri: str
|
||||
verification_uri_complete: str
|
||||
expires_in: int
|
||||
interval: int
|
||||
|
||||
|
||||
def supports_device_login(endpoints: OAuthEndpoints, *, timeout: float = 5.0) -> bool:
|
||||
"""Whether the host advertises the device grant in its RFC 8414 metadata.
|
||||
|
||||
Fails closed: any connection error, non-200, or missing capability returns
|
||||
False, so hosts without the device grant simply don't offer the option.
|
||||
"""
|
||||
origin = endpoints.token_url.rsplit("/oauth/", 1)[0]
|
||||
try:
|
||||
body = oauth._http_get_json(f"{origin}{_AS_METADATA_PATH}", timeout)
|
||||
except Exception:
|
||||
return False
|
||||
grants = body.get("grant_types_supported")
|
||||
return isinstance(grants, list) and DEVICE_GRANT_TYPE in grants
|
||||
|
||||
|
||||
def request_device_code(
|
||||
endpoints: OAuthEndpoints, *, source: str | None = None
|
||||
) -> DeviceCode:
|
||||
"""Request a device + user code pair (RFC 8628 §3.1)."""
|
||||
if not endpoints.device_authorization_url:
|
||||
raise ValueError("no device authorization endpoint resolved")
|
||||
data = {"client_id": endpoints.client_id, "scope": endpoints.scope}
|
||||
if source:
|
||||
data["source"] = source
|
||||
status, body = oauth._http_post_form_status(
|
||||
endpoints.device_authorization_url, data, oauth._REFRESH_TIMEOUT_SECONDS
|
||||
)
|
||||
if status != 200:
|
||||
error = str(body.get("error") or f"http_{status}")
|
||||
raise DeviceFlowError(error, body.get("error_description"))
|
||||
try:
|
||||
verification_uri = body["verification_uri"]
|
||||
return DeviceCode(
|
||||
device_code=body["device_code"],
|
||||
user_code=body["user_code"],
|
||||
verification_uri=verification_uri,
|
||||
verification_uri_complete=body.get(
|
||||
"verification_uri_complete",
|
||||
f"{verification_uri}?user_code={body['user_code']}",
|
||||
),
|
||||
expires_in=int(body["expires_in"]),
|
||||
# RFC 8628 §3.2: interval is optional; clients default to 5s.
|
||||
interval=int(body.get("interval", 5)),
|
||||
)
|
||||
except (KeyError, TypeError, ValueError) as e:
|
||||
raise DeviceFlowError(
|
||||
"invalid_response", f"malformed device authorization response: {e}"
|
||||
) from e
|
||||
|
||||
|
||||
def poll_for_token(
|
||||
endpoints: OAuthEndpoints,
|
||||
device: DeviceCode,
|
||||
*,
|
||||
on_poll: Callable[[], None] | None = None,
|
||||
sleep: Callable[[float], None] = time.sleep,
|
||||
monotonic: Callable[[], float] = time.monotonic,
|
||||
) -> dict[str, object]:
|
||||
"""Poll the token endpoint until the grant is approved (RFC 8628 §3.4/§3.5).
|
||||
|
||||
Sleeps ``interval`` before each poll, bumping it on ``slow_down``. Raises
|
||||
``AccessDenied`` / ``DeviceCodeExpired`` on the terminal server outcomes and
|
||||
``AuthorizationTimeout`` when ``expires_in`` elapses with no decision.
|
||||
``sleep`` / ``monotonic`` are injectable for tests.
|
||||
"""
|
||||
import httpx
|
||||
|
||||
interval = max(1, min(device.interval, _POLL_INTERVAL_CAP))
|
||||
deadline = monotonic() + max(1, device.expires_in)
|
||||
while True:
|
||||
if monotonic() + interval >= deadline:
|
||||
raise AuthorizationTimeout(
|
||||
"expired_token", "timed out waiting for approval"
|
||||
)
|
||||
sleep(interval)
|
||||
if on_poll:
|
||||
on_poll()
|
||||
try:
|
||||
status, body = oauth._http_post_form_status(
|
||||
endpoints.token_url,
|
||||
{
|
||||
"grant_type": DEVICE_GRANT_TYPE,
|
||||
"device_code": device.device_code,
|
||||
"client_id": endpoints.client_id,
|
||||
},
|
||||
oauth._REFRESH_TIMEOUT_SECONDS,
|
||||
)
|
||||
except httpx.TransportError as e:
|
||||
# A network blip mid-poll shouldn't kill a 10-minute wait.
|
||||
logger.debug("device token poll transport error, retrying: %s", e)
|
||||
continue
|
||||
|
||||
if status == 200:
|
||||
if not body.get("access_token"):
|
||||
raise DeviceFlowError("invalid_response", "token response missing access_token")
|
||||
return body
|
||||
error = str(body.get("error") or f"http_{status}")
|
||||
description = body.get("error_description")
|
||||
if error == "authorization_pending":
|
||||
continue
|
||||
if error == "slow_down":
|
||||
interval = min(interval + _SLOW_DOWN_STEP, _POLL_INTERVAL_CAP)
|
||||
continue
|
||||
if error == "access_denied":
|
||||
raise AccessDenied(error, description)
|
||||
if error == "expired_token":
|
||||
raise DeviceCodeExpired(error, description)
|
||||
raise DeviceFlowError(error, description)
|
||||
|
||||
|
||||
def authorize_via_device_code(
|
||||
*,
|
||||
config_path: Path | None = None,
|
||||
host: str | None = None,
|
||||
source: str | None = None,
|
||||
apply_config: bool = True,
|
||||
display: Callable[[DeviceCode], None] | None = None,
|
||||
open_url: Callable[[str], None] | None = None,
|
||||
on_poll: Callable[[], None] | None = None,
|
||||
sleep: Callable[[float], None] = time.sleep,
|
||||
) -> oauth.OAuthCredential:
|
||||
"""Drive the full device flow: request codes → show user code → poll → persist.
|
||||
|
||||
``display`` shows the user code + verification URL. ``open_url`` (if given)
|
||||
receives ``verification_uri_complete`` — there is no default browser open,
|
||||
since the approving browser may be on another machine.
|
||||
"""
|
||||
endpoints = resolve_endpoints()
|
||||
path = config_path or resolve_config_path()
|
||||
target_host = host or resolve_active_host()
|
||||
|
||||
device = request_device_code(endpoints, source=source)
|
||||
if display:
|
||||
display(device)
|
||||
if open_url:
|
||||
open_url(device.verification_uri_complete)
|
||||
|
||||
grant = poll_for_token(endpoints, device, on_poll=on_poll, sleep=sleep)
|
||||
cred = oauth.install_grant(
|
||||
path,
|
||||
target_host,
|
||||
grant,
|
||||
client_id=endpoints.client_id,
|
||||
token_endpoint=endpoints.token_url,
|
||||
apply_config=apply_config,
|
||||
)
|
||||
from plugins.memory.honcho.client import reset_honcho_client
|
||||
|
||||
reset_honcho_client()
|
||||
logger.info("Honcho OAuth device grant installed for host %s", target_host)
|
||||
return cred
|
||||
|
||||
|
||||
# — Background launcher + status, for the desktop "Connect" button —
|
||||
# The flow blocks on a browser round-trip, so the web_server endpoint kicks it
|
||||
# off in a thread and the UI polls status rather than holding the request open.
|
||||
|
||||
|
||||
@dataclass
|
||||
class FlowStatus:
|
||||
state: str = "idle" # idle | pending | connected | error
|
||||
detail: str = ""
|
||||
|
||||
|
||||
_status = FlowStatus()
|
||||
_status_lock = threading.Lock()
|
||||
_flow_thread: threading.Thread | None = None
|
||||
|
||||
|
||||
def _detect_connection() -> tuple[bool, str | None]:
|
||||
"""Report whether a credential is already stored: 'oauth', 'apikey', or none."""
|
||||
try:
|
||||
from plugins.memory.honcho.client import HonchoClientConfig
|
||||
|
||||
cfg = HonchoClientConfig.from_global_config()
|
||||
block = (cfg.raw.get("hosts") or {}).get(cfg.host) or {}
|
||||
if oauth.OAuthCredential.from_host_block(block) is not None:
|
||||
return True, "oauth"
|
||||
if cfg.api_key:
|
||||
return True, "apikey"
|
||||
except Exception:
|
||||
pass
|
||||
return False, None
|
||||
|
||||
|
||||
def get_flow_status() -> dict[str, object]:
|
||||
with _status_lock:
|
||||
state, detail = _status.state, _status.detail
|
||||
connected, auth = _detect_connection()
|
||||
return {"state": state, "detail": detail, "connected": connected, "auth": auth}
|
||||
|
||||
|
||||
def _set_status(state: str, detail: str = "") -> None:
|
||||
with _status_lock:
|
||||
_status.state, _status.detail = state, detail
|
||||
|
||||
|
||||
def start_loopback_flow_background(
|
||||
*,
|
||||
config_path: Path | None = None,
|
||||
host: str | None = None,
|
||||
source: str = "hermes-desktop",
|
||||
timeout: float = 300.0,
|
||||
) -> dict[str, str]:
|
||||
"""Launch the loopback flow in a daemon thread; returns the initial status.
|
||||
|
||||
Idempotent while a flow is pending — a second call is a no-op so a
|
||||
double-clicked button can't open two browser tabs / bind :8765 twice.
|
||||
"""
|
||||
global _flow_thread
|
||||
# Resolve under the caller's profile scope NOW — the worker thread outlives
|
||||
# the request, where a context-local HERMES_HOME override can't reach.
|
||||
config_path = config_path or resolve_config_path()
|
||||
host = host or resolve_active_host()
|
||||
with _status_lock:
|
||||
if _status.state == "pending" and _flow_thread and _flow_thread.is_alive():
|
||||
return {"state": _status.state, "detail": _status.detail}
|
||||
_status.state, _status.detail = "pending", "waiting for browser consent"
|
||||
|
||||
def _run() -> None:
|
||||
try:
|
||||
authorize_via_loopback(config_path=config_path, host=host, source=source, timeout=timeout)
|
||||
_set_status("connected", "Honcho connected")
|
||||
except Exception as exc:
|
||||
logger.warning("Honcho OAuth loopback flow failed: %s", exc)
|
||||
_set_status("error", str(exc))
|
||||
|
||||
_flow_thread = threading.Thread(target=_run, name="honcho-oauth-loopback", daemon=True)
|
||||
_flow_thread.start()
|
||||
return get_flow_status()
|
||||
@@ -0,0 +1,7 @@
|
||||
name: honcho
|
||||
version: 1.0.0
|
||||
description: "Honcho AI-native memory — cross-session user modeling with dialectic Q&A, semantic search, and persistent conclusions."
|
||||
pip_dependencies:
|
||||
- honcho-ai
|
||||
hooks:
|
||||
- on_session_end
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user