Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license

This commit is contained in:
2026-09-05 13:26:46 +03:00
commit 03634b1ca3
11340 changed files with 3442369 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
# Hermes plugins package
+14
View File
@@ -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())
+7
View File
@@ -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
+351
View File
@@ -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
+15
View File
@@ -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())
+7
View File
@@ -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
+300
View File
@@ -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",
}
+16
View File
@@ -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())
+7
View File
@@ -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
+174
View File
@@ -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",
}
+285
View File
@@ -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
+356
View File
@@ -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
+267
View File
@@ -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
+154
View File
@@ -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
+491
View File
@@ -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,
)
+7
View File
@@ -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
+291
View File
@@ -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,
)
+7
View File
@@ -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
+673
View File
@@ -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,
)
+7
View File
@@ -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
+51
View File
@@ -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
+316
View File
@@ -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.",
)
+611
View File
@@ -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
+7
View File
@@ -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
+131
View File
@@ -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.
+148
View File
@@ -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.
+103
View File
@@ -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)
+248
View File
@@ -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}")
+476
View File
@@ -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))
+862
View File
@@ -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())
+54
View File
@@ -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",
]
+125
View File
@@ -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}
+107
View File
@@ -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", {})
+124
View File
@@ -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, ""
+112
View File
@@ -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
+200
View File
@@ -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()
+16
View File
@@ -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
+339
View File
@@ -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,
}
+10
View File
@@ -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")]
)
+348
View File
@@ -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})
+21
View File
@@ -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.
+150
View File
@@ -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.
![Hermes Achievements dashboard](docs/assets/achievements-dashboard-hd.png)
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
File diff suppressed because one or more lines are too long
+146
View File
@@ -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()
+336
View File
@@ -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())
+7
View File
@@ -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
+218
View File
@@ -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())
+7
View File
@@ -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
+921
View File
@@ -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())
+7
View File
@@ -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
+297
View File
@@ -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())
+7
View File
@@ -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
+770
View File
@@ -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
+419
View File
@@ -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())
+7
View File
@@ -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
+7
View File
@@ -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
+625
View File
@@ -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())
+7
View File
@@ -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
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+14
View File
@@ -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
+778
View File
@@ -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
+41
View File
@@ -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 |
+449
View File
@@ -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())
+9
View File
@@ -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
+144
View File
@@ -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
+150
View File
@@ -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
+76
View File
@@ -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,
),
),
)
+8
View File
@@ -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
+153
View File
@@ -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
+36
View File
@@ -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) |
+462
View File
@@ -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)
+290
View File
@@ -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
+5
View File
@@ -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
+668
View File
@@ -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
+688
View File
@@ -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()
+414
View File
@@ -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` (13, 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 (13) | 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 (13, 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
+324
View File
@@ -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 (13).",
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",
),
),
)
+640
View File
@@ -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
+656
View File
@@ -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()
+7
View File
@@ -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